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