mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(achievements): answer in the language the client asks for
Achievement names, descriptions and category labels, the secret placeholder and the monthly challenge are text the server writes and every client shows as is, so they stayed English in a translated web page and will in the localized iOS app. The routes that return them now read Accept-Language and answer in that language, with Vary: Accept-Language. The translations live in the web's catalogs under serverText, so Crowdin, its checks and its translators handle them through the existing pipeline; the server reads that subtree, and the image copies the catalogs beside dist. English is never looked up: a request without the header, and every client that predates it, gets the bytes it got before. A test keeps serverText in en.json equal to the seed and the constants it mirrors. The web now sends its chosen language on every API request, and the Accept-Language matcher moved into @bookorbit/types so the client and the server negotiate the same way. The iOS app is a consumer the web client does not show: it will send its own interface language. App Store builds that predate that still send the device language, so on a non-English device they will start getting translated achievement names inside English screens. That was accepted; the response shape does not change. Ref bookorbit/bookorbit-ios-support#79
This commit is contained in:
@@ -93,3 +93,78 @@ export interface LocalePreferences {
|
||||
export function isSupportedLocale(value: unknown): value is Locale {
|
||||
return typeof value === "string" && (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function canonicalizeLocale(value: string): string | null {
|
||||
try {
|
||||
return Intl.getCanonicalLocales(value)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The supported locale that best serves a ranked list of language tags, or null when none does.
|
||||
* An exact tag wins, then the same language and script (`zh-TW` and `zh-Hant-HK` both reach
|
||||
* `zh-Hant`), then the bare language (`pt-BR` reaches `pt`).
|
||||
*/
|
||||
export function matchSupportedLocale(candidates: readonly string[]): Locale | null {
|
||||
const supportedByCanonical = new Map<string, Locale>();
|
||||
const supportedByLanguageAndScript = new Map<string, Locale>();
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const canonical = canonicalizeLocale(locale);
|
||||
if (!canonical) continue;
|
||||
supportedByCanonical.set(canonical.toLowerCase(), locale);
|
||||
const maximized = new Intl.Locale(canonical).maximize();
|
||||
if (maximized.script) {
|
||||
supportedByLanguageAndScript.set(`${maximized.language.toLowerCase()}-${maximized.script.toLowerCase()}`, locale);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const canonical = canonicalizeLocale(candidate);
|
||||
if (!canonical) continue;
|
||||
const exact = supportedByCanonical.get(canonical.toLowerCase());
|
||||
if (exact) return exact;
|
||||
const maximized = new Intl.Locale(canonical).maximize();
|
||||
const language = maximized.language.toLowerCase();
|
||||
if (maximized.script) {
|
||||
const scriptMatch = supportedByLanguageAndScript.get(`${language}-${maximized.script.toLowerCase()}`);
|
||||
if (scriptMatch) return scriptMatch;
|
||||
}
|
||||
const baseLocale = supportedByCanonical.get(language);
|
||||
if (baseLocale) return baseLocale;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The language tags of an `Accept-Language` header, most preferred first. The `*` wildcard and
|
||||
* tags weighted `q=0` are dropped; a weight that is not a number counts as 1, and equal weights
|
||||
* keep the order they were sent in.
|
||||
*/
|
||||
export function parseAcceptLanguage(header: string | null | undefined): string[] {
|
||||
if (!header) return [];
|
||||
const entries: { tag: string; quality: number; index: number }[] = [];
|
||||
header.split(",").forEach((part, index) => {
|
||||
const [rawTag, ...parameters] = part.split(";");
|
||||
const tag = rawTag?.trim();
|
||||
if (!tag || tag === "*") return;
|
||||
let quality = 1;
|
||||
for (const parameter of parameters) {
|
||||
const [name, value] = parameter.split("=").map((piece) => piece.trim());
|
||||
if (name?.toLowerCase() !== "q") continue;
|
||||
const parsed = Number(value);
|
||||
quality = Number.isFinite(parsed) ? parsed : 1;
|
||||
}
|
||||
if (quality <= 0) return;
|
||||
entries.push({ tag, quality, index });
|
||||
});
|
||||
entries.sort((left, right) => right.quality - left.quality || left.index - right.index);
|
||||
return entries.map((entry) => entry.tag);
|
||||
}
|
||||
|
||||
/** The supported locale an `Accept-Language` header asks for, or null when it names none. */
|
||||
export function localeFromAcceptLanguage(header: string | null | undefined): Locale | null {
|
||||
return matchSupportedLocale(parseAcceptLanguage(header));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user