feat(i18n): add Catalan, Basque and Galician (23 locales, all 100%)

- Full dictionaries (8,703 keys each) in src/i18n/{ca,eu,gl}, 7 namespace
  batches, composed with deepMerge; placeholders verified, 2-4% identical
  strings are only brand/protocol names.
- Register in Language/LANGUAGE_LOADERS, detectLanguage (before es),
  LanguageToggle (CA/EU/GL text badges - no Unicode region flags exist) and
  dateFnsI18n (ca-ES/eu-ES/gl-ES).
- check-i18n.mjs now covers ca/eu/gl: all 23 locales at 100%, tsc/tests/build
  green.
This commit is contained in:
SkateSpots 2026-10-26 15:56:56 +07:00
parent 67f21bf1ac
commit d4c6261b81
33 changed files with 29196 additions and 2 deletions

View file

@ -2697,6 +2697,24 @@ similar are internal endpoints/hook names, not credits — leave them.)
stays English (spot, park, bowl, DIY, vert, crew, line, Stash, The Lab, Nostr,
Lightning, Zap, Relay, sats) because Thai skaters use the English terms.
### Catalan, Basque, Galician — 23 locales, all 100% (2026-10-26)
- Added **ca (Català), eu (Euskara), gl (Galego)** → **23 locales**.
- Registered in `Language` + `LANGUAGE_LOADERS`, `detectLanguage` (ca/eu/gl
checked before `es`), the `LanguageToggle` list, and `dateFnsI18n`
(date-fns `ca`/`eu`/`gl`, codes `ca-ES`/`eu-ES`/`gl-ES`).
- `LanguageToggle` uses **text badges `CA` / `EU` / `GL`**: there are no Unicode
region flags for these languages, and a country flag would be wrong. The
`CreateTourPage` language list already had ca/eu/gl for tour tags.
- Full dictionaries in `src/i18n/{ca,eu,gl}/` — 7 namespace batches each
(8,703 keys), composed in `index.ts`; placeholders verified, only 2–4%
identical strings (brand/protocol names). `check-i18n.mjs` includes them in
`extraLocales` and reports 100%.
- Latin script, so **no new font**; each locale lazy-loads like the rest.
- Translation sources were split by namespace into
`/tmp/opencode/i18n-src/batch1..7.json` (regenerate from `en.ts` if
re-translating). When adding new UI strings, add them to `en`/`es` **and all
23 locales** (batch file or `drift.ts`).
### i18n drift closed — all 20 locales 100% (2026-10-13)
- Every non-en/es locale had fallen to **97%** (245 keys each — `trustUi`,
`labOptions`, `cordnUi`, `socialUi`, `walletUi`, `marketTerms`, …). Backfilled

View file

