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