Conversation
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
marked this pull request as ready for review
September 14, 2026 08:54
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
VitePress carries the current hash across a locale switch.
theme-default/composables/langs.jsbuilds every locale link asnormalizeLink(...) + 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 callswindow.scrollTo(0, 0).The convention this repo was missing is the one VitePress's own docs use. On
vitepress.dev/zh/guide/markdownthe 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-anchorslands on the right section.Approach
Keep heading ids canonical in every locale without touching content.
docs/.vitepress/anchor-map.jsongets generated from the English source and applied throughmarkdown.anchor.slugifyWithState:Same id, four different titles:
real-world-examplereal-world-examplereal-world-examplereal-world-exampleWhy 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:I checked this against the real renderer.
getTokensTextcannot clean it up, because that option does not receive the token carrying the suffix. The build-time map renders a cleanPermalink to "實際案例".Files
scripts/anchor-map-utils.tsscripts/build-anchor-map.tsscripts/validate-anchors.tsdocs/.vitepress/anchor-map.jsondocs/.vitepress/config.mtsanchor.slugifyWithStatepackage.jsonanchors:build,anchors:check.github/workflows/deploy-pages.ymlanchors:checkbefore the buildThe slugifier is VitePress's, not
github-slugger. The two disagree on underscores (foo_barbecomesfoo-barrather thanfoo_bar) and on leading digits (1. Getting Startedbecomes_1-getting-startedrather than1-getting-started).github-sluggeris declared inpackage.jsonbut imported nowhere in the repo, so I left it alone.The id list includes the H1. The anchor plugin defaults to
level: 1and 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:
lectures/lecture-11-why-observability-belongs-inside-the-harnesslectures/lecture-12-why-every-session-must-leave-a-clean-stateprojects/project-01-baseline-vs-minimal-harnessprojects/project-02..06resources/openai-advanced,resources/reference,resources/templates(+2 repo-template files)harness-designs/codexCross-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:checklists 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:checkgates 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:checkexits 0 (105 pages), and exits 1 when the map is tampered withnpm run docs:buildpasses from a cleandist/cache(vitepress 1.6.4; only the pre-existing chunk-size warning)slugifyWithStatestubbed to the default slugifier, then hashed every<h1..h3>tag on all 105 English pages. Identical in both builds.問題的根源:一個惡性循環 -> #the-vicious-cycle-at-the-rootPermalink to "實際案例", with no{#...}in the labeljalecture-11) keeps its localized ids instead of getting mismatched onesnpm run docs:dev) applies the map the same way, no page errorsKnown 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.tsacross 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