From 3bd3df4627cb3920a5a2b158f9f374080c529f06 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 7 Nov 2026 11:42:37 +0100 Subject: [PATCH 1/2] =?UTF-8?q?Langues=20:=20liste=20de=20langues=20au=20l?= =?UTF-8?q?ieu=20du=20seul=20couple=20fran=C3=A7ais/anglais=20(#221)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LANGUAGES décrit chaque langue (nom, région des dates, dictionnaire) ; la détection, la région des dates, le bouton de choix et les liens d'aide s'en servent. Un texte absent d'un dictionnaire retombe sur l'anglais, puis sur le français. Rien ne change pour le français et l'anglais. Le test exige toujours l'anglais ; pour les autres langues, il signale les textes pas encore traduits sans échouer. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Sebastian --- CONTRIBUTING.fr.md | 2 +- CONTRIBUTING.md | 2 +- README.md | 4 +- .../src/components/navbar/LanguageSwitch.jsx | 9 +-- frontend/src/i18n/i18n.test.js | 53 ++++++++++--- frontend/src/i18n/index.js | 78 +++++++++++++------ frontend/src/utils/siteLinks.js | 12 +-- 7 files changed, 112 insertions(+), 48 deletions(-) diff --git a/CONTRIBUTING.fr.md b/CONTRIBUTING.fr.md index 1d22b2c..cd1a9f6 100644 --- a/CONTRIBUTING.fr.md +++ b/CONTRIBUTING.fr.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 178d919..70e19f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index f59bd54..35ac3af 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/src/components/navbar/LanguageSwitch.jsx b/frontend/src/components/navbar/LanguageSwitch.jsx index 6dbd963..b6bcda7 100644 --- a/frontend/src/components/navbar/LanguageSwitch.jsx +++ b/frontend/src/components/navbar/LanguageSwitch.jsx @@ -1,11 +1,8 @@ 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 = '' }) { @@ -21,7 +18,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()} diff --git a/frontend/src/i18n/i18n.test.js b/frontend/src/i18n/i18n.test.js index 30f179a..7c8c6c4 100644 --- a/frontend/src/i18n/i18n.test.js +++ b/frontend/src/i18n/i18n.test.js @@ -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 + } + }) }) diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js index dc80b6f..ac2a428 100644 --- a/frontend/src/i18n/index.js +++ b/frontend/src/i18n/index.js @@ -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 .// 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)) } diff --git a/frontend/src/utils/siteLinks.js b/frontend/src/utils/siteLinks.js index 5ef6cc9..6602bcc 100644 --- a/frontend/src/utils/siteLinks.js +++ b/frontend/src/utils/siteLinks.js @@ -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 From 94c38bdeb968a40f065cf1510cca441923297a4a Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 7 Nov 2026 12:27:39 +0100 Subject: [PATCH 2/2] =?UTF-8?q?Langues=20:=20le=20choix=20de=20langue=20?= =?UTF-8?q?=C3=A9chappe=20=C3=A0=20la=20traduction=20du=20navigateur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La traduction automatique (Chrome, Vivaldi…) transformait « FR · EN » en mots (« EN » → « IN ») sur une page affichée dans une autre langue que celle du navigateur. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Sebastian --- frontend/src/components/navbar/LanguageSwitch.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/navbar/LanguageSwitch.jsx b/frontend/src/components/navbar/LanguageSwitch.jsx index b6bcda7..8ecf5b4 100644 --- a/frontend/src/components/navbar/LanguageSwitch.jsx +++ b/frontend/src/components/navbar/LanguageSwitch.jsx @@ -7,7 +7,8 @@ import { LANGUAGES, LOCALES, locale, setLocale, t } from '../../i18n/index.js' */ function LanguageSwitch({ className = '' }) { return ( -
+ // translate="no" : la traduction automatique du navigateur prenait les codes de langue (« EN ») pour des mots +
{LOCALES.map((code, index) => { const isActive = code === locale return (