From 59567f1af00eb8ca8c6a878deaf08aab79b3e295 Mon Sep 17 00:00:00 2001 From: Julien Dudek <46819368+J-Dudek@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:48:45 +0100 Subject: [PATCH] feat(i18n): add French (#730) Adds French as a UI language, with French catalogs for message keys and source text, and the French space before a colon in settings labels. UI languages are now registered in one place, `LOCALES` in `src/platform/i18n.ts` (name in its own language and `` tag); the Language and Region options, settings validation and `` all come from it. The catalog tests read every catalog in `locales/` and `locales/text/`, so a new file is always checked, and literal message keys given to `t` must exist in `en.json`. Only Chinese must cover every UI string; French and Korean show English for text they lack. Also makes two date tests independent of DST changes. Co-authored-by: J-Dudek <46819368+J-Dudek@users.noreply.github.com> --- src/features/github/ui/ui.test.tsx | 15 +- .../settings/__tests__/staging.test.ts | 6 +- .../settings/components/Form.module.css | 3 + .../settings/languages/Sdk.module.css | 3 + src/features/settings/model/schema.ts | 7 +- src/features/settings/pages/behavior.tsx | 10 +- src/features/settings/runtime.ts | 8 +- src/features/tools/ServerSettings.module.css | 3 + src/platform/i18n.test.ts | 99 +- src/platform/i18n.ts | 17 +- src/platform/locales/fr.json | 450 ++ src/platform/locales/text/fr.json | 5797 +++++++++++++++++ src/platform/locales/textCatalog.test.ts | 47 +- 13 files changed, 6409 insertions(+), 56 deletions(-) create mode 100644 src/platform/locales/fr.json create mode 100644 src/platform/locales/text/fr.json diff --git a/src/features/github/ui/ui.test.tsx b/src/features/github/ui/ui.test.tsx index 747cf4a6d..166ca15ee 100644 --- a/src/features/github/ui/ui.test.tsx +++ b/src/features/github/ui/ui.test.tsx @@ -10,7 +10,8 @@ import { fullDateTime, prettyDate } from "./prettyDate"; describe("prettyDate", () => { const o = { ...DEFAULT_DATE_FORMAT, overrideSystem: true, pattern: "dd.MM.yyyy", use24Hour: true }; - const now = new Date(2026, 9, 31, 9, 2).getTime(); + // Between May and June, so that no date below crosses a DST change in any time zone that has one. + const now = new Date(2026, 5, 30, 9, 2).getTime(); it("formats relative dates like GitHub", () => { expect(prettyDate(now - 20_000, now, o)).toBe("just now"); @@ -18,16 +19,16 @@ describe("prettyDate", () => { expect(prettyDate(now - 60_000, now, o)).toBe("1 minute ago"); expect(prettyDate(now - 42 * 60_000, now, o)).toBe("42 minutes ago"); expect(prettyDate(now - 3_600_000, now, o)).toBe("1 hour ago"); - expect(prettyDate(new Date(2026, 9, 31, 0, 27).getTime(), now, o)).toBe("8 hours ago"); - expect(prettyDate(new Date(2026, 9, 30, 8, 0).getTime(), now, o)).toBe("yesterday"); - expect(prettyDate(new Date(2026, 9, 21, 9, 5).getTime(), now, o)).toBe("9 days ago"); - expect(prettyDate(new Date(2026, 8, 2, 9, 5).getTime(), now, o)).toBe("on 02.09.2026"); + expect(prettyDate(new Date(2026, 5, 30, 0, 27).getTime(), now, o)).toBe("8 hours ago"); + expect(prettyDate(new Date(2026, 5, 29, 8, 0).getTime(), now, o)).toBe("yesterday"); + expect(prettyDate(new Date(2026, 5, 20, 9, 5).getTime(), now, o)).toBe("9 days ago"); + expect(prettyDate(new Date(2026, 4, 2, 9, 5).getTime(), now, o)).toBe("on 02.05.2026"); expect(prettyDate("not a date", now, o)).toBe(""); }); it("shows the plain date and time with pretty formatting off", () => { - expect(prettyDate(now - 20_000, now, { ...o, pretty: false })).toBe("31.10.2026 09:01"); - expect(fullDateTime(new Date(2026, 9, 31, 0, 27).getTime(), o)).toBe("31.10.2026 00:27"); + expect(prettyDate(now - 20_000, now, { ...o, pretty: false })).toBe("30.06.2026 09:01"); + expect(fullDateTime(new Date(2026, 5, 30, 0, 27).getTime(), o)).toBe("30.06.2026 00:27"); }); }); diff --git a/src/features/settings/__tests__/staging.test.ts b/src/features/settings/__tests__/staging.test.ts index b4b802c6c..635318135 100644 --- a/src/features/settings/__tests__/staging.test.ts +++ b/src/features/settings/__tests__/staging.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Settings } from "@/platform/ipc"; import { DEFAULT_SSH_CONFIG } from "@/platform/remoteSettings"; +import { SUPPORTED_LOCALES } from "@/platform/i18n"; import { useProject } from "@/platform/project"; import { DEFAULT_SETTINGS, getAt, setAt, validateSettings } from "../model/schema"; import { @@ -267,7 +268,7 @@ describe("validateSettings", () => { expect(validateSettings(undefined)).toEqual(DEFAULT_SETTINGS); expect(validateSettings("garbage")).toEqual(DEFAULT_SETTINGS); const v = validateSettings({ - appearance: { theme: "neon", accent: "red", uiFontSize: 400, locale: "fr" }, + appearance: { theme: "neon", accent: "red", uiFontSize: 400, locale: "xx" }, editor: { fontSize: "big", tabSize: 2.6, lineHeight: -1, softWrap: "yes", fontFamily: " " }, files: { exclude: ["**/a", 3, "", null] }, keymap: 12, @@ -291,7 +292,8 @@ describe("validateSettings", () => { it("keeps valid values, including dark-hc", () => { const v = validateSettings({ appearance: { theme: "dark-hc", accent: "#C9A080", locale: "zh" } }); expect(v.appearance).toMatchObject({ theme: "dark-hc", accent: "#c9a080", locale: "zh" }); - expect(validateSettings({ appearance: { locale: "ko" } }).appearance.locale).toBe("ko"); + for (const locale of SUPPORTED_LOCALES) + expect(validateSettings({ appearance: { locale } }).appearance.locale).toBe(locale); }); it("reads and writes dotted keys immutably", () => { diff --git a/src/features/settings/components/Form.module.css b/src/features/settings/components/Form.module.css index 688e14d73..657222174 100644 --- a/src/features/settings/components/Form.module.css +++ b/src/features/settings/components/Form.module.css @@ -54,6 +54,9 @@ .row:not(.stacked) > .label::after { content: ":"; } +:lang(fr) .row:not(.stacked) > .label::after { + content: "\00a0:"; +} .control { display: flex; align-items: center; diff --git a/src/features/settings/languages/Sdk.module.css b/src/features/settings/languages/Sdk.module.css index 7543c69f4..7f256fc6c 100644 --- a/src/features/settings/languages/Sdk.module.css +++ b/src/features/settings/languages/Sdk.module.css @@ -31,6 +31,9 @@ .interpreterLabel::after { content: ":"; } +:lang(fr) .interpreterLabel::after { + content: "\00a0:"; +} .path { color: var(--fg-muted); font-family: var(--font-mono); diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts index 16011f873..04b8af8d5 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -5,6 +5,7 @@ * back to the default (mirrors `ide-core::settings` defaults). */ import { validateCMake } from "@/platform/cmakeSettings"; +import { SUPPORTED_LOCALES, type Locale } from "@/platform/i18n"; import { DEFAULT_IGNORED_NAMES } from "@/platform/ignoredNames"; import { validateFileTypeOverrides, validateFileTypes, type FileTypeAssociations } from "@/platform/fileTypes"; import type { AgentSettings, LanguageServerSettings, Settings } from "@/platform/ipc"; @@ -35,7 +36,6 @@ import { isThemeChoiceId, type ThemeChoiceId } from "@/platform/themes"; /** A built-in theme, a plugin's (`plugin:/`) or the legacy `"system"`. */ export type ThemeId = ThemeChoiceId | "system"; -export type LocaleId = "en" | "zh" | "ko"; /** A user keymap (Keymap › Duplicate…): its own overrides on top of a bundled preset. */ export interface CustomKeymapSettings { @@ -46,7 +46,7 @@ export interface CustomKeymapSettings { export interface ValidSettings extends WorkflowSettings { general: { reopenLastProject: boolean; confirmExit: boolean } & GeneralExtras; - appearance: { theme: ThemeId; accent: string; uiFontSize: number; locale: LocaleId } & AppearanceExtras; + appearance: { theme: ThemeId; accent: string; uiFontSize: number; locale: Locale } & AppearanceExtras; editor: { fontFamily: string; fontSize: number; @@ -175,7 +175,6 @@ export const LIMITS = { } as const; const theme = (v: unknown, d: ThemeId): ThemeId => (v === "system" || isThemeChoiceId(v) ? v : d); -const LOCALES: readonly LocaleId[] = ["en", "zh", "ko"]; export const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; type Obj = Record; @@ -224,7 +223,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings { theme: theme(a.theme, d.appearance.theme), accent: typeof a.accent === "string" && HEX_COLOR.test(a.accent) ? a.accent.toLowerCase() : d.appearance.accent, uiFontSize: num(a.uiFontSize, d.appearance.uiFontSize, LIMITS.uiFontSize, true), - locale: oneOf(a.locale, LOCALES, d.appearance.locale), + locale: oneOf(a.locale, SUPPORTED_LOCALES, d.appearance.locale), }, editor: { fontFamily: str(e.fontFamily, d.editor.fontFamily), diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index e0c87049e..b5a6bbd46 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -31,7 +31,7 @@ import { ACCENT_PRESETS, LIMITS, type ThemeId } from "../model/schema"; import type { PageSpec } from "./spec"; import { HTTP_PROXY_PAGE } from "./httpProxy"; import styles from "./Behavior.module.css"; -import { tx, useT } from "@/platform/i18n"; +import { LOCALES, SUPPORTED_LOCALES, tx, useT } from "@/platform/i18n"; import { BUILTIN_THEMES, useContributedIconThemes } from "@/platform/themes"; import { originDetail, themeSelectOptions, useThemeChoices } from "@/workbench/theme"; @@ -717,7 +717,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [ id: "general.language", title: "Language and Region", order: 0, - keywords: ["locale", "chinese", "中文", "korean", "한국어", "translation"], + keywords: ["locale", "translation", ...SUPPORTED_LOCALES.map((locale) => LOCALES[locale].name)], sections: [ { options: [ @@ -726,11 +726,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [ key: "appearance.locale", label: "Language", hint: "Language of menus and dialogs; untranslated text stays in English.", - options: [ - { value: "en", label: "English" }, - { value: "zh", label: "简体中文 (Chinese, Simplified)" }, - { value: "ko", label: "한국어 (Korean)" }, - ], + options: SUPPORTED_LOCALES.map((locale) => ({ value: locale, label: LOCALES[locale].name })), }, ], }, diff --git a/src/features/settings/runtime.ts b/src/features/settings/runtime.ts index 120279d4c..1f8b67f31 100644 --- a/src/features/settings/runtime.ts +++ b/src/features/settings/runtime.ts @@ -8,14 +8,12 @@ * Settings written by any window (e.g. the Settings window) reach every window * through `settingsChanged`, which re-reads the effective settings. */ +import { LOCALES } from "@/platform/i18n"; import { isTauri, onIdeEvent } from "@/platform/ipc"; import { applyKeymapSettings } from "@/platform/keymap"; import { loadSettings, useProject } from "@/platform/project"; import { applyAppearanceOptions, installAltDragGuard, installIdleSave } from "./appearanceRuntime"; -import { type LocaleId, validateSettings } from "./model/schema"; - -/** `` of each UI locale. */ -const HTML_LANG: Record = { en: "en", zh: "zh-CN", ko: "ko" }; +import { validateSettings } from "./model/schema"; export function applyRuntimeSettings(raw: unknown, root: HTMLElement = document.documentElement): void { const s = validateSettings(raw); @@ -23,7 +21,7 @@ export function applyRuntimeSettings(raw: unknown, root: HTMLElement = document. applyAppearanceOptions(s, root); - root.lang = HTML_LANG[s.appearance.locale]; + root.lang = LOCALES[s.appearance.locale].tag; } /** Subscribes to settings changes; returns the unsubscribe function. */ diff --git a/src/features/tools/ServerSettings.module.css b/src/features/tools/ServerSettings.module.css index 75c563cee..be7614699 100644 --- a/src/features/tools/ServerSettings.module.css +++ b/src/features/tools/ServerSettings.module.css @@ -55,6 +55,9 @@ .label::after { content: ":"; } +:lang(fr) .label::after { + content: "\00a0:"; +} .control { display: flex; align-items: flex-start; diff --git a/src/platform/i18n.test.ts b/src/platform/i18n.test.ts index 1b8ce1f5a..09525c093 100644 --- a/src/platform/i18n.test.ts +++ b/src/platform/i18n.test.ts @@ -1,3 +1,6 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { _resetI18nForTests, @@ -7,16 +10,13 @@ import { registerMessages, resolveLocale, setLocale, + SUPPORTED_LOCALES, t, tx, txTemplate, useI18n, } from "./i18n"; import en from "./locales/en.json"; -import ko from "./locales/ko.json"; -import koText from "./locales/text/ko.json"; -import zh from "./locales/zh.json"; -import zhText from "./locales/text/zh.json"; afterEach(() => _resetI18nForTests()); @@ -46,6 +46,15 @@ describe("formatMessage", () => { expect(formatMessage(m, { count: 1 }, "en")).toBe("1 one"); }); + it("uses CLDR rules of the locale (French zero is singular, `many` falls back to `other`)", () => { + const m = "{count, plural, one {# fichier} other {# fichiers}}"; + expect(formatMessage(m, { count: 0 }, "fr")).toBe("0 fichier"); + expect(formatMessage(m, { count: 2 }, "fr")).toBe("2 fichiers"); + expect(formatMessage(m, { count: 1_000_000 }, "fr")).toBe( + `${new Intl.NumberFormat("fr").format(1_000_000)} fichiers`, + ); + }); + it("supports select and nesting", () => { const m = "{os, select, macos {Finder} windows {Explorer} other {Files}}"; expect(formatMessage(m, { os: "macos" })).toBe("Finder"); @@ -74,7 +83,9 @@ describe("t / locales", () => { expect(resolveLocale("zh_Hans")).toBe("zh"); expect(resolveLocale("ko-KR")).toBe("ko"); expect(resolveLocale("EN-us")).toBe("en"); - expect(resolveLocale("fr")).toBe("en"); + expect(resolveLocale("fr")).toBe("fr"); + expect(resolveLocale("fr-CA")).toBe("fr"); + expect(resolveLocale("xx")).toBe("en"); expect(resolveLocale(undefined)).toBe(resolveLocale(navigator.language)); }); @@ -104,7 +115,7 @@ describe("t / locales", () => { }); it("falls back to English for unsupported locales", async () => { - await setLocale("de"); + await setLocale("xx"); expect(useI18n.getState().locale).toBe("en"); }); @@ -164,6 +175,16 @@ describe("t / locales", () => { expect(tx(zh)).toBe("Run 'orders'"); }); + it("loads the French catalogs", async () => { + await setLocale("fr-FR"); + expect(useI18n.getState().locale).toBe("fr"); + expect(t("common.cancel")).toBe("Annuler"); + expect(t("app.keymap.count", { count: 0 })).toBe("0 raccourci"); + expect(tx("Sync with OS")).toBe("Synchroniser avec le système"); + expect(tx`Run '${"orders"}'`).toBe("Exécuter 'orders'"); + expect(txTemplate("ruff is not installed")).toBe("ruff n'est pas installé"); + }); + it("lists keys by prefix", () => { const keys = keysWithPrefix("menu."); expect(keys).toContain("menu.file.save"); @@ -172,28 +193,59 @@ describe("t / locales", () => { }); describe("catalogs", () => { - const catalogs: Record> = { en, zh, ko }; - const textCatalogs: Record> = { zh: zhText, ko: koText }; - const translations = Object.entries(catalogs).filter(([locale]) => locale !== "en"); + type Catalog = Record; + const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales"); + /** Catalog files of a folder as `{ locale: catalog }`, read from disk so that a new file is always seen. */ + const readCatalogs = (dir: string) => + Object.fromEntries( + readdirSync(dir) + .filter((file) => file.endsWith(".json")) + .map((file) => [file.slice(0, -5), JSON.parse(readFileSync(join(dir, file), "utf8")) as Catalog]), + ); - it.each(Object.entries(textCatalogs))("translate source text into non-empty, different text (%s)", (_, texts) => { - for (const [source, text] of Object.entries(texts)) { - expect(text.trim(), source).not.toBe(""); - expect(text, source).not.toBe(source); - } + const { en: _en, ...translations } = readCatalogs(LOCALES_DIR); + const texts = readCatalogs(join(LOCALES_DIR, "text")); + const catalogs: Record = { en, ...translations }; + /** Must have every key; the other locales show English for keys they lack. */ + const COMPLETE = ["zh"]; + /** Written in another script, so a text equal to its English source was not translated. */ + const OWN_SCRIPT = new Set(["zh", "ko"]); + const translated = SUPPORTED_LOCALES.filter((locale) => locale !== "en").sort(); + + it("exist for exactly the supported locales", () => { + expect(translated.length).toBeGreaterThan(0); + expect(Object.keys(translations).sort()).toEqual(translated); + expect(Object.keys(texts).sort()).toEqual(translated); }); - it.each(Object.entries(textCatalogs))("keep the placeholders of source-text templates (%s)", (_, texts) => { + it("translate source text into non-empty text", () => { + for (const [locale, catalog] of Object.entries(texts)) + for (const [source, text] of Object.entries(catalog)) expect(text.trim(), `${locale}:${source}`).not.toBe(""); + }); + + it("translate source text into text different from English, in locales with another script", () => { + for (const [locale, catalog] of Object.entries(texts)) + if (OWN_SCRIPT.has(locale)) + for (const [source, text] of Object.entries(catalog)) expect(text, `${locale}:${source}`).not.toBe(source); + }); + + it("keep the placeholders of source-text templates", () => { const names = (m: string) => [...m.matchAll(/\{(\w+)\}/g)].map((x) => x[1]).sort(); - for (const [source, text] of Object.entries(texts)) expect(names(text), source).toEqual(names(source)); + for (const [locale, catalog] of Object.entries(texts)) + for (const [source, text] of Object.entries(catalog)) + expect(names(text), `${locale}:${source}`).toEqual(names(source)); }); - it("have the same keys (zh)", () => { - expect(Object.keys(zh).sort()).toEqual(Object.keys(en).sort()); + it.each(COMPLETE)("have the same keys as English (%s)", (locale) => { + expect(Object.keys(translations[locale]!).sort()).toEqual(Object.keys(en).sort()); }); - it.each(translations)("have no keys English lacks (%s)", (_, catalog) => { - expect(Object.keys(catalog).filter((key) => !Object.hasOwn(en, key))).toEqual([]); + it("have no keys English lacks", () => { + for (const [locale, catalog] of Object.entries(translations)) + expect( + Object.keys(catalog).filter((key) => !Object.hasOwn(en, key)), + locale, + ).toEqual([]); }); it("contain only well-formed messages", () => { @@ -202,9 +254,10 @@ describe("catalogs", () => { expect(isValidMessage(message), `${locale}:${key}`).toBe(true); }); - it.each(translations)("use the same parameters across locales (%s)", (_, catalog) => { + it("use the same parameters across locales", () => { const params = (m: string) => [...m.matchAll(/\{\s*([a-z]\w*)\s*[,}]/g)].map((x) => x[1]).sort(); - for (const [key, message] of Object.entries(catalog)) - expect(params(message), key).toEqual(params((en as Record)[key] ?? "")); + for (const [locale, catalog] of Object.entries(translations)) + for (const [key, message] of Object.entries(catalog)) + expect(params(message), `${locale}:${key}`).toEqual(params((en as Record)[key] ?? "")); }); }); diff --git a/src/platform/i18n.ts b/src/platform/i18n.ts index a83e549f6..2e4b91a6e 100644 --- a/src/platform/i18n.ts +++ b/src/platform/i18n.ts @@ -33,8 +33,19 @@ export type MessageParams = Record string; export const DEFAULT_LOCALE = "en"; -export const SUPPORTED_LOCALES = ["en", "zh", "ko"] as const; -export type Locale = (typeof SUPPORTED_LOCALES)[number]; +/** + * The UI languages: `name` is written in the language itself, `tag` is its BCP 47 + * tag for ``. Adding one here offers it in the settings. Its catalogs are + * `locales/.json` and `locales/text/.json`; text they lack shows in English. + */ +export const LOCALES = { + en: { name: "English", tag: "en" }, + zh: { name: "简体中文 (Chinese, Simplified)", tag: "zh-CN" }, + ko: { name: "한국어 (Korean)", tag: "ko" }, + fr: { name: "Français (French)", tag: "fr" }, +} as const; +export type Locale = keyof typeof LOCALES; +export const SUPPORTED_LOCALES = Object.keys(LOCALES) as Locale[]; const loaders = import.meta.glob(["./locales/*.json", "!./locales/en.json"], { import: "default" }); const textLoaders = import.meta.glob("./locales/text/*.json", { import: "default" }); @@ -106,7 +117,7 @@ export async function setLocale(input: string | null | undefined): Promise