Merge pull request #222 from fridgee1/feat/plusieurs-langues

Langues : liste de langues au lieu du seul couple français/anglais
This commit is contained in:
IliesAllali authored and GitHub committed 2026-11-11 13:41:44 +01:00
commit f8d1a48240
7 files changed
+114 -49

No files matched your search

+1 -1
View File
@@ -31,7 +31,7 @@ Si vous touchez à Docker ou à nginx, lancez `docker compose up -d --build` pui
## Conventions
- L'interface est en français et en anglais. Tout texte visible passe par `t()` de `frontend/src/i18n/index.js` : le texte français sert de clé, sa traduction anglaise va dans `frontend/src/i18n/en/`. Un test échoue si une traduction manque.
- L'interface est en français et en anglais. Tout texte visible passe par `t()` de `frontend/src/i18n/index.js` : le texte français sert de clé, sa traduction anglaise va dans `frontend/src/i18n/en/`. Un test échoue si une traduction anglaise manque. Les autres langues retombent sur l'anglais : un nouveau texte n'a besoin que du français et de l'anglais.
- Pas de nouvelle dépendance lourde (framework, bibliothèque d'interface, service externe) sans en avoir discuté dans une issue.
- La promesse du README tient : pas de publicité, et aucune mesure d'audience activée par défaut sur une instance auto-hébergée.
+1 -1
View File
@@ -31,7 +31,7 @@ If you touch Docker or nginx, run `docker compose up -d --build` then `bash tool
## Conventions
- The interface is in French and English. Every visible text goes through `t()` from `frontend/src/i18n/index.js`, with the French text as the key and its English entry in `frontend/src/i18n/en/`. A test fails if an English entry is missing.
- The interface is in French and English. Every visible text goes through `t()` from `frontend/src/i18n/index.js`, with the French text as the key and its English entry in `frontend/src/i18n/en/`. A test fails if an English entry is missing. Other languages fall back to English, so a new text only needs French and English.
- No new heavy dependency (framework, UI library, external service) without discussing it in an issue.
- The README promise holds: no ads, and no analytics turned on by default on a self-hosted instance.
+3 -1
View File
@@ -206,7 +206,9 @@ The Vite server proxies `/api`, `/trees`, `/auth` and `/health` to the local API
### Translations
Interface text goes through `t()` from `frontend/src/i18n/index.js`. The French text is the key, and the English lives in `frontend/src/i18n/en/*.js`. A missing English entry falls back to French, and a test fails if any text passed to `t()` has no English entry.
Interface text goes through `t()` from `frontend/src/i18n/index.js`. The French text is the key, and each other language has its own folder built like `frontend/src/i18n/en/*.js`. The languages are listed in `LANGUAGES` in the same file. A missing entry falls back to English, then to French. A test fails if any text passed to `t()` has no English entry. For the other languages, it only lists the texts that are not translated yet.
To add a language: copy the structure of `i18n/en/`, add a line to `LANGUAGES`, and add a kinship namer in `frontend/src/utils/kinship/` (English is used otherwise). The help and privacy links point to the English pages of parenthese.io.
### Tests
@@ -1,16 +1,14 @@
import { Fragment } from 'react'
import { LOCALES, locale, setLocale, t } from '../../i18n/index.js'
// Noms des langues écrits dans leur propre langue : ils ne se traduisent pas.
const NATIVE_NAMES = { fr: 'Français', en: 'English' }
import { LANGUAGES, LOCALES, locale, setLocale, t } from '../../i18n/index.js'
/**
* Choix de la langue « FR · EN », la langue courante en appui.
* Choix de la langue « FR · EN », une entrée par langue de LANGUAGES, la langue courante en appui.
* Changer de langue recharge la page (voir i18n/index.js).
*/
function LanguageSwitch({ className = '' }) {
return (
<div className={`pz-lang ${className}`.trim()} role="group" aria-label={t('Langue')}>
// translate="no" : la traduction automatique du navigateur prenait les codes de langue (« EN ») pour des mots
<div className={`pz-lang ${className}`.trim()} role="group" aria-label={t('Langue')} translate="no">
{LOCALES.map((code, index) => {
const isActive = code === locale
return (
@@ -21,7 +19,7 @@ function LanguageSwitch({ className = '' }) {
className={`pz-lang-opt${isActive ? ' is-active' : ''}`}
aria-pressed={isActive}
lang={code}
title={NATIVE_NAMES[code]}
title={LANGUAGES[code].name}
onClick={() => setLocale(code)}
>
{code.toUpperCase()}
+43 -10
View File
@@ -2,8 +2,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import en from './en/index.js'
import { frenchTypography, t } from './index.js'
import { FALLBACK_LOCALE, LANGUAGES, frenchTypography, pickLocale, t, translate } from './index.js'
const SRC = fileURLToPath(new URL('..', import.meta.url))
@@ -30,19 +29,31 @@ describe('i18n', () => {
expect(t('{n} personnes', { n: 3 })).toBe('3 personnes')
})
// Textes passés à t() sans entrée dans le dictionnaire d'une langue
const usedKeys = () => sourceFiles(SRC).flatMap((file) => literalKeys(readFileSync(file, 'utf8')).map((key) => ({ file, key })))
const missingIn = (dictionary) => usedKeys()
.filter(({ key }) => !Object.prototype.hasOwnProperty.call(dictionary, key))
.map(({ file, key }) => `${file.slice(SRC.length)} : ${key}`)
it('chaque texte passé à t() a sa traduction anglaise', () => {
const missing = []
for (const file of sourceFiles(SRC)) {
for (const key of literalKeys(readFileSync(file, 'utf8'))) {
if (!Object.prototype.hasOwnProperty.call(en, key)) missing.push(`${file.slice(SRC.length)} : ${key}`)
}
}
expect(missing).toEqual([])
expect(missingIn(LANGUAGES[FALLBACK_LOCALE].dictionary)).toEqual([])
})
// Les autres langues retombent sur l'anglais : un texte pas encore traduit est signalé, sans faire
// échouer les tests, pour qu'un nouveau texte en français et en anglais suffise à une contribution.
for (const [code, { dictionary }] of Object.entries(LANGUAGES)) {
if (!dictionary || code === FALLBACK_LOCALE) continue
it(`signale les textes pas encore traduits (${code})`, () => {
const gaps = [...new Set(missingIn(dictionary))]
if (gaps.length) console.warn(`[i18n] ${gaps.length} texte(s) sans traduction (${code}), affichés en anglais :\n${gaps.join('\n')}`)
})
}
it('les traductions gardent les mêmes variables', () => {
const vars = (s) => (s.match(/\{\w+\}/g) ?? []).sort().join(',')
const mismatched = Object.entries(en).filter(([fr, english]) => vars(fr) !== vars(english)).map(([fr]) => fr)
const mismatched = Object.entries(LANGUAGES)
.filter(([, { dictionary }]) => dictionary)
.flatMap(([code, { dictionary }]) => Object.entries(dictionary).filter(([fr, text]) => vars(fr) !== vars(text)).map(([fr]) => `${code} : ${fr}`))
expect(mismatched).toEqual([])
})
@@ -54,4 +65,26 @@ describe('i18n', () => {
expect(frenchTypography('https://app.parenthese.io/?lang=fr')).toBe('https://app.parenthese.io/?lang=fr')
expect(frenchTypography('?')).toBe('?')
})
it('choisit la langue : adresse, puis choix mémorisé, puis navigateur, anglais à défaut', () => {
expect(pickLocale({ fromUrl: 'en', stored: 'fr', languages: ['fr'] })).toBe('en')
expect(pickLocale({ fromUrl: 'xx', stored: 'fr', languages: ['en'] })).toBe('fr')
expect(pickLocale({ stored: 'xx', languages: ['fr-CA'] })).toBe('fr')
expect(pickLocale({ languages: ['en-US', 'fr'] })).toBe('fr')
expect(pickLocale({ languages: ['en-US'] })).toBe('en')
expect(pickLocale({ languages: ['ja', 'es-ES'] })).toBe('en')
expect(pickLocale()).toBe('en')
})
it('retombe sur l’anglais puis sur le français pour un texte non traduit', () => {
LANGUAGES.xx = { name: 'Test', region: 'en-GB', dictionary: { 'Bonjour': 'Hallo' } }
try {
expect(translate('xx', 'Bonjour')).toBe('Hallo')
expect(translate('xx', 'Mot de passe')).toBe(LANGUAGES.en.dictionary['Mot de passe'])
expect(translate('xx', 'Texte inconnu ?')).toBe('Texte inconnu ?')
expect(translate('fr', 'Texte inconnu ?')).toBe('Texte inconnu\u202F?')
} finally {
delete LANGUAGES.xx
}
})
})
+54 -24
View File
@@ -1,8 +1,8 @@
// Langue de l'interface : français ou anglais.
// Langue de l'interface : français, anglais, ou toute langue déclarée dans LANGUAGES.
//
// Le texte français sert de clé : t('Mot de passe incorrect.') renvoie la phrase telle quelle en
// français, et sa traduction du dictionnaire anglais (./en) en anglais. Une clé absente du
// dictionnaire retombe sur le français, jamais sur une chaîne vide.
// français, et sa traduction du dictionnaire de la langue (./en pour l'anglais) ailleurs. Une clé
// absente du dictionnaire retombe sur l'anglais, puis sur le français, jamais sur une chaîne vide.
//
// Variables entre accolades : t('{n} personnes', { n: 3 }).
//
@@ -12,9 +12,35 @@
import en from './en/index.js'
export const LOCALES = ['fr', 'en']
// Langues de l'interface, dans l'ordre du bouton de choix.
// name : le nom de la langue écrit dans la langue même, il ne se traduit pas
// region : la région des dates quand le navigateur n'en donne pas dans cette langue
// dictionary : les traductions, indexées sur le français (aucune pour le français, qui sert de clé)
// Ajouter une langue : un dossier ./<code>/ sur le modèle de ./en, une ligne ici, et ses noms de
// liens de parenté dans utils/kinship (l'anglais sinon).
export const LANGUAGES = {
fr: { name: 'Français', region: 'fr-FR' },
en: { name: 'English', region: 'en-GB', dictionary: en },
}
export const LOCALES = Object.keys(LANGUAGES)
// Langue de repli : celle d'un navigateur dont aucune langue n'est connue, et celle d'un texte
// pas encore traduit dans la langue choisie
export const FALLBACK_LOCALE = 'en'
const STORAGE_KEY = 'pz-locale'
const browserLanguages = () => (typeof navigator === 'undefined' ? [] : (navigator.languages?.length ? navigator.languages : [navigator.language]))
// ?lang= dans l'adresse, sinon le choix mémorisé, sinon la première langue du navigateur que l'appli
// connaît, l'anglais mis à part : il sert de repli, et « en-US, fr » donne le français.
export function pickLocale({ fromUrl, stored, languages = [] } = {}) {
if (LOCALES.includes(fromUrl)) return fromUrl
if (LOCALES.includes(stored)) return stored
const known = languages
.map((lang) => String(lang).toLowerCase().split(/[-_]/)[0])
.find((code) => code !== FALLBACK_LOCALE && LOCALES.includes(code))
return known || FALLBACK_LOCALE
}
function detectLocale() {
// ?lang=en dans l'adresse l'emporte (liens partagés vers la démo en anglais) et devient le choix mémorisé.
const fromUrl = new URLSearchParams(window.location.search).get('lang')
@@ -22,22 +48,18 @@ function detectLocale() {
try { window.localStorage.setItem(STORAGE_KEY, fromUrl) } catch { /* rien */ }
return fromUrl
}
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
if (LOCALES.includes(stored)) return stored
} catch { /* stockage indisponible : on suit le navigateur */ }
const languages = typeof navigator === 'undefined' ? [] : (navigator.languages?.length ? navigator.languages : [navigator.language])
return languages.some((lang) => String(lang).toLowerCase().startsWith('fr')) ? 'fr' : 'en'
let stored = null
try { stored = window.localStorage.getItem(STORAGE_KEY) } catch { /* stockage indisponible : on suit le navigateur */ }
return pickLocale({ stored, languages: browserLanguages() })
}
export const locale = typeof window === 'undefined' ? 'fr' : detectLocale()
// Pour toLocaleDateString et consorts : la région du navigateur dans la langue de l'interface (en-US, fr-CA…),
// sinon fr-FR ou en-GB. Elle décide de l'ordre jour, mois, année et du séparateur (issue #152).
// sinon celle de LANGUAGES (fr-FR, en-GB…). Elle décide de l'ordre jour, mois, année et du séparateur (issue #152).
function regionalDateLocale() {
const fallback = locale === 'fr' ? 'fr-FR' : 'en-GB'
const languages = typeof navigator === 'undefined' ? [] : (navigator.languages?.length ? navigator.languages : [navigator.language])
const regional = languages.find((lang) => new RegExp(`^${locale}-[a-z]{2}$`, 'i').test(String(lang)))
const fallback = LANGUAGES[locale].region
const regional = browserLanguages().find((lang) => new RegExp(`^${locale}-[a-z]{2}$`, 'i').test(String(lang)))
if (!regional) return fallback
try { return Intl.DateTimeFormat.supportedLocalesOf(regional)[0] || fallback } catch { return fallback }
}
@@ -59,8 +81,8 @@ const missing = new Set()
// Typographie française : espace fine insécable avant ? ! ;, insécable avant : et à l'intérieur
// des « », insécable entre un nombre (ou une variable) et son unité. Appliquée au texte avant le
// remplacement des variables, qui gardent leurs espaces (noms, adresses). Le dictionnaire anglais
// reste indexé sur le français tel qu'écrit dans le code.
// remplacement des variables, qui gardent leurs espaces (noms, adresses). Les dictionnaires
// restent indexés sur le français tel qu'écrit dans le code.
const NNBSP = '\u202F'
const NBSP = '\u00A0'
const typoCache = new Map()
@@ -78,15 +100,23 @@ export function frenchTypography(text) {
return out
}
export function t(fr, vars) {
let text = locale === 'fr' ? frenchTypography(fr) : fr
if (locale === 'en') {
if (Object.prototype.hasOwnProperty.call(en, fr)) text = en[fr]
else if (import.meta.env?.DEV && !missing.has(fr)) {
missing.add(fr)
console.warn(`[i18n] traduction anglaise manquante : ${JSON.stringify(fr)}`)
}
const has = (dictionary, key) => Boolean(dictionary) && Object.prototype.hasOwnProperty.call(dictionary, key)
// Texte d'une clé dans une langue : son dictionnaire, sinon celui de la langue de repli, sinon le français
export function translate(lang, fr) {
if (lang === 'fr') return frenchTypography(fr)
const own = LANGUAGES[lang]?.dictionary
if (has(own, fr)) return own[fr]
if (import.meta.env?.DEV && !missing.has(fr)) {
missing.add(fr)
console.warn(`[i18n] traduction manquante (${lang}) : ${JSON.stringify(fr)}`)
}
const fallback = LANGUAGES[FALLBACK_LOCALE].dictionary
return has(fallback, fr) ? fallback[fr] : fr
}
export function t(fr, vars) {
const text = translate(locale, fr)
if (!vars) return text
return text.replace(/\{(\w+)\}/g, (match, name) => (name in vars ? String(vars[name]) : match))
}
+7 -5
View File
@@ -1,12 +1,14 @@
// Pages du site vitrine liées depuis l'app, dans la langue de l'interface.
// Pages du site vitrine liées depuis l'app, dans la langue de l'interface. Le site n'existe qu'en
// français et en anglais : une autre langue mène aux pages anglaises.
// Une instance auto-hébergée peut pointer vers ses propres pages : VITE_HELP_URL et
// VITE_PRIVACY_URL (au build) remplacent alors les liens vers parenthese.io, pour toutes les langues.
import { locale } from '../i18n/index.js'
const env = import.meta.env || {}
export const HELP_URL = env.VITE_HELP_URL
|| (locale === 'en' ? 'https://parenthese.io/en/help/' : 'https://parenthese.io/aide/')
const HELP_PAGES = { fr: 'https://parenthese.io/aide/', en: 'https://parenthese.io/en/help/' }
const PRIVACY_PAGES = { fr: 'https://parenthese.io/donnees-et-vie-privee/', en: 'https://parenthese.io/en/privacy/' }
export const PRIVACY_URL = env.VITE_PRIVACY_URL
|| (locale === 'en' ? 'https://parenthese.io/en/privacy/' : 'https://parenthese.io/donnees-et-vie-privee/')
export const HELP_URL = env.VITE_HELP_URL || HELP_PAGES[locale] || HELP_PAGES.en
export const PRIVACY_URL = env.VITE_PRIVACY_URL || PRIVACY_PAGES[locale] || PRIVACY_PAGES.en