ssh git@grit.deadkey.org man

Commit 5479d3a h

Pat Ambrosio · 26-09-05 10:06

Take the markdown parser as a submodule instead of vendoring it

Changes: 9 files +26 -1034

Changes (Side-by-Side)

.gitmodules +3 -0

1 [submodule "share/jenny/lib/markdown.awk"]
2 path = share/jenny/lib/markdown.awk
3 url = git://grit.deadkey.org/mailmanpat/markdown.awk.git

bin/jenny +1 -1

if source_blogrc; then
142 [ -z "${POSTS_PER_PAGE:-}" ] && POSTS_PER_PAGE=10 142 [ -z "${POSTS_PER_PAGE:-}" ] && POSTS_PER_PAGE=10
143 [ -z "${DIST:-}" ] && DIST=$PWD/.dist 143 [ -z "${DIST:-}" ] && DIST=$PWD/.dist
144 [ -z "${LAYOUT_DIR:-}" ] && LAYOUT_DIR=$SHARE_DIR/layout 144 [ -z "${LAYOUT_DIR:-}" ] && LAYOUT_DIR=$SHARE_DIR/layout
145 [ -z "${MARKDOWN_COMMAND:-}" ] && MARKDOWN_COMMAND="\$AWK -f \$SHARE_DIR/lib/md2html.awk"
145 [ -z "${MARKDOWN_COMMAND:-}" ] && MARKDOWN_COMMAND="\$AWK -f \$SHARE_DIR/lib/markdown.awk/markdown.awk"
146 [ -z "${FEED_MAX_ITEMS:-}" ] && FEED_MAX_ITEMS=20 146 [ -z "${FEED_MAX_ITEMS:-}" ] && FEED_MAX_ITEMS=20
147 [ -z "${URL_STYLE:-}" ] && URL_STYLE=html 147 [ -z "${URL_STYLE:-}" ] && URL_STYLE=html
148 148

share/jenny/layout/page.sh +1 -1

cat << _EOF_
978 .contents h1:not(:is(h1, h2) ~ *)::before { content: none; } 978 .contents h1:not(:is(h1, h2) ~ *)::before { content: none; }
979 ol, ul { padding-left: 1em; } 979 ol, ul { padding-left: 1em; }
980 /* Notes and references are two different lists, so each is labelled. 980 /* Notes and references are two different lists, so each is labelled.
981 The wording lives here rather than in the parser: md2html.awk marks
981 The wording lives here rather than in the parser: markdown.awk marks
982 them with classes and leaves naming to the theme, which keeps 982 them with classes and leaves naming to the theme, which keeps
983 English out of the parser and lets a theme relabel or hide either 983 English out of the parser and lets a theme relabel or hide either
984 by overriding one rule. Styled to match an h5. */ 984 by overriding one rule. Styled to match an h5. */

share/jenny/layout/post.sh +1 -1

cat << _EOF_
799 .contents h1:not(:is(h1, h2) ~ *)::before { content: none; } 799 .contents h1:not(:is(h1, h2) ~ *)::before { content: none; }
800 ol, ul { padding-left: 1em; } 800 ol, ul { padding-left: 1em; }
801 /* Notes and references are two different lists, so each is labelled. 801 /* Notes and references are two different lists, so each is labelled.
802 The wording lives here rather than in the parser: md2html.awk marks
802 The wording lives here rather than in the parser: markdown.awk marks
803 them with classes and leaves naming to the theme, which keeps 803 them with classes and leaves naming to the theme, which keeps
804 English out of the parser and lets a theme relabel or hide either 804 English out of the parser and lets a theme relabel or hide either
805 by overriding one rule. Styled to match an h5. */ 805 by overriding one rule. Styled to match an h5. */

share/jenny/lib/helpers.sh +11 -2

