aboutsummaryrefslogtreecommitdiff
path: root/js/lib/blocks.js
blob: b1f69ebd12131581199e316452a8be17263748fa (plain)
  1. var Node = require('./node');
  2. var C_GREATERTHAN = 62;
  3. var C_NEWLINE = 10;
  4. var C_SPACE = 32;
  5. var C_OPEN_BRACKET = 91;
  6. var InlineParser = require('./inlines');
  7. var unescapeString = new InlineParser().unescapeString;
  8. var BLOCKTAGNAME = '(?:article|header|aside|hgroup|iframe|blockquote|hr|body|li|map|button|object|canvas|ol|caption|output|col|p|colgroup|pre|dd|progress|div|section|dl|table|td|dt|tbody|embed|textarea|fieldset|tfoot|figcaption|th|figure|thead|footer|footer|tr|form|ul|h1|h2|h3|h4|h5|h6|video|script|style)';
  9. var HTMLBLOCKOPEN = "<(?:" + BLOCKTAGNAME + "[\\s/>]" + "|" +
  10. "/" + BLOCKTAGNAME + "[\\s>]" + "|" + "[?!])";
  11. var reHtmlBlockOpen = new RegExp('^' + HTMLBLOCKOPEN, 'i');
  12. var reHrule = /^(?:(?:\* *){3,}|(?:_ *){3,}|(?:- *){3,}) *$/;
  13. var reMaybeSpecial = /^[ #`~*+_=<>0-9-]/;
  14. var reNonSpace = /[^ \t\n]/;
  15. var reBulletListMarker = /^[*+-]( +|$)/;
  16. var reOrderedListMarker = /^(\d+)([.)])( +|$)/;
  17. var reATXHeaderMarker = /^#{1,6}(?: +|$)/;
  18. var reCodeFence = /^`{3,}(?!.*`)|^~{3,}(?!.*~)/;
  19. var reClosingCodeFence = /^(?:`{3,}|~{3,})(?= *$)/;
  20. var reSetextHeaderLine = /^(?:=+|-+) *$/;
  21. var reLineEnding = /\r\n|\n|\r/;
  22. // Returns true if string contains only space characters.
  23. var isBlank = function(s) {
  24. "use strict";
  25. return !(reNonSpace.test(s));
  26. };
  27. // Convert tabs to spaces on each line using a 4-space tab stop.
  28. var detabLine = function(text) {
  29. "use strict";
  30. if (text.indexOf('\u0000') !== -1) {
  31. // replace NUL for security
  32. text = text.replace(/\0/g, '\uFFFD');
  33. }
  34. if (text.indexOf('\t') === -1) {
  35. return text;
  36. } else {
  37. var lastStop = 0;
  38. return text.replace(/\t/g, function(match, offset) {
  39. var result = ' '.slice((offset - lastStop) % 4);
  40. lastStop = offset + 1;
  41. return result;
  42. });
  43. }
  44. };
  45. // Attempt to match a regex in string s at offset offset.
  46. // Return index of match or -1.
  47. var matchAt = function(re, s, offset) {
  48. "use strict";
  49. var res = s.slice(offset).match(re);
  50. if (res) {
  51. return offset + res.index;
  52. } else {
  53. return -1;
  54. }
  55. };
  56. // destructively trip final blank lines in an array of strings
  57. var stripFinalBlankLines = function(lns) {
  58. "use strict";
  59. var i = lns.length - 1;
  60. while (!reNonSpace.test(lns[i])) {
  61. lns.pop();
  62. i--;
  63. }
  64. };
  65. // DOC PARSER
  66. // These are methods of a DocParser object, defined below.
  67. // Returns true if parent block can contain child block.
  68. var canContain = function(parent_type, child_type) {
  69. "use strict";
  70. return ( parent_type === 'Document' ||
  71. parent_type === 'BlockQuote' ||
  72. parent_type === 'ListItem' ||
  73. (parent_type === 'List' && child_type === 'ListItem') );
  74. };
  75. // Returns true if block type can accept lines of text.
  76. var acceptsLines = function(block_type) {
  77. "use strict";
  78. return ( block_type === 'Paragraph' ||
  79. block_type === 'IndentedCode' ||
  80. block_type === 'FencedCode' );
  81. };
  82. // Returns true if block ends with a blank line, descending if needed
  83. // into lists and sublists.
  84. var endsWithBlankLine = function(block) {
  85. "use strict";
  86. while (block) {
  87. if (block.last_line_blank) {
  88. return true;
  89. }
  90. if (block.t === 'List' || block.t === 'ListItem') {
  91. block = block.lastChild;
  92. } else {
  93. break;
  94. }
  95. }
  96. return false;
  97. };
  98. // Break out of all containing lists, resetting the tip of the
  99. // document to the parent of the highest list, and finalizing
  100. // all the lists. (This is used to implement the "two blank lines
  101. // break of of all lists" feature.)
  102. var breakOutOfLists = function(block, line_number) {
  103. "use strict";
  104. var b = block;
  105. var last_list = null;
  106. do {
  107. if (b.t === 'List') {
  108. last_list = b;
  109. }
  110. b = b.parent;
  111. } while (b);
  112. if (last_list) {
  113. while (block !== last_list) {
  114. this.finalize(block, line_number);
  115. block = block.parent;
  116. }
  117. this.finalize(last_list, line_number);
  118. this.tip = last_list.parent;
  119. }
  120. };
  121. // Add a line to the block at the tip. We assume the tip
  122. // can accept lines -- that check should be done before calling this.
  123. var addLine = function(ln, offset) {
  124. "use strict";
  125. var s = ln.slice(offset);
  126. if (!(this.tip.open)) {
  127. throw { msg: "Attempted to add line (" + ln + ") to closed container." };
  128. }
  129. this.tip.strings.push(s);
  130. };
  131. // Add block of type tag as a child of the tip. If the tip can't
  132. // accept children, close and finalize it and try its parent,
  133. // and so on til we find a block that can accept children.
  134. var addChild = function(tag, line_number, offset) {
  135. "use strict";
  136. while (!canContain(this.tip.t, tag)) {
  137. this.finalize(this.tip, line_number - 1);
  138. }
  139. var column_number = offset + 1; // offset 0 = column 1
  140. var newBlock = new Node(tag, [[line_number, column_number], [0, 0]]);
  141. newBlock.strings = [];
  142. newBlock.string_content = undefined;
  143. this.tip.appendChild(newBlock);
  144. this.tip = newBlock;
  145. return newBlock;
  146. };
  147. // Parse a list marker and return data on the marker (type,
  148. // start, delimiter, bullet character, padding) or null.
  149. var parseListMarker = function(ln, offset) {
  150. "use strict";
  151. var rest = ln.slice(offset);
  152. var match;
  153. var spaces_after_marker;
  154. var data = { type: undefined,
  155. tight: true,
  156. bullet_char: undefined,
  157. start: undefined,
  158. delimiter: undefined,
  159. padding: undefined };
  160. if (rest.match(reHrule)) {
  161. return null;
  162. }
  163. if ((match = rest.match(reBulletListMarker))) {
  164. spaces_after_marker = match[1].length;
  165. data.type = 'Bullet';
  166. data.bullet_char = match[0][0];
  167. } else if ((match = rest.match(reOrderedListMarker))) {
  168. spaces_after_marker = match[3].length;
  169. data.type = 'Ordered';
  170. data.start = parseInt(match[1]);
  171. data.delimiter = match[2];
  172. } else {
  173. return null;
  174. }
  175. var blank_item = match[0].length === rest.length;
  176. if (spaces_after_marker >= 5 ||
  177. spaces_after_marker < 1 ||
  178. blank_item) {
  179. data.padding = match[0].length - spaces_after_marker + 1;
  180. } else {
  181. data.padding = match[0].length;
  182. }
  183. return data;
  184. };
  185. // Returns true if the two list items are of the same type,
  186. // with the same delimiter and bullet character. This is used
  187. // in agglomerating list items into lists.
  188. var listsMatch = function(list_data, item_data) {
  189. "use strict";
  190. return (list_data.type === item_data.type &&
  191. list_data.delimiter === item_data.delimiter &&
  192. list_data.bullet_char === item_data.bullet_char);
  193. };
  194. // Analyze a line of text and update the document appropriately.
  195. // We parse markdown text by calling this on each line of input,
  196. // then finalizing the document.
  197. var incorporateLine = function(ln, line_number) {
  198. "use strict";
  199. var all_matched = true;
  200. var first_nonspace;
  201. var offset = 0;
  202. var match;
  203. var data;
  204. var blank;
  205. var indent;
  206. var last_matched_container;
  207. var i;
  208. var CODE_INDENT = 4;
  209. var container = this.doc;
  210. var oldtip = this.tip;
  211. // Convert tabs to spaces:
  212. ln = detabLine(ln);
  213. // For each containing block, try to parse the associated line start.
  214. // Bail out on failure: container will point to the last matching block.
  215. // Set all_matched to false if not all containers match.
  216. while (container.lastChild) {
  217. if (!container.lastChild.open) {
  218. break;
  219. }
  220. container = container.lastChild;
  221. match = matchAt(reNonSpace, ln, offset);
  222. if (match === -1) {
  223. first_nonspace = ln.length;
  224. blank = true;
  225. } else {
  226. first_nonspace = match;
  227. blank = false;
  228. }
  229. indent = first_nonspace - offset;
  230. switch (container.t) {
  231. case 'BlockQuote':
  232. if (indent <= 3 && ln.charCodeAt(first_nonspace) === C_GREATERTHAN) {
  233. offset = first_nonspace + 1;
  234. if (ln.charCodeAt(offset) === C_SPACE) {
  235. offset++;
  236. }
  237. } else {
  238. all_matched = false;
  239. }
  240. break;
  241. case 'ListItem':
  242. if (indent >= container.list_data.marker_offset +
  243. container.list_data.padding) {
  244. offset += container.list_data.marker_offset +
  245. container.list_data.padding;
  246. } else if (blank) {
  247. offset = first_nonspace;
  248. } else {
  249. all_matched = false;
  250. }
  251. break;
  252. case 'IndentedCode':
  253. if (indent >= CODE_INDENT) {
  254. offset += CODE_INDENT;
  255. } else if (blank) {
  256. offset = first_nonspace;
  257. } else {
  258. all_matched = false;
  259. }
  260. break;
  261. case 'Header':
  262. case 'HorizontalRule':
  263. // a header can never container > 1 line, so fail to match:
  264. all_matched = false;
  265. if (blank) {
  266. container.last_line_blank = true;
  267. }
  268. break;
  269. case 'FencedCode':
  270. // skip optional spaces of fence offset
  271. i = container.fence_offset;
  272. while (i > 0 && ln.charCodeAt(offset) === C_SPACE) {
  273. offset++;
  274. i--;
  275. }
  276. break;
  277. case 'HtmlBlock':
  278. if (blank) {
  279. container.last_line_blank = true;
  280. all_matched = false;
  281. }
  282. break;
  283. case 'Paragraph':
  284. if (blank) {
  285. container.last_line_blank = true;
  286. all_matched = false;
  287. }
  288. break;
  289. default:
  290. }
  291. if (!all_matched) {
  292. container = container.parent; // back up to last matching block
  293. break;
  294. }
  295. }
  296. last_matched_container = container;
  297. // This function is used to finalize and close any unmatched
  298. // blocks. We aren't ready to do this now, because we might
  299. // have a lazy paragraph continuation, in which case we don't
  300. // want to close unmatched blocks. So we store this closure for
  301. // use later, when we have more information.
  302. var closeUnmatchedBlocks = function(mythis) {
  303. // finalize any blocks not matched
  304. while (oldtip !== last_matched_container) {
  305. mythis.finalize(oldtip, line_number - 1);
  306. oldtip = oldtip.parent;
  307. }
  308. };
  309. // Check to see if we've hit 2nd blank line; if so break out of list:
  310. if (blank && container.last_line_blank) {
  311. this.breakOutOfLists(container, line_number);
  312. }
  313. // Unless last matched container is a code block, try new container starts,
  314. // adding children to the last matched container:
  315. while (container.t !== 'FencedCode' &&
  316. container.t !== 'IndentedCode' &&
  317. container.t !== 'HtmlBlock' &&
  318. // this is a little performance optimization:
  319. matchAt(reMaybeSpecial, ln, offset) !== -1) {
  320. match = matchAt(reNonSpace, ln, offset);
  321. if (match === -1) {
  322. first_nonspace = ln.length;
  323. blank = true;
  324. } else {
  325. first_nonspace = match;
  326. blank = false;
  327. }
  328. indent = first_nonspace - offset;
  329. if (indent >= CODE_INDENT) {
  330. // indented code
  331. if (this.tip.t !== 'Paragraph' && !blank) {
  332. offset += CODE_INDENT;
  333. closeUnmatchedBlocks(this);
  334. container = this.addChild('IndentedCode', line_number, offset);
  335. } else { // indent > 4 in a lazy paragraph continuation
  336. break;
  337. }
  338. } else if (ln.charCodeAt(first_nonspace) === C_GREATERTHAN) {
  339. // blockquote
  340. offset = first_nonspace + 1;
  341. // optional following space
  342. if (ln.charCodeAt(offset) === C_SPACE) {
  343. offset++;
  344. }
  345. closeUnmatchedBlocks(this);
  346. container = this.addChild('BlockQuote', line_number, offset);
  347. } else if ((match = ln.slice(first_nonspace).match(reATXHeaderMarker))) {
  348. // ATX header
  349. offset = first_nonspace + match[0].length;
  350. closeUnmatchedBlocks(this);
  351. container = this.addChild('Header', line_number, first_nonspace);
  352. container.level = match[0].trim().length; // number of #s
  353. // remove trailing ###s:
  354. container.strings =
  355. [ln.slice(offset).replace(/^ *#+ *$/, '').replace(/ +#+ *$/, '')];
  356. break;
  357. } else if ((match = ln.slice(first_nonspace).match(reCodeFence))) {
  358. // fenced code block
  359. var fence_length = match[0].length;
  360. closeUnmatchedBlocks(this);
  361. container = this.addChild('FencedCode', line_number, first_nonspace);
  362. container.fence_length = fence_length;
  363. container.fence_char = match[0][0];
  364. container.fence_offset = first_nonspace - offset;
  365. offset = first_nonspace + fence_length;
  366. break;
  367. } else if (matchAt(reHtmlBlockOpen, ln, first_nonspace) !== -1) {
  368. // html block
  369. closeUnmatchedBlocks(this);
  370. container = this.addChild('HtmlBlock', line_number, first_nonspace);
  371. // note, we don't adjust offset because the tag is part of the text
  372. break;
  373. } else if (container.t === 'Paragraph' &&
  374. container.strings.length === 1 &&
  375. ((match = ln.slice(first_nonspace).match(reSetextHeaderLine)))) {
  376. // setext header line
  377. closeUnmatchedBlocks(this);
  378. container.t = 'Header'; // convert Paragraph to SetextHeader
  379. container.level = match[0][0] === '=' ? 1 : 2;
  380. offset = ln.length;
  381. } else if (matchAt(reHrule, ln, first_nonspace) !== -1) {
  382. // hrule
  383. closeUnmatchedBlocks(this);
  384. container = this.addChild('HorizontalRule', line_number, first_nonspace);
  385. offset = ln.length - 1;
  386. break;
  387. } else if ((data = parseListMarker(ln, first_nonspace))) {
  388. // list item
  389. closeUnmatchedBlocks(this);
  390. data.marker_offset = indent;
  391. offset = first_nonspace + data.padding;
  392. // add the list if needed
  393. if (container.t !== 'List' ||
  394. !(listsMatch(container.list_data, data))) {
  395. container = this.addChild('List', line_number, first_nonspace);
  396. container.list_data = data;
  397. }
  398. // add the list item
  399. container = this.addChild('ListItem', line_number, first_nonspace);
  400. container.list_data = data;
  401. } else {
  402. break;
  403. }
  404. if (acceptsLines(container.t)) {
  405. // if it's a line container, it can't contain other containers
  406. break;
  407. }
  408. }
  409. // What remains at the offset is a text line. Add the text to the
  410. // appropriate container.
  411. match = matchAt(reNonSpace, ln, offset);
  412. if (match === -1) {
  413. first_nonspace = ln.length;
  414. blank = true;
  415. } else {
  416. first_nonspace = match;
  417. blank = false;
  418. }
  419. indent = first_nonspace - offset;
  420. // First check for a lazy paragraph continuation:
  421. if (this.tip !== last_matched_container &&
  422. !blank &&
  423. this.tip.t === 'Paragraph' &&
  424. this.tip.strings.length > 0) {
  425. // lazy paragraph continuation
  426. this.last_line_blank = false;
  427. this.addLine(ln, offset);
  428. } else { // not a lazy continuation
  429. // finalize any blocks not matched
  430. closeUnmatchedBlocks(this);
  431. // Block quote lines are never blank as they start with >
  432. // and we don't count blanks in fenced code for purposes of tight/loose
  433. // lists or breaking out of lists. We also don't set last_line_blank
  434. // on an empty list item.
  435. container.last_line_blank = blank &&
  436. !(container.t === 'BlockQuote' ||
  437. container.t === 'Header' ||
  438. container.t === 'FencedCode' ||
  439. (container.t === 'ListItem' &&
  440. !container.firstChild &&
  441. container.sourcepos[0][0] === line_number));
  442. var cont = container;
  443. while (cont.parent) {
  444. cont.parent.last_line_blank = false;
  445. cont = cont.parent;
  446. }
  447. switch (container.t) {
  448. case 'IndentedCode':
  449. case 'HtmlBlock':
  450. this.addLine(ln, offset);
  451. break;
  452. case 'FencedCode':
  453. // check for closing code fence:
  454. match = (indent <= 3 &&
  455. ln.charAt(first_nonspace) === container.fence_char &&
  456. ln.slice(first_nonspace).match(reClosingCodeFence));
  457. if (match && match[0].length >= container.fence_length) {
  458. // don't add closing fence to container; instead, close it:
  459. this.finalize(container, line_number);
  460. } else {
  461. this.addLine(ln, offset);
  462. }
  463. break;
  464. case 'Header':
  465. case 'HorizontalRule':
  466. // nothing to do; we already added the contents.
  467. break;
  468. default:
  469. if (acceptsLines(container.t)) {
  470. this.addLine(ln, first_nonspace);
  471. } else if (blank) {
  472. break;
  473. } else {
  474. // create paragraph container for line
  475. container = this.addChild('Paragraph', line_number, first_nonspace);
  476. this.addLine(ln, first_nonspace);
  477. }
  478. }
  479. }
  480. this.lastLineLength = ln.length - 1; // -1 for newline
  481. };
  482. // Finalize a block. Close it and do any necessary postprocessing,
  483. // e.g. creating string_content from strings, setting the 'tight'
  484. // or 'loose' status of a list, and parsing the beginnings
  485. // of paragraphs for reference definitions. Reset the tip to the
  486. // parent of the closed block.
  487. var finalize = function(block, line_number) {
  488. "use strict";
  489. var pos;
  490. // don't do anything if the block is already closed
  491. if (!block.open) {
  492. return 0;
  493. }
  494. block.open = false;
  495. block.sourcepos[1] = [line_number, this.lastLineLength + 1];
  496. switch (block.t) {
  497. case 'Paragraph':
  498. block.string_content = block.strings.join('\n');
  499. // try parsing the beginning as link reference definitions:
  500. while (block.string_content.charCodeAt(0) === C_OPEN_BRACKET &&
  501. (pos = this.inlineParser.parseReference(block.string_content,
  502. this.refmap))) {
  503. block.string_content = block.string_content.slice(pos);
  504. if (isBlank(block.string_content)) {
  505. block.t = 'ReferenceDef';
  506. break;
  507. }
  508. }
  509. break;
  510. case 'Header':
  511. block.string_content = block.strings.join('\n');
  512. break;
  513. case 'HtmlBlock':
  514. block.literal = block.strings.join('\n');
  515. break;
  516. case 'IndentedCode':
  517. stripFinalBlankLines(block.strings);
  518. block.literal = block.strings.join('\n') + '\n';
  519. block.t = 'CodeBlock';
  520. break;
  521. case 'FencedCode':
  522. // first line becomes info string
  523. block.info = unescapeString(block.strings[0].trim());
  524. if (block.strings.length === 1) {
  525. block.literal = '';
  526. } else {
  527. block.literal = block.strings.slice(1).join('\n') + '\n';
  528. }
  529. block.t = 'CodeBlock';
  530. break;
  531. case 'List':
  532. block.list_data.tight = true; // tight by default
  533. var item = block.firstChild;
  534. while (item) {
  535. // check for non-final list item ending with blank line:
  536. if (endsWithBlankLine(item) && item.next) {
  537. block.list_data.tight = false;
  538. break;
  539. }
  540. // recurse into children of list item, to see if there are
  541. // spaces between any of them:
  542. var subitem = item.firstChild;
  543. while (subitem) {
  544. if (endsWithBlankLine(subitem) && (item.next || subitem.next)) {
  545. block.list_data.tight = false;
  546. break;
  547. }
  548. subitem = subitem.next;
  549. }
  550. item = item.next;
  551. }
  552. break;
  553. default:
  554. break;
  555. }
  556. this.tip = block.parent || this.top;
  557. };
  558. // Walk through a block & children recursively, parsing string content
  559. // into inline content where appropriate. Returns new object.
  560. var processInlines = function(block) {
  561. "use strict";
  562. var node, event;
  563. var walker = block.walker();
  564. while ((event = walker.next())) {
  565. node = event.node;
  566. if (!event.entering && (node.t === 'Paragraph' ||
  567. node.t === 'Header')) {
  568. this.inlineParser.parse(node, this.refmap);
  569. }
  570. }
  571. };
  572. var Document = function() {
  573. "use strict";
  574. var doc = new Node('Document', [[1, 1], [0, 0]]);
  575. doc.string_content = undefined;
  576. doc.strings = [];
  577. return doc;
  578. };
  579. // The main parsing function. Returns a parsed document AST.
  580. var parse = function(input) {
  581. "use strict";
  582. this.doc = Document();
  583. this.tip = this.doc;
  584. this.refmap = {};
  585. if (this.options.time) { console.time("preparing input"); }
  586. var lines = input.split(reLineEnding);
  587. var len = lines.length;
  588. if (input.charCodeAt(input.length - 1) === C_NEWLINE) {
  589. // ignore last blank line created by final newline
  590. len -= 1;
  591. }
  592. if (this.options.time) { console.timeEnd("preparing input"); }
  593. if (this.options.time) { console.time("block parsing"); }
  594. for (var i = 0; i < len; i++) {
  595. this.incorporateLine(lines[i], i + 1);
  596. }
  597. while (this.tip) {
  598. this.finalize(this.tip, len);
  599. }
  600. if (this.options.time) { console.timeEnd("block parsing"); }
  601. if (this.options.time) { console.time("inline parsing"); }
  602. this.processInlines(this.doc);
  603. if (this.options.time) { console.timeEnd("inline parsing"); }
  604. return this.doc;
  605. };
  606. // The DocParser object.
  607. function DocParser(options){
  608. "use strict";
  609. return {
  610. doc: Document(),
  611. tip: this.doc,
  612. refmap: {},
  613. lastLineLength: 0,
  614. inlineParser: new InlineParser(),
  615. breakOutOfLists: breakOutOfLists,
  616. addLine: addLine,
  617. addChild: addChild,
  618. incorporateLine: incorporateLine,
  619. finalize: finalize,
  620. processInlines: processInlines,
  621. parse: parse,
  622. options: options || {}
  623. };
  624. }
  625. module.exports = DocParser;