compiler.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  1. 'use strict';
  2. var nodes = require('./nodes');
  3. var filters = require('./filters');
  4. var doctypes = require('./doctypes');
  5. var runtime = require('./runtime');
  6. var utils = require('./utils');
  7. var selfClosing = require('void-elements');
  8. var parseJSExpression = require('character-parser').parseMax;
  9. var constantinople = require('constantinople');
  10. function isConstant(src) {
  11. return constantinople(src, {jade: runtime, 'jade_interp': undefined});
  12. }
  13. function toConstant(src) {
  14. return constantinople.toConstant(src, {jade: runtime, 'jade_interp': undefined});
  15. }
  16. function errorAtNode(node, error) {
  17. error.line = node.line;
  18. error.filename = node.filename;
  19. return error;
  20. }
  21. /**
  22. * Initialize `Compiler` with the given `node`.
  23. *
  24. * @param {Node} node
  25. * @param {Object} options
  26. * @api public
  27. */
  28. var Compiler = module.exports = function Compiler(node, options) {
  29. this.options = options = options || {};
  30. this.node = node;
  31. this.hasCompiledDoctype = false;
  32. this.hasCompiledTag = false;
  33. this.pp = options.pretty || false;
  34. if (this.pp && typeof this.pp !== 'string') {
  35. this.pp = ' ';
  36. }
  37. this.debug = false !== options.compileDebug;
  38. this.indents = 0;
  39. this.parentIndents = 0;
  40. this.terse = false;
  41. this.mixins = {};
  42. this.dynamicMixins = false;
  43. if (options.doctype) this.setDoctype(options.doctype);
  44. };
  45. /**
  46. * Compiler prototype.
  47. */
  48. Compiler.prototype = {
  49. /**
  50. * Compile parse tree to JavaScript.
  51. *
  52. * @api public
  53. */
  54. compile: function(){
  55. this.buf = [];
  56. if (this.pp) this.buf.push("var jade_indent = [];");
  57. this.lastBufferedIdx = -1;
  58. this.visit(this.node);
  59. if (!this.dynamicMixins) {
  60. // if there are no dynamic mixins we can remove any un-used mixins
  61. var mixinNames = Object.keys(this.mixins);
  62. for (var i = 0; i < mixinNames.length; i++) {
  63. var mixin = this.mixins[mixinNames[i]];
  64. if (!mixin.used) {
  65. for (var x = 0; x < mixin.instances.length; x++) {
  66. for (var y = mixin.instances[x].start; y < mixin.instances[x].end; y++) {
  67. this.buf[y] = '';
  68. }
  69. }
  70. }
  71. }
  72. }
  73. return this.buf.join('\n');
  74. },
  75. /**
  76. * Sets the default doctype `name`. Sets terse mode to `true` when
  77. * html 5 is used, causing self-closing tags to end with ">" vs "/>",
  78. * and boolean attributes are not mirrored.
  79. *
  80. * @param {string} name
  81. * @api public
  82. */
  83. setDoctype: function(name){
  84. this.doctype = doctypes[name.toLowerCase()] || '<!DOCTYPE ' + name + '>';
  85. this.terse = this.doctype.toLowerCase() == '<!doctype html>';
  86. this.xml = 0 == this.doctype.indexOf('<?xml');
  87. },
  88. /**
  89. * Buffer the given `str` exactly as is or with interpolation
  90. *
  91. * @param {String} str
  92. * @param {Boolean} interpolate
  93. * @api public
  94. */
  95. buffer: function (str, interpolate) {
  96. var self = this;
  97. if (interpolate) {
  98. var match = /(\\)?([#!]){((?:.|\n)*)$/.exec(str);
  99. if (match) {
  100. this.buffer(str.substr(0, match.index), false);
  101. if (match[1]) { // escape
  102. this.buffer(match[2] + '{', false);
  103. this.buffer(match[3], true);
  104. return;
  105. } else {
  106. var rest = match[3];
  107. var range = parseJSExpression(rest);
  108. var code = ('!' == match[2] ? '' : 'jade.escape') + "((jade_interp = " + range.src + ") == null ? '' : jade_interp)";
  109. this.bufferExpression(code);
  110. this.buffer(rest.substr(range.end + 1), true);
  111. return;
  112. }
  113. }
  114. }
  115. str = utils.stringify(str);
  116. str = str.substr(1, str.length - 2);
  117. if (this.lastBufferedIdx == this.buf.length) {
  118. if (this.lastBufferedType === 'code') this.lastBuffered += ' + "';
  119. this.lastBufferedType = 'text';
  120. this.lastBuffered += str;
  121. this.buf[this.lastBufferedIdx - 1] = 'buf.push(' + this.bufferStartChar + this.lastBuffered + '");'
  122. } else {
  123. this.buf.push('buf.push("' + str + '");');
  124. this.lastBufferedType = 'text';
  125. this.bufferStartChar = '"';
  126. this.lastBuffered = str;
  127. this.lastBufferedIdx = this.buf.length;
  128. }
  129. },
  130. /**
  131. * Buffer the given `src` so it is evaluated at run time
  132. *
  133. * @param {String} src
  134. * @api public
  135. */
  136. bufferExpression: function (src) {
  137. if (isConstant(src)) {
  138. return this.buffer(toConstant(src) + '', false)
  139. }
  140. if (this.lastBufferedIdx == this.buf.length) {
  141. if (this.lastBufferedType === 'text') this.lastBuffered += '"';
  142. this.lastBufferedType = 'code';
  143. this.lastBuffered += ' + (' + src + ')';
  144. this.buf[this.lastBufferedIdx - 1] = 'buf.push(' + this.bufferStartChar + this.lastBuffered + ');'
  145. } else {
  146. this.buf.push('buf.push(' + src + ');');
  147. this.lastBufferedType = 'code';
  148. this.bufferStartChar = '';
  149. this.lastBuffered = '(' + src + ')';
  150. this.lastBufferedIdx = this.buf.length;
  151. }
  152. },
  153. /**
  154. * Buffer an indent based on the current `indent`
  155. * property and an additional `offset`.
  156. *
  157. * @param {Number} offset
  158. * @param {Boolean} newline
  159. * @api public
  160. */
  161. prettyIndent: function(offset, newline){
  162. offset = offset || 0;
  163. newline = newline ? '\n' : '';
  164. this.buffer(newline + Array(this.indents + offset).join(this.pp));
  165. if (this.parentIndents)
  166. this.buf.push("buf.push.apply(buf, jade_indent);");
  167. },
  168. /**
  169. * Visit `node`.
  170. *
  171. * @param {Node} node
  172. * @api public
  173. */
  174. visit: function(node){
  175. var debug = this.debug;
  176. if (debug) {
  177. this.buf.push('jade_debug.unshift(new jade.DebugItem( ' + node.line
  178. + ', ' + (node.filename
  179. ? utils.stringify(node.filename)
  180. : 'jade_debug[0].filename')
  181. + ' ));');
  182. }
  183. // Massive hack to fix our context
  184. // stack for - else[ if] etc
  185. if (false === node.debug && this.debug) {
  186. this.buf.pop();
  187. this.buf.pop();
  188. }
  189. this.visitNode(node);
  190. if (debug) this.buf.push('jade_debug.shift();');
  191. },
  192. /**
  193. * Visit `node`.
  194. *
  195. * @param {Node} node
  196. * @api public
  197. */
  198. visitNode: function(node){
  199. return this['visit' + node.type](node);
  200. },
  201. /**
  202. * Visit case `node`.
  203. *
  204. * @param {Literal} node
  205. * @api public
  206. */
  207. visitCase: function(node){
  208. var _ = this.withinCase;
  209. this.withinCase = true;
  210. this.buf.push('switch (' + node.expr + '){');
  211. this.visit(node.block);
  212. this.buf.push('}');
  213. this.withinCase = _;
  214. },
  215. /**
  216. * Visit when `node`.
  217. *
  218. * @param {Literal} node
  219. * @api public
  220. */
  221. visitWhen: function(node){
  222. if ('default' == node.expr) {
  223. this.buf.push('default:');
  224. } else {
  225. this.buf.push('case ' + node.expr + ':');
  226. }
  227. if (node.block) {
  228. this.visit(node.block);
  229. this.buf.push(' break;');
  230. }
  231. },
  232. /**
  233. * Visit literal `node`.
  234. *
  235. * @param {Literal} node
  236. * @api public
  237. */
  238. visitLiteral: function(node){
  239. this.buffer(node.str);
  240. },
  241. /**
  242. * Visit all nodes in `block`.
  243. *
  244. * @param {Block} block
  245. * @api public
  246. */
  247. visitBlock: function(block){
  248. var len = block.nodes.length
  249. , escape = this.escape
  250. , pp = this.pp
  251. // Pretty print multi-line text
  252. if (pp && len > 1 && !escape && block.nodes[0].isText && block.nodes[1].isText)
  253. this.prettyIndent(1, true);
  254. for (var i = 0; i < len; ++i) {
  255. // Pretty print text
  256. if (pp && i > 0 && !escape && block.nodes[i].isText && block.nodes[i-1].isText)
  257. this.prettyIndent(1, false);
  258. this.visit(block.nodes[i]);
  259. // Multiple text nodes are separated by newlines
  260. if (block.nodes[i+1] && block.nodes[i].isText && block.nodes[i+1].isText)
  261. this.buffer('\n');
  262. }
  263. },
  264. /**
  265. * Visit a mixin's `block` keyword.
  266. *
  267. * @param {MixinBlock} block
  268. * @api public
  269. */
  270. visitMixinBlock: function(block){
  271. if (this.pp) this.buf.push("jade_indent.push('" + Array(this.indents + 1).join(this.pp) + "');");
  272. this.buf.push('block && block();');
  273. if (this.pp) this.buf.push("jade_indent.pop();");
  274. },
  275. /**
  276. * Visit `doctype`. Sets terse mode to `true` when html 5
  277. * is used, causing self-closing tags to end with ">" vs "/>",
  278. * and boolean attributes are not mirrored.
  279. *
  280. * @param {Doctype} doctype
  281. * @api public
  282. */
  283. visitDoctype: function(doctype){
  284. if (doctype && (doctype.val || !this.doctype)) {
  285. this.setDoctype(doctype.val || 'default');
  286. }
  287. if (this.doctype) this.buffer(this.doctype);
  288. this.hasCompiledDoctype = true;
  289. },
  290. /**
  291. * Visit `mixin`, generating a function that
  292. * may be called within the template.
  293. *
  294. * @param {Mixin} mixin
  295. * @api public
  296. */
  297. visitMixin: function(mixin){
  298. var name = 'jade_mixins[';
  299. var args = mixin.args || '';
  300. var block = mixin.block;
  301. var attrs = mixin.attrs;
  302. var attrsBlocks = mixin.attributeBlocks.slice();
  303. var pp = this.pp;
  304. var dynamic = mixin.name[0]==='#';
  305. var key = mixin.name;
  306. if (dynamic) this.dynamicMixins = true;
  307. name += (dynamic ? mixin.name.substr(2,mixin.name.length-3):'"'+mixin.name+'"')+']';
  308. this.mixins[key] = this.mixins[key] || {used: false, instances: []};
  309. if (mixin.call) {
  310. this.mixins[key].used = true;
  311. if (pp) this.buf.push("jade_indent.push('" + Array(this.indents + 1).join(pp) + "');")
  312. if (block || attrs.length || attrsBlocks.length) {
  313. this.buf.push(name + '.call({');
  314. if (block) {
  315. this.buf.push('block: function(){');
  316. // Render block with no indents, dynamically added when rendered
  317. this.parentIndents++;
  318. var _indents = this.indents;
  319. this.indents = 0;
  320. this.visit(mixin.block);
  321. this.indents = _indents;
  322. this.parentIndents--;
  323. if (attrs.length || attrsBlocks.length) {
  324. this.buf.push('},');
  325. } else {
  326. this.buf.push('}');
  327. }
  328. }
  329. if (attrsBlocks.length) {
  330. if (attrs.length) {
  331. var val = this.attrs(attrs);
  332. attrsBlocks.unshift(val);
  333. }
  334. this.buf.push('attributes: jade.merge([' + attrsBlocks.join(',') + '])');
  335. } else if (attrs.length) {
  336. var val = this.attrs(attrs);
  337. this.buf.push('attributes: ' + val);
  338. }
  339. if (args) {
  340. this.buf.push('}, ' + args + ');');
  341. } else {
  342. this.buf.push('});');
  343. }
  344. } else {
  345. this.buf.push(name + '(' + args + ');');
  346. }
  347. if (pp) this.buf.push("jade_indent.pop();")
  348. } else {
  349. var mixin_start = this.buf.length;
  350. args = args ? args.split(',') : [];
  351. var rest;
  352. if (args.length && /^\.\.\./.test(args[args.length - 1].trim())) {
  353. rest = args.pop().trim().replace(/^\.\.\./, '');
  354. }
  355. // we need use jade_interp here for v8: https://code.google.com/p/v8/issues/detail?id=4165
  356. // once fixed, use this: this.buf.push(name + ' = function(' + args.join(',') + '){');
  357. this.buf.push(name + ' = jade_interp = function(' + args.join(',') + '){');
  358. this.buf.push('var block = (this && this.block), attributes = (this && this.attributes) || {};');
  359. if (rest) {
  360. this.buf.push('var ' + rest + ' = [];');
  361. this.buf.push('for (jade_interp = ' + args.length + '; jade_interp < arguments.length; jade_interp++) {');
  362. this.buf.push(' ' + rest + '.push(arguments[jade_interp]);');
  363. this.buf.push('}');
  364. }
  365. this.parentIndents++;
  366. this.visit(block);
  367. this.parentIndents--;
  368. this.buf.push('};');
  369. var mixin_end = this.buf.length;
  370. this.mixins[key].instances.push({start: mixin_start, end: mixin_end});
  371. }
  372. },
  373. /**
  374. * Visit `tag` buffering tag markup, generating
  375. * attributes, visiting the `tag`'s code and block.
  376. *
  377. * @param {Tag} tag
  378. * @api public
  379. */
  380. visitTag: function(tag){
  381. this.indents++;
  382. var name = tag.name
  383. , pp = this.pp
  384. , self = this;
  385. function bufferName() {
  386. if (tag.buffer) self.bufferExpression(name);
  387. else self.buffer(name);
  388. }
  389. if ('pre' == tag.name) this.escape = true;
  390. if (!this.hasCompiledTag) {
  391. if (!this.hasCompiledDoctype && 'html' == name) {
  392. this.visitDoctype();
  393. }
  394. this.hasCompiledTag = true;
  395. }
  396. // pretty print
  397. if (pp && !tag.isInline())
  398. this.prettyIndent(0, true);
  399. if (tag.selfClosing || (!this.xml && selfClosing[tag.name])) {
  400. this.buffer('<');
  401. bufferName();
  402. this.visitAttributes(tag.attrs, tag.attributeBlocks.slice());
  403. this.terse
  404. ? this.buffer('>')
  405. : this.buffer('/>');
  406. // if it is non-empty throw an error
  407. if (tag.block &&
  408. !(tag.block.type === 'Block' && tag.block.nodes.length === 0) &&
  409. tag.block.nodes.some(function (tag) {
  410. return tag.type !== 'Text' || !/^\s*$/.test(tag.val)
  411. })) {
  412. throw errorAtNode(tag, new Error(name + ' is self closing and should not have content.'));
  413. }
  414. } else {
  415. // Optimize attributes buffering
  416. this.buffer('<');
  417. bufferName();
  418. this.visitAttributes(tag.attrs, tag.attributeBlocks.slice());
  419. this.buffer('>');
  420. if (tag.code) this.visitCode(tag.code);
  421. this.visit(tag.block);
  422. // pretty print
  423. if (pp && !tag.isInline() && 'pre' != tag.name && !tag.canInline())
  424. this.prettyIndent(0, true);
  425. this.buffer('</');
  426. bufferName();
  427. this.buffer('>');
  428. }
  429. if ('pre' == tag.name) this.escape = false;
  430. this.indents--;
  431. },
  432. /**
  433. * Visit `filter`, throwing when the filter does not exist.
  434. *
  435. * @param {Filter} filter
  436. * @api public
  437. */
  438. visitFilter: function(filter){
  439. var text = filter.block.nodes.map(
  440. function(node){ return node.val; }
  441. ).join('\n');
  442. filter.attrs.filename = this.options.filename;
  443. try {
  444. this.buffer(filters(filter.name, text, filter.attrs), true);
  445. } catch (err) {
  446. throw errorAtNode(filter, err);
  447. }
  448. },
  449. /**
  450. * Visit `text` node.
  451. *
  452. * @param {Text} text
  453. * @api public
  454. */
  455. visitText: function(text){
  456. this.buffer(text.val, true);
  457. },
  458. /**
  459. * Visit a `comment`, only buffering when the buffer flag is set.
  460. *
  461. * @param {Comment} comment
  462. * @api public
  463. */
  464. visitComment: function(comment){
  465. if (!comment.buffer) return;
  466. if (this.pp) this.prettyIndent(1, true);
  467. this.buffer('<!--' + comment.val + '-->');
  468. },
  469. /**
  470. * Visit a `BlockComment`.
  471. *
  472. * @param {Comment} comment
  473. * @api public
  474. */
  475. visitBlockComment: function(comment){
  476. if (!comment.buffer) return;
  477. if (this.pp) this.prettyIndent(1, true);
  478. this.buffer('<!--' + comment.val);
  479. this.visit(comment.block);
  480. if (this.pp) this.prettyIndent(1, true);
  481. this.buffer('-->');
  482. },
  483. /**
  484. * Visit `code`, respecting buffer / escape flags.
  485. * If the code is followed by a block, wrap it in
  486. * a self-calling function.
  487. *
  488. * @param {Code} code
  489. * @api public
  490. */
  491. visitCode: function(code){
  492. // Wrap code blocks with {}.
  493. // we only wrap unbuffered code blocks ATM
  494. // since they are usually flow control
  495. // Buffer code
  496. if (code.buffer) {
  497. var val = code.val.trim();
  498. val = 'null == (jade_interp = '+val+') ? "" : jade_interp';
  499. if (code.escape) val = 'jade.escape(' + val + ')';
  500. this.bufferExpression(val);
  501. } else {
  502. this.buf.push(code.val);
  503. }
  504. // Block support
  505. if (code.block) {
  506. if (!code.buffer) this.buf.push('{');
  507. this.visit(code.block);
  508. if (!code.buffer) this.buf.push('}');
  509. }
  510. },
  511. /**
  512. * Visit `each` block.
  513. *
  514. * @param {Each} each
  515. * @api public
  516. */
  517. visitEach: function(each){
  518. this.buf.push(''
  519. + '// iterate ' + each.obj + '\n'
  520. + ';(function(){\n'
  521. + ' var $$obj = ' + each.obj + ';\n'
  522. + ' if (\'number\' == typeof $$obj.length) {\n');
  523. if (each.alternative) {
  524. this.buf.push(' if ($$obj.length) {');
  525. }
  526. this.buf.push(''
  527. + ' for (var ' + each.key + ' = 0, $$l = $$obj.length; ' + each.key + ' < $$l; ' + each.key + '++) {\n'
  528. + ' var ' + each.val + ' = $$obj[' + each.key + '];\n');
  529. this.visit(each.block);
  530. this.buf.push(' }\n');
  531. if (each.alternative) {
  532. this.buf.push(' } else {');
  533. this.visit(each.alternative);
  534. this.buf.push(' }');
  535. }
  536. this.buf.push(''
  537. + ' } else {\n'
  538. + ' var $$l = 0;\n'
  539. + ' for (var ' + each.key + ' in $$obj) {\n'
  540. + ' $$l++;'
  541. + ' var ' + each.val + ' = $$obj[' + each.key + '];\n');
  542. this.visit(each.block);
  543. this.buf.push(' }\n');
  544. if (each.alternative) {
  545. this.buf.push(' if ($$l === 0) {');
  546. this.visit(each.alternative);
  547. this.buf.push(' }');
  548. }
  549. this.buf.push(' }\n}).call(this);\n');
  550. },
  551. /**
  552. * Visit `attrs`.
  553. *
  554. * @param {Array} attrs
  555. * @api public
  556. */
  557. visitAttributes: function(attrs, attributeBlocks){
  558. if (attributeBlocks.length) {
  559. if (attrs.length) {
  560. var val = this.attrs(attrs);
  561. attributeBlocks.unshift(val);
  562. }
  563. this.bufferExpression('jade.attrs(jade.merge([' + attributeBlocks.join(',') + ']), ' + utils.stringify(this.terse) + ')');
  564. } else if (attrs.length) {
  565. this.attrs(attrs, true);
  566. }
  567. },
  568. /**
  569. * Compile attributes.
  570. */
  571. attrs: function(attrs, buffer){
  572. var buf = [];
  573. var classes = [];
  574. var classEscaping = [];
  575. attrs.forEach(function(attr){
  576. var key = attr.name;
  577. var escaped = attr.escaped;
  578. if (key === 'class') {
  579. classes.push(attr.val);
  580. classEscaping.push(attr.escaped);
  581. } else if (isConstant(attr.val)) {
  582. if (buffer) {
  583. this.buffer(runtime.attr(key, toConstant(attr.val), escaped, this.terse));
  584. } else {
  585. var val = toConstant(attr.val);
  586. if (key === 'style') val = runtime.style(val);
  587. if (escaped && !(key.indexOf('data') === 0 && typeof val !== 'string')) {
  588. val = runtime.escape(val);
  589. }
  590. buf.push(utils.stringify(key) + ': ' + utils.stringify(val));
  591. }
  592. } else {
  593. if (buffer) {
  594. this.bufferExpression('jade.attr("' + key + '", ' + attr.val + ', ' + utils.stringify(escaped) + ', ' + utils.stringify(this.terse) + ')');
  595. } else {
  596. var val = attr.val;
  597. if (key === 'style') {
  598. val = 'jade.style(' + val + ')';
  599. }
  600. if (escaped && !(key.indexOf('data') === 0)) {
  601. val = 'jade.escape(' + val + ')';
  602. } else if (escaped) {
  603. val = '(typeof (jade_interp = ' + val + ') == "string" ? jade.escape(jade_interp) : jade_interp)';
  604. }
  605. buf.push(utils.stringify(key) + ': ' + val);
  606. }
  607. }
  608. }.bind(this));
  609. if (buffer) {
  610. if (classes.every(isConstant)) {
  611. this.buffer(runtime.cls(classes.map(toConstant), classEscaping));
  612. } else {
  613. this.bufferExpression('jade.cls([' + classes.join(',') + '], ' + utils.stringify(classEscaping) + ')');
  614. }
  615. } else if (classes.length) {
  616. if (classes.every(isConstant)) {
  617. classes = utils.stringify(runtime.joinClasses(classes.map(toConstant).map(runtime.joinClasses).map(function (cls, i) {
  618. return classEscaping[i] ? runtime.escape(cls) : cls;
  619. })));
  620. } else {
  621. classes = '(jade_interp = ' + utils.stringify(classEscaping) + ',' +
  622. ' jade.joinClasses([' + classes.join(',') + '].map(jade.joinClasses).map(function (cls, i) {' +
  623. ' return jade_interp[i] ? jade.escape(cls) : cls' +
  624. ' }))' +
  625. ')';
  626. }
  627. if (classes.length)
  628. buf.push('"class": ' + classes);
  629. }
  630. return '{' + buf.join(',') + '}';
  631. }
  632. };