Переглянути джерело

All blocks and spans covered by unit tests

main
Rocketsoup 1 рік тому
джерело
коміт
ee94689498
2 змінених файлів з 786 додано та 163 видалено
  1. 279
    123
      js/markdown.js
  2. 507
    40
      testjs.html

+ 279
- 123
js/markdown.js Переглянути файл

1
 // FIXME: Nested blockquotes require blank line
1
 // FIXME: Nested blockquotes require blank line
2
 // TODO: HTML tags probably need better handling. Consider whether interior of matched tags should be interpreted as markdown.
2
 // TODO: HTML tags probably need better handling. Consider whether interior of matched tags should be interpreted as markdown.
3
 // TODO: Test broken/incomplete syntax thoroughly
3
 // TODO: Test broken/incomplete syntax thoroughly
4
-// TODO: Sanity checks on loops/recursion?
5
-// TODO: Tolerate whitespace between tokens (e.g. [click here] [urlref])
4
+// TODO: Sanity checks on loops/recursion
6
 // TODO: Spreadsheet functions in tables
5
 // TODO: Spreadsheet functions in tables
7
-// TODO: Support document differentiators for CSS identifiers
6
+// TODO: Support document differentiators for CSS identifiers (using markdown to render 2+ documents in the same page, need ids to be unique within the page)
7
+// TODO: Support language marker in ``` to at least add a CSS class to the <pre>
8
+// TODO: Better way to detect start of new block inside of list item without line break
8
 
9
 
9
 class MDTokenType {
10
 class MDTokenType {
10
 	static Text = new MDTokenType('Text');
11
 	static Text = new MDTokenType('Text');
43
 	toString() {
44
 	toString() {
44
 		return `${this.constructor.name}.${this.name}`;
45
 		return `${this.constructor.name}.${this.name}`;
45
 	}
46
 	}
47
+
48
+	equals(other) {
49
+		return (other instanceof MDTokenType) && other.name == this.name;
50
+	}
46
 }
51
 }
47
 
52
 
48
 class MDToken {
53
 class MDToken {
83
 		this.tag = tag;
88
 		this.tag = tag;
84
 	}
89
 	}
85
 
90
 
91
+	toString() {
92
+		return `(${this.constructor.name} type=${this.type.toString()} content=${this.content})`;
93
+	}
94
+
86
 	/**
95
 	/**
87
 	 * Searches an array of MDToken for the given pattern of MDTokenTypes.
96
 	 * Searches an array of MDToken for the given pattern of MDTokenTypes.
88
 	 * If found, returns an object with the given keys.
97
 	 * If found, returns an object with the given keys.
190
 		}
199
 		}
191
 		return null;
200
 		return null;
192
 	}
201
 	}
202
+
203
+	equals(other) {
204
+		if (!(other instanceof MDToken)) return false;
205
+		if (other.original !== this.original) return false;
206
+		if (!other.type.equals(this.type)) return false;
207
+		if (other.content !== this.content) return false;
208
+		if (other.extra !== this.extra) return false;
209
+		if (!MDUtils.equal(other.tag, this.tag)) return false;
210
+		if (!MDUtils.equals(other.modifier, this.modifier)) return false;
211
+		return true
212
+	}
193
 }
213
 }
194
 
214
 
195
 class MDUtils {
215
 class MDUtils {
199
 	static baseEmailRegex = /(?:(?:[^<>()\[\]\\.,;:\s@"]+(?:\.[^<>()\[\]\\.,;:\s@"]+)*)|(?:".+"))@(?:(?:\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(?:(?:[a-z\-0-9]+\.)+[a-z]{2,}))/i;
219
 	static baseEmailRegex = /(?:(?:[^<>()\[\]\\.,;:\s@"]+(?:\.[^<>()\[\]\\.,;:\s@"]+)*)|(?:".+"))@(?:(?:\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(?:(?:[a-z\-0-9]+\.)+[a-z]{2,}))/i;
200
 
220
 
201
 	/**
221
 	/**
202
-	 * @param {string} str
203
-	 * @returns {string}
222
+	 * Escapes special HTML characters.
223
+	 *
224
+	 * @param {string} str - string to escape
225
+	 * @param {boolean} encodeNewlinesAsBreaks - whether to convert newline characters to `<br>` tags
226
+	 * @returns {string} escaped HTML
204
 	 */
227
 	 */
205
-	static escapeHTML(str) {
206
-		return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
228
+	static escapeHTML(str, encodeNewlinesAsBreaks=false) {
229
+		var html = str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
230
+		if (encodeNewlinesAsBreaks) {
231
+			html = html.replace(/\n/g, "<br>\n");
232
+		}
233
+		return html;
207
 	}
234
 	}
208
 
235
 
209
 	/**
236
 	/**
210
-	 * @param {string} email
237
+	 * Encodes characters as HTML numeric entities to make it marginally more
238
+	 * difficult for web scrapers to grab sensitive info.
239
+	 *
240
+	 * @param {string} text - text to escape
241
+	 * @returns {string} escaped HTML
211
 	 */
242
 	 */
212
 	static escapeObfuscated(text) {
243
 	static escapeObfuscated(text) {
213
 		var html = '';
244
 		var html = '';
247
 	}
278
 	}
248
 
279
 
249
 	/**
280
 	/**
281
+	 * Tests if an array of lines contains at least one blank. A blank line
282
+	 * can contain whitespace.
283
+	 * 
284
+	 * @param {String[]} lines
285
+	 * @returns {boolean} whether `lines` contains any whitespace-only lines
286
+	 */
287
+	static containsBlankLine(lines) {
288
+		for (const line of lines) {
289
+			if (line.trim().length == 0) return true;
290
+		}
291
+		return false;
292
+	}
293
+
294
+	/**
250
 	 * Counts the number of indent levels in a line of text. Partial indents
295
 	 * Counts the number of indent levels in a line of text. Partial indents
251
 	 * (1 to 3 spaces) are counted as one indent level unless `fullIndentsOnly`
296
 	 * (1 to 3 spaces) are counted as one indent level unless `fullIndentsOnly`
252
 	 * is `true`.
297
 	 * is `true`.
361
 		}
406
 		}
362
 		return typeof value;
407
 		return typeof value;
363
 	}
408
 	}
409
+
410
+	static #equalArrays(a, b) {
411
+		if (a === b) return true;
412
+		if (!(a instanceof Array) || !(b instanceof Array)) return false;
413
+		if (a == null || b == null) return false;
414
+		if (a.length != b.length) return false;
415
+		for (var i = 0; i < a.length; i++) {
416
+			if (!this.equal(a[i], b[i])) return false;
417
+		}
418
+		return true;
419
+	}
420
+
421
+	static #equalObjects(a, b) {
422
+		if (a === b) return true;
423
+		if (!(a instanceof Object) || !(b instanceof Object)) return false;
424
+		if (a == null || b == null) return false;
425
+		if (a.equals !== undefined) {
426
+			return a.equals(b);
427
+		}
428
+		for (const key of Object.keys(a)) {
429
+			if (!this.equal(a[key], b[key])) return false;
430
+		}
431
+		for (const key of Object.keys(b)) {
432
+			if (!this.equal(a[key], b[key])) return false;
433
+		}
434
+		return true;
435
+	}
436
+
437
+	/**
438
+	 * Tests for equality on lots of different kinds of values including objects
439
+	 * and arrays. Will use `.equals` on objects that implement it.
440
+	 * 
441
+	 * @param {any} a
442
+	 * @param {any} b
443
+	 * @returns {boolean}
444
+	 */
445
+	static equal(a, b) {
446
+		if (a instanceof Array && b instanceof Array) {
447
+			return this.#equalArrays(a, b);
448
+		}
449
+		if (a instanceof Object && b instanceof Object) {
450
+			return this.#equalObjects(a, b);
451
+		}
452
+		return a == b;
453
+	}
364
 }
454
 }
365
 
455
 
366
 
456
 
499
 		state.p = p;
589
 		state.p = p;
500
 		const level = groups[1].length;
590
 		const level = groups[1].length;
501
 		const content = groups[2];
591
 		const content = groups[2];
502
-		let block = new MDHeaderBlock(level, state.inlineMarkdownToSpan(content));
592
+		let block = new MDHeaderBlock(level, new MDInlineBlock(state.inlineMarkdownToSpan(content)));
503
 		if (modifier) modifier.applyTo(block);
593
 		if (modifier) modifier.applyTo(block);
504
 		return block;
594
 		return block;
505
 	}
595
 	}
546
 	}
636
 	}
547
 }
637
 }
548
 
638
 
