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 `<html lang>` tag); the Language and Region options, settings
validation and `<html lang>` 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 <[email protected]>
Цей коміт міститься в:
Julien Dudek authored and GitHub committed 2026-11-04 11:48:45 +08:00
1 джерело 7f0c78893e
коміт 59567f1af0
13 files changed
+6409 -56

No files matched your search

+8 -7
Переглянути файл
@@ -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");
});
});
+4 -2
Переглянути файл
@@ -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", () => {
+3
Переглянути файл
@@ -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;
+3
Переглянути файл
@@ -31,6 +31,9 @@
.interpreterLabel::after {
content: ":";
}
:lang(fr) .interpreterLabel::after {
content: "\00a0:";
}
.path {
color: var(--fg-muted);
font-family: var(--font-mono);
+3 -4
Переглянути файл
@@ -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:<id>/<theme>`) 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<string, unknown>;
@@ -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),
+3 -7
Переглянути файл
@@ -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 })),
},
],
},
+3 -5
Переглянути файл
@@ -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";
/** `<html lang>` of each UI locale. */
const HTML_LANG: Record<LocaleId, string> = { 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. */
+3
Переглянути файл
@@ -55,6 +55,9 @@
.label::after {
content: ":";
}
:lang(fr) .label::after {
content: "\00a0:";
}
.control {
display: flex;
align-items: flex-start;
+76 -23
Переглянути файл
@@ -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<string, Record<string, string>> = { en, zh, ko };
const textCatalogs: Record<string, Record<string, string>> = { zh: zhText, ko: koText };
const translations = Object.entries(catalogs).filter(([locale]) => locale !== "en");
type Catalog = Record<string, string>;
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<string, Catalog> = { 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<string, string>)[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<string, string>)[key] ?? ""));
});
});
+14 -3
Переглянути файл
@@ -33,8 +33,19 @@ export type MessageParams = Record<string, string | number | boolean | null | un
export type Translate = (key: string, params?: MessageParams) => 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 `<html lang>`. Adding one here offers it in the settings. Its catalogs are
* `locales/<locale>.json` and `locales/text/<locale>.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<Catalog>(["./locales/*.json", "!./locales/en.json"], { import: "default" });
const textLoaders = import.meta.glob<Catalog>("./locales/text/*.json", { import: "default" });
@@ -106,7 +117,7 @@ export async function setLocale(input: string | null | undefined): Promise<Local
}
if (token === switchToken) {
useI18n.setState({ locale, messages: merged(locale), texts: textCatalogs.get(locale) ?? {} });
if (typeof document !== "undefined") document.documentElement.lang = locale;
if (typeof document !== "undefined") document.documentElement.lang = LOCALES[locale].tag;
}
return locale;
}
+450
Переглянути файл
@@ -0,0 +1,450 @@
{
"app.about.build": "Build",
"app.about.configDir": "Configuration",
"app.about.copyInfo": "Copier les informations",
"app.about.copyright": "Copyright © 2026 les contributeurs d'OpenIDEA",
"app.about.disclaimer": "Non affilié à JetBrains.",
"app.about.license": "Distribué sous licence Apache 2.0",
"app.about.platform": "Plateforme",
"app.about.runtime": "Environnement d'exécution",
"app.about.showConfig": "Ouvrir le dossier de configuration",
"app.about.tagline": "Un IDE open source inspiré d'IntelliJ IDEA",
"app.about.thirdParty": "Inclut des icônes de la plateforme IntelliJ (Apache-2.0).",
"app.about.title": "À propos d'OpenIDEA",
"app.about.version": "Version",
"app.action.about": "À propos d'OpenIDEA",
"app.action.checkForUpdates": "Rechercher des mises à jour…",
"app.action.copy": "Copier",
"app.action.cut": "Couper",
"app.action.keymapReference": "Référence des raccourcis clavier",
"app.action.lightEditOpenProject": "Ouvrir le dossier parent comme projet",
"app.action.newWindow": "Nouvelle fenêtre",
"app.action.nextWindow": "Fenêtre de projet suivante",
"app.action.openFile": "Ouvrir un fichier…",
"app.action.openInNewWindow": "Ouvrir dans une nouvelle fenêtre…",
"app.action.openRecentProject": "Ouvrir un projet récent",
"app.action.paste": "Coller",
"app.action.previousWindow": "Fenêtre de projet précédente",
"app.action.quit": "{os, select, macos {Quitter OpenIDEA} other {Quitter}}",
"app.action.redo": "Rétablir",
"app.action.selectAll": "Tout sélectionner",
"app.action.showLog": "{os, select, macos {Afficher le journal dans le Finder} windows {Afficher le journal dans l'Explorateur} other {Afficher le journal dans Fichiers}}",
"app.action.toggleFullScreen": "Activer/désactiver le plein écran",
"app.action.undo": "Annuler",
"app.category.edit": "Édition",
"app.category.file": "Fichier",
"app.category.help": "Aide",
"app.category.view": "Affichage",
"app.category.window": "Fenêtre",
"app.exit.body": "Voulez-vous vraiment quitter OpenIDEA ?",
"app.exit.confirm": "Quitter",
"app.exit.dontAsk": "Ne plus demander",
"app.exit.title": "Confirmer la fermeture",
"app.keymap.count": "{count, plural, one {# raccourci} other {# raccourcis}}",
"app.keymap.empty": "Aucun raccourci correspondant",
"app.keymap.filter": "Filtrer par action ou raccourci",
"app.keymap.other": "Autres",
"app.keymap.preset": "Raccourcis clavier : {preset}",
"app.keymap.title": "Référence des raccourcis clavier",
"app.lightEdit.openProject": "Ouvrir comme projet",
"app.lightEdit.openProjectTooltip": "Ouvrir {folder} comme projet",
"app.log.failed": "Impossible d'afficher le journal",
"app.title.default": "OpenIDEA",
"app.unsaved.body": "{count, plural, one {# fichier contient} other {# fichiers contiennent}} des modifications non enregistrées dans {project}. Enregistrer avant de fermer ?",
"app.unsaved.discard": "Ne pas enregistrer",
"app.unsaved.save": "Tout enregistrer",
"app.unsaved.saveFailed": "Impossible d'enregistrer tous les fichiers",
"app.unsaved.title": "Modifications non enregistrées",
"app.update.available": "OpenIDEA {version} est disponible",
"app.update.cancel": "Annuler",
"app.update.cancelled": "Mise à jour annulée",
"app.update.checking": "Recherche de mises à jour…",
"app.update.disabled": "Les mises à jour automatiques ne sont pas configurées",
"app.update.disabledBody": "Définissez un point de terminaison de mise à jour (OPENIDEA_UPDATE_ENDPOINT ou plugins.updater.endpoints) pour activer la recherche de mises à jour.",
"app.update.downloading": "Téléchargement d'OpenIDEA {version}",
"app.update.failed": "Échec de la recherche de mises à jour",
"app.update.install": "Installer et redémarrer",
"app.update.installFailed": "Impossible d'installer la mise à jour",
"app.update.installed": "OpenIDEA {version} est installé",
"app.update.installedBody": "Il sera utilisé après le redémarrage d'OpenIDEA.",
"app.update.installingMessage": "Installation…",
"app.update.installUncancellable": "La mise à jour est en cours d'installation ; cette étape ne peut pas être annulée",
"app.update.none": "OpenIDEA {version} est à jour",
"app.update.progress": "{done} Mo sur {total} Mo",
"app.update.progressUnknown": "{done} Mo",
"app.update.restart": "Redémarrer",
"app.update.retry": "Réessayer",
"app.window.desktopOnly": "Les fenêtres multiples nécessitent l'application de bureau",
"app.window.openFailed": "Impossible d'ouvrir {name}",
"app.window.pickFileTitle": "Ouvrir un fichier",
"app.window.pickTitle": "Ouvrir dans une nouvelle fenêtre",
"app.window.askTitle": "Ouvrir un projet",
"app.window.askBody": "Les projets peuvent s'ouvrir dans une nouvelle fenêtre, remplacer le projet actuel ou être rattachés aux projets déjà ouverts.\nComment souhaitez-vous ouvrir « {name} » ?",
"app.window.askThis": "Cette fenêtre",
"app.window.askAttach": "Rattacher",
"app.window.askNew": "Nouvelle fenêtre",
"app.window.askRemember": "Ne plus demander",
"common.cancel": "Annuler",
"common.close": "Fermer",
"common.copied": "Copié",
"common.copy": "Copier",
"menu.app": "OpenIDEA",
"menu.app.about": "À propos d'OpenIDEA",
"menu.app.checkForUpdates": "Rechercher des mises à jour…",
"menu.app.quit": "{os, select, macos {Quitter OpenIDEA} other {Quitter}}",
"menu.appearance": "Apparence",
"menu.code": "Code",
"menu.code.commentBlock": "Commenter avec un commentaire de bloc",
"menu.code.commentLine": "Commenter avec un commentaire de ligne",
"menu.code.completion": "Basique",
"menu.code.deleteLine": "Supprimer la ligne",
"menu.code.duplicateLine": "Dupliquer la ligne ou la sélection",
"menu.code.extendSelection": "Étendre la sélection",
"menu.code.foldAll": "Tout réduire",
"menu.code.foldToggle": "Développer/réduire",
"menu.code.generate": "Générer…",
"menu.code.pasteAsPlainText": "Coller en texte brut",
"menu.editor.copyReference": "Copier la référence",
"menu.code.cyclicExpandWord": "Compléter le mot par cycle",
"menu.code.cyclicExpandWordBackward": "Compléter le mot par cycle (arrière)",
"menu.code.expressionTypeInfo": "Type de l'expression",
"menu.code.overrideMethods": "Redéfinir des méthodes…",
"menu.code.implementMethods": "Implémenter des méthodes…",
"menu.code.insertLiveTemplate": "Insérer un modèle dynamique…",
"menu.code.moveLineDown": "Déplacer la ligne vers le bas",
"menu.code.moveLineUp": "Déplacer la ligne vers le haut",
"menu.code.optimizeImports": "Optimiser les imports",
"menu.code.parameterInfo": "Informations sur les paramètres",
"menu.code.quickDocumentation": "Documentation rapide",
"menu.code.reformat": "Reformater le code",
"menu.code.showIntentions": "Afficher les actions contextuelles",
"menu.code.shrinkSelection": "Réduire la sélection",
"menu.code.surroundWith": "Entourer avec…",
"menu.code.unwrap": "Déballer/supprimer…",
"menu.code.unfoldAll": "Tout développer",
"menu.debug.evaluate": "Évaluer l'expression…",
"menu.debug.pause": "Mettre le programme en pause",
"menu.debug.resume": "Reprendre le programme",
"menu.debug.runToCursor": "Exécuter jusqu'au curseur",
"menu.debug.stepInto": "Pas à pas détaillé",
"menu.debug.stepOut": "Pas à pas sortant",
"menu.debug.stepOver": "Pas à pas principal",
"menu.debug.toggleBreakpoint": "Activer/désactiver le point d'arrêt de ligne",
"menu.debug.viewBreakpoints": "Afficher les points d'arrêt…",
"menu.debug.attachToProcess": "Attacher à un processus…",
"menu.debug.forceStepOver": "Forcer le pas à pas principal",
"menu.debug.forceStepInto": "Forcer le pas à pas détaillé",
"menu.debug.smartStepInto": "Pas à pas détaillé intelligent",
"menu.debug.forceRunToCursor": "Forcer l'exécution jusqu'au curseur",
"menu.debug.showExecutionPoint": "Afficher le point d'exécution",
"menu.debug.resetFrame": "Réinitialiser le cadre de pile",
"menu.debug.toggleTemporaryBreakpoint": "Activer/désactiver le point d'arrêt de ligne temporaire",
"menu.debug.toggleMethodBreakpoint": "Activer/désactiver le point d'arrêt de méthode",
"menu.debug.toggleFieldWatchpoint": "Activer/désactiver le point de surveillance de champ",
"menu.edit": "Édition",
"menu.edit.copy": "Copier",
"menu.edit.cut": "Couper",
"menu.edit.paste": "Coller",
"menu.code.pasteFromHistory": "Coller depuis l'historique…",
"menu.edit.redo": "Rétablir",
"menu.edit.selectAll": "Tout sélectionner",
"menu.edit.undo": "Annuler",
"menu.editor.copyPath": "Copier le chemin",
"menu.code.sortLines": "Trier les lignes",
"menu.code.reverseLines": "Inverser les lignes",
"menu.editor.toggleColumnSelection": "Mode de sélection en colonne",
"menu.editor.splitDown": "Scinder vers le bas",
"menu.editor.splitRight": "Scinder vers la droite",
"menu.editor.toggleZen": "{distractionFree, select, on {Quitter le mode sans distraction} other {Passer en mode sans distraction}}",
"menu.editor.unsplit": "Annuler la scission",
"menu.file": "Fichier",
"menu.fileProperties": "Propriétés du fichier",
"menu.newProjectsSetup": "Configuration des nouveaux projets",
"menu.file.newProjectsSettings": "Paramètres des nouveaux projets…",
"menu.file.toggleReadOnly": "Rendre le fichier en lecture seule / modifiable",
"menu.file.closeProject": "Fermer le projet",
"menu.remote.reloadEnvironment": "Recharger l'environnement",
"menu.file.new": "Nouveau…",
"menu.file.newScratchFile": "Nouveau fichier brouillon…",
"menu.file.openInNewWindow": "Ouvrir dans une nouvelle fenêtre…",
"menu.file.openProject": "Ouvrir…",
"menu.file.save": "Tout enregistrer",
"menu.file.settings": "Paramètres…",
"menu.find": "Rechercher",
"menu.find.inFile": "Rechercher…",
"menu.find.inPath": "Rechercher dans les fichiers…",
"menu.find.replaceInFile": "Remplacer…",
"menu.find.replaceInPath": "Remplacer dans les fichiers…",
"menu.find.showUsages": "Afficher les utilisations",
"menu.find.usages": "Rechercher les utilisations",
"menu.completion": "Complétion de code",
"menu.folding": "Pliage",
"menu.code.foldSelection": "Plier la sélection / supprimer la région",
"menu.git": "Git",
"menu.help": "Aide",
"menu.mainMenu": "Menu principal",
"window.caption.minimize": "Réduire",
"window.caption.maximize": "Agrandir",
"window.caption.restore": "Restaurer",
"window.caption.close": "Fermer",
"menu.help.keymapReference": "Référence des raccourcis clavier",
"menu.help.showLog": "{os, select, macos {Afficher le journal dans le Finder} windows {Afficher le journal dans l'Explorateur} other {Afficher le journal dans Fichiers}}",
"menu.native.bringAllToFront": "Tout ramener au premier plan",
"menu.native.copy": "Copier",
"menu.native.cut": "Couper",
"menu.native.hide": "Masquer OpenIDEA",
"menu.native.hideOthers": "Masquer les autres",
"menu.native.minimize": "Réduire",
"menu.native.paste": "Coller",
"menu.native.redo": "Rétablir",
"menu.native.selectAll": "Tout sélectionner",
"menu.native.services": "Services",
"menu.native.showAll": "Tout afficher",
"menu.native.undo": "Annuler",
"menu.native.zoom": "Zoom",
"menu.navigate": "Naviguer",
"menu.navigate.back": "Précédent",
"menu.navigate.lastEditLocation": "Dernier emplacement modifié",
"menu.navigate.nextEditLocation": "Emplacement modifié suivant",
"menu.navigate.callHierarchy": "Hiérarchie des appels",
"menu.navigate.declaration": "Déclaration ou utilisations",
"menu.navigate.fileStructure": "Structure du fichier",
"menu.navigate.findAction": "Rechercher une action…",
"menu.navigate.forward": "Suivant",
"menu.navigate.gotoClass": "Classe…",
"menu.navigate.gotoFile": "Fichier…",
"menu.navigate.gotoLine": "Ligne/colonne…",
"menu.navigate.gotoSymbol": "Symbole…",
"menu.navigate.implementation": "Implémentation(s)",
"menu.navigate.nextError": "Erreur signalée suivante",
"menu.navigate.prevError": "Erreur signalée précédente",
"menu.navigate.recentFiles": "Fichiers récents",
"menu.navigate.recentLocations": "Emplacements récents",
"menu.navigate.searchEverywhere": "Rechercher partout",
"menu.navigate.selectIn": "Sélectionner dans…",
"menu.navigate.showBookmarks": "Afficher les signets",
"menu.navigate.toggleBookmark": "Ajouter/retirer un signet",
"menu.navigate.typeDeclaration": "Déclaration du type",
"menu.navigate.typeHierarchy": "Hiérarchie des types",
"menu.navigate.superMethod": "Méthode parente",
"menu.navigate.gotoTest": "Test",
"menu.navigate.nextMethod": "Méthode suivante",
"menu.navigate.previousMethod": "Méthode précédente",
"menu.openRecent": "Ouvrir un élément récent",
"menu.recent.none": "Aucun projet récent",
"menu.refactor": "Refactoriser",
"menu.refactor.changeSignature": "Modifier la signature…",
"menu.refactor.extractConstant": "Introduire une constante…",
"menu.refactor.extractField": "Introduire un champ…",
"menu.refactor.extractMethod": "Extraire une méthode…",
"menu.refactor.extractParameter": "Introduire un paramètre…",
"menu.refactor.extractVariable": "Introduire une variable…",
"menu.refactor.inline": "Intégrer…",
"menu.refactor.move": "Déplacer…",
"menu.refactor.rename": "Renommer…",
"menu.refactor.safeDelete": "Suppression sûre…",
"menu.refactor.thisMenu": "Refactoriser ceci…",
"menu.run": "Exécuter",
"menu.run.chooseConfig": "Exécuter…",
"menu.run.debug": "Déboguer",
"menu.run.debugContext": "Déboguer la configuration du contexte",
"menu.run.coverage": "Exécuter avec couverture",
"menu.run.profile": "Profiler",
"menu.coverage.hide": "Masquer la couverture",
"menu.debuggingActions": "Actions de débogage",
"menu.debug.reloadChangedClasses": "Recharger les classes modifiées",
"menu.toolwindow.coverage": "Couverture",
"menu.toolwindow.profiler": "Profileur",
"menu.run.editConfigs": "Modifier les configurations…",
"menu.run.run": "Exécuter",
"menu.run.runContext": "Exécuter la configuration du contexte",
"menu.run.stop": "Arrêter",
"menu.toolWindows": "Fenêtres d'outils",
"menu.toolwindow.bookmarks": "Signets",
"menu.toolwindow.commit": "Commit",
"menu.toolwindow.debug": "Débogage",
"menu.toolwindow.find": "Recherche",
"menu.toolwindow.git": "Git",
"menu.toolwindow.hideAll": "Masquer toutes les fenêtres d'outils",
"menu.toolwindow.problems": "Problèmes",
"menu.toolwindow.project": "Projet",
"menu.toolwindow.run": "Exécution",
"menu.toolwindow.structure": "Structure",
"menu.toolwindow.terminal": "Terminal",
"terminal.close.title": "Le processus '{name}' est en cours d'exécution",
"terminal.close.message": "Voulez-vous mettre fin au processus '{name}' ?",
"terminal.close.terminate": "Terminer",
"menu.vcs.annotate": "Annoter avec Git Blame",
"menu.vcs.branches": "Branches…",
"menu.vcs.commit": "Commit…",
"menu.vcs.fetch": "Fetch",
"menu.vcs.localHistory": "Historique local",
"menu.vcs.newBranch": "Nouvelle branche…",
"menu.vcs.push": "Push…",
"menu.vcs.rollback": "Annuler les modifications…",
"menu.vcs.showDiff": "Afficher le diff",
"menu.vcs.showLog": "Afficher le journal Git",
"menu.vcs.stash": "Remiser les modifications…",
"menu.vcs.unstash": "Restaurer des modifications remisées…",
"menu.vcs.update": "Mettre à jour le projet…",
"menu.vcs.pull": "Pull…",
"menu.vcs.merge": "Fusionner…",
"menu.vcs.rebase": "Rebase…",
"menu.vcs.newWorktree": "Nouveau worktree…",
"menu.vcs.worktrees": "Worktrees…",
"menu.vcs.manageRemotes": "Gérer les dépôts distants…",
"menu.vcs.clone": "Cloner…",
"menu.vcs.operations": "Menu des opérations VCS…",
"menu.view": "Affichage",
"menu.view.notifications": "Notifications",
"menu.view.toggleTheme": "Basculer entre thème clair et sombre",
"menu.welcome.recentProjects": "Gérer les projets…",
"menu.window": "Fenêtre",
"menu.tools": "Outils",
"menu.http.newRequest": "Nouvelle requête HTTP",
"menu.http.showToolWindow": "Client HTTP",
"menu.db.showToolWindow": "Base de données",
"menu.db.newDataSource": "Nouvelle source de données…",
"menu.db.openConsole": "Ouvrir la console de requêtes",
"menu.vim.toggle": "Vim",
"menu.window.new": "Nouvelle fenêtre",
"menu.window.next": "Fenêtre de projet suivante",
"menu.window.previous": "Fenêtre de projet précédente",
"menu.window.toggleFullScreen": "Activer/désactiver le plein écran",
"os.linux": "Linux",
"os.macos": "macOS",
"os.web": "Navigateur",
"os.windows": "Windows",
"menu.gitPatch": "Patch",
"menu.vcs.newTag": "Nouveau tag…",
"menu.vcs.resetHead": "Réinitialiser HEAD…",
"menu.vcs.shelve": "Mettre les modifications de côté…",
"menu.vcs.showShelf": "Afficher les modifications de côté",
"menu.vcs.createPatch": "Créer un patch…",
"menu.vcs.applyPatch": "Appliquer un patch…",
"menu.vcs.applyPatchFromClipboard": "Appliquer un patch depuis le presse-papiers…",
"menu.vcs.resolveConflicts": "Résoudre les conflits…",
"menu.vcs.rebase.continue": "Continuer le rebase",
"menu.vcs.rebase.abort": "Abandonner le rebase",
"menu.vcs.rebase.skip": "Ignorer le commit",
"menu.vcs.merge.abort": "Abandonner la fusion",
"menu.vcs.cherryPick.continue": "Continuer le cherry-pick",
"menu.vcs.cherryPick.abort": "Abandonner le cherry-pick",
"menu.vcs.revert.continue": "Continuer le revert",
"menu.vcs.revert.abort": "Abandonner le revert",
"menu.gitUncommittedChanges": "Modifications non validées",
"menu.gitCurrentFile": "Fichier actuel",
"menu.vcs.addToVcs": "Ajouter au VCS",
"menu.vcs.compareWithBranch": "Comparer avec une branche ou une révision…",
"menu.vcs.showHistory": "Afficher l'historique",
"menu.build": "Build",
"menu.build.buildProject": "Compiler le projet",
"menu.build.buildModule": "Compiler le module",
"menu.build.rebuildProject": "Recompiler le projet",
"menu.build.buildArtifacts": "Compiler les artefacts",
"menu.toolwindow.build": "Build",
"menu.find.structural": "Recherche structurelle…",
"menu.find.replaceStructural": "Remplacement structurel…",
"menu.analyze.inspectCode": "Inspecter le code…",
"menu.analyzeCode": "Analyser le code",
"menu.analyze.locateDuplicates": "Rechercher les doublons…",
"menu.analyze.stackTrace": "Analyser une trace de pile ou un dump de threads…",
"menu.toolwindow.services": "Services",
"menu.gitHub": "GitHub",
"menu.github.pullRequests": "Voir les pull requests",
"menu.github.createPullRequest": "Créer une pull request…",
"menu.github.checkoutPullRequest": "Extraire une pull request…",
"menu.github.openInBrowser": "Ouvrir sur GitHub",
"menu.file.newProject": "Nouveau projet…",
"menu.file.renameProject": "Renommer le projet…",
"menu.file.reloadAllFromDisk": "Tout recharger depuis le disque",
"menu.file.invalidateCaches": "Invalider les caches…",
"menu.manageIdeSettings": "Gérer les paramètres de l'IDE",
"menu.file.exportSettings": "Exporter les paramètres…",
"menu.file.importSettings": "Importer les paramètres…",
"menu.file.togglePowerSave": "Mode économie d'énergie",
"menu.macros": "Macros",
"menu.macro.startRecording": "Démarrer l'enregistrement de la macro",
"menu.macro.stopRecording": "Arrêter l'enregistrement de la macro",
"menu.macro.playBack": "Rejouer la dernière macro",
"menu.macro.playSaved": "Rejouer des macros enregistrées…",
"menu.macro.edit": "Modifier les macros…",
"menu.bookmarks": "Signets",
"menu.navigate.toggleBookmarkWithMnemonic": "Ajouter/retirer un signet avec mnémonique",
"menu.navigate.nextBookmark": "Signet suivant",
"menu.navigate.prevBookmark": "Signet précédent",
"menu.view.toggleCompactMode": "Mode compact",
"menu.view.zoomIn": "Zoom avant de l'IDE",
"menu.view.zoomOut": "Zoom arrière de l'IDE",
"menu.view.resetZoom": "Réinitialiser le zoom de l'IDE",
"menu.toolbar.customize": "Personnaliser la barre d'outils principale…",
"menu.code.quickDefinition": "Définition rapide",
"menu.code.quickTypeDefinition": "Définition rapide du type",
"menu.localHistory.recentChanges": "Modifications récentes",
"menu.diff.compareWithClipboard": "Comparer avec le presse-papiers",
"menu.view.quickSwitchScheme": "Changement rapide de schéma…",
"menu.editor.resetFontSize": "Réinitialiser la taille de police dans tous les éditeurs",
"menu.code.cleanup": "Nettoyage du code…",
"menu.runAnything.show": "Exécuter n'importe quoi…",
"menu.localHistory.putLabel": "Poser une étiquette…",
"menu.toolWindowLayouts": "Dispositions des fenêtres d'outils",
"menu.layout.save": "Enregistrer la disposition actuelle sous…",
"menu.layout.restoreDefault": "Restaurer la disposition par défaut",
"menu.layout.rename": "Renommer la disposition…",
"menu.layout.delete": "Supprimer la disposition…",
"menu.activeToolWindow": "Fenêtre d'outil active",
"menu.toolwindow.hideActive": "Masquer la fenêtre d'outil active",
"menu.toolwindow.maximize": "Agrandir la fenêtre d'outil",
"menu.toolwindow.closeContent": "Fermer l'onglet actif",
"menu.toolwindow.jumpToLast": "Aller à la dernière fenêtre d'outil",
"menu.toolwindow.focusEditor": "Activer l'éditeur",
"menu.editorTabs": "Onglets de l'éditeur",
"menu.editor.nextTab": "Sélectionner l'onglet suivant",
"menu.editor.prevTab": "Sélectionner l'onglet précédent",
"menu.editor.pinTab": "Épingler l'onglet",
"menu.editor.closeTab": "Fermer l'onglet",
"menu.editor.closeOtherTabs": "Fermer les autres onglets",
"menu.editor.closeAllTabs": "Fermer tous les onglets",
"menu.editor.reopenClosedTab": "Rouvrir l'onglet fermé",
"menu.editor.moveToOppositeGroup": "Déplacer vers le groupe opposé",
"menu.editor.openInOppositeGroup": "Ouvrir dans le groupe opposé",
"menu.editor.changeSplitOrientation": "Changer l'orientation de la scission",
"menu.editor.unsplitAll": "Annuler toutes les scissions",
"menu.navigate.showFilePath": "Chemin du fichier",
"menu.navigate.customRegion": "Pliage personnalisé…",
"menu.code.showErrorDescription": "Description de l'erreur",
"menu.code.surroundWithLiveTemplate": "Entourer avec un modèle dynamique…",
"menu.code.autoIndentLines": "Indenter automatiquement les lignes",
"menu.code.moveStatementUp": "Déplacer l'instruction vers le haut",
"menu.code.moveStatementDown": "Déplacer l'instruction vers le bas",
"menu.code.moveElementLeft": "Déplacer l'élément vers la gauche",
"menu.code.moveElementRight": "Déplacer l'élément vers la droite",
"menu.code.collapseBlock": "Plier le bloc de code",
"menu.analyze.inspectFile": "Inspecter le fichier actuel",
"menu.project.copyElement": "Copier…",
"menu.run.chooseDebugConfig": "Déboguer…",
"menu.debug.quickEvaluate": "Évaluation rapide de l'expression",
"menu.resizeToolWindow": "Redimensionner",
"menu.toolwindow.resizeLeft": "Étirer vers la gauche",
"menu.toolwindow.resizeRight": "Étirer vers la droite",
"menu.toolwindow.resizeUp": "Étirer vers le haut",
"menu.toolwindow.resizeDown": "Étirer vers le bas",
"menu.editor.nextSplitter": "Aller à la scission suivante",
"menu.editor.prevSplitter": "Aller à la scission précédente",
"menu.expandToLevel": "Développer jusqu'au niveau",
"menu.expandAllToLevel": "Tout développer jusqu'au niveau",
"menu.code.expandToLevel1": "1",
"menu.code.expandAllToLevel1": "1",
"menu.code.expandToLevel2": "2",
"menu.code.expandAllToLevel2": "2",
"menu.code.expandToLevel3": "3",
"menu.code.expandAllToLevel3": "3",
"menu.code.expandToLevel4": "4",
"menu.code.expandAllToLevel4": "4",
"menu.code.expandToLevel5": "5",
"menu.code.expandAllToLevel5": "5",
"menu.editor.contextInfo": "Informations de contexte",
"run.services.configurationCount": "{count} configurations",
"search.bookmarks.count": "{count, plural, one {# signet} other {# signets}}",
"settings.zoom.hint": "Modifier avec {zoomIn} ou {zoomOut}. Revenir à 100 % avec {reset}"
}
File diff suppressed because it is too large. Load diff
+42 -5
Переглянути файл
@@ -26,11 +26,16 @@
* Not seen: text kept in a variable or built by a helper before it reaches `tx`,
* and text sent by the backend. Their entries count as used (not stale) as long as
* the text appears as a literal anywhere in `src/`, the Rust crates or `languages/`.
*
* Message keys are checked too: a literal key given to `t`, or to a function bound
* from `useT()` in the same file, must exist in `en.json`; otherwise the UI shows the
* raw key. Keys built at runtime or passed through a helper are not seen.
*/
import { readFileSync, readdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import en from "./en.json";
const ROOT = fileURLToPath(new URL("../../../", import.meta.url));
const TEXT_DIR = new URL("./text/", import.meta.url);
@@ -177,10 +182,6 @@ const UNTRANSLATED = new Set<string>([
"IDE",
"mod tests",
"Windows (\\r\\n)",
// Language names, written in their own language
"English",
"简体中文 (Chinese, Simplified)",
"한국어 (Korean)",
// Terms, keywords and acronyms kept in English
"DDL",
"GOPATH",
@@ -439,12 +440,27 @@ interface Scan {
untranslatable: Use[];
/** Translated templates that splice English words into the sentence. */
spliced: Use[];
/** Literal message keys given to `t`. */
keys: Use[];
}
/** Local names of `t` and of the functions bound from `useT()` in a file. */
function messageFunctions(source: string): Set<string> {
const names = new Set<string>();
for (const m of source.matchAll(/import\s*\{([^}]*)\}\s*from\s*"(?:@\/platform|\.)\/i18n"/g))
for (const part of m[1]!.split(",")) {
const named = /^\s*t(?:\s+as\s+(\w+))?\s*$/.exec(part);
if (named) names.add(named[1] ?? "t");
}
for (const m of source.matchAll(/(?:const|let)\s+(\w+)\s*=\s*useT\(\)/g)) names.add(m[1]!);
return names;
}
function scanTypeScript(): Scan {
const translated: Use[] = [];
const untranslatable: Use[] = [];
const spliced: Use[] = [];
const keys: Use[] = [];
const literals = new Set<string>();
const files = walk(`${ROOT}src`, (p) => /\.tsx?$/.test(p) && !isTest(p) && !p.endsWith("/bindings.ts"));
for (const file of files) {
@@ -462,8 +478,14 @@ function scanTypeScript(): Scan {
for (const m of source.matchAll(/(?:const|let)\s+(\w+)\s*=\s*useTx(Template)?\(\)/g))
(m[2] ? template : exact).add(m[1]!);
const checked = !UNCHECKED_FILES.has(path);
const messages = messageFunctions(source);
const where = (node: ts.Node) => `${path}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}`;
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && messages.has(node.expression.text)) {
const arg = node.arguments[0] && unwrap(node.arguments[0]);
const literal = arg && ts.isStringLiteralLike(arg) ? [arg.text] : arg && stringBranches(arg);
for (const text of literal ?? []) keys.push({ text, at: where(node), match: "exact" });
}
if (checked && ts.isTaggedTemplateExpression(node) && ts.isIdentifier(node.tag) && exact.has(node.tag.text)) {
// Without `${}` the tag looks its text up as written.
const match = ts.isNoSubstitutionTemplateLiteral(node.template) ? "exact" : "shape";
@@ -501,7 +523,7 @@ function scanTypeScript(): Scan {
};
visit(sf);
}
return { translated, literals, untranslatable, spliced };
return { translated, literals, untranslatable, spliced, keys };
}
/** Read strings without letting a Rust character literal open a string match. */
@@ -570,6 +592,21 @@ it("translates built text as whole sentences, one per variant", () => {
expect(report(scan.spliced)).toEqual([]);
});
it("reads the imported and bound names of the message function", () => {
expect(messageFunctions(`import { t, tx } from "@/platform/i18n";`)).toEqual(new Set(["t"]));
expect(messageFunctions(`import { t as translate, useT } from "./i18n"; const tr = useT();`)).toEqual(
new Set(["translate", "tr"]),
);
// `t` bound from `useTx()` takes English text, not a key.
expect(messageFunctions(`import { useTx } from "@/platform/i18n"; const t = useTx();`)).toEqual(new Set());
});
it("uses only message keys of the English catalog", () => {
// Ensure the check cannot pass because the scanner stopped finding keys.
expect(scan.keys.some((use) => use.text === "app.unsaved.title")).toBe(true);
expect(report(scan.keys.filter(({ text }) => !Object.hasOwn(en, text)))).toEqual([]);
});
describe.each(Object.keys(catalogs))("source-text catalog %s", (locale) => {
const catalog = catalogs[locale]!;
const patterns = templatePatterns(catalog);