feat(site): send / to the reader's locale at the edge

A Chinese reader arriving at flatpark.org had to find the globe menu before
seeing a word of Chinese. Negotiate it instead, on the one URL that is
ambiguous: a Pages Function on `/` reads Accept-Language and 302s to
`/zh-Hans/` when that is what the browser asks for.

Scoped to the root on purpose. English lives on the bare paths here, so every
other URL is already unambiguous and stays a plain static asset — which keeps
the canonical English pages out of any redirect and keeps Function invocations
off the rest of the site. `_routes.json` pins that.

An explicit pick from the language switcher wins over the browser: the switcher
writes a `lang` cookie and the function prefers it, so choosing English on a
Chinese browser is not undone on the next visit to the homepage.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Komh
2026-08-14 09:01:32 +08:00
co-authored by Claude Opus 5
parent 2362453438
commit d6818d655c
6 changed files with 211 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
// Cloudflare Pages Function for `/`, and only `/`.
//
// English lives on the bare paths, so every other URL is already unambiguous
// and must stay a plain static asset — both to keep the canonical English pages
// out of a redirect and to keep Function invocations off the rest of the site.
// `site/public/_routes.json` pins that scoping; widening it there is what would
// put this in front of anything else.
//
// The reader's own choice, once made, is final: the switcher writes a `lang`
// cookie (see Base.astro) and `localeForRequest` prefers it over the browser's
// header. Crawlers send no `Accept-Language`, so they get English here and find
// the rest through the hreflang alternates, which is the point of not touching
// the deep paths.
import { defaultLang } from '../site/src/i18n/config.mjs';
import { localeForRequest } from '../site/src/i18n/negotiate.mjs';
export async function onRequest(context) {
const { request, next } = context;
if (request.method !== 'GET' && request.method !== 'HEAD') return next();
const lang = localeForRequest({
acceptLanguage: request.headers.get('accept-language'),
cookieHeader: request.headers.get('cookie'),
});
if (lang !== defaultLang) {
const target = new URL(request.url);
target.pathname = `/${lang}/`;
return new Response(null, {
status: 302, // never 301: the answer differs per reader, so nothing may pin it
headers: {
Location: target.toString(),
'Cache-Control': 'no-store',
Vary: 'Accept-Language, Cookie',
},
});
}
// English: serve the static homepage untouched, but mark it as negotiated so
// no shared cache hands this copy to a reader who would have been redirected.
const response = await next();
const out = new Response(response.body, response);
out.headers.append('Vary', 'Accept-Language, Cookie');
return out;
}
+5
View File
@@ -0,0 +1,5 @@
{
"version": 1,
"include": ["/"],
"exclude": []
}
+1
View File
@@ -128,6 +128,7 @@ const langLinks = locales.map((l) => ({
href={l.href}
hreflang={l.code}
lang={l.code}
data-lang-pick={l.code}
aria-current={l.current ? 'true' : undefined}
class:list={[
'block px-4 py-2 text-sm hover:bg-canvas',
+88
View File
@@ -0,0 +1,88 @@
// Language negotiation for the one URL that has to guess: `/`.
//
// Every other path already says which locale it is (`/zh-Hans/...`) or is the
// English original, so only the bare root is ambiguous. `functions/index.js`
// runs this at the Cloudflare edge and 302s a reader whose browser asks for a
// language we ship in — the same thing Flathub does with a server-side 307,
// minus the flash of English that a client-side redirect would cause.
//
// Kept next to `config.mjs` and importing from it, because the locale table and
// the code that matches against it must never drift apart.
import { defaultLang, locales } from './config.mjs';
/** Cookie the language switcher writes; an explicit pick outranks the browser. */
export const LANG_COOKIE = 'lang';
// Chinese is the case where the browser's region stands in for a script: a
// `zh-TW` reader wants Traditional, `zh-CN` Simplified, and neither spells the
// script out. Everything else falls through to the language-only match below.
const ZH_SCRIPT_BY_REGION = { cn: 'hans', sg: 'hans', my: 'hans', tw: 'hant', hk: 'hant', mo: 'hant' };
/**
* The locale we would serve one browser tag, or null if we ship nothing in that
* language. A tag that names a script we do not have (`zh-TW` today) falls back
* to the first locale in the same language rather than to English: Traditional
* readers are better served by Simplified than by a language they may not read.
* Adding `zh-Hant` to the table is all it takes for them to resolve exactly.
*/
function resolveTag(tag) {
const [lang, ...rest] = String(tag).toLowerCase().split('-').filter(Boolean);
if (!lang) return null;
const candidates = locales.filter((l) => l.toLowerCase().split('-')[0] === lang);
if (!candidates.length) return null;
const script = rest.find((s) => s.length === 4) ?? (lang === 'zh' ? ZH_SCRIPT_BY_REGION[rest[0]] : undefined);
if (script) {
const exact = candidates.find((l) => l.toLowerCase().split('-')[1] === script);
if (exact) return exact;
}
return candidates[0];
}
/**
* Best locale for an `Accept-Language` header, defaulting to English. Tags are
* tried in q-order; the first one we ship wins. `*` means "anything", which is
* the default locale by definition, so it ends the search.
*/
export function preferredLocale(acceptLanguage) {
const tags = String(acceptLanguage || '')
.split(',')
.map((part) => {
const [tag, ...params] = part.trim().split(';');
const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
const weight = q ? Number.parseFloat(q.slice(2)) : 1;
return { tag: tag.trim(), q: Number.isFinite(weight) ? weight : 0 };
})
// Sort is stable, so equal weights keep the order the browser sent.
.filter((entry) => entry.tag && entry.q > 0)
.sort((a, b) => b.q - a.q);
for (const { tag } of tags) {
if (tag === '*') break;
const hit = resolveTag(tag);
if (hit) return hit;
}
return defaultLang;
}
/** The `lang` cookie's value, if it names a locale we actually ship. */
export function cookieLocale(cookieHeader) {
for (const pair of String(cookieHeader || '').split(';')) {
const [name, ...value] = pair.trim().split('=');
if (name === LANG_COOKIE) {
const wanted = decodeURIComponent(value.join('='));
return locales.includes(wanted) ? wanted : null;
}
}
return null;
}
/**
* What to serve at `/`. An explicit pick from the language switcher wins over
* the browser's list — without that, choosing English from a Chinese browser
* would be undone on the next visit to the root.
*/
export function localeForRequest({ acceptLanguage, cookieHeader } = {}) {
return cookieLocale(cookieHeader) ?? preferredLocale(acceptLanguage);
}
+12
View File
@@ -104,6 +104,7 @@ const searchMeta = {
<script>
import { initSearch } from '../scripts/search.js';
import { LANG_COOKIE } from '../i18n/negotiate.mjs';
// Strings the script swaps in at runtime, handed over from the server by
// Base.astro (see clientStrings there). English is the fallback so a
@@ -128,6 +129,17 @@ const searchMeta = {
// are reading.
initSearch(S);
// Language switcher: remember an explicit pick. The edge function on `/`
// (functions/index.js) reads this cookie and stops guessing from
// Accept-Language once it is set — otherwise choosing English on a
// Chinese browser would be undone on the next visit to the homepage.
for (const link of document.querySelectorAll('[data-lang-pick]')) {
link.addEventListener('click', () => {
const value = link.dataset.langPick;
document.cookie = `${LANG_COOKIE}=${encodeURIComponent(value)}; path=/; max-age=31536000; samesite=lax; secure`;
});
}
// Dark/light toggle: flip the class, remember the choice.
document.querySelector('[data-theme-toggle]')?.addEventListener('click', () => {
const dark = document.documentElement.classList.toggle('dark');
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Root-path language negotiation: which locale `/` serves for a given browser.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/tests/lib/assert.sh"
command -v node >/dev/null 2>&1 || { echo "test_i18n_negotiate: SKIP (no node)"; exit 0; }
out="$(node --input-type=module -e "
import { cookieLocale, localeForRequest, preferredLocale } from '$ROOT/site/src/i18n/negotiate.mjs';
const lines = [
// Nothing to go on -> English, which is what crawlers get (they send no header).
'empty=' + preferredLocale(''),
'missing=' + preferredLocale(undefined),
'wildcard=' + preferredLocale('*'),
// The plain cases.
'en=' + preferredLocale('en-US,en;q=0.9'),
'zh-cn=' + preferredLocale('zh-CN,zh;q=0.9,en;q=0.8'),
'zh-bare=' + preferredLocale('zh'),
// Traditional regions have no locale of their own yet, so they land on the
// only Chinese we ship rather than on English.
'zh-tw=' + preferredLocale('zh-TW,zh;q=0.9'),
'zh-hant=' + preferredLocale('zh-Hant'),
// A language we do not ship at all stays English.
'ja=' + preferredLocale('ja-JP,ja;q=0.9'),
// q-order decides, not the order the tags appear in.
'q-order=' + preferredLocale('en;q=0.3,zh-CN;q=0.9'),
'q-zero=' + preferredLocale('zh-CN;q=0,ja;q=0.5'),
// Case and stray whitespace are the browser's business, not ours.
'sloppy=' + preferredLocale(' ZH-hans-cn ; q=0.8 , en ; q=0.2 '),
// An explicit pick from the switcher outranks the browser, both ways.
'cookie-en=' + localeForRequest({ acceptLanguage: 'zh-CN', cookieHeader: 'theme=dark; lang=en' }),
'cookie-zh=' + localeForRequest({ acceptLanguage: 'en-US', cookieHeader: 'lang=zh-Hans' }),
// A cookie naming something we do not ship is ignored, not trusted.
'cookie-junk=' + localeForRequest({ acceptLanguage: 'zh-CN', cookieHeader: 'lang=../etc' }),
'cookie-none=' + cookieLocale('theme=dark'),
];
console.log(lines.join('\n'));
")"
get() { printf '%s\n' "$out" | grep "^$1=" | cut -d= -f2-; }
assert_eq "$(get empty)" "en"
assert_eq "$(get missing)" "en"
assert_eq "$(get wildcard)" "en"
assert_eq "$(get en)" "en"
assert_eq "$(get zh-cn)" "zh-Hans"
assert_eq "$(get zh-bare)" "zh-Hans"
assert_eq "$(get zh-tw)" "zh-Hans"
assert_eq "$(get zh-hant)" "zh-Hans"
assert_eq "$(get ja)" "en"
assert_eq "$(get q-order)" "zh-Hans"
assert_eq "$(get q-zero)" "en"
assert_eq "$(get sloppy)" "zh-Hans"
assert_eq "$(get cookie-en)" "en"
assert_eq "$(get cookie-zh)" "zh-Hans"
assert_eq "$(get cookie-junk)" "zh-Hans"
assert_eq "$(get cookie-none)" "null"
echo "test_i18n_negotiate: ok"