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