ssh git@grit.deadkey.org man

Commit 50fcdc7 h

Pat Ambrosio · 26-09-01 09:06

Make every passage addressable, not just every heading

Changes: 4 files +326 -3

Changes (Side-by-Side)

share/jenny/layout/page.sh +96 -0

cat << _EOF_
890 while their siblings were bold. Deeper headings stay regular -- they 890 while their siblings were bold. Deeper headings stay regular -- they
891 sit inside a section rather than opening one. */ 891 sit inside a section rather than opening one. */
892 .contents :is(h1, h2) { font-weight: bold; } 892 .contents :is(h1, h2) { font-weight: bold; }
893 /* Deep-link mark. Hidden until the passage is hovered or the mark
894 itself is focused, so a page of prose is not littered with symbols;
895 keyboard users reach it by tabbing, which is why focus counts. */
896 .anchor-link {
897 margin-left: .4em; text-decoration: none; opacity: 0;
898 color: var(--text-muted); font-weight: normal; cursor: pointer;
899 }
900 :is(h1, h2, h3, h4, p):hover > .anchor-link,
901 .anchor-link:focus { opacity: 1; }
902 /* Coarse pointers have no hover, so the mark would never appear and
903 would only take up room. */
904 @media (hover: none) { .anchor-link { display: none; } }
905 /* The passage a permalink pointed at, marked rather than animated: a
906 reader arriving mid-document needs to see which paragraph was meant,
907 and a flash they might miss does not tell them. */
908 .target-flash {
909 background-color: var(--bg-secondary);
910 box-shadow: 0 0 0 .4em var(--bg-secondary);
911 border-radius: 2px;
912 }
913 .copy-toast {
914 position: fixed; left: 50%; bottom: 2em; transform: translate(-50%, .5em);
915 background-color: var(--bg-secondary); color: var(--text-primary);
916 border: 1px solid var(--border-light); border-radius: 3px;
917 padding: .5em 1em; font-size: .875em; z-index: 3000;
918 opacity: 0; pointer-events: none;
919 }
920 .copy-toast.show { opacity: 1; transform: translate(-50%, 0); }
893 /* Every heading is body size, which keeps the page even but leaves a 921 /* Every heading is body size, which keeps the page even but leaves a
894 section heading reading as a stray line of capitals: uppercase alone 922 section heading reading as a stray line of capitals: uppercase alone
895 does not say "new section". A rule above says it structurally rather 923 does not say "new section". A rule above says it structurally rather
cat << _EOF_
1183 localStorage.setItem('theme', newTheme); 1211 localStorage.setItem('theme', newTheme);
1184 }); 1212 });
1185 1213
1214 // Deep links: a heading or paragraph carries an id, and hovering it
1215 // offers a mark that copies the link to that exact place. Without the
1216 // affordance the ids are there but undiscoverable -- a reader would
1217 // have to open the source to find one.
1218 function initAnchors() {
1219 var targets = document.querySelectorAll('.contents :is(h1,h2,h3,h4)[id], .contents p[id]');
1220 if (!targets.length) return;
1221
1222 var toast = null;
1223 function flash(msg) {
1224 if (!toast) {
1225 toast = document.createElement('div');
1226 toast.className = 'copy-toast';
1227 document.body.appendChild(toast);
1228 }
1229 toast.textContent = msg;
1230 toast.classList.add('show');
1231 clearTimeout(toast._t);
1232 toast._t = setTimeout(function() { toast.classList.remove('show'); }, 1600);
1233 }
1234
1235 targets.forEach(function(el) {
1236 var a = document.createElement('a');
1237 a.className = 'anchor-link';
1238 a.href = '#' + el.id;
1239 a.setAttribute('aria-label', 'Copy link to this passage');
1240 a.textContent = '\u00B6';
1241 a.addEventListener('click', function(e) {
1242 e.preventDefault();
1243 var url = location.origin + location.pathname + '#' + el.id;
1244 // The address bar is updated either way, so the link is
1245 // obtainable even where the clipboard is unavailable.
1246 history.replaceState(null, '', '#' + el.id);
1247 if (navigator.clipboard && navigator.clipboard.writeText) {
1248 navigator.clipboard.writeText(url).then(
1249 function() { flash('Link copied'); },
1250 function() { flash('Press \u2318/Ctrl+C to copy'); }
1251 );
1252 } else {
1253 flash('Press \u2318/Ctrl+C to copy');
1254 }
1255 });
1256 el.appendChild(a);
1257 });
1258
1259 // Mark whatever the reader arrived at, so a permalink lands on
1260 // something visibly identified rather than just scrolled to.
1261 function markTarget() {
1262 document.querySelectorAll('.target-flash').forEach(function(e) {
1263 e.classList.remove('target-flash');
1264 });
1265 if (!location.hash) return;
1266 var t = null;
1267 try { t = document.querySelector(location.hash); } catch (err) { return; }
1268 if (t) t.classList.add('target-flash');
1269 }
1270 window.addEventListener('hashchange', markTarget);
1271 markTarget();
1272 }
1273 // The script tag sits in the body, so at parse time the content it
1274 // needs may not exist yet -- the first version ran before any of it
1275 // was there and silently added nothing.
1276 if (document.readyState === 'loading') {
1277 document.addEventListener('DOMContentLoaded', initAnchors);
1278 } else {
1279 initAnchors();
1280 }
1281
1186 // Initialize footnote and citation tooltips 1282 // Initialize footnote and citation tooltips
1187 function initTooltips() { 1283 function initTooltips() {
1188 // Footnotes: create tooltip elements with cloned content 1284 // Footnotes: create tooltip elements with cloned content

share/jenny/layout/post.sh +96 -0

cat << _EOF_
711 while their siblings were bold. Deeper headings stay regular -- they 711 while their siblings were bold. Deeper headings stay regular -- they
712 sit inside a section rather than opening one. */ 712 sit inside a section rather than opening one. */
713 .contents :is(h1, h2) { font-weight: bold; } 713 .contents :is(h1, h2) { font-weight: bold; }
714 /* Deep-link mark. Hidden until the passage is hovered or the mark
715 itself is focused, so a page of prose is not littered with symbols;
716 keyboard users reach it by tabbing, which is why focus counts. */
717 .anchor-link {
718 margin-left: .4em; text-decoration: none; opacity: 0;
719 color: var(--text-muted); font-weight: normal; cursor: pointer;
720 }
721 :is(h1, h2, h3, h4, p):hover > .anchor-link,
722 .anchor-link:focus { opacity: 1; }
723 /* Coarse pointers have no hover, so the mark would never appear and
724 would only take up room. */
725 @media (hover: none) { .anchor-link { display: none; } }
726 /* The passage a permalink pointed at, marked rather than animated: a
727 reader arriving mid-document needs to see which paragraph was meant,
728 and a flash they might miss does not tell them. */
729 .target-flash {
730 background-color: var(--bg-secondary);
731 box-shadow: 0 0 0 .4em var(--bg-secondary);
732 border-radius: 2px;
733 }
734 .copy-toast {
735 position: fixed; left: 50%; bottom: 2em; transform: translate(-50%, .5em);
736 background-color: var(--bg-secondary); color: var(--text-primary);
737 border: 1px solid var(--border-light); border-radius: 3px;
738 padding: .5em 1em; font-size: .875em; z-index: 3000;
739 opacity: 0; pointer-events: none;
740 }
741 .copy-toast.show { opacity: 1; transform: translate(-50%, 0); }
714 /* Every heading is body size, which keeps the page even but leaves a 742 /* Every heading is body size, which keeps the page even but leaves a
715 section heading reading as a stray line of capitals: uppercase alone 743 section heading reading as a stray line of capitals: uppercase alone
716 does not say "new section". A rule above says it structurally rather 744 does not say "new section". A rule above says it structurally rather
cat << _EOF_
955 localStorage.setItem('theme', newTheme); 983 localStorage.setItem('theme', newTheme);
956 }); 984 });
957 985
986 // Deep links: a heading or paragraph carries an id, and hovering it
987 // offers a mark that copies the link to that exact place. Without the
988 // affordance the ids are there but undiscoverable -- a reader would
989 // have to open the source to find one.
990 function initAnchors() {
991 var targets = document.querySelectorAll('.contents :is(h1,h2,h3,h4)[id], .contents p[id]');
992 if (!targets.length) return;
993
994 var toast = null;
995 function flash(msg) {
996 if (!toast) {
997 toast = document.createElement('div');
998 toast.className = 'copy-toast';
999 document.body.appendChild(toast);
1000 }
1001 toast.textContent = msg;
1002 toast.classList.add('show');
1003 clearTimeout(toast._t);
1004 toast._t = setTimeout(function() { toast.classList.remove('show'); }, 1600);
1005 }
1006
1007 targets.forEach(function(el) {
1008 var a = document.createElement('a');
1009 a.className = 'anchor-link';
1010 a.href = '#' + el.id;
1011 a.setAttribute('aria-label', 'Copy link to this passage');
1012 a.textContent = '\u00B6';
1013 a.addEventListener('click', function(e) {
1014 e.preventDefault();
1015 var url = location.origin + location.pathname + '#' + el.id;
1016 // The address bar is updated either way, so the link is
1017 // obtainable even where the clipboard is unavailable.
1018 history.replaceState(null, '', '#' + el.id);
1019 if (navigator.clipboard && navigator.clipboard.writeText) {
1020 navigator.clipboard.writeText(url).then(
1021 function() { flash('Link copied'); },
1022 function() { flash('Press \u2318/Ctrl+C to copy'); }
1023 );
1024 } else {
1025 flash('Press \u2318/Ctrl+C to copy');
1026 }
1027 });
1028 el.appendChild(a);
1029 });
1030
1031 // Mark whatever the reader arrived at, so a permalink lands on
1032 // something visibly identified rather than just scrolled to.
1033 function markTarget() {
1034 document.querySelectorAll('.target-flash').forEach(function(e) {
1035 e.classList.remove('target-flash');
1036 });
1037 if (!location.hash) return;
1038 var t = null;
1039 try { t = document.querySelector(location.hash); } catch (err) { return; }
1040 if (t) t.classList.add('target-flash');
1041 }
1042 window.addEventListener('hashchange', markTarget);
1043 markTarget();
1044 }
1045 // The script tag sits in the body, so at parse time the content it
1046 // needs may not exist yet -- the first version ran before any of it
1047 // was there and silently added nothing.
1048 if (document.readyState === 'loading') {
1049 document.addEventListener('DOMContentLoaded', initAnchors);
1050 } else {
1051 initAnchors();
1052 }
1053
958 // Initialize footnote and citation tooltips 1054 // Initialize footnote and citation tooltips
959 function initTooltips() { 1055 function initTooltips() {
960 // Footnotes: create tooltip elements with cloned content 1056 // Footnotes: create tooltip elements with cloned content

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

function heading_id(t, s) {
365 return s 365 return s
366 } 366 }
367 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
368 function printp(tag, id) { 419 function printp(tag, id) {
369 if(!match(text, /^[ ]*$/)){ 420 if(!match(text, /^[ ]*$/)){
370 text = inline(text); 421 text = inline(text);
function printp(tag, id) {
377 else 428 else
378 oprint("<" tag ">" text "</" tag ">"); 429 oprint("<" tag ">" text "</" tag ">");
379 } 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 }
380 else 444 else
381 oprint("<" tag ">" text "</" tag ">"); 445 oprint("<" tag ">" text "</" tag ">");
382 } 446 }
function printp(tag, id) {
387 } 451 }
388 452
389 BEGIN { 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{|}~"
390 blank = 0; 457 blank = 0;
391 code = 0; 458 code = 0;
392 hr = 0; 459 hr = 0;

test/regressions.sh +67 -3

mkdir -p "$ix/pages/docs"
1134 printf '# Alpha One\n\nx\n' > "$ix/pages/docs/alpha.md" 1134 printf '# Alpha One\n\nx\n' > "$ix/pages/docs/alpha.md"
1135 printf '# Beta Two\n\nx\n' > "$ix/pages/docs/beta.md" 1135 printf '# Beta Two\n\nx\n' > "$ix/pages/docs/beta.md"
1136 run_jenny_split "$ix" 1136 run_jenny_split "$ix"
1137 rows=$("$GREP" -c '<p><a href="/docs/' "$ix/.dist/docs/index.html")
1137 # A paragraph carries a deep-link id now, so the opening tag is no longer bare.
1138 rows=$("$GREP" -c '<p[^>]*><a href="/docs/' "$ix/.dist/docs/index.html")
1138 assert_eq "$rows" "2" 1139 assert_eq "$rows" "2"
1139 assert_file_not_contains "$ix/.dist/docs/index.html" '<li><a href="/docs/' 1140 assert_file_not_contains "$ix/.dist/docs/index.html" '<li><a href="/docs/'
1140 1141
it "a heading with no sluggable characters gets no id"
1216 out=$(md_render '## ---') 1217 out=$(md_render '## ---')
1217 assert_not_contains "$out" 'id=""' 1218 assert_not_contains "$out" 'id=""'
1218 1219
1219 it "only headings carry ids"
1220 assert_not_contains "$(md_render 'Just a paragraph.')" 'id='
1220 it "a paragraph carries a deep-link id"
1221 # Headings were addressable and paragraphs were not, so a reader could link to
1222 # a section but not to the sentence they meant.
1223 assert_contains "$(md_render 'Just a paragraph.')" 'id="p-'
1224
1225 it "a paragraph's id is derived from its own text, not its position"
1226 # The point of hashing the text: inserting a paragraph above must not move the
1227 # anchor of the one below it. A positional id would keep resolving after the
1228 # insertion and quietly point at the wrong paragraph, which is worse than
1229 # breaking, because the link still looks like it works.
1230 before=$(md_render 'Alpha text.
1231
1232 Beta text.' | "$GREP" -o 'id="p-[a-f0-9]*"' | tail -1)
1233 after=$(md_render 'Inserted first.
1234
1235 Alpha text.
1236
1237 Beta text.' | "$GREP" -o 'id="p-[a-f0-9]*"' | tail -1)
1238 assert_eq "$before" "$after"
1239
1240 it "an id changes when the paragraph's own words change"
1241 # The other half of the contract, and the honest failure: the text someone
1242 # linked to is not there any more, so the link should not silently land on
1243 # something else.
1244 one=$(md_render 'Alpha text.' | "$GREP" -o 'id="p-[a-f0-9]*"')
1245 two=$(md_render 'Alpha text edited.' | "$GREP" -o 'id="p-[a-f0-9]*"')
1246 if [ "$one" != "$two" ]; then pass; else fail "id did not change with the text: $one"; fi
1247
1248 it "repeated text still gets unique ids"
1249 # Two paragraphs with the same words hash the same; ids on one page must not
1250 # collide or the second is unreachable.
1251 dup=$(md_render 'Same words.
1252
1253 Same words.' | "$GREP" -c 'id="p-')
1254 assert_eq "$dup" "2"
1255 assert_contains "$(md_render 'Same words.
1256
1257 Same words.')" '-2"'
1221 1258
1222 it "a change to the parser invalidates the lock" 1259 it "a change to the parser invalidates the lock"
1223 # The build signature covered the layouts but not the parser, so adding ids to 1260 # The build signature covered the layouts but not the parser, so adding ids to
assert_file_contains "$dg/.dist/post/charted.html" '.contents :is(h1, h2) {'
1386 assert_file_contains "$dg/.dist/chart.html" 'width: 3ch;' 1423 assert_file_contains "$dg/.dist/chart.html" 'width: 3ch;'
1387 assert_file_contains "$dg/.dist/chart.html" '.contents :is(h1, h2)::before {' 1424 assert_file_contains "$dg/.dist/chart.html" '.contents :is(h1, h2)::before {'
1388 1425
1426 it "a passage offers a mark that copies its link"
1427 # Ids alone are undiscoverable -- a reader would have to open the source to
1428 # find one. Hovering a heading or paragraph reveals a mark that copies the
1429 # full URL to that place.
1430 assert_file_contains "$dg/.dist/chart.html" "a.className = 'anchor-link';"
1431 assert_file_contains "$dg/.dist/post/charted.html" "a.className = 'anchor-link';"
1432
1433 it "the mark is hidden until the passage is hovered or the mark focused"
1434 # Otherwise a page of prose is littered with symbols. Focus counts too, or a
1435 # keyboard user could never reach it.
1436 assert_file_contains "$dg/.dist/chart.html" '.anchor-link:focus { opacity: 1; }'
1437 assert_file_contains "$dg/.dist/chart.html" '@media (hover: none) { .anchor-link { display: none; } }'
1438
1439 it "the link is obtainable even without the clipboard"
1440 # The address bar is updated whether or not the copy succeeds, so the reader
1441 # can always take the link from there.
1442 assert_file_contains "$dg/.dist/chart.html" "history.replaceState(null, '', '#' + el.id);"
1443
1444 it "following a permalink marks what was linked"
1445 # A reader landing mid-document needs to see which passage was meant.
1446 assert_file_contains "$dg/.dist/chart.html" "t.classList.add('target-flash');"
1447
1448 it "the anchors wait for the document"
1449 # The script tag sits in the body; the first version ran before the content
1450 # existed and silently added no marks at all.
1451 assert_file_contains "$dg/.dist/chart.html" "document.addEventListener('DOMContentLoaded', initAnchors);"
1452
1389 it "a section heading is bold" 1453 it "a section heading is bold"
1390 # Every heading is body size, so weight is what separates a section heading 1454 # Every heading is body size, so weight is what separates a section heading
1391 # from the prose around it. h1 as well as h2: a page's title repeat is an h1, 1455 # from the prose around it. h1 as well as h2: a page's title repeat is an h1,

Raw

commit 50fcdc73f88be439fd2f277fd7325c0502d61f13
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Tue Sep 1 09:06:32 2026 +0800

    Make every passage addressable, not just every heading
    
    Headings have carried ids for a while, which is what makes a link like
    /actions#validate resolve. Paragraphs did not, so a reader could point at
    a section but never at the sentence they actually meant.
    
    Each paragraph now carries p-<hash of its own text>. Derived from the
    content rather than the position, so a permalink survives edits elsewhere
    on the page: insert three paragraphs above one, or reorder the sections
    around it, and the link still lands. It breaks only when that 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, because the link still looks like it works.
    
    The hash is FNV-1a written in awk rather than a call out to md5sum: a
    page has hundreds of paragraphs and a sub-process each measured ~1.9ms,
    around 0.6s of build time here for nothing. Checked against all 776
    paragraphs on the site -- no collisions between different texts. Two
    paragraphs with identical words do collide by construction; the second
    takes a -2 suffix so both stay reachable.
    
    The ids alone are undiscoverable -- a reader would have to open the
    source to find one -- so hovering a heading or paragraph reveals a mark
    that copies the full URL, and following a permalink marks what was
    linked. The mark is hidden until hover or focus, so prose is not littered
    and a keyboard user can still reach it, and hidden entirely where there
    is no hover at all. The address bar is updated whether or not the
    clipboard write succeeds, so the link is always obtainable.
    
    The affordance script waits for DOMContentLoaded. Its tag sits in the
    body and the first version ran before the content existed, adding no
    marks at all.
    
    One existing test counted '<p><a href="/docs/' to check a generated
    listing puts each entry on its own row. Paragraphs now open with an id,
    so the literal stopped matching while the behaviour was unchanged; it
    matches the tag rather than the exact bytes now. The test asserting that
    only headings carry ids was the invariant this replaces, and is now four
    tests over the new contract.

diff --git a/share/jenny/layout/page.sh b/share/jenny/layout/page.sh
index 672f7ba..3c48745 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -890,6 +890,34 @@ cat << _EOF_
            while their siblings were bold. Deeper headings stay regular -- they
            sit inside a section rather than opening one. */
         .contents :is(h1, h2) { font-weight: bold; }
