ssh git@grit.deadkey.org man

mailmanpat/jenny · 2026-09-06 09:36:46 +08002026-9-6 +8git clone …

FILES / test / regressions.sh

NameLast CommitTimestampSize
..
fixtures ad9c389 h test: replace the cloned-blog CI job with a committed fixture 26-08-24 11:38 -
lib 41cf051 h fix(index): restore the original break-marker placement and dimming 26-08-24 22:33 -
compare-golden.sh ad9c389 h test: replace the cloned-blog CI job with a committed fixture 26-08-24 11:38 1 KB
regressions.sh b70d686 h Take the parser's escape-by-default contract 26-09-05 10:21 123 KB
run.sh a346b6b h test: add bash test harness, fixtures and link checker 26-08-20 09:30 392 B
smoke.sh a346b6b h test: add bash test harness, fixtures and link checker 26-08-20 09:30 2 KB
units.sh b70d686 h Take the parser's escape-by-default contract 26-09-05 10:21 20 KB
#!/usr/bin/env bash
# Regression tests, one case per bug ID from the hardening handoff.
# Run: test/regressions.sh (or test/run.sh for everything)
source "$(dirname "${BASH_SOURCE[0]}")/lib/assert.bash"
TMPROOT=$(mktemp -d -t jenny-regress.XXXXXX)
trap 'rm -rf "$TMPROOT"' EXIT
# A blog built once and shared by the read-only assertions below.
BLOG="$TMPROOT/blog"
make_blog "$BLOG" 3
run_jenny_split "$BLOG"
DIST="$BLOG/.dist"
suite "P0-1 markdown parser failures are loud, not silent"
it "renders lists, bold and inline code in the post body"
body=$(cat "$DIST/post/mdtest.html" 2>/dev/null)
if [[ "$body" == *"<ul>"* && "$body" == *"<strong>bold</strong>"* && "$body" == *"<code>code</code>"* ]]; then
pass
else
fail "mdtest.html missing <ul>, <strong>bold</strong> or <code>code</code>
$(sed -n '/class="contents"/,/class="tags"/p' "$DIST/post/mdtest.html" 2>/dev/null)"
fi
it "emphasis markers do not produce empty tag pairs"
assert_file_not_contains "$DIST/post/mdtest.html" "<strong></strong>"
it "a broken MARKDOWN_COMMAND fails the build loudly"
b="$TMPROOT/brokenmd"
make_blog_min "$b" 3 3
run_jenny "$b" -m false
assert_failure "$status" "$output"
it "the broken-parser build says which post failed"
assert_contains "$output" "markdown"
suite "P0-2 shell metacharacters in titles"
it "a title with quotes and angle brackets appears in the index"
assert_file_contains "$DIST/index.html" "Tricky"
it "that title is HTML-escaped in the index"
assert_file_contains "$DIST/index.html" "Tricky &amp; &lt;Title&gt;"
it "the build emits nothing on stderr"
assert_eq "$stderr_output" ""
it "a title containing \$(...) and backticks does not execute"
inj="$TMPROOT/injection"
make_blog_min "$inj" 2 3
printf '# Back`tick $(whoami) "quoted" & <angle>\n\nBody\n' > "$inj/2024-05-01 inject.md"
run_jenny_split "$inj"
whoami_out=$(whoami)
if grep -qF "$whoami_out" "$inj/.dist/index.html" 2>/dev/null; then
fail "command substitution executed: found '$whoami_out' in index.html"
else
pass
fi
it "the injected title survives verbatim in the index"
assert_file_contains "$inj/.dist/index.html" 'Back`tick $(whoami)'
it "the injection build is silent on stderr"
assert_eq "$stderr_output" ""
suite "P0-3 record delimiter splits on records, not bytes"
it "splitting an N-item list yields exactly N elements"
source "$REPO_ROOT/share/jenny/lib/helpers.sh"
list=$(join_by "$JENNY_FS" a b c)
split_list "$list" arr
assert_eq "${#arr[@]}" "3"
it "the delimiter is a single byte"
assert_eq "$(printf '%s' "$JENNY_FS" | wc -c | tr -d ' ')" "1"
it "fields containing spaces and quotes survive a round trip"
list=$(join_by "$JENNY_FS" 'a b "c"' 'd$e' 'f`g')
split_list "$list" arr2
assert_eq "${arr2[0]}|${arr2[1]}|${arr2[2]}" 'a b "c"|d$e|f`g'
suite "P0-4 main index is full and the break marker is last"
it "9 posts at 3/page puts exactly 3 posts on the main index"
n9="$TMPROOT/nine"
make_blog_min "$n9" 9 3
run_jenny "$n9"
assert_eq "$(index_post_count "$n9/.dist/index.html")" "3"
it "9 posts at 3/page needs no break marker"
assert_file_not_contains "$n9/.dist/index.html" "In page"
it "10 posts at 3/page still puts 3 posts on the main index"
n10="$TMPROOT/ten"
make_blog_min "$n10" 10 3
run_jenny "$n10"
assert_eq "$(index_post_count "$n10/.dist/index.html")" "3"
# The marker sits between the page's own posts and the ones borrowed from the
# next page to fill it out, marking where the spillover begins. That is the
# original design; everything rendered after it is borrowed.
it "the break marker precedes the borrowed posts"
marker_line=$(grep -n 'page-break-link' "$n10/.dist/index.html" | head -1 | cut -d: -f1)
first_borrowed=$(grep -n 'post-link in-next' "$n10/.dist/index.html" | head -1 | cut -d: -f1)
if [ -z "$marker_line" ]; then
skip "no break marker emitted"
elif [ -z "$first_borrowed" ]; then
fail "marker emitted but no post flagged as borrowed"
elif [ "$marker_line" -lt "$first_borrowed" ]; then
pass
else
fail "marker at $marker_line should precede first borrowed post at $first_borrowed"
fi
it "posts before the marker are not flagged as borrowed"
if [ -z "$marker_line" ]; then
skip "no break marker emitted"
else
# Count only list items: the .in-next CSS rule lives in the <style> block
# above the list and would otherwise match.
assert_eq "$(head -n "$marker_line" "$n10/.dist/index.html" \
| grep -c '<li class="post-link in-next"' || true)" "0"
fi
it "the tag index is also full"
assert_eq "$(index_post_count "$DIST/tag/alpha/index.html")" "3"
suite "P0-5 the feed carries the whole blog"
it "the feed holds min(total posts, FEED_MAX_ITEMS) items"
assert_eq "$(feed_item_count "$DIST/feed.xml")" "10"
it "FEED_MAX_ITEMS caps the feed"
cap="$TMPROOT/cap"
make_blog_min "$cap" 10 3
echo 'FEED_MAX_ITEMS=4' >> "$cap/.blogrc"
run_jenny "$cap"
assert_eq "$(feed_item_count "$cap/.dist/feed.xml")" "4"
it "feed items are newest first"
first_title=$(grep -o '<title>[^<]*</title>' "$DIST/feed.xml" | sed -n '2p')
assert_contains "$first_title" "MD Test"
suite "P0-6 incremental rendering"
it "a second build with no changes re-renders nothing"
inc="$TMPROOT/incremental"
make_blog_min "$inc" 4 3
run_jenny "$inc" >/dev/null
before=$(find "$inc/.dist/post" -name '*.html' -exec stat -c '%n %Y' {} \; | sort)
sleep 1
run_jenny "$inc"
after=$(find "$inc/.dist/post" -name '*.html' -exec stat -c '%n %Y' {} \; | sort)
assert_eq "$after" "$before" "post mtimes must not change on a no-op rebuild"
it "the second build reports no rendered posts"
assert_not_contains "$output" "+ /post/"
it "touching one post's content re-renders only that post"
printf '# Post 02\n\nBody changed\n' > "$inc/2024-01-02 post-02.md"
sleep 1
run_jenny "$inc"
changed=$(find "$inc/.dist/post" -name '*.html' -newermt "-2 seconds" -printf '%f\n' | sort | tr '\n' ' ')
assert_eq "${changed% }" "post-02.html"
it "-f forces a full rebuild"
sleep 1
run_jenny "$inc" -f
rebuilt=$(find "$inc/.dist/post" -name '*.html' -newermt "-2 seconds" | wc -l | tr -d ' ')
assert_eq "$rebuilt" "4"
it "-f does not wipe the lock file"
assert_eq "$(wc -l < "$inc/.bloglock" | tr -d ' ')" "4"
suite "P1-1 every link resolves from the document root"
it "no broken links anywhere in the build"
broken=$(check_links "$DIST") && pass || fail "broken links:
$broken"
it "the main index pagination link does not escape the root"
assert_file_not_contains "$DIST/index.html" 'href="./../page'
suite "P1-2 feeds carry well-formed absolute URLs"
it "every main-feed link is an absolute URL"
badlinks=$(grep -o '<link>[^<]*</link>' "$DIST/feed.xml" | sed 's/<[^>]*>//g' \
| grep -vE '^https?://[^ ]*$' || true)
assert_eq "$badlinks" ""
it "every tag-feed link is an absolute URL"
badlinks=$(grep -o '<link>[^<]*</link>' "$DIST/tag/alpha/feed.xml" | sed 's/<[^>]*>//g' \
| grep -vE '^https?://[^ ]*$' || true)
assert_eq "$badlinks" ""
it "tag feed links contain no relative path fragments"
assert_file_not_contains "$DIST/tag/alpha/feed.xml" "8000./.."
it "every guid is an absolute URL"
badguids=$(grep -o '<guid[^>]*>[^<]*</guid>' "$DIST/feed.xml" | sed 's/<[^>]*>//g' \
| grep -vE '^https?://[^ ]*$' || true)
assert_eq "$badguids" ""
suite "P1-3 HTML and XML escaping"
it "the main feed is well-formed XML"
if command -v xmllint >/dev/null 2>&1; then
out=$(xmllint --noout "$DIST/feed.xml" 2>&1)
assert_eq "$out" ""
else
skip "xmllint not installed"
fi
it "the tag feed is well-formed XML"
if command -v xmllint >/dev/null 2>&1; then
out=$(xmllint --noout "$DIST/tag/alpha/feed.xml" 2>&1)
assert_eq "$out" ""
else
skip "xmllint not installed"
fi
it "an ampersand in a title is escaped in the feed"
assert_file_contains "$DIST/feed.xml" "Tricky &amp;"
it "a raw unescaped angle bracket never reaches the feed title"
assert_file_not_contains "$DIST/feed.xml" "<title>Tricky & <Title>"
it "post body HTML is not double-escaped"
assert_file_not_contains "$DIST/post/mdtest.html" "&lt;ul&gt;"
suite "P1-4 titles come from real headings only"
it "a # inside a fenced block is not part of the title"
assert_file_contains "$DIST/post/fenced.html" "<title>Real Title"
it "the fenced post's index entry uses the real title"
assert_file_contains "$DIST/index.html" ">Real Title<"
it "the leaked fence text appears nowhere as a title"
assert_file_not_contains "$DIST/index.html" "Real Title not a title"
it "a post with no heading falls back to its slug"
nt="$TMPROOT/notitle"
make_blog_min "$nt" 2 3
printf 'Just a body, no heading at all.\n' > "$nt/2024-06-01 headless.md"
run_jenny "$nt"
assert_success "$status" "$output"
it "the fallback title is the slug"
assert_file_contains "$nt/.dist/index.html" "headless"
suite "incremental builds notice more than post content"
# The lock recorded a post's filename and content only. A layout edit was
# invisible to it, so posts were skipped -- while pages, which consulted no
# lock at all, were rewritten every build. One build therefore produced a site
# where the pages had the new layout and the posts still had the old one, which
# reads as a layout bug rather than a stale build.
inc="$TMPROOT/incremental"
make_blog_min "$inc" 1 10
mkdir -p "$inc/pages" "$inc/navs"
printf '# A Page\n\nBody.\n' > "$inc/pages/p.md"
cp -r "$JENNY_SHARE_DIR/layout" "$inc/mylayout"
run_jenny "$inc"
inc_post=$(find "$inc/.dist/post" -name '*.html' | head -1)
inc_stamp() { "$SED" -n "s/.*//p" /dev/null; stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; }
it "a rebuild with nothing changed rewrites nothing"
# Pages used to be rendered unconditionally, so every build rewrote them all.
before_post=$(inc_stamp "$inc_post")
before_page=$(inc_stamp "$inc/.dist/p.html")
sleep 1
run_jenny "$inc"
assert_eq "$(inc_stamp "$inc_post")" "$before_post"
assert_eq "$(inc_stamp "$inc/.dist/p.html")" "$before_page"
it "editing one page rebuilds only that page"
sleep 1
printf '# A Page\n\nEdited.\n' > "$inc/pages/p.md"
run_jenny "$inc"
assert_eq "$(inc_stamp "$inc_post")" "$before_post"
[ "$(inc_stamp "$inc/.dist/p.html")" != "$before_page" ] \
&& pass "the edited page was rebuilt" || fail "the edited page was skipped"
it "a layout edit rebuilds posts and pages alike"
# The defect this fixes: one build leaving half the site on the old layout.
sleep 1
"$SED" -i 's|<article>|<article data-mark="ZZ">|' "$inc/mylayout/post.sh" "$inc/mylayout/page.sh"
( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
assert_file_contains "$inc_post" 'data-mark="ZZ"'
assert_file_contains "$inc/.dist/p.html" 'data-mark="ZZ"'
it "a nav registry edit rebuilds the pages that read it"
# A page renders the nav it declares, so editing the .tsv changes that page's
# output without touching the page's own source.
sleep 1
printf 'Nav: side\n\n# Navved\n\nBody.\n' > "$inc/pages/navved.md"
printf 'One\t/one\n' > "$inc/navs/side.tsv"
( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
assert_file_contains "$inc/.dist/navved.html" 'One'
sleep 1
printf 'Two\t/two\n' > "$inc/navs/side.tsv"
( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
assert_file_contains "$inc/.dist/navved.html" 'Two'
it "a settings change rebuilds everything"
# BLOG_TITLE and URL_STYLE reach every rendered file.
sleep 1
"$SED" -i 's|^BLOG_TITLE=.*|BLOG_TITLE="Renamed Blog"|' "$inc/.blogrc"
( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
assert_file_contains "$inc_post" 'Renamed Blog'
assert_file_contains "$inc/.dist/p.html" 'Renamed Blog'
it "the lock keeps its build signature across a sweep"
# sweep_orphans rewrote every kept line with three fields, which would have
# stripped the signature and made each post look changed on the next build.
assert_eq "$("$AWK" 'NF && $1 !~ /^page:/ { print NF; exit }' "$inc/.bloglock")" "4"
it "a page entry survives the sweep"
# Page entries name their source path; swept by the post rule -- a *.md in the
# blog root whose name hashes to column two -- they matched nothing and were
# discarded on the very build that wrote them, so pages never skipped.
assert_eq "$("$GREP" -c '^page:' "$inc/.bloglock")" "2"
it "a removed page is swept from the lock and the output"
sleep 1
rm "$inc/pages/navved.md"
( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 )
assert_file_absent "$inc/.dist/navved.html"
assert_eq "$("$GREP" -c 'page:navved' "$inc/.bloglock" || true)" "0"
it "-f still forces a rebuild"
sleep 1
before_forced=$(inc_stamp "$inc_post")
( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" -f >/dev/null 2>&1 )
[ "$(inc_stamp "$inc_post")" != "$before_forced" ] \
&& pass "-f rebuilt the post" || fail "-f did not rebuild the post"
suite "P1-5 deleted sources are swept"
it "deleting a source removes its rendered HTML"
del="$TMPROOT/deleted"
make_blog_min "$del" 4 3
run_jenny "$del" >/dev/null
rm "$del/2024-01-02 post-02.md"
run_jenny "$del"
assert_file_absent "$del/.dist/post/post-02.html"
it "deleting a source drops its lock entry"
assert_file_not_contains "$del/.bloglock" "post-02"
it "surviving posts are untouched"
assert_file_exists "$del/.dist/post/post-03.html"
it "renaming a post's date but not its title makes no duplicate"
ren="$TMPROOT/renamed"
make_blog_min "$ren" 3 3
run_jenny "$ren" >/dev/null
mv "$ren/2024-01-02 post-02.md" "$ren/2024-02-15 post-02.md"
run_jenny "$ren"
assert_eq "$(ls "$ren/.dist/post" | grep -c 'post-02')" "1"
suite "P1-6 jenny edit"
it "edit opens a filename containing a space"
ed="$TMPROOT/edit"
make_blog_min "$ed" 2 3
# A stub editor that records exactly what it was handed.
cat > "$TMPROOT/fake-editor" <<'STUB'
#!/usr/bin/env bash
echo "ARGC=$#"
for a in "$@"; do echo "ARG=$a"; done
STUB
chmod +x "$TMPROOT/fake-editor"
opened=$(cd "$ed" && EDITOR="$TMPROOT/fake-editor" "$JENNY" edit post-01 2>&1)
assert_contains "$opened" "ARG=2024-01-01 post-01.md"
it "edit passes exactly one argument to the editor"
assert_contains "$opened" "ARGC=1"
it "edit on a missing post errors cleanly"
out=$(cd "$ed" && EDITOR=true "$JENNY" edit nonexistent 2>&1); st=$?
assert_failure "$st" "$out"
it "edit on an ambiguous prefix lists the matches"
out=$(cd "$ed" && EDITOR=true "$JENNY" edit post-0 2>&1); st=$?
assert_contains "$out" "post-01"
it "the ambiguous case does not open an editor"
assert_failure "$st" "$out"
suite "P1-7 jenny publish"
it "publish adds a date prefix to a spaced filename"
pub="$TMPROOT/publish"
make_blog_min "$pub" 1 3
printf '# Draft\n\nBody\n' > "$pub/my post.md"
out=$(cd "$pub" && "$JENNY" publish 'my post.md' 2>&1)
assert_file_exists "$pub/$(date +%Y-%m-%d) my post.md"
it "publish collapses no whitespace in the name"
assert_file_absent "$pub/$(date +%Y-%m-%d) my post .md"
it "publishing an already-dated file errors"
out=$(cd "$pub" && "$JENNY" publish "$(date +%Y-%m-%d) my post.md" 2>&1); st=$?
assert_failure "$st" "$out"
it "publishing twice does not double-prefix"
assert_file_absent "$pub/$(date +%Y-%m-%d) $(date +%Y-%m-%d) my post.md"
it "publishing a missing file errors"
out=$(cd "$pub" && "$JENNY" publish 'nope.md' 2>&1); st=$?
assert_failure "$st" "$out"
it "publish refuses to overwrite an existing destination"
printf '# Dup\n' > "$pub/dup.md"
mkdir -p "$pub/.keep" && printf '# Dup\n' > "$pub/$(date +%Y-%m-%d) dup.md"
out=$(cd "$pub" && "$JENNY" publish 'dup.md' 2>&1); st=$?
assert_failure "$st" "$out"
suite "P2-3 -c cleans the right directory, after parsing"
it "-c -d ./out cleans ./out, not the default .dist"
cl="$TMPROOT/clean"
make_blog_min "$cl" 3 3
run_jenny "$cl" >/dev/null # populate the default .dist
mkdir -p "$cl/out" && touch "$cl/out/stale.html"
before_marker="$cl/.dist/DO-NOT-DELETE"
touch "$before_marker"
run_jenny "$cl" -c -d ./out
assert_file_exists "$before_marker" "default .dist must survive -c -d ./out"
it "-c removed the stale file from the target directory"
assert_file_absent "$cl/out/stale.html"
it "-c on a missing directory is not an error"
rm -rf "$cl/out"
run_jenny "$cl" -c -d ./out
assert_success "$status" "$output"
it "-c refuses a dangerous DIST"
out=$(cd "$cl" && "$JENNY" -c -d / 2>&1); st=$?
assert_failure "$st" "$out"
suite "P2-4 temp files are cleaned up"
# Scoped to this build's own temp files. Matching every jenny.* in TMPDIR
# counted the in-flight files of any concurrent jenny -- another checkout,
# a parallel CI job -- as leaks, which is a false positive rather than a
# real one. A private TMPDIR isolates the run.
it "no jenny temp files survive a build"
tmpiso="$TMPROOT/tmpdir-isolated"
rm -rf "$tmpiso"; mkdir -p "$tmpiso"
leakblog="$TMPROOT/leakcheck"
make_blog_min "$leakblog" 2 10
mkdir -p "$leakblog/pages"
printf '# P\n\nx\n' > "$leakblog/pages/p.md"
( cd "$leakblog" && TMPDIR="$tmpiso" "$JENNY" >/dev/null 2>&1 )
leaked=$(find "$tmpiso" -maxdepth 1 -name 'jenny.*' 2>/dev/null | wc -l | tr -d ' ')
assert_eq "$leaked" "0"
it "a failed build cleans up after itself too"
( cd "$leakblog" && TMPDIR="$tmpiso" "$JENNY" -m false >/dev/null 2>&1 ) || true
leaked=$(find "$tmpiso" -maxdepth 1 -name 'jenny.*' 2>/dev/null | wc -l | tr -d ' ')
assert_eq "$leaked" "0"
it "the dangerous /tmp/jenny* glob removal is gone"
assert_file_not_contains "$REPO_ROOT/share/jenny/lib/helpers.sh" "rm -rf /tmp/jenny"
suite "P2-10 empty directory"
it "a blog with no posts errors clearly"
empty="$TMPROOT/empty"
mkdir -p "$empty"
cat > "$empty/.blogrc" <<'EOF'
BLOG_HOST="http://localhost:8000"
BLOG_TITLE="Empty"
POSTS_PER_PAGE=3
DIST=$PWD/.dist
EOF
run_jenny "$empty"
assert_failure "$status" "$output"
it "the error names the problem"
assert_contains "$output" "no posts"
it "no post is created from the literal glob"
assert_not_contains "$output" '+ /post/'
suite "P2-12 tag names cannot escape DIST"
it "a traversing tag name writes nothing outside DIST"
tg="$TMPROOT/tags"
make_blog_min "$tg" 2 3
printf '# Evil\n\ntags: ../evil\n\nBody\n' > "$tg/2024-04-01 evil.md"
run_jenny "$tg"
assert_file_absent "$tg/evil"
it "no traversal directory is created beside DIST"
assert_file_absent "$tg/.dist/../evil"
it "the build still succeeds"
assert_success "$status" "$output"
it "a multi-word tag is handled without splitting"
mw="$TMPROOT/multiword"
make_blog_min "$mw" 2 3
printf '# MW\n\ntags: alpha beta\n\nBody\n' > "$mw/2024-04-02 mw.md"
run_jenny "$mw"
assert_file_exists "$mw/.dist/tag/alpha/index.html"
suite "P2-1 local does not mask exit status"
it "get_existing_slug returns empty for an unknown sum"
lockdir="$TMPROOT/lockprobe"
mkdir -p "$lockdir"
BLOG_LOCK="$lockdir/.bloglock"
printf 'my-slug aaaa bbbb\n' > "$BLOG_LOCK"
GREP=grep; AWK=$(command -v gawk || command -v awk)
result=$(get_existing_slug "zzzz")
assert_eq "$result" ""
it "get_existing_slug finds a known sum"
result=$(get_existing_slug "aaaa")
assert_eq "$result" "my-slug"
it "get_existing_slug does not exit the caller"
( get_existing_slug "aaaa" >/dev/null; echo "survived" ) > "$TMPROOT/survived.txt"
assert_eq "$(cat "$TMPROOT/survived.txt")" "survived"
suite "P2-2 lock lookups are anchored"
it "a content-hash collision does not hide a new post"
printf 'other-slug 1111 deadbeef\n' > "$BLOG_LOCK"
if is_new "deadbeef"; then pass; else fail "deadbeef matched another line's content column"; fi
it "a real filename hash is recognised"
if is_new "1111"; then fail "1111 is present in the lock but reported new"; else pass; fi
suite "serve"
it "serve rejects a non-numeric port"
sv="$TMPROOT/serve"
make_blog_min "$sv" 3 3
run_jenny "$sv" >/dev/null
out=$(cd "$sv" && "$JENNY" serve abc 2>&1); st=$?
assert_failure "$st" "$out"
it "serve errors when the build directory is missing"
nb="$TMPROOT/nobuild"
make_blog_min "$nb" 1 3
out=$(cd "$nb" && "$JENNY" serve 8321 2>&1); st=$?
assert_failure "$st" "$out"
it "serve answers over HTTP and every link resolves"
if ! command -v curl >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1; then
skip "curl or python3 not available"
else
# Driven from a self-contained script so the background server never holds
# this harness's stdout open, and dies with its own timeout regardless.
cat > "$TMPROOT/http-check.sh" <<'HTTPCHECK'
#!/usr/bin/env bash
# usage: http-check.sh <blog-dir> <jenny>
sv=$1; jenny=$2
port=$(python3 -c 'import socket;s=socket.socket();s.bind(("",0));print(s.getsockname()[1]);s.close()')
cd "$sv" || exit 1
timeout 30 "$jenny" serve "$port" >/dev/null 2>&1 &
srv=$!
trap 'kill "$srv" 2>/dev/null' EXIT
for _ in $(seq 1 30); do
curl -sf -o /dev/null --max-time 2 "http://localhost:$port/" && break
sleep 0.2
done
curl -sf -o /dev/null --max-time 2 "http://localhost:$port/" || { echo "SERVER-DOWN"; exit 1; }
cd "$sv/.dist" || exit 1
for f in $(find . -name '*.html' | sed 's|^\./||'); do
d=$(dirname "$f")
for h in $(grep -oE '(href|src)="[^"]*"' "$f" \
| sed -E 's/^(href|src)="//; s/"$//' \
| grep -v '^http\|^data:\|^#\|^mailto:'); do
case "$h" in
/*) u="http://localhost:$port$h" ;;
*) if [ "$d" = "." ]; then u="http://localhost:$port/$h"; else u="http://localhost:$port/$d/$h"; fi ;;
esac
code=$(curl -s -o /dev/null --max-time 5 -w '%{http_code}' "$u")
[ "$code" = "200" ] || echo " $code $f -> $h"
done
done
HTTPCHECK
chmod +x "$TMPROOT/http-check.sh"
http_bad=$(timeout 60 "$TMPROOT/http-check.sh" "$sv" "$JENNY" </dev/null 2>&1)
assert_eq "$http_bad" "" "every link must resolve over HTTP from the document root"
fi
suite "legacy layouts and migration"
it "a legacy layout is refused rather than mis-rendered"
lg="$TMPROOT/legacy"
copy_fixture legacy-blog "$lg"
run_jenny "$lg"
assert_failure "$status" "$output"
it "the refusal names the problem and the fix"
assert_contains "$output" "old eval-based LIST format"
it "the refusal points at migrate-layout"
assert_contains "$output" "migrate-layout"
it "the refusal names the offending files"
assert_contains "$output" "index.sh"
it "nothing was built"
assert_file_absent "$lg/.dist/index.html"
it "migrate-layout reports what it migrated"
out=$(cd "$lg" && "$JENNY" migrate-layout .layout 2>&1)
assert_contains "$out" "migrated"
it "migration backs up the original"
assert_file_exists "$lg/.layout/index.sh.pre-records.bak"
it "the backup still holds the legacy form"
assert_file_contains "$lg/.layout/index.sh.pre-records.bak" "eval"
it "the migrated blog builds cleanly"
run_jenny_split "$lg"
assert_success "$status" "$output"
it "the migrated build is silent on stderr"
assert_eq "$stderr_output" ""
it "a .blogrc with no BLOG_HOST or BLOG_TITLE still builds"
assert_file_exists "$lg/.dist/index.html"
suite "migration preserves the user's markup"
it "a custom class in list_item survives"
assert_file_contains "$lg/.layout/index.sh" "in-next"
it "custom marker wording survives"
assert_file_contains "$lg/.layout/index.sh" "Under page"
it "custom page chrome survives"
assert_file_contains "$lg/.layout/index.sh" "custom-marker"
it "a custom feed description survives"
assert_file_contains "$lg/.layout/rss2.sh" "Posted on"
it "the custom marker reaches the rendered index"
assert_file_contains "$lg/.dist/index.html" "Under page"
it "the custom description reaches the feed"
assert_file_contains "$lg/.dist/feed.xml" "Posted on "
suite "migration fixes escaping the legacy layout never did"
it "a title with an ampersand is escaped in the index"
assert_file_contains "$lg/.dist/index.html" "a Title &amp; &lt;thing&gt;"
it "the migrated feed is well-formed XML"
if command -v xmllint >/dev/null 2>&1; then
out=$(xmllint --noout "$lg/.dist/feed.xml" 2>&1)
assert_eq "$out" ""
else
skip "xmllint not installed"
fi
it "a fenced heading does not leak into the title"
assert_file_not_contains "$lg/.dist/index.html" "not a title"
suite "migration is idempotent"
it "re-running migrate-layout is a no-op"
out=$(cd "$lg" && "$JENNY" migrate-layout .layout 2>&1)
assert_contains "$out" "already current"
it "a migrated layout is not flagged as legacy"
run_jenny_split "$lg"
assert_success "$status" "$output"
it "the second build is still silent"
assert_eq "$stderr_output" ""
it "rebuilding does not re-migrate"
assert_file_not_contains "$lg/.layout/index.sh" "$(printf '\xe2\x9c\x82')"
suite "URL_STYLE=extensionless"
it "index hrefs drop the .html extension"
ux="$TMPROOT/urlstyle-ext"
make_blog_min "$ux" 5 3
echo 'URL_STYLE=extensionless' >> "$ux/.blogrc"
run_jenny_split "$ux"
assert_file_contains "$ux/.dist/index.html" 'href="./post/post-05"'
it "no index href keeps .html"
assert_file_not_contains "$ux/.dist/index.html" 'post-05.html'
it "the build is clean"
assert_eq "$stderr_output" ""
it "files on disk still end in .html"
assert_file_exists "$ux/.dist/post/post-05.html"
it "feed links drop the extension too"
assert_file_contains "$ux/.dist/feed.xml" "/post/post-05<"
it "feed links stay absolute"
badlinks=$(grep -o '<link>[^<]*</link>' "$ux/.dist/feed.xml" | sed 's/<[^>]*>//g' \
| grep -vE '^https?://[^ ]*$' || true)
assert_eq "$badlinks" ""
it "the paginated page uses the right relative depth"
assert_file_contains "$ux/.dist/page/1.html" 'href="../post/post-01"'
it "the post page self-link drops the extension"
assert_file_contains "$ux/.dist/post/post-01.html" 'href="/post/post-01"'
it "every link resolves the way a Pages-style host would serve it"
broken=$(check_links "$ux/.dist") && pass || fail "broken links:
$broken"
suite "URL_STYLE=html (the default)"
it "an unset URL_STYLE keeps .html"
uh="$TMPROOT/urlstyle-default"
make_blog_min "$uh" 5 3
run_jenny_split "$uh"
assert_file_contains "$uh/.dist/index.html" 'href="./post/post-05.html"'
it "setting it explicitly behaves the same"
ue="$TMPROOT/urlstyle-html"
make_blog_min "$ue" 5 3
echo 'URL_STYLE=html' >> "$ue/.blogrc"
run_jenny "$ue"
assert_file_contains "$ue/.dist/index.html" 'href="./post/post-05.html"'
it "every link still resolves from the document root"
broken=$(check_links "$uh/.dist") && pass || fail "broken links:
$broken"
suite "URL_STYLE validation"
it "an unknown value fails the build"
ub="$TMPROOT/urlstyle-bad"
make_blog_min "$ub" 2 3
echo 'URL_STYLE=nonsense' >> "$ub/.blogrc"
run_jenny "$ub"
assert_failure "$status" "$output"
it "the error names the value and the valid options"
assert_contains "$output" "nonsense"
it "it names what was expected"
assert_contains "$output" "extensionless"
it "nothing is written when validation fails"
assert_file_absent "$ub/.dist/index.html"
suite "nested lists"
# The nesting machinery existed but the indent unit was a tab or four spaces,
# while a child is conventionally indented to its parent's content column --
# two spaces for "- ". Two-space nesting, the common form, came out flat.
md_render() {
printf '%s\n' "$1" | "$AWK" -f "$JENNY_SHARE_DIR/lib/markdown.awk/markdown.awk"
}
# Raw HTML is escaped unless the caller asks for passthrough. The tests that
# examine how markdown interacts with real tags need the tags to survive.
md_unsafe() {
printf '%s\n' "$1" | "$AWK" -v UNSAFE_HTML=1 -f "$JENNY_SHARE_DIR/lib/markdown.awk/markdown.awk"
}
it "a two-space child nests inside its parent"
out=$(md_render '- a
- b
- c')
assert_contains "$out" '<li>
a
<ul>'
it "a sibling after a nested list rejoins the outer list"
# The closer walked past the target depth because it compared against pblock,
# which is assigned nowhere and so was always empty. It closed the outer <ul>
# too, and the sibling opened a second one.
assert_eq "$(printf '%s' "$out" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "2"
assert_eq "$(printf '%s' "$out" | "$GREP" -o '</ul>' | wc -l | tr -d ' ')" "2"
it "three levels of two-space nesting all nest"
deep=$(md_render '- a
- b
- c
- d')
assert_eq "$(printf '%s' "$deep" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "3"
it "four-space nesting still works"
# Two spaces is the step, not an alternative to four: written as an
# alternation with four first, a four-space indent matched the wide branch and
# peeled only one level, collapsing three levels into two.
four=$(md_render '- a
- b
- c')
assert_eq "$(printf '%s' "$four" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "2"
it "a flat list is unaffected"
flat=$(md_render '- a
- b
- c')
assert_eq "$(printf '%s' "$flat" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "1"
assert_eq "$(printf '%s' "$flat" | "$GREP" -o '<li>' | wc -l | tr -d ' ')" "3"
it "ordered lists nest too"
onest=$(md_render '1. a
1. b
2. c')
assert_eq "$(printf '%s' "$onest" | "$GREP" -o '<ol>' | wc -l | tr -d ' ')" "2"
suite "strikethrough"
it "doubled tildes become del"
assert_contains "$(md_render 'Plain ~~struck~~ here.')" '<del>struck</del>'
it "text after a strike containing emphasis is not lost"
# nextil shares t1/tag/t2 globally, so parsing the struck text clobbered the
# tail still waiting in the outer frame -- the rest of the line vanished.
assert_contains "$(md_render '~~*it*~~ TAIL')" '<del><em>it</em></del> TAIL'
it "two strikes on one line both close"
two=$(md_render '~~one~~ mid ~~*two*~~ end')
assert_eq "$(printf '%s' "$two" | "$GREP" -o '<del>' | wc -l | tr -d ' ')" "2"
assert_contains "$two" 'end'
it "a single tilde stays literal"
# "~/path" and "approx ~5" must survive untouched.
assert_contains "$(md_render 'Path ~/x and approx ~5 items.')" '~/x and approx ~5'
it "an unpaired double tilde stays literal"
# Rather than opening a <del> that runs to the end of the document.
assert_contains "$(md_render 'Unpaired ~~ stays.')" 'Unpaired ~~ stays.'
it "tildes inside code are untouched"
assert_contains "$(md_render 'A `~~code~~` span.')" '<code>~~code~~</code>'
suite "underscores in inline HTML"
it "an underscore that is a tag's whole content is literal"
# <span class="us">_</span> writes a visible underscore. It opened an <em>
# that swallowed the markup after it.
assert_contains "$(md_unsafe 'x <span class="us">_</span> y')" '<span class="us">_</span>'
it "two such underscores do not pair across their tags"
# They emitted mis-nested "<b><em></b> ... <b></em></b>".
pair=$(md_unsafe 'A <b>_</b> and <b>_</b> here.')
assert_contains "$pair" '<b>_</b> and <b>_</b>'
assert_not_contains "$pair" '<em>'
it "real emphasis still works"
assert_contains "$(md_render 'Real _emphasis_ here.')" '<em>emphasis</em>'
it "bold still works"
assert_contains "$(md_render 'Real __bold__ here.')" '<strong>bold</strong>'
it "emphasis spanning a tag still works"
assert_contains "$(md_unsafe 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more</em>'
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 "a section heading sits close to what it labels"
# 1em of margin on top of a list's own leading left a visible empty band under
# the word -- about 22px under the tag row. The three headings now open their
# content at 4-5px apiece.
assert_file_contains "$tl_post" 'margin: 0 0 .25em; }'
it "the tag label leans on its own line box for the gap"
# A row of inline links has no list leading to account for, so it needs no
# bottom margin at all.
tag_rule=$("$SED" -n '/\.tags:has(a)::before/,/}/p' "$tl_post")
assert_not_contains "$tag_rule" 'margin'
it "the references list adds no second gap"
# The <ol> brought its own top margin, so references opened 14px below its
# heading where notes and tags opened at 4.
assert_file_contains "$tl_post" '.citations ol { padding-left: 1.4em; margin-top: 0; }'
it "the build is clean"
assert_eq "$stderr_output" ""
suite "a post has no breadcrumb"
# A post's own segment would be its slug, which usually restates the title
# directly beneath it. Making that read well would mean choosing titles that
# differ from their slugs -- the layout dictating what a post may be called.
# A page's trail earns its place because the path has segments the title does
# not repeat.
bc="$TMPROOT/breadcrumb"
make_blog_min "$bc" 1 10
mkdir -p "$bc/pages"
printf '# Plain\n\nBody.\n' > "$bc/pages/plain.md"
run_jenny_split "$bc"
bc_post=$(find "$bc/.dist/post" -name '*.html' | head -1)
it "a post's header is the site title alone"
bc_header=$("$GREP" -o '<header>.*</header>' "$bc_post" | head -1)
assert_not_contains "$bc_header" 'class="current"'
assert_not_contains "$bc_header" '&#x2192;&#xFE0E;'
it "a page still has its trail"
# The two headers differ on purpose, not by omission.
assert_file_contains "$bc/.dist/plain.html" 'class="current">plain</a>'
it "the build is clean"
assert_eq "$stderr_output" ""
suite "the footer row"
# The way back and the site links share one row at the end of the text. Before
# this the back link stood alone and the footer nav was never rendered at all.
fr="$TMPROOT/footerrow"
make_blog_min "$fr" 1 10
mkdir -p "$fr/pages" "$fr/navs"
printf '# Plain\n\nBody.\n' > "$fr/pages/plain.md"
printf '### Site\nAbout\t/about\nFeed\t/feed.xml\n' > "$fr/navs/site-footer.tsv"
run_jenny_split "$fr"
fr_post=$(find "$fr/.dist/post" -name '*.html' | head -1)
it "the site links render beside the way back"
assert_file_contains "$fr_post" '<footer class="site-footer">'
assert_file_contains "$fr_post" 'class="footer-link"'
it "the way back comes first"
# It is the link every page has and the one a reader is most likely to want.
home_line=$("$GREP" -n 'class="home"' "$fr_post" | head -1 | cut -d: -f1)
link_line=$("$GREP" -n 'class="footer-link"' "$fr_post" | head -1 | cut -d: -f1)
[ "$home_line" -lt "$link_line" ] \
&& pass "back link precedes the site links" \
|| fail "back link at $home_line should precede site links at $link_line"
it "the row flows from the left with one gap between items"
# Nothing is pushed to the far end: the row reads as a list.
assert_file_contains "$fr_post" '.site-footer { display: flex; align-items: baseline;'
assert_file_contains "$fr_post" 'gap: 1.5em; margin-top: 2.5em; }'
assert_file_not_contains "$fr_post" '.home { margin-left: auto;'
it "pages get the site links but no way back"
# A page is not listed on the index, so an arrow back to it points somewhere
# the reader did not come from.
assert_file_contains "$fr/.dist/plain.html" 'class="footer-link"'
assert_file_not_contains "$fr/.dist/plain.html" 'class="home"'
it "a blog with no site nav still gets its way back"
# The row exists for the back link; the nav is the optional part.
rm "$fr/navs/site-footer.tsv"
run_jenny_split "$fr"
assert_file_contains "$fr_post" 'class="home"'
assert_file_not_contains "$fr_post" 'class="footer-link"'
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
# title land on the index's, and the body runs in the title's column. Before
# this the title's left edge came from the stamp sitting inline beside it and
# the body's came from the back arrow sitting inline beside the body -- two
# independent accidents that agreed by coincidence. Moving the arrow below the
# text collapsed one and left the title 109px right of its own first paragraph.
al="$TMPROOT/aligned"
make_blog_min "$al" 2 10
run_jenny_split "$al"
al_post=$(find "$al/.dist/post" -name '*.html' | head -1)
it "the body's column is a copy of the stamp, not a length that restates it"
# The index's column is the rendered width of the date plus its padding --
# 109.140625px at the default size -- which no declaration can name. A 6.82em
# basis came to 109.12 and left the body 0.02px short of its own title, which
# is visible as a pixel once the two round different ways. The copy holds the
# same text in the same font with the same padding, so it measures the same
# width by construction.
assert_file_contains "$al_post" '<div class="gutter" aria-hidden="true">'
assert_file_contains "$al_post" '.body-row { display: flex; align-items: flex-start; }'
assert_file_contains "$al_post" '.body-row .gutter { flex: 0 0 auto; visibility: hidden;'
it "the flex container is the anchor, not the heading around it"
# The stamp and the title are wrapped in one <a>, so making .heading the
# container gave it a single flex child and the two inside stayed stacked --
# the date dropped onto its own line above the title.
assert_file_contains "$al_post" '.heading > a { display: flex'
it "the title is a flex item, not a table cell"
# The base rule set display:table-cell at equal specificity earlier in the
# file, so leaving it there won on source order and gave the title its own row.
assert_file_not_contains "$al_post" '.heading .title { color: var(--text-heading); display: table-cell'
it "the stamp column is stated once, in em"
# The index's stamp is a table-cell whose "width: 2.25em" is only a minimum --
# the cell grows to fit the date, so it draws 109px rather than the 60px that
# declaration adds up to. A flex basis cannot reproduce content-driven sizing,
# so the post states the length it resolves to; em rather than px so it tracks
# the type size. Whether the two agree is a rendered-geometry question, checked
# by measuring, not by matching declarations that are necessarily different.
assert_file_contains "$al_post" '--stamp-column: 6.82em'
assert_file_contains "$al/.dist/index.html" 'width: 2.25em'
it "the body is not indented by a second mechanism"
# A margin on .contents would be another copy of the column, drifting the
# moment the stamp changes.
# Matched on the tail rather than the whole declaration: pinning the measure
# in here meant the assertion stopped guarding anything the moment the width
# changed, while still passing.
assert_file_not_contains "$al_post" 'width: 100%; min-width: 0; margin-left'
it "long words cannot widen the page"
# A bare URL has no spaces to wrap at; one reference link made a post 375px
# wide inside a 320px viewport.
assert_file_contains "$al_post" 'article a { color: var(--link-article); overflow-wrap: break-word; }'
it "a wide table scrolls inside its own box"
# overflow-x alone does not let the box shrink below its content: without
# min-width the table pushed the document sideways instead of scrolling.
assert_file_contains "$al_post" 'article .table-scroll { overflow-x: auto; margin: 1em 0; min-width: 0; max-width: 100%; }'
it "a phone stacks the header instead of holding the column"
# The column is a quarter of a 440px screen held open for a date, and the body
# pays: 283px of a 392px article, 31 characters a line where the same text on a
# page gets 44. Below the width where the page layout stacks its sidebar, a
# post stacks too.
narrow_post=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$al_post")
assert_contains "$narrow_post" '.heading > a { display: block; }'
# The header stacks, so there is no column to fill: the copy is removed rather
# than left as an empty box holding a quarter of the screen.
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
# side there. The declarations outside the media query are untouched.
assert_file_contains "$al_post" '.body-row { display: flex; align-items: flex-start; }'
assert_file_contains "$al_post" 'flex: 0 0 var(--stamp-column)'
it "the stamp pads itself the way the index's does"
# Same padding on both sides of the comparison, so the column is the date plus
# 1.5em in each case rather than two different sums.
assert_file_contains "$al_post" 'padding-right: 1.5em; text-align: right;'
assert_file_contains "$al/.dist/index.html" 'padding-right: 1.5em;'
it "the build is clean"
assert_eq "$stderr_output" ""
suite "an index page is its directory"
# "index" is a filesystem convention, not part of the address: the page is
# reached at its directory's URL. Left in the trail, the last crumb read
# "index" -- a filename presented to the reader as a place.
ix="$TMPROOT/indexpage"
make_blog_min "$ix" 1 10
mkdir -p "$ix/pages/thesis/mdd" "$ix/pages/notes/method"
printf '# The Thesis\n\nBody.\n' > "$ix/pages/thesis/mdd/index.md"
printf '# Artifacts\n\nBody.\n' > "$ix/pages/thesis/mdd/artifacts.md"
printf '# Intro\n\nBody.\n' > "$ix/pages/notes/method/intro.md"
run_jenny_split "$ix"
it "the page renders at its directory"
assert_file_exists "$ix/.dist/thesis/mdd/index.html"
it "the trail does not name the file"
ix_head=$("$GREP" -o '<header>.*</header>' "$ix/.dist/thesis/mdd/index.html" | head -1)
assert_not_contains "$ix_head" '>index<'
it "the directory is the current crumb instead"
assert_contains "$ix_head" 'class="current">mdd</a>'
it "a sibling links back to the directory, not the site root"
# Intermediate segments used to point home because no directory had a page.
# One with an index.md does, and sending the reader home from a section that
# has a landing page is worse than not linking at all.
sib_head=$("$GREP" -o '<header>.*</header>' "$ix/.dist/thesis/mdd/artifacts.html" | head -1)
assert_contains "$sib_head" 'href="../../thesis/mdd/">mdd</a>'
it "a directory without its own index gets a generated one"
# A crumb that led to the site root looked like navigation and was not. Every
# section now has a page, so every segment leads somewhere it says it does.
assert_file_exists "$ix/.dist/notes/method/index.html"
no_ix=$("$GREP" -o '<header>.*</header>' "$ix/.dist/notes/method/intro.html" | head -1)
assert_contains "$no_ix" 'href="../../notes/method/">method</a>'
it "the generated listing names the pages under it"
# The page's own title, not its slug: the listing is for a reader, and the
# title is what the author called it.
assert_file_contains "$ix/.dist/notes/method/index.html" '/notes/method/intro'
it "each entry gets its own row"
# Not a bullet list, and not one run-together line: markdown joins adjacent
# lines into a single paragraph, so the entries are separated by a blank line
# and each becomes its own paragraph.
mkdir -p "$ix/pages/docs"
printf '# Alpha One\n\nx\n' > "$ix/pages/docs/alpha.md"
printf '# Beta Two\n\nx\n' > "$ix/pages/docs/beta.md"
run_jenny_split "$ix"
# A paragraph carries a deep-link id now, so the opening tag is no longer bare.
rows=$("$GREP" -c '<p[^>]*><a href="/docs/' "$ix/.dist/docs/index.html")
assert_eq "$rows" "2"
assert_file_not_contains "$ix/.dist/docs/index.html" '<li><a href="/docs/'
it "a label derived from a path is title cased"
# "migration driven development" is a slug read aloud, not something anyone
# wrote, so presenting it lowercase looks like a mistake.
assert_file_contains "$ix/.dist/docs/index.html" '>Docs<'
it "a page's own title keeps the author's casing"
# Only derived labels are re-cased. A title with a deliberate lowercase word
# must survive: "Method, an Introduction" must not become "Method, An
# Introduction".
printf '# Method, an Introduction\n\nBody.\n' > "$ix/pages/notes/method/intro.md"
run_jenny_split "$ix"
assert_file_contains "$ix/.dist/notes/method/index.html" 'Method, an Introduction'
it "a generated listing is marked so it can be swept"
# It is written into the source tree, which is where render_pages reads from.
# Unmarked, a directory that later gains a real index.md would keep the
# generated one and the author's page would never render.
assert_file_contains "$ix/pages/notes/method/index.md" 'generated-section-index'
it "a hand-written index is never overwritten"
assert_file_not_contains "$ix/pages/thesis/mdd/index.md" 'generated-section-index'
it "a page whose name merely starts with index keeps its crumb"
# The rule fires on a segment named exactly "index"; "indexing" is a page like
# any other and must keep its own trail.
mkdir -p "$ix/pages/solo"
printf '# Indexing\n\nBody.\n' > "$ix/pages/solo/indexing.md"
run_jenny_split "$ix"
idx_like=$("$GREP" -o '<header>.*</header>' "$ix/.dist/solo/indexing.html" | head -1)
assert_contains "$idx_like" 'class="current">indexing</a>'
it "a page at the top of pages/ cannot overwrite the blog index"
# pages/index.md lands on the blog's own index, which is written earlier in the
# build -- so the page silently replaced the post list with itself and the
# front page stopped being an index. Refused rather than resolved: either file
# could be what the author meant, and guessing makes one vanish without saying
# so.
printf '# Root\n\nBody.\n' > "$ix/pages/index.md"
run_jenny_split "$ix"
assert_contains "$stderr_output" 'would overwrite the blog index'
assert_file_contains "$ix/.dist/index.html" 'class="post-link"'
# Removed and rebuilt so the clean-build check below is not reading the
# warning this test provoked on purpose.
rm "$ix/pages/index.md"
run_jenny_split "$ix"
it "the build is clean"
assert_eq "$stderr_output" ""
suite "headings are addressable"
# A document long enough to link within needs its headings to be targets. Any
# "#section" link -- from its own contents list, from a sibling page, from
# somewhere else entirely -- resolves to nothing without them. Publishing a
# real thesis found 23 such links pointing at nothing.
it "a heading carries an id made from its words"
assert_contains "$(md_render '## Vision')" '<h2 id="vision">'
it "punctuation and spacing collapse to single dashes"
assert_contains "$(md_render '## Scope (what MDD is not)')" 'id="scope-what-mdd-is-not"'
it "inline markup is stripped before slugging"
# A heading may be emphasised or hold code; "<code>Vision</code>" must still
# slug to "vision".
assert_contains "$(md_render '## The `Blueprint` Artifact')" 'id="the-blueprint-artifact"'
assert_contains "$(md_render '## Scope (what MDD is *not*)')" 'id="scope-what-mdd-is-not"'
it "every level is addressable"
assert_contains "$(md_render '# One')" '<h1 id="one">'
assert_contains "$(md_render '###### Six')" '<h6 id="six">'
it "a heading with no sluggable characters gets no id"
# An empty id is worse than none: it is not a valid target and would collide
# with every other unsluggable heading.
out=$(md_render '## ---')
assert_not_contains "$out" 'id=""'
it "a paragraph carries a deep-link id"
# Headings were addressable and paragraphs were not, so a reader could link to
# a section but not to the sentence they meant.
assert_contains "$(md_render 'Just a paragraph.')" 'id="p-'
it "a paragraph's id is derived from its own text, not its position"
# The point of hashing the text: inserting a paragraph above must not move the
# anchor of the one below it. A positional id would keep resolving after the
# insertion and quietly point at the wrong paragraph, which is worse than
# breaking, because the link still looks like it works.
before=$(md_render 'Alpha text.
Beta text.' | "$GREP" -o 'id="p-[a-f0-9]*"' | tail -1)
after=$(md_render 'Inserted first.
Alpha text.
Beta text.' | "$GREP" -o 'id="p-[a-f0-9]*"' | tail -1)
assert_eq "$before" "$after"
it "an id changes when the paragraph's own words change"
# The other half of the contract, and the honest failure: the text someone
# linked to is not there any more, so the link should not silently land on
# something else.
one=$(md_render 'Alpha text.' | "$GREP" -o 'id="p-[a-f0-9]*"')
two=$(md_render 'Alpha text edited.' | "$GREP" -o 'id="p-[a-f0-9]*"')
if [ "$one" != "$two" ]; then pass; else fail "id did not change with the text: $one"; fi
it "repeated text still gets unique ids"
# Two paragraphs with the same words hash the same; ids on one page must not
# collide or the second is unreachable.
dup=$(md_render 'Same words.
Same words.' | "$GREP" -c 'id="p-')
assert_eq "$dup" "2"
assert_contains "$(md_render 'Same words.
Same words.')" '-2"'
it "a change to the parser invalidates the lock"
# The build signature covered the layouts but not the parser, so adding ids to
# headings changed every page and the build re-rendered none of them: it said
# nothing had changed while the change was in the markdown parser.
assert_file_contains "$JENNY_SHARE_DIR/lib/helpers.sh" "JENNY_LIB_DIR"
assert_file_contains "$JENNY_SHARE_DIR/lib/helpers.sh" "-name '*.awk'"
suite "diagrams"
# A diagram fence is held for a client-side renderer instead of being shown as
# source. jenny ships no renderer and pulls nothing from a CDN -- these test
# the fence rule and the opt-in hook only; the bundle, if a site wants one, is
# the site's own file.
it "a mermaid fence becomes a diagram div"
assert_contains "$(md_render '```mermaid
flowchart LR
A --> B
```')" '<div class="diagram diagram-mermaid">'
it "diagram source is not HTML-escaped"
# The renderer reads the element's text content and parses it, so escaping
# "-->" to "--&gt;" would break every arrow in the diagram. This is the one
# place a fence's content is left raw.
dia=$(md_render '```mermaid
flowchart LR
A["x<br/>y"] -->|"label"| B
```')
assert_contains "$dia" '-->'
assert_not_contains "$dia" '--&gt;'
assert_contains "$dia" '<br/>'
it "the diagram fence closes with a div, not a code block"
assert_contains "$dia" '</div>'
assert_not_contains "$dia" '</code></pre>'
it "the legend variant keeps its own class"
assert_contains "$(md_render '```mermaid-legend
flowchart LR
A --> B
```')" 'diagram-mermaid-legend'
it "an ordinary code fence is still escaped"
# Only the fixed set of diagram languages is raw; everything else escapes as
# before, so a document cannot introduce a new raw language.
code=$(md_render '```bash
echo "a" && b > c
```')
assert_contains "$code" '&amp;&amp;'
assert_contains "$code" '<pre><code class="language-bash">'
it "a bare fence is still escaped"
assert_contains "$(md_render '```
x > y & z
```')" '&gt;'
it "a language whose name merely starts with mermaid is not raw"
notmer=$(md_render '```mermaidish
a > b
```')
assert_contains "$notmer" '<pre><code'
assert_not_contains "$notmer" '<div class="diagram'
suite "diagram renderer hook"
# DIAGRAM_SCRIPT names a script a site serves itself. jenny bundles no
# renderer: with the setting unset nothing is emitted, and the path here is a
# fixture, not any real file.
dg="$TMPROOT/diagrams"
make_blog_min "$dg" 1 10
mkdir -p "$dg/pages"
printf '# Plain\n\nNo diagram here.\n' > "$dg/pages/plain.md"
printf '# Chart\n\n```mermaid\nflowchart LR\n A --> B\n```\n' > "$dg/pages/chart.md"
it "nothing is loaded when DIAGRAM_SCRIPT is unset"
# The default: a diagram degrades to its source text, which stays readable
# because it is what the author wrote.
run_jenny_split "$dg"
assert_file_not_contains "$dg/.dist/chart.html" '<script src='
it "the script is emitted on a page that has a diagram"
printf 'DIAGRAM_SCRIPT="/js/renderer.js"\n' >> "$dg/.blogrc"
run_jenny_split "$dg"
assert_file_contains "$dg/.dist/chart.html" 'js/renderer.js'
it "the script is not emitted on a page without one"
# A renderer bundle is typically megabytes; every page paying for it so that a
# few work would be the wrong trade.
assert_file_not_contains "$dg/.dist/plain.html" 'js/renderer.js'
it "the renderer matches both mermaid dialects"
# A class selector matches whole class names, so '.diagram-mermaid' never
# matched 'diagram-mermaid-legend'. A legend block sat unrendered, showing its
# own source text and pushing the page 3px wider than the viewport -- found
# when a real document with a legend was published.
# The selector is now bound once to SEL and used for the first render, the
# redraw after a theme change, and the modal, so the assertion follows it to
# its definition. The attribute-substring form is what matters, not where the
# literal sits.
assert_file_contains "$dg/.dist/chart.html" "var SEL = '[class*=\"diagram-mermaid\"]';"
assert_file_not_contains "$dg/.dist/chart.html" "var SEL = '.diagram-mermaid';"
it "the renderer is pointed at the class the parser emits"
# The markup uses a neutral "diagram" class rather than naming one renderer,
# so the renderer has to be told what to look for. Left at its own default it
# matched nothing and silently drew no diagram.
assert_file_contains "$dg/.dist/chart.html" 'querySelector:'
assert_file_contains "$dg/.dist/chart.html" 'diagram-mermaid'
it "the script path is resolved relative to the page"
mkdir -p "$dg/pages/deep"
printf '# Deep\n\n```mermaid\nflowchart LR\n A --> B\n```\n' > "$dg/pages/deep/d.md"
run_jenny_split "$dg"
assert_file_contains "$dg/.dist/deep/d.html" '"../js/renderer.js"'
it "posts get the same treatment as pages"
# The fence rule is in the parser, so it applies to every layout. A post that
# only got the div -- no renderer, no styling -- would show its diagram as
# run-together text.
printf '# Charted\n\n```mermaid\nflowchart LR\n A --> B\n```\n' > "$dg/$(date +%Y-%m-%d) charted.md"
run_jenny_split "$dg"
assert_file_contains "$dg/.dist/post/charted.html" '<div class="diagram diagram-mermaid">'
assert_file_contains "$dg/.dist/post/charted.html" 'js/renderer.js'
it "a post without a diagram loads no renderer"
post_plain=$(find "$dg/.dist/post" -name '*.html' ! -name 'charted.html' | head -1)
assert_file_not_contains "$post_plain" 'js/renderer.js'
it "both layouts carry the diagram styling"
# Lifted from one layout into the other rather than written twice, so they
# cannot drift.
assert_file_contains "$dg/.dist/post/charted.html" '.diagram svg { width: 100%'
assert_file_contains "$dg/.dist/chart.html" '.diagram svg { width: 100%'
it "the diagram box takes the body column, not its shrink-wrapped width"
# .contents is an inline-block, so a block inside it shrink-wraps to its
# content; the diagram sat small against a full-width column until the
# container was told to take the width.
# margin-block rather than the margin shorthand: the shorthand also sets
# margin-right, which resets the bleed to zero. Measured in Chrome with the
# shorthand in place, the diagram sat at exactly 100% of the column.
assert_file_contains "$dg/.dist/chart.html" '.diagram { margin-block: 1.5em; overflow-x: auto; display: block;'
# width is auto, not 100%: a fixed width pins the box to the column and leaves
# the bleed's negative margin nothing to expand into -- measured in Chrome,
# width:100% held the diagram at exactly 100% of the column. min-width stays 0,
# which is what lets it shrink: without that a narrow screen scrolled the whole
# page sideways instead of scrolling the diagram in its own box. Verified at a
# 375px phone viewport that the page still does not scroll sideways.
assert_file_contains "$dg/.dist/chart.html" 'width: auto; min-width: 0; }'
it "a section heading is marked by a rule above it"
# Headings are body size, which leaves a section heading reading as a stray
# line of capitals. A rule says "new section" structurally, so the type is
# untouched. Top two levels only -- a deeper heading sits inside a section and
# a line of its own would cut it off from one.
# Asserted against both layouts, which must not drift. The first version of
# this test checked only the page layout, so mutating the post layout left it
# green -- the mutation looked survivable when it was simply unobserved.
assert_file_contains "$dg/.dist/chart.html" '.contents :is(h1, h2) {'
assert_file_contains "$dg/.dist/post/charted.html" '.contents :is(h1, h2) {'
# A short mark, not a border. border-top spans the whole box, which drew a
# full-measure line reading as a divider between two halves of the page rather
# than as a mark belonging to the heading beneath it.
assert_file_contains "$dg/.dist/chart.html" 'width: 3ch;'
assert_file_contains "$dg/.dist/chart.html" '.contents :is(h1, h2)::before {'
it "a passage offers a mark linking to itself"
# Ids alone are undiscoverable -- a reader would have to open the source to
# find one. Hovering a heading or paragraph reveals an ordinary link to it.
assert_file_contains "$dg/.dist/chart.html" "a.className = 'anchor-link';"
assert_file_contains "$dg/.dist/post/charted.html" "a.className = 'anchor-link';"
it "the mark is a plain link, not a copy button"
# Following it puts the address in the bar, and right-clicking offers
# copy-link the way it does for every other link on the web. Nothing to learn,
# and nothing to go wrong where the clipboard is unavailable.
assert_file_not_contains "$dg/.dist/chart.html" "navigator.clipboard"
assert_file_not_contains "$dg/.dist/chart.html" "copy-toast"
it "the mark is pinned to the top right of its block"
# Trailing the last word put it wherever the text happened to stop, which is a
# different place on every block and reads as punctuation. A fixed corner is
# somewhere the eye can learn. Measured in Chrome: flush with the block top,
# 8px past its right edge, and no mark overflows the viewport at 700px.
assert_file_contains "$dg/.dist/chart.html" 'position: absolute; top: 0; left: 100%;'
assert_file_contains "$dg/.dist/chart.html" '.contents p[id] { position: relative; }'
it "the mark is hidden until the passage is hovered or the mark focused"
# Otherwise a page of prose is littered with symbols. Focus counts too, or a
# keyboard user could never reach it.
assert_file_contains "$dg/.dist/chart.html" '.anchor-link:focus { opacity: 1; }'
assert_file_contains "$dg/.dist/chart.html" '@media (hover: none) { .anchor-link { display: none; } }'
it "the marked passage paints as one box"
# A spread box-shadow in the same colour as the background painted a second
# region abutting the first, and the two antialiased against each other -- a
# faint darker seam along the inside of the bottom edge. Padding paints once.
# Negative margins hold the text where it was, so marking does not move it.
assert_file_contains "$dg/.dist/chart.html" 'padding: .4em .5em;'
assert_file_not_contains "$dg/.dist/chart.html" 'box-shadow: 0 0 0 .4em var(--bg-secondary);'
it "the note label column has a floor"
# Each note is its own table, so the label column was only as wide as that
# note's own label: "1:" measured 20px and a named note far more, and the text
# of every note began at a different place down the list. The minimum steadies
# the short labels, which are most of them; a long one still pushes past it
# rather than wrapping.
assert_file_contains "$dg/.dist/chart.html" '.fn-handle { padding-right: 1ch; min-width: 4ch; }'
it "a linked passage is centred, not pinned to the top"
# A passage flush with the top edge has no text above it, so nothing shows
# what it follows from. Centred, it arrives with its own context either side.
# Measured in Chrome once the scroll settles: dead centre for an in-page click,
# for an inbound permalink, and for a prose cross-reference.
assert_file_contains "$dg/.dist/chart.html" "block: 'center'"
assert_file_contains "$dg/.dist/post/charted.html" "block: 'center'"
it "an inbound permalink is re-centred after the browser's own jump"
# Arriving at .../page#id, the browser has already put the target at the top
# by the time this runs, so it has to be moved rather than merely marked --
# and a frame later, once fonts and diagrams have settled the height above it.
assert_file_contains "$dg/.dist/chart.html" "requestAnimationFrame(function() { showTarget(initial, false); });"
it "every in-page link is centred, not just the marks"
# The handler is delegated on the document, so a cross-reference written in
# the prose behaves the same way as a deep-link mark.
assert_file_contains "$dg/.dist/chart.html" "a[href^=\"#\"]"
it "following a permalink marks what was linked"
# A reader landing mid-document needs to see which passage was meant.
assert_file_contains "$dg/.dist/chart.html" "t.classList.add('target-flash');"
it "the anchors wait for the document"
# The script tag sits in the body; the first version ran before the content
# existed and silently added no marks at all.
assert_file_contains "$dg/.dist/chart.html" "document.addEventListener('DOMContentLoaded', initAnchors);"
it "a section heading is bold"
# Every heading is body size, so weight is what separates a section heading
# from the prose around it. h1 as well as h2: a page's title repeat is an h1,
# and so is a second top-level heading an author writes, so bolding h2 alone
# would leave those lighter than their siblings. Deeper levels stay regular --
# they sit inside a section rather than opening one.
assert_file_contains "$dg/.dist/chart.html" '.contents :is(h1, h2) { font-weight: bold; }'
assert_file_contains "$dg/.dist/post/charted.html" '.contents :is(h1, h2) { font-weight: bold; }'
it "the body's repeat of the page title carries no rule"
# A page repeats its title as the body's opening h1, and a rule there draws a
# line immediately under the title in the header. An h1 with no heading before
# it is exactly that repeat. Two earlier selectors got this wrong: :first-child
# missed it because a p.meta precedes it, and "only rule a heading that follows
# another heading" fixed pages while breaking posts -- a post's title is lifted
# into the header, so its opening h2 lost its rule and the first section read
# as preamble. Verified in Chrome on both layouts.
assert_file_contains "$dg/.dist/chart.html" '.contents h1:not(:is(h1, h2) ~ *)::before { content: none; }'
assert_file_contains "$dg/.dist/post/charted.html" '.contents h1:not(:is(h1, h2) ~ *)::before { content: none; }'
it "diagram sources are kept so the theme can be changed"
# mermaid resolves the palette into the svg it writes and then replaces the
# source text with that svg. A diagram drawn under one theme therefore keeps
# that theme's colours after a switch -- dark node fills and pale labels on a
# light page -- because only the CSS variables changed. There is nothing in the
# rendered svg to recolour selectively, so the source is stashed first and the
# diagram is drawn again.
assert_file_contains "$dg/.dist/chart.html" "sources[i] = el.textContent;"
assert_file_contains "$dg/.dist/post/charted.html" "sources[i] = el.textContent;"
it "a theme change redraws the diagrams"
# The observer watches data-theme on the root, which is what the theme script
# sets. Verified in Chrome across four toggles: node fill tracked the theme
# every time and all three diagrams survived.
assert_file_contains "$dg/.dist/chart.html" "records[i].attributeName === 'data-theme'"
it "the palette is read at render time, not once at load"
# getComputedStyle cached into a closure would keep handing back the values
# from first paint, so the redraw would repaint the diagram in the old colours.
assert_file_contains "$dg/.dist/chart.html" "getComputedStyle(document.documentElement).getPropertyValue(n)"
it "an unrendered diagram degrades to readable source"
# If the script is absent or blocked, the element still holds what the author
# wrote. Shown as the code it is rather than as a paragraph with the newlines
# collapsed.
assert_file_contains "$dg/.dist/chart.html" '.diagram:not(:has(svg)) { white-space: pre;'
it "the build is clean"
assert_eq "$stderr_output" ""
suite "MMD metadata end to end"
it "a metadata pair survives into the rendered post"
mt="$TMPROOT/metadata"
make_blog_min "$mt" 2 3
printf 'Series: Web Typography\n\n# Meta Post\n\nBody.\n' > "$mt/2024-02-01 meta.md"
run_jenny_split "$mt"
assert_file_contains "$mt/.dist/post/meta.html" 'data-key="Series"'
it "the value is addressable"
assert_file_contains "$mt/.dist/post/meta.html" 'data-value="Web Typography"'
it "the original text is not stripped"
assert_file_contains "$mt/.dist/post/meta.html" '>Series: Web Typography</p>'
it "the default theme hides it"
assert_file_contains "$mt/.dist/post/meta.html" '.meta { display: none; }'
it "the build is clean"
assert_eq "$stderr_output" ""
it "the post title still comes from the heading"
assert_file_contains "$mt/.dist/index.html" ">Meta Post<"
it "the tag line is preserved rather than deleted"
tg="$TMPROOT/metatags"
make_blog_min "$tg" 1 3
printf 'tags: alpha beta\n\n# Tagged\n\nBody.\n' > "$tg/2024-02-01 tagged.md"
run_jenny "$tg"
assert_file_contains "$tg/.dist/post/tagged.html" 'data-key="tags"'
it "tag indexes are still built from it"
assert_file_exists "$tg/.dist/tag/alpha/index.html"
it "the second tag also indexes"
assert_file_exists "$tg/.dist/tag/beta/index.html"
it "tags are not rendered twice visibly"
assert_eq "$(grep -c 'alpha beta' "$tg/.dist/post/tagged.html")" "1"
it "metadata reaches the layout as a variable"
lp="$TMPROOT/metalayout"
make_blog_min "$lp" 1 3
printf 'Series: Web Typography\n\n# Probe\n\nBody.\n' > "$lp/2024-02-01 probe.md"
mkdir -p "$lp/lay"
cp "$REPO_ROOT/share/jenny/layout/index.sh" "$REPO_ROOT/share/jenny/layout/rss2.sh" "$lp/lay/"
printf '#!/usr/bin/env bash\necho "SERIES=[${META_SERIES:-unset}]"\n' > "$lp/lay/post.sh"
chmod +x "$lp/lay/post.sh"
echo 'LAYOUT_DIR=./lay' >> "$lp/.blogrc"
run_jenny "$lp"
assert_file_contains "$lp/.dist/post/probe.html" "SERIES=[Web Typography]"
it "a post without metadata leaves the variable unset"
printf '# Plain\n\nBody.\n' > "$lp/2024-02-02 plain.md"
run_jenny "$lp"
assert_file_contains "$lp/.dist/post/plain.html" "SERIES=[unset]"
suite "tag pagination stays inside the tag"
it "a tag with more than one page paginates"
tp="$TMPROOT/tagpaged"
make_blog_min "$tp" 20 8
for f in "$tp"/2024-01-*.md; do
printf 'tags: alpha\n\n%s' "$(cat "$f")" > "$f.tmp" && mv "$f.tmp" "$f"
done
run_jenny_split "$tp"
assert_file_exists "$tp/.dist/tag/alpha/page/1.html"
it "the build is clean"
assert_eq "$stderr_output" ""
it "the tag index OLDER link stays in the tag"
older=$(grep -o '<a href="[^"]*">[^<]*OLDER[^<]*</a>' "$tp/.dist/tag/alpha/index.html" \
| grep -o 'href="[^"]*"' || true)
assert_contains "$older" "/tag/alpha/"
it "a tag page NEWER link stays in the tag"
newer=$(grep -o '<a href="[^"]*">[^<]*NEWER[^<]*</a>' "$tp/.dist/tag/alpha/page/1.html" \
| grep -o 'href="[^"]*"' || true)
assert_contains "$newer" "/tag/alpha/"
it "the main archive nav does not point into a tag"
mainnav=$(grep -o '<a href="[^"]*">[^<]*\(NEWER\|OLDER\)[^<]*</a>' "$tp/.dist/index.html" \
| grep -o 'href="[^"]*"' || true)
assert_not_contains "$mainnav" "/tag/"
it "every link still resolves"
broken=$(check_links "$tp/.dist") && pass || fail "broken links:
$broken"
suite "POSTS_PER_PAGE default"
it "a .blogrc with no POSTS_PER_PAGE gets 10"
dflt="$TMPROOT/default-ppp"
rm -rf "$dflt"; mkdir -p "$dflt"
cat > "$dflt/.blogrc" <<'EOF'
BLOG_HOST="http://localhost:8000"
BLOG_TITLE="Default PPP"
DIST=$PWD/.dist
EOF
i=1
while [ "$i" -le 12 ]; do
n=$(printf '%02d' "$i")
printf '# Post %s\n\nBody.\n' "$n" > "$dflt/2024-01-$n post-$n.md"
i=$((i + 1))
done
run_jenny_split "$dflt"
assert_eq "$(index_post_count "$dflt/.dist/index.html")" "10"
it "the build is clean"
assert_eq "$stderr_output" ""
it "12 posts at the default paginates"
assert_file_exists "$dflt/.dist/page/1.html"
it "an explicit POSTS_PER_PAGE still wins"
echo 'POSTS_PER_PAGE=3' >> "$dflt/.blogrc"
run_jenny "$dflt" -n
assert_eq "$(index_post_count "$dflt/.dist/index.html")" "3"
suite "404 page"
it "a build produces a 404 page"
nf="$TMPROOT/notfound"
make_blog_min "$nf" 3 10
run_jenny_split "$nf"
assert_file_exists "$nf/.dist/404.html"
it "the build is clean"
assert_eq "$stderr_output" ""
it "it carries the blog title"
assert_file_contains "$nf/.dist/404.html" "Test Blog"
it "it says the page was not found"
assert_file_contains "$nf/.dist/404.html" "not"
it "it links back to the index"
assert_file_contains "$nf/.dist/404.html" 'href="/"'
it "it is valid standalone HTML"
assert_file_contains "$nf/.dist/404.html" "<!DOCTYPE html>"
it "a blog title with markup is escaped"
esc="$TMPROOT/notfound-esc"
make_blog_min "$esc" 2 10
"$SED" -i 's/BLOG_TITLE=.*/BLOG_TITLE="A \& B <x>"/' "$esc/.blogrc" 2>/dev/null \
|| sed -i 's/BLOG_TITLE=.*/BLOG_TITLE="A \& B <x>"/' "$esc/.blogrc"
run_jenny "$esc"
assert_file_contains "$esc/.dist/404.html" "A &amp; B &lt;x&gt;"
it "a custom LAYOUT_DIR without a 404.sh still builds"
nol="$TMPROOT/notfound-nolayout"
make_blog_min "$nol" 2 10
mkdir -p "$nol/lay"
cp "$REPO_ROOT/share/jenny/layout/index.sh" "$REPO_ROOT/share/jenny/layout/post.sh" \
"$REPO_ROOT/share/jenny/layout/rss2.sh" "$nol/lay/"
echo 'LAYOUT_DIR=./lay' >> "$nol/.blogrc"
run_jenny_split "$nol"
assert_success "$status" "$output"
it "and is silent about the missing 404 layout"
assert_eq "$stderr_output" ""
suite "relative time on hover"
it "the stamp carries a machine-readable date"
rt="$TMPROOT/reltime"
make_blog_min "$rt" 3 10
run_jenny_split "$rt"
assert_file_contains "$rt/.dist/index.html" 'data-iso="'
it "the build is clean"
assert_eq "$stderr_output" ""
it "the absolute date is still the rendered text"
assert_file_contains "$rt/.dist/index.html" '>01/03/2024<'
it "the stamp is wrapped so the swap can be masked"
assert_file_contains "$rt/.dist/index.html" 'class="stamp"'
it "the relative-time script is present"
assert_file_contains "$rt/.dist/index.html" 'data-iso'
it "the script is inline, not an external request"
assert_file_not_contains "$rt/.dist/index.html" '<script src='
it "the date attribute parses as a date"
iso=$(grep -o 'data-iso="[^"]*"' "$rt/.dist/index.html" | head -1 | sed 's/data-iso="//;s/"//')
if date -d "$iso" >/dev/null 2>&1; then pass; else fail "unparseable: $iso"; fi
it "a post carries the relative-time script too"
# The same hover-swap stamp the index has. The script is shared rather than
# copied: two definitions of "1 day ago" would drift apart.
assert_file_contains "$rt/.dist/post/post-01.html" 'data-iso'
assert_file_contains "$rt/.dist/post/post-01.html" '<span class="stamp-rel"'
assert_file_contains "$rt/.dist/post/post-01.html" "stamp[data-iso]"
it "the hover target is the heading's own anchor"
# On the index the swap keys off .post-link; a post has no such wrapper, so
# the rules are rescoped to the anchor that holds the stamp and the title.
assert_file_contains "$rt/.dist/post/post-01.html" '.heading a:hover .stamp[data-rel] .stamp-abs'
it "the gutter's copy of the stamp is left alone"
# It is a hidden element whose only job is to be the width of the real stamp.
# Filled with a relative string it would measure differently from the date it
# stands in for.
gutter=$("$GREP" -o '<div class="gutter"[^>]*>[^<]*<span class="stamp"[^>]*>' "$rt/.dist/post/post-01.html" | head -1)
assert_not_contains "$gutter" 'data-iso'
it "a page has no stamp to animate"
# Pages carry no date, so neither the markup nor the script is emitted.
mkdir -p "$rt/pages"
printf '# Plain\n\nNo date here.\n' > "$rt/pages/plain.md"
run_jenny "$rt"
assert_file_not_contains "$rt/.dist/plain.html" 'data-iso'
assert_file_not_contains "$rt/.dist/plain.html" "stamp[data-iso]"
suite "pages/ mirrors its path to the URL"
it "a page renders at its path"
pg="$TMPROOT/pages"
make_blog_min "$pg" 2 10
mkdir -p "$pg/pages/study"
printf '# Consciousness\n\nA long-lived document.\n' > "$pg/pages/study/consciousness.md"
run_jenny_split "$pg"
assert_file_exists "$pg/.dist/study/consciousness.html"
it "the build is clean"
assert_eq "$stderr_output" ""
it "a page at the pages root lands at the site root"
printf '# About\n\nWho.\n' > "$pg/pages/about.md"
run_jenny "$pg"
assert_file_exists "$pg/.dist/about.html"
it "nesting is preserved to any depth"
mkdir -p "$pg/pages/notes/method"
printf '# Intro\n\nText.\n' > "$pg/pages/notes/method/intro.md"
run_jenny "$pg"
assert_file_exists "$pg/.dist/notes/method/intro.html"
it "a page needs no date prefix"
assert_file_contains "$pg/.dist/study/consciousness.html" "Consciousness"
it "pages stay out of the chronological index"
assert_file_not_contains "$pg/.dist/index.html" "Consciousness"
it "pages stay out of the feed"
assert_file_not_contains "$pg/.dist/feed.xml" "Consciousness"
it "posts are unaffected"
assert_eq "$(index_post_count "$pg/.dist/index.html")" "2"
it "a page is rebuilt when its content changes"
printf '# Consciousness\n\nRevised text here.\n' > "$pg/pages/study/consciousness.md"
run_jenny "$pg"
assert_file_contains "$pg/.dist/study/consciousness.html" "Revised text here."
it "a removed page is swept"
rm "$pg/pages/about.md"
run_jenny "$pg"
assert_file_absent "$pg/.dist/about.html"
it "sweeping a page leaves the others alone"
assert_file_exists "$pg/.dist/study/consciousness.html"
it "a page cannot escape DIST via its path"
mkdir -p "$pg/pages/../evil-src"
printf '# Evil\n\nx\n' > "$pg/pages/../evil-src/evil.md"
run_jenny "$pg"
assert_file_absent "$pg/../evil.html"
it "every link still resolves"
broken=$(check_links "$pg/.dist") && pass || fail "broken links:
$broken"
# `date -d ""` returns today rather than failing, so a page rendered through
# post.sh stamped the build date -- a number that changes on every rebuild
# while the document does not. Actively misleading on long-lived work.
it "a page carries no date stamp"
assert_file_not_contains "$pg/.dist/study/consciousness.html" '<span class="stamp">'
it "a post still carries its date stamp"
assert_file_contains "$pg/.dist/post/post-01.html" '<span class="stamp">'
it "a page heading is flagged so the indent can be dropped"
assert_file_contains "$pg/.dist/study/consciousness.html" 'heading heading-page'
suite "pages use their own layout with breadcrumbs"
it "a nested page shows every path segment as a crumb"
crumbs=$(sed -n '/<header>/,/<\/header>/p' "$pg/.dist/notes/method/intro.html")
assert_contains "$crumbs" ">notes<"
it "the middle segment appears too"
assert_contains "$crumbs" ">method<"
it "the page itself is the last crumb"
assert_contains "$crumbs" ">intro<"
it "only the page itself is marked current"
assert_eq "$(printf '%s' "$crumbs" | grep -c 'class="current"')" "1"
it "the current crumb is the page, not a parent"
assert_contains "$crumbs" 'class="current">intro<'
it "a root-level page has a single crumb"
rootcrumbs=$(sed -n '/<header>/,/<\/header>/p' "$pg/.dist/study/consciousness.html")
assert_contains "$rootcrumbs" 'class="current">consciousness<'
it "a hyphenated directory reads as words"
mkdir -p "$pg/pages/long-form"
printf '# Piece\n\nText.\n' > "$pg/pages/long-form/piece.md"
run_jenny "$pg"
assert_file_contains "$pg/.dist/long-form/piece.html" ">long form<"
it "posts keep the post layout and their date"
assert_file_contains "$pg/.dist/post/post-01.html" '<span class="stamp">'
it "posts get no breadcrumbs"
assert_file_not_contains "$pg/.dist/post/post-01.html" 'PAGE_CRUMBS'
it "a custom LAYOUT_DIR without page.sh still renders pages"
nop="$TMPROOT/nopagelayout"
make_blog_min "$nop" 2 10
mkdir -p "$nop/lay" "$nop/pages"
cp "$REPO_ROOT/share/jenny/layout/index.sh" "$REPO_ROOT/share/jenny/layout/post.sh" \
"$REPO_ROOT/share/jenny/layout/rss2.sh" "$nop/lay/"
echo 'LAYOUT_DIR=./lay' >> "$nop/.blogrc"
printf '# Fallback\n\nText.\n' > "$nop/pages/fallback.md"
run_jenny_split "$nop"
assert_file_exists "$nop/.dist/fallback.html"
it "and does so without complaint"
assert_eq "$stderr_output" ""
it "a page has no link back to the index"
# A page is not listed on the index, so an arrow back to it points at a place
# the reader did not come from -- the same reason the heading has no arrow.
# The breadcrumb in the header is what a page navigates by.
assert_file_not_contains "$pg/.dist/study/consciousness.html" 'class="home"'
it "a blog with only pages and no posts still builds"
only="$TMPROOT/onlypages"
rm -rf "$only"; mkdir -p "$only/pages"
cat > "$only/.blogrc" <<'EOF'
BLOG_HOST="http://localhost:8000"
BLOG_TITLE="Pages Only"
DIST=$PWD/.dist
EOF
printf '# Solo\n\nText.\n' > "$only/pages/solo.md"
run_jenny "$only"
assert_file_exists "$only/.dist/solo.html"
suite "nav registries"
it "a page with no Nav: renders without one"
nv="$TMPROOT/navs"
make_blog_min "$nv" 2 10
mkdir -p "$nv/pages/study" "$nv/navs"
printf '# Plain\n\nNo nav here.\n' > "$nv/pages/study/plain.md"
run_jenny_split "$nv"
assert_file_exists "$nv/.dist/study/plain.html"
it "the build is clean"
assert_eq "$stderr_output" ""
it "a page declaring a nav renders its entries"
printf 'Introduction\t/study/intro\nConsciousness\t/study/consciousness\n' > "$nv/navs/study-toc.tsv"
printf 'Nav: study-toc\n\n# Has Nav\n\nText.\n' > "$nv/pages/study/hasnav.md"
run_jenny_split "$nv"
assert_file_contains "$nv/.dist/study/hasnav.html" "Introduction"
it "the second entry renders too"
assert_file_contains "$nv/.dist/study/hasnav.html" "Consciousness"
it "entries link to their paths"
assert_file_contains "$nv/.dist/study/hasnav.html" 'href="../study/intro"'
it "the build stays clean with a nav"
assert_eq "$stderr_output" ""
it "a nav from a deeper page resolves relatively"
mkdir -p "$nv/pages/a/b"
printf 'Nav: study-toc\n\n# Deep\n\nText.\n' > "$nv/pages/a/b/deep.md"
run_jenny "$nv"
assert_file_contains "$nv/.dist/a/b/deep.html" 'href="../../study/intro"'
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"
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"
it "a label containing markup is escaped"
printf 'A & B <x>\t/ab\n' > "$nv/navs/esc.tsv"
printf 'Nav: esc\n\n# Esc\n\nText.\n' > "$nv/pages/esc.md"
run_jenny "$nv"
assert_file_contains "$nv/.dist/esc.html" "A &amp; B &lt;x&gt;"
it "a missing nav file fails the build"
printf 'Nav: nonexistent\n\n# Missing\n\nText.\n' > "$nv/pages/missing.md"
run_jenny "$nv"
assert_failure "$status" "$output"
it "the failure names the nav that was not found"
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'
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"
it "the main index emits theme CSS variables"
dm="$TMPROOT/darkmode"
make_blog "$dm" 3
run_jenny_split "$dm"
assert_file_contains "$dm/.dist/index.html" '--bg-primary'
assert_file_contains "$dm/.dist/index.html" '--text-primary'
it "the index CSS uses prefers-color-scheme for no-JS fallback"
assert_file_contains "$dm/.dist/index.html" '@media (prefers-color-scheme: dark)'
it "the index has a theme toggle button with accessible name"
assert_file_contains "$dm/.dist/index.html" 'class="theme-toggle"'
assert_file_contains "$dm/.dist/index.html" 'aria-label="Toggle dark mode"'
it "the toggle button uses an entity glyph, not raw Unicode"
# U+25D0, the half-filled circle that conventionally means contrast. The
# variation selector matters as much as the codepoint: without &#xFE0E; a
# platform is free to render the mark as a colour emoji, which is not what a
# control set in the page's own type should look like.
assert_file_not_contains "$dm/.dist/index.html" '◐'
assert_file_contains "$dm/.dist/index.html" '&#x25D0;&#xFE0E;'
it "the theme script applies data-theme and uses localStorage"
assert_file_contains "$dm/.dist/index.html" 'data-theme'
assert_file_contains "$dm/.dist/index.html" 'localStorage.setItem'
it "a post page emits theme CSS variables"
assert_file_contains "$dm/.dist/post/post-01.html" '--bg-primary'
assert_file_contains "$dm/.dist/post/post-01.html" '--text-primary'
it "a post page CSS uses prefers-color-scheme"
assert_file_contains "$dm/.dist/post/post-01.html" '@media (prefers-color-scheme: dark)'
it "a post page has a theme toggle with accessible name"
assert_file_contains "$dm/.dist/post/post-01.html" 'class="theme-toggle"'
assert_file_contains "$dm/.dist/post/post-01.html" 'aria-label="Toggle dark mode"'
it "the 404 page emits theme CSS variables"
assert_file_contains "$dm/.dist/404.html" '--bg-primary'
assert_file_contains "$dm/.dist/404.html" '--text-primary'
it "the 404 page CSS uses prefers-color-scheme"
assert_file_contains "$dm/.dist/404.html" '@media (prefers-color-scheme: dark)'
it "the 404 page has a theme toggle with accessible name"
assert_file_contains "$dm/.dist/404.html" 'class="theme-toggle"'
assert_file_contains "$dm/.dist/404.html" 'aria-label="Toggle dark mode"'
it "the tag index emits theme CSS variables"
assert_file_contains "$dm/.dist/tag/index.html" '--bg-primary'
assert_file_contains "$dm/.dist/tag/index.html" '--text-primary'
it "the tag index CSS uses prefers-color-scheme"
assert_file_contains "$dm/.dist/tag/index.html" '@media (prefers-color-scheme: dark)'
it "the tag index has a theme toggle with accessible name"
assert_file_contains "$dm/.dist/tag/index.html" 'class="theme-toggle"'
assert_file_contains "$dm/.dist/tag/index.html" 'aria-label="Toggle dark mode"'
it "no layout file contains raw non-ASCII bytes"
if grep -P '[^\x00-\x7F]' "$REPO_ROOT/share/jenny/layout"/*.sh 2>/dev/null; then
fail "found non-ASCII bytes in layout files (which emit HTML)"
else
pass
fi
it "all colour definitions use CSS variables"
if grep -E 'color:\s*(#|rgb)' "$REPO_ROOT/share/jenny/layout"/*.sh "$REPO_ROOT/share/jenny/lib/helpers.sh" 2>/dev/null | grep -v ':\s*var(--' >/dev/null; then
fail "found hardcoded colors not wrapped in var()"
else
pass
fi
it "localStorage.setItem only called inside the click delegation handler"
if sed -n '/<script>/,/<\/script>/p' "$REPO_ROOT/share/jenny/layout/index.sh" | grep -v 'document.addEventListener.*click\|localStorage.setItem.*theme.*newTheme' | grep -q 'localStorage.setItem'; then
fail "localStorage.setItem called outside delegated click handler"
else
pass
fi
it "toggle handler uses event delegation (document.addEventListener), not querySelector"
if sed -n '/<script>/,/<\/script>/p' "$REPO_ROOT/share/jenny/layout/index.sh" | grep -q 'document.addEventListener.*click' && sed -n '/<script>/,/<\/script>/p' "$REPO_ROOT/share/jenny/layout/index.sh" | grep -q 'e.target.closest'; then
pass
else
fail "script does not use event delegation for toggle handler"
fi
it "toggle handler finds buttons via closest(.theme-toggle), not direct selection"
if sed -n '/<script>/,/<\/script>/p' "$REPO_ROOT/share/jenny/layout/index.sh" | grep -q "e.target.closest('.theme-toggle')"; then
pass
else
fail "script does not use e.target.closest to find toggle button"
fi
it "all theme scripts listen for system preference changes"
if grep -q 'mediaQuery.addEventListener.*change\|mediaQuery.addListener' "$REPO_ROOT/share/jenny/layout/index.sh" 2>/dev/null; then
pass
else
fail "script does not listen for matchMedia change event"
fi
it "theme follows system preference when no saved preference exists"
if sed -n '/<script>/,/<\/script>/p' "$REPO_ROOT/share/jenny/layout/index.sh" | grep -q '!localStorage.getItem.*theme'; then
pass
else
fail "script does not check if localStorage preference exists before applying system change"
fi
it "post page uses event delegation for toggle"
if sed -n '/<script>/,/<\/script>/p' "$REPO_ROOT/share/jenny/layout/post.sh" | grep -q "document.addEventListener.*click" && sed -n '/<script>/,/<\/script>/p' "$REPO_ROOT/share/jenny/layout/post.sh" | grep -q "e.target.closest('.theme-toggle')"; then
pass
else
fail "post.sh does not use event delegation for toggle"
fi
it "404 page uses event delegation for toggle"
if sed -n '/<script>/,/<\/script>/p' "$REPO_ROOT/share/jenny/layout/404.sh" | grep -q "document.addEventListener.*click" && sed -n '/<script>/,/<\/script>/p' "$REPO_ROOT/share/jenny/layout/404.sh" | grep -q "e.target.closest('.theme-toggle')"; then
pass
else
fail "404.sh does not use event delegation for toggle"
fi
it "page layout uses event delegation for toggle"
if sed -n '/<script>/,/<\/script>/p' "$REPO_ROOT/share/jenny/layout/page.sh" | grep -q "document.addEventListener.*click" && sed -n '/<script>/,/<\/script>/p' "$REPO_ROOT/share/jenny/layout/page.sh" | grep -q "e.target.closest('.theme-toggle')"; then
pass
else
fail "page.sh does not use event delegation for toggle"
fi
it "tag index uses event delegation for toggle"
if sed -n '/<script>/,/<\/script>/p' "$REPO_ROOT/share/jenny/lib/helpers.sh" | grep -q "document.addEventListener.*click" && sed -n '/<script>/,/<\/script>/p' "$REPO_ROOT/share/jenny/lib/helpers.sh" | grep -q "e.target.closest('.theme-toggle')"; then
pass
else
fail "helpers.sh tag index does not use event delegation for toggle"
fi
suite "page nav sidebar layout"
nav_sb="$TMPROOT/navsidebar"
make_blog_min "$nav_sb" 2 10
mkdir -p "$nav_sb/pages" "$nav_sb/navs"
mkdir -p "$nav_sb/pages/plain"
printf 'Item\t/item\n' > "$nav_sb/navs/side.tsv"
printf 'Nav: side\n\n# Title\n\nBody.\n' > "$nav_sb/pages/test.md"
printf '# Plain\n\nNo nav.\n' > "$nav_sb/pages/plain.md"
run_jenny_split "$nav_sb"
# These assert structure and mechanism rather than literal declarations.
# Several earlier assertions here pinned exact strings -- "width: 16em",
# "align-items: center", "position: absolute" -- and every one of them passed
# against a layout that was visibly broken; "position: absolute" matched the
# theme toggle elsewhere in the file and never looked at the nav at all. What
# actually has to hold is that the nav and the body are siblings in one
# container, that the sidebar and the body share a table row so their tops
# line up without a measured offset, and that a page with no nav falls back to
# the post's own indent mechanism.
it "the nav and the body are siblings inside .page-wrapper"
assert_file_contains "$nav_sb/.dist/test.html" '<div class="page-wrapper">'
assert_file_contains "$nav_sb/.dist/test.html" '<nav class="page-nav">'
it "the nav is not nested inside .contents"
assert_file_not_contains "$nav_sb/.dist/test.html" '<div class="contents">.*<nav'
it "the sidebar and the body are cells of one table row"
# This is what makes the title and the first nav item line up: they share a
# row, so the browser aligns them. Asserting the row is asserting the
# alignment -- an offset-based version would satisfy a "tops match" check at
# one heading height and drift at every other.
# Flexbox, matching the post layout: one mechanism for the same two-column
# problem rather than tables here and flex there.
assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper { display: flex; align-items: flex-start; }'
assert_file_contains "$nav_sb/.dist/test.html" '.contents-cell { flex: 1 1 auto; min-width: 0;'
it "the sidebar is a table cell aligned to the top of its row"
# The cell is the <details> wrapper now: the nav is collapsible on a phone, so
# the sidebar column belongs to the element that holds the summary as well as
# the list.
# align-items: flex-start on the row does what vertical-align: top did on the
# cell -- the sidebar sits at the top rather than centring against the body.
assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper { display: flex; align-items: flex-start; }'
it "the sidebar column does not depend on its labels"
# An auto-layout table treats a declared width as a suggestion and shrinks the
# column to its content, which moved the body's left edge whenever a label
# changed. The floor is what keeps the column fixed.
# "flex: 0 0 <basis>" is the whole statement now: neither grow nor shrink, so
# the width/min-width/max-width trio the table version needed collapses.
assert_file_contains "$nav_sb/.dist/test.html" 'flex: 0 0 var(--page-nav-width)'
it "the sidebar carries no positioning offset"
# The nav used to be positioned absolutely and pushed down by a measured
# amount that had to be re-measured whenever anything above it changed.
assert_file_not_contains "$nav_sb/.dist/test.html" '.page-nav { position: absolute'
it "a page with no nav takes the body indent, not the sidebar column"
assert_file_contains "$nav_sb/.dist/plain.html" '.page-wrapper:not(:has(.page-nav-wrap))'
it "the body starts at the left margin, with no column reserved"
# The indent existed only to make room for the back arrow beside the body.
# With the arrow below the text there is nothing to clear, so the text uses the
# full measure -- on a 375px phone the reserved column cost a quarter of the
# line, 42 characters down to 32.
assert_file_not_contains "$nav_sb/.dist/plain.html" 'home-spacer'
# The spacing moved from .home to the footer row that now holds it.
assert_file_contains "$nav_sb/.dist/plain.html" '.site-footer { display: flex; align-items: baseline;'
it "a sidebar widens the article rather than narrowing the body"
# 92ch is sized for a post: arrow column plus a 78ch body. Letting the sidebar
# eat into it made the body narrower on a page than on a post -- a difference
# with no cause the reader could see.
assert_file_contains "$nav_sb/.dist/test.html" 'article:has(.page-nav-wrap) { max-width: calc(92ch'
it "the body column is declared identically to a post's"
# The user's constraint: the body of a page should differ from the body of a
# post in no way at all. Compare the declarations rather than trusting that
# they look similar.
# Anchored to the start of the declaration so a descendant rule such as
# ".page-wrapper:not(:has(.page-nav)) .contents { ... }" is not mistaken for
# the base rule -- one such override was what made the no-nav body differ.
page_contents=$("$GREP" -o '^ \.contents { [^}]*}' "$nav_sb/.dist/test.html" | head -1)
post_file=$(find "$nav_sb/.dist/post" -name '*.html' | head -1)
post_contents=$("$GREP" -o '^ \.contents { [^}]*}' "$post_file" | head -1)
assert_eq "$page_contents" "$post_contents"
it "the heading holds no reserved column either"
# Nothing sits beside the title any more, so it starts where its body starts
# without a spacer to keep in step.
assert_file_contains "$nav_sb/.dist/plain.html" 'class="heading heading-page"><h1 class="title">'
it "the heading has no breakpoint of its own"
# A post has no media query at all -- heading and body collapse together by
# wrapping. Forcing the page heading to block at 735px un-indented the title
# while the body was still indented, splitting the two for one step of the
# range.
narrow_head=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$nav_sb/.dist/plain.html")
assert_not_contains "$narrow_head" '.heading-page { margin-left: 0; display: block'
it "a post ends with the way back"
# The post side was unasserted: deleting the link from post.sh entirely broke
# no test, because every other check here reads a page.
post_file_bk=$(find "$nav_sb/.dist/post" -name '*.html' | head -1)
assert_file_contains "$post_file_bk" 'class="home">'
it "a post's back link comes after its body"
tl=$("$GREP" -n 'class="tags"' "$post_file_bk" | head -1 | cut -d: -f1)
hl=$("$GREP" -n 'class="home"' "$post_file_bk" | head -1 | cut -d: -f1)
[ -n "$hl" ] && [ "$hl" -gt "$tl" ] \
&& pass "post back link follows the content" \
|| fail "post back link at line $hl must come after the body at line $tl"
it "the back link names where it goes"
# A bare arrow says only "away". It names the destination -- "index", not the
# site title, which the header already carries two lines above.
assert_file_contains "$post_file_bk" '&#x2190;&#xFE0E; Index</a>'
assert_file_not_contains "$post_file_bk" '&#x2190;&#xFE0E; Test Blog'
it "the back link is quieter than the body it follows"
# It is an exit, not part of the reading. Set smaller and in caps, the way the
# theme marks its other small labels.
assert_file_contains "$post_file_bk" 'font-size: .85em; text-transform: uppercase;'
it "posts and pages declare the same back link"
# The whole point of moving it: both layouts end the same way. Compare the
# declarations rather than trusting they look alike.
page_home=$("$GREP" -o '\.home { display: block[^}]*}' "$nav_sb/.dist/plain.html" | head -1)
post_home=$("$GREP" -o '\.home { display: block[^}]*}' "$(find "$nav_sb/.dist/post" -name '*.html' | head -1)" | head -1)
assert_eq "$page_home" "$post_home"
it "narrow screens collapse the whole table, not just one cell"
# A lone cell reverting to block still gets an anonymous table wrapper and
# stays in its column, so all three have to leave table layout together.
narrow=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$nav_sb/.dist/test.html")
# One rule undoes the row; a flex container reverting to block takes its
# children with it, so the cells need no rules of their own.
assert_contains "$narrow" '.page-wrapper { display: block; }'
assert_contains "$narrow" '.page-nav-wrap { flex: none; }'
it "the narrow sidebar releases its fixed column"
# All three of width, min-width and max-width. The ceiling was the one left
# behind before: the nav stayed 96px wide on a phone while the rest of the page
# went full width, so every label wrapped at an 11px indent.
assert_contains "$narrow" 'min-width: 0'
assert_contains "$narrow" 'max-width: none'
it "the nav is a details element, so the toggle needs no script to work"
# <details> brings its own open/closed state, keyboard handling and screen
# reader semantics. A nav that needs JavaScript to open is a nav that does not
# open when the script fails.
assert_file_contains "$nav_sb/.dist/test.html" '<details class="page-nav-wrap" open>'
assert_file_contains "$nav_sb/.dist/test.html" '<summary class="page-nav-toggle">'
it "it ships open and is closed on a phone by script"
# Shipping open means a wide screen renders the sidebar it always did, and a
# script failure leaves an expanded nav rather than one the reader cannot
# reach. Overriding a closed <details> from CSS was tried first and does not
# work -- a closed details suppresses painting of its contents whatever
# display says, so the sidebar measured correct and rendered blank.
assert_file_contains "$nav_sb/.dist/test.html" "matchMedia('(max-width: 735px)')"
assert_file_contains "$nav_sb/.dist/test.html" "navWrap.removeAttribute('open')"
it "the collapse waits for the nav to exist"
# The script is in <head>, so it first ran before the nav was parsed: the
# query found nothing and the nav stayed open on a phone.
assert_file_contains "$nav_sb/.dist/test.html" "document.addEventListener('DOMContentLoaded', syncNav)"
it "the breakpoint is watched, not sampled once"
# Sampling at load left the attribute stale when the viewport crossed 735px
# afterwards: collapse the nav on a phone, then rotate to landscape or widen
# the window, and the sidebar stayed closed -- and a closed <details> paints
# nothing, so the desktop column rendered blank.
assert_file_contains "$nav_sb/.dist/test.html" "navMQ.addEventListener('change', syncNav)"
assert_file_contains "$nav_sb/.dist/test.html" "navWrap.setAttribute('open', '')"
it "re-collapsing does not fight the reader"
# Only on the way in to narrow: without the flag, every resize would shut a nav
# the reader had just opened.
assert_file_contains "$nav_sb/.dist/test.html" 'navWrap.dataset.narrow'
it "the toggle looks like something to tap"
# The default disclosure marker did not draw -- the summary's text began at
# x=0 with nothing before it -- so "CONTENTS" read as a heading. A chevron of
# our own replaces it, pointing right when closed and down when open.
assert_file_contains "$nav_sb/.dist/test.html" '.page-nav-toggle::before { content: ""'
assert_file_contains "$nav_sb/.dist/test.html" 'transform: rotate(-45deg)'
assert_file_contains "$nav_sb/.dist/test.html" '.page-nav-wrap[open] > .page-nav-toggle::before { transform: rotate(45deg); }'
it "the chevron holds still for a reader who asked for that"
assert_file_contains "$nav_sb/.dist/test.html" '@media (prefers-reduced-motion: reduce)'
it "the summary is hidden on a wide screen"
# The toggle is a phone affordance; beside a sidebar that already fits it is
# noise.
assert_file_contains "$nav_sb/.dist/test.html" '.page-nav-toggle { display: none; }'
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" ""
suite "type size and heading spacing"
# Reported from an iPhone: body copy was a size smaller on posts and pages
# than on the index, and a page title hung to the right of its own text.
ts="$TMPROOT/typesize"
make_blog_min "$ts" 2 10
mkdir -p "$ts/pages" "$ts/navs"
printf 'Item\t/item\n' > "$ts/navs/side.tsv"
printf '# Plain\n\nBody copy long enough to be worth reading on a phone.\n' > "$ts/pages/plain.md"
printf 'Nav: side\n\n# Sidebar\n\nBody copy.\n' > "$ts/pages/sb.md"
run_jenny_split "$ts"
it "posts do not shrink their body text on narrow screens"
# The index has always been a flat 16px. Posts and pages were 14px below
# 736px, so the same prose was smaller on a phone than the index linking to
# it. Nothing about a narrow screen calls for smaller type -- the measure is
# handled by the column width.
post_file=$(find "$ts/.dist/post" -name '*.html' | head -1)
assert_file_not_contains "$post_file" '@media (min-width: 736px) { body { font-size'
it "pages do not shrink their body text on narrow screens"
assert_file_not_contains "$ts/.dist/plain.html" '@media (min-width: 736px) { body { font-size'
it "every layout declares the same base type size"
# Compare the declarations rather than trusting they look alike.
idx_size=$("$GREP" -oE 'font-size: [0-9]+px;' "$ts/.dist/index.html" | head -1)
post_size=$("$GREP" -oE 'font-size: [0-9]+px;' "$post_file" | head -1)
page_size=$("$GREP" -oE 'font-size: [0-9]+px;' "$ts/.dist/plain.html" | head -1)
assert_eq "$post_size" "$idx_size"
assert_eq "$page_size" "$idx_size"
it "a page title keeps a gap above its body"
# ".heading-page .title { margin: 0 }" zeroed the base rule's bottom margin so
# the title could sit in a flex row; the title then touched the first
# paragraph, 0px where a post has 1em.
# A page heading is a title and nothing else -- no stamp, no arrow -- so it
# needs no row of its own. The bottom margin is what matters here: zeroing it
# once left a page title touching its first paragraph.
assert_file_contains "$ts/.dist/plain.html" '.heading-page .title { display: block; margin: 0 0 1em;'
it "narrow screens need no rule for the arrow column"
# There is no column to drop: the back link sits below the text at every
# width, so the narrow block only has to collapse the sidebar.
narrow_sp=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$ts/.dist/plain.html")
assert_not_contains "$narrow_sp" 'home-spacer'
assert_contains "$narrow_sp" '.contents-cell { padding-left: 0; }'
it "the heading is not a flex row"
# Flex items do not wrap, which is why the heading held its column at widths
# where the body had given up on it.
assert_file_not_contains "$ts/.dist/plain.html" '.heading-page { display: flex'
it "the page title rule comes after the base title rule"
# ".heading .title" sets display: table-cell at equal specificity, so source
# order decides. Placed earlier, the page rule lost and the title -- a
# table-cell -- took its own row below the spacer at every width.
base_line=$("$GREP" -n '\.heading \.title { color' "$ts/.dist/plain.html" | head -1 | cut -d: -f1)
page_line=$("$GREP" -n '\.heading-page \.title {' "$ts/.dist/plain.html" | head -1 | cut -d: -f1)
[ -n "$base_line" ] && [ -n "$page_line" ] && [ "$page_line" -gt "$base_line" ] \
&& pass "page title rule wins on source order" \
|| fail "page title rule at line $page_line must come after base rule at line $base_line"
it "the build is clean"
assert_eq "$stderr_output" ""
suite "template-baked navs"
# The default theme renders no footer, so render_footer_nav is not called from
# the template. The mechanism it demonstrates -- a nav declared by the theme
# rather than by a page, read from the same registry -- is the feature, and it
# is exercised here directly so it cannot rot while unused.
tfn="$TMPROOT/tfooternav"
make_blog_min "$tfn" 1 10
mkdir -p "$tfn/navs"
# shellcheck source=/dev/null
render_footer_nav_in() {
# Runs render_footer_nav from the page layout with a given navs dir and
# prefix, without rendering a whole page.
(
set +u
NAVS_DIR="$1" URL_PREFIX="$2"
SED="$SED" AWK="$AWK" GREP="$GREP"
# shellcheck disable=SC1090
source "$JENNY_SHARE_DIR/lib/helpers.sh"
eval "$("$SED" -n '/^render_footer_nav() {/,/^}/p' "$JENNY_SHARE_DIR/layout/page.sh")"
eval "$("$SED" -n '/^html_escape() {/,/^}/p' "$JENNY_SHARE_DIR/layout/page.sh")"
render_footer_nav
)
}
it "a template nav renders its entries"
printf 'Home\t/\nAbout\t/about\n' > "$tfn/navs/site-footer.tsv"
out=$(render_footer_nav_in "$tfn/navs" "..")
assert_eq "$(printf '%s' "$out" | "$GREP" -c 'class="footer-link"')" "2"
it "a template nav resolves paths against the page's prefix"
assert_contains "$out" 'href="../about"'
it "blank lines in a template nav are ignored"
printf 'One\t/one\n\nTwo\t/two\n' > "$tfn/navs/site-footer.tsv"
out=$(render_footer_nav_in "$tfn/navs" "..")
assert_eq "$(printf '%s' "$out" | "$GREP" -c 'class="footer-link"')" "2"
it "comment lines in a template nav are ignored"
printf '# Section\nLink\t/link\n' > "$tfn/navs/site-footer.tsv"
out=$(render_footer_nav_in "$tfn/navs" "..")
assert_eq "$(printf '%s' "$out" | "$GREP" -c 'class="footer-link"')" "1"
it "template nav entries are escaped"
printf 'Link & Text <foo>\t/x\n' > "$tfn/navs/site-footer.tsv"
out=$(render_footer_nav_in "$tfn/navs" "..")
assert_contains "$out" 'Link &amp; Text &lt;foo&gt;'
it "a row with neither links nor a way back renders nothing"
# Both halves are optional. Emitting the row anyway would leave an empty
# element and its top margin at the end of the document.
rm -f "$tfn/navs/site-footer.tsv"
out=$(render_footer_nav_in "$tfn/navs" ".." 2>/dev/null); st=$?
assert_eq "$st" "0"
assert_eq "$out" ""
suite "CSS variables completeness"
it "all CSS variables used in page.sh are defined in all theme blocks"
page_html=$(cat "$nav_sb/.dist/test.html")
# Extract all var(--name) references
vars_used=$(printf '%s' "$page_html" | grep -o 'var(--[^)]*)' | sort -u)
# Verify at least the key variables exist
assert_file_contains "$nav_sb/.dist/test.html" '--text-muted'
assert_file_contains "$nav_sb/.dist/test.html" '--link-nav'
assert_file_contains "$nav_sb/.dist/test.html" '--bg-primary'
suite "Footnote and citation tooltips"
# Build a blog with a post containing footnotes and citations.
tt="$TMPROOT/tooltips"
make_blog_min "$tt" 1 10
printf '# Test Post\n\nText with a footnote[^1] and a citation[#myref].\n\n[^1]: This is the footnote text.\n\n[#myref]: This is the citation text.' > "$tt/2024-01-01 tooltips.md"
run_jenny "$tt"
tt_post="$tt/.dist/post/tooltips.html"
it "footnote markers get a data-fnref attribute"
# Markers store the footnote ID for JavaScript to look up the text.
assert_file_contains "$tt_post" 'data-fnref='
it "citation markers get a data-citation attribute"
# Citation markers store the citation ID for JavaScript lookup.
assert_file_contains "$tt_post" 'data-citation='
it "tooltip CSS classes exist for footnotes"
# Tooltips are real elements with fn-tip class.
assert_file_contains "$tt_post" '.fn-tip'
it "tooltip CSS classes exist for citations"
# Citation tooltips use citation-tip class.
assert_file_contains "$tt_post" '.citation-tip {'
it "tooltip elements are positioned absolutely"
# Tooltips must be positioned absolutely to overlay.
assert_file_contains "$tt_post" 'position: absolute'
it "tooltips become visible when class is applied"
# The .visible class toggled by JavaScript.
assert_file_contains "$tt_post" '.fn-tip.visible'
it "tooltips show on keyboard focus events"
# Script listens to focusin and focusout.
assert_file_contains "$tt_post" "addEventListener('focusin'"
it "a tooltip is as wide as it needs to be, not as wide as its marker"
# An absolutely positioned box with no width shrink-wraps to its containing
# block, and the containing block is the <sup> -- about 48px. A long note
# collapsed into a 89px column 585px tall. max-width is a ceiling and cannot
# lift a box off that floor, so the width has to be stated.
# Scoped to the tooltip's own rule: "width: max-content" appears elsewhere in
# the stylesheet, so a bare search passed with this line deleted.
tip_w_rule=$("$SED" -n '/^ \.fn-tip,$/,/^ }/p' "$tt_post")
assert_contains "$tip_w_rule" 'width: max-content;'
it "the width cap counts the padding"
# Without border-box the cap applies to the content only, so a 280px maximum
# drew 299px -- enough to push a tooltip off a narrow screen.
tip_rule=$("$SED" -n '/^ \.fn-tip,$/,/^ }/p' "$tt_post")
assert_contains "$tip_rule" 'box-sizing: border-box;'
it "a tooltip is kept inside the viewport by measurement, not a breakpoint"
# Centred on its marker, a tooltip near the right margin hangs off the edge --
# at 440px a 232px tooltip on a marker at x=329 ended 5px off-screen, while its
# max-width was still well within the cap. Capping the width cannot fix a
# position problem, and a breakpoint written for 320px left every width between
# there and the desktop case unguarded.
#
# Only the browser knows where the marker landed after the line wrapped, so the
# tooltip is measured once shown and nudged back inside.
assert_file_contains "$tt_post" 'tooltip.getBoundingClientRect()'
# The shift must be BY the overflow, not a constant: setting it to a fixed
# value leaves the tooltip wherever it was and still satisfies a test that only
# looks for the property name.
assert_file_contains "$tt_post" "marginLeft = (-over) + 'px'"
assert_file_contains "$tt_post" "marginLeft = under + 'px'"
assert_file_contains "$tt_post" 'clientWidth'
it "the nudge is reset before each measurement"
# Otherwise a previous shift is measured as if it were the natural position and
# the tooltip walks further off-screen each time it is shown.
assert_file_contains "$tt_post" "tooltip.style.marginLeft = '';"
it "showing a tooltip cancels any pending hide"
# The bridge is what lets the pointer cross the gap from marker to tooltip: on
# leaving either, a hide is scheduled; on entering either, it must be cancelled.
# Without the cancel the tooltip closes underneath a pointer that is heading
# for it, and every link inside becomes unreachable.
assert_file_contains "$tt_post" 'clearTimeout(closeTimer);'
it "tooltips have a shared close timer bridge"
# Prevents closing when pointer moves from marker to tooltip.
assert_file_contains "$tt_post" 'closeTimer = setTimeout'
it "tooltips have no animation"
# transition: none ensures instant appearance with no fade/delay.
assert_file_contains "$tt_post" 'transition: none'
it "tooltips are positioned absolutely"
# Tooltips must not push page content around or cause overflow.
assert_file_contains "$tt_post" 'position: absolute'
it "footnote backref is not shown in tooltip"
# The backref link (&#x21A9;) must be stripped from tooltip text.
# This is verified by checking the script removes .fn-backref elements.
assert_file_contains "$tt_post" 'backref.parentNode.removeChild(backref)'
it "citation reverselink is not shown in tooltip"
# The reverselink (&#160;&#8617;) must be stripped from citation tooltips.
assert_file_contains "$tt_post" 'reverselink.parentNode.removeChild(reverselink)'
it "tooltips handle narrow viewports"
# At 320px, tooltips must not overflow the viewport.
assert_file_contains "$tt_post" '@media (max-width: 380px)'
it "tooltip initialization runs after DOM is ready"
# Script checks document.readyState to avoid accessing elements before they exist.
assert_file_contains "$tt_post" "document.readyState === 'loading'"
it "footnote text is extracted from fn-text span"
# The script queries .fn-text to find the content to display.
assert_file_contains "$tt_post" "querySelector('.fn-text')"
it "citation text is extracted from the paragraph"
# Citations live in a <p> inside the <li>.
assert_file_contains "$tt_post" "querySelector('p')"
it "the build is clean"
assert_eq "$stderr_output" ""
suite "Footnote bodies go through the inline parser"
# A footnote body must be parsed like prose and like a citation body. It used
# to be HTML-escaped on the way in, which turned every "<" into "&lt;" before
# the parser could see it -- so an autolink rendered as literal text inside a
# footnote while working everywhere else.
fi="$TMPROOT/footnote-inline"
make_blog_min "$fi" 1 10
printf '# Inline\n\nProse <https://example.com/prose> here.\n\nOne[^a] two[^b] three[^c].\n\n[^a]: See <https://example.com/auto> for details.\n\n[^b]: Tom & Jerry, 5 < 6.\n\n[^c]: With *emphasis* and [a link](https://example.com/md).\n' > "$fi/2024-01-01 inline.md"
run_jenny "$fi"
fi_post="$fi/.dist/post/inline.html"
fi_flat=$(tr '\n' ' ' < "$fi_post")
fi_a=$(printf '%s' "$fi_flat" | "$GREP" -o 'id="fn-a".\{0,200\}' | head -1)
fi_b=$(printf '%s' "$fi_flat" | "$GREP" -o 'id="fn-b".\{0,200\}' | head -1)
fi_c=$(printf '%s' "$fi_flat" | "$GREP" -o 'id="fn-c".\{0,200\}' | head -1)
it "an autolink inside a footnote becomes a link"
# The behaviour the fix exists for.
assert_contains "$fi_a" '<a href="https://example.com/auto">'
it "an autolink inside a footnote leaves no literal angle bracket"
# The old output was "&lt;https://example.com/auto>" -- escaped, then never parsed.
assert_not_contains "$fi_a" '&lt;https://example.com/auto'
it "an autolink in prose still becomes a link"
# The footnote fix must not disturb the path that already worked.
assert_contains "$fi_flat" '<a href="https://example.com/prose">'
it "ampersand and less-than in a footnote are still escaped"
# Storing the body raw does not mean skipping escaping: inline() escapes what
# is not markup. Unescaped "&" or "<" here would be invalid HTML.
assert_contains "$fi_b" 'Tom &amp; Jerry, 5 &lt; 6.'
it "emphasis inside a footnote still renders"
assert_contains "$fi_c" '<em>emphasis</em>'
it "a markdown link inside a footnote still renders"
assert_contains "$fi_c" '<a href="https://example.com/md">a link</a>'
suite "Diagram modal: full-screen diagram viewer"
# Create a blog with a diagram post for testing
dm_blog="$TMPROOT/diagram-test"
make_blog_min "$dm_blog" 1
# Add DIAGRAM_SCRIPT to enable diagram rendering
echo "DIAGRAM_SCRIPT=/js/mermaid.min.js" >> "$dm_blog/.blogrc"
# Add a post with a diagram
printf '# Post with Diagram\n\ntags: test\n\n```mermaid\ngraph TD\n A-->B\n```\n' > "$dm_blog/2024-04-01 with-diagram.md"
run_jenny "$dm_blog"
dm_post="$dm_blog/.dist/post/with-diagram.html"
it "a figure may bleed past the measure when there is room"
# Diagrams and images run up to 130% of the body column when the window can
# afford it, and sit at 100% when it cannot. Measured in Chrome: 130% at 1440
# and at 1920 (it caps, rather than growing without bound), 120.8% at 700, and
# exactly 100% below that, with no horizontal scroll at any width.
assert_file_contains "$dm_post" "margin-right: clamp(-30%, calc(100% - 100vw + 3em), 0px)"
it "the bled box is still bounded"
# max-width has to come off the measure or the figure would stay at 100% of it
# and merely sit off-centre -- but it is raised, not removed, so an oversized
# image cannot run off the page.
assert_file_contains "$dm_post" "max-width: calc(100% + 30%)"
it "the bleed grows rightward, not from both sides"
# The column is left-aligned, so the free space is all on one side. A
# symmetric bleed is capped by the fixed 24px left inset and never reaches
# 130%; an earlier attempt that split the leftover viewport evenly put the
# figure 40px off the left edge at 700px wide.
assert_file_not_contains "$dm_post" "margin-inline: clamp(-15%"
it "diagram elements have cursor pointer and are keyboard accessible"
# Diagrams should be clickable and keyboard-accessible buttons.
assert_file_contains "$dm_post" "diag.style.cursor = 'pointer'"
assert_file_contains "$dm_post" "setAttribute('role', 'button')"
assert_file_contains "$dm_post" "setAttribute('tabindex', '0')"
it "modal has dialog role and aria-modal for accessibility"
# The modal must be marked as a dialog with aria-modal=true.
assert_file_contains "$dm_post" "setAttribute('role', 'dialog')"
assert_file_contains "$dm_post" "setAttribute('aria-modal', 'true')"
it "close button has accessible label"
# The close button needs an aria-label for screen readers.
assert_file_contains "$dm_post" "'Close diagram'"
it "modal CSS uses fixed positioning at full viewport size"
# .diagram-modal must be position:fixed with inset:0 to cover the viewport.
assert_file_contains "$dm_post" ".diagram-modal { display: none; position: fixed; inset: 0; z-index: 2000"
it "modal shows with [open] attribute"
# CSS rule for .diagram-modal[open] controls visibility.
assert_file_contains "$dm_post" ".diagram-modal[open] { display: flex"
it "modal has backdrop that is clickable to close"
# The backdrop needs click handling to close the modal.
assert_file_contains "$dm_post" "backdrop.addEventListener('click', closeModal)"
it "Escape key closes the modal"
# Modal must listen for Escape on document and close.
assert_file_contains "$dm_post" "if (e.key === 'Escape'"
assert_file_contains "$dm_post" "modal.hasAttribute('open')"
it "modal disables page scrolling when open"
# document.documentElement.style.overflow must be set to 'hidden' to prevent scrolling.
assert_file_contains "$dm_post" "document.documentElement.style.overflow = 'hidden'"
it "modal restores page scrolling when closed"
# Overflow must be reset to empty string on close.
assert_file_contains "$dm_post" "document.documentElement.style.overflow = ''"
it "modal clones the SVG for display"
# The modal copies the diagram SVG to avoid removing it from the page.
assert_file_contains "$dm_post" "var clone = svg.cloneNode(true)"
it "the cloned SVG is given its class with setAttribute"
# On an SVG element className is a read-only SVGAnimatedString, so
# `clone.className = ...` silently does nothing and .diagram-modal-svg never
# matches -- a large diagram then overflows the modal instead of fitting it.
# The earlier version of this test asserted the broken assignment verbatim,
# which pinned the bug in place rather than catching it.
assert_file_contains "$dm_post" "clone.setAttribute('class', 'diagram-modal-svg')"
assert_file_not_contains "$dm_post" "clone.className ="
it "modal SVG is constrained by max-width and max-height"
# The diagram must not exceed the modal, and must be free to grow to fill it.
# This asserted the rule without !important, which is the form mermaid's inline
# style overrides -- another assertion that matched the broken code exactly.
assert_file_contains "$dm_post" ".diagram-modal-svg { max-width: 100% !important; max-height: 100%"
it "focus is given to modal when opened"
# The modal must take focus for keyboard accessibility.
assert_file_contains "$dm_post" "modal.focus()"
it "the modal is focusable at all"
# focus() only moves focus to an element the focus order can reach. Without a
# tabindex the call is a no-op: the dialog opens while focus stays on the page
# behind it. Asserting the focus() call alone did not catch that.
assert_file_contains "$dm_post" "m.setAttribute('tabindex', '-1')"
it "focus returns to diagram after closing"
# Focus must be restored to the diagram that opened the modal.
assert_file_contains "$dm_post" "openingDiagram.focus()"
it "diagram initialization waits for mermaid if undefined"
# Check for mermaid existence before initializing.
assert_file_contains "$dm_post" "if (typeof mermaid !== 'undefined')"
it "modal initialization is deferred until DOM is ready"
# Script must wait for DOMContentLoaded to avoid race conditions.
assert_file_contains "$dm_post" "if (document.readyState === 'loading')"
assert_file_contains "$dm_post" "document.addEventListener('DOMContentLoaded', initDiagramModals)"
it "keyboard interaction works on Enter and Space"
# Diagrams must respond to keyboard navigation.
assert_file_contains "$dm_post" "if (e.key === 'Enter' || e.key === ' '"
it "keyboard events are prevented to avoid page scroll"
# preventDefault must stop Space from scrolling the page.
assert_file_contains "$dm_post" "e.preventDefault()"
it "the diagram is centred inside the modal"
# Centring moved to the body: the content box now fills the viewport, so it is
# the diagram inside it that gets centred, not the box within the screen.
assert_file_contains "$dm_post" ".diagram-modal-body { flex: 1; overflow: auto; display: flex; align-items: center;"
it "the modal content box fills the viewport"
# It used to be capped at min(95vw, 95vh). On any landscape screen 95vh is the
# smaller term, so a 1440x900 viewport held the diagram to 855px and wasted
# 513px of width -- the opposite of what a full-screen view is for.
assert_file_contains "$dm_post" "width: 100%; height: 100%; overflow: auto; display: flex; }"
assert_file_not_contains "$dm_post" "max-width: min(95vw, 95vh)"
it "enlargement is capped at twice the diagram's natural size"
# Filling the viewport suits a dense chart and ruins a small one -- a
# three-node diagram at 1400px is all gaps and oversized text. The ceiling is
# read from the diagram itself, so it holds for any chart rather than being
# tuned to the ones in this repo.
assert_file_contains "$dm_post" "(natural * 2) + 'px'"
it "the natural width falls back to the viewBox"
# mermaid writes an inline max-width, but a diagram without one still carries
# its dimensions in the viewBox.
assert_file_contains "$dm_post" "svg.getAttribute('viewBox')"
it "the cap is set with important priority"
# The stylesheet rule is !important (it must be, to beat mermaid's own inline
# max-width). A plain inline value would lose to it and the cap would silently
# do nothing -- measured: 100% wins over a non-important inline 600px.
assert_file_contains "$dm_post" "setProperty('max-width', (natural * 2) + 'px', 'important')"
it "the diagram overrides mermaid's inline max-width"
# mermaid writes style="max-width: {natural}px" directly on the <svg>. An
# inline style beats any selector, so without !important the diagram keeps its
# natural width and never grows to fill the modal.
assert_file_contains "$dm_post" "max-width: 100% !important"
it "the modal has no chrome around the diagram"
# Just a close button floating over the diagram; no header bar, no frame.
assert_file_not_contains "$dm_post" "diagram-modal-header"
it "modal is removed from the normal flow"
# position:fixed takes the modal out of the document flow.
assert_file_contains "$dm_post" "position: fixed"
it "no diagram markup at all when DIAGRAM_SCRIPT is unset"
# $BLOG leaves DIAGRAM_SCRIPT unset, so neither the modal CSS nor the script
# should appear. This does NOT test the per-page gate -- see below.
body=$(cat "$DIST/index.html" 2>/dev/null)
if [[ "$body" == *".diagram-modal"* ]]; then
fail "index.html should not contain diagram modal CSS"
else
pass
fi
it "the modal script is withheld from a diagram-free page"
# The real gate: DIAGRAM_SCRIPT is set blog-wide, but a page with no diagram
# must still not ship the behaviour. diagram_script() checks POST_CONTENTS for
# class="diagram before emitting. (The CSS is part of the shared stylesheet and
# does ship on every page -- inert without the script, and not what this
# guards.)
dm_plain="$dm_blog/.dist/post/post-01.html"
assert_file_not_contains "$dm_plain" "openDiagramModal"
summary