ssh git@grit.deadkey.org man

Commit 427dc8c h

Pat Ambrosio · 26-08-26 17:49

Give headings ids, and put the parser in the build signature

Changes: 4 files +88 -4

Changes (Side-by-Side)

share/jenny/lib/helpers.sh +8 -0

function build_signature() {
207 find "$layout_dir" -type f -name '*.sh' -print0 2>/dev/null \ 207 find "$layout_dir" -type f -name '*.sh' -print0 2>/dev/null \
208 | LC_ALL=C sort -z \ 208 | LC_ALL=C sort -z \
209 | xargs -0 cat 2>/dev/null 209 | xargs -0 cat 2>/dev/null
210 # The parser and the libraries too. They shape every rendered file as
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
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' \) \
215 -print0 2>/dev/null \
216 | LC_ALL=C sort -z \
217 | xargs -0 cat 2>/dev/null
210 # Nav registries. A page renders the nav it declares, so editing 218 # Nav registries. A page renders the nav it declares, so editing
211 # navs/study-toc.tsv changes that page's output without touching the 219 # navs/study-toc.tsv changes that page's output without touching the
212 # page's own source -- exactly the kind of edit the lock would otherwise 220 # page's own source -- exactly the kind of edit the lock would otherwise

share/jenny/lib/md2html.awk +39 -3

function inline(t) {
339 return nextil(t); 339 return nextil(t);
340 } 340 }
341 341
342 function printp(tag) {
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 function printp(tag, id) {
343 if(!match(text, /^[ ]*$/)){ 369 if(!match(text, /^[ ]*$/)){
344 text = inline(text); 370 text = inline(text);
345 if(tag != "")
346 oprint("<" tag ">" text "</" tag ">");
371 if(tag != ""){
372 # Headings carry an id; nothing else does.
373 if(tag ~ /^h[1-6]$/){
374 id = heading_id(text)
375 if(id != "")
376 oprint("<" tag " id=\"" id "\">" text "</" tag ">");
377 else
378 oprint("<" tag ">" text "</" tag ">");
379 }
380 else
381 oprint("<" tag ">" text "</" tag ">");
382 }
347 else 383 else
348 oprint(text); 384 oprint(text);
349 } 385 }

test/regressions.sh +39 -0

assert_file_contains "$al/.dist/index.html" 'padding-right: 1.5em;'
1083 it "the build is clean" 1083 it "the build is clean"
1084 assert_eq "$stderr_output" "" 1084 assert_eq "$stderr_output" ""
1085 1085
1086 suite "headings are addressable"
1087
1088 # A document long enough to link within needs its headings to be targets. Any
1089 # "#section" link -- from its own contents list, from a sibling page, from
1090 # somewhere else entirely -- resolves to nothing without them. Publishing a
1091 # real thesis found 23 such links pointing at nothing.
1092
1093 it "a heading carries an id made from its words"
1094 assert_contains "$(md_render '## Vision')" '<h2 id="vision">'
1095
1096 it "punctuation and spacing collapse to single dashes"
1097 assert_contains "$(md_render '## Scope (what MDD is not)')" 'id="scope-what-mdd-is-not"'
1098
1099 it "inline markup is stripped before slugging"
1100 # A heading may be emphasised or hold code; "<code>Vision</code>" must still
1101 # slug to "vision".
1102 assert_contains "$(md_render '## The `Blueprint` Artifact')" 'id="the-blueprint-artifact"'
1103 assert_contains "$(md_render '## Scope (what MDD is *not*)')" 'id="scope-what-mdd-is-not"'
1104
1105 it "every level is addressable"
1106 assert_contains "$(md_render '# One')" '<h1 id="one">'
1107 assert_contains "$(md_render '###### Six')" '<h6 id="six">'
1108
1109 it "a heading with no sluggable characters gets no id"
1110 # An empty id is worse than none: it is not a valid target and would collide
1111 # with every other unsluggable heading.
1112 out=$(md_render '## ---')
1113 assert_not_contains "$out" 'id=""'
1114
1115 it "only headings carry ids"
1116 assert_not_contains "$(md_render 'Just a paragraph.')" 'id='
1117
1118 it "a change to the parser invalidates the lock"
1119 # The build signature covered the layouts but not the parser, so adding ids to
1120 # headings changed every page and the build re-rendered none of them: it said
1121 # nothing had changed while the change was in the markdown parser.
1122 assert_file_contains "$JENNY_SHARE_DIR/lib/helpers.sh" "JENNY_LIB_DIR"
1123 assert_file_contains "$JENNY_SHARE_DIR/lib/helpers.sh" "-name '*.awk'"
1124
1086 suite "diagrams" 1125 suite "diagrams"
1087 1126
1088 # A diagram fence is held for a client-side renderer instead of being shown as 1127 # A diagram fence is held for a client-side renderer instead of being shown as

test/units.sh +2 -1

assert_contains "$(md 'Series: Web Typography
505 # Title')" 'class="meta"' 505 # Title')" 'class="meta"'
506 506
507 it "the body still renders" 507 it "the body still renders"
508 # Headings carry an id now, so this asserts the text rather than the exact tag.
508 assert_contains "$(md 'Series: Web Typography 509 assert_contains "$(md 'Series: Web Typography
509 510
510 # Title')" "<h1>Title</h1>"
511 # Title')" ">Title</h1>"
511 512
512 it "multiple keys each get their own element" 513 it "multiple keys each get their own element"
513 out=$(md 'Series: Web Typography 514 out=$(md 'Series: Web Typography

Raw

commit 427dc8c666e99191d8735603c7fe047546533711
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Wed Aug 26 17:49:06 2026 +0800

    Give headings ids, and put the parser in the build signature
    
    Publishing the MDD thesis found 23 deep links pointing at nothing. Every
    "#vision", "#blueprint", "#expert" reference -- from a sibling page, from
    a page's own contents list -- resolved to no target, because the parser
    emitted bare <h2> where the document expected <h2 id="vision">.
    
    A document long enough to link within needs its headings addressable, so
    they now carry a slug: lowercased, runs of anything that is not a letter
    or digit collapsed to a dash, ends trimmed. Inline markup is stripped
    first, since a heading may be emphasised or hold code and
    "<code>Blueprint</code>" must still slug to "blueprint". Collisions are
    left alone -- the first of two identical headings wins, which is what a
    reader following a link expects, and numbering them would make every id
    depend on how many times a word appears earlier in the document.
    
    The slugs match what the thesis already links to, so the 23 dead anchors
    resolve without touching the prose.
    
    Fixing that exposed a second bug. The build signature covered the layout
    directory but not the parser, so the change altered every rendered file
    and the lock re-rendered none of them -- the build reported nothing had
    changed while the change was in the markdown parser. The signature now
    covers the lib directory too: md2html.awk and the shell libraries shape
    output as surely as a template does.
    
    Verified by resolving every link in the thesis against its real base URL,
    following each fragment to a matching id: 88 unique targets, zero broken
    pages, zero broken anchors.
    
    Tests: the slug's shape, punctuation collapsing, markup stripping, all six
    levels, the unsluggable case, that only headings carry ids, and that the
    signature covers the parser. One unit test pinned "<h1>Title</h1>" exactly
    and now asserts the text rather than the tag. Checked by mutation --
    dropping the markup strip, dropping ids entirely, removing the parser from
    the signature -- each caught.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01WnSAstgnDYdKPwLGv1YZE5

diff --git a/share/jenny/lib/helpers.sh b/share/jenny/lib/helpers.sh
index e4d8ec5..6f32794 100644
--- a/share/jenny/lib/helpers.sh
+++ b/share/jenny/lib/helpers.sh
@@ -207,6 +207,14 @@ function build_signature() {
     find "$layout_dir" -type f -name '*.sh' -print0 2>/dev/null \
       | LC_ALL=C sort -z \
       | xargs -0 cat 2>/dev/null
+    # The parser and the libraries too. They shape every rendered file as
+    # 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' \) \
+      -print0 2>/dev/null \
+      | LC_ALL=C sort -z \
+      | xargs -0 cat 2>/dev/null
     # Nav registries. A page renders the nav it declares, so editing
     # navs/study-toc.tsv changes that page's output without touching the
     # page's own source -- exactly the kind of edit the lock would otherwise
diff --git a/share/jenny/lib/md2html.awk b/share/jenny/lib/md2html.awk
index 4c85b16..c300eae 100755
--- a/share/jenny/lib/md2html.awk
+++ b/share/jenny/lib/md2html.awk
@@ -339,11 +339,47 @@ function inline(t) {
 	return nextil(t);
 }
 
-function printp(tag) {
+# 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
+}
+
+function printp(tag,   id) {
 	if(!match(text, /^[ 	]*$/)){
 		text = inline(text);
-		if(tag != "")
-			oprint("<" tag ">" text "</" tag ">");
+		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
+				oprint("<" tag ">" text "</" tag ">");
+		}
 		else
 			oprint(text);
 	}
diff --git a/test/regressions.sh b/test/regressions.sh
index 16dc027..ac5362d 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -1083,6 +1083,45 @@ assert_file_contains "$al/.dist/index.html" 'padding-right: 1.5em;'
 it "the build is clean"
 assert_eq "$stderr_output" ""
 
+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 "only headings carry ids"
+assert_not_contains "$(md_render 'Just a paragraph.')" 'id='
+
+it "a change to the parser invalidates the lock"
+# The build signature covered the layouts but not the parser, so adding ids to
+# headings changed every page and the build re-rendered none of them: it said
+# nothing had changed while the change was in the markdown parser.
+assert_file_contains "$JENNY_SHARE_DIR/lib/helpers.sh" "JENNY_LIB_DIR"
+assert_file_contains "$JENNY_SHARE_DIR/lib/helpers.sh" "-name '*.awk'"
+
 suite "diagrams"
 
 # A diagram fence is held for a client-side renderer instead of being shown as
diff --git a/test/units.sh b/test/units.sh
index 6e3d936..f6f41d1 100755
--- a/test/units.sh
+++ b/test/units.sh
@@ -505,9 +505,10 @@ 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')" "<h1>Title</h1>"
+# Title')" ">Title</h1>"
 
 it "multiple keys each get their own element"
 out=$(md 'Series: Web Typography