Commit 5a5d303 h
Changes: 5 files +168 -2
Changes (Side-by-Side)
README.md +16 -0
Home / | |||
| 252 | About /about |
252 | About /about |
| 253 | ``` |
253 | ``` |
| 254 | |
254 | |
| 255 | ### Naming a nav |
||
| 256 | |
||
| 257 | A line beginning with `###` is the nav's own name. The default theme shows it |
||
| 258 | on the collapsed nav on a phone, alongside a count of the entries behind it; |
||
| 259 | without one it says "Contents". |
||
| 260 | |
||
| 261 | ``` |
||
| 262 | ### Study Contents |
||
| 263 | Introduction /study/intro |
||
| 264 | Method /study/method |
||
| 265 | ``` |
||
| 266 | |
||
| 267 | A file names itself once. A second `###` is ignored — a file naming itself |
||
| 268 | twice is being edited, not renamed, and taking the last one silently would |
||
| 269 | hide the duplicate. |
||
| 270 | |
||
| 255 | ### Sections |
271 | ### Sections |
| 256 | |
272 | |
| 257 | A line beginning with `##` is a section: a label that names the group of |
273 | A line beginning with `##` is a section: a label that names the group of |
share/jenny/layout/page.sh +40 -1
page_nav() { | |||
| 44 | # and a nav that needs JavaScript to open is a nav that does not open when |
44 | # and a nav that needs JavaScript to open is a nav that does not open when |
| 45 | # the script fails. Wide screens override it back to always-open, where a |
45 | # the script fails. Wide screens override it back to always-open, where a |
| 46 | # summary would be noise beside a sidebar that already fits. |
46 | # summary would be noise beside a sidebar that already fits. |
| 47 | # The summary needs the nav's name and its size before the list is walked, |
||
| 48 | # so the lines are read once for those and once to render. A nav is a few |
||
| 49 | # dozen entries at most; two passes over that is cheaper than the contortion |
||
| 50 | # of emitting the summary after the list and moving it with CSS. |
||
| 51 | local nav_title nav_count |
||
| 52 | nav_title=$(printf '%s\n' "$PAGE_NAV" | "$AWK" -F'\t' '$1 == "title" { print $3; exit }') |
||
| 53 | nav_count=$(printf '%s\n' "$PAGE_NAV" | "$AWK" -F'\t' '$1 == "entry" { n++ } END { print n+0 }') |
||
| 54 | # Falls back rather than requiring the file to name itself: a nav that has |
||
| 55 | # never heard of this feature still gets a usable label. |
||
| 56 | [ -n "$nav_title" ] || nav_title="Contents" |
||
| 57 | |
||
| 47 | printf '<details class="page-nav-wrap" open>\n' |
58 | printf '<details class="page-nav-wrap" open>\n' |
| 48 | printf ' <summary class="page-nav-toggle">Contents</summary>\n' |
||
| 59 | # The count is what a collapsed nav is hiding, so it is shown only then -- |
||
| 60 | # once the list is open the reader can see the entries and a number beside |
||
| 61 | # the title is just noise. Plural handled here because "1 pages" is the kind |
||
| 62 | # of detail that makes a page look automated. |
||
| 63 | printf ' <summary class="page-nav-toggle">%s<span class="nav-count">%s %s</span></summary>\n' \ |
||
| 64 | "$(html_escape "$nav_title")" "$nav_count" \ |
||
| 65 | "$([ "$nav_count" = "1" ] && printf 'page' || printf 'pages')" |
||
| 49 | printf '<nav class="page-nav">\n' |
66 | printf '<nav class="page-nav">\n' |
| 50 | printf '%s\n' "$PAGE_NAV" | while IFS="$(printf '\t')" read -r kind depth label path; do |
67 | printf '%s\n' "$PAGE_NAV" | while IFS="$(printf '\t')" read -r kind depth label path; do |
| 51 | [ -n "$label" ] || continue |
68 | [ -n "$label" ] || continue |
| 52 | case "$kind" in |
69 | case "$kind" in |
| 70 | title) |
||
| 71 | # Consumed above for the summary; it is not an item in the list. |
||
| 72 | ;; |
||
| 53 | section) |
73 | section) |
| 54 | printf ' <p class="nav-section">%s</p>\n' "$(html_escape "$label")" |
74 | printf ' <p class="nav-section">%s</p>\n' "$(html_escape "$label")" |
| 55 | ;; |
75 | ;; |
cat << _EOF_ | |||
| 446 | min-width: var(--page-nav-width); |
466 | min-width: var(--page-nav-width); |
| 447 | max-width: var(--page-nav-width); |
467 | max-width: var(--page-nav-width); |
| 448 | margin: 0; padding: 0; font-size: .9em; } |
468 | margin: 0; padding: 0; font-size: .9em; } |
| 469 | /* The summary, and with it the count, is a phone affordance: on a wide |
||
| 470 | screen the sidebar is simply present and needs no label. */ |
||
| 449 | .page-nav-toggle { display: none; } |
471 | .page-nav-toggle { display: none; } |
| 450 | /* The element ships open, so a sidebar renders on a wide screen and, |
472 | /* The element ships open, so a sidebar renders on a wide screen and, |
| 451 | if the script below never runs, on a narrow one too -- failing to an |
473 | if the script below never runs, on a narrow one too -- failing to an |
cat << _EOF_ | |||
| 552 | color: var(--text-muted); padding: .4em 0; |
574 | color: var(--text-muted); padding: .4em 0; |
| 553 | border-bottom: 1px solid var(--border-lighter); } |
575 | border-bottom: 1px solid var(--border-lighter); } |
| 554 | .page-nav-toggle::marker { content: ""; } |
576 | .page-nav-toggle::marker { content: ""; } |
| 577 | /* The count says what is behind the summary, so it belongs to the |
||
| 578 | closed state only: with the list open the entries are right there |
||
| 579 | and a number beside the title is noise. Pushed to the far end so |
||
| 580 | the title stays where the eye lands. */ |
||
| 581 | .nav-count { margin-left: auto; font-size: .9em; opacity: .7; } |
||
| 582 | .page-nav-wrap[open] > .page-nav-toggle .nav-count { display: none; } |
||
| 555 | .page-nav-toggle::-webkit-details-marker { display: none; } |
583 | .page-nav-toggle::-webkit-details-marker { display: none; } |
| 556 | /* A border rotated 45 degrees rather than a glyph: it inherits the |
584 | /* A border rotated 45 degrees rather than a glyph: it inherits the |
| 557 | text colour, scales with the font, and needs no font support. */ |
585 | text colour, scales with the font, and needs no font support. */ |
cat << _EOF_ | |||
| 622 | .fn-handle, .fn-text { display: table-cell; } |
650 | .fn-handle, .fn-text { display: table-cell; } |
| 623 | .fn-handle { padding-right: 1ch; } |
651 | .fn-handle { padding-right: 1ch; } |
| 624 | .fnref { line-height: 0 } |
652 | .fnref { line-height: 0 } |
| 653 | /* Labelled the way notes and references are: a row of bare links under |
||
| 654 | a rule read as unexplained next to two sections that name |
||
| 655 | themselves. The wording lives here rather than in the template, so a |
||
| 656 | theme can relabel or hide it by overriding one rule -- the same |
||
| 657 | reason "Notes" and "References" are written in CSS. |
||
| 658 | |
||
| 659 | :has(a) because the div is emitted whether or not the post has tags; |
||
| 660 | without it every untagged post grew a heading over nothing. */ |
||
| 625 | .tags { margin-top: 2.5em; padding-top: 1.5em; font-size: .9em; border-top: 2px solid var(--border-lighter); } |
661 | .tags { margin-top: 2.5em; padding-top: 1.5em; font-size: .9em; border-top: 2px solid var(--border-lighter); } |
| 662 | .tags:has(a)::before { content: "Tags"; display: block; font-size: 1em; |
||
| 663 | font-weight: normal; text-transform: uppercase; |
||
| 664 | color: var(--text-primary); margin: 0 0 1em; } |
||
| 626 | /* MMD metadata pairs are preserved in the output rather than |
665 | /* MMD metadata pairs are preserved in the output rather than |
| 627 | stripped, so nothing the author wrote is lost. The default theme |
666 | stripped, so nothing the author wrote is lost. The default theme |
| 628 | hides them: the title comes from the heading and tags render |
667 | hides them: the title comes from the heading and tags render |
share/jenny/layout/post.sh +18 -1
cat << _EOF_ | |||
| 345 | alignment is untouched. */ |
345 | alignment is untouched. */ |
| 346 | @media (max-width: 735px) { |
346 | @media (max-width: 735px) { |
| 347 | .heading > a { display: block; } |
347 | .heading > a { display: block; } |
| 348 | .heading .stamp { display: block; flex: none; } |
||
| 348 | /* Right alignment belongs to the column: it sets the date against |
||
| 349 | the title beside it. Stacked, the stamp is a full-width block, so |
||
| 350 | the same rule threw the date to the far right of the screen with |
||
| 351 | the title under its left edge. The padding goes too -- it was the |
||
| 352 | gap to a column that is no longer there. */ |
||
| 353 | .heading .stamp { display: block; flex: none; |
||
| 354 | text-align: left; padding-right: 0; } |
||
| 349 | .body-row { display: block; } |
355 | .body-row { display: block; } |
| 350 | .body-row .gutter { display: none; } |
356 | .body-row .gutter { display: none; } |
| 351 | } |
357 | } |
cat << _EOF_ | |||
| 408 | .fn-handle, .fn-text { display: table-cell; } |
414 | .fn-handle, .fn-text { display: table-cell; } |
| 409 | .fn-handle { padding-right: 1ch; } |
415 | .fn-handle { padding-right: 1ch; } |
| 410 | .fnref { line-height: 0 } |
416 | .fnref { line-height: 0 } |
| 417 | /* Labelled the way notes and references are: a row of bare links under |
||
| 418 | a rule read as unexplained next to two sections that name |
||
| 419 | themselves. The wording lives here rather than in the template, so a |
||
| 420 | theme can relabel or hide it by overriding one rule -- the same |
||
| 421 | reason "Notes" and "References" are written in CSS. |
||
| 422 | |
||
| 423 | :has(a) because the div is emitted whether or not the post has tags; |
||
| 424 | without it every untagged post grew a heading over nothing. */ |
||
| 411 | .tags { margin-top: 2.5em; padding-top: 1.5em; font-size: .9em; border-top: 2px solid var(--border-lighter); } |
425 | .tags { margin-top: 2.5em; padding-top: 1.5em; font-size: .9em; border-top: 2px solid var(--border-lighter); } |
| 426 | .tags:has(a)::before { content: "Tags"; display: block; font-size: 1em; |
||
| 427 | font-weight: normal; text-transform: uppercase; |
||
| 428 | color: var(--text-primary); margin: 0 0 1em; } |
||
| 412 | /* MMD metadata pairs are preserved in the output rather than |
429 | /* MMD metadata pairs are preserved in the output rather than |
| 413 | stripped, so nothing the author wrote is lost. The default theme |
430 | stripped, so nothing the author wrote is lost. The default theme |
| 414 | hides them: the title comes from the heading and tags render |
431 | hides them: the title comes from the heading and tags render |
share/jenny/lib/helpers.sh +15 -0
function assert_layouts_current() { | |||
| 637 | # Overview /guide an entry: label, tab, site-root-relative path |
637 | # Overview /guide an entry: label, tab, site-root-relative path |
| 638 | # Setup /guide/setup a nested entry: two leading spaces per level |
638 | # Setup /guide/setup a nested entry: two leading spaces per level |
| 639 | # ## Reference a section label: groups the entries that follow |
639 | # ## Reference a section label: groups the entries that follow |
| 640 | # ### The Guide the nav's own name, for a template that shows one |
||
| 640 | # # anything else a comment, dropped |
641 | # # anything else a comment, dropped |
| 641 | # |
642 | # |
| 643 | # The name is what a collapsed nav is called on a phone; without one a |
||
| 644 | # template picks its own wording. It is "###" because the prefixes already |
||
| 645 | # ladder -- more hashes, broader scope -- and because "#" was long since |
||
| 646 | # spoken for by comments. A file may name itself once; a second name is a |
||
| 647 | # mistake rather than a rename, so the first wins. |
||
| 648 | # |
||
| 642 | # Sections are flat -- a section groups the entries under it and sections do |
649 | # Sections are flat -- a section groups the entries under it and sections do |
| 643 | # not contain other sections. Nesting is for entries: a child is indented two |
650 | # not contain other sections. Nesting is for entries: a child is indented two |
| 644 | # spaces relative to its parent, and depth is reported so a template can |
651 | # spaces relative to its parent, and depth is reported so a template can |
function read_nav() { | |||
| 693 | while (match(line, /^ /)) { indent++; line = substr(line, 3) } |
700 | while (match(line, /^ /)) { indent++; line = substr(line, 3) } |
| 694 | sub(/^[ \t]+/, "", line) |
701 | sub(/^[ \t]+/, "", line) |
| 695 | } |
702 | } |
| 703 | line ~ /^###/ { |
||
| 704 | label = substr(line, 4) |
||
| 705 | sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label) |
||
| 706 | # Emitted once. A file that names itself twice is being edited, not |
||
| 707 | # renamed, and silently taking the last one hides the duplicate. |
||
| 708 | if (label != "" && !titled) { print "title\t0\t" label "\t"; titled = 1 } |
||
| 709 | next |
||
| 710 | } |
||
| 696 | line ~ /^##/ { |
711 | line ~ /^##/ { |
| 697 | label = substr(line, 3) |
712 | label = substr(line, 3) |
| 698 | sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label) |
713 | sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label) |
test/regressions.sh +79 -0
assert_contains "$(md_render 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more | |||
| 865 | it "intraword underscores are still literal" |
865 | it "intraword underscores are still literal" |
| 866 | assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name' |
866 | assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name' |
| 867 | |
867 | |
| 868 | suite "the tag row is labelled" |
||
| 869 | |
||
| 870 | # Notes and references name themselves, so a bare row of links under a rule |
||
| 871 | # read as unexplained beside them. |
||
| 872 | |
||
| 873 | tl="$TMPROOT/taglabel" |
||
| 874 | make_blog_min "$tl" 2 10 |
||
| 875 | mkdir -p "$tl/pages" |
||
| 876 | printf '# Plain\n\nNo tags here.\n' > "$tl/pages/plain.md" |
||
| 877 | run_jenny_split "$tl" |
||
| 878 | tl_post=$(find "$tl/.dist/post" -name '*.html' | head -1) |
||
| 879 | |
||
| 880 | it "the label is a Tags heading matching Notes and References" |
||
| 881 | assert_file_contains "$tl_post" '.tags:has(a)::before { content: "Tags";' |
||
| 882 | |
||
| 883 | it "the wording lives in the theme, not the template" |
||
| 884 | # The same reason "Notes" and "References" are written in CSS: a theme |
||
| 885 | # relabels or hides it by overriding one rule. |
||
| 886 | assert_file_not_contains "$tl_post" '<div class="tags">Tags' |
||
| 887 | |
||
| 888 | it "an untagged post grows no heading over nothing" |
||
| 889 | # The div is emitted whether or not the post has tags, so the label is scoped |
||
| 890 | # to the case where there is something to label. |
||
| 891 | assert_file_contains "$tl_post" ':has(a)::before' |
||
| 892 | |
||
| 893 | it "pages carry the same rule" |
||
| 894 | assert_file_contains "$tl/.dist/plain.html" '.tags:has(a)::before { content: "Tags";' |
||
| 895 | |
||
| 896 | it "the build is clean" |
||
| 897 | assert_eq "$stderr_output" "" |
||
| 898 | |
||
| 868 | suite "a post lines up with its index entry" |
899 | suite "a post lines up with its index entry" |
| 869 | |
900 | |
| 870 | # A post should read as the index entry it came from: its stamp column and its |
901 | # A post should read as the index entry it came from: its stamp column and its |
assert_contains "$narrow_post" '.heading > a { display: block; }' | |||
| 938 | assert_contains "$narrow_post" '.body-row { display: block; }' |
969 | assert_contains "$narrow_post" '.body-row { display: block; }' |
| 939 | assert_contains "$narrow_post" '.body-row .gutter { display: none; }' |
970 | assert_contains "$narrow_post" '.body-row .gutter { display: none; }' |
| 940 | |
971 | |
| 972 | it "a stacked stamp is not right-aligned" |
||
| 973 | # Right alignment belongs to the column: it sets the date against the title |
||
| 974 | # beside it. Stacked, the stamp is a full-width block, so the same rule threw |
||
| 975 | # the date to the far right of the screen with the title under its left edge. |
||
| 976 | narrow_stamp=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$al_post") |
||
| 977 | assert_contains "$narrow_stamp" 'text-align: left; padding-right: 0;' |
||
| 978 | |
||
| 941 | it "the index alignment survives above that width" |
979 | it "the index alignment survives above that width" |
| 942 | # Giving up the match on a phone is deliberate -- an index entry is a single |
980 | # Giving up the match on a phone is deliberate -- an index entry is a single |
| 943 | # line with no body to squeeze, and nobody sees an index and a post side by |
981 | # line with no body to squeeze, and nobody sees an index and a post side by |
it "a page with no nav gets no toggle" | |||
| 1915 | assert_file_not_contains "$nav_sb/.dist/plain.html" '<details class="page-nav-wrap"' |
1953 | assert_file_not_contains "$nav_sb/.dist/plain.html" '<details class="page-nav-wrap"' |
| 1916 | assert_file_not_contains "$nav_sb/.dist/plain.html" '<summary' |
1954 | assert_file_not_contains "$nav_sb/.dist/plain.html" '<summary' |
| 1917 | |
1955 | |
| 1956 | it "a nav can name itself in its own file" |
||
| 1957 | # "###" because the prefixes already ladder -- more hashes, broader scope -- |
||
| 1958 | # and because "#" was long since spoken for by comments. |
||
| 1959 | printf '### Study Contents\nItem\t/item\n' > "$nav_sb/navs/side.tsv" |
||
| 1960 | run_jenny_split "$nav_sb" |
||
| 1961 | assert_file_contains "$nav_sb/.dist/test.html" '>Study Contents<' |
||
| 1962 | |
||
| 1963 | it "a nav that names itself twice keeps the first name" |
||
| 1964 | # A file naming itself twice is being edited, not renamed; taking the last one |
||
| 1965 | # silently would hide the duplicate. |
||
| 1966 | printf '### First\n### Second\nItem\t/item\n' > "$nav_sb/navs/side.tsv" |
||
| 1967 | run_jenny_split "$nav_sb" |
||
| 1968 | assert_file_contains "$nav_sb/.dist/test.html" '>First<' |
||
| 1969 | assert_file_not_contains "$nav_sb/.dist/test.html" '>Second<' |
||
| 1970 | |
||
| 1971 | it "a nav with no name of its own still gets a label" |
||
| 1972 | # A nav written before this feature existed has to keep working. |
||
| 1973 | printf 'Item\t/item\n' > "$nav_sb/navs/side.tsv" |
||
| 1974 | run_jenny_split "$nav_sb" |
||
| 1975 | assert_file_contains "$nav_sb/.dist/test.html" '>Contents<' |
||
| 1976 | |
||
| 1977 | it "the summary says how many entries it is hiding" |
||
| 1978 | printf '### Docs\nOne\t/one\nTwo\t/two\nThree\t/three\n' > "$nav_sb/navs/side.tsv" |
||
| 1979 | run_jenny_split "$nav_sb" |
||
| 1980 | assert_file_contains "$nav_sb/.dist/test.html" '<span class="nav-count">3 pages</span>' |
||
| 1981 | |
||
| 1982 | it "the count is not counting sections or the name" |
||
| 1983 | # Only entries are pages; a section label and the nav's own name are neither. |
||
| 1984 | printf '### Docs\n## Group\nOne\t/one\n' > "$nav_sb/navs/side.tsv" |
||
| 1985 | run_jenny_split "$nav_sb" |
||
| 1986 | assert_file_contains "$nav_sb/.dist/test.html" '<span class="nav-count">1 page</span>' |
||
| 1987 | |
||
| 1988 | it "one entry is a page, not pages" |
||
| 1989 | # "1 pages" is the kind of detail that makes a page look automated. |
||
| 1990 | assert_file_not_contains "$nav_sb/.dist/test.html" '>1 pages<' |
||
| 1991 | |
||
| 1992 | it "the count is hidden once the list is open" |
||
| 1993 | # It says what is behind the summary; with the list open the entries are right |
||
| 1994 | # there and a number beside the title is noise. |
||
| 1995 | assert_file_contains "$nav_sb/.dist/test.html" '.page-nav-wrap[open] > .page-nav-toggle .nav-count { display: none; }' |
||
| 1996 | |
||
| 1918 | it "the build is clean for nav sidebar" |
1997 | it "the build is clean for nav sidebar" |
| 1919 | assert_eq "$stderr_output" "" |
1998 | assert_eq "$stderr_output" "" |
| 1920 | |
1999 | |
Raw
commit 5a5d303bf1fd5f9d987c9f6c878ac2b2d3d32239
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date: Wed Aug 26 13:20:36 2026 +0800
Name a nav in its own file, count what it hides, label the tag row
Three things from reading the rendered pages.
The stamp was right-aligned on a phone. That alignment belongs to the
column -- it sets the date against the title beside it -- and when the
header stacks the stamp becomes a full-width block, so the same rule threw
the date to the far right of the screen with the title under its left
edge. Stacked, it aligns left with no padding, which is the gap to a column
that is no longer there. A regression from the commit that introduced the
column; caught by looking, not by a test.
A nav can name itself with "###", and the collapsed summary uses that name
instead of the fixed "Contents". The prefixes already ladder -- more
hashes, broader scope -- and "#" was long since spoken for by comments. A
file names itself once; a second name is a mistake rather than a rename, so
the first wins and the duplicate is not silently preferred. A nav written
before this still works and still says "Contents".
The summary also says how many entries are behind it, which is only useful
while they are hidden -- with the list open the entries are right there and
a number beside the title is noise. Sections and the nav's own name are not
pages, so they are not counted, and one entry is "1 page".
The tag row is labelled the way notes and references are. A row of bare
links under a rule read as unexplained next to two sections that name
themselves. The wording is in CSS for the same reason theirs is: a theme
relabels or hides it by overriding one rule. Scoped with :has(a) because
the div is emitted whether or not a post has tags -- without that, every
untagged post grew a heading over nothing.
Desktop re-checked after all three: 8 page types across 4 widths, no
overflow, no misalignment, posts still landing on the index's columns.
Tests: the stacked-stamp reset, the nav name and its first-wins rule, the
fallback label, the count and its pluralisation and what it excludes, and
the tag label on posts and pages. Checked by mutation -- leaving the stamp
right-aligned, dropping the title line, unscoping the tag label -- each
caught.
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 23682b8..5091e75 100644
--- a/README.md
+++ b/README.md
@@ -252,6 +252,22 @@ Home /
About /about
```
+### Naming a nav
+
+A line beginning with `###` is the nav's own name. The default theme shows it
+on the collapsed nav on a phone, alongside a count of the entries behind it;
+without one it says "Contents".
+
+```
+### Study Contents
+Introduction /study/intro
+Method /study/method
+```
+
+A file names itself once. A second `###` is ignored — a file naming itself
+twice is being edited, not renamed, and taking the last one silently would
+hide the duplicate.
+
### Sections
A line beginning with `##` is a section: a label that names the group of
diff --git a/share/jenny/layout/page.sh b/share/jenny/layout/page.sh
index 5cdb876..9f48148 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -44,12 +44,32 @@ page_nav() {
# and a nav that needs JavaScript to open is a nav that does not open when
# the script fails. Wide screens override it back to always-open, where a
# summary would be noise beside a sidebar that already fits.
+ # The summary needs the nav's name and its size before the list is walked,
+ # so the lines are read once for those and once to render. A nav is a few
+ # dozen entries at most; two passes over that is cheaper than the contortion
+ # of emitting the summary after the list and moving it with CSS.
+ local nav_title nav_count
+ nav_title=$(printf '%s\n' "$PAGE_NAV" | "$AWK" -F'\t' '$1 == "title" { print $3; exit }')
+ nav_count=$(printf '%s\n' "$PAGE_NAV" | "$AWK" -F'\t' '$1 == "entry" { n++ } END { print n+0 }')
+ # Falls back rather than requiring the file to name itself: a nav that has
+ # never heard of this feature still gets a usable label.
+ [ -n "$nav_title" ] || nav_title="Contents"
+
printf '<details class="page-nav-wrap" open>\n'
- printf ' <summary class="page-nav-toggle">Contents</summary>\n'
+ # The count is what a collapsed nav is hiding, so it is shown only then --
+ # once the list is open the reader can see the entries and a number beside
+ # the title is just noise. Plural handled here because "1 pages" is the kind
+ # of detail that makes a page look automated.
+ printf ' <summary class="page-nav-toggle">%s<span class="nav-count">%s %s</span></summary>\n' \
+ "$(html_escape "$nav_title")" "$nav_count" \
+ "$([ "$nav_count" = "1" ] && printf 'page' || printf 'pages')"
printf '<nav class="page-nav">\n'
printf '%s\n' "$PAGE_NAV" | while IFS="$(printf '\t')" read -r kind depth label path; do
[ -n "$label" ] || continue
case "$kind" in
+ title)
+ # Consumed above for the summary; it is not an item in the list.
+ ;;
section)
printf ' <p class="nav-section">%s</p>\n' "$(html_escape "$label")"
;;
@@ -446,6 +466,8 @@ cat << _EOF_
min-width: var(--page-nav-width);
max-width: var(--page-nav-width);
margin: 0; padding: 0; font-size: .9em; }
+ /* The summary, and with it the count, is a phone affordance: on a wide
+ screen the sidebar is simply present and needs no label. */
.page-nav-toggle { display: none; }
/* The element ships open, so a sidebar renders on a wide screen and,
if the script below never runs, on a narrow one too -- failing to an
@@ -552,6 +574,12 @@ cat << _EOF_
color: var(--text-muted); padding: .4em 0;
border-bottom: 1px solid var(--border-lighter); }
.page-nav-toggle::marker { content: ""; }
+ /* The count says what is behind the summary, so it belongs to the
+ closed state only: with the list open the entries are right there
+ and a number beside the title is noise. Pushed to the far end so
+ the title stays where the eye lands. */
+ .nav-count { margin-left: auto; font-size: .9em; opacity: .7; }
+ .page-nav-wrap[open] > .page-nav-toggle .nav-count { display: none; }
.page-nav-toggle::-webkit-details-marker { display: none; }
/* A border rotated 45 degrees rather than a glyph: it inherits the
text colour, scales with the font, and needs no font support. */
@@ -622,7 +650,18 @@ cat << _EOF_
.fn-handle, .fn-text { display: table-cell; }
.fn-handle { padding-right: 1ch; }
.fnref { line-height: 0 }
+ /* Labelled the way notes and references are: a row of bare links under
+ a rule read as unexplained next to two sections that name
+ themselves. The wording lives here rather than in the template, so a
+ theme can relabel or hide it by overriding one rule -- the same
+ reason "Notes" and "References" are written in CSS.
+
+ :has(a) because the div is emitted whether or not the post has tags;
+ without it every untagged post grew a heading over nothing. */
.tags { margin-top: 2.5em; padding-top: 1.5em; font-size: .9em; border-top: 2px solid var(--border-lighter); }
+ .tags:has(a)::before { content: "Tags"; display: block; font-size: 1em;
+ font-weight: normal; text-transform: uppercase;
+ color: var(--text-primary); margin: 0 0 1em; }
/* MMD metadata pairs are preserved in the output rather than
stripped, so nothing the author wrote is lost. The default theme
hides them: the title comes from the heading and tags render
diff --git a/share/jenny/layout/post.sh b/share/jenny/layout/post.sh
index f5cdfba..fdf9d21 100755
--- a/share/jenny/layout/post.sh
+++ b/share/jenny/layout/post.sh
@@ -345,7 +345,13 @@ cat << _EOF_
alignment is untouched. */
@media (max-width: 735px) {
.heading > a { display: block; }
- .heading .stamp { display: block; flex: none; }
+ /* Right alignment belongs to the column: it sets the date against
+ the title beside it. Stacked, the stamp is a full-width block, so
+ the same rule threw the date to the far right of the screen with
+ the title under its left edge. The padding goes too -- it was the
+ gap to a column that is no longer there. */
+ .heading .stamp { display: block; flex: none;
+ text-align: left; padding-right: 0; }
.body-row { display: block; }
.body-row .gutter { display: none; }
}
@@ -408,7 +414,18 @@ cat << _EOF_
.fn-handle, .fn-text { display: table-cell; }
.fn-handle { padding-right: 1ch; }
.fnref { line-height: 0 }
+ /* Labelled the way notes and references are: a row of bare links under
+ a rule read as unexplained next to two sections that name
+ themselves. The wording lives here rather than in the template, so a
+ theme can relabel or hide it by overriding one rule -- the same
+ reason "Notes" and "References" are written in CSS.
+
+ :has(a) because the div is emitted whether or not the post has tags;
+ without it every untagged post grew a heading over nothing. */
.tags { margin-top: 2.5em; padding-top: 1.5em; font-size: .9em; border-top: 2px solid var(--border-lighter); }
+ .tags:has(a)::before { content: "Tags"; display: block; font-size: 1em;
+ font-weight: normal; text-transform: uppercase;
+ color: var(--text-primary); margin: 0 0 1em; }
/* MMD metadata pairs are preserved in the output rather than
stripped, so nothing the author wrote is lost. The default theme
hides them: the title comes from the heading and tags render
diff --git a/share/jenny/lib/helpers.sh b/share/jenny/lib/helpers.sh
index 9a12a14..7d0bd61 100644
--- a/share/jenny/lib/helpers.sh
+++ b/share/jenny/lib/helpers.sh
@@ -637,8 +637,15 @@ function assert_layouts_current() {
# 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
+# ### The Guide the nav's own name, for a template that shows one
# # anything else a comment, dropped
#
+# The name is what a collapsed nav is called on a phone; without one a
+# template picks its own wording. It is "###" because the prefixes already
+# ladder -- more hashes, broader scope -- and because "#" was long since
+# spoken for by comments. A file may name itself once; a second name is a
+# mistake rather than a rename, so the first wins.
+#
# 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
@@ -693,6 +700,14 @@ function read_nav() {
while (match(line, /^ /)) { indent++; line = substr(line, 3) }
sub(/^[ \t]+/, "", line)
}
+ line ~ /^###/ {
+ label = substr(line, 4)
+ sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label)
+ # Emitted once. A file that names itself twice is being edited, not
+ # renamed, and silently taking the last one hides the duplicate.
+ if (label != "" && !titled) { print "title\t0\t" label "\t"; titled = 1 }
+ next
+ }
line ~ /^##/ {
label = substr(line, 3)
sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label)
diff --git a/test/regressions.sh b/test/regressions.sh
index 51631ef..668f21f 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -865,6 +865,37 @@ assert_contains "$(md_render 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more
it "intraword underscores are still literal"
assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name'
+suite "the tag row is labelled"
+
+# Notes and references name themselves, so a bare row of links under a rule
+# read as unexplained beside them.
+
+tl="$TMPROOT/taglabel"
+make_blog_min "$tl" 2 10
+mkdir -p "$tl/pages"
+printf '# Plain\n\nNo tags here.\n' > "$tl/pages/plain.md"
+run_jenny_split "$tl"
+tl_post=$(find "$tl/.dist/post" -name '*.html' | head -1)
+
+it "the label is a Tags heading matching Notes and References"
+assert_file_contains "$tl_post" '.tags:has(a)::before { content: "Tags";'
+
+it "the wording lives in the theme, not the template"
+# The same reason "Notes" and "References" are written in CSS: a theme
+# relabels or hides it by overriding one rule.
+assert_file_not_contains "$tl_post" '<div class="tags">Tags'
+
+it "an untagged post grows no heading over nothing"
+# The div is emitted whether or not the post has tags, so the label is scoped
+# to the case where there is something to label.
+assert_file_contains "$tl_post" ':has(a)::before'
+
+it "pages carry the same rule"
+assert_file_contains "$tl/.dist/plain.html" '.tags:has(a)::before { content: "Tags";'
+
+it "the build is clean"
+assert_eq "$stderr_output" ""
+
suite "a post lines up with its index entry"
# A post should read as the index entry it came from: its stamp column and its
@@ -938,6 +969,13 @@ assert_contains "$narrow_post" '.heading > a { display: block; }'
assert_contains "$narrow_post" '.body-row { display: block; }'
assert_contains "$narrow_post" '.body-row .gutter { display: none; }'
+it "a stacked stamp is not right-aligned"
+# Right alignment belongs to the column: it sets the date against the title
+# beside it. Stacked, the stamp is a full-width block, so the same rule threw
+# the date to the far right of the screen with the title under its left edge.
+narrow_stamp=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$al_post")
+assert_contains "$narrow_stamp" 'text-align: left; padding-right: 0;'
+
it "the index alignment survives above that width"
# Giving up the match on a phone is deliberate -- an index entry is a single
# line with no body to squeeze, and nobody sees an index and a post side by
@@ -1915,6 +1953,47 @@ it "a page with no nav gets no toggle"
assert_file_not_contains "$nav_sb/.dist/plain.html" '<details class="page-nav-wrap"'
assert_file_not_contains "$nav_sb/.dist/plain.html" '<summary'
+it "a nav can name itself in its own file"
+# "###" because the prefixes already ladder -- more hashes, broader scope --
+# and because "#" was long since spoken for by comments.
+printf '### Study Contents\nItem\t/item\n' > "$nav_sb/navs/side.tsv"
+run_jenny_split "$nav_sb"
+assert_file_contains "$nav_sb/.dist/test.html" '>Study Contents<'
+
+it "a nav that names itself twice keeps the first name"
+# A file naming itself twice is being edited, not renamed; taking the last one
+# silently would hide the duplicate.
+printf '### First\n### Second\nItem\t/item\n' > "$nav_sb/navs/side.tsv"
+run_jenny_split "$nav_sb"
+assert_file_contains "$nav_sb/.dist/test.html" '>First<'
+assert_file_not_contains "$nav_sb/.dist/test.html" '>Second<'
+
+it "a nav with no name of its own still gets a label"
+# A nav written before this feature existed has to keep working.
+printf 'Item\t/item\n' > "$nav_sb/navs/side.tsv"
+run_jenny_split "$nav_sb"
+assert_file_contains "$nav_sb/.dist/test.html" '>Contents<'
+
+it "the summary says how many entries it is hiding"
+printf '### Docs\nOne\t/one\nTwo\t/two\nThree\t/three\n' > "$nav_sb/navs/side.tsv"
+run_jenny_split "$nav_sb"
+assert_file_contains "$nav_sb/.dist/test.html" '<span class="nav-count">3 pages</span>'
+
+it "the count is not counting sections or the name"
+# Only entries are pages; a section label and the nav's own name are neither.
+printf '### Docs\n## Group\nOne\t/one\n' > "$nav_sb/navs/side.tsv"
+run_jenny_split "$nav_sb"
+assert_file_contains "$nav_sb/.dist/test.html" '<span class="nav-count">1 page</span>'
+
+it "one entry is a page, not pages"
+# "1 pages" is the kind of detail that makes a page look automated.
+assert_file_not_contains "$nav_sb/.dist/test.html" '>1 pages<'
+
+it "the count is hidden once the list is open"
+# It says what is behind the summary; with the list open the entries are right
+# there and a number beside the title is noise.
+assert_file_contains "$nav_sb/.dist/test.html" '.page-nav-wrap[open] > .page-nav-toggle .nav-count { display: none; }'
+
it "the build is clean for nav sidebar"
assert_eq "$stderr_output" ""