feat(site): pick the docs language from the browser on first visit

The site already ships both languages, but / always served Chinese, so
English readers had to find the switcher themselves. A Chinese browser now
stays on /, everything else lands on /en.

The decision runs as an inline script in index.html, next to the existing
theme anti-flash block: deferring it to React would show English readers a
full screen of Chinese first. It only fires on the root path — /en, /start
and /en/start are deliberate destinations, and redirecting those would kick
readers off any cross-language link they follow. A manual switch is stored
in localStorage and wins over the browser language, otherwise switching
would be undone on the next visit to the home page.

resolveRootRedirect carries the same rules as a testable pure function.
check-docs asserts the inline copy and the module agree on the storage key,
the Chinese tag pattern and the root-only guard, so the two cannot drift.
This commit is contained in:
程序员阿江(Relakkes) 2026-07-29 09:23:19 +08:00
parent 047f4fbfea
commit 07a06227ca
7 changed files with 208 additions and 4 deletions

View File

@ -16,6 +16,29 @@
<meta property="og:image" content="https://cchaha.ai/images/banner.png">
<meta name="twitter:card" content="summary_large_image">
<title>Claude Code Haha — Claude Code 的本地优先桌面客户端</title>
<script>
// 首帧就把语言定下来:中文浏览器留在中文站,其余跳英文站。等 React 起来再跳的话,
// 英文用户会先看到一整屏中文。只认根路径,带语言前缀的地址是用户的明确意图。
// 判定规则与 src/lib/locale.js 是同一套check-docs.mjs 盯着两处不漂移。
(function () {
try {
if (window.location.pathname.replace(/\/+$/, '') !== '') return
var stored = localStorage.getItem('cch-locale')
var locale = stored === 'en' || stored === 'zh'
? stored
: ((navigator.languages || [navigator.language || '']).some(function (tag) {
return /^zh(?:-|$)/i.test(String(tag).trim())
}) ? 'zh' : 'en')
if (locale === 'en') {
window.location.replace('/en' + window.location.search + window.location.hash)
}
} catch (error) {
// 读不到偏好就维持中文站,别让语言分流把首页整个拦死。
}
})()
</script>
<script>
// 首帧就把主题定下来,避免深色偏好的用户先闪一下白屏。
(function () {

View File

@ -11,7 +11,8 @@
"postbuild": "node scripts/prepare-static-output.mjs",
"preview": "vite preview --host 0.0.0.0",
"check:docs": "node scripts/check-docs.mjs",
"check": "npm run check:docs"
"test": "node --test \"src/**/*.test.js\"",
"check": "npm run check:docs && npm test"
},
"dependencies": {
"dompurify": "^3.3.0",

View File

@ -46,6 +46,40 @@ function isImageTarget(target) {
return /\.(?:avif|gif|jpe?g|png|svg|webp)$/i.test(withoutSuffix(target))
}
/**
* 语言分流的判定规则在两处各有一份src/lib/locale.js可测的模块 index.html 里的内联
* 副本首帧就要跳等不到模块加载两处漂移不会报错只会让首页悄悄按旧规则分流所以
* 在这里钉死storage key 和中文判定正则必须逐字一致且内联脚本必须只在根路径动手
*/
async function checkLocaleRedirect() {
const problems = []
const moduleSource = await fs.readFile(path.join(paths.siteDir, 'src/lib/locale.js'), 'utf8')
const shellSource = await fs.readFile(path.join(paths.siteDir, 'index.html'), 'utf8')
const storageKey = moduleSource.match(/LOCALE_STORAGE_KEY\s*=\s*'([^']+)'/)?.[1]
const chineseTag = moduleSource.match(/const CHINESE_TAG\s*=\s*(\/.+\/i)/)?.[1]
if (!storageKey || !chineseTag) {
problems.push('src/lib/locale.js: 读不出 LOCALE_STORAGE_KEY 或 CHINESE_TAG防漂移校验失效')
return problems
}
if (!shellSource.includes(`localStorage.getItem('${storageKey}')`)) {
problems.push(`index.html: 内联语言脚本没有用 '${storageKey}',与 src/lib/locale.js 不一致`)
}
if (!shellSource.includes(chineseTag)) {
problems.push(`index.html: 内联语言脚本的中文判定与 src/lib/locale.js 的 ${chineseTag} 不一致`)
}
// 少了这道判断,/en/start 这类地址也会被卷进分流。
if (!shellSource.includes("window.location.pathname.replace(/\\/+$/, '') !== ''")) {
problems.push('index.html: 内联语言脚本缺少「只在根路径生效」的判断')
}
return problems
}
async function main() {
const { records } = await generateDocsManifest()
const routes = new Set([
@ -55,7 +89,7 @@ async function main() {
'/en/docs',
...records.map((record) => record.path),
])
const problems = []
const problems = [...await checkLocaleRedirect()]
let checkedTargets = 0
for (const record of records) {

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import Icon from './icons'
import { toSiteHref } from '../content/docs'
import { rememberLocale } from '../lib/locale'
import { useTheme } from '../lib/theme'
import SearchDialog from './SearchDialog'
@ -83,7 +84,16 @@ export default function SiteHeader({ activeSection, locale = 'zh', localeHref })
{label}
</a>
))}
<a href={toSiteHref(switchHref)} onClick={() => setOpen(false)}>{c.toEnglish}</a>
<a
href={toSiteHref(switchHref)}
onClick={() => {
//
rememberLocale(locale === 'en' ? 'zh' : 'en')
setOpen(false)
}}
>
{c.toEnglish}
</a>
</nav>
<div className="header-tools">

48
site/src/lib/locale.js Normal file
View File

@ -0,0 +1,48 @@
/**
* 站点语言分流
*
* 只有根路径 `/` 会按浏览器语言自动选中文浏览器留在中文站其余跳 `/en`
* 带语言前缀的地址`/en``/start``/en/start`都是用户点进来的明确意图一概不动
* 否则英文用户点一条中文文档链接会被莫名踢走
*
* 手动切过语言之后选择记进 localStorage之后回到 `/` 就按记住的来不再被浏览器语言
* 盖掉不然导航里的切换器等于没用浏览器是中文的人切到英文下次进首页又被弹回中文
*
* 注意同一套判断在 index.html 里有一份内联副本 首帧就得跳完等不到这个模块加载
* STORAGE_KEY 或判定规则时两处要一起改check-docs.mjs 会盯着它们不漂移
*/
export const LOCALE_STORAGE_KEY = 'cch-locale'
/** BCP-47 里中文一律是 zh / zh-CN / zh-TW / zh-Hans…但 zhuang 之类不算。 */
const CHINESE_TAG = /^zh(?:-|$)/i
export function prefersChinese(languages) {
if (!Array.isArray(languages)) return false
return languages.some((tag) => typeof tag === 'string' && CHINESE_TAG.test(tag.trim()))
}
export function normalizeStoredLocale(value) {
return value === 'en' || value === 'zh' ? value : null
}
/**
* 根路径该跳去哪返回 null 表示留在原地中文站
*/
export function resolveRootRedirect({ languages, pathname, stored }) {
if (String(pathname ?? '/').replace(/\/+$/, '') !== '') return null
const locale = normalizeStoredLocale(stored) || (prefersChinese(languages) ? 'zh' : 'en')
return locale === 'en' ? '/en' : null
}
export function rememberLocale(locale) {
const value = normalizeStoredLocale(locale)
if (!value) return
try {
localStorage.setItem(LOCALE_STORAGE_KEY, value)
} catch {
// 隐私模式下 localStorage 直接抛,记不住偏好也不能让语言切换本身失败。
}
}

View File

@ -0,0 +1,82 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { normalizeStoredLocale, prefersChinese, resolveRootRedirect } from './locale.js'
describe('prefersChinese', () => {
it('认所有中文变体', () => {
for (const tag of ['zh', 'zh-CN', 'zh-TW', 'zh-HK', 'zh-Hans', 'zh-Hant-TW', 'ZH-cn']) {
assert.equal(prefersChinese([tag]), true, tag)
}
})
it('不把 zh 开头的其他语言当中文', () => {
// zhuang壮语真实存在于 BCP-47前缀匹配写松了会误判。
assert.equal(prefersChinese(['zha']), false)
assert.equal(prefersChinese(['zhuang']), false)
})
it('列表里任意一项是中文就算中文', () => {
assert.equal(prefersChinese(['en-US', 'zh-CN']), true)
assert.equal(prefersChinese(['en-US', 'ja', 'ko']), false)
})
it('输入不是数组或含脏值时不炸', () => {
assert.equal(prefersChinese(undefined), false)
assert.equal(prefersChinese(null), false)
assert.equal(prefersChinese([]), false)
assert.equal(prefersChinese([null, undefined, 42, ' zh-CN ']), true)
})
})
describe('normalizeStoredLocale', () => {
it('只接受 zh / en', () => {
assert.equal(normalizeStoredLocale('zh'), 'zh')
assert.equal(normalizeStoredLocale('en'), 'en')
assert.equal(normalizeStoredLocale('fr'), null)
assert.equal(normalizeStoredLocale(''), null)
assert.equal(normalizeStoredLocale(null), null)
})
})
describe('resolveRootRedirect', () => {
it('中文浏览器留在中文站', () => {
assert.equal(resolveRootRedirect({ languages: ['zh-CN'], pathname: '/' }), null)
})
it('非中文浏览器跳英文站', () => {
assert.equal(resolveRootRedirect({ languages: ['en-US'], pathname: '/' }), '/en')
assert.equal(resolveRootRedirect({ languages: ['ja-JP'], pathname: '/' }), '/en')
})
it('拿不到浏览器语言时按英文兜底', () => {
assert.equal(resolveRootRedirect({ languages: [], pathname: '/' }), '/en')
assert.equal(resolveRootRedirect({ pathname: '/' }), '/en')
})
it('根路径的尾斜杠和空串都算根', () => {
for (const pathname of ['/', '', '//']) {
assert.equal(resolveRootRedirect({ languages: ['en'], pathname }), '/en', JSON.stringify(pathname))
}
})
it('只动根路径,带前缀的地址一概不碰', () => {
// 这是整个功能的安全边界:英文用户点开中文文档不该被踢走,反之亦然。
const cases = ['/en', '/en/', '/start', '/en/start', '/desktop/pets', '/internals']
for (const pathname of cases) {
assert.equal(resolveRootRedirect({ languages: ['en-US'], pathname }), null, pathname)
assert.equal(resolveRootRedirect({ languages: ['zh-CN'], pathname }), null, pathname)
}
})
it('记住的偏好优先于浏览器语言', () => {
// 中文浏览器手动切到英文后,回首页不该被弹回中文,否则切换器等于没用。
assert.equal(resolveRootRedirect({ languages: ['zh-CN'], pathname: '/', stored: 'en' }), '/en')
assert.equal(resolveRootRedirect({ languages: ['en-US'], pathname: '/', stored: 'zh' }), null)
})
it('偏好是脏值时退回浏览器语言', () => {
assert.equal(resolveRootRedirect({ languages: ['zh-CN'], pathname: '/', stored: 'garbage' }), null)
assert.equal(resolveRootRedirect({ languages: ['en-US'], pathname: '/', stored: '' }), '/en')
})
})

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import Icon from '../../components/icons'
import SiteHeader, { DOWNLOAD_URL, GITHUB_URL } from '../../components/SiteHeader'
import { toSiteHref } from '../../content/docs'
import { rememberLocale } from '../../lib/locale'
import { setPageMeta } from '../../lib/meta'
import { content, mascotAccents, mascots } from './content'
import './home.css'
@ -278,7 +279,12 @@ function Footer({ c, locale }) {
</nav>
))}
<div className="site-footer__meta">
<a href={toSiteHref(locale === 'en' ? '/' : '/en')}>{locale === 'en' ? '中文' : 'English'}</a>
<a
href={toSiteHref(locale === 'en' ? '/' : '/en')}
onClick={() => rememberLocale(locale === 'en' ? 'zh' : 'en')}
>
{locale === 'en' ? '中文' : 'English'}
</a>
</div>
</div>
</footer>