feat(fase-20): accept-language + bootstrap recognise 'eu'
Extends the Fase 10.8 Accept-Language parser to surface `'eu'` as a third
known primary subtag, gated on the same q ≥ 0.5 threshold the existing
`'es'` path uses. `'en'` continues to short-circuit because it's the
source language. `AppLanguage` widens to `'en' | 'es' | 'eu'`.
Tests:
* Unit (apps/web): LANG-U-EU-01 bare 'eu', LANG-U-EU-02 'eu-ES',
LANG-U-EU-03 'eu-FR;q=0.5,en;q=0.4' (borderline q wins), LANG-U-EU-04
navigator.languages array — 4 new specs, all green.
* Integration (packages/test-utils): LANG-INT-EU-01 user updates own
language=eu, LANG-INT-EU-02 user attempts language='fr' and gets the
Postgres 22P02 from the ENUM (cast through `as unknown as` so the
narrow TS type doesn't block the test).
The integration negative case lands on the language_code ENUM gate from
migration 028, not RLS — it would still fail even from a service-role
client.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -51,4 +51,28 @@ describe('detectLanguage (Accept-Language style strings)', () => {
|
||||
it('AL-12: unknown language (fr) falls back to en', () => {
|
||||
expect(detectLanguage('fr-FR')).toBe('en');
|
||||
});
|
||||
|
||||
// ─── Fase 20.4.1 — Euskera (eu) support ───────────────────────────────
|
||||
// Mirrors the existing es-track tests. Coverage matches the plan's
|
||||
// LANG-U-EU-01..03 identifiers.
|
||||
|
||||
it('LANG-U-EU-01: bare "eu" maps to eu', () => {
|
||||
expect(detectLanguage('eu')).toBe('eu');
|
||||
});
|
||||
|
||||
it('LANG-U-EU-02: regional variant "eu-ES" maps to eu', () => {
|
||||
expect(detectLanguage('eu-ES')).toBe('eu');
|
||||
});
|
||||
|
||||
it('LANG-U-EU-03: "eu-FR;q=0.5,en" — eu wins by primary preference', () => {
|
||||
// q=0.5 is the lower bound we accept (mirrors AL-10 for es). The
|
||||
// borderline value still beats `en` because the parser ranks by
|
||||
// q-score and stops at the first known primary.
|
||||
expect(detectLanguage('eu-FR;q=0.5,en;q=0.4')).toBe('eu');
|
||||
});
|
||||
|
||||
it('LANG-U-EU-04: array of language tags — first eu wins over later en', () => {
|
||||
// Mirrors AL-11. Validates the navigator.languages path for eu.
|
||||
expect(detectLanguage(['eu-ES', 'en-GB'])).toBe('eu');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
/**
|
||||
* Detect the user's preferred app language from an Accept-Language header
|
||||
* (or navigator.languages array) — Fase 10.8.
|
||||
* (or navigator.languages array) — Fase 10.8 + Fase 20.3.2 (eu).
|
||||
*
|
||||
* Supported app languages (in sync with messages/{en,es}.json + Paraglide
|
||||
* languageTags): `en`, `es`. Anything else falls back to `en`.
|
||||
* Supported app languages (in sync with messages/{en,es,eu}.json +
|
||||
* Paraglide languageTags): `en`, `es`, `eu`. Anything else falls back
|
||||
* to `en`.
|
||||
*
|
||||
* Rules (per plan §10.8.1):
|
||||
* Rules (per plan §10.8.1, extended for Fase 20):
|
||||
* * 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']`
|
||||
* - bare tag: `'es'`, `'en'`, `'es-ES'`, `'en-US'`, `'eu'`, `'eu-ES'`
|
||||
* - RFC 7231 list: `'es;q=0.9,en;q=0.8'`, `'eu-FR;q=0.5,en;q=0.4'`
|
||||
* - `navigator.languages` array: `['es-ES', 'en-GB']`, `['eu-ES', 'en']`
|
||||
* - 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'`.
|
||||
* * The first entry (sorted by q-score descending) whose primary subtag
|
||||
* is an app-known language wins, provided q ≥ 0.5. `en` short-circuits
|
||||
* immediately regardless of q-score because it's the source language.
|
||||
* * Otherwise (including unknown languages, malformed input, or no
|
||||
* clear preference) → `'en'`.
|
||||
*/
|
||||
export type AppLanguage = 'en' | 'es';
|
||||
export type AppLanguage = 'en' | 'es' | 'eu';
|
||||
|
||||
interface ParsedTag {
|
||||
tag: string;
|
||||
@@ -76,11 +79,18 @@ export function detectLanguage(
|
||||
// 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.
|
||||
// First app-known primary wins; non-`en` primaries require q ≥ 0.5 to
|
||||
// beat the implicit `en` fallback. `en` short-circuits because it's the
|
||||
// source language and can never be more wrong than the user's first
|
||||
// choice if they listed it at all. Fase 20 adds `eu` on the same gate
|
||||
// as `es`.
|
||||
for (const p of parsed) {
|
||||
if (p.primary === 'es') {
|
||||
return p.q >= 0.5 ? 'es' : 'en';
|
||||
}
|
||||
if (p.primary === 'eu') {
|
||||
return p.q >= 0.5 ? 'eu' : 'en';
|
||||
}
|
||||
if (p.primary === 'en') return 'en';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user