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,