+        /* Deep-link mark. Hidden until the passage is hovered or the mark
+           itself is focused, so a page of prose is not littered with symbols;
+           keyboard users reach it by tabbing, which is why focus counts. */
+        .anchor-link {
+          margin-left: .4em; text-decoration: none; opacity: 0;
+          color: var(--text-muted); font-weight: normal; cursor: pointer;
+        }
+        :is(h1, h2, h3, h4, p):hover > .anchor-link,
+        .anchor-link:focus { opacity: 1; }
+        /* Coarse pointers have no hover, so the mark would never appear and
+           would only take up room. */
+        @media (hover: none) { .anchor-link { display: none; } }
+        /* The passage a permalink pointed at, marked rather than animated: a
+           reader arriving mid-document needs to see which paragraph was meant,
+           and a flash they might miss does not tell them. */
+        .target-flash {
+          background-color: var(--bg-secondary);
+          box-shadow: 0 0 0 .4em var(--bg-secondary);
+          border-radius: 2px;
+        }
+        .copy-toast {
+          position: fixed; left: 50%; bottom: 2em; transform: translate(-50%, .5em);
+          background-color: var(--bg-secondary); color: var(--text-primary);
+          border: 1px solid var(--border-light); border-radius: 3px;
+          padding: .5em 1em; font-size: .875em; z-index: 3000;
+          opacity: 0; pointer-events: none;
+        }
+        .copy-toast.show { opacity: 1; transform: translate(-50%, 0); }
         /* Every heading is body size, which keeps the page even but leaves a
            section heading reading as a stray line of capitals: uppercase alone
            does not say "new section". A rule above says it structurally rather
@@ -1183,6 +1211,74 @@ cat << _EOF_
             localStorage.setItem('theme', newTheme);
           });
 
+          // Deep links: a heading or paragraph carries an id, and hovering it
+          // offers a mark that copies the link to that exact place. Without the
+          // affordance the ids are there but undiscoverable -- a reader would
+          // have to open the source to find one.
+          function initAnchors() {
+            var targets = document.querySelectorAll('.contents :is(h1,h2,h3,h4)[id], .contents p[id]');
+            if (!targets.length) return;
+
+            var toast = null;
+            function flash(msg) {
+              if (!toast) {
+                toast = document.createElement('div');
+                toast.className = 'copy-toast';
+                document.body.appendChild(toast);
+              }
+              toast.textContent = msg;
+              toast.classList.add('show');
+              clearTimeout(toast._t);
+              toast._t = setTimeout(function() { toast.classList.remove('show'); }, 1600);
+            }
+
+            targets.forEach(function(el) {
+              var a = document.createElement('a');
+              a.className = 'anchor-link';
+              a.href = '#' + el.id;
+              a.setAttribute('aria-label', 'Copy link to this passage');
+              a.textContent = '\u00B6';
+              a.addEventListener('click', function(e) {
+                e.preventDefault();
+                var url = location.origin + location.pathname + '#' + el.id;
+                // The address bar is updated either way, so the link is
+                // obtainable even where the clipboard is unavailable.
+                history.replaceState(null, '', '#' + el.id);
+                if (navigator.clipboard && navigator.clipboard.writeText) {
+                  navigator.clipboard.writeText(url).then(
+                    function() { flash('Link copied'); },
+                    function() { flash('Press \u2318/Ctrl+C to copy'); }
+                  );
+                } else {
+                  flash('Press \u2318/Ctrl+C to copy');
+                }
+              });
+              el.appendChild(a);
+            });
+
+            // Mark whatever the reader arrived at, so a permalink lands on
+            // something visibly identified rather than just scrolled to.
+            function markTarget() {
+              document.querySelectorAll('.target-flash').forEach(function(e) {
+                e.classList.remove('target-flash');
+              });
+              if (!location.hash) return;
+              var t = null;
+              try { t = document.querySelector(location.hash); } catch (err) { return; }
+              if (t) t.classList.add('target-flash');
+            }
+            window.addEventListener('hashchange', markTarget);
+            markTarget();
+          }
+          // The script tag sits in the body, so at parse time the content it
+          // needs may not exist yet -- the first version ran before any of it
+          // was there and silently added nothing.
+          if (document.readyState === 'loading') {
+            document.addEventListener('DOMContentLoaded', initAnchors);
+          } else {
+            initAnchors();
+          }
+
           // Initialize footnote and citation tooltips
           function initTooltips() {
             // Footnotes: create tooltip elements with cloned content
diff --git a/share/jenny/layout/post.sh b/share/jenny/layout/post.sh
index 926a1c8..1f05596 100755
--- a/share/jenny/layout/post.sh
+++ b/share/jenny/layout/post.sh
@@ -711,6 +711,34 @@ cat << _EOF_
            while their siblings were bold. Deeper headings stay regular -- they
            sit inside a section rather than opening one. */
         .contents :is(h1, h2) { font-weight: bold; }
