New $lib/utils/accept-language.ts parses Accept-Language strings
(server-style "es;q=0.9,en;q=0.8") and navigator.languages arrays,
returns 'en' or 'es'. Rule (plan §10.8.1): the highest-q-scored 'es'
entry wins if q >= 0.5; otherwise fall back to 'en'. Unknown
languages (e.g. fr) → 'en'. Pure function, 12 unit tests covering
bare tags, regional variants, q-score ordering, malformed input,
navigator.languages arrays, and the borderline q=0.5 case.
The root +layout.svelte calls maybeBootstrapLanguage() after each
SIGNED_IN: it only fires for genuinely new users (public.users row
created within the last 60s) whose language is still the default
('en'); it reads navigator.languages, runs the parser, and
UPDATEs public.users.language if the result is 'es'. setLanguageTag()
flips the live Paraglide runtime so the user lands on the next
page already in Spanish — no reload needed.
Established users keep their explicit choice (plan §10.8.5 / Riesgo
5). Server-side Accept-Language sniffing was rejected: the OIDC
dance lives outside SvelteKit (Keycloak ↔ GoTrue), so a hooks.server.ts
would never see those requests. navigator.languages is the
client-side equivalent and survives Paraglide's compile-time
tree-shaking.
LANG-01/02 (vitest integration): UPDATE public.users RLS contract
the bootstrap relies on — own row OK, other user's row rejected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
89 lines
2.5 KiB
TypeScript
89 lines
2.5 KiB
TypeScript
/**
|
|
* Detect the user's preferred app language from an Accept-Language header
|
|
* (or navigator.languages array) — Fase 10.8.
|
|
*
|
|
* Supported app languages (in sync with messages/{en,es}.json + Paraglide
|
|
* languageTags): `en`, `es`. Anything else falls back to `en`.
|
|
*
|
|
* Rules (per plan §10.8.1):
|
|
* * Inputs we accept:
|
|
* - bare tag: `'es'`, `'en'`, `'es-ES'`, `'en-US'`
|
|
* - RFC 7231 list: `'es;q=0.9,en;q=0.8'`
|
|
* - `navigator.languages` array: `['es-ES', 'en-GB']`
|
|
* - null / undefined / empty → `'en'`
|
|
* * If the highest q-scored entry whose primary subtag is `es` has
|
|
* q ≥ 0.5, the result is `'es'`. Otherwise (including unknown
|
|
* languages, malformed input, or no clear preference) → `'en'`.
|
|
*/
|
|
export type AppLanguage = 'en' | 'es';
|
|
|
|
interface ParsedTag {
|
|
tag: string;
|
|
primary: string;
|
|
q: number;
|
|
}
|
|
|
|
function parseTags(raw: string): ParsedTag[] {
|
|
return raw
|
|
.split(',')
|
|
.map((part) => {
|
|
const segments = part.trim().split(';').map((s) => s.trim());
|
|
if (!segments[0]) return null;
|
|
const tag = segments[0].toLowerCase();
|
|
let q = 1.0;
|
|
for (const seg of segments.slice(1)) {
|
|
const match = seg.match(/^q\s*=\s*([0-9.]+)$/);
|
|
if (match) {
|
|
const parsed = parseFloat(match[1]);
|
|
if (!Number.isNaN(parsed)) q = parsed;
|
|
}
|
|
}
|
|
const primary = tag.split('-')[0];
|
|
// Filter junk: primary must be alpha-only and 1+ chars.
|
|
if (!/^[a-z]+$/.test(primary)) return null;
|
|
return { tag, primary, q };
|
|
})
|
|
.filter((p): p is ParsedTag => p !== null);
|
|
}
|
|
|
|
export function detectLanguage(
|
|
input: string | string[] | null | undefined
|
|
): AppLanguage {
|
|
if (input == null) return 'en';
|
|
|
|
let parsed: ParsedTag[];
|
|
|
|
if (Array.isArray(input)) {
|
|
// navigator.languages: ordered by user preference, no q-scores.
|
|
parsed = input
|
|
.map((tag, index) => {
|
|
const t = tag.toLowerCase().trim();
|
|
if (!t) return null;
|
|
const primary = t.split('-')[0];
|
|
if (!/^[a-z]+$/.test(primary)) return null;
|
|
// Synthesize a decreasing q-score so first-listed wins.
|
|
return { tag: t, primary, q: 1 - index * 0.01 };
|
|
})
|
|
.filter((p): p is ParsedTag => p !== null);
|
|
} else {
|
|
const s = input.trim();
|
|
if (!s) return 'en';
|
|
parsed = parseTags(s);
|
|
}
|
|
|
|
if (parsed.length === 0) return 'en';
|
|
|
|
// Sort by q-score descending.
|
|
parsed.sort((a, b) => b.q - a.q);
|
|
|
|
// First app-known primary wins; if it's es with q ≥ 0.5 → es.
|
|
for (const p of parsed) {
|
|
if (p.primary === 'es') {
|
|
return p.q >= 0.5 ? 'es' : 'en';
|
|
}
|
|
if (p.primary === 'en') return 'en';
|
|
}
|
|
|
|
return 'en';
|
|
}
|