1 module md2d.parser; 2 3 import md2d.link, 4 md2d.util, 5 std.array, 6 std.range, 7 std.string; 8 import std.algorithm : canFind, countUntil, min; 9 import std.ascii : isAlpha, isWhite; 10 11 struct MarkdownSettings { 12 /// Controls the capabilities of the parser 13 MarkdownFlags flags = MarkdownFlags.githubInspired; 14 15 /// Heading tags will start at this level 16 size_t headingBaseLevel = 1; 17 /+ 18 /// Called for every link/image URL to perform arbitrary transformations 19 string delegate(string url_or_path, bool is_image) urlFilter; 20 21 /++ An optional delegate to post-process code blocks and inline code. 22 23 Useful to e.g. add code highlighting. 24 +/ 25 string delegate(string) @safe nothrow processCode = null; 26 +/ 27 bool noComment; 28 } 29 30 enum MarkdownFlags { 31 none = 0, 32 keepLineBreaks = 1 << 0, 33 backtickCodeBlocks = 1 << 1, 34 noInlineHtml = 1 << 2, 35 //noLinks = 1<<3, 36 //allowUnsafeHtml = 1<<4, 37 /// If used, subheadings are underlined by stars ('*') instead of dashes ('-') 38 alternateSubheaders = 1 << 5, 39 /// If used, '_' may not be used for emphasis ('*' may still be used) 40 disableUnderscoreEmphasis = 1 << 6, 41 supportTables = 1 << 7, 42 vanillaMarkdown = none, 43 forumDefault = keepLineBreaks | backtickCodeBlocks | noInlineHtml, 44 githubInspired = backtickCodeBlocks | supportTables, 45 } 46 47 enum IndentType { 48 White, 49 Quote 50 } 51 52 enum LineType { 53 Undefined, 54 Blank, 55 Plain, 56 Hline, 57 AtxHeader, 58 SetextHeader, 59 UList, 60 OList, 61 HtmlBlock, 62 CodeBlockDelimiter, 63 Table, 64 } 65 66 struct Line { 67 LineType type; 68 IndentType[] indent; 69 string text; 70 string unindented; 71 72 string unindent(size_t n) pure @safe { 73 assert(n <= indent.length); 74 string ln = text; 75 foreach (i; 0 .. n) { 76 final switch (indent[i]) { 77 case IndentType.White: 78 ln = ln[ln[0] == ' ' ? 4: 1 .. $]; 79 break; 80 case IndentType.Quote: 81 ln = stripLeft(ln)[1 .. $]; 82 break; 83 } 84 } 85 return ln; 86 } 87 } 88 89 struct Section { 90 size_t headingLevel; 91 string caption; 92 string anchor; 93 Section[] subSections; 94 } 95 96 /++ 97 Returns the hierarchy of sections 98 +/ 99 Section[] getMarkdownOutline(string md_source, scope MarkdownSettings settings = MarkdownSettings()) { 100 auto all_lines = splitLines(md_source); 101 auto lines = parseLines(all_lines, settings); 102 Block root_block; 103 parseBlocks(root_block, lines, null, settings); 104 Section root; 105 106 foreach (ref sb; root_block.blocks) { 107 if (sb.type == BlockType.Header) { 108 auto s = &root; 109 for (;;) { 110 if (s.subSections.length == 0) 111 break; 112 if (s.subSections[$ - 1].headingLevel >= sb.headerLevel) 113 break; 114 s = &s.subSections[$ - 1]; 115 } 116 s.subSections ~= Section(sb.headerLevel, sb.text[0], sb.text[0].asSlug.to!string); 117 } 118 } 119 120 return root.subSections; 121 } 122 123 @safe: 124 125 Line[] parseLines(ref string[] lines, scope MarkdownSettings settings) pure { 126 Line[] ret; 127 const subHeaderChar = settings.flags * MarkdownFlags.alternateSubheaders ? '*' : '-'; 128 while (!lines.empty) { 129 auto ln = lines.front; 130 lines.popFront(); 131 132 Line lninfo; 133 lninfo.text = ln; 134 135 while (ln.length) { 136 if (ln[0] == '\t') { 137 lninfo.indent ~= IndentType.White; 138 ln.popFront(); 139 } else if (ln.startsWith(" ")) { 140 lninfo.indent ~= IndentType.White; 141 ln.popFrontN(4); 142 } else { 143 ln = ln.stripLeft(); 144 if (ln.startsWith('>')) { 145 lninfo.indent ~= IndentType.Quote; 146 ln.popFront(); 147 } else 148 break; 149 } 150 } 151 lninfo.unindented = ln; 152 153 if ((settings.flags & MarkdownFlags.backtickCodeBlocks) && isCodeBlockDelimiter(ln)) 154 lninfo.type = LineType.CodeBlockDelimiter; 155 else if (isAtxHeaderLine(ln)) 156 lninfo.type = LineType.AtxHeader; 157 else if (isSetextHeaderLine(ln, subHeaderChar)) 158 lninfo.type = LineType.SetextHeader; 159 else if ((settings.flags & MarkdownFlags.supportTables) && isTableRowLine!false(ln)) 160 lninfo.type = LineType.Table; 161 else if (isHlineLine(ln)) 162 lninfo.type = LineType.Hline; 163 else if (isOListLine(ln)) 164 lninfo.type = LineType.OList; 165 else if (isUListLine(ln)) 166 lninfo.type = LineType.UList; 167 else if (isLineBlank(ln)) 168 lninfo.type = LineType.Blank; 169 else if (!(settings.flags & MarkdownFlags.noInlineHtml) && isHtmlBlockLine(ln)) 170 lninfo.type = LineType.HtmlBlock; 171 else 172 lninfo.type = LineType.Plain; 173 174 ret ~= lninfo; 175 } 176 return ret; 177 } 178 179 enum BlockType { 180 Plain, 181 Text, 182 Paragraph, 183 Header, 184 OList, 185 UList, 186 ListItem, 187 Code, 188 Quote, 189 Table, 190 TableRow, 191 TableHeader, 192 TableData, 193 } 194 195 struct Block { 196 BlockType type; 197 string[] text; 198 Block[] blocks; 199 size_t headerLevel; 200 201 // A human-readable toString for debugging 202 string toString() { 203 auto app = appender!string; 204 toStringNested(app); 205 return app[]; 206 } 207 208 // toString implementation; capable of indenting nested blocks 209 void toStringNested(R)(ref R appender, uint depth = 0) { 210 import std.format; 211 212 auto indent = " ".replicate(depth); 213 appender.put(indent); 214 appender.put("%s\n".format(type)); 215 appender.put(indent); 216 appender.put("%s\n".format(text)); 217 foreach (block; blocks) 218 block.toStringNested(appender, depth + 1); 219 appender.put(indent); 220 appender.put("%s\n".format(headerLevel)); 221 } 222 } 223 224 pure: 225 226 void parseBlocks(ref Block root, ref Line[] lines, IndentType[] base_indent, scope MarkdownSettings settings) { 227 if (base_indent.length == 0) 228 root.type = BlockType.Text; 229 else if (base_indent[$ - 1] == IndentType.Quote) 230 root.type = BlockType.Quote; 231 232 while (!lines.empty) { 233 auto ln = lines.front; 234 235 if (ln.type == LineType.Blank) { 236 lines.popFront(); 237 continue; 238 } 239 240 if (ln.indent != base_indent) { 241 if (ln.indent.length < base_indent.length || ln.indent[0 .. base_indent.length] != base_indent) 242 return; 243 244 auto cindent = base_indent ~ IndentType.White; 245 if (ln.indent == cindent) { 246 Block cblock; 247 cblock.type = BlockType.Code; 248 while (!lines.empty && lines.front.indent.length >= cindent.length 249 && lines.front.indent[0 .. cindent.length] == cindent) { 250 cblock.text ~= lines.front.unindent(cindent.length); 251 lines.popFront(); 252 } 253 root.blocks ~= cblock; 254 } else { 255 Block subblock; 256 parseBlocks(subblock, lines, ln.indent[0 .. base_indent.length + 1], settings); 257 root.blocks ~= subblock; 258 } 259 } else { 260 Block b; 261 void processPlain() { 262 b.type = BlockType.Paragraph; 263 b.text = skipText(lines, base_indent); 264 } 265 266 final switch (ln.type) { 267 case LineType.Undefined: 268 case LineType.Blank: 269 assert(0); 270 case LineType.Plain: 271 if (lines.length >= 2 && lines[1].type == LineType.SetextHeader) { 272 auto setln = lines[1].unindented; 273 b.type = BlockType.Header; 274 b.text = [ln.unindented]; 275 b.headerLevel = setln.strip()[0] == '=' ? 1 : 2; 276 lines.popFrontN(2); 277 } else { 278 processPlain(); 279 } 280 break; 281 case LineType.Hline: 282 b.type = BlockType.Plain; 283 b.text = ["<hr>"]; 284 lines.popFront(); 285 break; 286 case LineType.AtxHeader: 287 b.type = BlockType.Header; 288 string hl = ln.unindented; 289 b.headerLevel = 0; 290 while (hl.length && hl[0] == '#') { 291 b.headerLevel++; 292 hl = hl[1 .. $]; 293 } 294 while (hl.length && (hl[$ - 1] == '#' || hl[$ - 1] == ' ')) 295 hl = hl[0 .. $ - 1]; 296 b.text = [hl]; 297 lines.popFront(); 298 break; 299 case LineType.SetextHeader: 300 lines.popFront(); 301 break; 302 case LineType.UList: 303 case LineType.OList: 304 b.type = ln.type == LineType.UList ? BlockType.UList : BlockType.OList; 305 auto itemindent = base_indent ~ IndentType.White; 306 bool firstItem = true, paraMode = false; 307 while (!lines.empty && lines.front.type == ln.type && lines.front.indent == base_indent) { 308 Block itm; 309 itm.text = skipText(lines, itemindent); 310 itm.text[0] = removeListPrefix(itm.text[0], ln.type); 311 312 // emit <p></p> if there are blank lines between the items 313 if (firstItem && !lines.empty && lines.front.type == LineType.Blank) 314 paraMode = true; 315 firstItem = false; 316 if (paraMode) { 317 Block para; 318 para.type = BlockType.Paragraph; 319 para.text = itm.text; 320 itm.blocks ~= para; 321 itm.text = null; 322 } 323 324 parseBlocks(itm, lines, itemindent, settings); 325 itm.type = BlockType.ListItem; 326 b.blocks ~= itm; 327 } 328 break; 329 case LineType.HtmlBlock: 330 int nestlevel = 0; 331 auto starttag = parseHtmlBlockLine(ln.unindented); 332 if (!starttag.isHtmlBlock || !starttag.open) 333 break; 334 335 b.type = BlockType.Plain; 336 while (!lines.empty) { 337 if (lines.front.indent.length < base_indent.length) 338 break; 339 if (lines.front.indent[0 .. base_indent.length] != base_indent) 340 break; 341 342 auto str = lines.front.unindent(base_indent.length); 343 auto taginfo = parseHtmlBlockLine(str); 344 b.text ~= lines.front.unindent(base_indent.length); 345 lines.popFront(); 346 if (taginfo.isHtmlBlock && taginfo.tagName == starttag.tagName) 347 nestlevel += taginfo.open ? 1 : -1; 348 if (nestlevel <= 0) 349 break; 350 } 351 break; 352 case LineType.CodeBlockDelimiter: 353 lines.popFront(); // TODO: get language from line 354 b.type = BlockType.Code; 355 while (!lines.empty) { 356 if (lines.front.indent.length < base_indent.length) 357 break; 358 if (lines.front.indent[0 .. base_indent.length] != base_indent) 359 break; 360 if (lines.front.type == LineType.CodeBlockDelimiter) { 361 lines.popFront(); 362 break; 363 } 364 b.text ~= lines.front.unindent(base_indent.length); 365 lines.popFront(); 366 } 367 break; 368 case LineType.Table: 369 lines.popFront(); 370 // Can this be a valid table (is there a next line that could be a header separator)? 371 if (lines.empty) { 372 processPlain(); 373 break; 374 } 375 Line lnNext = lines.front; 376 immutable bool isTableHeader = 377 lnNext.type == LineType.Table 378 && lnNext.text.indexOf(" -") >= 0 379 && lnNext.text.indexOf("- ") >= 0 380 && lnNext.text.allOf("-:| "); 381 if (!isTableHeader) { 382 // Not a valid table header, so let's assume it's plain markdown 383 processPlain(); 384 break; 385 } 386 b.type = BlockType.Table; 387 // Parse header 388 b.blocks ~= ln.splitTableRow!(BlockType.TableHeader)(); 389 // Parse table rows 390 lines.popFront(); 391 while (!lines.empty) { 392 ln = lines.front; 393 if (ln.type != LineType.Table) 394 break; // not a table row, so let's assume it's the end of the table 395 b.blocks ~= ln.splitTableRow(); 396 lines.popFront(); 397 } 398 break; 399 } 400 root.blocks ~= b; 401 } 402 } 403 } 404 405 bool matchesIndent(IndentType[] indent, IndentType[] base_indent) { 406 // Any *plain* line with a higher indent should still be a part of 407 // a paragraph read by skipText(). Returning false here resulted in 408 // text such as: 409 // --- 410 // First line 411 // Second line 412 // --- 413 // being interpreted as a paragraph followed by a code block, even though 414 // other Markdown processors would interpret it as a single paragraph. 415 416 // if(indent.length > base_indent.length) return false; 417 if (indent.length > base_indent.length) 418 return true; 419 if (indent != base_indent[0 .. indent.length]) 420 return false; 421 sizediff_t qidx = -1; 422 foreach_reverse (i, tp; base_indent) 423 if (tp == IndentType.Quote) { 424 qidx = i; 425 break; 426 } 427 if (qidx >= 0) { 428 qidx = base_indent.length - 1 - qidx; 429 if (indent.length <= qidx) 430 return false; 431 } 432 return true; 433 } 434 435 string[] skipText(ref Line[] lines, IndentType[] indent) { 436 string[] ret; 437 438 for (;;) { 439 ret ~= lines.front.unindent(min(indent.length, lines.front.indent.length)); 440 lines.popFront(); 441 442 if (lines.empty || !matchesIndent(lines.front.indent, indent) || lines.front.type != LineType 443 .Plain) 444 return ret; 445 } 446 } 447 448 Block splitTableRow(BlockType type = BlockType.TableData)(Line line) { 449 static assert(type == BlockType.TableHeader || type == BlockType.TableData); 450 451 string ln = line.text.strip(); 452 immutable b = ln[0 .. 2] == "| " ? 2 : 0; 453 immutable e = ln[$ - 2 .. $] == " |" ? ln.length - 2 : ln.length; 454 Block ret; 455 ret.type = BlockType.TableRow; 456 foreach (txt; ln[b .. e].split(" | ")) { 457 Block d; 458 d.text = [txt.strip(" ").replace("\\|", "|")]; 459 d.type = type; 460 ret.blocks ~= d; 461 } 462 return ret; 463 } 464 465 string removeListPrefix(string str, LineType tp) { 466 switch (tp) { 467 default: 468 assert(0); 469 case LineType.OList: // skip bullets and output using normal escaping 470 auto idx = str.indexOf('.'); 471 assert(idx > 0); 472 return str[idx + 1 .. $].stripLeft(); 473 case LineType.UList: 474 return stripLeft(str.stripLeft()[1 .. $]); 475 } 476 } 477 478 immutable BlockTags = ["div", "ol", "p", "pre", "section", "table", "ul"]; 479 480 struct HtmlBlockInfo { 481 bool isHtmlBlock; 482 string tagName; 483 bool open; 484 } 485 486 auto parseHtmlBlockLine(string ln) { 487 auto ret = HtmlBlockInfo(false, "", true); 488 489 ln = ln.strip; 490 if (ln.length < 3) 491 return ret; 492 if (ln[0] != '<') 493 return ret; 494 if (ln[1] == '/') { 495 ret.open = false; 496 ln = ln[1 .. $]; 497 } 498 if (!isAlpha(ln[1])) 499 return ret; 500 ln = ln[1 .. $]; 501 size_t idx = 0; 502 while (idx < ln.length && ln[idx] != ' ' && ln[idx] != '>') 503 idx++; 504 ret.tagName = ln[0 .. idx]; 505 ln = ln[idx .. $]; 506 507 auto eidx = ln.indexOf('>'); 508 if (eidx < 0) 509 return ret; 510 if (eidx != ln.length - 1) 511 return ret; 512 513 if (!BlockTags.canFind(ret.tagName)) 514 return ret; 515 516 ret.isHtmlBlock = true; 517 return ret; 518 } 519 520 bool isHtmlBlockLine(string ln) { 521 auto bi = parseHtmlBlockLine(ln); 522 return bi.isHtmlBlock && bi.open; 523 } 524 525 bool isHtmlBlockCloseLine(string ln) { 526 auto bi = parseHtmlBlockLine(ln); 527 return bi.isHtmlBlock && !bi.open; 528 } 529 530 string getHtmlTagName(string ln) => parseHtmlBlockLine(ln).tagName; 531 532 int parseEmphasis(ref string str, ref string text) { 533 if (str.length < 3) 534 return false; 535 536 string pstr = str; 537 string ctag; 538 if (pstr.startsWith("***")) 539 ctag = "***"; 540 else if (pstr.startsWith("**")) 541 ctag = "**"; 542 else if (pstr.startsWith('*')) 543 ctag = "*"; 544 else if (pstr.startsWith("___")) 545 ctag = "___"; 546 else if (pstr.startsWith("__")) 547 ctag = "__"; 548 else if (pstr.startsWith('_')) 549 ctag = "_"; 550 else 551 return false; 552 553 pstr = pstr[ctag.length .. $]; 554 555 auto cidx = pstr.indexOf(ctag); 556 if (cidx < 1) 557 return false; 558 559 text = pstr[0 .. cidx]; 560 561 str = pstr[cidx + ctag.length .. $]; 562 return cast(int)ctag.length; 563 } 564 565 bool parseInlineCode(ref string str, ref string code) { 566 if (str.length < 3) 567 return false; 568 569 string pstr = str; 570 string ctag; 571 if (pstr.startsWith("``")) 572 ctag = "``"; 573 else if (pstr.startsWith('`')) 574 ctag = "`"; 575 else 576 return false; 577 pstr = pstr[ctag.length .. $]; 578 579 auto cidx = pstr.indexOf(ctag); 580 if (cidx < 1) 581 return false; 582 583 code = pstr[0 .. cidx]; 584 str = pstr[cidx + ctag.length .. $]; 585 return true; 586 }