+        /* Deep-link mark. Hidden until the passage is hovered or the mark
+           itself is focused, so a page of prose is not littered with symbols;
+           keyboard users reach it by tabbing, which is why focus counts. */
+        .anchor-link {
+          margin-left: .4em; text-decoration: none; opacity: 0;
+          color: var(--text-muted); font-weight: normal; cursor: pointer;
+        }
+        :is(h1, h2, h3, h4, p):hover > .anchor-link,
+        .anchor-link:focus { opacity: 1; }
+        /* Coarse pointers have no hover, so the mark would never appear and
+           would only take up room. */
+        @media (hover: none) { .anchor-link { display: none; } }
+        /* The passage a permalink pointed at, marked rather than animated: a
+           reader arriving mid-document needs to see which paragraph was meant,
+           and a flash they might miss does not tell them. */
+        .target-flash {
+          background-color: var(--bg-secondary);
+          box-shadow: 0 0 0 .4em var(--bg-secondary);
+          border-radius: 2px;
+        }
+        .copy-toast {
+          position: fixed; left: 50%; bottom: 2em; transform: translate(-50%, .5em);
+          background-color: var(--bg-secondary); color: var(--text-primary);
+          border: 1px solid var(--border-light); border-radius: 3px;
+          padding: .5em 1em; font-size: .875em; z-index: 3000;
+          opacity: 0; pointer-events: none;
+        }
+        .copy-toast.show { opacity: 1; transform: translate(-50%, 0); }
         /* Every heading is body size, which keeps the page even but leaves a
            section heading reading as a stray line of capitals: uppercase alone
            does not say "new section". A rule above says it structurally rather
@@ -955,6 +983,74 @@ cat << _EOF_
             localStorage.setItem('theme', newTheme);
           });
 
+          // Deep links: a heading or paragraph carries an id, and hovering it
+          // offers a mark that copies the link to that exact place. Without the
+          // affordance the ids are there but undiscoverable -- a reader would
+          // have to open the source to find one.
+          function initAnchors() {
+            var targets = document.querySelectorAll('.contents :is(h1,h2,h3,h4)[id], .contents p[id]');
+            if (!targets.length) return;
+
+            var toast = null;
+            function flash(msg) {
+              if (!toast) {
+                toast = document.createElement('div');
+                toast.className = 'copy-toast';
+                document.body.appendChild(toast);
+              }
+              toast.textContent = msg;
+              toast.classList.add('show');
+              clearTimeout(toast._t);
+              toast._t = setTimeout(function() { toast.classList.remove('show'); }, 1600);
+            }
+
+            targets.forEach(function(el) {
+              var a = document.createElement('a');
+              a.className = 'anchor-link';
+              a.href = '#' + el.id;
+              a.setAttribute('aria-label', 'Copy link to this passage');
+              a.textContent = '\u00B6';
+              a.addEventListener('click', function(e) {
+                e.preventDefault();
+                var url = location.origin + location.pathname + '#' + el.id;
+                // The address bar is updated either way, so the link is
+                // obtainable even where the clipboard is unavailable.
+                history.replaceState(null, '', '#' + el.id);
+                if (navigator.clipboard && navigator.clipboard.writeText) {
+                  navigator.clipboard.writeText(url).then(
+                    function() { flash('Link copied'); },
+                    function() { flash('Press \u2318/Ctrl+C to copy'); }
+                  );
+                } else {
+                  flash('Press \u2318/Ctrl+C to copy');
+                }
+              });
+              el.appendChild(a);
+            });
+
+            // Mark whatever the reader arrived at, so a permalink lands on
+            // something visibly identified rather than just scrolled to.
+            function markTarget() {
+              document.querySelectorAll('.target-flash').forEach(function(e) {
+                e.classList.remove('target-flash');
+              });
+              if (!location.hash) return;
+              var t = null;
+              try { t = document.querySelector(location.hash); } catch (err) { return; }
+              if (t) t.classList.add('target-flash');
+            }
+            window.addEventListener('hashchange', markTarget);
+            markTarget();
+          }
+          // The script tag sits in the body, so at parse time the content it
+          // needs may not exist yet -- the first version ran before any of it
+          // was there and silently added nothing.
+          if (document.readyState === 'loading') {
+            document.addEventListener('DOMContentLoaded', initAnchors);
+          } else {
+            initAnchors();
+          }
+
           // Initialize footnote and citation tooltips
           function initTooltips() {
             // Footnotes: create tooltip elements with cloned content
diff --git a/share/jenny/lib/md2html.awk b/share/jenny/lib/md2html.awk
index b5f2dce..f2ef676 100755
--- a/share/jenny/lib/md2html.awk
+++ b/share/jenny/lib/md2html.awk
@@ -365,6 +365,57 @@ function heading_id(t,   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);
@@ -377,6 +428,19 @@ function printp(tag,   id) {
 				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 ">");
 		}
@@ -387,6 +451,9 @@ function printp(tag,   id) {
 }
 
 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;
diff --git a/test/regressions.sh b/test/regressions.sh
index f234b8e..6315aac 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -1134,7 +1134,8 @@ mkdir -p "$ix/pages/docs"
 printf '# Alpha One\n\nx\n' > "$ix/pages/docs/alpha.md"
 printf '# Beta Two\n\nx\n' > "$ix/pages/docs/beta.md"
 run_jenny_split "$ix"
-rows=$("$GREP" -c '<p><a href="/docs/' "$ix/.dist/docs/index.html")
+# A paragraph carries a deep-link id now, so the opening tag is no longer bare.
+rows=$("$GREP" -c '<p[^>]*><a href="/docs/' "$ix/.dist/docs/index.html")
 assert_eq "$rows" "2"
 assert_file_not_contains "$ix/.dist/docs/index.html" '<li><a href="/docs/'
 
@@ -1216,8 +1217,44 @@ it "a heading with no sluggable characters gets no id"
 out=$(md_render '## ---')
 assert_not_contains "$out" 'id=""'
 
-it "only headings carry ids"
-assert_not_contains "$(md_render 'Just a paragraph.')" 'id='
+it "a paragraph carries a deep-link id"
+# Headings were addressable and paragraphs were not, so a reader could link to
+# a section but not to the sentence they meant.
+assert_contains "$(md_render 'Just a paragraph.')" 'id="p-'
+
+it "a paragraph's id is derived from its own text, not its position"
+# The point of hashing the text: inserting a paragraph above must not move the
+# anchor of the one below it. A positional id would keep resolving after the
+# insertion and quietly point at the wrong paragraph, which is worse than
+# breaking, because the link still looks like it works.
+before=$(md_render 'Alpha text.
+
+Beta text.' | "$GREP" -o 'id="p-[a-f0-9]*"' | tail -1)
+after=$(md_render 'Inserted first.
+
+Alpha text.
+
+Beta text.' | "$GREP" -o 'id="p-[a-f0-9]*"' | tail -1)
+assert_eq "$before" "$after"
+
+it "an id changes when the paragraph's own words change"
+# The other half of the contract, and the honest failure: the text someone
+# linked to is not there any more, so the link should not silently land on
+# something else.
+one=$(md_render 'Alpha text.' | "$GREP" -o 'id="p-[a-f0-9]*"')
+two=$(md_render 'Alpha text edited.' | "$GREP" -o 'id="p-[a-f0-9]*"')
+if [ "$one" != "$two" ]; then pass; else fail "id did not change with the text: $one"; fi
+
+it "repeated text still gets unique ids"
+# Two paragraphs with the same words hash the same; ids on one page must not
+# collide or the second is unreachable.
+dup=$(md_render 'Same words.
+
+Same words.' | "$GREP" -c 'id="p-')
+assert_eq "$dup" "2"
+assert_contains "$(md_render 'Same words.
+
+Same words.')" '-2"'
 
 it "a change to the parser invalidates the lock"
 # The build signature covered the layouts but not the parser, so adding ids to
@@ -1386,6 +1423,33 @@ assert_file_contains "$dg/.dist/post/charted.html" '.contents :is(h1, h2) {'
 assert_file_contains "$dg/.dist/chart.html" 'width: 3ch;'
 assert_file_contains "$dg/.dist/chart.html" '.contents :is(h1, h2)::before {'
 
+it "a passage offers a mark that copies its link"
+# Ids alone are undiscoverable -- a reader would have to open the source to
+# find one. Hovering a heading or paragraph reveals a mark that copies the
+# full URL to that place.
+assert_file_contains "$dg/.dist/chart.html" "a.className = 'anchor-link';"
+assert_file_contains "$dg/.dist/post/charted.html" "a.className = 'anchor-link';"
+
+it "the mark is hidden until the passage is hovered or the mark focused"
+# Otherwise a page of prose is littered with symbols. Focus counts too, or a
+# keyboard user could never reach it.
+assert_file_contains "$dg/.dist/chart.html" '.anchor-link:focus { opacity: 1; }'
+assert_file_contains "$dg/.dist/chart.html" '@media (hover: none) { .anchor-link { display: none; } }'
+
+it "the link is obtainable even without the clipboard"
+# The address bar is updated whether or not the copy succeeds, so the reader
+# can always take the link from there.
+assert_file_contains "$dg/.dist/chart.html" "history.replaceState(null, '', '#' + el.id);"
+
+it "following a permalink marks what was linked"
+# A reader landing mid-document needs to see which passage was meant.
+assert_file_contains "$dg/.dist/chart.html" "t.classList.add('target-flash');"
+
+it "the anchors wait for the document"
+# The script tag sits in the body; the first version ran before the content
+# existed and silently added no marks at all.
+assert_file_contains "$dg/.dist/chart.html" "document.addEventListener('DOMContentLoaded', initAnchors);"
+
 it "a section heading is bold"
 # Every heading is body size, so weight is what separates a section heading
 # from the prose around it. h1 as well as h2: a page's title repeat is an h1,