fix(content): key the entry memo by (file, slug, locale) to stop fresh-build slug poisoning#29
Conversation
…h-build slug poisoning The loadEntry memo was keyed by file path + mtime, but the cached ContentEntry embeds the slug and locale ARGUMENTS, which are scan-dependent. generateContent's bootstrap two-pass scans the same tree twice in one process: pass 1 without the config (regex locale guessing, where a 2-3 letter folder like docs/adr/ matches the BCP-47 shape and becomes a locale bucket whose files get FLAT slugs) and pass 2 with the declared locale set (nested slugs). Same file, same mtime, so pass 2 got pass 1's poisoned entry back and froze flat slugs into app/_content.ts. Only FRESH builds hit this (app/_content.ts must be missing for pass 1 to run), which made it look environment-specific: CI/action builds in a clean dir flattened docs/adr/0001-x to /0001-x while a warm local rebuild of the same tree nested it correctly. Regression tests cover both the scan pair and the frozen emit; verified end to end on a fresh build (control on the published package flattens, patched build nests and rewrites in-content links).
There was a problem hiding this comment.
Pull request overview
This PR addresses a fresh-build-only slug flattening bug in the June content pipeline, caused by memoization returning a cached ContentEntry whose embedded slug/locale came from an earlier “guessed locale” scan.
Changes:
- Adds regression coverage to ensure a guessed-locale scan (pass 1) cannot poison a declared-locale rescan (pass 2).
- Adds a
generateContentModuleassertion to ensure the frozen_content.tsoutput contains nested slugs and no phantom locale artifacts. - Publishes a patch changeset for
@junejs/serverdescribing the fix and its impact.
Reviewed changes
Copilot reviewed 2 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| packages/june/test/content.test.ts | Adds a focused regression suite covering the two-pass “slug poisoning” scenario and verifying frozen module output. |
| .changeset/content-memo-slug-poisoning.md | Declares a patch release and documents the fresh-build slug flattening fix. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| test("the frozen _content.ts of the rescan carries the nested slugs", () => { | ||
| generateContentModule(dir, undefined); // pass 1 (throwaway) | ||
| const { code } = generateContentModule(dir, []); // pass 2 — what lands in app/_content.ts | ||
| expect(code).toMatch(/"slug": "adr\/0001-x"/); | ||
| expect(code).not.toMatch(/"slug": "0001-x"/); | ||
| expect(code).not.toMatch(/"locale": "adr"/); |
There was a problem hiding this comment.
Great catch, fixed in b3bd4fb. The separator is now the \u0000 escape sequence in source (runtime key unchanged: NUL still separates, and it cannot appear in file paths or slugs), so the file is plain text to grep/diff tooling again. Verified: 0 raw NUL bytes, file(1) reports UTF-8 text, suite still 35/35.
The (file, slug, locale) memo key used literal NUL bytes as separators, which made content.ts a binary file to grep/ripgrep/diff tooling. Write the separator as the \u0000 escape instead: the runtime key is byte-identical (NUL still cannot appear in paths or slugs), but the source file is plain text again.
…ttening) The ADRs were dropped because fresh CI builds flattened docs/adr/0001-x to /0001-x (a June content-memo bug, junebuild/june#29). With @junejs/server 0.0.56 the fresh build nests them correctly, so the Internals tab gets its design records back.
What
Fixes a slug-flattening bug that only bites FRESH builds: a docs subfolder whose name matches the BCP-47 shape (
adr/,cli/,api/,sdk/, ...) had its pages frozen with flat slugs (docs/adr/0001-x.mdrendered at/0001-xinstead of/adr/0001-x), while a warm rebuild of the same tree nested them correctly. Found in production on a Kura site built by the kurajs/pages action (always a clean dir): the sidebar and prerendered pages were internally-consistent but wrongly flat.Root cause
Three cooperating steps, pinned by a deterministic repro:
generateContent, intentional): on a fresh checkout the wrapper config imports the not-yet-generatedapp/_content.ts, so pass 1 scans with regex-guessed locales.adrmatchesLOCALE_DIR(/^[a-z]{2,3}(-...)*$/), sodocs/adr/is treated as a locale bucket whose files get FLAT slugs (0001-x,locale: "adr").loadEntry's memo was keyed byfile+ mtime, but the cachedContentEntryembeds theslug/localearguments. Pass 2 (declared locale set, computes the correct nestedadr/0001-x) got pass 1's poisoned entry back (same file, same mtime) and froze the flat slugs intoapp/_content.ts.Fix
Key the memo by
(file, slug, locale)+ mtime. One-line semantic change; dev-edit invalidation via mtime is unchanged, and the map stays bounded (one extra entry per file only during the bootstrap pass pair).Tests
scanCollection: a guessed-locale scan (pass 1) must not poison the declared-locale rescan (pass 2): nested slugs, no phantomlocale: "adr".generateContentModule: the frozen pass-2 emit carries"slug": "adr/0001-x"and no flat/locale remnants.@junejs/server0.0.55) flattens to/0001-x; with this patch the same fresh build nests/adr/0001-xand in-content links rewrite correctly.Changeset:
@junejs/serverpatch.