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