@mdbase-dev/i18n 0.0.0-stage → 0.1.0-beta.148
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +299 -2
- package/dist/codemirror/de.d.ts +3 -0
- package/dist/codemirror/de.js +33 -0
- package/dist/codemirror/en.d.ts +3 -0
- package/dist/codemirror/en.js +33 -0
- package/dist/codemirror/es.d.ts +3 -0
- package/dist/codemirror/es.js +33 -0
- package/dist/codemirror/fr.d.ts +3 -0
- package/dist/codemirror/fr.js +33 -0
- package/dist/codemirror/it.d.ts +3 -0
- package/dist/codemirror/it.js +33 -0
- package/dist/codemirror/ja.d.ts +3 -0
- package/dist/codemirror/ja.js +33 -0
- package/dist/codemirror/ko.d.ts +3 -0
- package/dist/codemirror/ko.js +33 -0
- package/dist/codemirror/pt.d.ts +3 -0
- package/dist/codemirror/pt.js +33 -0
- package/dist/codemirror/ru.d.ts +3 -0
- package/dist/codemirror/ru.js +33 -0
- package/dist/codemirror/zh.d.ts +3 -0
- package/dist/codemirror/zh.js +33 -0
- package/dist/codemirror.d.ts +8 -0
- package/dist/codemirror.js +17 -0
- package/dist/react.d.ts +13 -0
- package/dist/react.js +28 -0
- package/dist/runtime.d.ts +42 -0
- package/dist/runtime.js +112 -0
- package/package.json +75 -3
- package/scripts/bin.mjs +7 -0
- package/scripts/catalogues.d.mts +2 -0
- package/scripts/catalogues.mjs +25 -0
- package/scripts/checks.mjs +114 -0
- package/scripts/cli.mjs +108 -0
- package/scripts/config.d.mts +6 -0
- package/scripts/config.mjs +13 -0
- package/scripts/source.mjs +297 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Fixed CodeMirror lookup keys; preserve literal $ substitution tokens. */
|
|
2
|
+
const phrases = {
|
|
3
|
+
"Control character": "Управляющий символ",
|
|
4
|
+
"Selection deleted": "Выделение удалено",
|
|
5
|
+
"Completions": "Варианты дополнения",
|
|
6
|
+
"Folded lines": "Свёрнутые строки",
|
|
7
|
+
"Unfolded lines": "Развёрнутые строки",
|
|
8
|
+
"Fold line": "Свернуть строку",
|
|
9
|
+
"Unfold line": "Развернуть строку",
|
|
10
|
+
"folded code": "Свёрнутый код",
|
|
11
|
+
"unfold": "Развернуть",
|
|
12
|
+
"to": "–",
|
|
13
|
+
"Go to line": "Перейти к строке",
|
|
14
|
+
"go": "Перейти",
|
|
15
|
+
"Find": "Найти",
|
|
16
|
+
"Replace": "Заменить",
|
|
17
|
+
"next": "Далее",
|
|
18
|
+
"previous": "Назад",
|
|
19
|
+
"all": "Все",
|
|
20
|
+
"match case": "Учитывать регистр",
|
|
21
|
+
"regexp": "Регулярное выражение",
|
|
22
|
+
"by word": "Слова целиком",
|
|
23
|
+
"replace": "Заменить",
|
|
24
|
+
"replace all": "Заменить всё",
|
|
25
|
+
"close": "Закрыть",
|
|
26
|
+
"current match": "Текущее совпадение",
|
|
27
|
+
"on line": "в строке",
|
|
28
|
+
"replaced match on line $": "Заменено совпадение в строке $",
|
|
29
|
+
"replaced $ matches": "Заменено совпадений: $",
|
|
30
|
+
"Diagnostics": "Диагностика",
|
|
31
|
+
"No diagnostics": "Нет диагностических сообщений"
|
|
32
|
+
};
|
|
33
|
+
export default phrases;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Fixed CodeMirror lookup keys; preserve literal $ substitution tokens. */
|
|
2
|
+
const phrases = {
|
|
3
|
+
"Control character": "控制字符",
|
|
4
|
+
"Selection deleted": "已删除所选内容",
|
|
5
|
+
"Completions": "补全建议",
|
|
6
|
+
"Folded lines": "已折叠行",
|
|
7
|
+
"Unfolded lines": "已展开行",
|
|
8
|
+
"Fold line": "折叠行",
|
|
9
|
+
"Unfold line": "展开行",
|
|
10
|
+
"folded code": "已折叠代码",
|
|
11
|
+
"unfold": "展开",
|
|
12
|
+
"to": "至",
|
|
13
|
+
"Go to line": "跳转到行",
|
|
14
|
+
"go": "跳转",
|
|
15
|
+
"Find": "查找",
|
|
16
|
+
"Replace": "替换",
|
|
17
|
+
"next": "下一个",
|
|
18
|
+
"previous": "上一个",
|
|
19
|
+
"all": "全部",
|
|
20
|
+
"match case": "区分大小写",
|
|
21
|
+
"regexp": "正则表达式",
|
|
22
|
+
"by word": "全字匹配",
|
|
23
|
+
"replace": "替换",
|
|
24
|
+
"replace all": "全部替换",
|
|
25
|
+
"close": "关闭",
|
|
26
|
+
"current match": "当前匹配项",
|
|
27
|
+
"on line": "所在行",
|
|
28
|
+
"replaced match on line $": "已替换第 $ 行的匹配项",
|
|
29
|
+
"replaced $ matches": "已替换 $ 个匹配项",
|
|
30
|
+
"Diagnostics": "诊断",
|
|
31
|
+
"No diagnostics": "无诊断信息"
|
|
32
|
+
};
|
|
33
|
+
export default phrases;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Locale } from "./runtime.js";
|
|
2
|
+
/** Bundled English; pass to EditorState.phrases.of(...). */
|
|
3
|
+
export declare const englishCodeMirrorPhrases: Readonly<Record<string, string>>;
|
|
4
|
+
/** Lazy chunks from the app's own deployment. Reconfigure the facet on activation.
|
|
5
|
+
* Keys come from CodeMirror 6 view/search/autocomplete/language/commands/lint.
|
|
6
|
+
* pt is Brazilian Portuguese; zh is Simplified Chinese. Never translate document data.
|
|
7
|
+
*/
|
|
8
|
+
export declare function loadCodeMirrorPhrases(locale: Locale): Promise<Record<string, string>>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import english from "./codemirror/en.js";
|
|
2
|
+
/** Bundled English; pass to EditorState.phrases.of(...). */
|
|
3
|
+
export const englishCodeMirrorPhrases = Object.freeze(english);
|
|
4
|
+
const loaders = {
|
|
5
|
+
fr: () => import("./codemirror/fr.js"), de: () => import("./codemirror/de.js"),
|
|
6
|
+
es: () => import("./codemirror/es.js"), ja: () => import("./codemirror/ja.js"),
|
|
7
|
+
ko: () => import("./codemirror/ko.js"), pt: () => import("./codemirror/pt.js"),
|
|
8
|
+
it: () => import("./codemirror/it.js"),
|
|
9
|
+
ru: () => import("./codemirror/ru.js"), zh: () => import("./codemirror/zh.js"),
|
|
10
|
+
};
|
|
11
|
+
/** Lazy chunks from the app's own deployment. Reconfigure the facet on activation.
|
|
12
|
+
* Keys come from CodeMirror 6 view/search/autocomplete/language/commands/lint.
|
|
13
|
+
* pt is Brazilian Portuguese; zh is Simplified Chinese. Never translate document data.
|
|
14
|
+
*/
|
|
15
|
+
export async function loadCodeMirrorPhrases(locale) {
|
|
16
|
+
return { ...(locale === "en" ? english : (await loaders[locale]()).default) };
|
|
17
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ReactNode, type SelectHTMLAttributes } from "react";
|
|
2
|
+
import { type I18nRuntime } from "./runtime.js";
|
|
3
|
+
export declare function LocaleProvider({ runtime, onError, children }: {
|
|
4
|
+
runtime: I18nRuntime;
|
|
5
|
+
onError: (error: unknown) => void;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
8
|
+
export declare function useI18nRuntime(): I18nRuntime;
|
|
9
|
+
/** Native, unstyled select. The app supplies translated label and load-error UI. */
|
|
10
|
+
export declare function LanguagePicker({ label, onError, disabled, ...props }: Omit<SelectHTMLAttributes<HTMLSelectElement>, "value" | "defaultValue" | "onChange" | "aria-label"> & {
|
|
11
|
+
label: string;
|
|
12
|
+
onError: (error: unknown) => void;
|
|
13
|
+
}): import("react").JSX.Element;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { I18nProvider, useLingui } from "@lingui/react";
|
|
3
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
4
|
+
import { isLocale, localeNames, locales } from "./runtime.js";
|
|
5
|
+
const RuntimeContext = createContext(null);
|
|
6
|
+
export function LocaleProvider({ runtime, onError, children }) {
|
|
7
|
+
useEffect(() => { void runtime.initialize().catch(onError); }, [runtime, onError]);
|
|
8
|
+
return _jsx(RuntimeContext.Provider, { value: runtime, children: _jsx(I18nProvider, { i18n: runtime.i18n, children: children }) });
|
|
9
|
+
}
|
|
10
|
+
export function useI18nRuntime() {
|
|
11
|
+
const runtime = useContext(RuntimeContext);
|
|
12
|
+
if (!runtime)
|
|
13
|
+
throw new Error("useI18nRuntime requires LocaleProvider");
|
|
14
|
+
return runtime;
|
|
15
|
+
}
|
|
16
|
+
/** Native, unstyled select. The app supplies translated label and load-error UI. */
|
|
17
|
+
export function LanguagePicker({ label, onError, disabled, ...props }) {
|
|
18
|
+
const runtime = useI18nRuntime();
|
|
19
|
+
const { i18n } = useLingui();
|
|
20
|
+
const [busy, setBusy] = useState(false);
|
|
21
|
+
return _jsx("select", { ...props, "aria-label": label, value: i18n.locale, disabled: disabled || busy, "aria-busy": busy, onChange: (event) => {
|
|
22
|
+
const locale = event.currentTarget.value;
|
|
23
|
+
if (!isLocale(locale))
|
|
24
|
+
return;
|
|
25
|
+
setBusy(true);
|
|
26
|
+
void runtime.selectLocale(locale).catch(onError).finally(() => setBusy(false));
|
|
27
|
+
}, children: locales.map((locale) => _jsx("option", { value: locale, lang: locale, children: localeNames[locale] }, locale)) });
|
|
28
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type I18n } from "@lingui/core";
|
|
2
|
+
export { mergeCatalogues } from "../scripts/catalogues.mjs";
|
|
3
|
+
export declare const locales: readonly ["en", "fr", "de", "es", "ja", "ko", "pt", "ru", "zh", "it"];
|
|
4
|
+
export type Locale = (typeof locales)[number];
|
|
5
|
+
export declare const localeNames: Record<Locale, string>;
|
|
6
|
+
export declare function isLocale(value: unknown): value is Locale;
|
|
7
|
+
export declare function negotiateLocale(preferences: readonly string[]): Locale;
|
|
8
|
+
export interface LocaleStorage {
|
|
9
|
+
getLocale(): string | null | Promise<string | null>;
|
|
10
|
+
setLocale(locale: Locale): void | Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/** JSON imports lose compiled tuple types. This is compiler-owned ICU data, not code. */
|
|
13
|
+
export interface Catalogue {
|
|
14
|
+
messages: object;
|
|
15
|
+
}
|
|
16
|
+
export interface CatalogueSource {
|
|
17
|
+
english: Catalogue;
|
|
18
|
+
loadLocale: (locale: Exclude<Locale, "en">) => Promise<Catalogue>;
|
|
19
|
+
}
|
|
20
|
+
export interface RuntimeOptions extends CatalogueSource {
|
|
21
|
+
/** Inject the Lingui global singleton when existing core macros target it. */
|
|
22
|
+
i18n?: I18n;
|
|
23
|
+
/** Libraries own their PO scope and lazy compiled catalogues, never a provider. */
|
|
24
|
+
libraries?: readonly CatalogueSource[];
|
|
25
|
+
storage?: LocaleStorage;
|
|
26
|
+
preferences?: readonly string[];
|
|
27
|
+
document?: Pick<Document, "documentElement">;
|
|
28
|
+
}
|
|
29
|
+
export declare function createI18nRuntime(options: RuntimeOptions): {
|
|
30
|
+
i18n: I18n;
|
|
31
|
+
/** Idempotent, and deliberately not awaited by the provider's first render. */
|
|
32
|
+
initialize(): Promise<void>;
|
|
33
|
+
/** Load failures leave the active catalogue and stored choice untouched. */
|
|
34
|
+
selectLocale(locale: Locale): Promise<void>;
|
|
35
|
+
};
|
|
36
|
+
export type I18nRuntime = ReturnType<typeof createI18nRuntime>;
|
|
37
|
+
/** Display-only formatting: never use these to serialize record values. */
|
|
38
|
+
export declare function formatters(locale: string): {
|
|
39
|
+
date: (value: Date | number, options?: Intl.DateTimeFormatOptions) => string;
|
|
40
|
+
number: (value: number, options?: Intl.NumberFormatOptions) => string;
|
|
41
|
+
relativeTime: (value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions) => string;
|
|
42
|
+
};
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { setupI18n } from "@lingui/core";
|
|
2
|
+
import { mergeCatalogues } from "../scripts/catalogues.mjs";
|
|
3
|
+
export { mergeCatalogues } from "../scripts/catalogues.mjs";
|
|
4
|
+
export const locales = ["en", "fr", "de", "es", "ja", "ko", "pt", "ru", "zh", "it"];
|
|
5
|
+
export const localeNames = {
|
|
6
|
+
en: "English", fr: "Français", de: "Deutsch", es: "Español",
|
|
7
|
+
ja: "日本語", ko: "한국어", pt: "Português (Brasil)", ru: "Русский", zh: "简体中文", it: "Italiano",
|
|
8
|
+
};
|
|
9
|
+
export function isLocale(value) {
|
|
10
|
+
return typeof value === "string" && locales.includes(value);
|
|
11
|
+
}
|
|
12
|
+
export function negotiateLocale(preferences) {
|
|
13
|
+
for (const preference of preferences) {
|
|
14
|
+
const base = preference.toLowerCase().split(/[-_]/)[0];
|
|
15
|
+
if (isLocale(base))
|
|
16
|
+
return base;
|
|
17
|
+
}
|
|
18
|
+
return "en";
|
|
19
|
+
}
|
|
20
|
+
const literalInstances = new WeakSet();
|
|
21
|
+
/** Copies values with backslashes encoded, so the user's text survives Lingui's decoding pass. */
|
|
22
|
+
const literalValues = (values) => values && Object.fromEntries(Object.entries(values)
|
|
23
|
+
.map(([key, value]) => [key, typeof value === "string" ? value.replace(/\\/g, "\\u005c") : value]));
|
|
24
|
+
/**
|
|
25
|
+
* Lingui decodes \uXXXX/\xXX after interpolation, so `C:\x41dir` would render as `C:Adir`.
|
|
26
|
+
* Wrap `_` (and `t`, bound to the original `_`) once per instance; shared singletons stay single-encoded.
|
|
27
|
+
*/
|
|
28
|
+
function renderValuesLiterally(i18n) {
|
|
29
|
+
if (literalInstances.has(i18n))
|
|
30
|
+
return;
|
|
31
|
+
literalInstances.add(i18n);
|
|
32
|
+
const render = i18n._.bind(i18n);
|
|
33
|
+
const translate = (id, values, options) => typeof id === "object" && id !== null
|
|
34
|
+
? render({ ...id, values: literalValues(id.values) }, literalValues(values), options)
|
|
35
|
+
: render(id, literalValues(values), options);
|
|
36
|
+
i18n._ = translate;
|
|
37
|
+
i18n.t = translate;
|
|
38
|
+
}
|
|
39
|
+
export function createI18nRuntime(options) {
|
|
40
|
+
const i18n = options.i18n ?? setupI18n();
|
|
41
|
+
renderValuesLiterally(i18n);
|
|
42
|
+
const sources = [options, ...(options.libraries ?? [])];
|
|
43
|
+
i18n.load("en", mergeCatalogues(sources.map((source) => source.english)).messages);
|
|
44
|
+
i18n.activate("en");
|
|
45
|
+
const document = options.document ?? (typeof globalThis.document === "undefined" ? undefined : globalThis.document);
|
|
46
|
+
let generation = 0;
|
|
47
|
+
let initialization;
|
|
48
|
+
let saving = Promise.resolve();
|
|
49
|
+
const loaded = new Set(["en"]);
|
|
50
|
+
const syncDocument = () => {
|
|
51
|
+
if (document) {
|
|
52
|
+
document.documentElement.lang = i18n.locale;
|
|
53
|
+
document.documentElement.dir = "ltr"; // All supported languages use LTR scripts.
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
syncDocument();
|
|
57
|
+
async function activate(locale, persist) {
|
|
58
|
+
const request = ++generation;
|
|
59
|
+
if (!loaded.has(locale) && locale !== "en") {
|
|
60
|
+
const catalogues = await Promise.all(sources.map((source) => source.loadLocale(locale)));
|
|
61
|
+
if (request !== generation)
|
|
62
|
+
return;
|
|
63
|
+
const catalogue = mergeCatalogues(catalogues);
|
|
64
|
+
i18n.load(locale, catalogue.messages);
|
|
65
|
+
loaded.add(locale);
|
|
66
|
+
}
|
|
67
|
+
if (request !== generation)
|
|
68
|
+
return;
|
|
69
|
+
i18n.activate(locale);
|
|
70
|
+
syncDocument();
|
|
71
|
+
if (persist && options.storage) {
|
|
72
|
+
// Serialize async adapters so an older slow write cannot replace a newer choice.
|
|
73
|
+
const storage = options.storage;
|
|
74
|
+
saving = saving.catch(() => undefined).then(async () => {
|
|
75
|
+
if (request === generation)
|
|
76
|
+
await storage.setLocale(locale);
|
|
77
|
+
});
|
|
78
|
+
await saving;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
i18n,
|
|
83
|
+
/** Idempotent, and deliberately not awaited by the provider's first render. */
|
|
84
|
+
initialize() {
|
|
85
|
+
initialization ??= (async () => {
|
|
86
|
+
const request = generation;
|
|
87
|
+
const stored = await options.storage?.getLocale();
|
|
88
|
+
if (request !== generation)
|
|
89
|
+
return; // A user choice wins over delayed startup.
|
|
90
|
+
const preferences = options.preferences ??
|
|
91
|
+
(typeof navigator === "undefined" ? [] : navigator.languages);
|
|
92
|
+
await activate(isLocale(stored) ? stored : negotiateLocale(preferences), false);
|
|
93
|
+
})();
|
|
94
|
+
return initialization;
|
|
95
|
+
},
|
|
96
|
+
/** Load failures leave the active catalogue and stored choice untouched. */
|
|
97
|
+
selectLocale(locale) {
|
|
98
|
+
if (!isLocale(locale))
|
|
99
|
+
return Promise.reject(new Error("Unsupported locale"));
|
|
100
|
+
return activate(locale, true);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** Display-only formatting: never use these to serialize record values. */
|
|
105
|
+
export function formatters(locale) {
|
|
106
|
+
const intlLocale = locale === "pt" ? "pt-BR" : locale === "zh" ? "zh-CN" : locale;
|
|
107
|
+
return {
|
|
108
|
+
date: (value, options) => new Intl.DateTimeFormat(intlLocale, options).format(value),
|
|
109
|
+
number: (value, options) => new Intl.NumberFormat(intlLocale, options).format(value),
|
|
110
|
+
relativeTime: (value, unit, options) => new Intl.RelativeTimeFormat(intlLocale, options).format(value, unit),
|
|
111
|
+
};
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,78 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mdbase-dev/i18n",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0-beta.148",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": false,
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mdbase-dev/mdbase-connect.git",
|
|
10
|
+
"directory": "packages/i18n"
|
|
11
|
+
},
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"scripts/catalogues.mjs",
|
|
16
|
+
"scripts/catalogues.d.mts",
|
|
17
|
+
"scripts/bin.mjs",
|
|
18
|
+
"scripts/cli.mjs",
|
|
19
|
+
"scripts/config.mjs",
|
|
20
|
+
"scripts/config.d.mts",
|
|
21
|
+
"scripts/checks.mjs",
|
|
22
|
+
"scripts/source.mjs",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"bin": {
|
|
26
|
+
"mdbase-i18n": "scripts/bin.mjs"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/runtime.d.ts",
|
|
31
|
+
"import": "./dist/runtime.js"
|
|
32
|
+
},
|
|
33
|
+
"./react": {
|
|
34
|
+
"types": "./dist/react.d.ts",
|
|
35
|
+
"import": "./dist/react.js"
|
|
36
|
+
},
|
|
37
|
+
"./config": "./scripts/config.mjs",
|
|
38
|
+
"./checks": "./scripts/checks.mjs",
|
|
39
|
+
"./source": "./scripts/source.mjs",
|
|
40
|
+
"./codemirror": {
|
|
41
|
+
"types": "./dist/codemirror.d.ts",
|
|
42
|
+
"import": "./dist/codemirror.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@lingui/cli": "6.9.0",
|
|
47
|
+
"@lingui/conf": "6.9.0",
|
|
48
|
+
"@lingui/format-po": "6.9.0",
|
|
49
|
+
"@lingui/message-utils": "6.9.0",
|
|
50
|
+
"typescript": "6.0.3"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@lingui/core": "6.9.0",
|
|
54
|
+
"@lingui/react": "6.9.0",
|
|
55
|
+
"react": "^19.2.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@lingui/core": "6.9.0",
|
|
59
|
+
"@lingui/react": "6.9.0",
|
|
60
|
+
"@types/react": "^19.3.0",
|
|
61
|
+
"@types/react-dom": "^19.3.0",
|
|
62
|
+
"react": "^19.3.0",
|
|
63
|
+
"react-dom": "^19.3.0",
|
|
64
|
+
"@testing-library/react": "^16.3.2",
|
|
65
|
+
"jsdom": "^29.1.1",
|
|
66
|
+
"vitest": "^4.1.11",
|
|
67
|
+
"eslint": "^10.1.0",
|
|
68
|
+
"typescript-eslint": "^8.65.0"
|
|
69
|
+
},
|
|
70
|
+
"scripts": {
|
|
71
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
72
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
73
|
+
"test": "vitest run && node --test scripts/*.test.mjs",
|
|
74
|
+
"test:packed": "node --test scripts/cli.test.mjs",
|
|
75
|
+
"lint": "eslint src scripts",
|
|
76
|
+
"verify": "pnpm typecheck && pnpm lint && pnpm build && pnpm test"
|
|
77
|
+
}
|
|
6
78
|
}
|
package/scripts/bin.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Stable union: identical generated IDs deduplicate; conflicting values fail closed. */
|
|
2
|
+
/**
|
|
3
|
+
* @param {readonly { messages: object }[]} catalogues
|
|
4
|
+
* @returns {{ messages: object }}
|
|
5
|
+
*/
|
|
6
|
+
export function mergeCatalogues(catalogues) {
|
|
7
|
+
/** @param {unknown} value @returns {unknown} */
|
|
8
|
+
const canonical = (value) => {
|
|
9
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
10
|
+
if (value && typeof value === "object") return Object.fromEntries(
|
|
11
|
+
Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, value]) => [key, canonical(value)]));
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
14
|
+
const messages = new Map();
|
|
15
|
+
const conflicts = new Set();
|
|
16
|
+
for (const catalogue of catalogues) for (const [id, value] of Object.entries(catalogue.messages)) {
|
|
17
|
+
const normalized = canonical(value);
|
|
18
|
+
if (messages.has(id) && JSON.stringify(messages.get(id)) !== JSON.stringify(normalized)) {
|
|
19
|
+
conflicts.add("Conflicting compiled translation for message ID: " + id +
|
|
20
|
+
" — " + JSON.stringify(messages.get(id)) + " vs " + JSON.stringify(normalized));
|
|
21
|
+
} else messages.set(id, normalized);
|
|
22
|
+
}
|
|
23
|
+
if (conflicts.size) throw new Error([...conflicts].join("\n"));
|
|
24
|
+
return { messages: Object.fromEntries([...messages].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) };
|
|
25
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { compileMessageOrThrow } from "@lingui/message-utils/compileMessage";
|
|
3
|
+
import { generateMessageId } from "@lingui/message-utils/generateMessageId";
|
|
4
|
+
|
|
5
|
+
const sorted = (set) => [...set].sort();
|
|
6
|
+
function shape(parts) {
|
|
7
|
+
const args = new Set(), tags = new Set(), selectors = [];
|
|
8
|
+
let text = "";
|
|
9
|
+
for (const part of parts) {
|
|
10
|
+
if (typeof part === "string") { text += part; continue; }
|
|
11
|
+
const [name, type, options] = part;
|
|
12
|
+
args.add(JSON.stringify([name, type ?? "argument"]));
|
|
13
|
+
if (["plural", "selectordinal", "select"].includes(type)) {
|
|
14
|
+
assert.ok(options && options.other, "ICU selection requires other");
|
|
15
|
+
selectors.push({ name, type, options });
|
|
16
|
+
for (const [key, branch] of Object.entries(options)) {
|
|
17
|
+
if (key === "offset") continue;
|
|
18
|
+
const child = shape(branch);
|
|
19
|
+
child.args.forEach((arg) => args.add(arg));
|
|
20
|
+
child.tags.forEach((tag) => tags.add(tag));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const stack = [];
|
|
25
|
+
for (const match of text.matchAll(/<(\/?[A-Za-z0-9][^>]*)>/g)) {
|
|
26
|
+
const tag = match[1];
|
|
27
|
+
assert.match(tag, /^\/?\d+\/?$/, "Only numeric React placeholders are allowed");
|
|
28
|
+
const id = tag.replaceAll("/", "");
|
|
29
|
+
tags.add(id);
|
|
30
|
+
if (tag.startsWith("/")) assert.equal(stack.pop(), id, "Unbalanced React placeholder");
|
|
31
|
+
else if (!tag.endsWith("/")) stack.push(id);
|
|
32
|
+
}
|
|
33
|
+
assert.equal(stack.length, 0, "Unclosed React placeholder");
|
|
34
|
+
return { args: sorted(args), tags: sorted(tags), selectors, pounds: (text.match(/#/g) ?? []).length };
|
|
35
|
+
}
|
|
36
|
+
/** Plural categories are language-specific; exact numeric and select cases are contracts. */
|
|
37
|
+
export function assertMessageContract(source, target, locale, onWarning = () => {}, onInfo = () => {}) {
|
|
38
|
+
const check = (sourceParts, targetParts, path = []) => {
|
|
39
|
+
const a = shape(sourceParts), b = shape(targetParts);
|
|
40
|
+
assert.deepEqual(b.args, a.args, "ICU argument names/types changed");
|
|
41
|
+
assert.deepEqual(b.tags, a.tags, "React placeholder tags changed");
|
|
42
|
+
assert.equal(b.pounds, a.pounds, "ICU pound markers changed");
|
|
43
|
+
assert.equal(b.selectors.length, a.selectors.length, "ICU selectors changed");
|
|
44
|
+
const used = new Set();
|
|
45
|
+
for (const selector of a.selectors) {
|
|
46
|
+
const index = b.selectors.findIndex((s, index) => !used.has(index) && s.name === selector.name && s.type === selector.type);
|
|
47
|
+
assert.ok(index >= 0, "ICU selector changed");
|
|
48
|
+
used.add(index);
|
|
49
|
+
const translated = b.selectors[index];
|
|
50
|
+
const sourceKeys = Object.keys(selector.options).filter((k) => k !== "offset");
|
|
51
|
+
const targetKeys = Object.keys(translated.options).filter((k) => k !== "offset");
|
|
52
|
+
assert.equal(translated.options.offset, selector.options.offset, "ICU plural offset changed");
|
|
53
|
+
if (selector.type === "select") assert.deepEqual(targetKeys.sort(), sourceKeys.sort(), "ICU select cases changed");
|
|
54
|
+
else {
|
|
55
|
+
const exact = (keys) => keys.filter((k) => /^=?\d/.test(k)).sort();
|
|
56
|
+
for (const key of exact(sourceKeys))
|
|
57
|
+
assert.ok(targetKeys.includes(key), "ICU exact numeric cases changed: missing " + key);
|
|
58
|
+
const rules = new Intl.PluralRules(locale === "pt" ? "pt-BR" : locale, {
|
|
59
|
+
type: selector.type === "selectordinal" ? "ordinal" : "cardinal",
|
|
60
|
+
});
|
|
61
|
+
const categories = rules.resolvedOptions().pluralCategories;
|
|
62
|
+
for (const key of targetKeys) assert.ok(/^=?\d/.test(key) || categories.includes(key), "Invalid plural category for " + locale + ": " + key);
|
|
63
|
+
if (locale !== "en") {
|
|
64
|
+
// A PO message has no call-site numeric domain: use integer counts.
|
|
65
|
+
// Exact cases match the original count; CLDR selects count - offset.
|
|
66
|
+
const reachable = new Set(), uncovered = new Set();
|
|
67
|
+
for (let count = 0; count <= 999; count++) {
|
|
68
|
+
const category = rules.select(count - (translated.options.offset ?? 0));
|
|
69
|
+
reachable.add(category);
|
|
70
|
+
if (!targetKeys.includes(category) && !targetKeys.includes(String(count))) uncovered.add(category);
|
|
71
|
+
}
|
|
72
|
+
const optional = categories.filter((key) => !reachable.has(key) && !targetKeys.includes(key));
|
|
73
|
+
const location = [...path, selector.name + ", " + selector.type].join(" > ");
|
|
74
|
+
if (optional.length) onInfo("Optional plural categories absent (outside integer counts 0..999) at " + location + ": " + optional.join(", "));
|
|
75
|
+
assert.equal(uncovered.size, 0, "Missing plural categories for " + locale + " " + selector.type + " " + selector.name + ": " + sorted(uncovered).join(", "));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const key of targetKeys) {
|
|
79
|
+
const sourceBranch = selector.options[key] ?? selector.options.other;
|
|
80
|
+
const targetBranch = translated.options[key];
|
|
81
|
+
const branchPath = [...path, selector.name + ", " + selector.type + ", " + key];
|
|
82
|
+
// Compare literal text byte-for-byte, not normalized whole-message copy.
|
|
83
|
+
// Argument names and nested selector keys are not English prose.
|
|
84
|
+
const text = sourceBranch.filter((part) => typeof part === "string").join("");
|
|
85
|
+
if (locale !== "en" && /\p{L}/u.test(text) && JSON.stringify(targetBranch) === JSON.stringify(sourceBranch))
|
|
86
|
+
onWarning("English-identical ICU branch: " + branchPath.join(" > "));
|
|
87
|
+
check(sourceBranch, targetBranch, branchPath);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
check(compileMessageOrThrow(source), compileMessageOrThrow(target));
|
|
92
|
+
}
|
|
93
|
+
export function assertCompleteCatalogue(english, target, locale, allowedIdentical = [], onWarning = console.warn, onInfo = console.info) {
|
|
94
|
+
const active = (entries) => Object.fromEntries(Object.entries(entries).filter(([, e]) => !e.obsolete));
|
|
95
|
+
const source = active(english), translated = active(target);
|
|
96
|
+
assert.deepEqual(Object.keys(translated).sort(), Object.keys(source).sort(), locale + " active catalogue keys differ");
|
|
97
|
+
for (const [id, entry] of Object.entries(translated)) {
|
|
98
|
+
assert.equal(id, generateMessageId(entry.message, entry.context), "Explicit/non-generated message ID: " + id);
|
|
99
|
+
assert.equal(entry.message, source[id].message, "Source message changed: " + id);
|
|
100
|
+
assert.equal(entry.context ?? "", source[id].context ?? "", "Source context changed: " + id);
|
|
101
|
+
assert.ok(entry.translation?.trim(), locale + " missing authored translation before fallback: " + id);
|
|
102
|
+
assert.ok(!entry.extra?.flags?.includes("fuzzy"), locale + " fuzzy translation: " + id);
|
|
103
|
+
const normalized = (text) => text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
104
|
+
if (locale !== "en" && normalized(entry.translation) === normalized(entry.message))
|
|
105
|
+
assert.ok(allowedIdentical.includes(id), locale + " English placeholder (allowlist reviewed proper nouns/terms by ID): " + id);
|
|
106
|
+
try {
|
|
107
|
+
assertMessageContract(entry.message, entry.translation, locale, (warning) => {
|
|
108
|
+
if (!allowedIdentical.includes(id)) onWarning(locale + " message " + id + ": " + warning);
|
|
109
|
+
}, (info) => onInfo("info: " + locale + " message " + id + ": " + info));
|
|
110
|
+
}
|
|
111
|
+
catch (error) { throw new Error(locale + " message " + id + ": " + error.message, { cause: error }); }
|
|
112
|
+
}
|
|
113
|
+
return Object.keys(translated).length;
|
|
114
|
+
}
|
package/scripts/cli.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { readFile, readdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { getConfig } from "@lingui/conf";
|
|
6
|
+
import { formatter } from "@lingui/format-po";
|
|
7
|
+
import { assertCompleteCatalogue } from "./checks.mjs";
|
|
8
|
+
import { inspectUiSource } from "./source.mjs";
|
|
9
|
+
import { locales } from "./config.mjs";
|
|
10
|
+
// Shared with the runtime, but executable before the workspace has any dist.
|
|
11
|
+
import { mergeCatalogues } from "./catalogues.mjs";
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const lingui = resolve(dirname(require.resolve("@lingui/cli")), "lingui.js");
|
|
15
|
+
function run(args) {
|
|
16
|
+
const result = spawnSync(process.execPath, [lingui, ...args], { stdio: "inherit" });
|
|
17
|
+
if (result.error) throw result.error;
|
|
18
|
+
if (result.status !== 0) throw new Error("Lingui " + args.join(" ") + " failed");
|
|
19
|
+
}
|
|
20
|
+
// Lingui 6.9 compiles obsolete PO entries, including English fallback values.
|
|
21
|
+
// Prune at the shared compile boundary, not as a one-off catalogue cleanup.
|
|
22
|
+
async function compile(config, args) {
|
|
23
|
+
for (const catalogue of config.catalogs) {
|
|
24
|
+
for (const locale of config.locales) {
|
|
25
|
+
const filename = catalogue.path.replaceAll("{locale}", locale) + ".po";
|
|
26
|
+
const existing = await readFile(filename, "utf8");
|
|
27
|
+
const context = { locale, sourceLocale: config.sourceLocale, filename, existing };
|
|
28
|
+
const entries = await config.format.parse(existing, context);
|
|
29
|
+
const active = Object.entries(entries).filter(([, entry]) => !entry.obsolete);
|
|
30
|
+
if (active.length !== Object.keys(entries).length)
|
|
31
|
+
await writeFile(filename, await config.format.serialize(Object.fromEntries(active), context));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
run(["compile", "--strict", "--workers", "1", ...args]);
|
|
35
|
+
}
|
|
36
|
+
export async function main(command, args = []) {
|
|
37
|
+
if (!["extract", "compile", "check", "source"].includes(command))
|
|
38
|
+
throw new Error("Usage: mdbase-i18n extract|compile|check|source [--config lingui.config.ts] [--compose library/{locale}/messages.json ...]");
|
|
39
|
+
const compose = [];
|
|
40
|
+
const linguiArgs = [];
|
|
41
|
+
for (let index = 0; index < args.length; index++) {
|
|
42
|
+
if (args[index] !== "--compose") { linguiArgs.push(args[index]); continue; }
|
|
43
|
+
if (command !== "check") throw new Error("--compose is only supported by check");
|
|
44
|
+
const start = compose.length;
|
|
45
|
+
while (index + 1 < args.length && !args[index + 1].startsWith("--")) compose.push(args[++index]);
|
|
46
|
+
if (compose.length === start) throw new Error("--compose requires compiled JSON paths containing {locale}");
|
|
47
|
+
}
|
|
48
|
+
if (compose.some((path) => !path.includes("{locale}") || !path.endsWith(".json")))
|
|
49
|
+
throw new Error("--compose requires compiled JSON paths containing {locale}");
|
|
50
|
+
args = linguiArgs;
|
|
51
|
+
if (command === "extract") return run(["extract", "--workers", "1", ...args]);
|
|
52
|
+
const configIndex = args.indexOf("--config");
|
|
53
|
+
const config = getConfig({ configPath: configIndex < 0 ? undefined : args[configIndex + 1] });
|
|
54
|
+
if (command === "compile") return compile(config, args);
|
|
55
|
+
let policy = {};
|
|
56
|
+
try { policy = JSON.parse(await readFile(resolve(config.rootDir, "i18n-policy.json"), "utf8")); }
|
|
57
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
58
|
+
if (command === "check") {
|
|
59
|
+
if (JSON.stringify(config.locales) !== JSON.stringify(locales) || config.sourceLocale !== "en" || config.compileNamespace !== "json")
|
|
60
|
+
throw new Error("Use the ten-locale English-source strict-JSON kit preset");
|
|
61
|
+
run(["check", "sync", ...args]);
|
|
62
|
+
for (const catalogue of config.catalogs) {
|
|
63
|
+
const load = async (locale) => formatter({}).parse(
|
|
64
|
+
await readFile(catalogue.path.replaceAll("{locale}", locale) + ".po", "utf8"), { locale });
|
|
65
|
+
const english = await load("en");
|
|
66
|
+
for (const locale of locales)
|
|
67
|
+
assertCompleteCatalogue(english, await load(locale), locale, policy.allowIdentical?.[locale]);
|
|
68
|
+
}
|
|
69
|
+
await compile(config, args);
|
|
70
|
+
// Use exactly the runtime's compiled-value equality, including nested ICU objects.
|
|
71
|
+
// Include every app scope so app/app and library/library collisions also fail.
|
|
72
|
+
const paths = [...config.catalogs.map((catalogue) => catalogue.path + ".json"),
|
|
73
|
+
...compose.map((path) => resolve(config.rootDir, path))];
|
|
74
|
+
const conflicts = [];
|
|
75
|
+
for (const locale of locales) {
|
|
76
|
+
const catalogues = await Promise.all(paths.map(async (path) => {
|
|
77
|
+
const filename = path.replaceAll("{locale}", locale);
|
|
78
|
+
const catalogue = JSON.parse(await readFile(filename, "utf8"));
|
|
79
|
+
if (!catalogue.messages || typeof catalogue.messages !== "object" || Array.isArray(catalogue.messages))
|
|
80
|
+
throw new Error("Expected compiled catalogue messages: " + filename);
|
|
81
|
+
return catalogue;
|
|
82
|
+
}));
|
|
83
|
+
try { mergeCatalogues(catalogues); }
|
|
84
|
+
catch (error) { conflicts.push(...error.message.split("\n").map((line) => locale + ": " + line)); }
|
|
85
|
+
}
|
|
86
|
+
if (conflicts.length) throw new Error(conflicts.join("\n"));
|
|
87
|
+
}
|
|
88
|
+
const source = policy.source ?? {};
|
|
89
|
+
const includes = source.include ?? config.catalogs.flatMap((catalogue) => catalogue.include);
|
|
90
|
+
const excludes = [...new Set(["**/node_modules/**", "**/locales/**", "**/*.test.*", "**/*.spec.*",
|
|
91
|
+
...config.catalogs.flatMap((catalogue) => catalogue.exclude ?? []), ...(source.exclude ?? [])])];
|
|
92
|
+
const files = [];
|
|
93
|
+
// Source includes are directories; excludes are Node's platform-independent glob patterns.
|
|
94
|
+
const { matchesGlob } = await import("node:path");
|
|
95
|
+
async function walk(path) {
|
|
96
|
+
const relative = path.slice(config.rootDir.length + 1).replaceAll("\\", "/");
|
|
97
|
+
if (excludes.some((pattern) => matchesGlob(relative, pattern))) return;
|
|
98
|
+
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
99
|
+
const child = resolve(path, entry.name);
|
|
100
|
+
if (entry.isDirectory()) await walk(child);
|
|
101
|
+
else if (/\.[cm]?[jt]sx?$/.test(entry.name) && !excludes.some((pattern) => matchesGlob(child.slice(config.rootDir.length + 1), pattern))) files.push(child);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const include of new Set(includes)) await walk(resolve(config.rootDir, include.replace("<rootDir>/", "")));
|
|
105
|
+
const issues = (await Promise.all(files.map(async (file) => inspectUiSource(await readFile(file, "utf8"), file, source)))).flat();
|
|
106
|
+
if (issues.length) throw new Error(issues.map((i) => i.file + ":" + i.line + " " + i.kind + ": " + i.text).join("\n"));
|
|
107
|
+
console.log("i18n: catalogue contracts and source guard passed (" + files.length + " modules).");
|
|
108
|
+
}
|