ssh git@grit.deadkey.org man

Commit 6b66487 h

Pat Ambrosio · 26-09-05 09:57

Extract the markdown parser from jenny

Changes: 6 files +1939 -0

Changes (Side-by-Side)

Raw

commit 6b66487f0d2b7206f56ab1b6a023c289ec2b5691
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Sat Sep 5 09:57:34 2026 +0800

    Extract the markdown parser from jenny
    
    A single-file awk parser with no dependency on the generator it grew up
    in. The two references to jenny it carried were comments standing in for
    "whatever consumes this", and read as "the caller" and "the consumer"
    now.
    
    The tests come with it: the six markdown suites from jenny's unit tests
    and the five parser suites from its regressions, 116 cases in all, with
    the reasoning kept in the comment above each. One test stayed behind --
    "a change to the parser invalidates the lock" is about jenny's build
    signature, not about parsing.
    
    The assertion library is jenny's with everything that knows what a blog
    is removed: make_blog, run_jenny, copy_fixture and the rest. What is left
    is the reporting and the generic assertions.
    
    Verified against every markdown file on the site the parser was extracted
    from: byte-identical output.

diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bc4b5bf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,34 @@
+# markdown.awk
+
+A markdown-to-HTML parser in a single awk script. No build step, no runtime,
+no dependencies beyond an awk and a shell — it runs anywhere those do.
+
+## Use
+
+    awk -f markdown.awk < input.md > output.html
+
+Nothing is configurable by flag; the parser reads markdown on stdin and
+writes HTML on stdout.
+
+## What it renders
+
+Headings (with slugged ids), paragraphs (with content-derived ids for deep
+links), emphasis and strong, inline and fenced code, links and autolinks,
+images, lists including nested ones, tables, blockquotes, horizontal rules,
+strikethrough, footnotes, citations, MultiMarkdown metadata blocks, and
+fenced diagram blocks left raw for a client-side renderer.
+
+Deliberately unsupported: anything requiring a second pass over the document,
+or configuration. The parser is one stream, one direction.
+
+## Tests
+
+    test/run.sh
+
+Plain bash, no framework. `test/units.sh` covers the parsing rules;
+`test/regressions.sh` holds one case per bug the parser has had, with the
+reasoning in the comment above each.
+
+## Licence
+
+Same terms as the project it was extracted from.
diff --git a/markdown.awk b/markdown.awk
new file mode 100755
index 0000000..1487c77
--- /dev/null
+++ b/markdown.awk
@@ -0,0 +1,1027 @@
+#!/bin/awk -f
+#
+# by: Jesus Galan (yiyus) 2009
+#
+# Usage: md2html.awk file.md > file.html
+# See: http://4l77.com/src/md2html.awk
+
+# Attribute values need quotes escaped as well as the usual entities.
+function escattr(t) {
+	t = eschtml(t);
+	gsub(">", "\\&gt;", t);
+	gsub("\"", "\\&quot;", t);
+	return t;
+}
+
+# Fenced content is fully literal, so > is escaped too. eschtml is shared with
+# inline parsing, where a bare > is legitimate text, so it is left alone.
+function escfence(t) {
+	t = eschtml(t);
+	gsub(">", "\\&gt;", t);
+	return t;
+}
+
+function eschtml(t) {
+	gsub("&", "\\&amp;", t);
+	gsub("<", "\\&lt;", t);
+	return t;
+}
+
+function oprint(t){
+	if(nr == 0)
+		print t;
+	else
+		otext = otext "\n" t;
+}
+
+function subref(id){
+	for(; nr > 0 && sub("<<" id, ref[id], otext); nr--);
+	if(nr == 0 && otext) {
+		print otext;
+		otext = "";
+	}
+}
+
+# Escapes regex metacharacters so a literal string can be interpolated into
+# a dynamic regex. Without this "^" "*" reaches the regex engine, which mawk
+# rejects outright and gawk quietly mismatches.
+function re_quote(s,   out, i, c) {
+	out = "";
+	for(i = 1; i <= length(s); i++) {
+		c = substr(s, i, 1);
+		if(index("\\^$.[]|()*+?{}/", c) > 0)
+			out = out "\\" c;
+		else
+			out = out c;
+	}
+	return out;
+}
+
+function nextil(t) {
+	if(!match(t, /[`<&\[*_~\\-]|(!\[)|(\[\^)|(\[#)/))
+		return t;
+	t1 = substr(t, 1, RSTART - 1);
+	tag = substr(t, RSTART, RLENGTH);
+	t2 = substr(t, RSTART + RLENGTH);
+	if(ilcode && tag != "`")
+		return eschtml(t1 tag) nextil(t2);
+	# Backslash escaping
+	if(tag == "\\"){
+		if(match(t2, /^[\\`*_{}\[\]()#+\-\.!]/)){
+			tag = substr(t2, 1, 1);
+			t2 = substr(t2, 2);
+		}
+		return t1 tag nextil(t2);
+	}
+	# Dashes
+	if(tag == "-"){
+		if(sub(/^-/, "", t2))
+			tag = "&#8212;";
+		return t1 tag nextil(t2);
+	}
+	# Inline Code
+	if(tag == "`"){
+		if(sub(/^`/, "", t2)){
+			if(!match(t2, /``/))
+				return t1 "&#8221;" nextil(t2);
+			ilcode2 = !ilcode2;
+		}
+		else if(ilcode2)
+			return t1 tag nextil(t2);
+		tag = "<code>";
+		if(ilcode){
+			t1 = eschtml(t1);
+			tag = "</code>";
+		}
+		ilcode = !ilcode;
+		return t1 tag nextil(t2);
+	}
+	if(tag == "<"){
+	# Autolinks
+		if(match(t2, /^[^ 	]+[\.@][^ 	]+>/)){
+			url = eschtml(substr(t2, 1, RLENGTH - 1));
+			t2 = substr(t2, RLENGTH + 1);
+			linktext = url;
+			if(match(url, /@/) && !match(url, /^mailto:/))
+				url = "mailto:" url;
+			return t1 "<a href=\"" escattr(url) "\">" linktext "</a>" nextil(t2);
+		}
+	# Html tags
+		if(match(t2, /^[A-Za-z\/!][^>]*>/)){
+			tag = tag substr(t2, RSTART, RLENGTH);
+			t2 = substr(t2, RLENGTH + 1);
+			return t1 tag nextil(t2);
+		}
+		return t1 "&lt;" nextil(t2);
+	}
+	# Html special entities
+	if(tag == "&"){
+		if(match(t2, /^#?[A-Za-z0-9]+;/)){
+			tag = tag substr(t2, RSTART, RLENGTH);
+			t2 = substr(t2, RLENGTH + 1);
+			return t1 tag nextil(t2);
+		}
+		return t1 "&amp;" nextil(t2);
+	}
+	# Images
+	if(tag == "!["){
+		if(!match(t2, /(\[.*\])|(\(.*\))/))
+			return t1 tag nextil(t2);
+		match(t2, /^[^\]]*/);
+		alt = substr(t2, 1, RLENGTH);
+		t2 = substr(t2, RLENGTH + 2);
+		if(match(t2, /^\(/)){
+			# Inline
+			sub(/^\(/, "", t2);
+			match(t2, /^[^\)]+/);
+			url = eschtml(substr(t2, 1, RLENGTH));
+			t2 = substr(t2, RLENGTH + 2);
+			title = "";
+			if(match(url, /[ 	]+".*"[ 	]*$/)) {
+				title = substr(url, RSTART, RLENGTH);
+				url = substr(url, 1, RSTART - 1);
+				match(title, /".*"/);
+				title = " title=\"" escattr(substr(title, RSTART + 1, RLENGTH - 2)) "\"";
+			}
+			if(match(url, /^<.*>$/))
+				url = substr(url, 2, RLENGTH - 2);
+			return t1 "<img src=\"" escattr(url) "\" alt=\"" escattr(alt) "\"" title " />" nextil(t2);
+		}
+		else{
+			# Referenced
+			sub(/^ ?\[/, "", t2);
+			id = alt;
+			if(match(t2, /^[^\]]+/))
+				id = substr(t2, 1, RLENGTH);
+			t2 = substr(t2, RLENGTH + 2);
+			if(ref[id])
+				r = ref[id];
+			else{
+				r = "<<" id;
+				nr++;
+			}
+			return t1 "<img src=\"" escattr(r) "\" alt=\"" escattr(alt) "\" />" nextil(t2);
+		}
+	}
+	# Citations
+	#
+	# [#id] or [locator][#id]. Numbered by first use; a repeat reuses the
+	# number, matching multimarkdown.
+	if(tag == "[#"){
+		match(t2, /^[^\]]*/);
+		cid = substr(t2, 1, RLENGTH);
+		t2 = substr(t2, RLENGTH + 2);
+		if(!(cid in cite_num)){
+			cite_num[cid] = ++cite_n;
+			cite_order[cite_n] = cid;
+		}
+		cnum = cite_num[cid];
+		# A locator set by the link branch for the [locator][#id] form.
+		cloc = cite_locator;
+		cite_locator = "";
+		clabel = (cloc != "") ? "(" cloc ", " cnum ")" : "(" cnum ")";
+		return t1 "<a href=\"#cn:" cnum "\" id=\"cnref:" cnum "\" data-citation=\"" cid \
+			"\" title=\"see citation\" class=\"citation\">" clabel "</a>" nextil(t2);
+	}
+
+	# Footnotes
+	if(tag == "[^"){
+		match(t2, /^[^\]]*(\[[^\]]*\][^\]]*)*/);
+		linktext = substr(t2, 1, RLENGTH);
+		t2 = substr(t2, RLENGTH + 2);
+    return t1 "<sup class=\"fnref\" data-fnref=\"" linktext "\"><a href=\"#fn-" linktext "\" id=\"fnref-" linktext "\">" linktext "</a></sup>" nextil(t2);
+	}
+	# Links
+	if(tag == "["){
+		# A shortcut reference is a bare [text] with no following [] or ().
+		# It is only a link if a definition matching the text exists, so the
+		# early return here must not fire for one -- it is decided below.
+		if(!match(t2, /(\[.*\])|(\(.*\))/)){
+			if(!match(t2, /^[^\]]*\]/))
+				return t1 tag nextil(t2);
+			shortcut_candidate = 1;
+		}
+		else
+			shortcut_candidate = 0;
+		match(t2, /^[^\]]*(\[[^\]]*\][^\]]*)*/);
+		linktext = substr(t2, 1, RLENGTH);
+		t2 = substr(t2, RLENGTH + 2);
+		if(match(t2, /^\(/)){
+			# Inline
+			match(t2, /^[^\)]+(\([^\)]+\)[^\)]*)*/);
+			url = substr(t2, 2, RLENGTH - 1);
+			pt2 = substr(t2, RLENGTH + 2);
+			title = "";
+			if(match(url, /[ 	]+".*"[ 	]*$/)) {
+				title = substr(url, RSTART, RLENGTH);
+				url = substr(url, 1, RSTART - 1);
+				match(title, /".*"/);
+				title = " title=\"" escattr(substr(title, RSTART + 1, RLENGTH - 2)) "\"";
+			}
+			if(match(url, /^<.*>$/))
+				url = substr(url, 2, RLENGTH - 2);
+			url = eschtml(url);
+			return t1 "<a href=\"" escattr(url) "\"" title ">" nextil(linktext) "</a>" nextil(pt2);
+		}
+		else{
+			# Referenced: [text][id], [text][] or the shortcut [text].
+			id = linktext;
+			shortcut = 1;
+			if(match(t2, /^ ?\[#/)){
+				# [locator][#id] is a citation, not a reference link. The
+				# locator is handed to the citation branch directly; nextil
+				# has already split t1 away, so it cannot be recovered there.
+				sub(/^ ?\[/, "", t2);
+				cite_locator = linktext;
+				return t1 nextil("[" t2);
+			}
+			if(match(t2, /^ ?\[/)){
+				shortcut = 0;
+				sub(/^ ?\[/, "", t2);
+				if(match(t2, /^[^\]]*/)){
+					# An empty [] means "reuse the link text as the label".
+					if(RLENGTH > 0)
+						id = substr(t2, 1, RLENGTH);
+					t2 = substr(t2, RLENGTH + 2);
+				}
+			}
+			id = tolower(id);
+			if(ref[id])
+				r = ref[id];
+			else{
+				# The definition may appear later in the document, so the
+				# link is emitted with a placeholder that subref() fills in
+				# when the definition is read. A shortcut [text] that is
+				# still unresolved at EOF is restored to literal text by
+				# the END rule.
+				r = "<<" id;
+				nr++;
+				if(shortcut)
+					shortcut_ref[id] = linktext;
+			}
+			pt2 = t2;
+			return t1 "<a href=\"" r "\">" nextil(linktext) "</a>" nextil(pt2);
+		}
+	}
+	# Strikethrough
+	#
+	# Only the doubled form, which is what GFM defines and what authors
+	# write. A single ~ stays literal so that "~/path" and "approx ~5" are
+	# not silently turned into markup. An unpaired "~~" is left alone rather
+	# than opening a <del> that would run to the end of the document.
+	if(tag == "~"){
+		if(sub(/^~/, "", t2)){
+			if(match(t2, /~~/)){
+				strike = substr(t2, 1, RSTART - 1);
+				# t1, tag and t2 are globals shared with every nextil frame,
+				# so parsing the struck text clobbers the tail still waiting
+				# here -- it came back with whatever the inner call left
+				# behind, dropping the rest of the line. The link handler
+				# above keeps its own copy for the same reason.
+				strike_t1 = t1;
+				strike_rest = substr(t2, RSTART + 2);
+				strike_done = nextil(strike);
+				return strike_t1 "<del>" strike_done "</del>" nextil(strike_rest);
+			}
+			return t1 "~~" nextil(t2);
+		}
+		return t1 "~" nextil(t2);
+	}
+	# Emphasis
+	if(match(tag, /[*_]/)){
+		# An underscore between two word characters is literal, not an
+		# emphasis marker -- otherwise identifiers like node_modules and
+		# snake_case_name get torn apart, and an unmatched one leaves a
+		# stray <em> open for the rest of the document. CommonMark makes
+		# the same exception, and only for _: intraword * still emphasises.
+		if(tag == "_" && match(t1, /[A-Za-z0-9]$/) && match(t2, /^[A-Za-z0-9]/))
+			return t1 tag nextil(t2);
+		# An underscore that is the whole content of an HTML element is
+		# literal: <span class="us">_</span> is a way of writing a visible
+		# underscore, not emphasis. Without this it opened an <em> that
+		# swallowed the markup after it, and two such underscores paired with
+		# each other across the tags between them, emitting mis-nested
+		# "<b><em></b> ... <b></em></b>". CommonMark reaches the same result
+		# by its flanking rules: a delimiter preceded by punctuation and
+		# followed by punctuation is neither opening nor closing.
+		if(tag == "_" && t1 == "" && match(t2, /^</) && stag[ns] != "_" && stag[ns] != "__")
+			return t1 tag nextil(t2);
+		ntag = tag;
+		# `tag` is a literal * or _, so it must be escaped before being
+		# interpolated into a regex: "^" "*" is not a valid regex.
+		etag = "^" re_quote(tag);
+		if(sub(etag, "", t2)){
+			if(stag[ns] == tag && match(t2, etag))
+				t2 = tag t2;
+			else
+				ntag = tag tag
+		}
+		n = length(ntag);
+		tag = (n == 2) ? "strong" : "em";
+		if(match(t1, / $/) && match(t2, /^ /))
+			return t1 tag nextil(t2);
+		if(stag[ns] == ntag){
+			tag = "/" tag;
+			ns--;
+		}
+		else
+			stag[++ns] = ntag;
+		tag = "<" tag ">";
+		return t1 tag nextil(t2);
+	}
+}
+
+function inline(t) {
+	ilcode = 0;
+	ilcode2 = 0;
+	ns = 0;
+
+	return nextil(t);
+}
+
+# heading_id <text>
+# The slug a heading is addressable by: lowercased, runs of anything that is
+# not a letter or digit collapsed to a single dash, ends trimmed.
+#
+# A document long enough to link within needs its headings to be targets. Any
+# "#section" link -- from its own table of contents, from a sibling page, from
+# somewhere else entirely -- resolves to nothing without them.
+#
+# Inline markup is stripped first: a heading may be emphasised or hold code,
+# and "<code>Vision</code>" must still slug to "vision".
+#
+# Collisions are left alone. Two headings with the same words produce the same
+# id and the first wins, which is what a reader following a link expects and
+# what every other implementation does; numbering them would make the ids
+# depend on how many times a word appears earlier in the document.
+function heading_id(t,   s) {
+	s = t
+	gsub(/<[^>]*>/, "", s)
+	s = tolower(s)
+	gsub(/&[a-z]+;|&#[0-9]+;|&#x[0-9a-fA-F]+;/, "", s)
+	gsub(/[^a-z0-9]+/, "-", s)
+	sub(/^-+/, "", s)
+	sub(/-+$/, "", s)
+	return s
+}
+
+# para_id <html>
+# The anchor a paragraph is addressable by: "p-" and a hash of the paragraph's
+# own text.
+#
+# Derived from the content rather than the position, so a permalink survives
+# edits elsewhere on the page -- insert three paragraphs above this one, or
+# reorder the sections around it, and the link still resolves. It breaks only
+# when this paragraph's own wording changes, which is the honest failure: the
+# text someone linked to is not there any more. A positional p-7 would keep
+# resolving after an insertion and quietly point at the wrong paragraph, which
+# is worse than breaking.
+#
+# Inline markup is stripped first so that editing a link's target, or wrapping
+# a word in emphasis, does not move the anchor.
+function para_id(t,   s) {
+	s = t
+	gsub(/<[^>]*>/, "", s)
+	gsub(/&[a-z]+;|&#[0-9]+;|&#x[0-9a-fA-F]+;/, "", s)
+	gsub(/[ \t]+/, " ", s)
+	sub(/^ /, "", s); sub(/ $/, "", s)
+	if (s == "") return ""
+	return "p-" fnv1a(s)
+}
+
+# fnv1a <string>
+# FNV-1a, 32 bits, in awk rather than by calling out to a hash program: a page
+# has hundreds of paragraphs and a sub-process each would be the slowest part
+# of the build. Checked against every paragraph in the site at the time it was
+# written -- no collisions between different texts.
+function fnv1a(s,   h, i, c, n) {
+	h = 2166136261
+	n = length(s)
+	for (i = 1; i <= n; i++) {
+		c = index(HASHCHARS, substr(s, i, 1))
+		if (c == 0) c = 32
+		h = xor32(h, c)
+		h = (h * 16777619) % 4294967296
+	}
+	return sprintf("%08x", h)
+}
+
+function xor32(a, b,   r, i, x, y) {
+	r = 0; i = 1
+	while (a > 0 || b > 0) {
+		x = a % 2; y = b % 2
+		if (x != y) r += i
+		a = int(a / 2); b = int(b / 2); i *= 2
+	}
+	return r
+}
+
+function printp(tag,   id) {
+	if(!match(text, /^[ 	]*$/)){
+		text = inline(text);
+		if(tag != ""){
+			# Headings carry an id; nothing else does.
+			if(tag ~ /^h[1-6]$/){
+				id = heading_id(text)
+				if(id != "")
+					oprint("<" tag " id=\"" id "\">" text "</" tag ">");
+				else
+					oprint("<" tag ">" text "</" tag ">");
+			}
+			else if(tag == "p"){
+				# Two paragraphs with the same words hash the same; the second
+				# and later take a suffix so every id on the page is unique.
+				id = para_id(text)
+				if(id != ""){
+					PARA_SEEN[id]++
+					if(PARA_SEEN[id] > 1)
+						id = id "-" PARA_SEEN[id]
+					oprint("<p id=\"" id "\">" text "</p>");
+				}
+				else
+					oprint("<p>" text "</p>");
+			}
+			else
+				oprint("<" tag ">" text "</" tag ">");
+		}
+		else
+			oprint(text);
+	}
+	text = "";
+}
+
+BEGIN {
+	# Character table for fnv1a: index() gives each byte a value, so the hash
+	# needs no ord() -- which one-true-awk does not have.
+	HASHCHARS = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
+	blank = 0;
+	code = 0;
+	hr = 0;
+	html = 0;
+	nl = 0;
+	nr = 0;
+	otext = "";
+	text = "";
+	par = "p";
+}
+
+# References
+# Reference link definitions: [id]: url "optional title"
+# Standard markdown. The [^!id] form below predates this and is kept so any
+# document using it still resolves. Labels are case-insensitive, so they are
+# normalised to lowercase on both definition and use.
+!code && !fence && /^ *\[[^\^#\]][^\]]*\]:[ 	]+/ {
+	sub(/^ *\[/, "");
+	match($0, /\]/);
+	id = tolower(substr($0, 1, RSTART - 1));
+	sub(/^[^\]]*\]:[ 	]+/, "");
+	title = "";
+	if(match($0, /".*"$/))
+		title = "\" title=\"" substr($0, RSTART + 1, RLENGTH - 2);
+	sub(/[ 	]+".*"$/, "");
+	url = eschtml($0);
+	ref[id] = url title;
+
+	subref(id);
+	next;
+}
+
+!code && /^ *\[\^![^\]]*\]:[ 	]+/ {
+	sub(/^ *\[\^!/, "");
+	match($0, /\]/);
+	id = substr($0, 1, RSTART - 1);
+	sub(id "\\]:[ 	]+", "");
+	id = tolower(id);
+	title = "";
+	if(match($0, /".*"$/))
+		title = "\" title=\"" substr($0, RSTART + 1, RLENGTH - 2);
+	sub(/[ 	]+".*"$/, "");
+	url = eschtml($0);
+	ref[id] = url title;
+
+	subref(id);
+	next;
+}
+
+!code && !fence && /^ *\[#[^\]]*\]:[ 	]+/ {
+	sub(/^ *\[#/, "");
+	match($0, /\]/);
+	id = substr($0, 1, RSTART - 1);
+	sub(/^[^\]]*\]:[ 	]+/, "");
+	cite_body[id] = $0;
+	next;
+}
+
+!code && /^ *\[\^[^\]]*\]:[ 	]+/ {
+	sub(/^ *\[\^/, "");
+	match($0, /\]/);
+	id = substr($0, 1, RSTART - 1);
+	sub(id "\\]:[ 	]+", "");
+	sub(/[ 	]+".*"$/, "");
+	# Stored raw, like a citation's body: inline() escapes what needs it and
+	# resolves what does not. Pre-escaping turned every "<" into "&lt;" before
+	# the parser could see it, so an autolink -- <https://example.com> -- came
+	# out as literal text in a footnote while working in prose and in
+	# citations. The variable was called "url" because this rule began life
+	# handling link-reference definitions, where a bare URL was all it held.
+	fnref[id] = $0;
+
+	subref(id);
+	next;
+}
+
+# html
+!html && /^<(address|blockquote|center|dir|div|dl|fieldset|form|h[1-6r]|\
+isindex|menu|noframes|noscript|ol|p|pre|table|ul|!--)/ {
+	if(code)
+		oprint(code_held "</code></pre>"); code_held = "";
+	for(; !text && block[nl] == "blockquote"; nl--)
+		oprint("</blockquote>");
+	match($0, /^<(address|blockquote|center|dir|div|dl|fieldset|form|h[1-6r]|\
+	isindex|menu|noframes|noscript|ol|p|pre|table|ul|!--)/);
+	htag = substr($0, 2, RLENGTH - 1);
+	if(!match($0, "(<\\/" htag ">)|((^<hr ?\\/?)|(--)>$)"))
+		html = 1;
+	if(html && match($0, /^<hr/))
+		hr = 1;
+	oprint($0);
+	next;
+}
+
+html && (/(^<\/(address|blockquote|center|dir|div|dl|fieldset|form|h[1-6r]|\
+isindex|menu|noframes|noscript|ol|p|pre|table|ul).*)|(--)>$/ ||
+(hr && />$/)) {
+	html = 0;
+	hr = 0;
+	oprint($0);
+	next;
+}
+
+html {
+	oprint($0);
+	next;
+}
+
+# MultiMarkdown metadata block
+#
+# Key: value pairs at the very top of the file, ending at the first blank
+# line. The pairs are preserved verbatim rather than stripped -- the author
+# wrote them deliberately -- but each is wrapped so a theme can style or hide
+# it, and so the caller can read the values back. Whether they are visible is
+# a readability decision for the consumer, not the parser's to make.
+#
+# The entire leading run must look like metadata. If any line before the first
+# blank is not Key: value, the whole block is ordinary prose -- otherwise a
+# post opening "Note: something" would silently become metadata.
+#
+# Lines are buffered rather than consumed with getline, so the main rules see
+# them unchanged when the block turns out to be prose.
+meta_state == 0 {
+	if(NR == 1 && $0 ~ /^[A-Za-z][A-Za-z0-9 _-]*:[ \t]*./){
+		meta_state = 1;
+		meta_n = 0;
+		meta_buf[++meta_n] = $0;
+		next;
+	}
+	meta_state = 2;
+}
+
+meta_state == 1 {
+	if($0 ~ /^[ \t]*$/){
+		# End of the block, and every line matched: emit them as metadata.
+		for(mi = 1; mi <= meta_n; mi++){
+			mline = meta_buf[mi];
+			match(mline, /:/);
+			mkey = substr(mline, 1, RSTART - 1);
+			mval = substr(mline, RSTART + 1);
+			sub(/^[ \t]+/, "", mval);
+			sub(/[ \t]+$/, "", mval);
+			oprint("<p class=\"meta\" data-key=\"" escattr(mkey) \
+				"\" data-value=\"" escattr(mval) "\">" eschtml(mline) "</p>");
+		}
+		meta_state = 2;
+		next;
+	}
+	if($0 ~ /^[A-Za-z][A-Za-z0-9 _-]*:[ \t]*./){
+		meta_buf[++meta_n] = $0;
+		next;
+	}
+	# Not a metadata block after all. The buffered lines are ordinary prose,
+	# and awk cannot re-run its own rules over them, so they are pushed back
+	# into the paragraph accumulator that the fall-through rule uses. That is
+	# where a run of plain lines would have ended up anyway.
+	meta_state = 2;
+	for(mi = 1; mi <= meta_n; mi++)
+		text = (text ? text " " : "") eschtml(meta_buf[mi]);
+	meta_n = 0;
+}
+
+# Tables (MultiMarkdown)
+#
+# A row is only a table row if the line after it is a separator: |---|:-:|.
+# That means one line of lookahead, which is done by holding the candidate
+# header until the next line decides. Alignment comes from the colons in the
+# separator and is emitted as an inline style, matching multimarkdown's own
+# output.
+
+function table_cells(line,   n, i, out) {
+	# Split on unescaped pipes, dropping the optional edge pipes.
+	sub(/^[ \t]*\|/, "", line);
+	sub(/\|[ \t]*$/, "", line);
+	return line;
+}
+
+function table_align(spec,   a) {
+	a = spec;
+	gsub(/[ \t]/, "", a);
+	if(a ~ /^:.*:$/) return "center";
+	if(a ~ /:$/)     return "right";
+	if(a ~ /^:/)     return "left";
+	return "";
+}
+
+function table_row(line, celltag,   n, i, cells, out, st) {
+	n = split(table_cells(line), cells, "|");
+	out = "<tr>";
+	for(i = 1; i <= n; i++){
+		sub(/^[ \t]+/, "", cells[i]);
+		sub(/[ \t]+$/, "", cells[i]);
+		st = "";
+		if(tbl_align[i] != "")
+			st = " style=\"text-align:" tbl_align[i] ";\"";
+		out = out "\n\t<" celltag st ">" inline(cells[i]) "</" celltag ">";
+	}
+	return out "\n</tr>";
+}
+
+# A held header row, resolved by whatever follows it.
+tbl_pending != "" {
+	if($0 ~ /^[ \t]*\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/){
+		# It was a table after all.
+		n_align = split(table_cells($0), align_spec, "|");
+		for(ai = 1; ai <= n_align; ai++)
+			tbl_align[ai] = table_align(align_spec[ai]);
+		if(text)
+			printp(par ? par : "p");
+		oprint("<div class=\"table-scroll\">");
+		oprint("<table>");
+		oprint("<thead>");
+		oprint(table_row(tbl_pending, "th"));
+		oprint("</thead>");
+		oprint("<tbody>");
+		tbl = 1;
+		tbl_pending = "";
+		next;
+	}
+	# Not a table: the held line is ordinary text.
+	text = (text ? text " " : "") tbl_pending;
+	tbl_pending = "";
+}
+
+tbl {
+	if($0 ~ /^[ \t]*$/ || $0 !~ /\|/){
+		oprint("</tbody>");
+		oprint("</table>");
+		oprint("</div>");
+		tbl = 0;
+		for(ci in tbl_align) delete tbl_align[ci];
+		if($0 ~ /^[ \t]*$/)
+			next;
+	}
+	else{
+		oprint(table_row($0, "td"));
+		next;
+	}
+}
+
+!fence && !code && !tbl && /\|/ && !/^[ \t]*$/ && (!text || text == "") {
+	# Candidate header row: hold it and let the next line decide. Only a line
+	# whose pipes separate non-empty cells qualifies, so ordinary prose that
+	# happens to contain a pipe is not swallowed.
+	tbl_probe = $0;
+	sub(/^[ \t]*\|/, "", tbl_probe);
+	sub(/\|[ \t]*$/, "", tbl_probe);
+	if(index(tbl_probe, "|") > 0 || $0 ~ /^[ \t]*\|.*\|[ \t]*$/){
+		tbl_pending = $0;
+		next;
+	}
+}
+
+# Fenced code blocks (MultiMarkdown / CommonMark)
+#
+# Must run before every other line rule: content inside a fence is literal,
+# so it has to bypass inline parsing, smart quotes and list detection alike.
+# The closing fence has to be at least as long as the opener and use the same
+# character, so a fence can contain a shorter run of backticks.
+fence {
+	if($0 ~ fence_re && length($0) >= 0){
+		# Candidate closer: same char, at least as long, nothing after it.
+		if(match($0, "^[ \t]*" fence_char "{" fence_len ",}[ \t]*$")){
+			if(fence_first)
+				# An empty fence: nothing was ever buffered.
+				oprint(fence_open fence_close);
+			else
+				oprint(fence_held fence_close);
+			fence = 0;
+			fence_held = "";
+			next;
+		}
+	}
+	# Hold each line until the next one arrives, so the closing tag can be
+	# joined to the last line rather than sitting on its own.
+	if(fence_first){
+		fence_held = fence_open (fence_raw ? $0 : escfence($0));
+		fence_first = 0;
+	}
+	else{
+		oprint(fence_held);
+		fence_held = (fence_raw ? $0 : escfence($0));
+	}
+	next;
+}
+
+/^[ \t]*(```|~~~)/ {
+	# Opening fence. Everything after the run is an info string; its first
+	# word is the language.
+	line = $0;
+	sub(/^[ \t]*/, "", line);
+	fence_char = substr(line, 1, 1);
+	fence_len = 0;
+	while(substr(line, fence_len + 1, 1) == fence_char)
+		fence_len++;
+	info = substr(line, fence_len + 1);
+	sub(/^[ \t]+/, "", info);
+	sub(/[ \t].*$/, "", info);
+
+	# Close anything currently open so the block is not nested inside a <p>.
+	if(text)
+		printp(par ? par : "p");
+	if(code){
+		oprint(code_held "</code></pre>"); code_held = "";
+		code = 0;
+	}
+
+	fence = 1;
+	fence_first = 1;
+	fence_re = "^[ \t]*" fence_char;
+	# A diagram language is held for a client-side renderer rather than shown
+	# as source. The text goes in verbatim: mermaid reads the element's text
+	# content and parses it itself, so escaping "-->" to "--&gt;" would break
+	# every arrow in the diagram. That makes this the one place a fence's
+	# content is not escaped, which is safe only because the set of languages
+	# treated this way is fixed here -- an author cannot introduce a new one
+	# from a document.
+	#
+	# The language is kept as a class so the consumer decides what to do with
+	# it. This parser renders no diagrams and loads nothing; a caller that
+	# wants them supplies the renderer.
+	fence_raw = 0;
+	fence_close = "</code></pre>";
+	lang = info;
+	sub(/[ \t].*$/, "", lang);
+	if(lang == "mermaid" || lang == "mermaid-legend"){
+		fence_raw = 1;
+		fence_close = "</div>";
+		fence_open = "<div class=\"diagram diagram-" eschtml(lang) "\">";
+	}
+	else if(info != "")
+		fence_open = "<pre><code class=\"language-" eschtml(info) "\">";
+	else
+		fence_open = "<pre><code>";
+	next;
+}
+
+# List and quote blocks
+
+#   Remove indentation
+#
+# One nesting level is peeled per step. A tab or four spaces was the only unit
+# understood here, but a child is conventionally indented to its parent's
+# content column, which for "- " is two spaces -- so two-space nesting, the
+# common form, was invisible: the item regex below tolerates up to three
+# leading spaces as noise, so a two-space child matched as a sibling and the
+# whole list came out flat.
+#
+# Two spaces is the step, not an alternative to four. Written as an
+# alternation with four spaces first, a four-space indent matched the wide
+# branch and peeled a single level, so three levels of four-space nesting
+# collapsed into two. A tab remains one whole level.
+{
+	for(nnl = 0; nnl < nl; nnl++)
+		if((match(block[nnl + 1], /[ou]l/) && !sub(/^(  |	)/, "")) || \
+		(block[nnl + 1] == "blockquote" && !sub(/^> ?/, "")))
+			break;
+}
+nnl < nl && !blank && text && ! /^ ? ? ?([*+-]|([0-9]+\.)+)( +|	)/ { nnl = nl; }
+#   Quote blocks
+{
+	while(sub(/^> /, ""))
+		nblock[++nnl] = "blockquote";
+}
+#   Horizontal rules
+{ hr = 0; }
+(blank || (!text && !code)) && /^ ? ? ?([-*_][ 	]*)([-*_][ 	]*)([-*_][ 	]*)+$/ {
+	if(code){
+		oprint(code_held "</code></pre>"); code_held = "";
+		code = 0;
+	}
+	blank = 0;
+	nnl = 0;
+	hr = 1;
+}
+#   List items
+block[nl] ~ /[ou]l/ && /^$/ {
+	blank = 1;
+	next;
+}
+{ newli = 0; }
+!hr && (nnl != nl || !text || block[nl] ~ /[ou]l/) && /^ ? ? ?[*+-]( +|	)/ {
+	sub(/^ ? ? ?[*+-]( +|	)/, "");
+	nnl++;
+	nblock[nnl] = "ul";
+	newli = 1;
+}
+(nnl != nl || !text || block[nl] ~ /[ou]l/) && /^ ? ? ?([0-9]+\.)+( +|	)/ {
+	sub(/^ ? ? ?([0-9]+\.)+( +|	)/, "");
+	nnl++;
+	nblock[nnl] = "ol";
+	newli = 1;
+}
+newli {
+	if(blank && nnl == nl && !par)
+		par = "p";
+	blank = 0;
+	printp(par);
+	if(nnl == nl && block[nl] == nblock[nl])
+		oprint("</li><li>");
+}
+blank && ! /^$/ {
+	if(match(block[nnl], /[ou]l/) && !par)
+		par = "p";
+	printp(par);
+	par = "p";
+	blank = 0;
+}
+
+# Close old blocks and open new ones
+nnl != nl || nblock[nl] != block[nl] {
+	if(code){
+		oprint(code_held "</code></pre>"); code_held = "";
+		code = 0;
+	}
+	printp(par);
+	b = (nnl > nl) ? nblock[nnl] : block[nnl];
+	par = (match(b, /[ou]l/)) ? "" : "p";
+}
+nnl < nl || (nnl == nl && nblock[nl] != block[nl]) {
+	# The second clause compares against nblock, the block kind this line
+	# wants, not pblock -- which is assigned nowhere and so was always empty,
+	# making the test true for any open list. Once the walk reached nnl it
+	# therefore kept going and closed the outer <ul> along with the nested one,
+	# leaving a dedented sibling no list to rejoin. Harmless while nesting did
+	# not work at all; load-bearing now that it does.
+	for(; nl > nnl || (nl > 0 && nnl == nl && nblock[nl] != block[nl]); nl--){
+		if(match(block[nl], /[ou]l/))
+			oprint("</li>");
+		oprint("</" block[nl] ">");
+	}
+	# A sibling after a nested list needs a fresh <li> in the list we just came
+	# back to. The "</li><li>" rule earlier only fires when the item is already
+	# at its own depth (nnl == nl), which is false on a dedent.
+	if(newli && nnl > 0 && nnl == nl && match(block[nl], /[ou]l/) && block[nl] == nblock[nl]){
+		oprint("</li><li>");
+		newli = 0;
+	}
+}
+nnl > nl {
+	for(; nl < nnl; nl++){
+		block[nl + 1] = nblock[nl + 1];
+		oprint("<" block[nl + 1] ">");
+		if(match(block[nl + 1], /[ou]l/))
+			oprint("<li>");
+	}
+}
+hr {
+	oprint("<hr>");
+	next;
+}
+
+# Code blocks
+code && /^$/ {
+	if(blanK)
+		oprint("");
+	blank = 1;
+	next;
+}
+!text && sub(/^(	|    )/, "") {
+	if(blanK)
+		oprint("");
+	blank = 0;
+	$0 = eschtml($0);
+	if(!code){
+		code_held = "<pre><code>" $0;
+		code = 1;
+	}
+	else{
+		oprint(code_held);
+		code_held = $0;
+	}
+	next;
+}
+code {
+	oprint(code_held "</code></pre>");
+	code_held = "";
+	code = 0;
+}
+
+# Setex-style Headers
+text && /^=+$/ {printp("h1"); next;}
+text && /^-+$/ {printp("h2"); next;}
+
+# Atx-Style headers
+/^#+/ && (!newli || par=="p" || /^##/) {
+	for(n = 0; n < 6 && sub(/^# */, ""); n++)
+		sub(/#$/, "");
+	par = "h" n;
+}
+
+# Paragraph
+/^$/ {
+	printp(par);
+	par = "p";
+	next;
+}
+
+# Add text
+{ text = (text ? text " " : "") $0; }
+
+function alen(a, ix, k) {
+  k = 0
+  for(ix in a) k++
+  return k
+}
+
+END {
+	# A candidate header row with no separator after it was ordinary text.
+	if(tbl_pending != ""){
+		text = (text ? text " " : "") tbl_pending;
+		tbl_pending = "";
+	}
+	if(tbl){
+		oprint("</tbody>");
+		oprint("</table>");
+		oprint("</div>");
+		tbl = 0;
+	}
+	if(code){
+		oprint(code_held "</code></pre>"); code_held = "";
+		code = 0;
+	}
+	printp(par);
+	for(; nl > 0; nl--){
+		if(match(block[nl], /[ou]l/))
+			oprint("</li>");
+		oprint("</" block[nl] ">");
+	}
+	# A reference whose definition never appeared is not a link at all.
+	# Restore the literal text the author wrote rather than emitting an
+	# anchor with an empty href.
+	for(sid in shortcut_ref){
+		lt = shortcut_ref[sid];
+		gsub("<a href=\"<<" sid "\">" lt "</a>", "[" lt "]", otext);
+	}
+	while(match(otext, /<a href="<<[^"]*">[^<]*<\/a>/)){
+		frag = substr(otext, RSTART, RLENGTH);
+		lt = frag;
+		sub(/^<a href="<<[^"]*">/, "", lt);
+		sub(/<\/a>$/, "", lt);
+		rid = frag;
+		sub(/^<a href="<</, "", rid);
+		sub(/">.*$/, "", rid);
+		otext = substr(otext, 1, RSTART - 1) "[" lt "][" rid "]" \
+			substr(otext, RSTART + RLENGTH);
+	}
+	gsub(/<<[^"]*/, "", otext);
+	print(otext);
+
+  # Print citations, in order of first use.
+  if(cite_n > 0) {
+    print "<div class=\"citations\">";
+    print "<hr />";
+    print "<ol>";
+    for(ci = 1; ci <= cite_n; ci++){
+      cid = cite_order[ci];
+      print "<li id=\"cn:" ci "\"><p>" inline(cite_body[cid]) \
+        " <a href=\"#cnref:" ci "\" title=\"return to body\" class=\"reversecitation\">&#160;&#8617;&#xfe0e;</a></p></li>";
+    }
+    print "</ol>";
+    print "</div>";
+  }
+
+  # Print footnotes
+  if(alen(fnref)>0) {
+    print "<ul class=\"fn-list\">";
+    for (i in fnref) print "<li id=\"fn-" i "\" class=\"fn-item\"><span class=\"fn-handle\">" i ": </span><span class=\"fn-text\">" inline(fnref[i]) " <a href=\"#fnref-" i "\" class=\"fn-backref\">&#x21A9;&#xFE0E;</a></span></li>";
+    print "</ul>";
+  }
+}
diff --git a/test/assert.bash b/test/assert.bash
new file mode 100644
index 0000000..136e8de
--- /dev/null
+++ b/test/assert.bash
@@ -0,0 +1,168 @@
+#!/usr/bin/env bash
+# Minimal assertion library for the markdown.awk test suites.
+# Plain bash on purpose: no dev dependency, runs anywhere awk runs.
+
+TESTS_RUN=0
+TESTS_FAILED=0
+CURRENT_TEST=""
+
+# ANSI colours, disabled when not a tty or when NO_COLOR is set.
+if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
+  C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'
+  C_DIM=$'\033[2m'; C_OFF=$'\033[0m'
+else
+  C_RED=""; C_GREEN=""; C_YELLOW=""; C_DIM=""; C_OFF=""
+fi
+
+
+# ---------------------------------------------------------------- test runner
+
+# it <description> -- starts a new test case
+it() {
+  CURRENT_TEST="$1"
+  TESTS_RUN=$((TESTS_RUN + 1))
+}
+
+pass() {
+  printf '  %sok%s %s\n' "$C_GREEN" "$C_OFF" "$CURRENT_TEST"
+}
+
+fail() {
+  TESTS_FAILED=$((TESTS_FAILED + 1))
+  printf '  %sFAIL%s %s\n' "$C_RED" "$C_OFF" "$CURRENT_TEST"
+  local line
+  while IFS= read -r line; do
+    printf '       %s%s%s\n' "$C_DIM" "$line" "$C_OFF"
+  done <<< "$1"
+}
+
+skip() {
+  printf '  %sskip%s %s %s(%s)%s\n' \
+    "$C_YELLOW" "$C_OFF" "$CURRENT_TEST" "$C_DIM" "$1" "$C_OFF"
+}
+
+suite() {
+  printf '\n%s\n' "$1"
+}
+
+# Call at the end of a test file. Exits non-zero if anything failed.
+summary() {
+  printf '\n'
+  if [ "$TESTS_FAILED" -eq 0 ]; then
+    printf '%s%d passed%s\n' "$C_GREEN" "$TESTS_RUN" "$C_OFF"
+    return 0
+  fi
+  printf '%s%d of %d failed%s\n' "$C_RED" "$TESTS_FAILED" "$TESTS_RUN" "$C_OFF"
+  return 1
+}
+
+# ----------------------------------------------------------------- assertions
+
+assert_eq() {
+  # assert_eq <actual> <expected> [label]
+  if [ "$1" = "$2" ]; then
+    pass
+  else
+    fail "${3:+$3
+}expected: $2
+actual:   $1"
+  fi
+}
+
+assert_contains() {
+  # assert_contains <haystack> <needle> [label]
+  case "$1" in
+    *"$2"*) pass ;;
+    *) fail "${3:+$3
+}expected to contain: $2
+actual: $1" ;;
+  esac
+}
+
+assert_not_contains() {
+  case "$1" in
+    *"$2"*) fail "${3:+$3
+}expected NOT to contain: $2
+actual: $1" ;;
+    *) pass ;;
+  esac
+}
+
+assert_file_contains() {
+  # assert_file_contains <file> <fixed-string> [label]
+  if [ ! -f "$1" ]; then
+    fail "${3:+$3
+}no such file: $1"
+    return
+  fi
+  if grep -qF -- "$2" "$1"; then
+    pass
+  else
+    fail "${3:+$3
+}$1 does not contain: $2"
+  fi
+}
+
+assert_file_not_contains() {
+  if [ ! -f "$1" ]; then
+    fail "${3:+$3
+}no such file: $1"
+    return
+  fi
+  if grep -qF -- "$2" "$1"; then
+    fail "${3:+$3
+}$1 unexpectedly contains: $2"
+  else
+    pass
+  fi
+}
+
+assert_file_exists() {
+  if [ -f "$1" ]; then pass; else fail "${2:+$2
+}expected file to exist: $1"; fi
+}
+
+assert_file_absent() {
+  if [ ! -e "$1" ]; then pass; else fail "${2:+$2
+}expected file to be absent: $1"; fi
+}
+
+assert_success() {
+  # assert_success <status> [output]
+  if [ "$1" -eq 0 ]; then pass; else fail "expected exit 0, got $1
+${2:-}"; fi
+}
+
+assert_failure() {
+  # assert_failure <status> [output]
+  if [ "$1" -ne 0 ]; then pass; else fail "expected non-zero exit, got 0
+${2:-}"; fi
+}
+
+# --------------------------------------------------------------- blog fixture
+
+# make_blog <dir> [posts_per_page]
+# Builds the standard repro blog from the handoff document.
+
+# make_blog_min <dir> <count> [posts_per_page]
+# A blog with exactly <count> plain posts and nothing tricky.
+
+# copy_fixture <name> <dest>
+# Copies a committed fixture blog to a scratch directory. Fixtures are
+# committed rather than generated so the legacy layouts they carry stay
+# byte-identical to what shipped before the record format changed.
+
+
+# Same, but keeps stderr separate in $stderr_output.
+
+# ---------------------------------------------------------------- link checker
+
+# check_links <dist_dir>
+# Extracts every href/src from every .html file under <dist_dir> and verifies
+# that site-relative and document-relative targets resolve to a real file,
+# treating <dist_dir> as the document root. Prints one line per broken link.
+# Returns 0 when everything resolves.
+
+# feed_item_count <feed.xml>
+
+# index_post_count <index.html> -- number of real post links (excludes break marker)
diff --git a/test/regressions.sh b/test/regressions.sh
new file mode 100755
index 0000000..cd6f732
--- /dev/null
+++ b/test/regressions.sh
@@ -0,0 +1,241 @@
+#!/usr/bin/env bash
+# Regression tests: one case per bug the parser has had.
+# Run: test/regressions.sh   (or test/run.sh for everything)
+
+set -uo pipefail
+source "$(dirname "${BASH_SOURCE[0]}")/assert.bash"
+
+REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+AWK="${AWK:-awk}"
+GREP="${GREP:-grep}"
+
+md_render() {
+  printf '%s\n' "$1" | "$AWK" -f "$REPO_ROOT/markdown.awk"
+}
+
+suite "nested lists"
+
+# The nesting machinery existed but the indent unit was a tab or four spaces,
+# while a child is conventionally indented to its parent's content column --
+# two spaces for "- ". Two-space nesting, the common form, came out flat.
+
+it "a two-space child nests inside its parent"
+out=$(md_render '- a
+  - b
+- c')
+assert_contains "$out" '<li>
+a
+<ul>'
+
+it "a sibling after a nested list rejoins the outer list"
+# The closer walked past the target depth because it compared against pblock,
+# which is assigned nowhere and so was always empty. It closed the outer <ul>
+# too, and the sibling opened a second one.
+assert_eq "$(printf '%s' "$out" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "2"
+assert_eq "$(printf '%s' "$out" | "$GREP" -o '</ul>' | wc -l | tr -d ' ')" "2"
+
+it "three levels of two-space nesting all nest"
+deep=$(md_render '- a
+  - b
+    - c
+- d')
+assert_eq "$(printf '%s' "$deep" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "3"
+
+it "four-space nesting still works"
+# Two spaces is the step, not an alternative to four: written as an
+# alternation with four first, a four-space indent matched the wide branch and
+# peeled only one level, collapsing three levels into two.
+four=$(md_render '- a
+    - b
+- c')
+assert_eq "$(printf '%s' "$four" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "2"
+
+it "a flat list is unaffected"
+flat=$(md_render '- a
+- b
+- c')
+assert_eq "$(printf '%s' "$flat" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "1"
+assert_eq "$(printf '%s' "$flat" | "$GREP" -o '<li>' | wc -l | tr -d ' ')" "3"
+
+it "ordered lists nest too"
+onest=$(md_render '1. a
+  1. b
+2. c')
+assert_eq "$(printf '%s' "$onest" | "$GREP" -o '<ol>' | wc -l | tr -d ' ')" "2"
+
+suite "strikethrough"
+
+it "doubled tildes become del"
+assert_contains "$(md_render 'Plain ~~struck~~ here.')" '<del>struck</del>'
+
+it "text after a strike containing emphasis is not lost"
+# nextil shares t1/tag/t2 globally, so parsing the struck text clobbered the
+# tail still waiting in the outer frame -- the rest of the line vanished.
+assert_contains "$(md_render '~~*it*~~ TAIL')" '<del><em>it</em></del> TAIL'
+
+it "two strikes on one line both close"
+two=$(md_render '~~one~~ mid ~~*two*~~ end')
+assert_eq "$(printf '%s' "$two" | "$GREP" -o '<del>' | wc -l | tr -d ' ')" "2"
+assert_contains "$two" 'end'
+
+it "a single tilde stays literal"
+# "~/path" and "approx ~5" must survive untouched.
+assert_contains "$(md_render 'Path ~/x and approx ~5 items.')" '~/x and approx ~5'
+
+it "an unpaired double tilde stays literal"
+# Rather than opening a <del> that runs to the end of the document.
+assert_contains "$(md_render 'Unpaired ~~ stays.')" 'Unpaired ~~ stays.'
+
+it "tildes inside code are untouched"
+assert_contains "$(md_render 'A `~~code~~` span.')" '<code>~~code~~</code>'
+
+suite "underscores in inline HTML"
+
+it "an underscore that is a tag's whole content is literal"
+# <span class="us">_</span> writes a visible underscore. It opened an <em>
+# that swallowed the markup after it.
+assert_contains "$(md_render 'x <span class="us">_</span> y')" '<span class="us">_</span>'
+
+it "two such underscores do not pair across their tags"
+# They emitted mis-nested "<b><em></b> ... <b></em></b>".
+pair=$(md_render 'A <b>_</b> and <b>_</b> here.')
+assert_contains "$pair" '<b>_</b> and <b>_</b>'
+assert_not_contains "$pair" '<em>'
+
+it "real emphasis still works"
+assert_contains "$(md_render 'Real _emphasis_ here.')" '<em>emphasis</em>'
+
+it "bold still works"
+assert_contains "$(md_render 'Real __bold__ here.')" '<strong>bold</strong>'
+
+it "emphasis spanning a tag still works"
+assert_contains "$(md_render 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more</em>'
+
+it "intraword underscores are still literal"
+assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name'
+
+suite "headings are addressable"
+
+# A document long enough to link within needs its headings to be targets. Any
+# "#section" link -- from its own contents list, from a sibling page, from
+# somewhere else entirely -- resolves to nothing without them. Publishing a
+# real thesis found 23 such links pointing at nothing.
+
+it "a heading carries an id made from its words"
+assert_contains "$(md_render '## Vision')" '<h2 id="vision">'
+
+it "punctuation and spacing collapse to single dashes"
+assert_contains "$(md_render '## Scope (what MDD is not)')" 'id="scope-what-mdd-is-not"'
+
+it "inline markup is stripped before slugging"
+# A heading may be emphasised or hold code; "<code>Vision</code>" must still
+# slug to "vision".
+assert_contains "$(md_render '## The `Blueprint` Artifact')" 'id="the-blueprint-artifact"'
+assert_contains "$(md_render '## Scope (what MDD is *not*)')" 'id="scope-what-mdd-is-not"'
+
+it "every level is addressable"
+assert_contains "$(md_render '# One')" '<h1 id="one">'
+assert_contains "$(md_render '###### Six')" '<h6 id="six">'
+
+it "a heading with no sluggable characters gets no id"
+# An empty id is worse than none: it is not a valid target and would collide
+# with every other unsluggable heading.
+out=$(md_render '## ---')
+assert_not_contains "$out" 'id=""'
+
+it "a paragraph carries a deep-link id"
+# Headings were addressable and paragraphs were not, so a reader could link to
+# a section but not to the sentence they meant.
+assert_contains "$(md_render 'Just a paragraph.')" 'id="p-'
+
+it "a paragraph's id is derived from its own text, not its position"
+# The point of hashing the text: inserting a paragraph above must not move the
+# anchor of the one below it. A positional id would keep resolving after the
+# insertion and quietly point at the wrong paragraph, which is worse than
+# breaking, because the link still looks like it works.
+before=$(md_render 'Alpha text.
+
+Beta text.' | "$GREP" -o 'id="p-[a-f0-9]*"' | tail -1)
+after=$(md_render 'Inserted first.
+
+Alpha text.
+
+Beta text.' | "$GREP" -o 'id="p-[a-f0-9]*"' | tail -1)
+assert_eq "$before" "$after"
+
+it "an id changes when the paragraph's own words change"
+# The other half of the contract, and the honest failure: the text someone
+# linked to is not there any more, so the link should not silently land on
+# something else.
+one=$(md_render 'Alpha text.' | "$GREP" -o 'id="p-[a-f0-9]*"')
+two=$(md_render 'Alpha text edited.' | "$GREP" -o 'id="p-[a-f0-9]*"')
+if [ "$one" != "$two" ]; then pass; else fail "id did not change with the text: $one"; fi
+
+it "repeated text still gets unique ids"
+# Two paragraphs with the same words hash the same; ids on one page must not
+# collide or the second is unreachable.
+dup=$(md_render 'Same words.
+
+Same words.' | "$GREP" -c 'id="p-')
+assert_eq "$dup" "2"
+assert_contains "$(md_render 'Same words.
+
+Same words.')" '-2"'
+
+suite "diagrams"
+
+# A diagram fence is held for a client-side renderer instead of being shown as
+# source. jenny ships no renderer and pulls nothing from a CDN -- these test
+# the fence rule and the opt-in hook only; the bundle, if a site wants one, is
+# the site's own file.
+
+it "a mermaid fence becomes a diagram div"
+assert_contains "$(md_render '```mermaid
+flowchart LR
+  A --> B
+```')" '<div class="diagram diagram-mermaid">'
+
+it "diagram source is not HTML-escaped"
+# The renderer reads the element's text content and parses it, so escaping
+# "-->" to "--&gt;" would break every arrow in the diagram. This is the one
+# place a fence's content is left raw.
+dia=$(md_render '```mermaid
+flowchart LR
+  A["x<br/>y"] -->|"label"| B
+```')
+assert_contains "$dia" '-->'
+assert_not_contains "$dia" '--&gt;'
+assert_contains "$dia" '<br/>'
+
+it "the diagram fence closes with a div, not a code block"
+assert_contains "$dia" '</div>'
+assert_not_contains "$dia" '</code></pre>'
+
+it "the legend variant keeps its own class"
+assert_contains "$(md_render '```mermaid-legend
+flowchart LR
+  A --> B
+```')" 'diagram-mermaid-legend'
+
+it "an ordinary code fence is still escaped"
+# Only the fixed set of diagram languages is raw; everything else escapes as
+# before, so a document cannot introduce a new raw language.
+code=$(md_render '```bash
+echo "a" && b > c
+```')
+assert_contains "$code" '&amp;&amp;'
+assert_contains "$code" '<pre><code class="language-bash">'
+
+it "a bare fence is still escaped"
+assert_contains "$(md_render '```
+x > y & z
+```')" '&gt;'
+
+it "a language whose name merely starts with mermaid is not raw"
+notmer=$(md_render '```mermaidish
+a > b
+```')
+assert_contains "$notmer" '<pre><code'
+assert_not_contains "$notmer" '<div class="diagram'
+
+summary
diff --git a/test/run.sh b/test/run.sh
new file mode 100755
index 0000000..633324f
--- /dev/null
+++ b/test/run.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Runs every test file. Exits non-zero if any suite fails.
+cd "$(dirname "$0")" || exit 1
+
+failed=0
+for f in units.sh regressions.sh; do
+  printf '\n\033[1m=== %s\033[0m\n' "$f"
+  bash "$f" || failed=1
+done
+
+printf '\n'
+if [ "$failed" -eq 0 ]; then
+  printf '\033[32mall suites passed\033[0m\n'
+else
+  printf '\033[31msome suites failed\033[0m\n'
+fi
+exit $failed
diff --git a/test/units.sh b/test/units.sh
new file mode 100755
index 0000000..bd0e7c8
--- /dev/null
+++ b/test/units.sh
@@ -0,0 +1,452 @@
+#!/usr/bin/env bash
+# Unit tests for the inline and block parsing rules.
+# Run: test/units.sh   (or test/run.sh for everything)
+
+set -uo pipefail
+source "$(dirname "${BASH_SOURCE[0]}")/assert.bash"
+
+REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+AWK="${AWK:-awk}"
+SED="${SED:-sed}"
+
+suite "markdown: intraword underscores"
+
+# The deep-link id is stripped so these assertions can state the markup they
+# are about. Each is checking how a span of text is parsed, not that a
+# paragraph carries an anchor -- that has tests of its own, and pinning a
+# content hash in every one of these would make them unreadable and would
+# break them all again on any wording change.
+md() { printf '%s\n' "$1" | "$AWK" -f "$REPO_ROOT/markdown.awk" \
+       | "$SED" 's/<p id="p-[0-9a-f]*\(-[0-9]*\)\{0,1\}">/<p>/g'; }
+
+it "an underscore inside a word is literal"
+assert_eq "$(md 'node_modules here')" "<p>node_modules here</p>"
+
+it "snake_case survives intact"
+assert_eq "$(md 'snake_case_name')" "<p>snake_case_name</p>"
+
+it "a leading underscore still opens emphasis"
+assert_eq "$(md 'a _real_ emphasis')" "<p>a <em>real</em> emphasis</p>"
+
+it "intraword and real emphasis coexist on one line"
+assert_eq "$(md 'x_y and _z_')" "<p>x_y and <em>z</em></p>"
+
+it "asterisk emphasis is unaffected"
+assert_eq "$(md 'a *starred* word')" "<p>a <em>starred</em> word</p>"
+
+it "intraword asterisks still emphasise, as in CommonMark"
+assert_eq "$(md 'a*b*c')" "<p>a<em>b</em>c</p>"
+
+it "bold with underscores still works"
+assert_eq "$(md 'a __bold__ word')" "<p>a <strong>bold</strong> word</p>"
+
+it "no stray em is emitted for an identifier"
+assert_not_contains "$(md 'multi_word_ident text')" "<em>"
+
+suite "markdown: fenced code blocks"
+
+# Whitespace is significant inside <pre>, so the tags are joined to the first
+# and last content lines. A newline after the opening tag would render as a
+# blank first line in the block.
+it "a plain fence produces a code block"
+assert_eq "$(md '```
+echo hi
+```')" "<pre><code>echo hi</code></pre>"
+
+it "no blank line is introduced at the top of a block"
+assert_not_contains "$(md '```
+echo hi
+```')" "<code>
+"
+
+it "no blank line at the bottom either"
+assert_not_contains "$(md '```
+echo hi
+```')" "
+</code>"
+
+it "interior blank lines are preserved"
+assert_contains "$(md '```
+one
+
+two
+```')" "one
+
+two"
+
+it "the fence markers are not in the output"
+assert_not_contains "$(md '```
+echo hi
+```')" '`'
+
+it "a language tag becomes a class"
+assert_contains "$(md '```bash
+echo hi
+```')" 'class="language-bash"'
+
+it "multiple lines are preserved separately"
+out=$(md '```
+line one
+line two
+```')
+assert_contains "$out" "line one
+line two"
+
+it "HTML inside a fence is escaped"
+assert_contains "$(md '```
+<b>x</b> & y
+```')" "&lt;b&gt;x&lt;/b&gt; &amp; y"
+
+it "markdown inside a fence is left alone"
+assert_contains "$(md '```
+*not emphasis* and _not_ either
+```')" "*not emphasis* and _not_ either"
+
+it "smart quotes do not touch fenced content"
+assert_contains "$(md '```
+say "quoted" here
+```')" 'say "quoted" here'
+
+it "text around a fence still renders"
+out=$(md 'Before.
+
+```
+code
+```
+
+After.')
+assert_contains "$out" "<p>Before.</p>"
+
+it "text after a fence is a separate paragraph"
+assert_contains "$out" "<p>After.</p>"
+
+it "a tilde fence works too"
+assert_contains "$(md '~~~
+echo hi
+~~~')" "<pre><code>"
+
+it "an empty fence is handled"
+assert_contains "$(md '```
+```')" "<pre><code>"
+
+it "indented code blocks still work"
+assert_contains "$(md 'Text:
+
+    indented')" "<pre><code>"
+
+suite "markdown: reference links"
+
+it "an explicit label resolves"
+assert_contains "$(md 'See [the docs][d].
+
+[d]: http://example.com')" '<a href="http://example.com">the docs</a>'
+
+it "the definition does not appear in the output"
+assert_not_contains "$(md 'See [the docs][d].
+
+[d]: http://example.com')" 'http://example.com<'
+
+it "an implicit label reuses the link text"
+assert_contains "$(md 'See [docs][].
+
+[docs]: http://example.com')" '<a href="http://example.com">docs</a>'
+
+it "a shortcut reference resolves"
+assert_contains "$(md 'See [docs].
+
+[docs]: http://example.com')" '<a href="http://example.com">docs</a>'
+
+it "labels are case-insensitive"
+assert_contains "$(md 'See [Docs][D].
+
+[d]: http://example.com')" 'href="http://example.com"'
+
+it "a title is carried through"
+assert_contains "$(md 'See [x][d].
+
+[d]: http://example.com "The Title"')" 'title="The Title"'
+
+it "a definition before its use resolves"
+assert_contains "$(md '[d]: http://example.com
+
+See [x][d].')" 'href="http://example.com"'
+
+it "an unresolved reference is left as literal text"
+out=$(md 'See [x][nope].')
+assert_not_contains "$out" '<a href="">'
+
+it "inline links are unaffected"
+assert_contains "$(md 'See [x](http://example.com).')" '<a href="http://example.com">x</a>'
+
+it "footnotes still work alongside references"
+out=$(md 'Text[^1] and [x][d].
+
+[^1]: note
+[d]: http://example.com')
+assert_contains "$out" 'href="http://example.com"'
+
+it "the footnote still renders"
+assert_contains "$out" 'fn-item'
+
+it "a definition inside a fence is literal"
+assert_contains "$(md '```
+[d]: http://example.com
+```')" '[d]: http://example.com'
+
+suite "markdown: MMD metadata block"
+
+it "a metadata pair is wrapped, not stripped"
+assert_contains "$(md 'Series: Web Typography
+
+# Title')" 'data-key="Series"'
+
+it "the value is exposed as an attribute"
+assert_contains "$(md 'Series: Web Typography
+
+# Title')" 'data-value="Web Typography"'
+
+it "the original text is preserved verbatim"
+assert_contains "$(md 'Series: Web Typography
+
+# Title')" '>Series: Web Typography</p>'
+
+it "the block carries a class the theme can target"
+assert_contains "$(md 'Series: Web Typography
+
+# Title')" 'class="meta"'
+
+it "the body still renders"
+# Headings carry an id now, so this asserts the text rather than the exact tag.
+assert_contains "$(md 'Series: Web Typography
+
+# Title')" ">Title</h1>"
+
+it "multiple keys each get their own element"
+out=$(md 'Series: Web Typography
+Author: Pat
+
+# Title')
+assert_contains "$out" 'data-key="Series"'
+
+it "the second key is wrapped too"
+assert_contains "$out" 'data-key="Author"'
+
+it "tags are wrapped like any other key"
+assert_contains "$(md 'tags: alpha beta
+
+# Title')" 'data-key="tags"'
+
+it "a multi-word key is preserved verbatim"
+assert_contains "$(md 'Base Header Level: 2
+
+# Title')" 'data-key="Base Header Level"'
+
+# MultiMarkdown makes no attempt to tell metadata from prose that happens to
+# start Key: value -- position is the whole rule. Verified against
+# multimarkdown 6.6.0, which renders "Note: this is prose." at the top of a
+# file as <meta name="note">. Matching that rather than inventing a smarter
+# heuristic keeps the parser predictable for anyone who knows MMD.
+it "a leading Key: value line is metadata even if it reads as prose"
+assert_contains "$(md 'Note: this is ordinary prose.
+
+# Title')" 'data-key="Note"'
+
+it "such a line keeps its text, so nothing is lost either way"
+assert_contains "$(md 'Note: this is ordinary prose.
+
+# Title')" '>Note: this is ordinary prose.</p>'
+
+it "a block below the top of the file is prose"
+assert_not_contains "$(md '# Title
+
+Series: Web Typography')" 'data-key="Series"'
+
+it "a document with no metadata emits no meta element"
+assert_not_contains "$(md '# Title
+
+Body.')" 'class="meta"'
+
+it "attribute values are escaped"
+assert_contains "$(md 'Series: A & B <x>
+
+# Title')" 'data-value="A &amp; B &lt;x&gt;"'
+
+suite "markdown: tables"
+
+it "a basic table renders as a table"
+assert_contains "$(md '| A | B |
+|---|---|
+| 1 | 2 |')" "<table>"
+
+it "the header row becomes thead"
+assert_contains "$(md '| A | B |
+|---|---|
+| 1 | 2 |')" "<thead>"
+
+it "header cells are th"
+assert_contains "$(md '| A | B |
+|---|---|
+| 1 | 2 |')" "<th"
+
+it "body cells are td"
+assert_contains "$(md '| A | B |
+|---|---|
+| 1 | 2 |')" "<td"
+
+it "the separator row is not rendered"
+assert_not_contains "$(md '| A | B |
+|---|---|
+| 1 | 2 |')" "---"
+
+it "cell text is preserved"
+out=$(md '| A | B |
+|---|---|
+| 1 | 2 |')
+assert_contains "$out" "A"
+
+it "left alignment is applied"
+assert_contains "$(md '| L |
+|:--|
+| 1 |')" "text-align:left"
+
+it "centre alignment is applied"
+assert_contains "$(md '| C |
+|:-:|
+| 1 |')" "text-align:center"
+
+it "right alignment is applied"
+assert_contains "$(md '| R |
+|--:|
+| 1 |')" "text-align:right"
+
+it "pipes at the row edges are optional"
+assert_contains "$(md 'A | B
+--- | ---
+1 | 2')" "<table>"
+
+it "inline markdown inside a cell is parsed"
+assert_contains "$(md '| **bold** |
+|---|
+| a |')" "<strong>bold</strong>"
+
+it "a link inside a cell is parsed"
+assert_contains "$(md '| x |
+|---|
+| [y](http://e.com) |')" '<a href="http://e.com">y</a>'
+
+it "multiple body rows all render"
+out=$(md '| A |
+|---|
+| 1 |
+| 2 |')
+assert_eq "$(printf '%s' "$out" | grep -c '<td')" "2"
+
+it "the table closes"
+assert_contains "$(md '| A |
+|---|
+| 1 |')" "</table>"
+
+it "text after a table is a separate paragraph"
+assert_contains "$(md '| A |
+|---|
+| 1 |
+
+After.')" "<p>After.</p>"
+
+it "a pipe in prose is not a table"
+assert_not_contains "$(md 'a | b in ordinary prose')" "<table>"
+
+# Markdown deliberately allows inline HTML, in cells as in prose.
+# multimarkdown 6.6.0 emits <th> <b>x</b> </th> for this, and this parser's
+# prose handling passes raw HTML through too, so a cell must not differ.
+it "raw html in a cell passes through, as in prose"
+assert_contains "$(md '| <b>x</b> |
+|---|
+| a |')" "<b>x</b>"
+
+it "a table inside a fence is literal"
+assert_contains "$(md '```
+| A |
+|---|
+```')" "| A |"
+
+suite "markdown: citations"
+
+it "a citation renders an inline reference"
+assert_contains "$(md 'Shown[#smith].
+
+[#smith]: John Smith. 2011.')" 'class="citation"'
+
+it "the citation is numbered"
+assert_contains "$(md 'Shown[#smith].
+
+[#smith]: John Smith. 2011.')" '(1)'
+
+it "a locator is carried into the reference"
+assert_contains "$(md 'Shown[p. 42][#smith].
+
+[#smith]: John Smith. 2011.')" '(p. 42, 1)'
+
+it "the definition does not render as body text"
+assert_not_contains "$(md 'Shown[#smith].
+
+[#smith]: John Smith. 2011.')" '<p>[#smith]:'
+
+it "a citation list is emitted"
+assert_contains "$(md 'Shown[#smith].
+
+[#smith]: John Smith. 2011.')" 'class="citations"'
+
+it "the citation body appears in the list"
+assert_contains "$(md 'Shown[#smith].
+
+[#smith]: John Smith. 2011.')" 'John Smith. 2011.'
+
+it "markdown inside a citation body is parsed"
+assert_contains "$(md 'Shown[#smith].
+
+[#smith]: John Smith. *A Book*. 2011.')" '<em>A Book</em>'
+
+it "the inline reference links to the entry"
+assert_contains "$(md 'Shown[#smith].
+
+[#smith]: John Smith. 2011.')" 'href="#cn:1"'
+
+it "the entry links back to the reference"
+assert_contains "$(md 'Shown[#smith].
+
+[#smith]: John Smith. 2011.')" 'href="#cnref:1"'
+
+it "numbering follows first use"
+out=$(md 'A[#x] and B[#y].
+
+[#x]: First.
+[#y]: Second.')
+assert_contains "$out" '(1)'
+
+it "the second citation gets the next number"
+assert_contains "$out" '(2)'
+
+it "a repeated citation reuses its number"
+out=$(md 'A[#x], B[#y], C[#x].
+
+[#x]: First.
+[#y]: Second.')
+assert_eq "$(printf '%s' "$out" | grep -o 'class="citation">(1)' | wc -l | tr -d ' ')" "2"
+
+it "footnotes still work alongside citations"
+out=$(md 'A[^1] and B[#x].
+
+[^1]: a note
+[#x]: a citation')
+assert_contains "$out" 'fn-item'
+
+it "the citation still renders too"
+assert_contains "$out" 'class="citations"'
+
+it "a citation inside a fence is literal"
+assert_contains "$(md '```
+[#x]: literal
+```')" '[#x]: literal'
+
+summary