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 ( -
/ 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