Commit 7a99676 h
Pat Ambrosio · 26-08-25 21:21
Give navs flat sections and nested entries
Changes: 4 files
+268
-22
Changes (Side-by-Side)
README.md +54 -4
Chapter 1 /tutorial/ch1 |
| 239 |
Chapter 2 /tutorial/ch2 |
239 |
Chapter 2 /tutorial/ch2 |
| 240 |
``` |
240 |
``` |
| 241 |
|
241 |
|
| 242 |
**Template navs** are presentation: a layout renders a nav without any page mentioning it. The default theme includes a footer nav that appears on every page. Create `navs/site-footer.tsv` with your site-wide links, or leave it absent — the footer will silently not render. |
| |
| |
242 |
**Template navs** are presentation: a layout renders a nav without any page mentioning it, so the same links appear on every page without each page asking for them. |
| 243 |
|
243 |
|
| 244 |
Comments (`#`) and blank lines in nav files are ignored, so you can organize them: |
| |
| |
244 |
The default theme ships `render_footer_nav`, which reads `navs/site-footer.tsv`, but does not call it — the default theme renders no footer. It is kept as a worked example of the mechanism: call it from your own layout, or write your own reader, to put a theme-declared nav on every page. |
| |
245 |
|
| |
246 |
Comments (`#`) and blank lines are ignored, so you can annotate a nav file |
| |
247 |
without the notes appearing on the page: |
| 245 |
|
248 |
|
| 246 |
``` |
249 |
``` |
| 247 |
# Site structure |
| |
| |
250 |
# reviewed 2026-08; drop the legal links when the new ones land |
| 248 |
Home / |
251 |
Home / |
| 249 |
About /about |
252 |
About /about |
| |
253 |
``` |
| |
254 |
|
| |
255 |
### Sections |
| 250 |
|
256 |
|
| 251 |
# Legal |
| |
| |
257 |
A line beginning with `##` is a section: a label that names the group of |
| |
258 |
entries under it. Sections are flat — a section groups the entries that follow |
| |
259 |
it and sections do not contain other sections. |
| |
260 |
|
| |
261 |
``` |
| |
262 |
## Site structure |
| |
263 |
Home / |
| |
264 |
About /about |
| |
265 |
|
| |
266 |
## Legal |
| 252 |
Privacy /privacy |
267 |
Privacy /privacy |
| 253 |
Terms /terms |
268 |
Terms /terms |
| 254 |
``` |
269 |
``` |
| 255 |
|
270 |
|
| |
271 |
`##` rather than `#` so that existing nav files do not change meaning: comments |
| |
272 |
were always dropped, and turning every one of them into a visible heading would |
| |
273 |
change what already-written navs render. |
| |
274 |
|
| |
275 |
### Nesting |
| |
276 |
|
| |
277 |
An entry indented by two spaces is a child of the entry above it. Nesting |
| |
278 |
applies to entries, not sections, and can go as deep as you like: |
| |
279 |
|
| |
280 |
``` |
| |
281 |
## Guide |
| |
282 |
Overview /guide |
| |
283 |
Setup /guide/setup |
| |
284 |
Requirements /guide/setup/reqs |
| |
285 |
Usage /guide/usage |
| |
286 |
Back at top /top |
| |
287 |
``` |
| |
288 |
|
| |
289 |
Depth comes from each line's own indentation, so an entry returns to any level |
| |
290 |
simply by being indented that far. Indent with spaces, not tabs — tab is the |
| |
291 |
field separator between a label and its path. |
| |
292 |
|
| |
293 |
The default theme indents each level and stops adding indent past the third, |
| |
294 |
so a deep entry stays inside the sidebar column rather than running out of it. |
| |
295 |
A theme that wants a different treatment can style `.nav-depth-0` through |
| |
296 |
`.nav-depth-3`, or ignore depth entirely. |
| |
297 |
|
| |
298 |
### What a nav means |
| |
299 |
|
| |
300 |
A nav carries no meaning of its own. It is an ordered list of names and places, |
| |
301 |
with grouping and depth attached; what to do with that is the template's |
| |
302 |
decision. The default page layout renders it as a sidebar with section labels |
| |
303 |
and indented children. The footer nav in the same file reads the same |
| |
304 |
structure and deliberately flattens it, because a footer is a row of links. |
| |
305 |
|
| 256 |
### Customizing template navs |
306 |
### Customizing template navs |
| 257 |
|
307 |
|
| 258 |
If you customize the layout, you can add template navs to your own templates. Source the nav helper and call `read_nav` with `--optional`: |
308 |
If you customize the layout, you can add template navs to your own templates. Source the nav helper and call `read_nav` with `--optional`: |
share/jenny/layout/page.sh +59 -7
URL_PREFIX=${URL_PREFIX:-.} |
| 28 |
# built by components.sh, which knows where the file sits. The last segment |
28 |
# built by components.sh, which knows where the file sits. The last segment |
| 29 |
# is the page itself and is marked current, matching how an index or tag page |
29 |
# is the page itself and is marked current, matching how an index or tag page |
| 30 |
# shows where you are. |
30 |
# shows where you are. |
| 31 |
# PAGE_NAV is the nav the page declared, as "label<TAB>path" lines. The paths |
| |
| 32 |
# are site-root-relative; they become relative hrefs here. jenny attaches no |
| |
| 33 |
# meaning to a nav -- rendering it as a list is this template's choice. |
| |
| |
31 |
# PAGE_NAV is the nav the page declared, as "kind<TAB>depth<TAB>label<TAB>path" |
| |
32 |
# lines. The paths are site-root-relative; they become relative hrefs here. |
| |
33 |
# jenny attaches no meaning to a nav -- rendering it as a list is this |
| |
34 |
# template's choice, and so is what a section and a depth look like. |
| |
35 |
# |
| |
36 |
# A section is a label, not a link: it names the group under it and there is |
| |
37 |
# nowhere for it to go. Depth is rendered as a class rather than an inline |
| |
38 |
# style so a theme can restyle a level -- or ignore depth entirely -- without |
| |
39 |
# touching this function. |
| 34 |
page_nav() { |
40 |
page_nav() { |
| 35 |
[ -n "${PAGE_NAV:-}" ] || return 0 |
41 |
[ -n "${PAGE_NAV:-}" ] || return 0 |
| 36 |
printf '<nav class="page-nav">\n' |
42 |
printf '<nav class="page-nav">\n' |
| 37 |
printf '%s\n' "$PAGE_NAV" | while IFS="$(printf '\t')" read -r label path; do |
| |
| |
43 |
printf '%s\n' "$PAGE_NAV" | while IFS="$(printf '\t')" read -r kind depth label path; do |
| 38 |
[ -n "$label" ] || continue |
44 |
[ -n "$label" ] || continue |
| 39 |
printf ' <a class="nav-item" href="%s%s">%s</a>\n' \ |
| |
| 40 |
"$URL_PREFIX" "$(html_escape "$path")" "$(html_escape "$label")" |
| |
| |
45 |
case "$kind" in |
| |
46 |
section) |
| |
47 |
printf ' <p class="nav-section">%s</p>\n' "$(html_escape "$label")" |
| |
48 |
;; |
| |
49 |
*) |
| |
50 |
# Depth is clamped for the class name only: past three levels the |
| |
51 |
# indent stops growing rather than marching the label off the column. |
| |
52 |
# The nesting is still expressed in the markup for anyone who styles |
| |
53 |
# deeper. |
| |
54 |
d=${depth:-0} |
| |
55 |
[ "$d" -gt 3 ] 2>/dev/null && d=3 |
| |
56 |
printf ' <a class="nav-item nav-depth-%s" href="%s%s">%s</a>\n' \ |
| |
57 |
"$d" "$URL_PREFIX" "$(html_escape "$path")" "$(html_escape "$label")" |
| |
58 |
;; |
| |
59 |
esac |
| 41 |
done |
60 |
done |
| 42 |
printf '</nav>\n' |
61 |
printf '</nav>\n' |
| 43 |
} |
62 |
} |
render_footer_nav() { |
| 80 |
[ -n "$lines" ] || return 0 |
99 |
[ -n "$lines" ] || return 0 |
| 81 |
|
100 |
|
| 82 |
printf '<footer class="site-footer">\n' |
101 |
printf '<footer class="site-footer">\n' |
| 83 |
printf '%s\n' "$lines" | while IFS="$(printf '\t')" read -r label path; do |
| |
| |
102 |
# A footer is a flat row of links, so sections and depth are read and |
| |
103 |
# deliberately dropped rather than rendered. A nav carries structure; what |
| |
104 |
# to do with it is each template's decision, and this one flattens. |
| |
105 |
printf '%s\n' "$lines" | while IFS="$(printf '\t')" read -r kind depth label path; do |
| |
106 |
[ "$kind" = "entry" ] || continue |
| 84 |
[ -n "$label" ] || continue |
107 |
[ -n "$label" ] || continue |
| 85 |
printf ' <a class="footer-link" href="%s%s">%s</a>\n' \ |
108 |
printf ' <a class="footer-link" href="%s%s">%s</a>\n' \ |
| 86 |
"$URL_PREFIX" "$(html_escape "$path")" "$(html_escape "$label")" |
109 |
"$URL_PREFIX" "$(html_escape "$path")" "$(html_escape "$label")" |
cat << _EOF_ |
| 329 |
.page-nav { display: table-cell; vertical-align: top; |
352 |
.page-nav { display: table-cell; vertical-align: top; |
| 330 |
width: var(--page-nav-width); |
353 |
width: var(--page-nav-width); |
| 331 |
min-width: var(--page-nav-width); |
354 |
min-width: var(--page-nav-width); |
| |
355 |
max-width: var(--page-nav-width); |
| 332 |
margin: 0; padding: 0; font-size: .9em; } |
356 |
margin: 0; padding: 0; font-size: .9em; } |
| |
357 |
/* A label with no space in it -- a long slug, a URL -- cannot wrap, so |
| |
358 |
the column grew to fit it and pushed the body right; min-width is a |
| |
359 |
floor, not a ceiling. The ceiling plus overflow-wrap keeps the |
| |
360 |
column at 6rem and breaks the word instead. Ordinary multi-word |
| |
361 |
labels wrap on their spaces and never reach this. */ |
| |
362 |
.page-nav .nav-item, |
| |
363 |
.page-nav .nav-section { overflow-wrap: break-word; } |
| 333 |
.page-nav .nav-item { display: block; text-decoration: none; |
364 |
.page-nav .nav-item { display: block; text-decoration: none; |
| 334 |
color: var(--link-nav); margin-bottom: .35em; } |
365 |
color: var(--link-nav); margin-bottom: .35em; } |
| 335 |
.page-nav .nav-item:hover { color: var(--link-nav-hover); } |
366 |
.page-nav .nav-item:hover { color: var(--link-nav-hover); } |
| |
367 |
/* A section names the group under it. Set apart from the links by |
| |
368 |
case and colour rather than by weight or a rule, so it reads as a |
| |
369 |
label without competing with the page's own headings. No margin |
| |
370 |
above the first one -- it has to start level with the page title, |
| |
371 |
which is the whole point of the shared table row. */ |
| |
372 |
.page-nav .nav-section { text-transform: uppercase; color: var(--text-muted); |
| |
373 |
margin: 1.5em 0 .5em; font-size: .9em; } |
| |
374 |
.page-nav .nav-section:first-child { margin-top: 0; } |
| |
375 |
/* Depth as an indent step. Kept to .75em per level: the column is |
| |
376 |
6rem, so a wider step would push a third-level label out of it, and |
| |
377 |
these are links in a sidebar rather than a tree that needs to be |
| |
378 |
read as one. */ |
| |
379 |
/* Padding rather than a text-indent hang: an entry is one label, so |
| |
380 |
every line of it belongs at the same depth. A negative text-indent |
| |
381 |
is for a list marker that should sit outside the text block, and |
| |
382 |
using it here put the first line .75em left of its own wrapped |
| |
383 |
lines -- the label appeared to straddle two levels. Labels in a 6rem |
| |
384 |
column wrap often enough that this is the common case, not an edge. */ |
| |
385 |
.page-nav .nav-depth-1 { padding-left: .75em; } |
| |
386 |
.page-nav .nav-depth-2 { padding-left: 1.5em; } |
| |
387 |
.page-nav .nav-depth-3 { padding-left: 2.25em; } |
| 336 |
|
388 |
|
| 337 |
/* The sidebar and the body are two cells of one table row, which is |
389 |
/* The sidebar and the body are two cells of one table row, which is |
| 338 |
the same mechanism the post layout uses to hold a stamp beside a |
390 |
the same mechanism the post layout uses to hold a stamp beside a |
share/jenny/lib/helpers.sh +57 -8
function assert_layouts_current() { |
| 564 |
# means is the template's business. |
564 |
# means is the template's business. |
| 565 |
|
565 |
|
| 566 |
# read_nav <navs_dir> <name> [--optional] |
566 |
# read_nav <navs_dir> <name> [--optional] |
| 567 |
# Echoes the nav's entries, one "label<TAB>path" per line. Blank lines and |
| |
| 568 |
# lines starting with # are dropped, so an author can group a long list. |
| |
| |
567 |
# Echoes the nav's entries, one "kind<TAB>depth<TAB>label<TAB>path" per line. |
| |
568 |
# |
| |
569 |
# A nav file has three kinds of line: |
| |
570 |
# |
| |
571 |
# Overview /guide an entry: label, tab, site-root-relative path |
| |
572 |
# Setup /guide/setup a nested entry: two leading spaces per level |
| |
573 |
# ## Reference a section label: groups the entries that follow |
| |
574 |
# # anything else a comment, dropped |
| |
575 |
# |
| |
576 |
# Sections are flat -- a section groups the entries under it and sections do |
| |
577 |
# not contain other sections. Nesting is for entries: a child is indented two |
| |
578 |
# spaces relative to its parent, and depth is reported so a template can |
| |
579 |
# indent it. The two are independent, which is why a section is a marker and |
| |
580 |
# depth is a number: grouping and hierarchy are different questions. |
| |
581 |
# |
| |
582 |
# "##" rather than "#" for a section so that existing files do not change |
| |
583 |
# meaning. Comments were always dropped, and the README's own example uses |
| |
584 |
# them to group a long list; promoting every one of those to a visible heading |
| |
585 |
# would be a silent regression in any nav already written. |
| |
586 |
# |
| |
587 |
# kind and depth lead, and path -- the only field that can be empty -- is |
| |
588 |
# last. Tab is IFS whitespace to the shell, so a run of tabs collapses into |
| |
589 |
# one separator: an empty field in the middle silently shifts every field |
| |
590 |
# after it left. Putting the empty-able field at the end means a reader using |
| |
591 |
# plain "read a b c d" gets the right values, with the empty path simply |
| |
592 |
# arriving as an unset trailing variable. |
| |
593 |
# |
| |
594 |
# A section carries an empty path, which is what distinguishes it: a section |
| |
595 |
# names a group, it does not go anywhere. |
| |
596 |
# |
| 569 |
# By default, fails when the nav does not exist: a mistyped name is a mistake. |
597 |
# By default, fails when the nav does not exist: a mistyped name is a mistake. |
| 570 |
# With --optional, returns 0 and outputs nothing if the nav does not exist: |
598 |
# With --optional, returns 0 and outputs nothing if the nav does not exist: |
| 571 |
# this is for presentation navs that a template wants but are not required. |
599 |
# this is for presentation navs that a template wants but are not required. |
function read_nav() { |
| 587 |
return 1 |
615 |
return 1 |
| 588 |
fi |
616 |
fi |
| 589 |
|
617 |
|
| |
618 |
# Indentation is measured before the line is split, because the field |
| |
619 |
# separator is a tab and the indent is spaces: counting first keeps the two |
| |
620 |
# from being confused. An odd number of spaces rounds down rather than |
| |
621 |
# failing the build -- a miscounted space is a typo in a list of links, not |
| |
622 |
# something worth refusing to publish over. |
| 590 |
"$AWK" -F'\t' ' |
623 |
"$AWK" -F'\t' ' |
| 591 |
/^[ \t]*#/ { next } |
| |
| 592 |
/^[ \t]*$/ { next } |
| |
| 593 |
NF >= 2 { |
| |
| 594 |
label = $1 |
| |
| 595 |
path = $2 |
| |
| |
624 |
{ |
| |
625 |
line = $0 |
| |
626 |
indent = 0 |
| |
627 |
while (match(line, /^ /)) { indent++; line = substr(line, 3) } |
| |
628 |
sub(/^[ \t]+/, "", line) |
| |
629 |
} |
| |
630 |
line ~ /^##/ { |
| |
631 |
label = substr(line, 3) |
| |
632 |
sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label) |
| |
633 |
# A section with no name is an empty heading, which is a typo rather |
| |
634 |
# than a divider; nothing is rendered for it. |
| |
635 |
if (label != "") print "section\t" indent "\t" label "\t" |
| |
636 |
next |
| |
637 |
} |
| |
638 |
line ~ /^#/ { next } |
| |
639 |
line ~ /^[ \t]*$/ { next } |
| |
640 |
{ |
| |
641 |
n = split(line, f, "\t") |
| |
642 |
if (n < 2) next |
| |
643 |
label = f[1] |
| |
644 |
path = f[2] |
| 596 |
sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label) |
645 |
sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label) |
| 597 |
sub(/^[ \t]+/, "", path); sub(/[ \t]+$/, "", path) |
646 |
sub(/^[ \t]+/, "", path); sub(/[ \t]+$/, "", path) |
| 598 |
if (label != "" && path != "") |
647 |
if (label != "" && path != "") |
| 599 |
print label "\t" path |
| |
| |
648 |
print "entry\t" indent "\t" label "\t" path |
| 600 |
} |
649 |
} |
| 601 |
' "$file" |
650 |
' "$file" |
| 602 |
} |
651 |
} |
test/regressions.sh +98 -3
it "blank lines in a nav are ignored" |
| 1046 |
printf 'One\t/one\n\nTwo\t/two\n' > "$nv/navs/spaced.tsv" |
1046 |
printf 'One\t/one\n\nTwo\t/two\n' > "$nv/navs/spaced.tsv" |
| 1047 |
printf 'Nav: spaced\n\n# Spaced\n\nText.\n' > "$nv/pages/spaced.md" |
1047 |
printf 'Nav: spaced\n\n# Spaced\n\nText.\n' > "$nv/pages/spaced.md" |
| 1048 |
run_jenny "$nv" |
1048 |
run_jenny "$nv" |
| 1049 |
assert_eq "$(grep -c 'class="nav-item"' "$nv/.dist/spaced.html")" "2" |
| |
| |
1049 |
assert_eq "$(grep -c 'class="nav-item ' "$nv/.dist/spaced.html")" "2" |
| 1050 |
|
1050 |
|
| 1051 |
it "comment lines in a nav are ignored" |
1051 |
it "comment lines in a nav are ignored" |
| 1052 |
printf '# a heading for the author\nOnly\t/only\n' > "$nv/navs/commented.tsv" |
1052 |
printf '# a heading for the author\nOnly\t/only\n' > "$nv/navs/commented.tsv" |
| 1053 |
printf 'Nav: commented\n\n# Commented\n\nText.\n' > "$nv/pages/commented.md" |
1053 |
printf 'Nav: commented\n\n# Commented\n\nText.\n' > "$nv/pages/commented.md" |
| 1054 |
run_jenny "$nv" |
1054 |
run_jenny "$nv" |
| 1055 |
assert_eq "$(grep -c 'class="nav-item"' "$nv/.dist/commented.html")" "1" |
| |
| |
1055 |
assert_eq "$(grep -c 'class="nav-item ' "$nv/.dist/commented.html")" "1" |
| 1056 |
|
1056 |
|
| 1057 |
it "a label containing markup is escaped" |
1057 |
it "a label containing markup is escaped" |
| 1058 |
printf 'A & B <x>\t/ab\n' > "$nv/navs/esc.tsv" |
1058 |
printf 'A & B <x>\t/ab\n' > "$nv/navs/esc.tsv" |
assert_contains "$output" "nonexistent" |
| 1071 |
it "posts are unaffected by nav handling" |
1071 |
it "posts are unaffected by nav handling" |
| 1072 |
rm "$nv/pages/missing.md" |
1072 |
rm "$nv/pages/missing.md" |
| 1073 |
run_jenny "$nv" |
1073 |
run_jenny "$nv" |
| 1074 |
assert_file_not_contains "$nv/.dist/post/post-01.html" 'class="nav-item"' |
| |
| |
1074 |
assert_file_not_contains "$nv/.dist/post/post-01.html" 'class="nav-item' |
| |
1075 |
|
| |
1076 |
suite "nav sections and nesting" |
| |
1077 |
|
| |
1078 |
# A nav groups with sections and nests with indentation. The two are separate: |
| |
1079 |
# sections are flat labels that group the entries under them, and nesting is |
| |
1080 |
# for entries, which can have children indented relative to their parent. |
| |
1081 |
|
| |
1082 |
nvs="$TMPROOT/navsections" |
| |
1083 |
make_blog_min "$nvs" 1 10 |
| |
1084 |
mkdir -p "$nvs/pages" "$nvs/navs" |
| |
1085 |
printf 'Nav: structured\n\n# Structured\n\nBody.\n' > "$nvs/pages/s.md" |
| |
1086 |
{ |
| |
1087 |
printf '# a private note\n' |
| |
1088 |
printf '## Site structure\n' |
| |
1089 |
printf 'Home\t/\n' |
| |
1090 |
printf '## Guide\n' |
| |
1091 |
printf 'Overview\t/guide\n' |
| |
1092 |
printf ' Setup\t/guide/setup\n' |
| |
1093 |
printf ' Requirements\t/guide/setup/reqs\n' |
| |
1094 |
printf ' Usage\t/guide/usage\n' |
| |
1095 |
printf 'Back at top\t/top\n' |
| |
1096 |
} > "$nvs/navs/structured.tsv" |
| |
1097 |
run_jenny_split "$nvs" |
| |
1098 |
|
| |
1099 |
it "a section renders as a label, not a link" |
| |
1100 |
# A section names the group under it; there is nowhere for it to go, so it |
| |
1101 |
# must not be an anchor with an href. |
| |
1102 |
assert_file_contains "$nvs/.dist/s.html" '<p class="nav-section">Site structure</p>' |
| |
1103 |
|
| |
1104 |
it "every section renders" |
| |
1105 |
assert_eq "$("$GREP" -c 'class="nav-section"' "$nvs/.dist/s.html")" "2" |
| |
1106 |
|
| |
1107 |
it "a single-hash line is still a comment" |
| |
1108 |
# "##" rather than "#" for a section exactly so that existing files do not |
| |
1109 |
# change meaning -- the README's own example uses comments to group links, and |
| |
1110 |
# promoting those to visible headings would be a silent regression. |
| |
1111 |
assert_file_not_contains "$nvs/.dist/s.html" 'a private note' |
| |
1112 |
|
| |
1113 |
it "entries carry their nesting depth" |
| |
1114 |
assert_file_contains "$nvs/.dist/s.html" 'class="nav-item nav-depth-0" href="./guide">Overview' |
| |
1115 |
assert_file_contains "$nvs/.dist/s.html" 'class="nav-item nav-depth-1" href="./guide/setup">Setup' |
| |
1116 |
assert_file_contains "$nvs/.dist/s.html" 'class="nav-item nav-depth-2" href="./guide/setup/reqs">Requirements' |
| |
1117 |
|
| |
1118 |
it "a sibling after a deeper child returns to its own depth" |
| |
1119 |
# Depth comes from the line's own indentation, not from what preceded it. |
| |
1120 |
assert_file_contains "$nvs/.dist/s.html" 'class="nav-item nav-depth-1" href="./guide/usage">Usage' |
| |
1121 |
|
| |
1122 |
it "an entry after a nested group returns to the top level" |
| |
1123 |
assert_file_contains "$nvs/.dist/s.html" 'class="nav-item nav-depth-0" href="./top">Back at top' |
| |
1124 |
|
| |
1125 |
it "sections do not nest" |
| |
1126 |
# Sections are flat: a section groups the entries under it and is not itself |
| |
1127 |
# a child of another section, so no section carries a depth class. |
| |
1128 |
assert_file_not_contains "$nvs/.dist/s.html" 'nav-section nav-depth' |
| |
1129 |
|
| |
1130 |
it "a section label is escaped" |
| |
1131 |
printf '## Tools & <Toys>\nHome\t/\n' > "$nvs/navs/structured.tsv" |
| |
1132 |
run_jenny_split "$nvs" |
| |
1133 |
assert_file_contains "$nvs/.dist/s.html" 'Tools & <Toys>' |
| |
1134 |
|
| |
1135 |
it "an empty section heading renders nothing" |
| |
1136 |
# "##" with no name is a typo, not a divider. |
| |
1137 |
printf '##\nHome\t/\n' > "$nvs/navs/structured.tsv" |
| |
1138 |
run_jenny_split "$nvs" |
| |
1139 |
assert_file_not_contains "$nvs/.dist/s.html" 'class="nav-section"' |
| |
1140 |
|
| |
1141 |
it "an entry with an odd indent rounds down rather than failing" |
| |
1142 |
# A miscounted space is a typo in a list of links, not a reason to refuse to |
| |
1143 |
# publish. |
| |
1144 |
printf 'Home\t/\n Odd\t/odd\n' > "$nvs/navs/structured.tsv" |
| |
1145 |
run_jenny_split "$nvs" |
| |
1146 |
assert_file_contains "$nvs/.dist/s.html" 'nav-depth-1" href="./odd"' |
| |
1147 |
|
| |
1148 |
it "the build stays clean with sections and nesting" |
| |
1149 |
assert_eq "$stderr_output" "" |
| |
1150 |
|
| |
1151 |
it "depth past the styled levels is clamped, not dropped" |
| |
1152 |
# The indent stops growing rather than marching the label out of a 6rem |
| |
1153 |
# column; the entry still renders. |
| |
1154 |
printf 'Home\t/\n Deep\t/deep\n' > "$nvs/navs/structured.tsv" |
| |
1155 |
run_jenny_split "$nvs" |
| |
1156 |
assert_file_contains "$nvs/.dist/s.html" 'nav-depth-3" href="./deep"' |
| |
1157 |
|
| |
1158 |
it "the nav column cannot be widened by an unbreakable label" |
| |
1159 |
# min-width is a floor, not a ceiling: a label with no space in it grew the |
| |
1160 |
# column to 215px and pushed the body right. The ceiling plus overflow-wrap |
| |
1161 |
# keeps the column fixed and breaks the word instead. |
| |
1162 |
assert_file_contains "$nvs/.dist/s.html" 'max-width: var(--page-nav-width)' |
| |
1163 |
assert_file_contains "$nvs/.dist/s.html" 'overflow-wrap: break-word' |
| |
1164 |
|
| |
1165 |
it "wrapped labels keep every line at the same depth" |
| |
1166 |
# A negative text-indent is for a list marker sitting outside the text block. |
| |
1167 |
# Used here it put an entry's first line left of its own wrapped lines, so the |
| |
1168 |
# label appeared to straddle two levels. |
| |
1169 |
assert_file_not_contains "$nvs/.dist/s.html" 'nav-depth-1 { padding-left: 1.5em; text-indent' |
| 1075 |
|
1170 |
|
| 1076 |
suite "dark mode" |
1171 |
suite "dark mode" |
| 1077 |
|
1172 |
|
Raw
commit 7a996761d5d30c451ae6a636da2c6a6104dd2ee5
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date: Tue Aug 25 21:21:30 2026 +0800
Give navs flat sections and nested entries
A nav was a flat list of names and places. Long ones had no way to say
that a run of entries belongs together, or that one entry is a child of
another -- the README's own example used comments as invisible group
headers, which is the feature asking to exist.
Sections group and nesting descends, and they are deliberately separate
mechanisms: a section is a marker, depth is a number. Sections are flat --
a section groups the entries under it and does not contain other sections.
Nesting is for entries, two spaces per level.
"##" marks a section rather than "#" so that existing nav files do not
change meaning. Comments were always dropped; promoting every one of them
to a visible heading would silently change what already-written navs
render. Indentation is spaces because tab is the field separator.
read_nav now emits "kind<TAB>depth<TAB>label<TAB>path". kind and depth
lead and path trails because path is the only field that can be empty --
a section has nowhere to go -- and tab is IFS whitespace to the shell, so
a run of tabs collapses into one separator and an empty middle field
shifts every field after it left. That bug rendered sections as links to
"..0" before the field order was changed to make it unrepresentable.
The template renders a section as a label and depth as a class, so a theme
can restyle a level or ignore depth entirely. render_footer_nav reads the
same structure and deliberately flattens it: a nav carries structure, and
what to do with it is each template's decision.
Two layout defects found by measuring rather than reading:
Indent uses padding alone. A negative text-indent is for a list marker
sitting outside the text block; used here it put an entry's first line
.75em left of its own wrapped lines, so a label appeared to straddle two
levels. In a 6rem column most labels wrap, so this was the common case.
The nav column now has a max-width and breaks words. min-width is a floor,
not a ceiling: a label with no space in it -- a slug, a URL -- grew the
column to 215px and pushed the body from 128 to 247.
Tests: a new suite covering section rendering, comment compatibility,
depth tracking, return-to-shallower, escaping, empty and odd-indented
lines, and the column cap. Checked by mutation -- single-# as section,
indent not counted, section case removed, depth class dropped -- each
caught. Two existing assertions pinned class="nav-item" with a closing
quote and broke on the added depth class; they now match the attribute's
opening, and a third that would have passed vacuously was repointed.
README documents both features and drops the claim that the default theme
renders a footer, which it no longer does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnSAstgnDYdKPwLGv1YZE5
diff --git a/README.md b/README.md
index 0abbce0..23682b8 100644
--- a/README.md
+++ b/README.md
@@ -239,20 +239,70 @@ Chapter 1 /tutorial/ch1
Chapter 2 /tutorial/ch2
```
-**Template navs** are presentation: a layout renders a nav without any page mentioning it. The default theme includes a footer nav that appears on every page. Create `navs/site-footer.tsv` with your site-wide links, or leave it absent — the footer will silently not render.
+**Template navs** are presentation: a layout renders a nav without any page mentioning it, so the same links appear on every page without each page asking for them.
-Comments (`#`) and blank lines in nav files are ignored, so you can organize them:
+The default theme ships `render_footer_nav`, which reads `navs/site-footer.tsv`, but does not call it — the default theme renders no footer. It is kept as a worked example of the mechanism: call it from your own layout, or write your own reader, to put a theme-declared nav on every page.
+
+Comments (`#`) and blank lines are ignored, so you can annotate a nav file
+without the notes appearing on the page:
```
-# Site structure
+# reviewed 2026-08; drop the legal links when the new ones land
Home /
About /about
+```
+
+### Sections
-# Legal
+A line beginning with `##` is a section: a label that names the group of
+entries under it. Sections are flat — a section groups the entries that follow
+it and sections do not contain other sections.
+
+```
+## Site structure
+Home /
+About /about
+
+## Legal
Privacy /privacy
Terms /terms
```
+`##` rather than `#` so that existing nav files do not change meaning: comments
+were always dropped, and turning every one of them into a visible heading would
+change what already-written navs render.
+
+### Nesting
+
+An entry indented by two spaces is a child of the entry above it. Nesting
+applies to entries, not sections, and can go as deep as you like:
+
+```
+## Guide
+Overview /guide
+ Setup /guide/setup
+ Requirements /guide/setup/reqs
+ Usage /guide/usage
+Back at top /top
+```
+
+Depth comes from each line's own indentation, so an entry returns to any level
+simply by being indented that far. Indent with spaces, not tabs — tab is the
+field separator between a label and its path.
+
+The default theme indents each level and stops adding indent past the third,
+so a deep entry stays inside the sidebar column rather than running out of it.
+A theme that wants a different treatment can style `.nav-depth-0` through
+`.nav-depth-3`, or ignore depth entirely.
+
+### What a nav means
+
+A nav carries no meaning of its own. It is an ordered list of names and places,
+with grouping and depth attached; what to do with that is the template's
+decision. The default page layout renders it as a sidebar with section labels
+and indented children. The footer nav in the same file reads the same
+structure and deliberately flattens it, because a footer is a row of links.
+
### Customizing template navs
If you customize the layout, you can add template navs to your own templates. Source the nav helper and call `read_nav` with `--optional`:
diff --git a/share/jenny/layout/page.sh b/share/jenny/layout/page.sh
index caa3b2d..3316907 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -28,16 +28,35 @@ URL_PREFIX=${URL_PREFIX:-.}
# built by components.sh, which knows where the file sits. The last segment
# is the page itself and is marked current, matching how an index or tag page
# shows where you are.
-# PAGE_NAV is the nav the page declared, as "label<TAB>path" lines. The paths
-# are site-root-relative; they become relative hrefs here. jenny attaches no
-# meaning to a nav -- rendering it as a list is this template's choice.
+# PAGE_NAV is the nav the page declared, as "kind<TAB>depth<TAB>label<TAB>path"
+# lines. The paths are site-root-relative; they become relative hrefs here.
+# jenny attaches no meaning to a nav -- rendering it as a list is this
+# template's choice, and so is what a section and a depth look like.
+#
+# A section is a label, not a link: it names the group under it and there is
+# nowhere for it to go. Depth is rendered as a class rather than an inline
+# style so a theme can restyle a level -- or ignore depth entirely -- without
+# touching this function.
page_nav() {
[ -n "${PAGE_NAV:-}" ] || return 0
printf '<nav class="page-nav">\n'
- printf '%s\n' "$PAGE_NAV" | while IFS="$(printf '\t')" read -r label path; do
+ printf '%s\n' "$PAGE_NAV" | while IFS="$(printf '\t')" read -r kind depth label path; do
[ -n "$label" ] || continue
- printf ' <a class="nav-item" href="%s%s">%s</a>\n' \
- "$URL_PREFIX" "$(html_escape "$path")" "$(html_escape "$label")"
+ case "$kind" in
+ section)
+ printf ' <p class="nav-section">%s</p>\n' "$(html_escape "$label")"
+ ;;
+ *)
+ # Depth is clamped for the class name only: past three levels the
+ # indent stops growing rather than marching the label off the column.
+ # The nesting is still expressed in the markup for anyone who styles
+ # deeper.
+ d=${depth:-0}
+ [ "$d" -gt 3 ] 2>/dev/null && d=3
+ printf ' <a class="nav-item nav-depth-%s" href="%s%s">%s</a>\n' \
+ "$d" "$URL_PREFIX" "$(html_escape "$path")" "$(html_escape "$label")"
+ ;;
+ esac
done
printf '</nav>\n'
}
@@ -80,7 +99,11 @@ render_footer_nav() {
[ -n "$lines" ] || return 0
printf '<footer class="site-footer">\n'
- printf '%s\n' "$lines" | while IFS="$(printf '\t')" read -r label path; do
+ # A footer is a flat row of links, so sections and depth are read and
+ # deliberately dropped rather than rendered. A nav carries structure; what
+ # to do with it is each template's decision, and this one flattens.
+ printf '%s\n' "$lines" | while IFS="$(printf '\t')" read -r kind depth label path; do
+ [ "$kind" = "entry" ] || continue
[ -n "$label" ] || continue
printf ' <a class="footer-link" href="%s%s">%s</a>\n' \
"$URL_PREFIX" "$(html_escape "$path")" "$(html_escape "$label")"
@@ -329,10 +352,39 @@ cat << _EOF_
.page-nav { display: table-cell; vertical-align: top;
width: var(--page-nav-width);
min-width: var(--page-nav-width);
+ max-width: var(--page-nav-width);
margin: 0; padding: 0; font-size: .9em; }
+ /* A label with no space in it -- a long slug, a URL -- cannot wrap, so
+ the column grew to fit it and pushed the body right; min-width is a
+ floor, not a ceiling. The ceiling plus overflow-wrap keeps the
+ column at 6rem and breaks the word instead. Ordinary multi-word
+ labels wrap on their spaces and never reach this. */
+ .page-nav .nav-item,
+ .page-nav .nav-section { overflow-wrap: break-word; }
.page-nav .nav-item { display: block; text-decoration: none;
color: var(--link-nav); margin-bottom: .35em; }
.page-nav .nav-item:hover { color: var(--link-nav-hover); }
+ /* A section names the group under it. Set apart from the links by
+ case and colour rather than by weight or a rule, so it reads as a
+ label without competing with the page's own headings. No margin
+ above the first one -- it has to start level with the page title,
+ which is the whole point of the shared table row. */
+ .page-nav .nav-section { text-transform: uppercase; color: var(--text-muted);
+ margin: 1.5em 0 .5em; font-size: .9em; }
+ .page-nav .nav-section:first-child { margin-top: 0; }
+ /* Depth as an indent step. Kept to .75em per level: the column is
+ 6rem, so a wider step would push a third-level label out of it, and
+ these are links in a sidebar rather than a tree that needs to be
+ read as one. */
+ /* Padding rather than a text-indent hang: an entry is one label, so
+ every line of it belongs at the same depth. A negative text-indent
+ is for a list marker that should sit outside the text block, and
+ using it here put the first line .75em left of its own wrapped
+ lines -- the label appeared to straddle two levels. Labels in a 6rem
+ column wrap often enough that this is the common case, not an edge. */
+ .page-nav .nav-depth-1 { padding-left: .75em; }
+ .page-nav .nav-depth-2 { padding-left: 1.5em; }
+ .page-nav .nav-depth-3 { padding-left: 2.25em; }
/* The sidebar and the body are two cells of one table row, which is
the same mechanism the post layout uses to hold a stamp beside a
diff --git a/share/jenny/lib/helpers.sh b/share/jenny/lib/helpers.sh
index 517c660..da6d09a 100644
--- a/share/jenny/lib/helpers.sh
+++ b/share/jenny/lib/helpers.sh
@@ -564,8 +564,36 @@ function assert_layouts_current() {
# means is the template's business.
# read_nav <navs_dir> <name> [--optional]
-# Echoes the nav's entries, one "label<TAB>path" per line. Blank lines and
-# lines starting with # are dropped, so an author can group a long list.
+# Echoes the nav's entries, one "kind<TAB>depth<TAB>label<TAB>path" per line.
+#
+# A nav file has three kinds of line:
+#
+# Overview /guide an entry: label, tab, site-root-relative path
+# Setup /guide/setup a nested entry: two leading spaces per level
+# ## Reference a section label: groups the entries that follow
+# # anything else a comment, dropped
+#
+# Sections are flat -- a section groups the entries under it and sections do
+# not contain other sections. Nesting is for entries: a child is indented two
+# spaces relative to its parent, and depth is reported so a template can
+# indent it. The two are independent, which is why a section is a marker and
+# depth is a number: grouping and hierarchy are different questions.
+#
+# "##" rather than "#" for a section so that existing files do not change
+# meaning. Comments were always dropped, and the README's own example uses
+# them to group a long list; promoting every one of those to a visible heading
+# would be a silent regression in any nav already written.
+#
+# kind and depth lead, and path -- the only field that can be empty -- is
+# last. Tab is IFS whitespace to the shell, so a run of tabs collapses into
+# one separator: an empty field in the middle silently shifts every field
+# after it left. Putting the empty-able field at the end means a reader using
+# plain "read a b c d" gets the right values, with the empty path simply
+# arriving as an unset trailing variable.
+#
+# A section carries an empty path, which is what distinguishes it: a section
+# names a group, it does not go anywhere.
+#
# By default, fails when the nav does not exist: a mistyped name is a mistake.
# With --optional, returns 0 and outputs nothing if the nav does not exist:
# this is for presentation navs that a template wants but are not required.
@@ -587,16 +615,37 @@ function read_nav() {
return 1
fi
+ # Indentation is measured before the line is split, because the field
+ # separator is a tab and the indent is spaces: counting first keeps the two
+ # from being confused. An odd number of spaces rounds down rather than
+ # failing the build -- a miscounted space is a typo in a list of links, not
+ # something worth refusing to publish over.
"$AWK" -F'\t' '
- /^[ \t]*#/ { next }
- /^[ \t]*$/ { next }
- NF >= 2 {
- label = $1
- path = $2
+ {
+ line = $0
+ indent = 0
+ while (match(line, /^ /)) { indent++; line = substr(line, 3) }
+ sub(/^[ \t]+/, "", line)
+ }
+ line ~ /^##/ {
+ label = substr(line, 3)
+ sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label)
+ # A section with no name is an empty heading, which is a typo rather
+ # than a divider; nothing is rendered for it.
+ if (label != "") print "section\t" indent "\t" label "\t"
+ next
+ }
+ line ~ /^#/ { next }
+ line ~ /^[ \t]*$/ { next }
+ {
+ n = split(line, f, "\t")
+ if (n < 2) next
+ label = f[1]
+ path = f[2]
sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label)
sub(/^[ \t]+/, "", path); sub(/[ \t]+$/, "", path)
if (label != "" && path != "")
- print label "\t" path
+ print "entry\t" indent "\t" label "\t" path
}
' "$file"
}
diff --git a/test/regressions.sh b/test/regressions.sh
index dfc7d32..ee2698e 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -1046,13 +1046,13 @@ it "blank lines in a nav are ignored"
printf 'One\t/one\n\nTwo\t/two\n' > "$nv/navs/spaced.tsv"
printf 'Nav: spaced\n\n# Spaced\n\nText.\n' > "$nv/pages/spaced.md"
run_jenny "$nv"
-assert_eq "$(grep -c 'class="nav-item"' "$nv/.dist/spaced.html")" "2"
+assert_eq "$(grep -c 'class="nav-item ' "$nv/.dist/spaced.html")" "2"
it "comment lines in a nav are ignored"
printf '# a heading for the author\nOnly\t/only\n' > "$nv/navs/commented.tsv"
printf 'Nav: commented\n\n# Commented\n\nText.\n' > "$nv/pages/commented.md"
run_jenny "$nv"
-assert_eq "$(grep -c 'class="nav-item"' "$nv/.dist/commented.html")" "1"
+assert_eq "$(grep -c 'class="nav-item ' "$nv/.dist/commented.html")" "1"
it "a label containing markup is escaped"
printf 'A & B <x>\t/ab\n' > "$nv/navs/esc.tsv"
@@ -1071,7 +1071,102 @@ assert_contains "$output" "nonexistent"
it "posts are unaffected by nav handling"
rm "$nv/pages/missing.md"
run_jenny "$nv"
-assert_file_not_contains "$nv/.dist/post/post-01.html" 'class="nav-item"'
+assert_file_not_contains "$nv/.dist/post/post-01.html" 'class="nav-item'
+
+suite "nav sections and nesting"
+
+# A nav groups with sections and nests with indentation. The two are separate:
+# sections are flat labels that group the entries under them, and nesting is
+# for entries, which can have children indented relative to their parent.
+
+nvs="$TMPROOT/navsections"
+make_blog_min "$nvs" 1 10
+mkdir -p "$nvs/pages" "$nvs/navs"
+printf 'Nav: structured\n\n# Structured\n\nBody.\n' > "$nvs/pages/s.md"
+{
+ printf '# a private note\n'
+ printf '## Site structure\n'
+ printf 'Home\t/\n'
+ printf '## Guide\n'
+ printf 'Overview\t/guide\n'
+ printf ' Setup\t/guide/setup\n'
+ printf ' Requirements\t/guide/setup/reqs\n'
+ printf ' Usage\t/guide/usage\n'
+ printf 'Back at top\t/top\n'
+} > "$nvs/navs/structured.tsv"
+run_jenny_split "$nvs"
+
+it "a section renders as a label, not a link"
+# A section names the group under it; there is nowhere for it to go, so it
+# must not be an anchor with an href.
+assert_file_contains "$nvs/.dist/s.html" '<p class="nav-section">Site structure</p>'
+
+it "every section renders"
+assert_eq "$("$GREP" -c 'class="nav-section"' "$nvs/.dist/s.html")" "2"
+
+it "a single-hash line is still a comment"
+# "##" rather than "#" for a section exactly so that existing files do not
+# change meaning -- the README's own example uses comments to group links, and
+# promoting those to visible headings would be a silent regression.
+assert_file_not_contains "$nvs/.dist/s.html" 'a private note'
+
+it "entries carry their nesting depth"
+assert_file_contains "$nvs/.dist/s.html" 'class="nav-item nav-depth-0" href="./guide">Overview'
+assert_file_contains "$nvs/.dist/s.html" 'class="nav-item nav-depth-1" href="./guide/setup">Setup'
+assert_file_contains "$nvs/.dist/s.html" 'class="nav-item nav-depth-2" href="./guide/setup/reqs">Requirements'
+
+it "a sibling after a deeper child returns to its own depth"
+# Depth comes from the line's own indentation, not from what preceded it.
+assert_file_contains "$nvs/.dist/s.html" 'class="nav-item nav-depth-1" href="./guide/usage">Usage'
+
+it "an entry after a nested group returns to the top level"
+assert_file_contains "$nvs/.dist/s.html" 'class="nav-item nav-depth-0" href="./top">Back at top'
+
+it "sections do not nest"
+# Sections are flat: a section groups the entries under it and is not itself
+# a child of another section, so no section carries a depth class.
+assert_file_not_contains "$nvs/.dist/s.html" 'nav-section nav-depth'
+
+it "a section label is escaped"
+printf '## Tools & <Toys>\nHome\t/\n' > "$nvs/navs/structured.tsv"
+run_jenny_split "$nvs"
+assert_file_contains "$nvs/.dist/s.html" 'Tools & <Toys>'
+
+it "an empty section heading renders nothing"
+# "##" with no name is a typo, not a divider.
+printf '##\nHome\t/\n' > "$nvs/navs/structured.tsv"
+run_jenny_split "$nvs"
+assert_file_not_contains "$nvs/.dist/s.html" 'class="nav-section"'
+
+it "an entry with an odd indent rounds down rather than failing"
+# A miscounted space is a typo in a list of links, not a reason to refuse to
+# publish.
+printf 'Home\t/\n Odd\t/odd\n' > "$nvs/navs/structured.tsv"
+run_jenny_split "$nvs"
+assert_file_contains "$nvs/.dist/s.html" 'nav-depth-1" href="./odd"'
+
+it "the build stays clean with sections and nesting"
+assert_eq "$stderr_output" ""
+
+it "depth past the styled levels is clamped, not dropped"
+# The indent stops growing rather than marching the label out of a 6rem
+# column; the entry still renders.
+printf 'Home\t/\n Deep\t/deep\n' > "$nvs/navs/structured.tsv"
+run_jenny_split "$nvs"
+assert_file_contains "$nvs/.dist/s.html" 'nav-depth-3" href="./deep"'
+
+it "the nav column cannot be widened by an unbreakable label"
+# min-width is a floor, not a ceiling: a label with no space in it grew the
+# column to 215px and pushed the body right. The ceiling plus overflow-wrap
+# keeps the column fixed and breaks the word instead.
+assert_file_contains "$nvs/.dist/s.html" 'max-width: var(--page-nav-width)'
+assert_file_contains "$nvs/.dist/s.html" 'overflow-wrap: break-word'
+
+it "wrapped labels keep every line at the same depth"
+# A negative text-indent is for a list marker sitting outside the text block.
+# Used here it put an entry's first line left of its own wrapped lines, so the
+# label appeared to straddle two levels.
+assert_file_not_contains "$nvs/.dist/s.html" 'nav-depth-1 { padding-left: 1.5em; text-indent'
suite "dark mode"