|
#!/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 & <Title>" |
|
|
|
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 &" |
|
|
|
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" "<ul>" |
|
|
|
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 & <thing>" |
|
|
|
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" '→︎' |
|
|
|
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 "-->" 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" '-->' |
|
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" '&&' |
|
assert_contains "$code" '<pre><code class="language-bash">' |
|
|
|
it "a bare fence is still escaped" |
|
assert_contains "$(md_render '``` |
|
x > y & z |
|
```')" '>' |
|
|
|
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 & B <x>" |
|
|
|
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 & B <x>" |
|
|
|
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 & <Toys>' |
|
|
|
it "an empty section heading renders nothing" |
|
# "##" with no name is a typo, not a divider. |
|
printf '##\nHome\t/\n' > "$nvs/navs/structured.tsv" |
|
run_jenny_split "$nvs" |
|
assert_file_not_contains "$nvs/.dist/s.html" 'class="nav-section"' |
|
|
|
it "an entry with an odd indent rounds down rather than failing" |
|
# A miscounted space is a typo in a list of links, not a reason to refuse to |
|
# publish. |
|
printf 'Home\t/\n Odd\t/odd\n' > "$nvs/navs/structured.tsv" |
|
run_jenny_split "$nvs" |
|
assert_file_contains "$nvs/.dist/s.html" 'nav-depth-1" href="./odd"' |
|
|
|
it "the build stays clean with sections and nesting" |
|
assert_eq "$stderr_output" "" |
|
|
|
it "depth past the styled levels is clamped, not dropped" |
|
# The indent stops growing rather than marching the label out of a 6rem |
|
# column; the entry still renders. |
|
printf 'Home\t/\n Deep\t/deep\n' > "$nvs/navs/structured.tsv" |
|
run_jenny_split "$nvs" |
|
assert_file_contains "$nvs/.dist/s.html" 'nav-depth-3" href="./deep"' |
|
|
|
it "the nav column cannot be widened by an unbreakable label" |
|
# min-width is a floor, not a ceiling: a label with no space in it grew the |
|
# column to 215px and pushed the body right. The ceiling plus overflow-wrap |
|
# keeps the column fixed and breaks the word instead. |
|
assert_file_contains "$nvs/.dist/s.html" 'max-width: var(--page-nav-width)' |
|
assert_file_contains "$nvs/.dist/s.html" 'overflow-wrap: break-word' |
|
|
|
it "wrapped labels keep every line at the same depth" |
|
# A negative text-indent is for a list marker sitting outside the text block. |
|
# Used here it put an entry's first line left of its own wrapped lines, so the |
|
# label appeared to straddle two levels. |
|
assert_file_not_contains "$nvs/.dist/s.html" 'nav-depth-1 { padding-left: 1.5em; text-indent' |
|
|
|
suite "dark mode" |
|
|
|
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 ︎ 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" '◐︎' |
|
|
|
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" '←︎ Index</a>' |
|
assert_file_not_contains "$post_file_bk" '←︎ 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 & Text <foo>' |
|
|
|
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 (↩) 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 ( ↩) 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 "<" 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 "<https://example.com/auto>" -- escaped, then never parsed. |
|
assert_not_contains "$fi_a" '<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 & Jerry, 5 < 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 |