Skip to content

fix(content): key the entry memo by (file, slug, locale) to stop fresh-build slug poisoning#29

Merged
linyiru merged 2 commits into
mainfrom
fix/content-memo-slug-poisoning
Jul 3, 2026
Merged

fix(content): key the entry memo by (file, slug, locale) to stop fresh-build slug poisoning#29
linyiru merged 2 commits into
mainfrom
fix/content-memo-slug-poisoning

Conversation

@linyiru

@linyiru linyiru commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

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.md rendered at /0001-x instead 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:

  1. Bootstrap two-pass (generateContent, intentional): on a fresh checkout the wrapper config imports the not-yet-generated app/_content.ts, so pass 1 scans with regex-guessed locales. adr matches LOCALE_DIR (/^[a-z]{2,3}(-...)*$/), so docs/adr/ is treated as a locale bucket whose files get FLAT slugs (0001-x, locale: "adr").
  2. The actual bug, fixed here: loadEntry's memo was keyed by file + mtime, but the cached ContentEntry embeds the slug/locale arguments. Pass 2 (declared locale set, computes the correct nested adr/0001-x) got pass 1's poisoned entry back (same file, same mtime) and froze the flat slugs into app/_content.ts.
  3. Warm builds never run pass 1, which is why local rebuilds looked correct and CI builds did not.

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 phantom locale: "adr".
  • generateContentModule: the frozen pass-2 emit carries "slug": "adr/0001-x" and no flat/locale remnants.
  • Both tests fail on the previous memo (verified by stashing the fix: 2 fail / 33 pass) and pass with it (35/35; whole suite 334/334).
  • End-to-end on a fresh build with the published stack: control (@junejs/server 0.0.55) flattens to /0001-x; with this patch the same fresh build nests /adr/0001-x and in-content links rewrite correctly.

Changeset: @junejs/server patch.

…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).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 generateContentModule assertion to ensure the frozen _content.ts output contains nested slugs and no phantom locale artifacts.
  • Publishes a patch changeset for @junejs/server describing 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.

Comment on lines +351 to +356
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"/);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@linyiru linyiru merged commit e0f2e6f into main Jul 3, 2026
5 checks passed
linyiru added a commit to linyiru/rustbgpd that referenced this pull request Jul 3, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants