Skip to content

Commit af6555a

Browse files
committed
fix(FR-2754): per-version metadata propagation in docs site (#7103)
Resolves #7101 (FR-2754) Stacked on top of #7099 (FR-2753). ## Why Two per-version-metadata bugs surface on the multi-version docs deploy (FR-2729). Both predate FR-2753; they are observable now that the version selector reaches a real archive on the deployed site. ### Bug 1 — Spurious version-mismatch banner From a page on the latest minor (e.g. `/26.4/ko/`), clicking "next" in the sidebar version selector lands on `/next/ko/index.html` with the banner *"이 페이지는 버전 next에 존재하지 않습니다. 해당 버전의 인덱스 페이지로 이동했습니다."*. The page exists in `next` — the message is wrong. Refreshing clears it. The reverse direction (`next → 26.4`) is fine. **Cause:** `availability` is computed from `pageRegistry.hasSlug(version, slug)` and the registry is filled incrementally as pages render. With `versions: [26.4 (latest), next]`, `next`'s slugs are not yet in the registry when 26.4 pages render, so `availability["next"] = false` is baked into 26.4 pages. The inline switcher script reads that and arms a sessionStorage notice on click. ### Bug 2 — Brand pill always shows the workspace version The pill in the topbar (`<span class="bai-brand-version">`) reads `v26.5.0-alpha.0 (sha)` on every page including pages under `/26.4/`. It should reflect the version the user is viewing. **Cause:** `metadata.version` is set once from `getDocVersion()` at build start and reused for every per-version render. ## What - **`VersionPageRegistry.declareSlug(version, slug)`** — records slug presence without adding a sitemap row. `record()` reuses it so callers cannot accidentally diverge the two paths. - **Pre-pass in `generateWebsite`** (versioned mode only) iterates every declared version, loads its `book.config.yaml`, and `declareSlug()`'s every (lang × nav-item) slug plus the synthetic home slug. Runs BEFORE any page is rendered, so per-page `availability` is correct regardless of build order. No markdown files are loaded — only `book.config.yaml` navigation entries. - **`pickDisplayVersion()` helper** in `versions.ts` chooses the brand pill text per render: workspace version in flat mode and on workspace-source entries (`next`); `pdfTag` (e.g. `v26.4.7`) for an archive-branch version when set; the version label itself as final fallback. `buildLanguage` calls it via metadata. - **`packages/backend.ai-webui-docs/.docs-archive/` gitignored** — that directory is the local materialization point for `docs-archive/<minor>` worktrees and must not be tracked back into the workspace. ## Tests 7 new unit tests in `versions.test.ts`: - `VersionPageRegistry.declareSlug` records slug presence without adding sitemap rows. - `record()` after `declareSlug()` is idempotent on the slug set but still emits one row per `record()` call. - `pickDisplayVersion`: - flat mode → workspace version - workspace-source `next` → workspace version - archive-branch with `pdfTag` → `pdfTag` - archive-branch without `pdfTag` → version label - missing `versionEntry` → workspace version (defensive) ## Verification - `pnpm --filter backend.ai-docs-toolkit test` → **146 pass / 0 fail** (was 139 before this PR). - `bash scripts/verify.sh` → ALL PASS. - `pnpm run build:web --lang all` against the live config with `docs-archive/26.4` worktree materialized: - `/26.4/ko/index.html` `data-availability` is `{"26.4":true,"next":true}` (was `{"26.4":true,"next":false}`). - `/26.4/ko/index.html` brand pill renders `v26.4.7` (was `v26.5.0-alpha.0 (sha)`). - `/next/ko/index.html` brand pill renders `v26.5.0-alpha.0 (sha)` (workspace) — preserved. - Browser-driven Playwright check (5/5 pass): 26.4→next click leaves sessionStorage clean (no banner); next→26.4 likewise; brand pill text matches the version on each page; both pages share the corrected `data-availability` map. ## Files changed - `packages/backend.ai-docs-toolkit/src/versions.ts` — `declareSlug` + `pickDisplayVersion`. - `packages/backend.ai-docs-toolkit/src/website-generator.ts` — pre-pass loop + `pickDisplayVersion` call site. - `packages/backend.ai-docs-toolkit/src/versions.test.ts` — 7 tests. - `packages/backend.ai-webui-docs/.gitignore` — `.docs-archive/` entry. ## Checklist - [x] Documentation — N/A (toolkit-internal change; user-facing doc pages unaffected) - [x] Minimum required manager version — N/A (no backend change) - [x] Test case(s) to demonstrate the difference of before/after — 7 unit tests + end-to-end build inspection + Playwright browser verification
1 parent d8af7bc commit af6555a

5 files changed

Lines changed: 287 additions & 4 deletions

File tree

packages/backend.ai-docs-toolkit/src/styles-web.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2101,6 +2101,20 @@ details > :last-child {
21012101
content column slightly and removes the chapter top padding so the
21022102
hero can breathe.
21032103
========================================================================== */
2104+
/*
2105+
* The home page does not emit a right-rail .doc-toc (it has no
2106+
* headings to scroll-spy), so the default .doc-page 3-column grid
2107+
* (sidebar + main + TOC) leaves an empty 240px column on the right
2108+
* and main sits flush against the sidebar — making the hero look
2109+
* left-shifted on wide viewports. The .doc-page--home modifier is
2110+
* already emitted on the home page outer div; collapse the grid to
2111+
* two columns there and center the (max-width 960px) main inside
2112+
* the post-sidebar region.
2113+
*/
2114+
.doc-page--home {
2115+
grid-template-columns: var(--bai-sider-w) minmax(0, 1fr);
2116+
}
2117+
21042118
.doc-main--home {
21052119
padding-top: 56px;
21062120
max-width: 960px;

packages/backend.ai-docs-toolkit/src/versions.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import {
2020
loadVersions,
2121
findLatest,
2222
canonicalPathFor,
23+
pickDisplayVersion,
2324
resolveVersionSource,
2425
VersionPageRegistry,
26+
type Version,
2527
} from "./versions.js";
2628
import type { ResolvedDocConfig, VersionEntry } from "./config.js";
2729

@@ -523,4 +525,133 @@ describe("VersionPageRegistry", () => {
523525
assert.equal(reg.hasSlug("25.05", "overview"), false);
524526
assert.equal(reg.enumerateAll().length, 3);
525527
});
528+
529+
it("declareSlug records slug presence without adding sitemap rows (FR-2754)", () => {
530+
// Bug 1 of FR-2754: when versions render in declared order, later
531+
// versions are absent from the registry while earlier ones render,
532+
// so the per-page `availability` map is wrong. Fix: pre-pass calls
533+
// declareSlug() for every (version, slug) pair before rendering.
534+
// declareSlug must NOT add sitemap rows — only record() does that.
535+
const reg = new VersionPageRegistry();
536+
reg.declareSlug("26.4", "index");
537+
reg.declareSlug("26.4", "quickstart");
538+
reg.declareSlug("next", "index");
539+
assert.equal(reg.hasSlug("26.4", "index"), true);
540+
assert.equal(reg.hasSlug("26.4", "quickstart"), true);
541+
assert.equal(reg.hasSlug("next", "index"), true);
542+
assert.equal(reg.hasSlug("next", "quickstart"), false);
543+
// Critical: declareSlug must not pollute the sitemap row stream.
544+
assert.equal(reg.enumerateAll().length, 0);
545+
});
546+
547+
it("record() after declareSlug() of the same (version, slug) is idempotent", () => {
548+
// The pre-pass declares every slug it can find from book.config;
549+
// the render pass then calls record() with the full row. The set
550+
// membership stays the same, but the sitemap row is added exactly
551+
// once per call to record().
552+
const reg = new VersionPageRegistry();
553+
reg.declareSlug("26.4", "overview");
554+
reg.record({
555+
version: "26.4",
556+
lang: "en",
557+
slug: "overview",
558+
path: "26.4/en/overview.html",
559+
isLatest: true,
560+
});
561+
reg.record({
562+
version: "26.4",
563+
lang: "ko",
564+
slug: "overview",
565+
path: "26.4/ko/overview.html",
566+
isLatest: true,
567+
});
568+
assert.equal(reg.hasSlug("26.4", "overview"), true);
569+
// declareSlug added 0 rows; the two record() calls each added 1.
570+
assert.equal(reg.enumerateAll().length, 2);
571+
});
572+
});
573+
574+
describe("pickDisplayVersion — FR-2754 brand version pill", () => {
575+
const archive26: Version = {
576+
label: "26.4",
577+
source: { kind: "archive-branch", ref: "docs-archive/26.4" },
578+
isLatest: true,
579+
outDir: "26.4",
580+
pdfTag: "v26.4.7",
581+
};
582+
const archive26NoTag: Version = {
583+
label: "26.3",
584+
source: { kind: "archive-branch", ref: "docs-archive/26.3" },
585+
isLatest: false,
586+
outDir: "26.3",
587+
};
588+
const workspaceNext: Version = {
589+
label: "next",
590+
source: { kind: "workspace" },
591+
isLatest: false,
592+
outDir: "next",
593+
};
594+
const ws = "v26.5.0-alpha.0 (be6c92b05)";
595+
596+
it("returns the workspace version in flat (non-versioned) mode", () => {
597+
assert.equal(
598+
pickDisplayVersion({
599+
workspaceVersion: ws,
600+
versionLabel: null,
601+
versionEntry: null,
602+
}),
603+
ws,
604+
);
605+
});
606+
607+
it("returns the workspace version for the workspace-source `next` entry", () => {
608+
// `next` IS the workspace tip, so the SHA is meaningful and the
609+
// pdfTag concept does not apply.
610+
assert.equal(
611+
pickDisplayVersion({
612+
workspaceVersion: ws,
613+
versionLabel: "next",
614+
versionEntry: workspaceNext,
615+
}),
616+
ws,
617+
);
618+
});
619+
620+
it("returns the pdfTag for archive-branch versions when set (e.g. v26.4.7)", () => {
621+
// This is the user-facing fix: visiting `/26.4/...` should reveal
622+
// exactly which Backend.AI release the docs were cut from, not the
623+
// workspace version of whichever build emitted the page.
624+
assert.equal(
625+
pickDisplayVersion({
626+
workspaceVersion: ws,
627+
versionLabel: "26.4",
628+
versionEntry: archive26,
629+
}),
630+
"v26.4.7",
631+
);
632+
});
633+
634+
it("falls back to the version label when an archive-branch entry has no pdfTag", () => {
635+
assert.equal(
636+
pickDisplayVersion({
637+
workspaceVersion: ws,
638+
versionLabel: "26.3",
639+
versionEntry: archive26NoTag,
640+
}),
641+
"26.3",
642+
);
643+
});
644+
645+
it("returns the workspace version when versionEntry is missing (defensive)", () => {
646+
// If the caller couldn't resolve the entry from the label, the
647+
// safe fallback is the workspace version — same as flat mode.
648+
assert.equal(
649+
pickDisplayVersion({
650+
workspaceVersion: ws,
651+
versionLabel: "26.4",
652+
versionEntry: null,
653+
}),
654+
ws,
655+
);
656+
});
526657
});

packages/backend.ai-docs-toolkit/src/versions.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,38 @@ export function canonicalPathFor(
367367
return `${loaded.latest.label}/${lang}/${slug}.html`;
368368
}
369369

370+
/**
371+
* Choose the version string to render in the topbar brand pill for a
372+
* single page render (FR-2754 Fix 2).
373+
*
374+
* - Flat / non-versioned mode (`versionLabel === null`): use the
375+
* workspace version (`package.json` version + git short SHA from
376+
* `getDocVersion()`). Same as pre-FR-2754 behavior.
377+
* - Workspace-source version (`next`): also use the workspace
378+
* version — `next` IS the workspace tip, so the SHA is meaningful.
379+
* - Archive-branch version (`26.4` and earlier minors): prefer its
380+
* pinned release tag (`pdfTag`, e.g. `v26.4.7`) so the pill tells
381+
* the reader exactly which Backend.AI release they are reading
382+
* docs for. Fall back to the version label itself when no tag was
383+
* configured.
384+
*
385+
* Kept here next to `Version` / `LoadedVersions` so the rule stays in
386+
* one place; `website-generator.ts` just calls this from inside
387+
* `buildLanguage`.
388+
*/
389+
export function pickDisplayVersion(args: {
390+
workspaceVersion: string;
391+
versionLabel: string | null;
392+
versionEntry: Version | null | undefined;
393+
}): string {
394+
const { workspaceVersion, versionLabel, versionEntry } = args;
395+
if (!versionLabel || !versionEntry) return workspaceVersion;
396+
if (versionEntry.source.kind === "archive-branch") {
397+
return versionEntry.pdfTag ?? versionEntry.label;
398+
}
399+
return workspaceVersion;
400+
}
401+
370402
/**
371403
* Cross-version slug map: for a given slug, which versions contain it.
372404
* The header version selector uses this to fall back to the version's
@@ -382,12 +414,25 @@ export class VersionPageRegistry {
382414

383415
record(row: PageEnumerationRow): void {
384416
this.rows.push(row);
385-
let bucket = this.slugsByVersion.get(row.version);
417+
this.declareSlug(row.version, row.slug);
418+
}
419+
420+
/**
421+
* Mark `slug` as existing in `version` without adding a sitemap row
422+
* (FR-2754). The website generator's pre-pass uses this to populate
423+
* the slug-by-version map for every declared version BEFORE any page
424+
* is rendered, so the per-page `availability` map is correct
425+
* regardless of build/iteration order. Calling this for a slug that
426+
* is later passed to `record()` is harmless — `Set.add` is
427+
* idempotent.
428+
*/
429+
declareSlug(version: string, slug: string): void {
430+
let bucket = this.slugsByVersion.get(version);
386431
if (!bucket) {
387432
bucket = new Set<string>();
388-
this.slugsByVersion.set(row.version, bucket);
433+
this.slugsByVersion.set(version, bucket);
389434
}
390-
bucket.add(row.slug);
435+
bucket.add(slug);
391436
}
392437

393438
/** True when `slug` exists in `version` (any language). */

packages/backend.ai-docs-toolkit/src/website-generator.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { processMarkdownFilesForWeb } from "./markdown-processor-web.js";
1212
import type { LinkDiagnostic } from "./markdown-processor-web.js";
1313
import {
1414
RESERVED_HOME_SLUG,
15+
resolveMarkdownPath,
1516
slugFromNavPath,
1617
} from "./markdown-processor.js";
1718
import {
@@ -57,6 +58,7 @@ import {
5758
} from "./image-optimizer.js";
5859
import {
5960
loadVersions,
61+
pickDisplayVersion,
6062
resolveVersionSource,
6163
VersionPageRegistry,
6264
type LoadedVersions,
@@ -561,6 +563,78 @@ export async function generateWebsite(
561563
// language. Populated inside the versioned-mode loop below.
562564
let latestVersionLanguages: string[] | null = null;
563565

566+
// FR-2754 Fix 1: pre-populate the slug registry before any version is
567+
// rendered, so per-page `availability` is correct regardless of build
568+
// order. Without this pass the FIRST version in the loop sees an
569+
// empty registry for every other version and bakes
570+
// `availability[<other>] = false` into its pages — which spuriously
571+
// fires the version-mismatch banner on click even when the slug
572+
// exists in both versions. The walk only consults `book.config.yaml`
573+
// navigation, so it does not need any markdown files to be loaded.
574+
// Versions whose archive-branch source isn't materialized are still
575+
// skipped (no entries declared); their availability remains `false`,
576+
// which is the desired fallback.
577+
if (loadedVersions.enabled) {
578+
for (const v of loadedVersions.entries) {
579+
const resolved = resolveVersionSource(config, v);
580+
if (!resolved.ok || !resolved.rootDir) continue;
581+
const versionConfig: ResolvedDocConfig =
582+
v.source.kind === "workspace"
583+
? config
584+
: {
585+
...config,
586+
projectRoot: resolved.rootDir,
587+
srcDir: path.join(resolved.rootDir, "src"),
588+
};
589+
let versionBookConfig: NormalizedBookConfig;
590+
try {
591+
versionBookConfig = loadBookConfig(versionConfig.srcDir);
592+
} catch {
593+
// If a past minor's archive can't be parsed for any reason,
594+
// skip it here — the main render loop below will surface the
595+
// diagnostic with full context.
596+
continue;
597+
}
598+
// The synthetic home chapter writes to `<lang>/index.html` and
599+
// is always present in every version's output, so register it
600+
// even though it has no `navigation[]` entry of its own.
601+
pageRegistry.declareSlug(v.label, RESERVED_HOME_SLUG);
602+
// Mirror the renderer's path resolution: a `book.config.yaml`
603+
// entry whose markdown file does not actually exist in the
604+
// version's `src/<lang>/` tree is silently skipped by
605+
// `processMarkdownFilesForWeb` (it warns and continues), so
606+
// declaring its slug here would over-promise — the version
607+
// selector would link to a slug that never produced HTML and
608+
// 404. We therefore gate `declareSlug()` on `resolveMarkdownPath`
609+
// succeeding for at least one language; once the slug is on disk
610+
// for any lang the renderer will emit it for that lang and the
611+
// version-switcher fallback to the index page is enough for the
612+
// languages where it is missing. `pathFallbacks` is sourced from
613+
// the version's resolved config, mirroring the renderer.
614+
const pathFallbacks = versionConfig.pathFallbacks ?? {};
615+
for (const lang of languages) {
616+
if (!versionBookConfig.languages.includes(lang)) continue;
617+
const navGroups: NavGroup[] =
618+
versionBookConfig.navigationGroups[lang] ?? [];
619+
for (const group of navGroups) {
620+
for (const item of group.items) {
621+
try {
622+
resolveMarkdownPath(
623+
lang,
624+
item.path,
625+
versionConfig.srcDir,
626+
pathFallbacks,
627+
);
628+
} catch {
629+
continue;
630+
}
631+
pageRegistry.declareSlug(v.label, slugFromNavPath(item.path));
632+
}
633+
}
634+
}
635+
}
636+
}
637+
564638
if (loadedVersions.enabled) {
565639
// Versioned mode — iterate over each declared version.
566640
// Past minors that fail to resolve their archive-branch worktree are
@@ -876,7 +950,21 @@ async function buildLanguage(args: {
876950
for (const d of langDiagnostics) allDiagnostics.push(d);
877951

878952
const availableLanguages = bookConfig.languages;
879-
const metadata: WebsiteMetadata = { title, version, lang, availableLanguages };
953+
// FR-2754 Fix 2: choose what the brand version pill shows for THIS
954+
// version. See `pickDisplayVersion` for the exact rule.
955+
const displayVersion = pickDisplayVersion({
956+
workspaceVersion: version,
957+
versionLabel,
958+
versionEntry: versionLabel
959+
? loadedVersions.entries.find((v) => v.label === versionLabel) ?? null
960+
: null,
961+
});
962+
const metadata: WebsiteMetadata = {
963+
title,
964+
version: displayVersion,
965+
lang,
966+
availableLanguages,
967+
};
880968

881969
// F3: nav groups for the current language. Always present — even legacy
882970
// flat configs are wrapped in a single anonymous-category group by the
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
dist/
22
node_modules/
33
.agent-output/
4+
# Local checkouts of `docs-archive/<minor>` branches used by
5+
# docs-toolkit for versioned builds. Materialized via `git worktree
6+
# add .docs-archive/docs-archive__<minor> docs-archive/<minor>` and
7+
# never tracked back into the workspace.
8+
.docs-archive/

0 commit comments

Comments
 (0)