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