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