# INFRA-F47 — In-site Changelog (docs version menu 404 fix)

> Date: 2026-07-08 · Owner-gated task from `docs/internal/backlog.md` INFRA-F47.
> Status: design approved (owner picked "站内 changelog + 立项 Layer B"); approach **H** picked after adversarial self-review.

## Problem (baseline-verified, not backlog-assumed)

The docs-site top toolbar version menu ([`playground/docs/DocsShell.vue`](../../../playground/docs/DocsShell.vue) `versionOptions`) is **not** a docs-version switcher — each historical-version entry is an **external link** to GitHub, `target=_blank`, deep-linking a CHANGELOG.md anchor. The backlog's hypothesis ("部署/路由/多版本快照机制") was **wrong**.

Root cause of the 404, both live-verified:

1. **Branch typo** — version entries hardcode `/blob/main/CHANGELOG.md#…` (line 172) but the repo default branch is `master` (no `main` branch exists on GitHub — `git ls-remote github` + `gh repo view` confirm). The bottom "Changelog" entry (line 23) correctly uses `/blob/master/` → works; every per-version entry 404s. Matches owner's "只有最新版能显示、切旧版 404".
2. **Private repo** — `NancyZeng0210/TVU-Design-System` is PRIVATE (`gh repo view`). These GitHub links only resolve for authenticated users with repo access; the docs-site P0 audience (npm consumers) has no access → every version link is a dead link for them regardless of the branch fix.

(The CHANGELOG anchor slug is correct: `## 0.10.1` → GitHub slug `0101` = code `version.replace(/\./g,'')`. No anchor change needed.)

## Goal

