@xndrjs/i18n 0.2.0-alpha.0
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/README.md +572 -0
- package/bin/codegen.mjs +17 -0
- package/bin/setup.mjs +14 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +320 -0
- package/dist/index.js.map +1 -0
- package/dist/types-CFWVT2PP.d.ts +77 -0
- package/dist/validation/index.d.ts +44 -0
- package/dist/validation/index.js +490 -0
- package/dist/validation/index.js.map +1 -0
- package/package.json +65 -0
- package/src/IcuTranslationProviderMulti.test.ts +290 -0
- package/src/IcuTranslationProviderMulti.ts +195 -0
- package/src/IcuTranslationProviderSingle.test.ts +314 -0
- package/src/IcuTranslationProviderSingle.ts +155 -0
- package/src/codegen/config.ts +63 -0
- package/src/codegen/emit/dictionary-file.ts +73 -0
- package/src/codegen/emit/dictionary-schema-file.ts +129 -0
- package/src/codegen/emit/instance-file.ts +60 -0
- package/src/codegen/emit/namespace-loaders-file.ts +44 -0
- package/src/codegen/emit/types-file.ts +118 -0
- package/src/codegen/generate-i18n-types.test.ts +482 -0
- package/src/codegen/generate-i18n-types.ts +173 -0
- package/src/codegen/icu-analysis.ts +88 -0
- package/src/codegen/locale-fallback.ts +64 -0
- package/src/codegen/paths.ts +27 -0
- package/src/codegen/types.ts +30 -0
- package/src/deep-freeze.test.ts +27 -0
- package/src/deep-freeze.ts +17 -0
- package/src/ensure-namespace.integration.test.ts +66 -0
- package/src/ensure-namespace.test.ts +125 -0
- package/src/ensure-namespace.ts +70 -0
- package/src/icu/extract-variables.ts +87 -0
- package/src/icu/parse-template.ts +24 -0
- package/src/index.ts +32 -0
- package/src/resolve-locale.test.ts +91 -0
- package/src/resolve-locale.ts +88 -0
- package/src/setup/setup-i18n.test.ts +86 -0
- package/src/setup/setup-i18n.ts +179 -0
- package/src/setup/type-names.ts +20 -0
- package/src/types.ts +34 -0
- package/src/validation/create-args-schema.ts +76 -0
- package/src/validation/create-normalized-schema.ts +52 -0
- package/src/validation/errors.ts +41 -0
- package/src/validation/index.ts +90 -0
- package/src/validation/normalize.ts +158 -0
- package/src/validation/to-dictionary.ts +67 -0
- package/src/validation/types.ts +78 -0
- package/src/validation/validate-normalized.ts +91 -0
- package/src/validation/validation.test.ts +265 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse } from "@formatjs/icu-messageformat-parser";
|
|
4
|
+
import {
|
|
5
|
+
extractVariables,
|
|
6
|
+
mergeVariableSpecs,
|
|
7
|
+
type VariableSpec,
|
|
8
|
+
} from "../icu/extract-variables.js";
|
|
9
|
+
import type { DictionaryJson, NamespaceEntry } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export function paramsTypeForVariables(variables: VariableSpec): string {
|
|
12
|
+
const keys = Object.keys(variables);
|
|
13
|
+
if (keys.length === 0) {
|
|
14
|
+
return "never";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const props = keys.map((key) => {
|
|
18
|
+
const type = variables[key] === "date" ? "Date | number" : variables[key];
|
|
19
|
+
return `${key}: ${type}`;
|
|
20
|
+
});
|
|
21
|
+
return `{ ${props.join("; ")} }`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DictionaryAnalysis {
|
|
25
|
+
paramsByNamespace: Record<string, Record<string, string>>;
|
|
26
|
+
argsSpecByNamespace: Record<string, Record<string, VariableSpec>>;
|
|
27
|
+
locales: Set<string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function analyzeDictionaries(
|
|
31
|
+
projectRoot: string,
|
|
32
|
+
entries: NamespaceEntry[]
|
|
33
|
+
): { ok: true; analysis: DictionaryAnalysis } | { ok: false } {
|
|
34
|
+
const paramsByNamespace: Record<string, Record<string, string>> = {};
|
|
35
|
+
const argsSpecByNamespace: Record<string, Record<string, VariableSpec>> = {};
|
|
36
|
+
const locales = new Set<string>();
|
|
37
|
+
let hasErrors = false;
|
|
38
|
+
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
const absolutePath = path.resolve(projectRoot, entry.filePath);
|
|
41
|
+
|
|
42
|
+
if (!fs.existsSync(absolutePath)) {
|
|
43
|
+
console.error(
|
|
44
|
+
`[Codegen Error] Dictionary file not found for namespace "${entry.namespace}": ${absolutePath}`
|
|
45
|
+
);
|
|
46
|
+
hasErrors = true;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const dictionary = JSON.parse(fs.readFileSync(absolutePath, "utf8")) as DictionaryJson;
|
|
51
|
+
paramsByNamespace[entry.namespace] = {};
|
|
52
|
+
argsSpecByNamespace[entry.namespace] = {};
|
|
53
|
+
|
|
54
|
+
for (const [key, localesByKey] of Object.entries(dictionary)) {
|
|
55
|
+
const variables: VariableSpec = {};
|
|
56
|
+
|
|
57
|
+
for (const locale of Object.keys(localesByKey)) {
|
|
58
|
+
locales.add(locale);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const [locale, template] of Object.entries(localesByKey)) {
|
|
62
|
+
try {
|
|
63
|
+
const ast = parse(template);
|
|
64
|
+
const extracted = extractVariables(ast);
|
|
65
|
+
Object.assign(variables, mergeVariableSpecs(variables, extracted));
|
|
66
|
+
} catch (error) {
|
|
67
|
+
hasErrors = true;
|
|
68
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
69
|
+
console.error(
|
|
70
|
+
`[Codegen Error] ICU syntax error — namespace "${entry.namespace}", key "${key}", locale "${locale}": ${message}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
paramsByNamespace[entry.namespace]![key] = paramsTypeForVariables(variables);
|
|
76
|
+
argsSpecByNamespace[entry.namespace]![key] = variables;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (hasErrors) {
|
|
81
|
+
return { ok: false };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
analysis: { paramsByNamespace, argsSpecByNamespace, locales },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { validateLocaleFallback } from "../resolve-locale.js";
|
|
2
|
+
|
|
3
|
+
export function collectRequestLocales(
|
|
4
|
+
dictionaryLocales: Set<string>,
|
|
5
|
+
fallback?: Record<string, string | null>
|
|
6
|
+
): Set<string> {
|
|
7
|
+
const all = new Set(dictionaryLocales);
|
|
8
|
+
if (!fallback) {
|
|
9
|
+
return all;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
for (const [locale, target] of Object.entries(fallback)) {
|
|
13
|
+
all.add(locale);
|
|
14
|
+
if (target !== null) {
|
|
15
|
+
all.add(target);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return all;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function validateCodegenLocaleFallback(
|
|
23
|
+
fallback: Record<string, string | null>,
|
|
24
|
+
dictionaryLocales: Set<string>
|
|
25
|
+
): boolean {
|
|
26
|
+
let hasErrors = false;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
validateLocaleFallback(fallback);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
console.error(`[Codegen Error] ${message}`);
|
|
33
|
+
hasErrors = true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const [locale, target] of Object.entries(fallback)) {
|
|
37
|
+
if (target !== null && !(target in fallback) && !dictionaryLocales.has(target)) {
|
|
38
|
+
console.error(
|
|
39
|
+
`[Codegen Error] localeFallback: "${locale}" points to "${target}" which is not defined in the fallback map or dictionary locales`
|
|
40
|
+
);
|
|
41
|
+
hasErrors = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return hasErrors;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatLocaleFallbackBlock(
|
|
49
|
+
fallback: Record<string, string | null>,
|
|
50
|
+
constName: string,
|
|
51
|
+
typeName: string
|
|
52
|
+
): string {
|
|
53
|
+
const lines = Object.entries(fallback)
|
|
54
|
+
.map(
|
|
55
|
+
([locale, target]) =>
|
|
56
|
+
` ${JSON.stringify(locale)}: ${target === null ? "null" : JSON.stringify(target)},`
|
|
57
|
+
)
|
|
58
|
+
.join("\n");
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
`export const ${constName} = {\n${lines}\n} as const satisfies Record<string, string | null>;\n\n` +
|
|
62
|
+
`export type ${typeName} = typeof ${constName};\n\n`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export const GENERATED_FILE_BANNER = "// Automatically generated code. Do not edit manually.\n";
|
|
4
|
+
|
|
5
|
+
export function fail(message: string): never {
|
|
6
|
+
console.error(message);
|
|
7
|
+
process.exit(1);
|
|
8
|
+
throw new Error(message);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function toImportPath(fromFile: string, toFile: string): string {
|
|
12
|
+
const relative = path.relative(path.dirname(fromFile), toFile).replace(/\\/g, "/");
|
|
13
|
+
const withoutExt = relative.replace(/\.json$/, "");
|
|
14
|
+
return withoutExt.startsWith(".") ? withoutExt : `./${withoutExt}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function toModuleBasename(filePath: string): string {
|
|
18
|
+
return path.basename(filePath).replace(/\.ts$/, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toImportIdentifier(namespace: string): string {
|
|
22
|
+
const safe = namespace.replace(/[^a-zA-Z0-9_$]/g, "_");
|
|
23
|
+
if (/^[0-9]/.test(safe)) {
|
|
24
|
+
return `ns_${safe}`;
|
|
25
|
+
}
|
|
26
|
+
return `${safe}Ns`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface CodegenConfig {
|
|
2
|
+
dictionary?: string;
|
|
3
|
+
namespaces?: Record<string, string>;
|
|
4
|
+
defaultNamespace?: string;
|
|
5
|
+
typesOutput: string;
|
|
6
|
+
dictionaryOutput: string;
|
|
7
|
+
instanceOutput: string;
|
|
8
|
+
dictionarySchemaOutput?: string;
|
|
9
|
+
loadOnInit?: string[];
|
|
10
|
+
namespaceLoadersOutput?: string;
|
|
11
|
+
paramsTypeName: string;
|
|
12
|
+
schemaTypeName: string;
|
|
13
|
+
localeTypeName?: string;
|
|
14
|
+
localeFallbackConstName?: string;
|
|
15
|
+
localeFallback?: Record<string, string | null>;
|
|
16
|
+
factoryName?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface NamespaceEntry {
|
|
20
|
+
namespace: string;
|
|
21
|
+
filePath: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type DictionaryJson = Record<string, Record<string, string>>;
|
|
25
|
+
|
|
26
|
+
export interface LoadOnInitResolution {
|
|
27
|
+
loadOnInitSet: Set<string>;
|
|
28
|
+
lazyEntries: NamespaceEntry[];
|
|
29
|
+
hasLazy: boolean;
|
|
30
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { cloneAndFreeze, deepFreeze } from "./deep-freeze.js";
|
|
3
|
+
|
|
4
|
+
describe("deepFreeze", () => {
|
|
5
|
+
it("freezes nested objects", () => {
|
|
6
|
+
const value = { welcome: { en: "Hello" } };
|
|
7
|
+
deepFreeze(value);
|
|
8
|
+
|
|
9
|
+
expect(Object.isFrozen(value)).toBe(true);
|
|
10
|
+
expect(Object.isFrozen(value.welcome)).toBe(true);
|
|
11
|
+
expect(() => {
|
|
12
|
+
value.welcome.en = "Hacked";
|
|
13
|
+
}).toThrow(TypeError);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("cloneAndFreeze", () => {
|
|
18
|
+
it("returns a deep-frozen clone", () => {
|
|
19
|
+
const source = { welcome: { en: "Hello" } };
|
|
20
|
+
const snapshot = cloneAndFreeze(source);
|
|
21
|
+
|
|
22
|
+
expect(snapshot).toEqual(source);
|
|
23
|
+
expect(snapshot).not.toBe(source);
|
|
24
|
+
expect(Object.isFrozen(snapshot)).toBe(true);
|
|
25
|
+
expect(Object.isFrozen(snapshot.welcome)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function deepFreeze<T>(value: T): T {
|
|
2
|
+
if (value === null || typeof value !== "object") {
|
|
3
|
+
return value;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
Object.freeze(value);
|
|
7
|
+
|
|
8
|
+
for (const child of Object.values(value)) {
|
|
9
|
+
deepFreeze(child);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function cloneAndFreeze<T>(value: T): T {
|
|
16
|
+
return deepFreeze(structuredClone(value));
|
|
17
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ensureNamespacesLoadedImpl } from "./ensure-namespace.js";
|
|
3
|
+
import { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
|
|
4
|
+
|
|
5
|
+
type TestSchema = {
|
|
6
|
+
default: {
|
|
7
|
+
login_button: { en: string };
|
|
8
|
+
};
|
|
9
|
+
user: {
|
|
10
|
+
greeting: { en: string };
|
|
11
|
+
};
|
|
12
|
+
billing: {
|
|
13
|
+
invoice_summary: { en: string };
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type TestParams = {
|
|
18
|
+
default: { login_button: never };
|
|
19
|
+
user: { greeting: { name: string } };
|
|
20
|
+
billing: { invoice_summary: { count: number } };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const namespaces: TestSchema = {
|
|
24
|
+
default: { login_button: { en: "Login" } },
|
|
25
|
+
user: { greeting: { en: "Hello {name}!" } },
|
|
26
|
+
billing: {
|
|
27
|
+
invoice_summary: {
|
|
28
|
+
en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("ensureNamespacesLoadedImpl integration", () => {
|
|
34
|
+
it("mirrors the lazy codegen flow: partial init, preload batch, sync get", async () => {
|
|
35
|
+
const i18n = new IcuTranslationProviderMulti<TestSchema, TestParams>({
|
|
36
|
+
default: namespaces.default,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(i18n.hasNamespace("default")).toBe(true);
|
|
40
|
+
expect(i18n.hasNamespace("user")).toBe(false);
|
|
41
|
+
expect(i18n.hasNamespace("billing")).toBe(false);
|
|
42
|
+
|
|
43
|
+
expect(i18n.get("default", "login_button", "en")).toBe("Login");
|
|
44
|
+
|
|
45
|
+
expect(() => i18n.get("billing", "invoice_summary", "en", { count: 1 })).toThrow(
|
|
46
|
+
'Namespace not loaded: "billing"'
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
await ensureNamespacesLoadedImpl(
|
|
50
|
+
{
|
|
51
|
+
provider: i18n,
|
|
52
|
+
resolveLoader: (namespace) => async () => namespaces[namespace],
|
|
53
|
+
validate: (_namespace, raw) => ({ ok: true, data: raw as TestSchema["billing"] }),
|
|
54
|
+
},
|
|
55
|
+
["user", "billing"]
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(i18n.get("user", "greeting", "en", { name: "Ada" })).toBe("Hello Ada!");
|
|
59
|
+
expect(i18n.get("billing", "invoice_summary", "en", { count: 2 })).toBe("You have 2 invoices");
|
|
60
|
+
|
|
61
|
+
i18n.setNamespace("billing", {
|
|
62
|
+
invoice_summary: { en: "{count, plural, one {1 bill} other {{count} bills}}" },
|
|
63
|
+
});
|
|
64
|
+
expect(i18n.get("billing", "invoice_summary", "en", { count: 3 })).toBe("3 bills");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ensureNamespacesLoadedImpl } from "./ensure-namespace.js";
|
|
3
|
+
import { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
|
|
4
|
+
import type { ValidationResult } from "./validation/types.js";
|
|
5
|
+
|
|
6
|
+
type TestSchema = {
|
|
7
|
+
default: {
|
|
8
|
+
hello: { en: string };
|
|
9
|
+
};
|
|
10
|
+
billing: {
|
|
11
|
+
invoice: { en: string };
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type TestParams = {
|
|
16
|
+
default: { hello: never };
|
|
17
|
+
billing: { invoice: never };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const defaultNs: TestSchema["default"] = { hello: { en: "Hello" } };
|
|
21
|
+
const billingNs: TestSchema["billing"] = { invoice: { en: "Invoice" } };
|
|
22
|
+
|
|
23
|
+
function createProvider(initial: Partial<TestSchema> = { default: defaultNs }) {
|
|
24
|
+
return new IcuTranslationProviderMulti<TestSchema, TestParams>(initial);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("ensureNamespacesLoadedImpl", () => {
|
|
28
|
+
it("is a noop for an empty namespaces array", async () => {
|
|
29
|
+
const provider = createProvider();
|
|
30
|
+
const load = vi.fn();
|
|
31
|
+
|
|
32
|
+
await ensureNamespacesLoadedImpl(
|
|
33
|
+
{
|
|
34
|
+
provider,
|
|
35
|
+
resolveLoader: () => load,
|
|
36
|
+
validate: () => ({ ok: true, data: billingNs }),
|
|
37
|
+
},
|
|
38
|
+
[]
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(load).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("is a noop when the namespace is already loaded", async () => {
|
|
45
|
+
const provider = createProvider({ default: defaultNs, billing: billingNs });
|
|
46
|
+
const load = vi.fn();
|
|
47
|
+
|
|
48
|
+
await ensureNamespacesLoadedImpl(
|
|
49
|
+
{
|
|
50
|
+
provider,
|
|
51
|
+
resolveLoader: () => load,
|
|
52
|
+
validate: () => ({ ok: true, data: billingNs }),
|
|
53
|
+
},
|
|
54
|
+
["billing"]
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(load).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("loads multiple namespaces in parallel", async () => {
|
|
61
|
+
const provider = createProvider();
|
|
62
|
+
const loadBilling = vi.fn(async () => billingNs);
|
|
63
|
+
const loadDefault = vi.fn(async () => defaultNs);
|
|
64
|
+
|
|
65
|
+
await ensureNamespacesLoadedImpl(
|
|
66
|
+
{
|
|
67
|
+
provider,
|
|
68
|
+
resolveLoader: (namespace) => (namespace === "billing" ? loadBilling : loadDefault),
|
|
69
|
+
validate: (_namespace, raw) => ({ ok: true, data: raw as TestSchema["billing"] }),
|
|
70
|
+
},
|
|
71
|
+
["billing"]
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(loadBilling).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(provider.hasNamespace("billing")).toBe(true);
|
|
76
|
+
expect(provider.get("billing", "invoice", "en")).toBe("Invoice");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("dedupes concurrent requests for the same namespace", async () => {
|
|
80
|
+
const provider = createProvider();
|
|
81
|
+
let resolveLoad!: () => void;
|
|
82
|
+
const load = vi.fn(
|
|
83
|
+
() =>
|
|
84
|
+
new Promise<TestSchema["billing"]>((resolve) => {
|
|
85
|
+
resolveLoad = () => resolve(billingNs);
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const options = {
|
|
90
|
+
provider,
|
|
91
|
+
resolveLoader: () => load,
|
|
92
|
+
validate: () => ({ ok: true, data: billingNs }),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const first = ensureNamespacesLoadedImpl(options, ["billing"]);
|
|
96
|
+
const second = ensureNamespacesLoadedImpl(options, ["billing"]);
|
|
97
|
+
|
|
98
|
+
resolveLoad();
|
|
99
|
+
await Promise.all([first, second]);
|
|
100
|
+
|
|
101
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(provider.hasNamespace("billing")).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("propagates validation failures without marking the namespace loaded", async () => {
|
|
106
|
+
const provider = createProvider();
|
|
107
|
+
const failed: ValidationResult<TestSchema["billing"]> = {
|
|
108
|
+
ok: false,
|
|
109
|
+
issues: [{ kind: "invalid_input", message: "bad data" }],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
await expect(
|
|
113
|
+
ensureNamespacesLoadedImpl(
|
|
114
|
+
{
|
|
115
|
+
provider,
|
|
116
|
+
resolveLoader: () => async () => ({ bad: true }),
|
|
117
|
+
validate: () => failed,
|
|
118
|
+
},
|
|
119
|
+
["billing"]
|
|
120
|
+
)
|
|
121
|
+
).rejects.toThrow("bad data");
|
|
122
|
+
|
|
123
|
+
expect(provider.hasNamespace("billing")).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { MultiDictionary } from "./types.js";
|
|
2
|
+
import type { ValidationResult } from "./validation/types.js";
|
|
3
|
+
import { DictionaryValidationError } from "./validation/errors.js";
|
|
4
|
+
|
|
5
|
+
export interface EnsureNamespacesLoadedOptions<
|
|
6
|
+
Schema extends MultiDictionary,
|
|
7
|
+
NS extends keyof Schema & string,
|
|
8
|
+
> {
|
|
9
|
+
provider: Pick<
|
|
10
|
+
{ hasNamespace(namespace: NS): boolean; setNamespace(namespace: NS, values: Schema[NS]): void },
|
|
11
|
+
"hasNamespace" | "setNamespace"
|
|
12
|
+
>;
|
|
13
|
+
resolveLoader: (namespace: NS) => () => Promise<unknown>;
|
|
14
|
+
validate: (namespace: NS, raw: unknown) => ValidationResult<Schema[NS]>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const inFlightByProvider = new WeakMap<object, Map<string, Promise<void>>>();
|
|
18
|
+
|
|
19
|
+
async function ensureOneNamespace<Schema extends MultiDictionary, NS extends keyof Schema & string>(
|
|
20
|
+
options: EnsureNamespacesLoadedOptions<Schema, NS>,
|
|
21
|
+
namespace: NS
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
if (options.provider.hasNamespace(namespace)) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const providerKey = options.provider as object;
|
|
28
|
+
let inFlight = inFlightByProvider.get(providerKey);
|
|
29
|
+
if (!inFlight) {
|
|
30
|
+
inFlight = new Map();
|
|
31
|
+
inFlightByProvider.set(providerKey, inFlight);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const existing = inFlight.get(namespace);
|
|
35
|
+
if (existing) {
|
|
36
|
+
return existing;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const promise = (async () => {
|
|
40
|
+
try {
|
|
41
|
+
if (options.provider.hasNamespace(namespace)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const raw = await options.resolveLoader(namespace)();
|
|
46
|
+
const result = options.validate(namespace, raw);
|
|
47
|
+
if (!result.ok) {
|
|
48
|
+
throw new DictionaryValidationError(result.issues);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
options.provider.setNamespace(namespace, result.data);
|
|
52
|
+
} finally {
|
|
53
|
+
inFlight!.delete(namespace);
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
|
|
57
|
+
inFlight.set(namespace, promise);
|
|
58
|
+
return promise;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function ensureNamespacesLoadedImpl<
|
|
62
|
+
Schema extends MultiDictionary,
|
|
63
|
+
NS extends keyof Schema & string,
|
|
64
|
+
>(options: EnsureNamespacesLoadedOptions<Schema, NS>, namespaces: NS[]): Promise<void> {
|
|
65
|
+
if (namespaces.length === 0) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await Promise.all(namespaces.map((namespace) => ensureOneNamespace(options, namespace)));
|
|
70
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { parse } from "@formatjs/icu-messageformat-parser";
|
|
2
|
+
|
|
3
|
+
export type VariableType = "string" | "number" | "date";
|
|
4
|
+
export type VariableSpec = Record<string, VariableType>;
|
|
5
|
+
|
|
6
|
+
type IcuAst = ReturnType<typeof parse>;
|
|
7
|
+
type IcuNode = IcuAst[number];
|
|
8
|
+
|
|
9
|
+
function isNumericIcuNode(node: IcuNode): boolean {
|
|
10
|
+
if (node.type === 2) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (node.type === 6 && "pluralType" in node && node.pluralType) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function hasVariableName(node: IcuNode): node is Extract<IcuNode, { value: string }> {
|
|
22
|
+
return "value" in node && typeof node.value === "string";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function extractVariables(nodes: IcuAst): VariableSpec {
|
|
26
|
+
const variables: VariableSpec = {};
|
|
27
|
+
|
|
28
|
+
const walk = (walkNodes: IcuAst) => {
|
|
29
|
+
for (const node of walkNodes) {
|
|
30
|
+
if (node.type === 1) {
|
|
31
|
+
variables[node.value] = variables[node.value] ?? "string";
|
|
32
|
+
} else if (isNumericIcuNode(node) && hasVariableName(node)) {
|
|
33
|
+
variables[node.value] = "number";
|
|
34
|
+
} else if ((node.type === 3 || node.type === 4) && hasVariableName(node)) {
|
|
35
|
+
variables[node.value] = "date";
|
|
36
|
+
} else if (node.type === 5 && hasVariableName(node)) {
|
|
37
|
+
variables[node.value] = variables[node.value] ?? "string";
|
|
38
|
+
} else if (node.type === 6 && hasVariableName(node)) {
|
|
39
|
+
variables[node.value] = variables[node.value] ?? "string";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if ("options" in node && node.options) {
|
|
43
|
+
for (const option of Object.values(node.options)) {
|
|
44
|
+
walk(option.value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if ("children" in node && Array.isArray(node.children)) {
|
|
49
|
+
walk(node.children);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
walk(nodes);
|
|
55
|
+
return variables;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function mergeVariableSpecs(target: VariableSpec, source: VariableSpec): VariableSpec {
|
|
59
|
+
const merged = { ...target };
|
|
60
|
+
|
|
61
|
+
for (const [varName, varType] of Object.entries(source)) {
|
|
62
|
+
const existing = merged[varName];
|
|
63
|
+
if (existing && existing !== varType) {
|
|
64
|
+
merged[varName] = "number";
|
|
65
|
+
} else {
|
|
66
|
+
merged[varName] = varType;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return merged;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function variableSpecsEqual(a: VariableSpec, b: VariableSpec): boolean {
|
|
74
|
+
const aKeys = Object.keys(a).sort();
|
|
75
|
+
const bKeys = Object.keys(b).sort();
|
|
76
|
+
|
|
77
|
+
if (aKeys.length !== bKeys.length) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return aKeys.every((key, index) => {
|
|
82
|
+
if (key !== bKeys[index]) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return a[key] === b[key];
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { parse } from "@formatjs/icu-messageformat-parser";
|
|
2
|
+
import { extractVariables, type VariableSpec } from "./extract-variables.js";
|
|
3
|
+
|
|
4
|
+
export type ParseTemplateSuccess = {
|
|
5
|
+
readonly ok: true;
|
|
6
|
+
readonly args: VariableSpec;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type ParseTemplateFailure = {
|
|
10
|
+
readonly ok: false;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type ParseTemplateResult = ParseTemplateSuccess | ParseTemplateFailure;
|
|
15
|
+
|
|
16
|
+
export function parseTemplate(template: string): ParseTemplateResult {
|
|
17
|
+
try {
|
|
18
|
+
const ast = parse(template);
|
|
19
|
+
return { ok: true, args: extractVariables(ast) };
|
|
20
|
+
} catch (error) {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
return { ok: false, message };
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
TranslationProviderSingle,
|
|
3
|
+
TranslationProviderSingleForLocale,
|
|
4
|
+
} from "./IcuTranslationProviderSingle.js";
|
|
5
|
+
export { IcuTranslationProviderSingle } from "./IcuTranslationProviderSingle.js";
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
TranslationProviderMulti,
|
|
9
|
+
TranslationProviderMultiForLocale,
|
|
10
|
+
} from "./IcuTranslationProviderMulti.js";
|
|
11
|
+
export { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
IcuTranslationProviderOptions,
|
|
15
|
+
KeyDictionary,
|
|
16
|
+
LocaleDictionary,
|
|
17
|
+
LocaleFallbackMap,
|
|
18
|
+
LocaleOfMulti,
|
|
19
|
+
LocaleOfSingle,
|
|
20
|
+
MultiDictionary,
|
|
21
|
+
RequestLocale,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
formatLocaleFallbackChain,
|
|
26
|
+
resolveLocaleTemplate,
|
|
27
|
+
validateLocaleFallback,
|
|
28
|
+
} from "./resolve-locale.js";
|
|
29
|
+
export type { ResolvedLocaleTemplate } from "./resolve-locale.js";
|
|
30
|
+
|
|
31
|
+
export { ensureNamespacesLoadedImpl } from "./ensure-namespace.js";
|
|
32
|
+
export type { EnsureNamespacesLoadedOptions } from "./ensure-namespace.js";
|