Skip to content

Fix language switch losing the section anchor - #76

Open
alecchen wants to merge 2 commits into
walkinglabs:mainfrom
alecchen:fix/canonical-heading-anchors
Open

alecchen wants to merge 2 commits into
walkinglabs:mainfrom
alecchen:fix/canonical-heading-anchors

Conversation

@alecchen

@alecchen alecchen commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a language switch sending the reader back to the top of the page when the URL has a section anchor. Section ids become the same in every locale, which is what vitepress.dev's own multilingual docs do. The ids get applied at build time, so no markdown file changes.

Problem

Open a lecture in any locale, click a section in the outline, then switch language:

zh-TW  .../lecture-04-why-one-giant-instruction-file-fails/#實際案例   <- reader is on 實際案例
       switch language via the navbar dropdown
en     .../lecture-04-why-one-giant-instruction-file-fails/#實際案例   <- no such id, jumps to top

VitePress carries the current hash across a locale switch. theme-default/composables/langs.js builds every locale link as normalizeLink(...) + hash.value, with no condition on it. There is no built-in anchor translation.

Heading ids, though, get auto-slugged from heading text. No heading in this repo uses an explicit id, so each locale ends up with ids in its own language: 實際案例, 実際の事例, praxisbeispiel. The carried #實際案例 has no matching element on the English page, so VitePress skips the hash-scroll path and calls window.scrollTo(0, 0).

The convention this repo was missing is the one VitePress's own docs use. On vitepress.dev/zh/guide/markdown the heading is <h2 id="header-anchors">标题锚点</h2>. The id is the English slug and the text is translated. Switching that page's language on #header-anchors lands on the right section.

Approach

Keep heading ids canonical in every locale without touching content. docs/.vitepress/anchor-map.json gets generated from the English source and applied through markdown.anchor.slugifyWithState:

slugifyWithState(source, state) {
  const [locale, ...rest] = String(state.env.relativePath ?? '').split('/')
  const page = (anchorMap as Record<string, { ids: string[]; locales: Record<string, number> }>)[
    rest.join('/')
  ]
  if (page && page.locales[locale] === page.ids.length) {
    const index = (state.env.__anchorIndex = (state.env.__anchorIndex ?? -1) + 1)
    if (page.ids[index]) return page.ids[index]
  }
  return defaultSlugify(source)
}

Same id, four different titles:

locale id visible heading
en real-world-example Real-World Example
zh-TW real-world-example 實際案例
ja real-world-example 実例
ar real-world-example مثال من الواقع

