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