639
+/**
640
+ * Abstract base class for ordered and unordered lists.
641
+ */
549
 class MDBaseListBlockReader extends MDBlockReader {
642
 class MDBaseListBlockReader extends MDBlockReader {
550
 	constructor(priority) {
643
 	constructor(priority) {
551
 		super(priority);
644
 		super(priority);
868
 		if (line.endsWith('|')) line = line.substring(0, line.length - 1);
961
 		if (line.endsWith('|')) line = line.substring(0, line.length - 1);
869
 		let cellTokens = line.split('|');
962
 		let cellTokens = line.split('|');
870
 		let cells = cellTokens.map(function(token) {
963
 		let cells = cellTokens.map(function(token) {
871
-			let content = state.inlineMarkdownToSpan(token);
964
+			let content = state.inlineMarkdownToSpan(token.trim());
872
 			return isHeader ? new MDTableHeaderCellBlock(content) : new MDTableCellBlock(content);
965
 			return isHeader ? new MDTableHeaderCellBlock(content) : new MDTableCellBlock(content);
873
 		});
966
 		});
874
 		state.p = p;
967
 		state.p = p;
956
 		while (state.hasLines(1, p)) {
1049
 		while (state.hasLines(1, p)) {
957
 			let line = state.lines[p++];
1050
 			let line = state.lines[p++];
958
 			if (line.trim().length == 0) {
1051
 			if (line.trim().length == 0) {
959
-				p--;
960
 				break;
1052
 				break;
961
 			}
1053
 			}
962
 			if (/^\s+/.exec(line)) {
1054
 			if (/^\s+/.exec(line)) {
972
 		}
1064
 		}
973
 		if (termCount == 0 || definitionCount == 0) return null;
1065
 		if (termCount == 0 || definitionCount == 0) return null;
974
 		let blocks = defLines.map(function(line) {
1066
 		let blocks = defLines.map(function(line) {
975
-			if (groups = /^:\s+(.*)$/.exec(line)) {
1067
+			if (groups = /^:\s+(.*?)$/s.exec(line)) {
976
 				return new MDDefinitionDefinitionBlock(state.inlineMarkdownToSpans(groups[1]));
1068
 				return new MDDefinitionDefinitionBlock(state.inlineMarkdownToSpans(groups[1]));
977
 			} else {
1069
 			} else {
978
 				return new MDDefinitionTermBlock(state.inlineMarkdownToSpans(line));
1070
 				return new MDDefinitionTermBlock(state.inlineMarkdownToSpans(line));
1123
 	readBlock(state) {
1215
 	readBlock(state) {
1124
 		var paragraphLines = [];
1216
 		var paragraphLines = [];
1125
 		var p = state.p;
1217
 		var p = state.p;
1218
+		var foundBlankLine = false;
1126
 		while (p < state.lines.length) {
1219
 		while (p < state.lines.length) {
1127
 			let line = state.lines[p++];
1220
 			let line = state.lines[p++];
1128
 			if (line.trim().length == 0) {
1221
 			if (line.trim().length == 0) {
1222
+				foundBlankLine = true;
1129
 				break;
1223
 				break;
1130
 			}
1224
 			}
1131
 			paragraphLines.push(line);
1225
 			paragraphLines.push(line);
1132
 		}
1226
 		}
1227
+		if (state.p == 0 && p >= state.lines.length) {
1228
+			// If it's the entire document don't wrap it in a paragraph
1229
+			return null;
1230
+		}
1133
 		if (paragraphLines.length > 0) {
1231
 		if (paragraphLines.length > 0) {
1134
 			state.p = p;
1232
 			state.p = p;
1135
 			let content = paragraphLines.join("\n");
1233
 			let content = paragraphLines.join("\n");
1683
 class MDHeaderBlock extends MDBlock {
1781
 class MDHeaderBlock extends MDBlock {
1684
 	/** @type {number} */
1782
 	/** @type {number} */
1685
 	#level;
1783
 	#level;
1686
-	/** @type {MDBlock} */
1784
+	/** @type {MDBlock[]} */
1687
 	#content;
1785
 	#content;
1688
 
1786
 
1689
 	/**
1787
 	/**
1690
 	 * @param {number} level
1788
 	 * @param {number} level
1691
-	 * @param {MDBlock} content
1789
+	 * @param {MDBlock|MDBlock[]} content
1692
 	 */
1790
 	 */
1693
 	constructor(level, content) {
1791
 	constructor(level, content) {
1694
 		super();
1792
 		super();
1695
 		this.#level = level;
1793
 		this.#level = level;
1696
-		this.#content = content;
1794
+		this.#content = (content instanceof Array) ? content : [ content ];
1697
 	}
1795
 	}
1698
 
1796
 
1699
 	toHTML(state) {
1797
 	toHTML(state) {
1700
-		let contentHTML = this.#content.toHTML(state);
1701
-		return `<h${this.#level}${this.htmlAttributes()}>${contentHTML}</h${this.level}>\n`;
1798
+		let contentHTML = MDBlock.toHTML(this.#content, state);
1799
+		return `<h${this.#level}${this.htmlAttributes()}>${contentHTML}</h${this.#level}>\n`;
1702
 	}
1800
 	}
1703
 
1801
 
1704
 	visitChildren(fn) {
1802
 	visitChildren(fn) {
1705
-		fn(this.content);
1706
-		this.content.visitChildren(fn);
1803
+		for (const child of this.#content) {
1804
+			fn(child);
1805
+			child.visitChildren(fn);
1806
+		}
1707
 	}
1807
 	}
1708
 }
1808
 }
1709
 
1809
 
1775
 
1875
 
1776
 	htmlAttributes() {
1876
 	htmlAttributes() {
1777
 		var html = super.htmlAttributes();
1877
 		var html = super.htmlAttributes();
1778
-		if (this.startOrdinal !== null) {
1878
+		if (this.startOrdinal !== null && this.startOrdinal != 1) {
1779
 			html += ` start="${this.startOrdinal}"`;
1879
 			html += ` start="${this.startOrdinal}"`;
1780
 		}
1880
 		}
1781
 		return html;
1881
 		return html;
1989
 }
2089
 }
1990
 
2090
 
1991
 class MDDefinitionTermBlock extends MDBlock {
2091
 class MDDefinitionTermBlock extends MDBlock {
1992
-	/** @type {MDBlock} */
2092
+	/** @type {MDBlock[]} */
1993
 	#content;
2093
 	#content;
1994
 
2094
 
1995
 	/**
2095
 	/**
1996
-	 * @param {MDBlock} content
2096
+	 * @param {MDBlock|MDBlock[]} content
1997
 	 */
2097
 	 */
1998
 	constructor(content) {
2098
 	constructor(content) {
1999
 		super();
2099
 		super();
2000
-		this.#content = content;
2100
+		if (content instanceof Array) {
2101
+			this.#content = content;
2102
+		} else if (content instanceof MDBlock) {
2103
+			this.#content = [ content ];
2104
+		} else {
2105
+			throw new Error(`${this.constructor.name} expects MDBlock or MDBlock[], got ${typeof content}`);
2106
+		}
2001
 	}
2107
 	}
2002
 
2108
 
2003
 	toHTML(state) {
2109
 	toHTML(state) {
2004
-		let contentHTML = this.#content.toHTML(state);
2110
+		let contentHTML = MDBlock.toHTML(this.#content, state);
2005
 		return `<dt${this.htmlAttributes()}>${contentHTML}</dt>`;
2111
 		return `<dt${this.htmlAttributes()}>${contentHTML}</dt>`;
2006
 	}
2112
 	}
2007
 
2113
 
2008
 	visitChildren(fn) {
2114
 	visitChildren(fn) {
2009
-		fn(this.#content);
2010
-		this.#content.visitChildren(fn);
2115
+		for (const child of this.#content) {
2116
+			fn(child);
2117
+			child.visitChildren(fn);
2118
+		}
2011
 	}
2119
 	}
2012
 }
2120
 }
2013
 
2121
 
2014
 class MDDefinitionDefinitionBlock extends MDBlock {
2122
 class MDDefinitionDefinitionBlock extends MDBlock {
2015
-	/** @type {MDBlock} */
2123
+	/** @type {MDBlock[]} */
2016
 	#content;
2124
 	#content;
2017
 
2125
 
2018
 	/**
2126
 	/**
2019
-	 * @param {MDBlock} content
2127
+	 * @param {MDBlock|MDBlock[]} content
2020
 	 */
2128
 	 */
2021
 	constructor(content) {
2129
 	constructor(content) {
2022
 		super();
2130
 		super();
2023
-		this.#content = content;
2131
+		if (content instanceof Array) {
2132
+			this.#content = content;
2133
+		} else if (content instanceof MDBlock) {
2134
+			this.#content = [ content ];
2135
+		} else {
2136
+			throw new Error(`${this.constructor.name} expects MDBlock or MDBlock[], got ${typeof content}`);
2137
+		}
2024
 	}
2138
 	}
2025
 
2139
 
2026
 	toHTML(state) {
2140
 	toHTML(state) {
2027
-		let contentHTML = this.#content.toHTML(state);
2141
+		let contentHTML = MDBlock.toHTML(this.#content, state);
2028
 		return `<dd${this.htmlAttributes()}>${contentHTML}</dd>`;
2142
 		return `<dd${this.htmlAttributes()}>${contentHTML}</dd>`;
2029
 	}
2143
 	}
2030
 
2144
 
2031
 	visitChildren(fn) {
2145
 	visitChildren(fn) {
2032
-		fn(this.#content);
2033
-		this.#content.visitChildren(fn);
2146
+		for (const child of this.#content) {
2147
+			fn(child);
2148
+			child.visitChildren(fn);
2149
+		}
2034
 	}
2150
 	}
2035
 }
2151
 }
2036
 
2152
 
2071
 	#content;
2187
 	#content;
2072
 
2188
 
2073
 	/**
2189
 	/**
2074
-	 * @param {MDSpan[]} content
2190
+	 * @param {MDSpan|MDSpan[]} content
2075
 	 */
2191
 	 */
2076
 	constructor(content) {
2192
 	constructor(content) {
2077
 		super();
2193
 		super();
2078
-		this.#content = content;
2194
+		this.#content = (content instanceof Array) ? content : [ content ];
2195
+		for (const span of this.#content) {
2196
+			if (!(span instanceof MDSpan)) {
2197
+				throw new Error(`${this.constructor.name} expects MDSpan or MDSpan[], got ${MDUtils.typename(span)}`);
2198
+			}
2199
+		}
2079
 	}
2200
 	}
2080
 
2201
 
2081
 	toHTML(state) {
2202
 	toHTML(state) {
2508
 		this.attributes = attributes;
2629
 		this.attributes = attributes;
2509
 	}
2630
 	}
2510
 
2631
 
2632
+	toString() {
2633
+		return this.fullTag;
2634
+	}
2635
+
2636
+	equals(other) {
2637
+		if (!(other instanceof MDHTMLTag)) return false;
2638
+		return other.fullTag == this.fullTag;
2639
+	}
2640
+
2511
 	static #htmlTagNameFirstRegex = /[a-z]/i;
2641
 	static #htmlTagNameFirstRegex = /[a-z]/i;
2512
 	static #htmlTagNameMedialRegex = /[a-z0-9]/i;
2642
 	static #htmlTagNameMedialRegex = /[a-z0-9]/i;
2513
 	static #htmlAttributeNameFirstRegex = /[a-z]/i;
2643
 	static #htmlAttributeNameFirstRegex = /[a-z]/i;
2674
 	}
2804
 	}
2675
 }
2805
 }
2676
 
2806
 
2807
+class MDTagModifier {
2808
+	/** @type {string} */
2809
+	original;
2810
+	/** @type {string[]} */
2811
+	cssClasses = [];
2812
+	/** @type {string|null} */
2813
+	cssId = null;
2814
+	/** @type {object} */
2815
+	attributes = {};
2816
+
2817
+	static #baseClassRegex = /\.([a-z_\-][a-z0-9_\-]*?)/i;
2818
+	static #baseIdRegex = /#([a-z_\-][a-z0-9_\-]*?)/i;
2819
+	static #baseAttributeRegex = /([a-z0-9]+?)=([^\s\}]+?)/i;
2820
+	static #baseRegex = /\{([^}]+?)}/i;
2821
+	static #leadingClassRegex = new RegExp('^' + this.#baseRegex.source, 'i');
2822
+	static #trailingClassRegex = new RegExp('^(.*?)\\s*' + this.#baseRegex.source + '\\s*$', 'i');
2823
+	static #classRegex = new RegExp('^' + this.#baseClassRegex.source + '$', 'i');  // 1=classname
2824
+	static #idRegex = new RegExp('^' + this.#baseIdRegex.source + '$', 'i');  // 1=id
2825
+	static #attributeRegex = new RegExp('^' + this.#baseAttributeRegex.source + '$', 'i');  // 1=attribute name, 2=attribute value
2826
+
2827
+	/**
2828
+	 * @param {MDBlock|MDSpan} elem
2829
+	 */
2830
+	applyTo(elem) {
2831
+		if (elem instanceof MDBlock || elem instanceof MDSpan) {
2832
+			elem.cssClasses = elem.cssClasses.concat(this.cssClasses);
2833
+			if (this.cssId) elem.cssId = this.cssId;
2834
+			for (const name in this.attributes) {
2835
+				elem.attributes[name] = this.attributes[name];
2836
+			}
2837
+		}
2838
+	}
2839
+
2840
+	equals(other) {
2841
+		if (!(other instanceof MDTagModifier)) return false;
2842
+		if (!MDUtils.equal(other.cssClasses, this.cssClasses)) return false;
2843
+		if (other.cssId !== this.cssId) return false;
2844
+		if (!MDUtils.equal(other.attributes, this.attributes)) return false;
2845
+		return true;
2846
+	}
2847
+
2848
+	toString() {
2849
+		return this.original;
2850
+	}
2851
+
2852
+	static #fromContents(contents) {
2853
+		let modifierTokens = contents.split(/\s+/);
2854
+		let mod = new MDTagModifier();
2855
+		mod.original = `{${contents}}`;
2856
+		var groups;
2857
+		for (const token of modifierTokens) {
2858
+			if (token.trim() == '') continue;
2859
+			if (groups = this.#classRegex.exec(token)) {
2860
+				mod.cssClasses.push(groups[1]);
2861
+			} else if (groups = this.#idRegex.exec(token)) {
2862
+				mod.cssId = groups[1];
2863
+			} else if (groups = this.#attributeRegex.exec(token)) {
2864
+				mod.attributes[groups[1]] = groups[2];
2865
+			} else {
2866
+				return null;
2867
+			}
2868
+		}
2869
+		return mod;
2870
+	}
2871
+
2872
+	/**
2873
+	 * Extracts modifier from line.
2874
+	 * @param {string} line
2875
+	 * @returns {Array} Tuple with remaining line and MDTagModifier.
2876
+	 */
2877
+	static fromLine(line) {
2878
+		let groups = this.#trailingClassRegex.exec(line);
2879
+		if (groups === null) return [ line, null ];
2880
+		let bareLine = groups[1];
2881
+		let mod = this.#fromContents(groups[2]);
2882
+		return [ bareLine, mod ];
2883
+	}
2884
+
2885
+	/**
2886
+	 * Extracts modifier from head of string.
2887
+	 * @param {string} line
2888
+	 * @returns {MDTagModifier}
2889
+	 */
2890
+	static fromStart(line) {
2891
+		let groups = this.#leadingClassRegex.exec(line);
2892
+		if (groups === null) return null;
2893
+		return this.#fromContents(groups[1]);
2894
+	}
2895
+
2896
+	/**
2897
+	 * @param {string} line
2898
+	 * @returns {string}
2899
+	 */
2900
+	static strip(line) {
2901
+		let groups = this.#trailingClassRegex.exec(line);
2902
+		if (groups === null) return line;
2903
+		return groups[1];
2904
+	}
2905
+}
2906
+
2677
 class MDState {
2907
 class MDState {
2678
 	/** @type {string[]} */
2908
 	/** @type {string[]} */
2679
 	#lines = [];
2909
 	#lines = [];
2907
 	}
3137
 	}
2908
 
3138
 
2909
 	/**
3139
 	/**
3140
+	 * Creates a simple `MDInlineBlock` if no other registered blocks match.
3141
+	 * 
3142
+	 * @returns {MDInlineBlock|null} fallback block
3143
+	 */
3144
+	#readFallbackBlock() {
3145
+		if (this.p >= this.lines.length) return null;
3146
+		const lines = MDUtils.withoutTrailingBlankLines(this.lines.slice(this.p));
3147
+		if (lines.length == 0) return null;
3148
+		this.p = this.lines.length;
3149
+		return new MDInlineBlock(this.inlineMarkdownToSpans(lines.join("\n")));
3150
+	}
3151
+
3152
+	/**
2910
 	 * Attempts to read one block from the current line pointer. The pointer
3153
 	 * Attempts to read one block from the current line pointer. The pointer
2911
 	 * will be positioned just after the end of the block.
3154
 	 * will be positioned just after the end of the block.
2912
 	 *
3155
 	 *
2922
 			const block = reader.readBlock(this);
3165
 			const block = reader.readBlock(this);
2923
 			if (block) return block;
3166
 			if (block) return block;
2924
 		}
3167
 		}
2925
-		return null;
3168
+		const fallback = this.#readFallbackBlock();
3169
+		return fallback;
2926
 	}
3170
 	}
2927
 
3171
 
2928
 	static #textWhitespaceRegex = /^(\s*)(?:(\S|\S.*\S)(\s*?))?$/; // 1=leading WS, 2=text, 3=trailing WS
3172
 	static #textWhitespaceRegex = /^(\s*)(?:(\S|\S.*\S)(\s*?))?$/; // 1=leading WS, 2=text, 3=trailing WS
3066
 	}
3310
 	}
3067
 }
3311
 }
3068
 
3312
 
3069
-class MDTagModifier {
3070
-	/** @type {string} */
3071
-	original;
3072
-	/** @type {string[]} */
3073
-	cssClasses = [];
3074
-	/** @type {string|null} */
3075
-	cssId = null;
3076
-	/** @type {object} */
3077
-	attributes = {};
3078
-
3079
-	static #baseClassRegex = /\.([a-z_\-][a-z0-9_\-]*?)/i;
3080
-	static #baseIdRegex = /#([a-z_\-][a-z0-9_\-]*?)/i;
3081
-	static #baseAttributeRegex = /([a-z0-9]+?)=([^\s\}]+?)/i;
3082
-	static #baseRegex = /\{([^}]+?)}/i;
3083
-	static #leadingClassRegex = new RegExp('^' + this.#baseRegex.source, 'i');
3084
-	static #trailingClassRegex = new RegExp('^(.*?)\\s*' + this.#baseRegex.source + '\\s*$', 'i');
3085
-	static #classRegex = new RegExp('^' + this.#baseClassRegex.source + '$', 'i');  // 1=classname
3086
-	static #idRegex = new RegExp('^' + this.#baseIdRegex.source + '$', 'i');  // 1=id
3087
-	static #attributeRegex = new RegExp('^' + this.#baseAttributeRegex.source + '$', 'i');  // 1=attribute name, 2=attribute value
3088
-
3089
-	/**
3090
-	 * @param {MDBlock|MDSpan} elem
3091
-	 */
3092
-	applyTo(elem) {
3093
-		if (elem instanceof MDBlock || elem instanceof MDSpan) {
3094
-			elem.cssClasses = elem.cssClasses.concat(this.cssClasses);
3095
-			if (this.cssId) elem.cssId = this.cssId;
3096
-			for (const name in this.attributes) {
3097
-				elem.attributes[name] = this.attributes[name];
3098
-			}
3099
-		}
3100
-	}
3101
-
3102
-	static #fromContents(contents) {
3103
-		let modifierTokens = contents.split(/\s+/);
3104
-		let mod = new MDTagModifier();
3105
-		mod.original = `{${contents}}`;
3106
-		var groups;
3107
-		for (const token of modifierTokens) {
3108
-			if (token.trim() == '') continue;
3109
-			if (groups = this.#classRegex.exec(token)) {
3110
-				mod.cssClasses.push(groups[1]);
3111
-			} else if (groups = this.#idRegex.exec(token)) {
3112
-				mod.cssId = groups[1];
3113
-			} else if (groups = this.#attributeRegex.exec(token)) {
3114
-				mod.attributes[groups[1]] = groups[2];
3115
-			} else {
3116
-				return null;
3117
-			}
3118
-		}
3119
-		return mod;
3120
-	}
3121
-
3122
-	/**
3123
-	 * Extracts modifier from line.
3124
-	 * @param {string} line
3125
-	 * @returns {Array} Tuple with remaining line and MDTagModifier.
3126
-	 */
3127
-	static fromLine(line) {
3128
-		let groups = this.#trailingClassRegex.exec(line);
3129
-		if (groups === null) return [ line, null ];
3130
-		let bareLine = groups[1];
3131
-		let mod = this.#fromContents(groups[2]);
3132
-		return [ bareLine, mod ];
3133
-	}
3134
-
3135
-	/**
3136
-	 * Extracts modifier from head of string.
3137
-	 * @param {string} line
3138
-	 * @returns {MDTagModifier}
3139
-	 */
3140
-	static fromStart(line) {
3141
-		let groups = this.#leadingClassRegex.exec(line);
3142
-		if (groups === null) return null;
3143
-		return this.#fromContents(groups[1]);
3144
-	}
3145
-
3146
-	/**
3147
-	 * @param {string} line
3148
-	 * @returns {string}
3149
-	 */
3150
-	static strip(line) {
3151
-		let groups = this.#trailingClassRegex.exec(line);
3152
-		if (groups === null) return line;
3153
-		return groups[1];
3154
-	}
3155
-}
3156
-
3157
 class Markdown {
3313
 class Markdown {
3158
 	/**
3314
 	/**
3159
 	 * Set of standard block readers.
3315
 	 * Set of standard block readers.

+ 507
- 40
testjs.html Переглянути файл

7
 		<style type="text/css">
7
 		<style type="text/css">
8
 			:root {
8
 			:root {
9
 				font-family: sans-serif;
9
 				font-family: sans-serif;
10
+				--color-passed: #090;
11
+				--color-failed: #a00;
12
+				--color-errored: #a80;
13
+				--color-untested: #888;
10
 			}
14
 			}
11
 			.testclass {
15
 			.testclass {
12
 				border: 1px solid black;
16
 				border: 1px solid black;
19
 				font-size: 1.25rem;
23
 				font-size: 1.25rem;
20
 				padding-bottom: 0.25em;
24
 				padding-bottom: 0.25em;
21
 			}
25
 			}
26
+			.testclassstatus {
27
+
28
+			}
29
+			.testclassstatus.passed { color: var(--color-passed); }
30
+			.testclassstatus.failed { color: var(--color-failed); }
31
+			.testclassstatus.errored { color: var(--color-errored); }
32
+			.testclassstatus.untested { color: var(--color-untested); }
22
 			.testcase {
33
 			.testcase {
34
+				clear: both;
23
 				padding: 0.2em 0;
35
 				padding: 0.2em 0;
24
 				margin-left: 2em;
36
 				margin-left: 2em;
25
 			}
37
 			}
27
 				border-top: 1px solid #888;
39
 				border-top: 1px solid #888;
28
 			}
40
 			}
29
 			.testcasename {
41
 			.testcasename {
30
-				font-size: 115%;
31
-				font-weight: bold;
42
+				font-family: monospace;
32
 			}
43
 			}
33
 			.testcasestatus {
44
 			.testcasestatus {
34
 				font-weight: bold;
45
 				font-weight: bold;
46
+				font-size: 80%;
47
+				float: left;
48
+			}
49
+			.testcasetiming {
50
+				float: right;
51
+				color: #888;
52
+				font-size: 80%;
53
+			}
54
+			.testcaseresult {
55
+				height: 1em;
56
+			}
57
+			.testcasemessage {
58
+				clear: both;
35
 			}
59
 			}
36
 			.result-untested {
60
 			.result-untested {
37
 				color: #888;
61
 				color: #888;
99
 					if (test) this.fail(failMessage || `expected false, got ${test}`);
123
 					if (test) this.fail(failMessage || `expected false, got ${test}`);
100
 				}
124
 				}
101
 				assertEqual(a, b, failMessage=null) {
125
 				assertEqual(a, b, failMessage=null) {
102
-					if (a == b) return;
126
+					if (MDUtils.equal(a, b)) return;
103
 					const aVal = `${a}`;
127
 					const aVal = `${a}`;
104
 					const bVal = `${b}`;
128
 					const bVal = `${b}`;
105
 					if (aVal.length > 20 || bVal.length > 20) {
129
 					if (aVal.length > 20 || bVal.length > 20) {
145
 				/** @var {String|null} */
169
 				/** @var {String|null} */
146
 				message = null;
170
 				message = null;
147
 				expectedError = null;
171
 				expectedError = null;
172
+				duration = null;
148
 				/** @var {String} */
173
 				/** @var {String} */
149
 				get className() { return this.#objectUnderTest.constructor.name; }
174
 				get className() { return this.#objectUnderTest.constructor.name; }
150
 				/** @var {String} */
175
 				/** @var {String} */
159
 					this.uniqueId = TestCaseRunner.#nextUniqueId++;
184
 					this.uniqueId = TestCaseRunner.#nextUniqueId++;
160
 				}
185
 				}
161
 				run() {
186
 				run() {
187
+					var start;
188
+					this.expectedError = null;
189
+					this.#objectUnderTest.currentRunner = this;
162
 					try {
190
 					try {
163
-						this.expectedError = null;
164
-						this.#objectUnderTest.currentRunner = this;
165
 						this.#objectUnderTest.setUp();
191
 						this.#objectUnderTest.setUp();
192
+					} catch (e) {
193
+						console.error(`Failed to run ${this.className}.setUp() - ${e.message}`);
194
+						this.result = ResultType.errored;
195
+						this.message = e.message;
196
+						return;
197
+					}
198
+					try {
199
+						start = performance.now();
166
 						this.#method.bind(this.#objectUnderTest)();
200
 						this.#method.bind(this.#objectUnderTest)();
201
+						this.duration = performance.now() - start;
167
 						this.result = ResultType.passed;
202
 						this.result = ResultType.passed;
168
 						this.message = null;
203
 						this.message = null;
169
 					} catch (e) {
204
 					} catch (e) {
205
+						this.duration = performance.now() - start;
170
 						if (e instanceof FailureError) {
206
 						if (e instanceof FailureError) {
171
 							this.result = ResultType.failed;
207
 							this.result = ResultType.failed;
172
 							this.message = e.message;
208
 							this.message = e.message;
202
 				toHTML() {
238
 				toHTML() {
203
 					var html = `<div class="testcase" id="${this.#cssId}">`;
239
 					var html = `<div class="testcase" id="${this.#cssId}">`;
204
 					html += `<div class="testcasename"><span class="testcasemethod">${this.methodName}</span></div>`;
240
 					html += `<div class="testcasename"><span class="testcasemethod">${this.methodName}</span></div>`;
241
+					html += '<div class="testcaseresult">';
205
 					switch (this.result) {
242
 					switch (this.result) {
206
 						case ResultType.untested:
243
 						case ResultType.untested:
207
 							html += '<div class="testcasestatus result-untested">Waiting to test</div>';
244
 							html += '<div class="testcasestatus result-untested">Waiting to test</div>';
208
 							break;
245
 							break;
209
 						case ResultType.testing:
246
 						case ResultType.testing:
210
-							html += '<div class="testcasestatus result-tesitng">Testing...</div>';
247
+							html += '<div class="testcasestatus result-testing">Testing...</div>';
211
 							break;
248
 							break;
212
 						case ResultType.passed:
249
 						case ResultType.passed:
213
 							html += '<div class="testcasestatus result-passed">Passed</div>';
250
 							html += '<div class="testcasestatus result-passed">Passed</div>';
214
 							break;
251
 							break;
215
 						case ResultType.failed:
252
 						case ResultType.failed:
216
 							html += '<div class="testcasestatus result-failed">Failed</div>';
253
 							html += '<div class="testcasestatus result-failed">Failed</div>';
217
-							html += `<div class="testcasemessage">${escapeHTML(this.message)}</div>`;
218
 							break;
254
 							break;
219
 						case ResultType.errored:
255
 						case ResultType.errored:
220
 							html += '<div class="testcasestatus result-errored">Errored</div>';
256
 							html += '<div class="testcasestatus result-errored">Errored</div>';
221
-							html += `<div class="testcasemessage">${escapeHTML(this.message)}</div>`;
222
 							break;
257
 							break;
223
 					}
258
 					}
259
+					if (this.duration !== null) {
260
+						html += `<div class="testcasetiming">${Number(this.duration / 1000.0).toFixed(3)}s</div>`;
261
+					}
262
+					html += '</div>';
263
+					if (this.message !== null) {
264
+						html += `<div class="testcasemessage">${escapeHTML(this.message)}</div>`;
265
+					}
224
 					html += `</div>`;
266
 					html += `</div>`;
225
 					return html;
267
 					return html;
226
 				}
268
 				}
273
 					this.#uniqueId = TestClassRunner.#nextUniqueId++;
315
 					this.#uniqueId = TestClassRunner.#nextUniqueId++;
274
 				}
316
 				}
275
 				get #cssId() { return `testclass${this.#uniqueId}`; }
317
 				get #cssId() { return `testclass${this.#uniqueId}`; }
318
+				#summaryHTML() {
319
+					var anyTesting = false;
320
+					var anyFailed = false;
321
+					var anyErrored = false;
322
+					var anyUntested = false;
323
+					var anyPassed = false;
324
+					var allPassed = true;
325
+					for (const test of this.testCases) {
326
+						switch (test.result) {
327
+							case ResultType.untested:
328
+								anyUntested = true;
329
+								allPassed = false;
330
+								break;
331
+							case ResultType.testing:
332
+								anyTesting = true;
333
+								allPassed = false;
334
+								break;
335
+							case ResultType.passed:
336
+								anyPassed = true;
337
+								break;
338
+							case ResultType.failed:
339
+								anyFailed = true;
340
+								allPassed = false;
341
+								break;
342
+							case ResultType.errored:
343
+								anyErrored = true;
344
+								allPassed = false;
345
+								break;
346
+						}
347
+					}
348
+					var html = '';
349
+					html += `<summary class="testclasssummary" id="${this.#cssId}summary">`;
350
+					html += `<span class="testclassname">${this.#theClass.name}</span> `;
351
+					if (anyTesting || (anyUntested && (anyPassed || anyFailed || anyErrored))) {
352
+						html += '<span class="testclassstatus testing">Testing...</span>';
353
+					} else if (anyErrored) {
354
+						html += '<span class="testclassstatus errored">Errored</span>';
355
+					} else if (anyFailed) {
356
+						html += '<span class="testclassstatus failed">Failed</span>';
357
+					} else if (allPassed) {
358
+						html += '<span class="testclassstatus passed">Passed</span>';
359
+					}
360
+					html += '</summary>';
361
+					return html;
362
+				}
276
 				toHTML() {
363
 				toHTML() {
277
 					var html = '';
364
 					var html = '';
278
 					html += `<div class="testclass" id="${this.#cssId}">`;
365
 					html += `<div class="testclass" id="${this.#cssId}">`;
279
-					html += `<div class="testclassname">${this.#theClass.name}</div>`;
366
+					html += `<details id="${this.#cssId}details">`;
367
+					html += this.#summaryHTML();
280
 					for (const testCase of this.#testCases) {
368
 					for (const testCase of this.#testCases) {
281
 						html += testCase.toHTML();
369
 						html += testCase.toHTML();
282
 					}
370
 					}
371
+					html += '</details>';
283
 					html += '</div>';
372
 					html += '</div>';
284
 					return html;
373
 					return html;
285
 				}
374
 				}
286
 				updateHTML() {
375
 				updateHTML() {
287
-					var existing = document.getElementById(this.#cssId);
376
+					var existing = document.getElementById(`${this.#cssId}summary`);
288
 					if (!existing) {
377
 					if (!existing) {
289
 						document.getElementById('results').innerHTML += this.toHTML();
378
 						document.getElementById('results').innerHTML += this.toHTML();
379
+					} else {
380
+						existing.outerHTML = this.#summaryHTML();
381
+						var allPassed = true;
382
+						for (const test of this.testCases) {
383
+							if (test.result != ResultType.passed) {
384
+								allPassed = false;
385
+								break;
386
+							}
387
+						}
388
+						document.getElementById(`${this.#cssId}details`).open = !allPassed;
290
 					}
389
 					}
291
 				}
390
 				}
292
 
391
 
325
 			function onLoad() {
424
 			function onLoad() {
326
 				let testClasses = [
425
 				let testClasses = [
327
 					TokenTests,
426
 					TokenTests,
427
+					UtilsTests,
328
 					InlineTests,
428
 					InlineTests,
329
 					BlockTests,
429
 					BlockTests,
330
 				];
430
 				];
358
 						tokens: [ tokens[2] ],
458
 						tokens: [ tokens[2] ],
359
 						index: 2,
459
 						index: 2,
360
 					};
460
 					};
361
-					this.assertEqual(JSON.stringify(result), JSON.stringify(expected));
461
+					this.assertEqual(result, expected);
462
+				}
463
+
464
+				test_findFirstTokens_optionalWhitespace1() {
465
+					const tokens = [
466
+						new MDToken('Lorem', MDTokenType.Text),
467
+						new MDToken(' ', MDTokenType.Whitespace),
468
+						new MDToken('[ipsum]', MDTokenType.Label, 'ipsum'),
469
+						new MDToken('(link.html)', MDTokenType.URL, 'link.html'),
470
+						new MDToken(' ', MDTokenType.Whitespace),
471
+						new MDToken('dolor', MDTokenType.Text),
472
+					];
473
+					const pattern = [
474
+						MDTokenType.Label,
475
+						MDTokenType.META_OptionalWhitespace,
476
+						MDTokenType.URL,
477
+					];
478
+					const result = MDToken.findFirstTokens(tokens, pattern);
479
+					const expected = {
480
+						tokens: [ tokens[2], tokens[3] ],
481
+						index: 2,
482
+					};
483
+					this.assertEqual(result, expected);
484
+				}
485
+
486
+				test_findFirstTokens_optionalWhitespace2() {
487
+					const tokens = [
488
+						new MDToken('Lorem', MDTokenType.Text),
489
+						new MDToken(' ', MDTokenType.Whitespace),
490
+						new MDToken('[ipsum]', MDTokenType.Label, 'ipsum'),
491
+						new MDToken(' ', MDTokenType.Whitespace),
492
+						new MDToken('(link.html)', MDTokenType.URL, 'link.html'),
493
+						new MDToken(' ', MDTokenType.Whitespace),
494
+						new MDToken('dolor', MDTokenType.Text),
495
+					];
496
+					const pattern = [
497
+						MDTokenType.Label,
498
+						MDTokenType.META_OptionalWhitespace,
499
+						MDTokenType.URL,
500
+					];
501
+					const result = MDToken.findFirstTokens(tokens, pattern);
502
+					const expected = {
503
+						tokens: [ tokens[2], tokens[3], tokens[4] ],
504
+						index: 2,
505
+					};
506
+					this.assertEqual(result, expected);
362
 				}
507
 				}
363
 
508
 
364
 				test_findPairedTokens() {
509
 				test_findPairedTokens() {
387
 						endIndex: 4,
532
 						endIndex: 4,
388
 						totalLength: 3,
533
 						totalLength: 3,
389
 					}
534
 					}
390
-					this.assertEqual(JSON.stringify(result), JSON.stringify(expected));
535
+					this.assertEqual(result, expected);
536
+				}
537
+			}
538
+
539
+			class UtilsTests extends BaseTest {
540
+				test_stripIndent() {
541
+					this.assertEqual(MDUtils.stripIndent(''), '');
542
+					this.assertEqual(MDUtils.stripIndent('  '), '');
543
+					this.assertEqual(MDUtils.stripIndent('foo'), 'foo');
544
+					this.assertEqual(MDUtils.stripIndent(' foo'), 'foo');
545
+					this.assertEqual(MDUtils.stripIndent('  foo'), 'foo');
546
+					this.assertEqual(MDUtils.stripIndent('   foo'), 'foo');
547
+					this.assertEqual(MDUtils.stripIndent('    foo'), 'foo');
548
+					this.assertEqual(MDUtils.stripIndent('     foo'), ' foo');
549
+					this.assertEqual(MDUtils.stripIndent('\tfoo'), 'foo');
550
+					this.assertEqual(MDUtils.stripIndent('\t\tfoo'), '\tfoo');
551
+					this.assertEqual(MDUtils.stripIndent('\t\tfoo', 2), 'foo');
552
+					this.assertEqual(MDUtils.stripIndent('      foo', 2), 'foo');
553
+				}
554
+
555
+				test_countIndents() {
556
+					this.assertEqual(MDUtils.countIndents(''), 0);
557
+					this.assertEqual(MDUtils.countIndents('  '), 1);
558
+					this.assertEqual(MDUtils.countIndents('    '), 1);
559
+					this.assertEqual(MDUtils.countIndents('foo'), 0);
560
+					this.assertEqual(MDUtils.countIndents('foo'), 0);
561
+					this.assertEqual(MDUtils.countIndents(' foo'), 1);
562
+					this.assertEqual(MDUtils.countIndents('  foo'), 1);
563
+					this.assertEqual(MDUtils.countIndents('   foo'), 1);
564
+					this.assertEqual(MDUtils.countIndents('    foo'), 1);
565
+					this.assertEqual(MDUtils.countIndents('     foo'), 2);
566
+					this.assertEqual(MDUtils.countIndents('\tfoo'), 1);
567
+					this.assertEqual(MDUtils.countIndents('\t\tfoo'), 2);
568
+
569
+					this.assertEqual(MDUtils.countIndents('', true), 0);
570
+					this.assertEqual(MDUtils.countIndents('  ', true), 0);
571
+					this.assertEqual(MDUtils.countIndents('    ', true), 1);
572
+					this.assertEqual(MDUtils.countIndents('foo', true), 0);
573
+					this.assertEqual(MDUtils.countIndents(' foo', true), 0);
574
+					this.assertEqual(MDUtils.countIndents('  foo', true), 0);
575
+					this.assertEqual(MDUtils.countIndents('   foo', true), 0);
576
+					this.assertEqual(MDUtils.countIndents('    foo', true), 1);
577
+					this.assertEqual(MDUtils.countIndents('     foo', true), 1);
578
+					this.assertEqual(MDUtils.countIndents('\tfoo', true), 1);
579
+					this.assertEqual(MDUtils.countIndents('\t\tfoo', true), 2);
580
+				}
581
+
582
+				test_tokenizeLabel() {
583
+					// Escapes are preserved
584
+					this.assertEqual(MDUtils.tokenizeLabel('[foo] bar'), [ '[foo]', 'foo' ]);
585
+					this.assertEqual(MDUtils.tokenizeLabel('[foo\\[] bar'), [ '[foo\\[]', 'foo\\[' ]);
586
+					this.assertEqual(MDUtils.tokenizeLabel('[foo\\]] bar'), [ '[foo\\]]', 'foo\\]' ]);
587
+					this.assertEqual(MDUtils.tokenizeLabel('[foo[]] bar'), [ '[foo[]]', 'foo[]' ]);
588
+					this.assertEqual(MDUtils.tokenizeLabel('[foo\\(] bar'), [ '[foo\\(]', 'foo\\(' ]);
589
+					this.assertEqual(MDUtils.tokenizeLabel('[foo\\)] bar'), [ '[foo\\)]', 'foo\\)' ]);
590
+					this.assertEqual(MDUtils.tokenizeLabel('[foo()] bar'), [ '[foo()]', 'foo()' ]);
591
+
592
+					this.assertEqual(MDUtils.tokenizeLabel('foo bar'), null);
593
+					this.assertEqual(MDUtils.tokenizeLabel('[foo\\] bar'), null);
594
+					this.assertEqual(MDUtils.tokenizeLabel('[foo bar'), null);
595
+					this.assertEqual(MDUtils.tokenizeLabel('[foo[] bar'), null);
596
+				}
597
+
598
+				test_tokenizeURL() {
599
+					this.assertEqual(MDUtils.tokenizeURL('(page.html) foo'), [ '(page.html)', 'page.html', null ]);
600
+					this.assertEqual(MDUtils.tokenizeURL('(page.html "link title") foo'), [ '(page.html "link title")', 'page.html', 'link title' ]);
601
+					this.assertEqual(MDUtils.tokenizeURL('(https://example.com/path/page.html?query=foo&bar=baz#fragment) foo'), [ '(https://example.com/path/page.html?query=foo&bar=baz#fragment)', 'https://example.com/path/page.html?query=foo&bar=baz#fragment', null ]);
602
+
603
+					this.assertEqual(MDUtils.tokenizeURL('page.html foo'), null);
604
+					this.assertEqual(MDUtils.tokenizeURL('(page.html foo'), null);
605
+					this.assertEqual(MDUtils.tokenizeURL('page.html) foo'), null);
606
+					this.assertEqual(MDUtils.tokenizeURL('(page.html "title) foo'), null);
607
+					this.assertEqual(MDUtils.tokenizeURL('(page .html) foo'), null);
608
+					this.assertEqual(MDUtils.tokenizeURL('(user@example.com) foo'), null);
609
+					this.assertEqual(MDUtils.tokenizeURL('(user@example.com "title") foo'), null);
610
+				}
611
+
612
+				test_tokenizeEmail() {
613
+					this.assertEqual(MDUtils.tokenizeEmail('(user@example.com)'), [ '(user@example.com)', 'user@example.com', null ]);
614
+					this.assertEqual(MDUtils.tokenizeEmail('(user@example.com "link title")'), [ '(user@example.com "link title")', 'user@example.com', 'link title' ]);
615
+
616
+					this.assertEqual(MDUtils.tokenizeEmail('(https://example.com) foo'), null);
617
+					this.assertEqual(MDUtils.tokenizeEmail('(https://example.com "link title") foo'), null);
618
+					this.assertEqual(MDUtils.tokenizeEmail('(user@example.com "link title) foo'), null);
619
+					this.assertEqual(MDUtils.tokenizeEmail('(user@example.com foo'), null);
620
+					this.assertEqual(MDUtils.tokenizeEmail('user@example.com) foo'), null);
391
 				}
621
 				}
392
 			}
622
 			}
393
 
623
 
402
 					this.parser = Markdown.completeParser;
632
 					this.parser = Markdown.completeParser;
403
 				}
633
 				}
404
 
634
 
405
-				test_simpleSingleParagraph() {
635
+				test_simpleText() {
406
 					let markdown = 'Lorem ipsum';
636
 					let markdown = 'Lorem ipsum';
407
-					let expected = '<p>Lorem ipsum</p>';
637
+					let expected = 'Lorem ipsum';
408
 					let actual = this.md(markdown);
638
 					let actual = this.md(markdown);
409
 					this.assertEqual(actual, expected);
639
 					this.assertEqual(actual, expected);
410
 				}
640
 				}
411
 
641
 
412
 				test_strong() {
642
 				test_strong() {
413
 					let markdown = 'Lorem **ipsum** dolor **sit**';
643
 					let markdown = 'Lorem **ipsum** dolor **sit**';
414
-					let expected = '<p>Lorem <strong>ipsum</strong> dolor <strong>sit</strong></p>';
644
+					let expected = 'Lorem <strong>ipsum</strong> dolor <strong>sit</strong>';
415
 					let actual = this.md(markdown);
645
 					let actual = this.md(markdown);
416
 					this.assertEqual(actual, expected);
646
 					this.assertEqual(actual, expected);
417
 				}
647
 				}
418
 
648
 
419
 				test_emphasis() {
649
 				test_emphasis() {
420
 					let markdown = 'Lorem _ipsum_ dolor _sit_';
650
 					let markdown = 'Lorem _ipsum_ dolor _sit_';
421
-					let expected = '<p>Lorem <em>ipsum</em> dolor <em>sit</em></p>';
651
+					let expected = 'Lorem <em>ipsum</em> dolor <em>sit</em>';
422
 					let actual = this.md(markdown);
652
 					let actual = this.md(markdown);
423
 					this.assertEqual(actual, expected);
653
 					this.assertEqual(actual, expected);
424
 				}
654
 				}
425
 
655
 
426
 				test_strongEmphasis_cleanNesting1() {
656
 				test_strongEmphasis_cleanNesting1() {
427
 					let markdown = 'Lorem **ipsum *dolor* sit** amet';
657
 					let markdown = 'Lorem **ipsum *dolor* sit** amet';
428
-					let expected = '<p>Lorem <strong>ipsum <em>dolor</em> sit</strong> amet</p>';
658
+					let expected = 'Lorem <strong>ipsum <em>dolor</em> sit</strong> amet';
429
 					let actual = this.md(markdown);
659
 					let actual = this.md(markdown);
430
 					this.assertEqual(actual, expected);
660
 					this.assertEqual(actual, expected);
431
 				}
661
 				}
432
 
662
 
433
 				test_strongEmphasis_cleanNesting2() {
663
 				test_strongEmphasis_cleanNesting2() {
434
 					let markdown = 'Lorem *ipsum **dolor** sit* amet';
664
 					let markdown = 'Lorem *ipsum **dolor** sit* amet';
435
-					let expected = '<p>Lorem <em>ipsum <strong>dolor</strong> sit</em> amet</p>';
665
+					let expected = 'Lorem <em>ipsum <strong>dolor</strong> sit</em> amet';
436
 					let actual = this.md(markdown);
666
 					let actual = this.md(markdown);
437
 					this.assertEqual(actual, expected);
667
 					this.assertEqual(actual, expected);
438
 				}
668
 				}
439
 
669
 
440
 				test_strongEmphasis_tightNesting() {
670
 				test_strongEmphasis_tightNesting() {
441
 					let markdown = 'Lorem ***ipsum*** dolor';
671
 					let markdown = 'Lorem ***ipsum*** dolor';
442
-					let expected1 = '<p>Lorem <strong><em>ipsum</em></strong> dolor</p>';
443
-					let expected2 = '<p>Lorem <em><strong>ipsum</strong></em> dolor</p>';
672
+					let expected1 = 'Lorem <strong><em>ipsum</em></strong> dolor';
673
+					let expected2 = 'Lorem <em><strong>ipsum</strong></em> dolor';
444
 					let actual = this.md(markdown);
674
 					let actual = this.md(markdown);
445
 					this.assertTrue(actual == expected1 || actual == expected2);
675
 					this.assertTrue(actual == expected1 || actual == expected2);
446
 				}
676
 				}
447
 
677
 
448
 				test_strongEmphasis_lopsidedNesting1() {
678
 				test_strongEmphasis_lopsidedNesting1() {
449
 					let markdown = 'Lorem ***ipsum* dolor** sit';
679
 					let markdown = 'Lorem ***ipsum* dolor** sit';
450
-					let expected = '<p>Lorem <strong><em>ipsum</em> dolor</strong> sit</p>';
680
+					let expected = 'Lorem <strong><em>ipsum</em> dolor</strong> sit';
451
 					let actual = this.md(markdown);
681
 					let actual = this.md(markdown);
452
 					this.assertEqual(actual, expected);
682
 					this.assertEqual(actual, expected);
453
 				}
683
 				}
454
 
684
 
455
 				test_strongEmphasis_lopsidedNesting2() {
685
 				test_strongEmphasis_lopsidedNesting2() {
456
 					let markdown = 'Lorem ***ipsum** dolor* sit';
686
 					let markdown = 'Lorem ***ipsum** dolor* sit';
457
-					let expected = '<p>Lorem <em><strong>ipsum</strong> dolor</em> sit</p>';
687
+					let expected = 'Lorem <em><strong>ipsum</strong> dolor</em> sit';
458
 					let actual = this.md(markdown);
688
 					let actual = this.md(markdown);
459
 					this.assertEqual(actual, expected);
689
 					this.assertEqual(actual, expected);
460
 				}
690
 				}
461
 
691
 
462
 				test_strongEmphasis_lopsidedNesting3() {
692
 				test_strongEmphasis_lopsidedNesting3() {
463
 					let markdown = 'Lorem **ipsum *dolor*** sit';
693
 					let markdown = 'Lorem **ipsum *dolor*** sit';
464
-					let expected = '<p>Lorem <strong>ipsum <em>dolor</em></strong> sit</p>';
694
+					let expected = 'Lorem <strong>ipsum <em>dolor</em></strong> sit';
465
 					let actual = this.md(markdown);
695
 					let actual = this.md(markdown);
466
 					this.assertEqual(actual, expected);
696
 					this.assertEqual(actual, expected);
467
 				}
697
 				}
468
 
698
 
469
 				test_strongEmphasis_lopsidedNesting4() {
699
 				test_strongEmphasis_lopsidedNesting4() {
470
 					let markdown = 'Lorem *ipsum **dolor*** sit';
700
 					let markdown = 'Lorem *ipsum **dolor*** sit';
471
-					let expected = '<p>Lorem <em>ipsum <strong>dolor</strong></em> sit</p>';
701
+					let expected = 'Lorem <em>ipsum <strong>dolor</strong></em> sit';
472
 					let actual = this.md(markdown);
702
 					let actual = this.md(markdown);
473
 					this.assertEqual(actual, expected);
703
 					this.assertEqual(actual, expected);
474
 				}
704
 				}
475
 
705
 
476
 				test_inlineCode() {
706
 				test_inlineCode() {
477
 					let markdown = 'Lorem `ipsum` dolor';
707
 					let markdown = 'Lorem `ipsum` dolor';
478
-					let expected = '<p>Lorem <code>ipsum</code> dolor</p>';
708
+					let expected = 'Lorem <code>ipsum</code> dolor';
479
 					let actual = this.md(markdown);
709
 					let actual = this.md(markdown);
480
 					this.assertEqual(actual, expected);
710
 					this.assertEqual(actual, expected);
481
 				}
711
 				}
482
 
712
 
483
 				test_inlineCode_withInnerBacktick() {
713
 				test_inlineCode_withInnerBacktick() {
484
 					let markdown = 'Lorem ``ip`su`m`` dolor';
714
 					let markdown = 'Lorem ``ip`su`m`` dolor';
485
-					let expected = '<p>Lorem <code>ip`su`m</code> dolor</p>';
715
+					let expected = 'Lorem <code>ip`su`m</code> dolor';
486
 					let actual = this.md(markdown);
716
 					let actual = this.md(markdown);
487
 					this.assertEqual(actual, expected);
717
 					this.assertEqual(actual, expected);
488
 				}
718
 				}
489
 
719
 
490
 				test_strikethrough_single() {
720
 				test_strikethrough_single() {
491
 					let markdown = 'Lorem ~ipsum~ dolor';
721
 					let markdown = 'Lorem ~ipsum~ dolor';
492
-					let expected = '<p>Lorem <strike>ipsum</strike> dolor</p>';
722
+					let expected = 'Lorem <strike>ipsum</strike> dolor';
493
 					let actual = this.md(markdown);
723
 					let actual = this.md(markdown);
494
 					this.assertEqual(actual, expected);
724
 					this.assertEqual(actual, expected);
495
 				}
725
 				}
496
 
726
 
497
 				test_strikethrough_double() {
727
 				test_strikethrough_double() {
498
 					let markdown = 'Lorem ~~ipsum~~ dolor';
728
 					let markdown = 'Lorem ~~ipsum~~ dolor';
499
-					let expected = '<p>Lorem <strike>ipsum</strike> dolor</p>';
729
+					let expected = 'Lorem <strike>ipsum</strike> dolor';
500
 					let actual = this.md(markdown);
730
 					let actual = this.md(markdown);
501
 					this.assertEqual(actual, expected);
731
 					this.assertEqual(actual, expected);
502
 				}
732
 				}
503
 
733
 
504
 				test_link_fullyQualified() {
734
 				test_link_fullyQualified() {
505
 					let markdown = 'Lorem [ipsum](https://example.com/path/page.html) dolor';
735
 					let markdown = 'Lorem [ipsum](https://example.com/path/page.html) dolor';
506
-					let expected = '<p>Lorem <a href="https://example.com/path/page.html">ipsum</a> dolor</p>';
736
+					let expected = 'Lorem <a href="https://example.com/path/page.html">ipsum</a> dolor';
507
 					let actual = this.md(markdown);
737
 					let actual = this.md(markdown);
508
 					this.assertEqual(actual, expected);
738
 					this.assertEqual(actual, expected);
509
 				}
739
 				}
510
 
740
 
511
 				test_link_relative() {
741
 				test_link_relative() {
512
 					let markdown = 'Lorem [ipsum](page.html) dolor';
742
 					let markdown = 'Lorem [ipsum](page.html) dolor';
513
-					let expected = '<p>Lorem <a href="page.html">ipsum</a> dolor</p>';
743
+					let expected = 'Lorem <a href="page.html">ipsum</a> dolor';
514
 					let actual = this.md(markdown);
744
 					let actual = this.md(markdown);
515
 					this.assertEqual(actual, expected);
745
 					this.assertEqual(actual, expected);
516
 				}
746
 				}
517
 
747
 
518
 				test_link_title() {
748
 				test_link_title() {
519
 					let markdown = 'Lorem [ipsum](page.html "link title") dolor';
749
 					let markdown = 'Lorem [ipsum](page.html "link title") dolor';
520
-					let expected = '<p>Lorem <a href="page.html" title="link title">ipsum</a> dolor</p>';
750
+					let expected = 'Lorem <a href="page.html" title="link title">ipsum</a> dolor';
521
 					let actual = this.md(markdown);
751
 					let actual = this.md(markdown);
522
 					this.assertEqual(actual, expected);
752
 					this.assertEqual(actual, expected);
523
 				}
753
 				}
524
 
754
 
525
 				test_link_literal() {
755
 				test_link_literal() {
526
 					let markdown = 'Lorem <https://example.com> dolor';
756
 					let markdown = 'Lorem <https://example.com> dolor';
527
-					let expected = '<p>Lorem <a href="https://example.com">https://example.com</a> dolor</p>';
757
+					let expected = 'Lorem <a href="https://example.com">https://example.com</a> dolor';
528
 					let actual = this.md(markdown);
758
 					let actual = this.md(markdown);
529
 					this.assertEqual(actual, expected);
759
 					this.assertEqual(actual, expected);
530
 				}
760
 				}
538
 
768
 
539
 				test_link_email() {
769
 				test_link_email() {
540
 					let markdown = 'Lorem [ipsum](user@example.com) dolor';
770
 					let markdown = 'Lorem [ipsum](user@example.com) dolor';
541
-					let expected = '<p>Lorem <a href="mailto:&#117;&#115;&#101;&#114;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;">ipsum</a> dolor</p>';
771
+					let expected = 'Lorem <a href="mailto:&#117;&#115;&#101;&#114;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;">ipsum</a> dolor';
542
 					let actual = this.md(markdown);
772
 					let actual = this.md(markdown);
543
 					this.assertEqual(actual, expected);
773
 					this.assertEqual(actual, expected);
544
 				}
774
 				}
545
 
775
 
546
 				test_link_email_withTitle() {
776
 				test_link_email_withTitle() {
547
 					let markdown = 'Lorem [ipsum](user@example.com "title") dolor';
777
 					let markdown = 'Lorem [ipsum](user@example.com "title") dolor';
548
-					let expected = '<p>Lorem <a href="mailto:&#117;&#115;&#101;&#114;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;" title="title">ipsum</a> dolor</p>';
778
+					let expected = 'Lorem <a href="mailto:&#117;&#115;&#101;&#114;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;" title="title">ipsum</a> dolor';
549
 					let actual = this.md(markdown);
779
 					let actual = this.md(markdown);
550
 					this.assertEqual(actual, expected);
780
 					this.assertEqual(actual, expected);
551
 				}
781
 				}
552
 
782
 
553
 				test_link_literalEmail() {
783
 				test_link_literalEmail() {
554
 					let markdown = 'Lorem <user@example.com> dolor';
784
 					let markdown = 'Lorem <user@example.com> dolor';
555
-					let expected = '<p>Lorem <a href="mailto:&#117;&#115;&#101;&#114;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;">&#117;&#115;&#101;&#114;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;</a> dolor</p>';
785
+					let expected = 'Lorem <a href="mailto:&#117;&#115;&#101;&#114;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;">&#117;&#115;&#101;&#114;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;</a> dolor';
786
+					let actual = this.md(markdown);
787
+					this.assertEqual(actual, expected);
788
+				}
789
+
790
+				test_link_image() {
791
+					let markdown = 'Lorem [![alt](image.jpg)](page.html) ipsum';
792
+					let expected = 'Lorem <a href="page.html"><img src="image.jpg" alt="alt"></a> ipsum';
793
+					let actual = this.md(markdown);
794
+					this.assertEqual(actual, expected);
795
+				}
796
+
797
+				test_link_image_complex() {
798
+					let markdown = 'Lorem [![alt] (image.jpg "image title")] (page.html "link title") ipsum';
799
+					let expected = 'Lorem <a href="page.html" title="link title"><img src="image.jpg" alt="alt" title="image title"></a> ipsum';
556
 					let actual = this.md(markdown);
800
 					let actual = this.md(markdown);
557
 					this.assertEqual(actual, expected);
801
 					this.assertEqual(actual, expected);
558
 				}
802
 				}
559
 
803
 
560
 				test_image() {
804
 				test_image() {
561
 					let markdown = 'Lorem ![alt text](image.jpg) dolor';
805
 					let markdown = 'Lorem ![alt text](image.jpg) dolor';
562
-					let expected = '<p>Lorem <img src="image.jpg" alt="alt text"> dolor</p>';
806
+					let expected = 'Lorem <img src="image.jpg" alt="alt text"> dolor';
563
 					let actual = this.md(markdown);
807
 					let actual = this.md(markdown);
564
 					this.assertEqual(actual, expected);
808
 					this.assertEqual(actual, expected);
565
 				}
809
 				}
566
 
810
 
567
 				test_image_noAlt() {
811
 				test_image_noAlt() {
568
 					let markdown = 'Lorem ![](image.jpg) dolor';
812
 					let markdown = 'Lorem ![](image.jpg) dolor';
569
-					let expected = '<p>Lorem <img src="image.jpg"> dolor</p>';
813
+					let expected = 'Lorem <img src="image.jpg"> dolor';
570
 					let actual = this.md(markdown);
814
 					let actual = this.md(markdown);
571
 					this.assertEqual(actual, expected);
815
 					this.assertEqual(actual, expected);
572
 				}
816
 				}
573
 
817
 
574
 				test_image_withTitle() {
818
 				test_image_withTitle() {
575
 					let markdown = 'Lorem ![alt text](image.jpg "image title") dolor';
819
 					let markdown = 'Lorem ![alt text](image.jpg "image title") dolor';
820
+					let expected = 'Lorem <img src="image.jpg" alt="alt text" title="image title"> dolor';
821
+					let actual = this.md(markdown);
822
+					this.assertEqual(actual, expected);
823
+				}
824
+
825
+				test_image_ref() {
826
+					let markdown = 'Lorem ![alt text][ref] dolor\n\n' +
827
+						'[ref]: image.jpg "image title"';
576
 					let expected = '<p>Lorem <img src="image.jpg" alt="alt text" title="image title"> dolor</p>';
828
 					let expected = '<p>Lorem <img src="image.jpg" alt="alt text" title="image title"> dolor</p>';
577
 					let actual = this.md(markdown);
829
 					let actual = this.md(markdown);
578
 					this.assertEqual(actual, expected);
830
 					this.assertEqual(actual, expected);
579
 				}
831
 				}
832
+
833
+				test_htmlTags() {
834
+					let markdown = 'Lorem <strong title="value" foo=\'with " quote\' bar="with \' apostrophe" attr=unquoted checked>ipsum</strong> dolor';
835
+					let expected = markdown;
836
+					let actual = this.md(markdown);
837
+					this.assertEqual(actual, expected);
838
+				}
580
 			}
839
 			}
581
 
840
 
582
 			class BlockTests extends BaseTest {
841
 			class BlockTests extends BaseTest {
599
 
858
 
600
 				test_paragraph_lineGrouping() {
859
 				test_paragraph_lineGrouping() {
601
 					let markdown = "Lorem ipsum\ndolor sit amet";
860
 					let markdown = "Lorem ipsum\ndolor sit amet";
602
-					let expected = "<p>Lorem ipsum dolor sit amet</p>";
861
+					let expected = "Lorem ipsum dolor sit amet";
862
+					let actual = this.md(markdown);
863
+					this.assertEqual(actual, expected);
864
+				}
865
+
866
+				test_header_underlineH1() {
867
+					let markdown = "Header 1\n===\n\nLorem ipsum";
868
+					let expected = "<h1>Header 1</h1> <p>Lorem ipsum</p>";
869
+					let actual = this.md(markdown);
870
+					this.assertEqual(actual, expected);
871
+				}
872
+
873
+				test_header_underlineH2() {
874
+					let markdown = "Header 2\n---\n\nLorem ipsum";
875
+					let expected = "<h2>Header 2</h2> <p>Lorem ipsum</p>";
876
+					let actual = this.md(markdown);
877
+					this.assertEqual(actual, expected);
878
+				}
879
+
880
+				test_header_hash() {
881
+					let markdown = "# Header 1\n## Header 2\n### Header 3\n#### Header 4\n##### Header 5\n###### Header 6\n";
882
+					let expected = '<h1>Header 1</h1> <h2>Header 2</h2> <h3>Header 3</h3> <h4>Header 4</h4> <h5>Header 5</h5> <h6>Header 6</h6>';
883
+					let actual = this.md(markdown);
884
+					this.assertEqual(actual, expected);
885
+				}
886
+
887
+				test_header_hash_trailing() {
888
+					let markdown = "# Header 1 #\n## Header 2 ##\n### Header 3 ######";
889
+					let expected = '<h1>Header 1</h1> <h2>Header 2</h2> <h3>Header 3</h3>';
603
 					let actual = this.md(markdown);
890
 					let actual = this.md(markdown);
604
 					this.assertEqual(actual, expected);
891
 					this.assertEqual(actual, expected);
605
 				}
892
 				}
611
 					this.assertEqual(actual, expected);
898
 					this.assertEqual(actual, expected);
612
 				}
899
 				}
613
 
900
 
901
+				test_unorderedList_nested() {
902
+					let markdown = "* Lorem\n + Ipsum\n* Dolor";
903
+					let expected = '<ul> <li>Lorem <ul> <li>Ipsum</li> </ul></li> <li>Dolor</li> </ul>';
904
+					let actual = this.md(markdown);
905
+					this.assertEqual(actual, expected);
906
+				}
907
+
614
 				test_orderedList() {
908
 				test_orderedList() {
615
 					let markdown = "1. Lorem\n1. Ipsum\n5. Dolor";
909
 					let markdown = "1. Lorem\n1. Ipsum\n5. Dolor";
616
-					let expected = '<ol start="1"> <li>Lorem</li> <li>Ipsum</li> <li>Dolor</li> </ol>';
910
+					let expected = '<ol> <li>Lorem</li> <li>Ipsum</li> <li>Dolor</li> </ol>';
617
 					let actual = this.md(markdown);
911
 					let actual = this.md(markdown);
618
 					this.assertEqual(actual, expected);
912
 					this.assertEqual(actual, expected);
619
 				}
913
 				}
625
 					this.assertEqual(actual, expected);
919
 					this.assertEqual(actual, expected);
626
 				}
920
 				}
627
 
921
 
922
+				test_orderedList_nested1() {
923
+					let markdown = "1. Lorem\n 1. Ipsum\n1. Dolor";
924
+					let expected = '<ol> <li>Lorem <ol> <li>Ipsum</li> </ol></li> <li>Dolor</li> </ol>';
925
+					let actual = this.md(markdown);
926
+					this.assertEqual(actual, expected);
927
+				}
928
+
929
+				test_orderedList_nested2() {
930
+					let markdown = "1. Lorem\n 1. Ipsum\n      1. Dolor\n 1. Sit\n1. Amet";
931
+					let expected = '<ol> <li>Lorem <ol> <li>Ipsum <ol> <li>Dolor</li> </ol></li> <li>Sit</li> </ol></li> <li>Amet</li> </ol>';
932
+					let actual = this.md(markdown);
933
+					this.assertEqual(actual, expected);
934
+				}
935
+
628
 				test_blockquote() {
936
 				test_blockquote() {
629
 					let markdown = '> Lorem ipsum dolor';
937
 					let markdown = '> Lorem ipsum dolor';
630
-					let expected = '<blockquote> <p>Lorem ipsum dolor</p> </blockquote>';
938
+					let expected = '<blockquote> Lorem ipsum dolor </blockquote>';
939
+					let actual = this.md(markdown);
940
+					this.assertEqual(actual, expected);
941
+				}
942
+
943
+				test_blockquote_paragraphs() {
944
+					let markdown = '> Lorem ipsum dolor\n>\n>Sit amet';
945
+					let expected = '<blockquote> <p>Lorem ipsum dolor</p> <p>Sit amet</p> </blockquote>';
946
+					let actual = this.md(markdown);
947
+					this.assertEqual(actual, expected);
948
+				}
949
+
950
+				test_blockquote_list() {
951
+					let markdown = '> 1. Lorem\n> 2. Ipsum';
952
+					let expected = '<blockquote> <ol> <li>Lorem</li> <li>Ipsum</li> </ol> </blockquote>';
953
+					let actual = this.md(markdown);
954
+					this.assertEqual(actual, expected);
955
+				}
956
+
957
+				test_codeBlock_indented() {
958
+					let markdown = "Code\n\n    function foo() {\n        return 'bar';\n    }\n\nend";
959
+					let expected = "<p>Code</p>\n\n<pre><code>function foo() {\n    return 'bar';\n}</code></pre>\n<p>end</p>\n";
960
+					let actual = this.parser.toHTML(markdown); // don't normalize whitespace
961
+					this.assertEqual(actual.replace(/ /g, '⎵'), expected.replace(/ /g, '⎵'));
962
+				}
963
+
964
+				test_codeBlock_fenced() {
965
+					let markdown = "Code\n\n```\nfunction foo() {\n    return 'bar';\n}\n```\n\nend";
966
+					let expected = "<p>Code</p>\n\n<pre><code>function foo() {\n    return 'bar';\n}</code></pre>\n<p>end</p>\n";
967
+					let actual = this.parser.toHTML(markdown); // don't normalize whitespace
968
+					this.assertEqual(actual.replace(/ /g, '⎵'), expected.replace(/ /g, '⎵'));
969
+				}
970
+
971
+				test_horizontalRule() {
972
+					let markdown = "Before\n\n---\n\n- - -\n\n***\n\n* * * * * * *\n\nafter";
973
+					let expected = "<p>Before</p> <hr> <hr> <hr> <hr> <p>after</p>";
974
+					let actual = this.md(markdown);
975
+					this.assertEqual(actual, expected);
976
+				}
977
+
978
+				test_table_unfenced() {
979
+					let markdown = "Column A | Column B | Column C\n--- | --- | ---\n1 | 2 | 3\n4 | 5 | 6";
980
+					let expected = "<table> <thead> <tr> <th>Column A</th> <th>Column B</th> <th>Column C</th> </tr> </thead> <tbody> <tr> <td>1</td> <td>2</td> <td>3</td> </tr> <tr> <td>4</td> <td>5</td> <td>6</td> </tr> </tbody> </table>";
981
+					let actual = this.md(markdown);
982
+					this.assertEqual(actual, expected);
983
+				}
984
+
985
+				test_table_fenced() {
986
+					let markdown = "| Column A | Column B | Column C |\n| --- | --- | --- |\n| 1 | 2 | 3\n4 | 5 | 6 |";
987
+					let expected = "<table> <thead> <tr> <th>Column A</th> <th>Column B</th> <th>Column C</th> </tr> </thead> <tbody> <tr> <td>1</td> <td>2</td> <td>3</td> </tr> <tr> <td>4</td> <td>5</td> <td>6</td> </tr> </tbody> </table>";
988
+					let actual = this.md(markdown);
989
+					this.assertEqual(actual, expected);
990
+				}
991
+
992
+				test_table_alignment() {
993
+					let markdown = 'Column A | Column B | Column C\n' +
994
+						':--- | :---: | ---:\n' +
995
+						'1 | 2 | 3\n' +
996
+						'4 | 5 | 6';
997
+					let expected = '<table> ' +
998
+						'<thead> ' +
999
+						'<tr> ' +
1000
+						'<th align="left">Column A</th> ' +
1001
+						'<th align="center">Column B</th> ' +
1002
+						'<th align="right">Column C</th> ' +
1003
+						'</tr> ' +
1004
+						'</thead> ' +
1005
+						'<tbody> ' +
1006
+						'<tr> ' +
1007
+						'<td align="left">1</td> ' +
1008
+						'<td align="center">2</td> ' +
1009
+						'<td align="right">3</td> ' +
1010
+						'</tr> ' +
1011
+						'<tr> ' +
1012
+						'<td align="left">4</td> ' +
1013
+						'<td align="center">5</td> ' +
1014
+						'<td align="right">6</td> ' +
1015
+						'</tr> ' +
1016
+						'</tbody> ' +
1017
+						'</table>';
1018
+					let actual = this.md(markdown);
1019
+					this.assertEqual(actual, expected);
1020
+				}
1021
+
1022
+				test_table_holes() {
1023
+					let markdown = 'Column A||Column C\n' +
1024
+						'---|---|---\n' +
1025
+						'|1|2||\n' +
1026
+						'|4||6|\n' +
1027
+						'||8|9|';
1028
+					let expected = '<table> ' +
1029
+						'<thead> ' +
1030
+						'<tr> ' +
1031
+						'<th>Column A</th> ' +
1032
+						'<th></th> ' +
1033
+						'<th>Column C</th> ' +
1034
+						'</tr> ' +
1035
+						'</thead> ' +
1036
+						'<tbody> ' +
1037
+						'<tr> ' +
1038
+						'<td>1</td> ' +
1039
+						'<td>2</td> ' +
1040
+						'<td></td> ' +
1041
+						'</tr> ' +
1042
+						'<tr> ' +
1043
+						'<td>4</td> ' +
1044
+						'<td></td> ' +
1045
+						'<td>6</td> ' +
1046
+						'</tr> ' +
1047
+						'<tr> ' +
1048
+						'<td></td> ' +
1049
+						'<td>8</td> ' +
1050
+						'<td>9</td> ' +
1051
+						'</tr> ' +
1052
+						'</tbody> ' +
1053
+						'</table>';
1054
+					let actual = this.md(markdown);
1055
+					this.assertEqual(actual, expected);
1056
+				}
1057
+
1058
+				test_definitionList() {
1059
+					let markdown = 'term\n' +
1060
+						': definition\n' +
1061
+						'another' +
1062
+						' term\n' +
1063
+						': def 1\n' +
1064
+						' broken on next line\n' +
1065
+						': def 2';
1066
+					let expected = '<dl> ' +
1067
+						'<dt>term</dt> ' +
1068
+						'<dd>definition</dd> ' +
1069
+						'<dt>another term</dt> ' +
1070
+						'<dd>def 1 broken on next line</dd> ' +
1071
+						'<dd>def 2</dd> ' +
1072
+						'</dl>';
1073
+					let actual = this.md(markdown);
1074
+					this.assertEqual(actual, expected);
1075
+				}
1076
+
1077
+				test_footnotes() {
1078
+					let markdown = 'Lorem ipsum[^1] dolor[^2] sit[^1] amet\n\n[^1]: A footnote\n[^2]: Another footnote';
1079
+					let expected = '<p>Lorem ipsum<sup id="footnoteref_1"><a href="#footnote_1">1</a></sup> ' +
1080
+						'dolor<sup id="footnoteref_2"><a href="#footnote_2">2</a></sup> ' +
1081
+						'sit<sup id="footnoteref_3"><a href="#footnote_1">1</a></sup> amet</p> ' +
1082
+						'<div class="footnotes">' +
1083
+						'<hr/>' +
1084
+						'<ol>' +
1085
+						'<li value="1" id="footnote_1">A footnote <a href="#footnoteref_1" class="footnote-backref">↩︎</a> <a href="#footnoteref_3" class="footnote-backref">↩︎</a></li> ' +
1086
+						'<li value="2" id="footnote_2">Another footnote <a href="#footnoteref_2" class="footnote-backref">↩︎</a></li> ' +
1087
+						'</ol>' +
1088
+						'</div>';
1089
+					let actual = this.md(markdown);
1090
+					this.assertEqual(actual, expected);
1091
+				}
1092
+
1093
+				test_abbreviations() {
1094
+					let markdown = 'Lorem ipsum HTML dolor HTML sit\n' +
1095
+						'\n' +
1096
+						'*[HTML]: Hypertext Markup Language';
1097
+					let expected = '<p>Lorem ipsum <abbr title="Hypertext Markup Language">HTML</abbr> dolor <abbr title="Hypertext Markup Language">HTML</abbr> sit</p>';
631
 					let actual = this.md(markdown);
1098
 					let actual = this.md(markdown);
632
 					this.assertEqual(actual, expected);
1099
 					this.assertEqual(actual, expected);
633
 				}
1100
 				}

Завантаження…
Відмінити
Зберегти