function build_signature() {
211 # surely as a template does: adding ids to headings changed the output of 211 # surely as a template does: adding ids to headings changed the output of
212 # every page, and without this the lock skipped them all -- the build said 212 # every page, and without this the lock skipped them all -- the build said
213 # nothing had changed while the change was in the markdown parser. 213 # nothing had changed while the change was in the markdown parser.
214 find "${JENNY_LIB_DIR:-}" -maxdepth 1 -type f \( -name '*.sh' -o -name '*.awk' \) \
214 #
215 # No -maxdepth: the parser is a submodule now, one directory down, and a
216 # depth-limited scan stopped seeing it. Verified before the fix -- editing
217 # the parser left the signature identical, which is the same silent
218 # skip-everything this line was added to prevent, returned by a different
219 # route. The submodule's own tests are excluded: they change without
220 # changing a single rendered byte, and hashing them would rebuild the
221 # whole site for a test edit.
222 find "${JENNY_LIB_DIR:-}" -type f \( -name '*.sh' -o -name '*.awk' \) \
223 -not -path '*/test/*' \
215 -print0 2>/dev/null \ 224 -print0 2>/dev/null \
216 | LC_ALL=C sort -z \ 225 | LC_ALL=C sort -z \
217 | xargs -0 cat 2>/dev/null 226 | xargs -0 cat 2>/dev/null
function read_nav() {
848 # 857 #
849 # The parser owns what counts as metadata and wraps each pair in the rendered 858 # The parser owns what counts as metadata and wraps each pair in the rendered
850 # output. jenny reads the same block from the source to hydrate variables the 859 # output. jenny reads the same block from the source to hydrate variables the
851 # layout can use, so there is one definition of the syntax, in md2html.awk,
860 # layout can use, so there is one definition of the syntax, in markdown.awk,
852 # and this stays a straight read of the same shape. 861 # and this stays a straight read of the same shape.
853 862
854 # get_metadata <file> 863 # get_metadata <file>

share/jenny/lib/markdown.awk +1 -0

1 Subproject commit 8cd72df31b3ae05a98735b419311da6274329401

share/jenny/lib/md2html.awk +0 -1027

1 #!/bin/awk -f
2 #
3 # by: Jesus Galan (yiyus) 2009
4 #
5 # Usage: md2html.awk file.md > file.html
6 # See: http://4l77.com/src/md2html.awk
7
8 # Attribute values need quotes escaped as well as the usual entities.
9 function escattr(t) {
10 t = eschtml(t);
11 gsub(">", "\\&gt;", t);
12 gsub("\"", "\\&quot;", t);
13 return t;
14 }
15
16 # Fenced content is fully literal, so > is escaped too. eschtml is shared with
17 # inline parsing, where a bare > is legitimate text, so it is left alone.
18 function escfence(t) {
19 t = eschtml(t);
20 gsub(">", "\\&gt;", t);
21 return t;
22 }
23
24 function eschtml(t) {
25 gsub("&", "\\&amp;", t);
26 gsub("<", "\\&lt;", t);
27 return t;
28 }
29
30 function oprint(t){
31 if(nr == 0)
32 print t;
33 else
34 otext = otext "\n" t;
35 }
36
37 function subref(id){
38 for(; nr > 0 && sub("<<" id, ref[id], otext); nr--);
39 if(nr == 0 && otext) {
40 print otext;
41 otext = "";
42 }
43 }
44
45 # Escapes regex metacharacters so a literal string can be interpolated into
46 # a dynamic regex. Without this "^" "*" reaches the regex engine, which mawk
47 # rejects outright and gawk quietly mismatches.
48 function re_quote(s, out, i, c) {
49 out = "";
50 for(i = 1; i <= length(s); i++) {
51 c = substr(s, i, 1);
52 if(index("\\^$.[]|()*+?{}/", c) > 0)
53 out = out "\\" c;
54 else
55 out = out c;
56 }
57 return out;
58 }
59
60 function nextil(t) {
61 if(!match(t, /[`<&\[*_~\\-]|(!\[)|(\[\^)|(\[#)/))
62 return t;
63 t1 = substr(t, 1, RSTART - 1);
64 tag = substr(t, RSTART, RLENGTH);
65 t2 = substr(t, RSTART + RLENGTH);
66 if(ilcode && tag != "`")
67 return eschtml(t1 tag) nextil(t2);
68 # Backslash escaping
69 if(tag == "\\"){
70 if(match(t2, /^[\\`*_{}\[\]()#+\-\.!]/)){
71 tag = substr(t2, 1, 1);
72 t2 = substr(t2, 2);
73 }
74 return t1 tag nextil(t2);
75 }
76 # Dashes
77 if(tag == "-"){
78 if(sub(/^-/, "", t2))
79 tag = "&#8212;";
80 return t1 tag nextil(t2);
81 }
82 # Inline Code
83 if(tag == "`"){
84 if(sub(/^`/, "", t2)){
85 if(!match(t2, /``/))
86 return t1 "&#8221;" nextil(t2);
87 ilcode2 = !ilcode2;
88 }
89 else if(ilcode2)
90 return t1 tag nextil(t2);
91 tag = "<code>";
92 if(ilcode){
93 t1 = eschtml(t1);
94 tag = "</code>";
95 }
96 ilcode = !ilcode;
97 return t1 tag nextil(t2);
98 }
99 if(tag == "<"){
100 # Autolinks
101 if(match(t2, /^[^ ]+[\.@][^ ]+>/)){
102 url = eschtml(substr(t2, 1, RLENGTH - 1));
103 t2 = substr(t2, RLENGTH + 1);
104 linktext = url;
105 if(match(url, /@/) && !match(url, /^mailto:/))
106 url = "mailto:" url;
107 return t1 "<a href=\"" url "\">" linktext "</a>" nextil(t2);
108 }
109 # Html tags
110 if(match(t2, /^[A-Za-z\/!][^>]*>/)){
111 tag = tag substr(t2, RSTART, RLENGTH);
112 t2 = substr(t2, RLENGTH + 1);
113 return t1 tag nextil(t2);
114 }
115 return t1 "&lt;" nextil(t2);
116 }
117 # Html special entities
118 if(tag == "&"){
119 if(match(t2, /^#?[A-Za-z0-9]+;/)){
120 tag = tag substr(t2, RSTART, RLENGTH);
121 t2 = substr(t2, RLENGTH + 1);
122 return t1 tag nextil(t2);
123 }
124 return t1 "&amp;" nextil(t2);
125 }
126 # Images
127 if(tag == "!["){
128 if(!match(t2, /(\[.*\])|(\(.*\))/))
129 return t1 tag nextil(t2);
130 match(t2, /^[^\]]*/);
131 alt = substr(t2, 1, RLENGTH);
132 t2 = substr(t2, RLENGTH + 2);
133 if(match(t2, /^\(/)){
134 # Inline
135 sub(/^\(/, "", t2);
136 match(t2, /^[^\)]+/);
137 url = eschtml(substr(t2, 1, RLENGTH));
138 t2 = substr(t2, RLENGTH + 2);
139 title = "";
140 if(match(url, /[ ]+".*"[ ]*$/)) {
141 title = substr(url, RSTART, RLENGTH);
142 url = substr(url, 1, RSTART - 1);
143 match(title, /".*"/);
144 title = " title=\"" substr(title, RSTART + 1, RLENGTH - 2) "\"";
145 }
146 if(match(url, /^<.*>$/))
147 url = substr(url, 2, RLENGTH - 2);
148 return t1 "<img src=\"" url "\" alt=\"" alt "\"" title " />" nextil(t2);
149 }
150 else{
151 # Referenced
152 sub(/^ ?\[/, "", t2);
153 id = alt;
154 if(match(t2, /^[^\]]+/))
155 id = substr(t2, 1, RLENGTH);
156 t2 = substr(t2, RLENGTH + 2);
157 if(ref[id])
158 r = ref[id];
159 else{
160 r = "<<" id;
161 nr++;
162 }
163 return t1 "<img src=\"" r "\" alt=\"" alt "\" />" nextil(t2);
164 }
165 }
166 # Citations
167 #
168 # [#id] or [locator][#id]. Numbered by first use; a repeat reuses the
169 # number, matching multimarkdown.
170 if(tag == "[#"){
171 match(t2, /^[^\]]*/);
172 cid = substr(t2, 1, RLENGTH);
173 t2 = substr(t2, RLENGTH + 2);
174 if(!(cid in cite_num)){
175 cite_num[cid] = ++cite_n;
176 cite_order[cite_n] = cid;
177 }
178 cnum = cite_num[cid];
179 # A locator set by the link branch for the [locator][#id] form.
180 cloc = cite_locator;
181 cite_locator = "";
182 clabel = (cloc != "") ? "(" cloc ", " cnum ")" : "(" cnum ")";
183 return t1 "<a href=\"#cn:" cnum "\" id=\"cnref:" cnum "\" data-citation=\"" cid \
184 "\" title=\"see citation\" class=\"citation\">" clabel "</a>" nextil(t2);
185 }
186
187 # Footnotes
188 if(tag == "[^"){
189 match(t2, /^[^\]]*(\[[^\]]*\][^\]]*)*/);
190 linktext = substr(t2, 1, RLENGTH);
191 t2 = substr(t2, RLENGTH + 2);
192 return t1 "<sup class=\"fnref\" data-fnref=\"" linktext "\"><a href=\"#fn-" linktext "\" id=\"fnref-" linktext "\">" linktext "</a></sup>" nextil(t2);
193 }
194 # Links
195 if(tag == "["){
196 # A shortcut reference is a bare [text] with no following [] or ().
197 # It is only a link if a definition matching the text exists, so the
198 # early return here must not fire for one -- it is decided below.
199 if(!match(t2, /(\[.*\])|(\(.*\))/)){
200 if(!match(t2, /^[^\]]*\]/))
201 return t1 tag nextil(t2);
202 shortcut_candidate = 1;
203 }
204 else
205 shortcut_candidate = 0;
206 match(t2, /^[^\]]*(\[[^\]]*\][^\]]*)*/);
207 linktext = substr(t2, 1, RLENGTH);
208 t2 = substr(t2, RLENGTH + 2);
209 if(match(t2, /^\(/)){
210 # Inline
211 match(t2, /^[^\)]+(\([^\)]+\)[^\)]*)*/);
212 url = substr(t2, 2, RLENGTH - 1);
213 pt2 = substr(t2, RLENGTH + 2);
214 title = "";
215 if(match(url, /[ ]+".*"[ ]*$/)) {
216 title = substr(url, RSTART, RLENGTH);
217 url = substr(url, 1, RSTART - 1);
218 match(title, /".*"/);
219 title = " title=\"" substr(title, RSTART + 1, RLENGTH - 2) "\"";
220 }
221 if(match(url, /^<.*>$/))
222 url = substr(url, 2, RLENGTH - 2);
223 url = eschtml(url);
224 return t1 "<a href=\"" url "\"" title ">" nextil(linktext) "</a>" nextil(pt2);
225 }
226 else{
227 # Referenced: [text][id], [text][] or the shortcut [text].
228 id = linktext;
229 shortcut = 1;
230 if(match(t2, /^ ?\[#/)){
231 # [locator][#id] is a citation, not a reference link. The
232 # locator is handed to the citation branch directly; nextil
233 # has already split t1 away, so it cannot be recovered there.
234 sub(/^ ?\[/, "", t2);
235 cite_locator = linktext;
236 return t1 nextil("[" t2);
237 }
238 if(match(t2, /^ ?\[/)){
239 shortcut = 0;
240 sub(/^ ?\[/, "", t2);
241 if(match(t2, /^[^\]]*/)){
242 # An empty [] means "reuse the link text as the label".
243 if(RLENGTH > 0)
244 id = substr(t2, 1, RLENGTH);
245 t2 = substr(t2, RLENGTH + 2);
246 }
247 }
248 id = tolower(id);
249 if(ref[id])
250 r = ref[id];
251 else{
252 # The definition may appear later in the document, so the
253 # link is emitted with a placeholder that subref() fills in
254 # when the definition is read. A shortcut [text] that is
255 # still unresolved at EOF is restored to literal text by
256 # the END rule.
257 r = "<<" id;
258 nr++;
259 if(shortcut)
260 shortcut_ref[id] = linktext;
261 }
262 pt2 = t2;
263 return t1 "<a href=\"" r "\">" nextil(linktext) "</a>" nextil(pt2);
264 }
265 }
266 # Strikethrough
267 #
268 # Only the doubled form, which is what GFM defines and what authors
269 # write. A single ~ stays literal so that "~/path" and "approx ~5" are
270 # not silently turned into markup. An unpaired "~~" is left alone rather
271 # than opening a <del> that would run to the end of the document.
272 if(tag == "~"){
273 if(sub(/^~/, "", t2)){
274 if(match(t2, /~~/)){
275 strike = substr(t2, 1, RSTART - 1);
276 # t1, tag and t2 are globals shared with every nextil frame,
277 # so parsing the struck text clobbers the tail still waiting
278 # here -- it came back with whatever the inner call left
279 # behind, dropping the rest of the line. The link handler
280 # above keeps its own copy for the same reason.
281 strike_t1 = t1;
282 strike_rest = substr(t2, RSTART + 2);
283 strike_done = nextil(strike);
284 return strike_t1 "<del>" strike_done "</del>" nextil(strike_rest);
285 }
286 return t1 "~~" nextil(t2);
287 }
288 return t1 "~" nextil(t2);
289 }
290 # Emphasis
291 if(match(tag, /[*_]/)){
292 # An underscore between two word characters is literal, not an
293 # emphasis marker -- otherwise identifiers like node_modules and
294 # snake_case_name get torn apart, and an unmatched one leaves a
295 # stray <em> open for the rest of the document. CommonMark makes
296 # the same exception, and only for _: intraword * still emphasises.
297 if(tag == "_" && match(t1, /[A-Za-z0-9]$/) && match(t2, /^[A-Za-z0-9]/))
298 return t1 tag nextil(t2);
299 # An underscore that is the whole content of an HTML element is
300 # literal: <span class="us">_</span> is a way of writing a visible
301 # underscore, not emphasis. Without this it opened an <em> that
302 # swallowed the markup after it, and two such underscores paired with
303 # each other across the tags between them, emitting mis-nested
304 # "<b><em></b> ... <b></em></b>". CommonMark reaches the same result
305 # by its flanking rules: a delimiter preceded by punctuation and
306 # followed by punctuation is neither opening nor closing.
307 if(tag == "_" && t1 == "" && match(t2, /^</) && stag[ns] != "_" && stag[ns] != "__")
308 return t1 tag nextil(t2);
309 ntag = tag;
310 # `tag` is a literal * or _, so it must be escaped before being
311 # interpolated into a regex: "^" "*" is not a valid regex.
312 etag = "^" re_quote(tag);
313 if(sub(etag, "", t2)){
314 if(stag[ns] == tag && match(t2, etag))
315 t2 = tag t2;
316 else
317 ntag = tag tag
318 }
319 n = length(ntag);
320 tag = (n == 2) ? "strong" : "em";
321 if(match(t1, / $/) && match(t2, /^ /))
322 return t1 tag nextil(t2);
323 if(stag[ns] == ntag){
324 tag = "/" tag;
325 ns--;
326 }
327 else
328 stag[++ns] = ntag;
329 tag = "<" tag ">";
330 return t1 tag nextil(t2);
331 }
332 }
333
334 function inline(t) {
335 ilcode = 0;
336 ilcode2 = 0;
337 ns = 0;
338
339 return nextil(t);
340 }
341
342 # heading_id <text>
343 # The slug a heading is addressable by: lowercased, runs of anything that is
344 # not a letter or digit collapsed to a single dash, ends trimmed.
345 #
346 # A document long enough to link within needs its headings to be targets. Any
347 # "#section" link -- from its own table of contents, from a sibling page, from
348 # somewhere else entirely -- resolves to nothing without them.
349 #
350 # Inline markup is stripped first: a heading may be emphasised or hold code,
351 # and "<code>Vision</code>" must still slug to "vision".
352 #
353 # Collisions are left alone. Two headings with the same words produce the same
354 # id and the first wins, which is what a reader following a link expects and
355 # what every other implementation does; numbering them would make the ids
356 # depend on how many times a word appears earlier in the document.
357 function heading_id(t, s) {
358 s = t
359 gsub(/<[^>]*>/, "", s)
360 s = tolower(s)
361 gsub(/&[a-z]+;|&#[0-9]+;|&#x[0-9a-fA-F]+;/, "", s)
362 gsub(/[^a-z0-9]+/, "-", s)
363 sub(/^-+/, "", s)
364 sub(/-+$/, "", s)
365 return s
366 }
367
368 # para_id <html>
369 # The anchor a paragraph is addressable by: "p-" and a hash of the paragraph's
370 # own text.
371 #
372 # Derived from the content rather than the position, so a permalink survives
373 # edits elsewhere on the page -- insert three paragraphs above this one, or
374 # reorder the sections around it, and the link still resolves. It breaks only
375 # when this paragraph's own wording changes, which is the honest failure: the
376 # text someone linked to is not there any more. A positional p-7 would keep
377 # resolving after an insertion and quietly point at the wrong paragraph, which
378 # is worse than breaking.
379 #
380 # Inline markup is stripped first so that editing a link's target, or wrapping
381 # a word in emphasis, does not move the anchor.
382 function para_id(t, s) {
383 s = t
384 gsub(/<[^>]*>/, "", s)
385 gsub(/&[a-z]+;|&#[0-9]+;|&#x[0-9a-fA-F]+;/, "", s)
386 gsub(/[ \t]+/, " ", s)
387 sub(/^ /, "", s); sub(/ $/, "", s)
388 if (s == "") return ""
389 return "p-" fnv1a(s)
390 }
391
392 # fnv1a <string>
393 # FNV-1a, 32 bits, in awk rather than by calling out to a hash program: a page
394 # has hundreds of paragraphs and a sub-process each would be the slowest part
395 # of the build. Checked against every paragraph in the site at the time it was
396 # written -- no collisions between different texts.
397 function fnv1a(s, h, i, c, n) {
398 h = 2166136261
399 n = length(s)
400 for (i = 1; i <= n; i++) {
401 c = index(HASHCHARS, substr(s, i, 1))
402 if (c == 0) c = 32
403 h = xor32(h, c)
404 h = (h * 16777619) % 4294967296
405 }
406 return sprintf("%08x", h)
407 }
408
409 function xor32(a, b, r, i, x, y) {
410 r = 0; i = 1
411 while (a > 0 || b > 0) {
412 x = a % 2; y = b % 2
413 if (x != y) r += i
414 a = int(a / 2); b = int(b / 2); i *= 2
415 }
416 return r
417 }
418
419 function printp(tag, id) {
420 if(!match(text, /^[ ]*$/)){
421 text = inline(text);
422 if(tag != ""){
423 # Headings carry an id; nothing else does.
424 if(tag ~ /^h[1-6]$/){
425 id = heading_id(text)
426 if(id != "")
427 oprint("<" tag " id=\"" id "\">" text "</" tag ">");
428 else
429 oprint("<" tag ">" text "</" tag ">");
430 }
431 else if(tag == "p"){
432 # Two paragraphs with the same words hash the same; the second
433 # and later take a suffix so every id on the page is unique.
434 id = para_id(text)
435 if(id != ""){
436 PARA_SEEN[id]++
437 if(PARA_SEEN[id] > 1)
438 id = id "-" PARA_SEEN[id]
439 oprint("<p id=\"" id "\">" text "</p>");
440 }
441 else
442 oprint("<p>" text "</p>");
443 }
444 else
445 oprint("<" tag ">" text "</" tag ">");
446 }
447 else
448 oprint(text);
449 }
450 text = "";
451 }
452
453 BEGIN {
454 # Character table for fnv1a: index() gives each byte a value, so the hash
455 # needs no ord() -- which one-true-awk does not have.
456 HASHCHARS = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
457 blank = 0;
458 code = 0;
459 hr = 0;
460 html = 0;
461 nl = 0;
462 nr = 0;
463 otext = "";
464 text = "";
465 par = "p";
466 }
467
468 # References
469 # Reference link definitions: [id]: url "optional title"
470 # Standard markdown. The [^!id] form below predates this and is kept so any
471 # document using it still resolves. Labels are case-insensitive, so they are
472 # normalised to lowercase on both definition and use.
473 !code && !fence && /^ *\[[^\^#\]][^\]]*\]:[ ]+/ {
474 sub(/^ *\[/, "");
475 match($0, /\]/);
476 id = tolower(substr($0, 1, RSTART - 1));
477 sub(/^[^\]]*\]:[ ]+/, "");
478 title = "";
479 if(match($0, /".*"$/))
480 title = "\" title=\"" substr($0, RSTART + 1, RLENGTH - 2);
481 sub(/[ ]+".*"$/, "");
482 url = eschtml($0);
483 ref[id] = url title;
484
485 subref(id);
486 next;
487 }
488
489 !code && /^ *\[\^![^\]]*\]:[ ]+/ {
490 sub(/^ *\[\^!/, "");
491 match($0, /\]/);
492 id = substr($0, 1, RSTART - 1);
493 sub(id "\\]:[ ]+", "");
494 id = tolower(id);
495 title = "";
496 if(match($0, /".*"$/))
497 title = "\" title=\"" substr($0, RSTART + 1, RLENGTH - 2);
498 sub(/[ ]+".*"$/, "");
499 url = eschtml($0);
500 ref[id] = url title;
501
502 subref(id);
503 next;
504 }
505
506 !code && !fence && /^ *\[#[^\]]*\]:[ ]+/ {
507 sub(/^ *\[#/, "");
508 match($0, /\]/);
509 id = substr($0, 1, RSTART - 1);
510 sub(/^[^\]]*\]:[ ]+/, "");
511 cite_body[id] = $0;
512 next;
513 }
514
515 !code && /^ *\[\^[^\]]*\]:[ ]+/ {
516 sub(/^ *\[\^/, "");
517 match($0, /\]/);
518 id = substr($0, 1, RSTART - 1);
519 sub(id "\\]:[ ]+", "");
520 sub(/[ ]+".*"$/, "");
521 # Stored raw, like a citation's body: inline() escapes what needs it and
522 # resolves what does not. Pre-escaping turned every "<" into "&lt;" before
523 # the parser could see it, so an autolink -- <https://example.com> -- came
524 # out as literal text in a footnote while working in prose and in
525 # citations. The variable was called "url" because this rule began life
526 # handling link-reference definitions, where a bare URL was all it held.
527 fnref[id] = $0;
528
529 subref(id);
530 next;
531 }
532
533 # html
534 !html && /^<(address|blockquote|center|dir|div|dl|fieldset|form|h[1-6r]|\
535 isindex|menu|noframes|noscript|ol|p|pre|table|ul|!--)/ {
536 if(code)
537 oprint(code_held "</code></pre>"); code_held = "";
538 for(; !text && block[nl] == "blockquote"; nl--)
539 oprint("</blockquote>");
540 match($0, /^<(address|blockquote|center|dir|div|dl|fieldset|form|h[1-6r]|\
541 isindex|menu|noframes|noscript|ol|p|pre|table|ul|!--)/);
542 htag = substr($0, 2, RLENGTH - 1);
543 if(!match($0, "(<\\/" htag ">)|((^<hr ?\\/?)|(--)>$)"))
544 html = 1;
545 if(html && match($0, /^<hr/))
546 hr = 1;
547 oprint($0);
548 next;
549 }
550
551 html && (/(^<\/(address|blockquote|center|dir|div|dl|fieldset|form|h[1-6r]|\
552 isindex|menu|noframes|noscript|ol|p|pre|table|ul).*)|(--)>$/ ||
553 (hr && />$/)) {
554 html = 0;
555 hr = 0;
556 oprint($0);
557 next;
558 }
559
560 html {
561 oprint($0);
562 next;
563 }
564
565 # MultiMarkdown metadata block
566 #
567 # Key: value pairs at the very top of the file, ending at the first blank
568 # line. The pairs are preserved verbatim rather than stripped -- the author
569 # wrote them deliberately -- but each is wrapped so a theme can style or hide
570 # it, and so jenny can read the values back. Whether they are visible is a
571 # readability decision for the layout, not the parser's to make.
572 #
573 # The entire leading run must look like metadata. If any line before the first
574 # blank is not Key: value, the whole block is ordinary prose -- otherwise a
575 # post opening "Note: something" would silently become metadata.
576 #
577 # Lines are buffered rather than consumed with getline, so the main rules see
578 # them unchanged when the block turns out to be prose.
579 meta_state == 0 {
580 if(NR == 1 && $0 ~ /^[A-Za-z][A-Za-z0-9 _-]*:[ \t]*./){
581 meta_state = 1;
582 meta_n = 0;
583 meta_buf[++meta_n] = $0;
584 next;
585 }
586 meta_state = 2;
587 }
588
589 meta_state == 1 {
590 if($0 ~ /^[ \t]*$/){
591 # End of the block, and every line matched: emit them as metadata.
592 for(mi = 1; mi <= meta_n; mi++){
593 mline = meta_buf[mi];
594 match(mline, /:/);
595 mkey = substr(mline, 1, RSTART - 1);
596 mval = substr(mline, RSTART + 1);
597 sub(/^[ \t]+/, "", mval);
598 sub(/[ \t]+$/, "", mval);
599 oprint("<p class=\"meta\" data-key=\"" escattr(mkey) \
600 "\" data-value=\"" escattr(mval) "\">" eschtml(mline) "</p>");
601 }
602 meta_state = 2;
603 next;
604 }
605 if($0 ~ /^[A-Za-z][A-Za-z0-9 _-]*:[ \t]*./){
606 meta_buf[++meta_n] = $0;
607 next;
608 }
609 # Not a metadata block after all. The buffered lines are ordinary prose,
610 # and awk cannot re-run its own rules over them, so they are pushed back
611 # into the paragraph accumulator that the fall-through rule uses. That is
612 # where a run of plain lines would have ended up anyway.
613 meta_state = 2;
614 for(mi = 1; mi <= meta_n; mi++)
615 text = (text ? text " " : "") eschtml(meta_buf[mi]);
616 meta_n = 0;
617 }
618
619 # Tables (MultiMarkdown)
620 #
621 # A row is only a table row if the line after it is a separator: |---|:-:|.
622 # That means one line of lookahead, which is done by holding the candidate
623 # header until the next line decides. Alignment comes from the colons in the
624 # separator and is emitted as an inline style, matching multimarkdown's own
625 # output.
626
627 function table_cells(line, n, i, out) {
628 # Split on unescaped pipes, dropping the optional edge pipes.
629 sub(/^[ \t]*\|/, "", line);
630 sub(/\|[ \t]*$/, "", line);
631 return line;
632 }
633
634 function table_align(spec, a) {
635 a = spec;
636 gsub(/[ \t]/, "", a);
637 if(a ~ /^:.*:$/) return "center";
638 if(a ~ /:$/) return "right";
639 if(a ~ /^:/) return "left";
640 return "";
641 }
642
643 function table_row(line, celltag, n, i, cells, out, st) {
644 n = split(table_cells(line), cells, "|");
645 out = "<tr>";
646 for(i = 1; i <= n; i++){
647 sub(/^[ \t]+/, "", cells[i]);
648 sub(/[ \t]+$/, "", cells[i]);
649 st = "";
650 if(tbl_align[i] != "")
651 st = " style=\"text-align:" tbl_align[i] ";\"";
652 out = out "\n\t<" celltag st ">" inline(cells[i]) "</" celltag ">";
653 }
654 return out "\n</tr>";
655 }
656
657 # A held header row, resolved by whatever follows it.
658 tbl_pending != "" {
659 if($0 ~ /^[ \t]*\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/){
660 # It was a table after all.
661 n_align = split(table_cells($0), align_spec, "|");
662 for(ai = 1; ai <= n_align; ai++)
663 tbl_align[ai] = table_align(align_spec[ai]);
664 if(text)
665 printp(par ? par : "p");
666 oprint("<div class=\"table-scroll\">");
667 oprint("<table>");
668 oprint("<thead>");
669 oprint(table_row(tbl_pending, "th"));
670 oprint("</thead>");
671 oprint("<tbody>");
672 tbl = 1;
673 tbl_pending = "";
674 next;
675 }
676 # Not a table: the held line is ordinary text.
677 text = (text ? text " " : "") tbl_pending;
678 tbl_pending = "";
679 }
680
681 tbl {
682 if($0 ~ /^[ \t]*$/ || $0 !~ /\|/){
683 oprint("</tbody>");
684 oprint("</table>");
685 oprint("</div>");
686 tbl = 0;
687 for(ci in tbl_align) delete tbl_align[ci];
688 if($0 ~ /^[ \t]*$/)
689 next;
690 }
691 else{
692 oprint(table_row($0, "td"));
693 next;
694 }
695 }
696
697 !fence && !code && !tbl && /\|/ && !/^[ \t]*$/ && (!text || text == "") {
698 # Candidate header row: hold it and let the next line decide. Only a line
699 # whose pipes separate non-empty cells qualifies, so ordinary prose that
700 # happens to contain a pipe is not swallowed.
701 tbl_probe = $0;
702 sub(/^[ \t]*\|/, "", tbl_probe);
703 sub(/\|[ \t]*$/, "", tbl_probe);
704 if(index(tbl_probe, "|") > 0 || $0 ~ /^[ \t]*\|.*\|[ \t]*$/){
705 tbl_pending = $0;
706 next;
707 }
708 }
709
710 # Fenced code blocks (MultiMarkdown / CommonMark)
711 #
712 # Must run before every other line rule: content inside a fence is literal,
713 # so it has to bypass inline parsing, smart quotes and list detection alike.
714 # The closing fence has to be at least as long as the opener and use the same
715 # character, so a fence can contain a shorter run of backticks.
716 fence {
717 if($0 ~ fence_re && length($0) >= 0){
718 # Candidate closer: same char, at least as long, nothing after it.
719 if(match($0, "^[ \t]*" fence_char "{" fence_len ",}[ \t]*$")){
720 if(fence_first)
721 # An empty fence: nothing was ever buffered.
722 oprint(fence_open fence_close);
723 else
724 oprint(fence_held fence_close);
725 fence = 0;
726 fence_held = "";
727 next;
728 }
729 }
730 # Hold each line until the next one arrives, so the closing tag can be
731 # joined to the last line rather than sitting on its own.
732 if(fence_first){
733 fence_held = fence_open (fence_raw ? $0 : escfence($0));
734 fence_first = 0;
735 }
736 else{
737 oprint(fence_held);
738 fence_held = (fence_raw ? $0 : escfence($0));
739 }
740 next;
741 }
742
743 /^[ \t]*(```|~~~)/ {
744 # Opening fence. Everything after the run is an info string; its first
745 # word is the language.
746 line = $0;
747 sub(/^[ \t]*/, "", line);
748 fence_char = substr(line, 1, 1);
749 fence_len = 0;
750 while(substr(line, fence_len + 1, 1) == fence_char)
751 fence_len++;
752 info = substr(line, fence_len + 1);
753 sub(/^[ \t]+/, "", info);
754 sub(/[ \t].*$/, "", info);
755
756 # Close anything currently open so the block is not nested inside a <p>.
757 if(text)
758 printp(par ? par : "p");
759 if(code){
760 oprint(code_held "</code></pre>"); code_held = "";
761 code = 0;
762 }
763
764 fence = 1;
765 fence_first = 1;
766 fence_re = "^[ \t]*" fence_char;
767 # A diagram language is held for a client-side renderer rather than shown
768 # as source. The text goes in verbatim: mermaid reads the element's text
769 # content and parses it itself, so escaping "-->" to "--&gt;" would break
770 # every arrow in the diagram. That makes this the one place a fence's
771 # content is not escaped, which is safe only because the set of languages
772 # treated this way is fixed here -- an author cannot introduce a new one
773 # from a document.
774 #
775 # The language is kept as a class so a theme decides what to do with it.
776 # jenny renders no diagrams itself and loads nothing; a theme that wants
777 # them supplies the renderer.
778 fence_raw = 0;
779 fence_close = "</code></pre>";
780 lang = info;
781 sub(/[ \t].*$/, "", lang);
782 if(lang == "mermaid" || lang == "mermaid-legend"){
783 fence_raw = 1;
784 fence_close = "</div>";
785 fence_open = "<div class=\"diagram diagram-" eschtml(lang) "\">";
786 }
787 else if(info != "")
788 fence_open = "<pre><code class=\"language-" eschtml(info) "\">";
789 else
790 fence_open = "<pre><code>";
791 next;
792 }
793
794 # List and quote blocks
795
796 # Remove indentation
797 #
798 # One nesting level is peeled per step. A tab or four spaces was the only unit
799 # understood here, but a child is conventionally indented to its parent's
800 # content column, which for "- " is two spaces -- so two-space nesting, the
801 # common form, was invisible: the item regex below tolerates up to three
802 # leading spaces as noise, so a two-space child matched as a sibling and the
803 # whole list came out flat.
804 #
805 # Two spaces is the step, not an alternative to four. Written as an
806 # alternation with four spaces first, a four-space indent matched the wide
807 # branch and peeled a single level, so three levels of four-space nesting
808 # collapsed into two. A tab remains one whole level.
809 {
810 for(nnl = 0; nnl < nl; nnl++)
811 if((match(block[nnl + 1], /[ou]l/) && !sub(/^( | )/, "")) || \
812 (block[nnl + 1] == "blockquote" && !sub(/^> ?/, "")))
813 break;
814 }
815 nnl < nl && !blank && text && ! /^ ? ? ?([*+-]|([0-9]+\.)+)( +| )/ { nnl = nl; }
816 # Quote blocks
817 {
818 while(sub(/^> /, ""))
819 nblock[++nnl] = "blockquote";
820 }
821 # Horizontal rules
822 { hr = 0; }
823 (blank || (!text && !code)) && /^ ? ? ?([-*_][ ]*)([-*_][ ]*)([-*_][ ]*)+$/ {
824 if(code){
825 oprint(code_held "</code></pre>"); code_held = "";
826 code = 0;
827 }
828 blank = 0;
829 nnl = 0;
830 hr = 1;
831 }
832 # List items
833 block[nl] ~ /[ou]l/ && /^$/ {
834 blank = 1;
835 next;
836 }
837 { newli = 0; }
838 !hr && (nnl != nl || !text || block[nl] ~ /[ou]l/) && /^ ? ? ?[*+-]( +| )/ {
839 sub(/^ ? ? ?[*+-]( +| )/, "");
840 nnl++;
841 nblock[nnl] = "ul";
842 newli = 1;
843 }
844 (nnl != nl || !text || block[nl] ~ /[ou]l/) && /^ ? ? ?([0-9]+\.)+( +| )/ {
845 sub(/^ ? ? ?([0-9]+\.)+( +| )/, "");
846 nnl++;
847 nblock[nnl] = "ol";
848 newli = 1;
849 }
850 newli {
851 if(blank && nnl == nl && !par)
852 par = "p";
853 blank = 0;
854 printp(par);
855 if(nnl == nl && block[nl] == nblock[nl])
856 oprint("</li><li>");
857 }
858 blank && ! /^$/ {
859 if(match(block[nnl], /[ou]l/) && !par)
860 par = "p";
861 printp(par);
862 par = "p";
863 blank = 0;
864 }
865
866 # Close old blocks and open new ones
867 nnl != nl || nblock[nl] != block[nl] {
868 if(code){
869 oprint(code_held "</code></pre>"); code_held = "";
870 code = 0;
871 }
872 printp(par);
873 b = (nnl > nl) ? nblock[nnl] : block[nnl];
874 par = (match(b, /[ou]l/)) ? "" : "p";
875 }
876 nnl < nl || (nnl == nl && nblock[nl] != block[nl]) {
877 # The second clause compares against nblock, the block kind this line
878 # wants, not pblock -- which is assigned nowhere and so was always empty,
879 # making the test true for any open list. Once the walk reached nnl it
880 # therefore kept going and closed the outer <ul> along with the nested one,
881 # leaving a dedented sibling no list to rejoin. Harmless while nesting did
882 # not work at all; load-bearing now that it does.
883 for(; nl > nnl || (nl > 0 && nnl == nl && nblock[nl] != block[nl]); nl--){
884 if(match(block[nl], /[ou]l/))
885 oprint("</li>");
886 oprint("</" block[nl] ">");
887 }
888 # A sibling after a nested list needs a fresh <li> in the list we just came
889 # back to. The "</li><li>" rule earlier only fires when the item is already
890 # at its own depth (nnl == nl), which is false on a dedent.
891 if(newli && nnl > 0 && nnl == nl && match(block[nl], /[ou]l/) && block[nl] == nblock[nl]){
892 oprint("</li><li>");
893 newli = 0;
894 }
895 }
896 nnl > nl {
897 for(; nl < nnl; nl++){
898 block[nl + 1] = nblock[nl + 1];
899 oprint("<" block[nl + 1] ">");
900 if(match(block[nl + 1], /[ou]l/))
901 oprint("<li>");
902 }
903 }
904 hr {
905 oprint("<hr>");
906 next;
907 }
908
909 # Code blocks
910 code && /^$/ {
911 if(blanK)
912 oprint("");
913 blank = 1;
914 next;
915 }
916 !text && sub(/^( | )/, "") {
917 if(blanK)
918 oprint("");
919 blank = 0;
920 $0 = eschtml($0);
921 if(!code){
922 code_held = "<pre><code>" $0;
923 code = 1;
924 }
925 else{
926 oprint(code_held);
927 code_held = $0;
928 }
929 next;
930 }
931 code {
932 oprint(code_held "</code></pre>");
933 code_held = "";
934 code = 0;
935 }
936
937 # Setex-style Headers
938 text && /^=+$/ {printp("h1"); next;}
939 text && /^-+$/ {printp("h2"); next;}
940
941 # Atx-Style headers
942 /^#+/ && (!newli || par=="p" || /^##/) {
943 for(n = 0; n < 6 && sub(/^# */, ""); n++)
944 sub(/#$/, "");
945 par = "h" n;
946 }
947
948 # Paragraph
949 /^$/ {
950 printp(par);
951 par = "p";
952 next;
953 }
954
955 # Add text
956 { text = (text ? text " " : "") $0; }
957
958 function alen(a, ix, k) {
959 k = 0
960 for(ix in a) k++
961 return k
962 }
963
964 END {
965 # A candidate header row with no separator after it was ordinary text.
966 if(tbl_pending != ""){
967 text = (text ? text " " : "") tbl_pending;
968 tbl_pending = "";
969 }
970 if(tbl){
971 oprint("</tbody>");
972 oprint("</table>");
973 oprint("</div>");
974 tbl = 0;
975 }
976 if(code){
977 oprint(code_held "</code></pre>"); code_held = "";
978 code = 0;
979 }
980 printp(par);
981 for(; nl > 0; nl--){
982 if(match(block[nl], /[ou]l/))
983 oprint("</li>");
984 oprint("</" block[nl] ">");
985 }
986 # A reference whose definition never appeared is not a link at all.
987 # Restore the literal text the author wrote rather than emitting an
988 # anchor with an empty href.
989 for(sid in shortcut_ref){
990 lt = shortcut_ref[sid];
991 gsub("<a href=\"<<" sid "\">" lt "</a>", "[" lt "]", otext);
992 }
993 while(match(otext, /<a href="<<[^"]*">[^<]*<\/a>/)){
994 frag = substr(otext, RSTART, RLENGTH);
995 lt = frag;
996 sub(/^<a href="<<[^"]*">/, "", lt);
997 sub(/<\/a>$/, "", lt);
998 rid = frag;
999 sub(/^<a href="<</, "", rid);
1000 sub(/">.*$/, "", rid);
1001 otext = substr(otext, 1, RSTART - 1) "[" lt "][" rid "]" \
1002 substr(otext, RSTART + RLENGTH);
1003 }
1004 gsub(/<<[^"]*/, "", otext);
1005 print(otext);
1006
1007 # Print citations, in order of first use.
1008 if(cite_n > 0) {
1009 print "<div class=\"citations\">";
1010 print "<hr />";
1011 print "<ol>";
1012 for(ci = 1; ci <= cite_n; ci++){
1013 cid = cite_order[ci];
1014 print "<li id=\"cn:" ci "\"><p>" inline(cite_body[cid]) \
1015 " <a href=\"#cnref:" ci "\" title=\"return to body\" class=\"reversecitation\">&#160;&#8617;&#xfe0e;</a></p></li>";
1016 }
1017 print "</ol>";
1018 print "</div>";
1019 }
1020
1021 # Print footnotes
1022 if(alen(fnref)>0) {
1023 print "<ul class=\"fn-list\">";
1024 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>";
1025 print "</ul>";
1026 }
1027 }

test/regressions.sh +1 -1

suite "nested lists"
767 # two spaces for "- ". Two-space nesting, the common form, came out flat. 767 # two spaces for "- ". Two-space nesting, the common form, came out flat.
768 768
769 md_render() { 769 md_render() {
770 printf '%s\n' "$1" | "$AWK" -f "$JENNY_SHARE_DIR/lib/md2html.awk"
770 printf '%s\n' "$1" | "$AWK" -f "$JENNY_SHARE_DIR/lib/markdown.awk/markdown.awk"
771 } 771 }
772 772
773 it "a two-space child nests inside its parent" 773 it "a two-space child nests inside its parent"

test/units.sh +7 -1

URL_STYLE=""
306 306
307 suite "markdown: intraword underscores" 307 suite "markdown: intraword underscores"
308 308
309 md() { printf '%s\n' "$1" | "$AWK" -f "$REPO_ROOT/share/jenny/lib/md2html.awk"; }
309 # The deep-link id is stripped so these assertions can state the markup they
310 # are about. Each is checking how a span of text is parsed, not that a
311 # paragraph carries an anchor -- that has tests of its own, and pinning a
312 # content hash in every one of these would make them unreadable and would
313 # break them all again on any wording change.
314 md() { printf '%s\n' "$1" | "$AWK" -f "$REPO_ROOT/share/jenny/lib/markdown.awk/markdown.awk" \
315 | "$SED" 's/<p id="p-[0-9a-f]*\(-[0-9]*\)\{0,1\}">/<p>/g'; }
310 316
311 it "an underscore inside a word is literal" 317 it "an underscore inside a word is literal"
312 assert_eq "$(md 'node_modules here')" "<p>node_modules here</p>" 318 assert_eq "$(md 'node_modules here')" "<p>node_modules here</p>"

Raw

commit 5479d3a7c6e2e184513c3da317164688f476d6db
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Sat Sep 5 10:06:44 2026 +0800

    Take the markdown parser as a submodule instead of vendoring it
    
    The parser had no dependency on the generator it lived in -- the only two
    mentions of jenny were comments standing in for "whatever consumes this".
    It is its own repository now, with its own tests, and comes back in at
    share/jenny/lib/markdown.awk.
    
    The recorded URL is the anonymous git:// one so that cloning this repo
    works without an account on the host; the push URL is set to ssh locally.
    
    The build signature had to change with it. It hashed the lib directory
    with -maxdepth 1, and the parser is one directory down now, so a parser
    edit stopped moving the signature -- verified before fixing: the hash was
    identical across a change to the parser, which is the silent
    skip-everything the depth-limited scan was originally added to prevent,
    arriving by a different route. The scan is unlimited now and excludes the
    submodule's own tests, which change without changing a rendered byte.

diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..e9d6076
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "share/jenny/lib/markdown.awk"]
+	path = share/jenny/lib/markdown.awk
+	url = git://grit.deadkey.org/mailmanpat/markdown.awk.git
diff --git a/bin/jenny b/bin/jenny
index 29df54f..4a5cadd 100755
--- a/bin/jenny
+++ b/bin/jenny
@@ -142,7 +142,7 @@ if source_blogrc; then
   [ -z "${POSTS_PER_PAGE:-}" ] && POSTS_PER_PAGE=10
   [ -z "${DIST:-}" ] && DIST=$PWD/.dist
   [ -z "${LAYOUT_DIR:-}" ] && LAYOUT_DIR=$SHARE_DIR/layout
-  [ -z "${MARKDOWN_COMMAND:-}" ] && MARKDOWN_COMMAND="\$AWK -f \$SHARE_DIR/lib/md2html.awk"
+  [ -z "${MARKDOWN_COMMAND:-}" ] && MARKDOWN_COMMAND="\$AWK -f \$SHARE_DIR/lib/markdown.awk/markdown.awk"
   [ -z "${FEED_MAX_ITEMS:-}" ] && FEED_MAX_ITEMS=20
   [ -z "${URL_STYLE:-}" ] && URL_STYLE=html
 
diff --git a/share/jenny/layout/page.sh b/share/jenny/layout/page.sh
index 54ea40a..9285d67 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -978,7 +978,7 @@ cat << _EOF_
         .contents h1:not(:is(h1, h2) ~ *)::before { content: none; }
         ol, ul { padding-left: 1em; }
         /* Notes and references are two different lists, so each is labelled.
-           The wording lives here rather than in the parser: md2html.awk marks
+           The wording lives here rather than in the parser: markdown.awk marks
            them with classes and leaves naming to the theme, which keeps
            English out of the parser and lets a theme relabel or hide either
            by overriding one rule. Styled to match an h5. */
diff --git a/share/jenny/layout/post.sh b/share/jenny/layout/post.sh
index dd2f46c..0830c02 100755
--- a/share/jenny/layout/post.sh
+++ b/share/jenny/layout/post.sh
@@ -799,7 +799,7 @@ cat << _EOF_
         .contents h1:not(:is(h1, h2) ~ *)::before { content: none; }
         ol, ul { padding-left: 1em; }
         /* Notes and references are two different lists, so each is labelled.
-           The wording lives here rather than in the parser: md2html.awk marks
+           The wording lives here rather than in the parser: markdown.awk marks
            them with classes and leaves naming to the theme, which keeps
            English out of the parser and lets a theme relabel or hide either
            by overriding one rule. Styled to match an h5. */
diff --git a/share/jenny/lib/helpers.sh b/share/jenny/lib/helpers.sh
index 6f32794..14ee52c 100644
--- a/share/jenny/lib/helpers.sh
+++ b/share/jenny/lib/helpers.sh
@@ -211,7 +211,16 @@ function build_signature() {
     # surely as a template does: adding ids to headings changed the output of
     # every page, and without this the lock skipped them all -- the build said
     # nothing had changed while the change was in the markdown parser.
-    find "${JENNY_LIB_DIR:-}" -maxdepth 1 -type f \( -name '*.sh' -o -name '*.awk' \) \
+    #
+    # No -maxdepth: the parser is a submodule now, one directory down, and a
+    # depth-limited scan stopped seeing it. Verified before the fix -- editing
+    # the parser left the signature identical, which is the same silent
+    # skip-everything this line was added to prevent, returned by a different
+    # route. The submodule's own tests are excluded: they change without
+    # changing a single rendered byte, and hashing them would rebuild the
+    # whole site for a test edit.
+    find "${JENNY_LIB_DIR:-}" -type f \( -name '*.sh' -o -name '*.awk' \) \
+      -not -path '*/test/*' \
       -print0 2>/dev/null \
       | LC_ALL=C sort -z \
       | xargs -0 cat 2>/dev/null
@@ -848,7 +857,7 @@ function read_nav() {
 #
 # The parser owns what counts as metadata and wraps each pair in the rendered
 # output. jenny reads the same block from the source to hydrate variables the
-# layout can use, so there is one definition of the syntax, in md2html.awk,
+# layout can use, so there is one definition of the syntax, in markdown.awk,
 # and this stays a straight read of the same shape.
 
 # get_metadata <file>
diff --git a/share/jenny/lib/markdown.awk b/share/jenny/lib/markdown.awk
new file mode 160000
index 0000000..8cd72df
--- /dev/null
+++ b/share/jenny/lib/markdown.awk
@@ -0,0 +1 @@
+Subproject commit 8cd72df31b3ae05a98735b419311da6274329401
diff --git a/share/jenny/lib/md2html.awk b/share/jenny/lib/md2html.awk
deleted file mode 100755
index f2ef676..0000000
--- a/share/jenny/lib/md2html.awk
+++ /dev/null
@@ -1,1027 +0,0 @@
-#!/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=\"" 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=\"" substr(title, RSTART + 1, RLENGTH - 2) "\"";
-			}
-			if(match(url, /^<.*>$/))
-				url = substr(url, 2, RLENGTH - 2);
-			return t1 "<img src=\"" url "\" alt=\"" 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=\"" r "\" alt=\"" 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=\"" substr(title, RSTART + 1, RLENGTH - 2) "\"";
-			}
-			if(match(url, /^<.*>$/))
-				url = substr(url, 2, RLENGTH - 2);
-			url = eschtml(url);
-			return t1 "<a href=\"" 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 jenny can read the values back. Whether they are visible is a
-# readability decision for the layout, 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 a theme decides what to do with it.
-	# jenny renders no diagrams itself and loads nothing; a theme 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/regressions.sh b/test/regressions.sh
index 0c76397..8db745f 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -767,7 +767,7 @@ suite "nested lists"
 # two spaces for "- ". Two-space nesting, the common form, came out flat.
 
 md_render() {
-  printf '%s\n' "$1" | "$AWK" -f "$JENNY_SHARE_DIR/lib/md2html.awk"
+  printf '%s\n' "$1" | "$AWK" -f "$JENNY_SHARE_DIR/lib/markdown.awk/markdown.awk"
 }
 
 it "a two-space child nests inside its parent"
diff --git a/test/units.sh b/test/units.sh
index f6f41d1..d73a960 100755
--- a/test/units.sh
+++ b/test/units.sh
@@ -306,7 +306,13 @@ URL_STYLE=""
 
 suite "markdown: intraword underscores"
 
-md() { printf '%s\n' "$1" | "$AWK" -f "$REPO_ROOT/share/jenny/lib/md2html.awk"; }
+# 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/share/jenny/lib/markdown.awk/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>"