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