@ -130,7 +130,7 @@ async function main() {
const jaCoverage = en.size ? Math.round(((en.size - missingJa) / en.size) * 100) : 100;
// 5. Additional partial locales (same rules as th/ja).
const extraLocales = ['pt', 'fr', 'de', 'it', 'nl', 'pl', 'ru', 'ko', 'zh-CN', 'zh-TW', 'uk', 'tr', 'vi', 'id', 'sv', 'cs', 'ro', 'el'];
const extraLocales = ['pt', 'fr', 'de', 'it', 'nl', 'pl', 'ru', 'ko', 'zh-CN', 'zh-TW', 'uk', 'tr', 'vi', 'id', 'sv', 'cs', 'ro', 'el', 'ca', 'eu', 'gl'];
const extraStats = [];
for (const lang of extraLocales) {
const dict = flatten(await loadTranslations(join(SRC, `i18n/${lang}/index.ts`)));

View file

@ -20,6 +20,9 @@ interface LanguageOption {
const languages: LanguageOption[] = [
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧' },
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸' },
{ code: 'ca', name: 'Catalan', nativeName: 'Català', flag: 'CA' },
{ code: 'eu', name: 'Basque', nativeName: 'Euskara', flag: 'EU' },
{ code: 'gl', name: 'Galician', nativeName: 'Galego', flag: 'GL' },
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷' },
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷' },
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪' },

View file

@ -47,6 +47,9 @@ function detectLanguage(): Language {
if (tags.some((t) => t.startsWith('cs'))) return 'cs';
if (tags.some((t) => t.startsWith('ro'))) return 'ro';
if (tags.some((t) => t.startsWith('el'))) return 'el';
if (tags.some((t) => t.startsWith('ca'))) return 'ca';
if (tags.some((t) => t.startsWith('eu'))) return 'eu';
if (tags.some((t) => t.startsWith('gl'))) return 'gl';
if (tags.some((t) => t.startsWith('es'))) return 'es';
return 'en';
}

View file

@ -7,7 +7,7 @@ import en from '@/i18n/en';
* `react-refresh/only-export-components` rule.
*/
export type Language = 'en' | 'es' | 'th' | 'ja' | 'pt' | 'fr' | 'de' | 'it' | 'nl' | 'pl' | 'ru' | 'ko' | 'ko-KP' | 'zh-CN' | 'zh-TW' | 'uk' | 'tr' | 'vi' | 'id' | 'sv' | 'cs' | 'ro' | 'el';
export type Language = 'en' | 'es' | 'ca' | 'eu' | 'gl' | 'th' | 'ja' | 'pt' | 'fr' | 'de' | 'it' | 'nl' | 'pl' | 'ru' | 'ko' | 'ko-KP' | 'zh-CN' | 'zh-TW' | 'uk' | 'tr' | 'vi' | 'id' | 'sv' | 'cs' | 'ro' | 'el';
type TranslationKey = string;
type TranslationValue = string | { [key: string]: TranslationValue };
@ -39,6 +39,9 @@ export const englishTranslations = en;
export const LANGUAGE_LOADERS: Record<Language, () => Promise<Translations>> = {
en: async () => en,
es: () => import('@/i18n/es').then((m) => m.default),
ca: () => import('@/i18n/ca').then((m) => m.default),
eu: () => import('@/i18n/eu').then((m) => m.default),
gl: () => import('@/i18n/gl').then((m) => m.default),
th: () => import('@/i18n/th').then((m) => m.default),
ja: () => import('@/i18n/ja').then((m) => m.default),
pt: () => import('@/i18n/pt').then((m) => m.default),

1268
src/i18n/ca/batch1.ts Normal file

File diff suppressed because it is too large Load diff

1491
src/i18n/ca/batch2.ts Normal file

File diff suppressed because it is too large Load diff

1300
src/i18n/ca/batch3.ts Normal file

File diff suppressed because it is too large Load diff

1427
src/i18n/ca/batch4.ts Normal file

File diff suppressed because it is too large Load diff

1463
src/i18n/ca/batch5.ts Normal file

File diff suppressed because it is too large Load diff

1137
src/i18n/ca/batch6.ts Normal file

File diff suppressed because it is too large Load diff

1599
src/i18n/ca/batch7.ts Normal file

File diff suppressed because it is too large Load diff

9
src/i18n/ca/drift.ts Normal file
View file

@ -0,0 +1,9 @@
/**
* Post-translation corrections for ca.
*
* Add keys here when a batch translation needs fixing without editing the
* generated batch files. Values must also exist in `en`.
*/
const drift = {};
export default drift;

24
src/i18n/ca/index.ts Normal file
View file

@ -0,0 +1,24 @@
import batch1 from './batch1';
import batch2 from './batch2';
import batch3 from './batch3';
import batch4 from './batch4';
import batch5 from './batch5';
import batch6 from './batch6';
import batch7 from './batch7';
import drift from './drift';
import { deepMerge } from '../deepMerge';
const ca = deepMerge(
{
...batch1,
...batch2,
...batch3,
...batch4,
...batch5,
...batch6,
...batch7,
},
drift,
);
export default ca;

1268
src/i18n/eu/batch1.ts Normal file

File diff suppressed because it is too large Load diff

1491
src/i18n/eu/batch2.ts Normal file

File diff suppressed because it is too large Load diff

1300
src/i18n/eu/batch3.ts Normal file

File diff suppressed because it is too large Load diff

1427
src/i18n/eu/batch4.ts Normal file

File diff suppressed because it is too large Load diff

1463
src/i18n/eu/batch5.ts Normal file

File diff suppressed because it is too large Load diff

1137
src/i18n/eu/batch6.ts Normal file

File diff suppressed because it is too large Load diff

1599
src/i18n/eu/batch7.ts Normal file

File diff suppressed because it is too large Load diff

9
src/i18n/eu/drift.ts Normal file
View file

@ -0,0 +1,9 @@
/**
* Post-translation corrections for eu.
*
* Add keys here when a batch translation needs fixing without editing the
* generated batch files. Values must also exist in `en`.
*/
const drift = {};
export default drift;

24
src/i18n/eu/index.ts Normal file
View file

@ -0,0 +1,24 @@
import batch1 from './batch1';
import batch2 from './batch2';
import batch3 from './batch3';
import batch4 from './batch4';
import batch5 from './batch5';
import batch6 from './batch6';
import batch7 from './batch7';
import drift from './drift';
import { deepMerge } from '../deepMerge';
const eu = deepMerge(
{
...batch1,
...batch2,
...batch3,
...batch4,
...batch5,
...batch6,
...batch7,
},
drift,
);
export default eu;

1268
src/i18n/gl/batch1.ts Normal file

File diff suppressed because it is too large Load diff

1491
src/i18n/gl/batch2.ts Normal file

File diff suppressed because it is too large Load diff

1300
src/i18n/gl/batch3.ts Normal file

File diff suppressed because it is too large Load diff

1427
src/i18n/gl/batch4.ts Normal file

File diff suppressed because it is too large Load diff

1463
src/i18n/gl/batch5.ts Normal file

File diff suppressed because it is too large Load diff

1137
src/i18n/gl/batch6.ts Normal file

File diff suppressed because it is too large Load diff

1600
src/i18n/gl/batch7.ts Normal file

File diff suppressed because it is too large Load diff

9
src/i18n/gl/drift.ts Normal file
View file

@ -0,0 +1,9 @@
/**
* Post-translation corrections for gl.
*
* Add keys here when a batch translation needs fixing without editing the
* generated batch files. Values must also exist in `en`.
*/
const drift = {};
export default drift;

24
src/i18n/gl/index.ts Normal file
View file

@ -0,0 +1,24 @@
import batch1 from './batch1';
import batch2 from './batch2';
import batch3 from './batch3';
import batch4 from './batch4';
import batch5 from './batch5';
import batch6 from './batch6';
import batch7 from './batch7';
import drift from './drift';
import { deepMerge } from '../deepMerge';
const gl = deepMerge(
{
...batch1,
...batch2,
...batch3,
...batch4,
...batch5,
...batch6,
...batch7,
},
drift,
);
export default gl;

View file

@ -22,6 +22,9 @@ import {
import {
enUS,
es as esLocale,
ca as caLocale,
eu as euLocale,
gl as glLocale,
th as thLocale,
ja as jaLocale,
ptBR as ptLocale,
@ -54,6 +57,15 @@ export function setDateLocale(language: Language): void {
if (language === 'es') {
activeLocale = esLocale;
activeCode = 'es-ES';
} else if (language === 'ca') {
activeLocale = caLocale;
activeCode = 'ca-ES';
} else if (language === 'eu') {
activeLocale = euLocale;
activeCode = 'eu-ES';
} else if (language === 'gl') {
activeLocale = glLocale;
activeCode = 'gl-ES';
} else if (language === 'th') {
activeLocale = thLocale;
activeCode = 'th-TH';