Kill the 404 **and** the private-repo dead-link by moving version history **in-site**. Version menu points to an in-site changelog page. This also seeds "in-site version viewing" (Layer B's first step).

## Approach: H — zero-dependency structured render

Chosen over alternatives after self-review:

- **B1** (marked as runtime dep) — **rejected**: the deploy path is `deploy-hook: git pull + npm build` and `build` runs **no install step**; a new runtime import would be unresolved on the deploy server → build fails → auto-deploy breaks. This is the exact META-02 failure class (fixed there by tracking `react-pilot/dist`).
- **B2** (marked at dev-time, commit generated `changelog.html`) — deploy-safe but adds a devDep + committed artifact + a `changelog:build` script + a staleness sync-gate coupled to every release CHANGELOG edit.
- **C** (hand-rolled runtime markdown) — **rejected**: the existing CHANGELOG already contains the hardest cases (`<TopBar>`/`<Message />`/`<!-- -->` literals that a naive v-html renderer silently drops; a fenced code block nested inside a multi-paragraph list item). High risk of silent content loss.
- **H (chosen)** — render in-site with **zero new dependency, no artifact, no gate, no v-html**. Use the existing `parseVersions` to split CHANGELOG into version sections; render each version's raw body verbatim in `<pre>{{ body }}</pre>` via Vue text interpolation (auto-escaped → zero injection, zero content-drop). Cost: body prose is monospace and links are not clickable — acceptable for a changelog.

## Components

1. **`playground/docs/docs-meta.ts`** — extend `parseVersions` to also capture each version's raw body (lines between its heading and the next version heading), and any preamble before the first version heading. Export a single-source-of-truth `versionAnchorId(version)` helper (`0.10.1` → `v-0-10-1`) used by both the page and the menu so anchors never drift.

2. **`playground/docs/pages/ChangelogPage.vue`** (new hidden page) — renders optional preamble + one `<section :id="versionAnchorId(v.version)">` per version: styled `<h2>` (version + kind badge) and `<pre class="changelog-body">{{ v.body }}</pre>`. Styling uses design tokens only (no raw hex) to stay clear of any token audit and match docs chrome.

3. **`playground/docs/navigation.ts`** — add `'changelog'` to `CanonicalPageId`, to `HIDDEN_PAGE_IDS` (not shown in left nav; entered from the version menu), a `getPagePath('changelog') → '/changelog'` branch, and a `getPageIdFromLocation` branch resolving `#/changelog` (and tolerating a trailing `/…` segment) to `'changelog'`.

4. **`playground/docs/DocsShell.vue`** — add `changelog` entries to the `pageLoaders` and `pageComponents` `satisfies Record<CanonicalPageId, …>` maps (vue-tsc enforces this exhaustively). Rework `versionOptions`: each item carries a `version` (bottom "Changelog" entry → `null` = top of page) instead of an external `href`. Menu items are `<a href="#/changelog" @click.prevent>` → `handleVersionSelect(version)` sets the module-scoped pending-anchor ref, closes the menu, and `navigateTo('changelog')`. Drop the now-unused `repositoryUrl`/`changelogUrl` constants and the `target=_blank`/external-href markup.

   **Scroll design (headless-verified decision):** the version scroll is menu-driven and **transient** — the route stays `#/changelog` and never carries the version segment. This is deliberate and consistent with the app's documented design that section anchors are not persisted to the URL (`DocsShell.scrollToSection`). An earlier attempt to make `#/changelog/<version>` a shareable/refreshable deep link was rejected after headless testing showed `DocsShell.onMounted`'s URL normalization (`navigateTo(initialPage, true)` → `getPagePath('changelog')` = `/changelog`) strips the version segment before `ChangelogPage` mounts. ChangelogPage consumes the pending ref via **both** `onMounted` (fresh navigation) and a `watch` (picking another version while already on the page).

## Data flow

`CHANGELOG.md?raw` → `parseVersions` (version + kind + body + preamble) → `versionHistory` consumed by both `versionOptions` (menu) and `ChangelogPage` (render). Menu click → `navigateTo('changelog')` + set pending anchor → ChangelogPage mount → `scrollIntoView` to `versionAnchorId`.

## Testing

- **Unit (`tests/`, jsdom, existing config)** — `parseVersions` returns a non-empty body for a known version, captures every `## x.y.z` heading, and `versionAnchorId` round-trips (`0.10.1`↔`v-0-10-1`). Pure-function test, no DOM-of-playground dependency.
- **Gate suite (all green before commit)** — `vue-tsc --noEmit` (0), `pnpm test` (395 passed / 5 skipped, incl. the new 3), `build:playground` (0; ChangelogPage lazy chunk emitted, no new dep), `audit-demo-slot-boolean-coverage` (0; in-scope=28, side-failures=0), `audit-demo-framework-parity` (0; components=25, fails=0). The two page audits derive scope from `components.config.ts` / a hardcoded `PILOT_MAP`, so a non-component page runs them but is not required by them — empirically confirmed.
- **Headless render (playwright/chromium, throwaway script)** — direct `#/changelog` load renders all 14 version sections + title; body preserves a literal `<TopBar …>` token verbatim (H's zero-content-drop property, live-confirmed); a version-menu click navigates in-site and scrolls the picked section into view; all 15 menu links are `#/changelog` (no GitHub target); no console/page errors. This caught the URL-normalization scroll gap above.

## Risks (all gate-caught, not silent)

- Adding to `CanonicalPageId` forces the two exhaustive `Record<CanonicalPageId,…>` maps to gain a `changelog` entry — vue-tsc fails loudly if missed.
- Route regex for `#/changelog` must not shadow `/component/…` or `/internal/…` — covered by unit test + existing routing behavior.
- Non-blocking: `audit:docs-site` / `site-review-manifest.json` may want a changelog entry (not a pre-commit gate) — register for cleanliness.

## Out of scope — Layer B (separate owner-gated backlog item)

Interactive cross-version docs viewing (build + archive a docs snapshot per release at `/v0.9/` etc., version menu points to snapshots, for visual comparison — high value alongside F46 dual-framework). Heavy: deploy-pipeline changes + old-version builds may be unreproducible. Filed separately; not in this change.

## Commit discipline

Docs-chrome visual change → `VISUAL_COMMIT_APPROVED=1` + three-remote push (Gitea + GitHub). Stage only F47 files; leave parallel-session dirty files (F48 `variables.css`/`audit-sort-tokens.*`/`package.json`, `figma-data/*`) untouched — never `git add -A`.