Why not the documented {#id} syntax

## 實際案例 {#real-world-example} is the documented VitePress mechanism and would also work, but it needs roughly 3150 content edits across 14 locales. It also pushes the literal suffix into the header anchor's accessibility label on every annotated heading:

aria-label="Permalink to &quot;實際案例 {#real-world-example}&quot;"

I checked this against the real renderer. getTokensText cannot clean it up, because that option does not receive the token carrying the suffix. The build-time map renders a clean Permalink to "實際案例".

Files

File
scripts/anchor-map-utils.ts Shared VitePress slugifier (transcribed from the dist bundle) plus the fence-aware heading collector
scripts/build-anchor-map.ts Generates the map from the English source
scripts/validate-anchors.ts Fails if the map goes stale; the editing rules and drift list live in its header
docs/.vitepress/anchor-map.json 105 pages: canonical ids plus per-locale heading counts
docs/.vitepress/config.mts anchor.slugifyWithState
package.json anchors:build, anchors:check
.github/workflows/deploy-pages.yml Runs anchors:check before the build

The slugifier is VitePress's, not github-slugger. The two disagree on underscores (foo_bar becomes foo-bar rather than foo_bar) and on leading digits (1. Getting Started becomes _1-getting-started rather than 1-getting-started). github-slugger is declared in package.json but imported nowhere in the repo, so I left it alone.

The id list includes the H1. The anchor plugin defaults to level: 1 and its unique-slug counter starts there, so collecting from the first H2 would shift every id by one.

Drift (reported, not fixed)

Ids are matched by position, not by heading text, so the map only gets applied where a locale's heading count equals English. 15 pages differ and keep their current localized ids:

Page en headings drift
lectures/lecture-11-why-observability-belongs-inside-the-harness 15 9 locales (ar, de, es, fr, ja, ko, ru, tr, uz)
lectures/lecture-12-why-every-session-must-leave-a-clean-state 16 9 locales (same set)
projects/project-01-baseline-vs-minimal-harness 13 13 locales
projects/project-02..06 5 each zh, zh-TW (fuller bodies than en)
resources/openai-advanced, resources/reference, resources/templates (+2 repo-template files) 5-10 ar, ja, ko, ru, vi, fr
harness-designs/codex 9 zh

Cross-language anchor links do not resolve on those pages, and they did not before this change either. No link lands on the wrong section. npm run anchors:check lists them as a note and exits 0.

Judgment calls (please review)

Drift is reported rather than fixed. The project pages are the clearest case: English is a 5-heading stub while zh/zh-TW carry full run protocols, so English is the incomplete document and matching would mean writing English course content. I can follow up separately if you want the structures aligned.

anchors:check gates the existing Pages deploy instead of getting its own workflow. It needs no browser and runs in about a second. Tell me if you would rather have a standalone workflow.

The map is committed (2763 lines) so the build does not depend on a prebuild step. Regenerate it with npm run anchors:build.

Test plan

  • npm run anchors:check exits 0 (105 pages), and exits 1 when the map is tampered with
  • npm run docs:build passes from a clean dist/cache (vitepress 1.6.4; only the pre-existing chunk-size warning)
  • All 105 English pages: generated ids identical to the real renderer's output, 0 mismatches
  • English headings unchanged: built the site with the map active and again with slugifyWithState stubbed to the default slugifier, then hashed every <h1..h3> tag on all 105 English pages. Identical in both builds.
  • No duplicate ids: 626 pages across en, zh-TW, ja, de, ar and ko, 0 duplicates
  • Reported bug fixed end to end (Playwright against the built site): zh-TW outline click, switch to English, lands on "Real-World Example" at scrollY 4110 instead of the top
  • Outline still shows translated text with canonical hrefs: 問題的根源:一個惡性循環 -> #the-vicious-cycle-at-the-root
  • Header anchors render Permalink to "實際案例", with no {#...} in the label
  • Drifted page (ja lecture-11) keeps its localized ids instead of getting mismatched ones
  • Dev server (npm run docs:dev) applies the map the same way, no page errors

Known limitations

A same-count section reorder in a locale would silently mismatch ids, and the count guard cannot detect it. No locale does this today, and the rule is written down in scripts/validate-anchors.ts.

The config imports scripts/anchor-map-utils.ts across directories. That works in dev and in the build, but it does couple the site config to the scripts folder. I can inline the 12-line slugifier into the config instead if you would prefer that.

🤖 Generated with Claude Code

alecchen and others added 2 commits September 14, 2026 16:05
Switching language on an anchored URL dropped the reader at the top of the
page. VitePress carries the current hash across a locale switch, but heading
ids are auto-slugged from heading text, so "#實際案例" has no target on the
English page and the router falls back to scrollTo(0, 0).

Apply the English slug as the id in every locale, keeping heading text
translated - the same convention vitepress.dev uses. Ids come from a map
generated off the English source and applied via markdown.anchor.slugifyWithState,
so no markdown file changes and header anchors keep a clean aria-label.

Ids are matched by position, so the map is applied only where a locale's
heading count equals English. 15 pages have known structure drift and keep
their localized ids; those are reported by npm run anchors:check (wired into
the Pages workflow) rather than silently mis-mapped.

Also includes a pre-existing working-tree change to package.json
(allowScripts) that shared the file with the new script entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Move the drift documentation out of CLAUDE.md into the validator's header
comment, which is where the build rule is enforced, and drop the unrelated
package.json allowScripts block and the package-lock license line. Neither
is required: with allowScripts removed, npm ci succeeds and esbuild still
runs, because the policy only silences an install-scripts warning unless
strict-allow-scripts is set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alecchen
alecchen marked this pull request as ready for review September 14, 2026 08:54
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.

1 participant