ssh git@grit.deadkey.org man

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 &amp; &lt;Toys&gt;'
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 &amp; &lt;Toys&gt;'
+
+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"