Commit 1108ea8 h
Changes: 4 files +196 -13
Changes (Side-by-Side)
bin/jenny +9 -3
if source_blogrc; then | |||
| 219 | # garbage silently, so refuse to build against one. |
219 | # garbage silently, so refuse to build against one. |
| 220 | assert_layouts_current "$LAYOUT_DIR" || exit 1 |
220 | assert_layouts_current "$LAYOUT_DIR" || exit 1 |
| 221 | |
221 | |
| 222 | # Everything that shapes the output but is not the document: the layouts and |
||
| 223 | # the settings they read. Recorded with each lock entry so a theme edit |
||
| 224 | # invalidates rendered files the way a content edit does. |
||
| 225 | BUILD_SUM=$(build_signature "$LAYOUT_DIR") |
||
| 226 | export BUILD_SUM |
||
| 227 | |
||
| 222 | # Sets up directory |
228 | # Sets up directory |
| 223 | touch "$BLOG_LOCK" |
229 | touch "$BLOG_LOCK" |
| 224 | mkdir -p "$DIST/post" |
230 | mkdir -p "$DIST/post" |
begin () { | |||
| 310 | slug=$(get_unique_slug "$f") |
316 | slug=$(get_unique_slug "$f") |
| 311 | forRendering=true |
317 | forRendering=true |
| 312 | echo "$T + /post/$slug.html" |
318 | echo "$T + /post/$slug.html" |
| 313 | echo "$slug $filename_sum $content_sum" >> "$BLOG_LOCK" |
||
| 319 | echo "$slug $filename_sum $content_sum $BUILD_SUM" >> "$BLOG_LOCK" |
||
| 314 | else |
320 | else |
| 315 | # not new |
321 | # not new |
| 316 | slug=$(get_existing_slug "$filename_sum") |
322 | slug=$(get_existing_slug "$filename_sum") |
| 317 | if is_changed "$filename_sum" "$content_sum"; then |
||
| 323 | if is_changed "$filename_sum" "$content_sum" "$BUILD_SUM"; then |
||
| 318 | echo "$T ↑ /post/$slug.html" |
324 | echo "$T ↑ /post/$slug.html" |
| 319 | $SED -i "/ $filename_sum /d" "$BLOG_LOCK" |
325 | $SED -i "/ $filename_sum /d" "$BLOG_LOCK" |
| 320 | echo "$slug $filename_sum $content_sum" >> "$BLOG_LOCK" |
||
| 326 | echo "$slug $filename_sum $content_sum $BUILD_SUM" >> "$BLOG_LOCK" |
||
| 321 | forRendering=true |
327 | forRendering=true |
| 322 | fi |
328 | fi |
| 323 | fi |
329 | fi |
share/jenny/lib/components.sh +26 -4
render_pages () { | |||
| 280 | title=$(basename "${rel%.md}") |
280 | title=$(basename "${rel%.md}") |
| 281 | fi |
281 | fi |
| 282 | |
282 | |
| 283 | echo "$T ▤ /${rel%.md}.html" |
||
| 284 | if ! render_page "$src" "$out" "$title" "$dist"; then |
||
| 285 | BUILD_FAILED=1 |
||
| 286 | continue |
||
| 283 | # Pages consult the lock the way posts do. Before this they were rewritten |
||
| 284 | # on every build, so a run with nothing changed still rendered every page |
||
| 285 | # -- and, worse, a layout edit updated the pages while the posts were |
||
| 286 | # skipped, leaving one build's output half old and half new. |
||
| 287 | # |
||
| 288 | # A page has no slug indirection, so the path is its identity: the first |
||
| 289 | # column is the page's own path rather than a generated name. That is also |
||
| 290 | # what keeps a page's entry from colliding with a post's. |
||
| 291 | local page_key page_sum |
||
| 292 | page_key="page:${rel%.md}" |
||
| 293 | page_sum=$(cat "$src" | $SUM | cut -d ' ' -f 1) |
||
| 294 | local render_this=true |
||
| 295 | if [ "${FORCE_RENDER:-0}" -ne 1 ] \ |
||
| 296 | && [ -f "$out" ] \ |
||
| 297 | && ! is_changed "$page_key" "$page_sum" "${BUILD_SUM:-}"; then |
||
| 298 | render_this=false |
||
| 299 | fi |
||
| 300 | |
||
| 301 | if [ "$render_this" = true ]; then |
||
| 302 | echo "$T ▤ /${rel%.md}.html" |
||
| 303 | if ! render_page "$src" "$out" "$title" "$dist"; then |
||
| 304 | BUILD_FAILED=1 |
||
| 305 | continue |
||
| 306 | fi |
||
| 307 | "$SED" -i "/ $page_key /d" "$BLOG_LOCK" 2>/dev/null || true |
||
| 308 | echo "$page_key $page_key $page_sum ${BUILD_SUM:-}" >> "$BLOG_LOCK" |
||
| 287 | fi |
309 | fi |
| 288 | record_page "$dist" "${rel%.md}.html" |
310 | record_page "$dist" "${rel%.md}.html" |
| 289 | count=$((count + 1)) |
311 | count=$((count + 1)) |
share/jenny/lib/helpers.sh +72 -6
function is_scheduled() { | |||
| 184 | # Lookups match on a named column so a filename hash can never be satisfied |
184 | # Lookups match on a named column so a filename hash can never be satisfied |
| 185 | # by another line's content hash. |
185 | # by another line's content hash. |
| 186 | |
186 | |
| 187 | # build_signature <layout_dir> |
||
| 188 | # A hash of everything that shapes the output but is not the document itself: |
||
| 189 | # the layout files, and the settings in .blogrc that layouts read. Stored |
||
| 190 | # alongside each entry in the lock so a themed change invalidates the rendered |
||
| 191 | # files the way a content change does. |
||
| 192 | # |
||
| 193 | # Without this a layout edit was invisible to the lock. Posts consulted it and |
||
| 194 | # were skipped; pages did not and were rewritten -- so one build produced a |
||
| 195 | # site where the pages had the new layout and the posts still had the old one. |
||
| 196 | # The inconsistency reads as a layout bug rather than a stale build, which is |
||
| 197 | # the worst way for it to fail. |
||
| 198 | # |
||
| 199 | # The whole directory is hashed rather than each layout separately: a partial |
||
| 200 | # is shared between layouts, and attributing which output depends on which file |
||
| 201 | # is a dependency graph this does not need. The cost is that editing one layout |
||
| 202 | # re-renders everything, which is the same work the -f flag has always done. |
||
| 203 | function build_signature() { |
||
| 204 | local layout_dir=$1 |
||
| 205 | { |
||
| 206 | # Sorted so the hash does not depend on directory order. |
||
| 207 | find "$layout_dir" -type f -name '*.sh' -print0 2>/dev/null \ |
||
| 208 | | LC_ALL=C sort -z \ |
||
| 209 | | xargs -0 cat 2>/dev/null |
||
| 210 | # Nav registries. A page renders the nav it declares, so editing |
||
| 211 | # navs/study-toc.tsv changes that page's output without touching the |
||
| 212 | # page's own source -- exactly the kind of edit the lock would otherwise |
||
| 213 | # skip. They are hashed with the layouts rather than per page because |
||
| 214 | # which page reads which nav is not knowable here. |
||
| 215 | find "${NAVS_DIR:-navs}" -type f -name '*.tsv' -print0 2>/dev/null \ |
||
| 216 | | LC_ALL=C sort -z \ |
||
| 217 | | xargs -0 cat 2>/dev/null |
||
| 218 | # Settings a layout reads. A change to URL_STYLE or the site title changes |
||
| 219 | # every rendered file just as surely as editing the template does. |
||
| 220 | printf '%s\n' "${BLOG_TITLE:-}" "${BLOG_HOST:-}" "${URL_STYLE:-}" \ |
||
| 221 | "${POSTS_PER_PAGE:-}" "${DIAGRAM_SCRIPT:-}" |
||
| 222 | } | $SUM | cut -d ' ' -f 1 |
||
| 223 | } |
||
| 224 | |
||
| 187 | function is_new() { |
225 | function is_new() { |
| 188 | local filename_sum=$1 |
226 | local filename_sum=$1 |
| 189 | if "$AWK" -v sum="$filename_sum" '$2 == sum { found = 1; exit } END { exit !found }' "$BLOG_LOCK"; then |
227 | if "$AWK" -v sum="$filename_sum" '$2 == sum { found = 1; exit } END { exit !found }' "$BLOG_LOCK"; then |
function is_new() { | |||
| 193 | fi |
231 | fi |
| 194 | } |
232 | } |
| 195 | |
233 | |
| 234 | # is_changed <filename_sum> <content_sum> [build_sum] |
||
| 235 | # A file is unchanged only when its content and the build signature both match |
||
| 236 | # what the lock recorded. An entry written before signatures existed has no |
||
| 237 | # fourth column and so never matches, which re-renders it once and then records |
||
| 238 | # the signature -- an old lock upgrades itself rather than needing to be reset. |
||
| 196 | function is_changed() { |
239 | function is_changed() { |
| 197 | local filename_sum=$1 content_sum=$2 |
||
| 198 | if "$AWK" -v fsum="$filename_sum" -v csum="$content_sum" \ |
||
| 199 | '$2 == fsum && $3 == csum { found = 1; exit } END { exit !found }' "$BLOG_LOCK"; then |
||
| 240 | local filename_sum=$1 content_sum=$2 build_sum=${3:-} |
||
| 241 | if "$AWK" -v fsum="$filename_sum" -v csum="$content_sum" -v bsum="$build_sum" \ |
||
| 242 | '$2 == fsum && $3 == csum && $4 == bsum { found = 1; exit } END { exit !found }' "$BLOG_LOCK"; then |
||
| 200 | return 1 # unchanged |
243 | return 1 # unchanged |
| 201 | else |
244 | else |
| 202 | return 0 # changed |
245 | return 0 # changed |
function sweep_orphans() { | |||
| 345 | type jenny_register_temp >/dev/null 2>&1 && jenny_register_temp "$kept" |
388 | type jenny_register_temp >/dev/null 2>&1 && jenny_register_temp "$kept" |
| 346 | |
389 | |
| 347 | # Explicit IFS: the lock is space-separated, and a caller may have left |
390 | # Explicit IFS: the lock is space-separated, and a caller may have left |
| 348 | # IFS set to something else. |
||
| 349 | while IFS=' ' read -r slug fsum csum; do |
||
| 391 | # IFS set to something else. The fourth column is the build signature; it is |
||
| 392 | # read and written back so this pass does not strip it -- rewriting a kept |
||
| 393 | # line with three fields would have silently dropped every signature and made |
||
| 394 | # each post look changed on the following build. |
||
| 395 | local bsum |
||
| 396 | while IFS=' ' read -r slug fsum csum bsum; do |
||
| 350 | [ -z "$slug" ] && continue |
397 | [ -z "$slug" ] && continue |
| 351 | |
398 | |
| 399 | # A page entry names its own source path rather than a hashed filename, so |
||
| 400 | # it is checked against that path. Sweeping it by the post rule -- looking |
||
| 401 | # for a *.md in the blog root whose name hashes to fsum -- found nothing, |
||
| 402 | # because a page's source lives under pages/, so every page entry was |
||
| 403 | # discarded on the build that wrote it and pages never skipped at all. |
||
| 404 | case "$slug" in |
||
| 405 | page:*) |
||
| 406 | if [ -f "${PAGES_DIR:-pages}/${slug#page:}.md" ]; then |
||
| 407 | printf '%s %s %s %s\n' "$slug" "$fsum" "$csum" "$bsum" >> "$kept" |
||
| 408 | else |
||
| 409 | if [ -f "$dist/${slug#page:}.html" ]; then |
||
| 410 | echo "$T - /${slug#page:}.html (source removed)" |
||
| 411 | rm -f "$dist/${slug#page:}.html" |
||
| 412 | fi |
||
| 413 | fi |
||
| 414 | continue |
||
| 415 | ;; |
||
| 416 | esac |
||
| 417 | |
||
| 352 | # Is there still a source file whose name hashes to fsum? |
418 | # Is there still a source file whose name hashes to fsum? |
| 353 | found="" |
419 | found="" |
| 354 | for f in *.md; do |
420 | for f in *.md; do |
function sweep_orphans() { | |||
| 360 | done |
426 | done |
| 361 | |
427 | |
| 362 | if [ -n "$found" ]; then |
428 | if [ -n "$found" ]; then |
| 363 | printf '%s %s %s\n' "$slug" "$fsum" "$csum" >> "$kept" |
||
| 429 | printf '%s %s %s %s\n' "$slug" "$fsum" "$csum" "$bsum" >> "$kept" |
||
| 364 | else |
430 | else |
| 365 | if [ -f "$dist/post/$slug.html" ]; then |
431 | if [ -f "$dist/post/$slug.html" ]; then |
| 366 | echo "$T - /post/$slug.html (source removed)" |
432 | echo "$T - /post/$slug.html (source removed)" |
test/regressions.sh +89 -0
assert_success "$status" "$output" | |||
| 250 | it "the fallback title is the slug" |
250 | it "the fallback title is the slug" |
| 251 | assert_file_contains "$nt/.dist/index.html" "headless" |
251 | assert_file_contains "$nt/.dist/index.html" "headless" |
| 252 | |
252 | |
| 253 | suite "incremental builds notice more than post content" |
||
| 254 | |
||
| 255 | # The lock recorded a post's filename and content only. A layout edit was |
||
| 256 | # invisible to it, so posts were skipped -- while pages, which consulted no |
||
| 257 | # lock at all, were rewritten every build. One build therefore produced a site |
||
| 258 | # where the pages had the new layout and the posts still had the old one, which |
||
| 259 | # reads as a layout bug rather than a stale build. |
||
| 260 | |
||
| 261 | inc="$TMPROOT/incremental" |
||
| 262 | make_blog_min "$inc" 1 10 |
||
| 263 | mkdir -p "$inc/pages" "$inc/navs" |
||
| 264 | printf '# A Page\n\nBody.\n' > "$inc/pages/p.md" |
||
| 265 | cp -r "$JENNY_SHARE_DIR/layout" "$inc/mylayout" |
||
| 266 | run_jenny "$inc" |
||
| 267 | |
||
| 268 | inc_post=$(find "$inc/.dist/post" -name '*.html' | head -1) |
||
| 269 | inc_stamp() { "$SED" -n "s/.*//p" /dev/null; stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; } |
||
| 270 | |
||
| 271 | it "a rebuild with nothing changed rewrites nothing" |
||
| 272 | # Pages used to be rendered unconditionally, so every build rewrote them all. |
||
| 273 | before_post=$(inc_stamp "$inc_post") |
||
| 274 | before_page=$(inc_stamp "$inc/.dist/p.html") |
||
| 275 | sleep 1 |
||
| 276 | run_jenny "$inc" |
||
| 277 | assert_eq "$(inc_stamp "$inc_post")" "$before_post" |
||
| 278 | assert_eq "$(inc_stamp "$inc/.dist/p.html")" "$before_page" |
||
| 279 | |
||
| 280 | it "editing one page rebuilds only that page" |
||
| 281 | sleep 1 |
||
| 282 | printf '# A Page\n\nEdited.\n' > "$inc/pages/p.md" |
||
| 283 | run_jenny "$inc" |
||
| 284 | assert_eq "$(inc_stamp "$inc_post")" "$before_post" |
||
| 285 | [ "$(inc_stamp "$inc/.dist/p.html")" != "$before_page" ] \ |
||
| 286 | && pass "the edited page was rebuilt" || fail "the edited page was skipped" |
||
| 287 | |
||
| 288 | it "a layout edit rebuilds posts and pages alike" |
||
| 289 | # The defect this fixes: one build leaving half the site on the old layout. |
||
| 290 | sleep 1 |
||
| 291 | "$SED" -i 's|<article>|<article data-mark="ZZ">|' "$inc/mylayout/post.sh" "$inc/mylayout/page.sh" |
||
| 292 | ( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 ) |
||
| 293 | assert_file_contains "$inc_post" 'data-mark="ZZ"' |
||
| 294 | assert_file_contains "$inc/.dist/p.html" 'data-mark="ZZ"' |
||
| 295 | |
||
| 296 | it "a nav registry edit rebuilds the pages that read it" |
||
| 297 | # A page renders the nav it declares, so editing the .tsv changes that page's |
||
| 298 | # output without touching the page's own source. |
||
| 299 | sleep 1 |
||
| 300 | printf 'Nav: side\n\n# Navved\n\nBody.\n' > "$inc/pages/navved.md" |
||
| 301 | printf 'One\t/one\n' > "$inc/navs/side.tsv" |
||
| 302 | ( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 ) |
||
| 303 | assert_file_contains "$inc/.dist/navved.html" 'One' |
||
| 304 | sleep 1 |
||
| 305 | printf 'Two\t/two\n' > "$inc/navs/side.tsv" |
||
| 306 | ( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 ) |
||
| 307 | assert_file_contains "$inc/.dist/navved.html" 'Two' |
||
| 308 | |
||
| 309 | it "a settings change rebuilds everything" |
||
| 310 | # BLOG_TITLE and URL_STYLE reach every rendered file. |
||
| 311 | sleep 1 |
||
| 312 | "$SED" -i 's|^BLOG_TITLE=.*|BLOG_TITLE="Renamed Blog"|' "$inc/.blogrc" |
||
| 313 | ( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 ) |
||
| 314 | assert_file_contains "$inc_post" 'Renamed Blog' |
||
| 315 | assert_file_contains "$inc/.dist/p.html" 'Renamed Blog' |
||
| 316 | |
||
| 317 | it "the lock keeps its build signature across a sweep" |
||
| 318 | # sweep_orphans rewrote every kept line with three fields, which would have |
||
| 319 | # stripped the signature and made each post look changed on the next build. |
||
| 320 | assert_eq "$("$AWK" 'NF && $1 !~ /^page:/ { print NF; exit }' "$inc/.bloglock")" "4" |
||
| 321 | |
||
| 322 | it "a page entry survives the sweep" |
||
| 323 | # Page entries name their source path; swept by the post rule -- a *.md in the |
||
| 324 | # blog root whose name hashes to column two -- they matched nothing and were |
||
| 325 | # discarded on the very build that wrote them, so pages never skipped. |
||
| 326 | assert_eq "$("$GREP" -c '^page:' "$inc/.bloglock")" "2" |
||
| 327 | |
||
| 328 | it "a removed page is swept from the lock and the output" |
||
| 329 | sleep 1 |
||
| 330 | rm "$inc/pages/navved.md" |
||
| 331 | ( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" >/dev/null 2>&1 ) |
||
| 332 | assert_file_absent "$inc/.dist/navved.html" |
||
| 333 | assert_eq "$("$GREP" -c 'page:navved' "$inc/.bloglock" || true)" "0" |
||
| 334 | |
||
| 335 | it "-f still forces a rebuild" |
||
| 336 | sleep 1 |
||
| 337 | before_forced=$(inc_stamp "$inc_post") |
||
| 338 | ( cd "$inc" && LAYOUT_DIR="$inc/mylayout" "$JENNY" -f >/dev/null 2>&1 ) |
||
| 339 | [ "$(inc_stamp "$inc_post")" != "$before_forced" ] \ |
||
| 340 | && pass "-f rebuilt the post" || fail "-f did not rebuild the post" |
||
| 341 | |
||
| 253 | suite "P1-5 deleted sources are swept" |
342 | suite "P1-5 deleted sources are swept" |
| 254 | |
343 | |
| 255 | it "deleting a source removes its rendered HTML" |
344 | it "deleting a source removes its rendered HTML" |
Raw
commit 1108ea848749a50ae2b91d36be5e6ef346044409
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date: Wed Aug 26 11:15:45 2026 +0800
Make the lock cover the layout, and let pages use it too
Posts consulted the lock; pages did not. So a layout edit produced a site
where the pages had the new template and the posts still had the old one,
from a single build with no warning. That is worse than a stale build: it
reads as a layout bug, which is how it cost time in this session.
The lock recorded a post's filename and content only. It now also records
a build signature -- the hash of every layout file, the nav registries, and
the .blogrc settings that layouts read. A theme edit, a nav edit or a title
change invalidates rendered files the way a content edit always has.
Pages get the same treatment, keyed on their own path rather than a slug.
Before this they were rewritten on every build, so a run with nothing
changed still re-rendered every page. Now a no-op build writes nothing at
all except the index and the 404, which regenerate by design because the
index lists every post.
Nav registries are in the signature because a page renders the nav it
declares: editing navs/study-toc.tsv changes that page's output without
touching the page's source, which is exactly the edit the lock would
otherwise skip. The existing nav-sections tests caught this -- they rewrite
a .tsv and rebuild, and started failing the moment pages began skipping.
Two bugs in sweep_orphans came out of this. It read three columns and wrote
three back, so it would have stripped the signature from every kept line
and made each post look changed on the following build. And it swept page
entries by the post rule -- looking for a *.md in the blog root whose name
hashes to column two -- which matches nothing for a page under pages/, so
every page entry was discarded on the build that wrote it and pages would
never have skipped at all.
-f still forces a full rebuild and is unchanged; it is no longer the only
way to get a consistent site after touching a template.
Tests: a suite covering the no-op build, a single-page edit, a layout edit
reaching posts and pages alike, a nav edit, a settings change, signature
survival across a sweep, page-entry survival, page removal, and -f.
Checked by mutation -- pages never skipping, the layout left out of the
signature, the sweep stripping the fourth column -- each caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnSAstgnDYdKPwLGv1YZE5
diff --git a/bin/jenny b/bin/jenny
index 49a4bc8..8d42466 100755
--- a/bin/jenny
+++ b/bin/jenny
@@ -219,6 +219,12 @@ if source_blogrc; then
# garbage silently, so refuse to build against one.
assert_layouts_current "$LAYOUT_DIR" || exit 1
+ # Everything that shapes the output but is not the document: the layouts and
+ # the settings they read. Recorded with each lock entry so a theme edit
+ # invalidates rendered files the way a content edit does.
+ BUILD_SUM=$(build_signature "$LAYOUT_DIR")
+ export BUILD_SUM
+
# Sets up directory
touch "$BLOG_LOCK"
mkdir -p "$DIST/post"
@@ -310,14 +316,14 @@ begin () {
slug=$(get_unique_slug "$f")
forRendering=true
echo "$T + /post/$slug.html"
- echo "$slug $filename_sum $content_sum" >> "$BLOG_LOCK"
+ echo "$slug $filename_sum $content_sum $BUILD_SUM" >> "$BLOG_LOCK"
else
# not new
slug=$(get_existing_slug "$filename_sum")
- if is_changed "$filename_sum" "$content_sum"; then
+ if is_changed "$filename_sum" "$content_sum" "$BUILD_SUM"; then
echo "$T ↑ /post/$slug.html"
$SED -i "/ $filename_sum /d" "$BLOG_LOCK"
- echo "$slug $filename_sum $content_sum" >> "$BLOG_LOCK"
+ echo "$slug $filename_sum $content_sum $BUILD_SUM" >> "$BLOG_LOCK"
forRendering=true
fi
fi
diff --git a/share/jenny/lib/components.sh b/share/jenny/lib/components.sh
index ee1b4c3..7f56967 100644
--- a/share/jenny/lib/components.sh
+++ b/share/jenny/lib/components.sh
@@ -280,10 +280,32 @@ render_pages () {
title=$(basename "${rel%.md}")
fi
- echo "$T ▤ /${rel%.md}.html"
- if ! render_page "$src" "$out" "$title" "$dist"; then
- BUILD_FAILED=1
- continue
+ # Pages consult the lock the way posts do. Before this they were rewritten
+ # on every build, so a run with nothing changed still rendered every page
+ # -- and, worse, a layout edit updated the pages while the posts were
+ # skipped, leaving one build's output half old and half new.
+ #
+ # A page has no slug indirection, so the path is its identity: the first
+ # column is the page's own path rather than a generated name. That is also
+ # what keeps a page's entry from colliding with a post's.
+ local page_key page_sum
+ page_key="page:${rel%.md}"
+ page_sum=$(cat "$src" | $SUM | cut -d ' ' -f 1)
+ local render_this=true
+ if [ "${FORCE_RENDER:-0}" -ne 1 ] \
+ && [ -f "$out" ] \
+ && ! is_changed "$page_key" "$page_sum" "${BUILD_SUM:-}"; then
+ render_this=false
+ fi
+
+ if [ "$render_this" = true ]; then
+ echo "$T ▤ /${rel%.md}.html"
+ if ! render_page "$src" "$out" "$title" "$dist"; then
+ BUILD_FAILED=1
+ continue
+ fi
+ "$SED" -i "/ $page_key /d" "$BLOG_LOCK" 2>/dev/null || true
+ echo "$page_key $page_key $page_sum ${BUILD_SUM:-}" >> "$BLOG_LOCK"
fi
record_page "$dist" "${rel%.md}.html"
count=$((count + 1))
diff --git a/share/jenny/lib/helpers.sh b/share/jenny/lib/helpers.sh
index da6d09a..9a12a14 100644
--- a/share/jenny/lib/helpers.sh
+++ b/share/jenny/lib/helpers.sh
@@ -184,6 +184,44 @@ function is_scheduled() {
# Lookups match on a named column so a filename hash can never be satisfied
# by another line's content hash.
+# build_signature <layout_dir>
+# A hash of everything that shapes the output but is not the document itself:
+# the layout files, and the settings in .blogrc that layouts read. Stored
+# alongside each entry in the lock so a themed change invalidates the rendered
+# files the way a content change does.
+#
+# Without this a layout edit was invisible to the lock. Posts consulted it and
+# were skipped; pages did not and were rewritten -- so one build produced a
+# site where the pages had the new layout and the posts still had the old one.
+# The inconsistency reads as a layout bug rather than a stale build, which is
+# the worst way for it to fail.
+#
+# The whole directory is hashed rather than each layout separately: a partial
+# is shared between layouts, and attributing which output depends on which file
+# is a dependency graph this does not need. The cost is that editing one layout
+# re-renders everything, which is the same work the -f flag has always done.
+function build_signature() {
+ local layout_dir=$1
+ {
+ # Sorted so the hash does not depend on directory order.
+ find "$layout_dir" -type f -name '*.sh' -print0 2>/dev/null \
+ | LC_ALL=C sort -z \
+ | xargs -0 cat 2>/dev/null
+ # Nav registries. A page renders the nav it declares, so editing
+ # navs/study-toc.tsv changes that page's output without touching the
+ # page's own source -- exactly the kind of edit the lock would otherwise
+ # skip. They are hashed with the layouts rather than per page because
+ # which page reads which nav is not knowable here.
+ find "${NAVS_DIR:-navs}" -type f -name '*.tsv' -print0 2>/dev/null \
+ | LC_ALL=C sort -z \
+ | xargs -0 cat 2>/dev/null
+ # Settings a layout reads. A change to URL_STYLE or the site title changes
+ # every rendered file just as surely as editing the template does.
+ printf '%s\n' "${BLOG_TITLE:-}" "${BLOG_HOST:-}" "${URL_STYLE:-}" \
+ "${POSTS_PER_PAGE:-}" "${DIAGRAM_SCRIPT:-}"
+ } | $SUM | cut -d ' ' -f 1
+}
+
function is_new() {
local filename_sum=$1
if "$AWK" -v sum="$filename_sum" '$2 == sum { found = 1; exit } END { exit !found }' "$BLOG_LOCK"; then
@@ -193,10 +231,15 @@ function is_new() {
fi
}
+# is_changed <filename_sum> <content_sum> [build_sum]
+# A file is unchanged only when its content and the build signature both match
+# what the lock recorded. An entry written before signatures existed has no
+# fourth column and so never matches, which re-renders it once and then records
+# the signature -- an old lock upgrades itself rather than needing to be reset.
function is_changed() {
- local filename_sum=$1 content_sum=$2
- if "$AWK" -v fsum="$filename_sum" -v csum="$content_sum" \
- '$2 == fsum && $3 == csum { found = 1; exit } END { exit !found }' "$BLOG_LOCK"; then
+ local filename_sum=$1 content_sum=$2 build_sum=${3:-}
+ if "$AWK" -v fsum="$filename_sum" -v csum="$content_sum" -v bsum="$build_sum" \
+ '$2 == fsum && $3 == csum && $4 == bsum { found = 1; exit } END { exit !found }' "$BLOG_LOCK"; then
return 1 # unchanged
else
return 0 # changed
@@ -345,10 +388,33 @@ function sweep_orphans() {
type jenny_register_temp >/dev/null 2>&1 && jenny_register_temp "$kept"
# Explicit IFS: the lock is space-separated, and a caller may have left
- # IFS set to something else.
- while IFS=' ' read -r slug fsum csum; do
+ # IFS set to something else. The fourth column is the build signature; it is
+ # read and written back so this pass does not strip it -- rewriting a kept
+ # line with three fields would have silently dropped every signature and made
+ # each post look changed on the following build.
+ local bsum
+ while IFS=' ' read -r slug fsum csum bsum; do
[ -z "$slug" ] && continue
+ # A page entry names its own source path rather than a hashed filename, so
+ # it is checked against that path. Sweeping it by the post rule -- looking
+ # for a *.md in the blog root whose name hashes to fsum -- found nothing,
+ # because a page's source lives under pages/, so every page entry was
+ # discarded on the build that wrote it and pages never skipped at all.
+ case "$slug" in
+ page:*)
+ if [ -f "${PAGES_DIR:-pages}/${slug#page:}.md" ]; then
+ printf '%s %s %s %s\n' "$slug" "$fsum" "$csum" "$bsum" >> "$kept"
+ else
+ if [ -f "$dist/${slug#page:}.html" ]; then
+ echo "$T - /${slug#page:}.html (source removed)"
+ rm -f "$dist/${slug#page:}.html"
+ fi
+ fi
+ continue
+ ;;
+ esac
+
# Is there still a source file whose name hashes to fsum?
found=""
for f in *.md; do
@@ -360,7 +426,7 @@ function sweep_orphans() {
done
if [ -n "$found" ]; then
- printf '%s %s %s\n' "$slug" "$fsum" "$csum" >> "$kept"
+ printf '%s %s %s %s\n' "$slug" "$fsum" "$csum" "$bsum" >> "$kept"
else
if [ -f "$dist/post/$slug.html" ]; then
echo "$T - /post/$slug.html (source removed)"
diff --git a/test/regressions.sh b/test/regressions.sh
index 3cc6221..d9eb9da 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -250,6 +250,95 @@ 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"