feat: add French and Spanish locales and update language selector

This commit is contained in:
2026-06-07 13:44:27 +02:00
parent 8b8196f6e3
commit d948325a45
15 changed files with 4089 additions and 1891 deletions
@@ -63,6 +63,21 @@ function FlagIcon({ lang, className, style }: { lang: string; className?: string
<path d="M7,0 h2 v16 h-2 z M0,7 h22 v2 h-22 z" fill="#00205B"/> <path d="M7,0 h2 v16 h-2 z M0,7 h22 v2 h-22 z" fill="#00205B"/>
</svg> </svg>
) )
case 'fr':
return (
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
<rect width="3" height="2" fill="#FFFFFF"/>
<rect width="1" height="2" fill="#002395"/>
<rect x="2" width="1" height="2" fill="#ED2939"/>
</svg>
)
case 'es':
return (
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
<rect width="3" height="2" fill="#C1272D"/>
<rect y="0.5" width="3" height="1" fill="#FEE100"/>
</svg>
)
default: default:
return null return null
} }
+5 -1
View File
@@ -6,6 +6,8 @@ import deJson from './locales/de.json'
import daJson from './locales/da.json' import daJson from './locales/da.json'
import svJson from './locales/sv.json' import svJson from './locales/sv.json'
import nbJson from './locales/nb.json' import nbJson from './locales/nb.json'
import frJson from './locales/fr.json'
import esJson from './locales/es.json'
import { initSeo } from '../utils/seo.js' import { initSeo } from '../utils/seo.js'
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js' import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
@@ -15,7 +17,9 @@ const resources = {
de: { translation: deJson.translation }, de: { translation: deJson.translation },
da: { translation: daJson.translation }, da: { translation: daJson.translation },
sv: { translation: svJson.translation }, sv: { translation: svJson.translation },
nb: { translation: nbJson.translation } nb: { translation: nbJson.translation },
fr: { translation: frJson.translation },
es: { translation: esJson.translation }
} }
i18n i18n
+5 -1
View File
@@ -4,6 +4,8 @@ import enJson from '../i18n/locales/en.json'
import daJson from '../i18n/locales/da.json' import daJson from '../i18n/locales/da.json'
import svJson from '../i18n/locales/sv.json' import svJson from '../i18n/locales/sv.json'
import nbJson from '../i18n/locales/nb.json' import nbJson from '../i18n/locales/nb.json'
import frJson from '../i18n/locales/fr.json'
import esJson from '../i18n/locales/es.json'
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] { function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
const keys: string[] = [] const keys: string[] = []
@@ -23,7 +25,9 @@ const bundles = {
en: enJson.translation, en: enJson.translation,
da: daJson.translation, da: daJson.translation,
sv: svJson.translation, sv: svJson.translation,
nb: nbJson.translation nb: nbJson.translation,
fr: frJson.translation,
es: esJson.translation
} as const } as const
describe('i18n locale key parity', () => { describe('i18n locale key parity', () => {
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -15,7 +15,9 @@
"en": "English", "en": "English",
"da": "Dansk", "da": "Dansk",
"sv": "Svenska", "sv": "Svenska",
"nb": "Norsk" "nb": "Norsk",
"fr": "Français",
"es": "Español"
}, },
"dialog": { "dialog": {
"ok": "OK", "ok": "OK",
+3 -1
View File
@@ -15,7 +15,9 @@
"en": "English", "en": "English",
"da": "Dansk", "da": "Dansk",
"sv": "Svenska", "sv": "Svenska",
"nb": "Norsk" "nb": "Norsk",
"fr": "French",
"es": "Spanish"
}, },
"dialog": { "dialog": {
"ok": "OK", "ok": "OK",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -20,14 +20,13 @@ vi.mock('../services/analytics.js', async (importOriginal) => {
}) })
function createMockI18n(language: string): I18nInstance { function createMockI18n(language: string): I18nInstance {
let current = language const mock = {
return { language,
language: current,
changeLanguage: vi.fn(async (lng: string) => { changeLanguage: vi.fn(async (lng: string) => {
current = lng mock.language = lng
;(this as { language: string }).language = lng
}) })
} as unknown as I18nInstance } as unknown as I18nInstance
return mock
} }
describe('i18nLanguages', () => { describe('i18nLanguages', () => {
@@ -72,11 +71,11 @@ describe('i18nLanguages', () => {
}) })
it('cycleAppLanguage tracks the next language', () => { it('cycleAppLanguage tracks the next language', () => {
const i18n = createMockI18n('nb') const i18n = createMockI18n('es')
cycleAppLanguage(i18n) cycleAppLanguage(i18n)
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, { expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
from: 'nb', from: 'es',
to: 'de' to: 'de'
}) })
}) })
+4 -2
View File
@@ -2,7 +2,7 @@ import type { i18n as I18nInstance } from 'i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
/** Supported UI languages (ISO 639-1, language-only). */ /** Supported UI languages (ISO 639-1, language-only). */
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb'] as const export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb', 'fr', 'es'] as const
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number] export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
@@ -11,7 +11,9 @@ export const LANGUAGE_FLAGS: Record<AppLanguage, string> = {
en: '🇬🇧', en: '🇬🇧',
da: '🇩🇰', da: '🇩🇰',
sv: '🇸🇪', sv: '🇸🇪',
nb: '🇳🇴' nb: '🇳🇴',
fr: '🇫🇷',
es: '🇪🇸'
} }
export function normalizeAppLanguage(language?: string): AppLanguage { export function normalizeAppLanguage(language?: string): AppLanguage {
+3 -1
View File
@@ -10,7 +10,9 @@ const OG_LOCALES: Record<SeoLang, string> = {
en: 'en_GB', en: 'en_GB',
da: 'da_DK', da: 'da_DK',
sv: 'sv_SE', sv: 'sv_SE',
nb: 'nb_NO' nb: 'nb_NO',
fr: 'fr_FR',
es: 'es_ES'
} }
let i18nRef: I18nInstance | null = null let i18nRef: I18nInstance | null = null
+3 -1
View File
@@ -23,7 +23,9 @@ const defaultSource = resolve(repoRoot, 'client/src/i18n/locales/de.json')
const TARGETS = { const TARGETS = {
da: 'DA', da: 'DA',
sv: 'SV', sv: 'SV',
nb: 'NB' nb: 'NB',
fr: 'FR',
es: 'ES'
} }
/** Keys whose values stay identical to source (language names, brand). */ /** Keys whose values stay identical to source (language names, brand). */
+1 -1
View File
@@ -11,7 +11,7 @@ import { flattenTranslation } from './lib/deepl-translate.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const localesDir = resolve(__dirname, '../client/src/i18n/locales') const localesDir = resolve(__dirname, '../client/src/i18n/locales')
const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json'] const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json', 'fr.json', 'es.json']
async function loadKeys(filename) { async function loadKeys(filename) {
const raw = await readFile(resolve(localesDir, filename), 'utf8') const raw = await readFile(resolve(localesDir, filename), 'utf8')