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, stays English (spot, park, bowl, DIY, vert, crew, line, Stash, The Lab, Nostr,
Lightning, Zap, Relay, sats) because Thai skaters use the English terms. 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) ### i18n drift closed — all 20 locales 100% (2026-10-13)
- Every non-en/es locale had fallen to **97%** (245 keys each — `trustUi`, - Every non-en/es locale had fallen to **97%** (245 keys each — `trustUi`,
`labOptions`, `cordnUi`, `socialUi`, `walletUi`, `marketTerms`, …). Backfilled `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; const jaCoverage = en.size ? Math.round(((en.size - missingJa) / en.size) * 100) : 100;
// 5. Additional partial locales (same rules as th/ja). // 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 = []; const extraStats = [];
for (const lang of extraLocales) { for (const lang of extraLocales) {
const dict = flatten(await loadTranslations(join(SRC, `i18n/${lang}/index.ts`))); const dict = flatten(await loadTranslations(join(SRC, `i18n/${lang}/index.ts`)));

View file

@ -20,6 +20,9 @@ interface LanguageOption {
const languages: LanguageOption[] = [ const languages: LanguageOption[] = [
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧' }, { code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧' },
{ code: 'es', name: 'Spanish', nativeName: 'Español', 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: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷' },
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷' }, { code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷' },
{ code: 'de', name: 'German', nativeName: 'Deutsch', 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('cs'))) return 'cs';
if (tags.some((t) => t.startsWith('ro'))) return 'ro'; if (tags.some((t) => t.startsWith('ro'))) return 'ro';
if (tags.some((t) => t.startsWith('el'))) return 'el'; 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'; if (tags.some((t) => t.startsWith('es'))) return 'es';
return 'en'; return 'en';
} }

View file

@ -7,7 +7,7 @@ import en from '@/i18n/en';
* `react-refresh/only-export-components` rule. * `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 TranslationKey = string;
type TranslationValue = string | { [key: string]: TranslationValue }; type TranslationValue = string | { [key: string]: TranslationValue };
@ -39,6 +39,9 @@ export const englishTranslations = en;
export const LANGUAGE_LOADERS: Record<Language, () => Promise<Translations>> = { export const LANGUAGE_LOADERS: Record<Language, () => Promise<Translations>> = {
en: async () => en, en: async () => en,
es: () => import('@/i18n/es').then((m) => m.default), 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), th: () => import('@/i18n/th').then((m) => m.default),
ja: () => import('@/i18n/ja').then((m) => m.default), ja: () => import('@/i18n/ja').then((m) => m.default),
pt: () => import('@/i18n/pt').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 { import {
enUS, enUS,
es as esLocale, es as esLocale,
ca as caLocale,
eu as euLocale,
gl as glLocale,
th as thLocale, th as thLocale,
ja as jaLocale, ja as jaLocale,
ptBR as ptLocale, ptBR as ptLocale,
@ -54,6 +57,15 @@ export function setDateLocale(language: Language): void {
if (language === 'es') { if (language === 'es') {
activeLocale = esLocale; activeLocale = esLocale;
activeCode = 'es-ES'; 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') { } else if (language === 'th') {
activeLocale = thLocale; activeLocale = thLocale;
activeCode = 'th-TH'; activeCode = 'th-TH';