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..8ecf5b4 100644 --- a/frontend/src/components/navbar/LanguageSwitch.jsx +++ b/frontend/src/components/navbar/LanguageSwitch.jsx @@ -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 ( -
+ // 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 ( @@ -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()} 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