feat(fase-10): auto language detection on first login (10.8)

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>
This commit is contained in:
2026-05-18 02:30:09 +02:00
parent 2924b94329
commit 378fcbe5f4
4 changed files with 251 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { detectLanguage } from './accept-language';
describe('detectLanguage (Accept-Language style strings)', () => {
it('AL-01: bare "es" maps to es', () => {
expect(detectLanguage('es')).toBe('es');
});
it('AL-02: bare "en" maps to en', () => {
expect(detectLanguage('en')).toBe('en');
});
it('AL-03: regional variant "es-ES" maps to es', () => {
expect(detectLanguage('es-ES')).toBe('es');
});
it('AL-04: es;q=0.9,en;q=0.8 — es wins (higher q)', () => {
expect(detectLanguage('es;q=0.9,en;q=0.8')).toBe('es');
});
it('AL-05: en-US,es;q=0.3 — en wins (no q means 1.0)', () => {
expect(detectLanguage('en-US,es;q=0.3')).toBe('en');
});
it('AL-06: empty string falls back to en', () => {
expect(detectLanguage('')).toBe('en');
});
it('AL-07: null falls back to en', () => {
expect(detectLanguage(null)).toBe('en');
});
it('AL-08: undefined falls back to en', () => {
expect(detectLanguage(undefined)).toBe('en');
});
it('AL-09: malformed string falls back to en', () => {
expect(detectLanguage('!!!@#$;q=??')).toBe('en');
});
it('AL-10: es with q=0.5 — borderline accepted', () => {
// Per plan §10.8.1: q-score >= 0.5 still counts as Spanish preference.
expect(detectLanguage('en;q=0.4,es;q=0.5')).toBe('es');
});
it('AL-11: array of language tags (navigator.languages) — first es wins', () => {
expect(detectLanguage(['en-GB', 'es-ES', 'fr'])).toBe('en');
expect(detectLanguage(['es-ES', 'en-GB'])).toBe('es');
});
it('AL-12: unknown language (fr) falls back to en', () => {
expect(detectLanguage('fr-FR')).toBe('en');
});
});

View File

@@ -0,0 +1,88 @@
/**
* 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';
}