mirror of
https://github.com/Heretek-Games/flatpark.git
synced 2026-10-11 20:59:53 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": 1,
|
||||
"include": ["/"],
|
||||
"exclude": []
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Executable
+60
@@ -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"
|
||||
Reference in New Issue
Block a user