From dbde9007f4efef31dedadcb557c651fef9fc5c27 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:02:02 +0100 Subject: [PATCH] docs: add Eleventy build performance optimization spec --- ...03-19-eleventy-build-performance-design.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-19-eleventy-build-performance-design.md diff --git a/docs/superpowers/specs/2026-03-19-eleventy-build-performance-design.md b/docs/superpowers/specs/2026-03-19-eleventy-build-performance-design.md new file mode 100644 index 0000000..7e89b89 --- /dev/null +++ b/docs/superpowers/specs/2026-03-19-eleventy-build-performance-design.md @@ -0,0 +1,145 @@ +# Eleventy Build Performance Optimization — Design Spec + +**Date:** 2026-03-19 +**Goal:** Reduce production build time (GitHub Actions deploy) +**Approach:** Measure first, fix known low-hanging fruit in the same pass + +--- + +## Context + +The blog runs Eleventy 3 with a monolithic `eleventy.config.js`. Two `eleventy.before` hooks run outside Eleventy's own timer: + +1. **OG image generation** — spawns a subprocess loop using Satori + resvg; manifest-based so incremental, but the loop still initializes every build. +2. **Unfurl prefetch** — walks all content files, parses frontmatter, calls `prefetchUrl()` for every interaction URL (likes, bookmarks, replies, reposts). Network responses are disk-cached by `eleventy-fetch`, but the walk + parse + cache-hit overhead runs unconditionally on every build. + +One filter is also a known repeated-work issue: + +- **`hash` filter** — reads a file from disk and computes MD5 on every call. The same 2–3 paths are passed on every page render (hundreds of pages), with no caching. + +Inspired by [rmendes.net — Optimizing Eleventy Build Performance](https://rmendes.net/articles/2026/03/10/optimizing-eleventy-build-performance/). + +--- + +## Changes + +### 1. Build timing instrumentation + +**Purpose:** Establish a baseline and validate that fixes in changes 2 and 3 have real impact. + +**Implementation:** +- Add `console.time("[og] image generation")` / `console.timeEnd(...)` around the OG `eleventy.before` hook body in `eleventy.config.js`. +- Add `console.time("[unfurl] prefetch")` / `console.timeEnd(...)` around the unfurl `eleventy.before` hook body. +- No logic is touched; labels are prefixed with `[og]` and `[unfurl]` to match existing log conventions. + +**How to use:** +```bash +DEBUG=Eleventy:Benchmark* npm run build 2>&1 | grep -E "Benchmark|og|unfurl" +``` + +This captures both Eleventy's internal timings (collections, transforms, filters, rendering) and the two before-hook timings in a single build run. + +--- + +### 2. `hash` filter memoization + +**Location:** `eleventy.config.js` — `addFilter("hash", ...)` at ~line 744 + +**Problem:** `readFileSync()` + MD5 on every call. With 500+ pages each calling `| hash` for 2–3 asset paths, this is ~1000+ redundant disk reads per build. + +**Fix:** Wrap with a `Map` cache, cleared on `eleventy.before`: + +```js +const _hashCache = new Map(); +eleventyConfig.on("eleventy.before", () => { _hashCache.clear(); }); +eleventyConfig.addFilter("hash", (filePath) => { + if (_hashCache.has(filePath)) return _hashCache.get(filePath); + try { + const fullPath = resolve(__dirname, filePath.startsWith("/") ? `.${filePath}` : filePath); + const result = createHash("md5").update(readFileSync(fullPath)).digest("hex").slice(0, 8); + _hashCache.set(filePath, result); + return result; + } catch { + return Date.now().toString(36); + } +}); +``` + +**Properties:** +- Cache is per-build (cleared on `eleventy.before`), so a CSS change during `npm run dev` is always picked up on the next rebuild. +- Pattern is identical to existing `_dateDisplayCache` and `_isoDateCache`. + +--- + +### 3. Unfurl prefetch manifest skip + +**Location:** `eleventy.config.js` — the `async eleventy.before` hook at ~line 1421 + +**Problem:** The hook walks all content files and calls `prefetchUrl()` for every interaction URL on every build, even when no new interaction posts have been added since the last build. + +**Fix:** A manifest file at `.cache/unfurl-manifest.json` tracks the set of previously prefetched URLs. On each build, only URLs absent from the manifest are prefetched. If no new URLs exist, the hook exits immediately after the walk. + +**Manifest lifecycle:** +- **First build / cold cache:** no manifest → full prefetch → manifest written with all URLs. +- **Subsequent builds, no new posts:** manifest loaded → `newUrls` is empty → early return, no network calls. +- **New interaction post added:** manifest loaded → new URL detected → only that URL prefetched → manifest updated. +- **Manifest deleted / corrupted:** falls back to full prefetch (same as first build). + +**Implementation sketch:** + +```js +eleventyConfig.on("eleventy.before", async () => { + const contentDir = resolve(__dirname, "content"); + if (!existsSync(contentDir)) return; + + // --- existing walk to collect `urls` Set (unchanged) --- + + if (urls.size === 0) return; + + // Load manifest of previously seen URLs + const manifestPath = resolve(__dirname, ".cache", "unfurl-manifest.json"); + let seen = new Set(); + try { + seen = new Set(JSON.parse(readFileSync(manifestPath, "utf-8"))); + } catch { /* first build */ } + + const newUrls = [...urls].filter(u => !seen.has(u)); + + if (newUrls.length === 0) { + console.log("[unfurl] No new URLs — skipping prefetch"); + return; + } + + // Prefetch only new URLs using existing batch logic + // (replace `urlArray` with `newUrls` in the batch loop) + + // Update manifest + mkdirSync(resolve(__dirname, ".cache"), { recursive: true }); + writeFileSync(manifestPath, JSON.stringify([...urls])); +}); +``` + +**Properties:** +- Manifest stored in `.cache/` — same gitignore and passthrough-copy rules as existing unfurl cache. +- `writeFileSync` is used (not async) to guarantee the manifest is written even if the process exits unexpectedly mid-batch. On failure, next build falls back to full prefetch. +- The content walk still runs on every build (cheap: local FS reads of small frontmatter). Only the prefetch calls are skipped. + +--- + +## Files changed + +| File | Change | +|------|--------| +| `eleventy.config.js` | Add timing to 2 before-hooks; memoize `hash` filter; add manifest skip to unfurl hook | + +No new files. No new dependencies. + +--- + +## Success criteria + +1. `console.time` output visible in production build logs. +2. `DEBUG=Eleventy:Benchmark*` output available for baseline comparison. +3. On a build with no new posts: `[unfurl] No new URLs — skipping prefetch` logged. +4. Hash filter: same cache-busting behaviour as before (values change when files change between builds). +5. No change to rendered output.