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 }