mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-13 08:22:03 +00:00
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:
7 files changed
+114
-49
No files matched your search
+1
-1
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user