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:
54
apps/web/src/lib/utils/accept-language.test.ts
Normal file
54
apps/web/src/lib/utils/accept-language.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
88
apps/web/src/lib/utils/accept-language.ts
Normal file
88
apps/web/src/lib/utils/accept-language.ts
Normal 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';
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
||||
import { i18n } from '$lib/i18n';
|
||||
import { initTheme } from '$lib/theme';
|
||||
import { setLanguageTag } from '$lib/paraglide/runtime';
|
||||
import { detectLanguage } from '$lib/utils/accept-language';
|
||||
|
||||
onMount(() => {
|
||||
// Fase 9.1: hydrate the theme stores from localStorage and start
|
||||
@@ -39,6 +41,12 @@
|
||||
setTimeout(async () => {
|
||||
await loadUserCollectives(session.user.id);
|
||||
|
||||
// Fase 10.8: auto-detect language for genuinely new users.
|
||||
// Best-effort, runs after every auth event so newly-seeded
|
||||
// (post-Keycloak-self-registration) rows pick up the user's
|
||||
// browser preference instead of the seed default 'en'.
|
||||
await maybeBootstrapLanguage(session.user.id);
|
||||
|
||||
if (event === 'SIGNED_IN') {
|
||||
// Post-login: send the user where they belong.
|
||||
// Only redirect when coming from the callback or the root — not on
|
||||
@@ -66,6 +74,47 @@
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
|
||||
/**
|
||||
* Fase 10.8 — Accept-Language detection for first-time users.
|
||||
*
|
||||
* The Paraglide stack is compile-time and the OIDC dance lives outside
|
||||
* SvelteKit, so we can't sniff the real Accept-Language header server-
|
||||
* side here without standing up a hooks.server.ts that the auth flow
|
||||
* never traverses. Instead we use `navigator.languages` (the browser's
|
||||
* Accept-Language proxy) on the client right after the first SIGNED_IN.
|
||||
*
|
||||
* Idempotency: we only UPDATE when the row was just created (within
|
||||
* 60s of now) AND `language` still equals the seed default ('en'). This
|
||||
* means a Spanish-browser user lands in Spanish on first run; an
|
||||
* established user who later switched themselves to English stays
|
||||
* English; existing users are not retroactively re-detected (plan
|
||||
* §10.8.5 — respect their previous explicit choice).
|
||||
*/
|
||||
async function maybeBootstrapLanguage(userId: string): Promise<void> {
|
||||
const supabase = getSupabase();
|
||||
const { data } = await supabase
|
||||
.from('users')
|
||||
.select('language, created_at')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
if (!data) return;
|
||||
|
||||
const ageMs = Date.now() - new Date(data.created_at).getTime();
|
||||
const isFreshAccount = ageMs < 60_000;
|
||||
if (!isFreshAccount) return;
|
||||
if (data.language !== 'en') return;
|
||||
|
||||
const langs =
|
||||
(typeof navigator !== 'undefined' &&
|
||||
(navigator.languages?.length ? Array.from(navigator.languages) : [navigator.language])) ||
|
||||
[];
|
||||
const detected = detectLanguage(langs);
|
||||
if (detected === 'en') return;
|
||||
|
||||
await supabase.from('users').update({ language: detected }).eq('id', userId);
|
||||
setLanguageTag(detected);
|
||||
}
|
||||
|
||||
async function loadUserCollectives(userId: string): Promise<void> {
|
||||
const supabase = getSupabase();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user