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