@xndrjs/i18n 0.7.0 → 0.8.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 +67 -872
- package/dist/codegen/index.d.ts +117 -51
- package/dist/codegen/index.js +1585 -93
- package/dist/codegen/index.js.map +1 -1
- package/dist/index.d.ts +100 -234
- package/dist/index.js +74 -638
- package/dist/index.js.map +1 -1
- package/dist/validation/index.d.ts +5 -0
- package/dist/validation/index.js.map +1 -1
- package/package.json +2 -2
- package/src/IcuTranslationProviderMulti.test.ts +18 -40
- package/src/IcuTranslationProviderMulti.ts +27 -37
- package/src/audit/audit-dictionaries.ts +1 -1
- package/src/audit/run-audit.test.ts +6 -7
- package/src/builder-load-registry.ts +26 -5
- package/src/builder-loaders.ts +8 -11
- package/src/builder-types.test.ts +11 -42
- package/src/builder.test.ts +82 -243
- package/src/builder.ts +8 -110
- package/src/codegen/codegen-config-schema.ts +82 -77
- package/src/codegen/config.test.ts +55 -143
- package/src/codegen/config.ts +10 -67
- package/src/codegen/dictionary-spec-contract.test.ts +28 -0
- package/src/codegen/dictionary-spec-contract.ts +138 -0
- package/src/codegen/emit/dictionary-schema-file.ts +7 -43
- package/src/codegen/emit/instance-file.test.ts +35 -52
- package/src/codegen/emit/instance-file.ts +170 -268
- package/src/codegen/emit/namespace-loaders-file.test.ts +72 -74
- package/src/codegen/emit/namespace-loaders-file.ts +147 -90
- package/src/codegen/emit/types-file.test.ts +20 -41
- package/src/codegen/emit/types-file.ts +48 -74
- package/src/codegen/generate-i18n-types.test.ts +163 -492
- package/src/codegen/generate-i18n-types.ts +7 -269
- package/src/codegen/paths.ts +0 -14
- package/src/codegen/project-locales-set-namespace.test.ts +44 -86
- package/src/codegen/read-dictionary.test.ts +27 -9
- package/src/codegen/read-dictionary.ts +18 -40
- package/src/codegen/regenerate-namespaces.ts +180 -0
- package/src/codegen/run-codegen.test.ts +230 -0
- package/src/codegen/run-codegen.ts +252 -0
- package/src/codegen/types.ts +0 -6
- package/src/codegen-config/build-config.ts +4 -23
- package/src/codegen-config/codegen-config.test.ts +10 -6
- package/src/codegen-config/index.ts +18 -3
- package/src/engine.ts +3 -27
- package/src/fetch-artifact.ts +16 -0
- package/src/i18n-handle.ts +202 -0
- package/src/index.ts +15 -34
- package/src/project-locales.ts +2 -2
- package/src/scope-multi.ts +1 -20
- package/src/scope-types.ts +14 -6
- package/src/scope.test.ts +3 -312
- package/src/serialized-state.test.ts +114 -0
- package/src/serialized-state.ts +28 -0
- package/src/setup/setup-i18n.test.ts +22 -33
- package/src/setup/setup-i18n.ts +22 -27
- package/src/types.ts +3 -3
- package/src/validation/index.ts +4 -0
- package/src/IcuTranslationProviderSingle.test.ts +0 -177
- package/src/IcuTranslationProviderSingle.ts +0 -114
- package/src/builder-multi.ts +0 -481
- package/src/codegen/emit/dictionary-file.test.ts +0 -215
- package/src/codegen/emit/dictionary-file.ts +0 -244
- package/src/deep-freeze.test.ts +0 -40
- package/src/deep-freeze.ts +0 -22
- package/src/patch-key.test.ts +0 -186
- package/src/patch-key.ts +0 -140
- package/src/scope-single.ts +0 -85
- package/src/single-builder.ts +0 -153
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import {
|
|
3
|
-
GENERATED_FILE_BANNER,
|
|
4
|
-
toImportIdentifier,
|
|
5
|
-
toImportPath,
|
|
6
|
-
toLocaleObjectKey,
|
|
7
|
-
toModuleBasename,
|
|
8
|
-
toRelativeModuleImport,
|
|
9
|
-
} from "../paths.js";
|
|
10
|
-
import type { DeliveryMode } from "../codegen-config-schema.js";
|
|
11
|
-
import type { ImportExtension, NamespaceEntry } from "../types.js";
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Emits the generated `dictionary.generated.ts` module: eager translation imports
|
|
15
|
-
* wired into `defaultDictionary` or `defaultDictionaryFor`, depending on delivery mode.
|
|
16
|
-
*/
|
|
17
|
-
export interface DictionaryFileOptions {
|
|
18
|
-
isSingle: boolean;
|
|
19
|
-
hasLazy: boolean;
|
|
20
|
-
entries: NamespaceEntry[];
|
|
21
|
-
eagerEntries: NamespaceEntry[];
|
|
22
|
-
projectRoot: string;
|
|
23
|
-
dictionaryOutputPath: string;
|
|
24
|
-
typesOutputPath: string;
|
|
25
|
-
schemaTypeName: string;
|
|
26
|
-
localeTypeName: string;
|
|
27
|
-
importExtension: ImportExtension;
|
|
28
|
-
delivery?: DeliveryMode;
|
|
29
|
-
splitPathsByNamespace?: Record<string, Record<string, string>>;
|
|
30
|
-
requestLocales?: readonly string[];
|
|
31
|
-
deliveryAreaTypeName?: string;
|
|
32
|
-
deliveryAreaNames?: readonly string[];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function toArtifactIdentifierSuffix(artifactKey: string): string {
|
|
36
|
-
return artifactKey
|
|
37
|
-
.split(/[^a-zA-Z0-9]+/)
|
|
38
|
-
.filter(Boolean)
|
|
39
|
-
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
40
|
-
.join("");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function toSplitImportIdentifier(namespace: string, artifactKey: string): string {
|
|
44
|
-
const namespaceId = toImportIdentifier(namespace).replace(/Ns$/, "");
|
|
45
|
-
return `${namespaceId}${toArtifactIdentifierSuffix(artifactKey)}`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function toNamespaceBaseIdentifier(namespace: string): string {
|
|
49
|
-
return toImportIdentifier(namespace).replace(/Ns$/, "");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
interface PartitionedDictionaryParams {
|
|
53
|
-
partitionKeys: readonly string[];
|
|
54
|
-
paramName: string;
|
|
55
|
-
paramTypeName: string;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function toPartitionSuffix(paramName: string): string {
|
|
59
|
-
return `By${paramName.charAt(0).toUpperCase()}${paramName.slice(1)}`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Emits dictionary source for split-by-locale and custom delivery.
|
|
64
|
-
* Imports one JSON file per namespace × partition key (locale or area) and
|
|
65
|
-
* exposes `defaultDictionaryFor(partition)` that selects the matching slice.
|
|
66
|
-
*/
|
|
67
|
-
function formatPartitionedDictionaryFile(
|
|
68
|
-
options: DictionaryFileOptions,
|
|
69
|
-
{ partitionKeys, paramName, paramTypeName }: PartitionedDictionaryParams
|
|
70
|
-
): string | null {
|
|
71
|
-
const {
|
|
72
|
-
isSingle,
|
|
73
|
-
hasLazy,
|
|
74
|
-
eagerEntries,
|
|
75
|
-
projectRoot,
|
|
76
|
-
dictionaryOutputPath,
|
|
77
|
-
typesOutputPath,
|
|
78
|
-
schemaTypeName,
|
|
79
|
-
importExtension,
|
|
80
|
-
splitPathsByNamespace = {},
|
|
81
|
-
} = options;
|
|
82
|
-
|
|
83
|
-
const partitionSuffix = toPartitionSuffix(paramName);
|
|
84
|
-
const typesModule = toModuleBasename(typesOutputPath);
|
|
85
|
-
const typesImport = toRelativeModuleImport(typesModule, importExtension);
|
|
86
|
-
const dictionaryTypeName = hasLazy ? "InitialSchema" : schemaTypeName;
|
|
87
|
-
const schemaTypeImport = hasLazy ? `, ${schemaTypeName}` : "";
|
|
88
|
-
|
|
89
|
-
if (eagerEntries.length === 0) {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const imports = eagerEntries
|
|
94
|
-
.flatMap((entry) =>
|
|
95
|
-
partitionKeys.map((partitionKey) => {
|
|
96
|
-
const splitRelativePath = splitPathsByNamespace[entry.namespace]?.[partitionKey];
|
|
97
|
-
if (!splitRelativePath) {
|
|
98
|
-
throw new Error(
|
|
99
|
-
`[Codegen Error] Missing split path for namespace "${entry.namespace}", ${paramName} "${partitionKey}".`
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
const importPath = toImportPath(
|
|
103
|
-
dictionaryOutputPath,
|
|
104
|
-
path.resolve(projectRoot, splitRelativePath)
|
|
105
|
-
);
|
|
106
|
-
const importId = toSplitImportIdentifier(entry.namespace, partitionKey);
|
|
107
|
-
return `import ${importId} from '${importPath}.json';`;
|
|
108
|
-
})
|
|
109
|
-
)
|
|
110
|
-
.join("\n");
|
|
111
|
-
|
|
112
|
-
if (isSingle) {
|
|
113
|
-
const entry = eagerEntries[0]!;
|
|
114
|
-
const baseId = toNamespaceBaseIdentifier(entry.namespace);
|
|
115
|
-
const partitionEntries = partitionKeys
|
|
116
|
-
.map(
|
|
117
|
-
(partitionKey) =>
|
|
118
|
-
` ${toLocaleObjectKey(partitionKey)}: ${toSplitImportIdentifier(entry.namespace, partitionKey)},`
|
|
119
|
-
)
|
|
120
|
-
.join("\n");
|
|
121
|
-
|
|
122
|
-
return (
|
|
123
|
-
`${GENERATED_FILE_BANNER}` +
|
|
124
|
-
`${imports}\n` +
|
|
125
|
-
`import type { ${schemaTypeName}, ${paramTypeName} } from '${typesImport}';\n\n` +
|
|
126
|
-
`const ${baseId}${partitionSuffix} = {\n${partitionEntries}\n} as const;\n\n` +
|
|
127
|
-
`export function defaultDictionaryFor(${paramName}: ${paramTypeName}): ${schemaTypeName} {\n` +
|
|
128
|
-
` return ${baseId}${partitionSuffix}[${paramName}];\n` +
|
|
129
|
-
`}\n`
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const byPartitionBlocks = eagerEntries
|
|
134
|
-
.map((entry) => {
|
|
135
|
-
const baseId = toNamespaceBaseIdentifier(entry.namespace);
|
|
136
|
-
const partitionEntries = partitionKeys
|
|
137
|
-
.map(
|
|
138
|
-
(partitionKey) =>
|
|
139
|
-
` ${toLocaleObjectKey(partitionKey)}: ${toSplitImportIdentifier(entry.namespace, partitionKey)},`
|
|
140
|
-
)
|
|
141
|
-
.join("\n");
|
|
142
|
-
return `const ${baseId}${partitionSuffix} = {\n${partitionEntries}\n} as const;`;
|
|
143
|
-
})
|
|
144
|
-
.join("\n\n");
|
|
145
|
-
|
|
146
|
-
const objectEntries = eagerEntries
|
|
147
|
-
.map((entry) => {
|
|
148
|
-
const baseId = toNamespaceBaseIdentifier(entry.namespace);
|
|
149
|
-
return ` ${entry.namespace}: ${baseId}${partitionSuffix}[${paramName}],`;
|
|
150
|
-
})
|
|
151
|
-
.join("\n");
|
|
152
|
-
|
|
153
|
-
const importsBlock = imports.length > 0 ? `${imports}\n` : "";
|
|
154
|
-
const byPartitionBlock = byPartitionBlocks.length > 0 ? `${byPartitionBlocks}\n\n` : "";
|
|
155
|
-
|
|
156
|
-
return (
|
|
157
|
-
`${GENERATED_FILE_BANNER}` +
|
|
158
|
-
`${importsBlock}` +
|
|
159
|
-
`import type { ${dictionaryTypeName}, ${paramTypeName}${schemaTypeImport} } from '${typesImport}';\n\n` +
|
|
160
|
-
`${byPartitionBlock}` +
|
|
161
|
-
`export function defaultDictionaryFor(${paramName}: ${paramTypeName}): ${dictionaryTypeName} {\n` +
|
|
162
|
-
` return {\n${objectEntries}\n };\n` +
|
|
163
|
-
`}\n`
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Builds `dictionary.generated.ts` for the configured delivery mode:
|
|
169
|
-
* canonical (single bundled JSON per namespace), split-by-locale, or custom areas.
|
|
170
|
-
*/
|
|
171
|
-
export function formatDictionaryFile(options: DictionaryFileOptions): string | null {
|
|
172
|
-
const delivery = options.delivery ?? "canonical";
|
|
173
|
-
if (delivery === "split-by-locale") {
|
|
174
|
-
return formatPartitionedDictionaryFile(options, {
|
|
175
|
-
partitionKeys: options.requestLocales ?? [],
|
|
176
|
-
paramName: "locale",
|
|
177
|
-
paramTypeName: options.localeTypeName,
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
if (delivery === "custom") {
|
|
181
|
-
if (!options.deliveryAreaTypeName) {
|
|
182
|
-
throw new Error("[Codegen Error] deliveryAreaTypeName is required for custom delivery.");
|
|
183
|
-
}
|
|
184
|
-
return formatPartitionedDictionaryFile(options, {
|
|
185
|
-
partitionKeys: options.deliveryAreaNames ?? [],
|
|
186
|
-
paramName: "area",
|
|
187
|
-
paramTypeName: options.deliveryAreaTypeName,
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const {
|
|
192
|
-
isSingle,
|
|
193
|
-
hasLazy,
|
|
194
|
-
entries,
|
|
195
|
-
eagerEntries,
|
|
196
|
-
projectRoot,
|
|
197
|
-
dictionaryOutputPath,
|
|
198
|
-
typesOutputPath,
|
|
199
|
-
schemaTypeName,
|
|
200
|
-
importExtension,
|
|
201
|
-
} = options;
|
|
202
|
-
|
|
203
|
-
const typesModule = toModuleBasename(typesOutputPath);
|
|
204
|
-
const typesImport = toRelativeModuleImport(typesModule, importExtension);
|
|
205
|
-
|
|
206
|
-
if (isSingle) {
|
|
207
|
-
const entry = entries[0]!;
|
|
208
|
-
const importPath = toImportPath(
|
|
209
|
-
dictionaryOutputPath,
|
|
210
|
-
path.resolve(projectRoot, entry.filePath)
|
|
211
|
-
);
|
|
212
|
-
const importId = toImportIdentifier(entry.namespace);
|
|
213
|
-
|
|
214
|
-
return (
|
|
215
|
-
`${GENERATED_FILE_BANNER}` +
|
|
216
|
-
`import ${importId} from '${importPath}.json';\n` +
|
|
217
|
-
`import type { ${schemaTypeName} } from '${typesImport}';\n\n` +
|
|
218
|
-
`export const defaultDictionary: ${schemaTypeName} = ${importId};\n`
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const dictionaryTypeName = hasLazy ? "InitialSchema" : schemaTypeName;
|
|
223
|
-
|
|
224
|
-
const imports = eagerEntries
|
|
225
|
-
.map((entry) => {
|
|
226
|
-
const importPath = toImportPath(
|
|
227
|
-
dictionaryOutputPath,
|
|
228
|
-
path.resolve(projectRoot, entry.filePath)
|
|
229
|
-
);
|
|
230
|
-
return `import ${toImportIdentifier(entry.namespace)} from '${importPath}.json';`;
|
|
231
|
-
})
|
|
232
|
-
.join("\n");
|
|
233
|
-
|
|
234
|
-
const objectEntries = eagerEntries
|
|
235
|
-
.map((entry) => ` ${entry.namespace}: ${toImportIdentifier(entry.namespace)},`)
|
|
236
|
-
.join("\n");
|
|
237
|
-
|
|
238
|
-
return (
|
|
239
|
-
`${GENERATED_FILE_BANNER}` +
|
|
240
|
-
`${imports}\n` +
|
|
241
|
-
`import type { ${dictionaryTypeName} } from '${typesImport}';\n\n` +
|
|
242
|
-
`export const defaultDictionary: ${dictionaryTypeName} = {\n${objectEntries}\n};\n`
|
|
243
|
-
);
|
|
244
|
-
}
|
package/src/deep-freeze.test.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
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
|
-
it("does not recurse forever on cyclic structures", () => {
|
|
17
|
-
const value: { name: string; self?: unknown; child: { parent?: unknown } } = {
|
|
18
|
-
name: "root",
|
|
19
|
-
child: {},
|
|
20
|
-
};
|
|
21
|
-
value.self = value;
|
|
22
|
-
value.child.parent = value;
|
|
23
|
-
|
|
24
|
-
expect(() => deepFreeze(value)).not.toThrow();
|
|
25
|
-
expect(Object.isFrozen(value)).toBe(true);
|
|
26
|
-
expect(Object.isFrozen(value.child)).toBe(true);
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe("cloneAndFreeze", () => {
|
|
31
|
-
it("returns a deep-frozen clone", () => {
|
|
32
|
-
const source = { welcome: { en: "Hello" } };
|
|
33
|
-
const snapshot = cloneAndFreeze(source);
|
|
34
|
-
|
|
35
|
-
expect(snapshot).toEqual(source);
|
|
36
|
-
expect(snapshot).not.toBe(source);
|
|
37
|
-
expect(Object.isFrozen(snapshot)).toBe(true);
|
|
38
|
-
expect(Object.isFrozen(snapshot.welcome)).toBe(true);
|
|
39
|
-
});
|
|
40
|
-
});
|
package/src/deep-freeze.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
export function deepFreeze<T>(value: T): T {
|
|
2
|
-
if (value === null || typeof value !== "object") {
|
|
3
|
-
return value;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
// Guards against infinite recursion on cyclic structures.
|
|
7
|
-
if (Object.isFrozen(value)) {
|
|
8
|
-
return value;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
Object.freeze(value);
|
|
12
|
-
|
|
13
|
-
for (const child of Object.values(value)) {
|
|
14
|
-
deepFreeze(child);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return value;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function cloneAndFreeze<T>(value: T): T {
|
|
21
|
-
return deepFreeze(structuredClone(value));
|
|
22
|
-
}
|
package/src/patch-key.test.ts
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
assertPatchKeyMulti,
|
|
4
|
-
assertPatchKeySingle,
|
|
5
|
-
preloadKeyMulti,
|
|
6
|
-
preloadKeySingle,
|
|
7
|
-
recordPreloadedKeysMulti,
|
|
8
|
-
recordPreloadedKeysSingle,
|
|
9
|
-
seedPreloadedKeysMulti,
|
|
10
|
-
seedPreloadedKeysSingle,
|
|
11
|
-
} from "./patch-key.js";
|
|
12
|
-
|
|
13
|
-
describe("preload key helpers", () => {
|
|
14
|
-
it("formats single preload keys as key:locale", () => {
|
|
15
|
-
expect(preloadKeySingle("welcome", "en")).toBe("welcome:en");
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("formats multi preload keys as namespace:key:locale", () => {
|
|
19
|
-
expect(preloadKeyMulti("billing", "invoice_summary", "it")).toBe("billing:invoice_summary:it");
|
|
20
|
-
});
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
describe("seedPreloadedKeysSingle", () => {
|
|
24
|
-
it("seeds every key/locale pair from the initial dictionary", () => {
|
|
25
|
-
const preloadedKeys = new Set<string>();
|
|
26
|
-
seedPreloadedKeysSingle(
|
|
27
|
-
{
|
|
28
|
-
login_button: { en: "Login", it: "Accedi" },
|
|
29
|
-
welcome: { en: "Welcome {name}!" },
|
|
30
|
-
},
|
|
31
|
-
preloadedKeys
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
expect(preloadedKeys).toEqual(new Set(["login_button:en", "login_button:it", "welcome:en"]));
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
describe("seedPreloadedKeysMulti", () => {
|
|
39
|
-
it("seeds every namespace/key/locale triple from the initial dictionary", () => {
|
|
40
|
-
const preloadedKeys = new Set<string>();
|
|
41
|
-
seedPreloadedKeysMulti(
|
|
42
|
-
{
|
|
43
|
-
default: { login_button: { en: "Login" } },
|
|
44
|
-
billing: { invoice_summary: { en: "One", it: "Uno" } },
|
|
45
|
-
},
|
|
46
|
-
preloadedKeys
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
expect(preloadedKeys).toEqual(
|
|
50
|
-
new Set([
|
|
51
|
-
"default:login_button:en",
|
|
52
|
-
"billing:invoice_summary:en",
|
|
53
|
-
"billing:invoice_summary:it",
|
|
54
|
-
])
|
|
55
|
-
);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe("recordPreloadedKeysSingle", () => {
|
|
60
|
-
it("records locales from a load merge payload", () => {
|
|
61
|
-
const preloadedKeys = new Set<string>();
|
|
62
|
-
recordPreloadedKeysSingle(
|
|
63
|
-
{
|
|
64
|
-
welcome: { it: "Benvenuto {name}!" },
|
|
65
|
-
},
|
|
66
|
-
preloadedKeys
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
expect(preloadedKeys).toEqual(new Set(["welcome:it"]));
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
describe("recordPreloadedKeysMulti", () => {
|
|
74
|
-
it("records locales from a namespace load merge payload", () => {
|
|
75
|
-
const preloadedKeys = new Set<string>();
|
|
76
|
-
recordPreloadedKeysMulti(
|
|
77
|
-
"billing",
|
|
78
|
-
{
|
|
79
|
-
invoice_summary: { it: "Hai {count} fatture" },
|
|
80
|
-
},
|
|
81
|
-
preloadedKeys
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
expect(preloadedKeys).toEqual(new Set(["billing:invoice_summary:it"]));
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
describe("assertPatchKeySingle", () => {
|
|
89
|
-
const preloadedKeys = new Set(["welcome:en", "welcome:it", "invoice_count:en"]);
|
|
90
|
-
|
|
91
|
-
it("accepts a valid patch for a preloaded key", () => {
|
|
92
|
-
expect(() =>
|
|
93
|
-
assertPatchKeySingle("welcome", "en", "Hello {name}!", preloadedKeys, {
|
|
94
|
-
en: "Welcome {name}!",
|
|
95
|
-
it: "Benvenuto {name}!",
|
|
96
|
-
})
|
|
97
|
-
).not.toThrow();
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("rejects a key that was not preloaded", () => {
|
|
101
|
-
expect(() =>
|
|
102
|
-
assertPatchKeySingle("welcome", "fr", "Bonjour {name}!", preloadedKeys, {
|
|
103
|
-
en: "Welcome {name}!",
|
|
104
|
-
})
|
|
105
|
-
).toThrow("[i18n] Key not preloaded: welcome (fr)");
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("rejects invalid ICU syntax", () => {
|
|
109
|
-
expect(() =>
|
|
110
|
-
assertPatchKeySingle("welcome", "en", "Hi {name", preloadedKeys, {
|
|
111
|
-
en: "Welcome {name}!",
|
|
112
|
-
})
|
|
113
|
-
).toThrow("[i18n] ICU syntax error on patch:");
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("rejects incompatible ICU args against other locales for the same key", () => {
|
|
117
|
-
expect(() =>
|
|
118
|
-
assertPatchKeySingle(
|
|
119
|
-
"invoice_count",
|
|
120
|
-
"en",
|
|
121
|
-
"{count, select, other {{count}}}",
|
|
122
|
-
preloadedKeys,
|
|
123
|
-
{
|
|
124
|
-
en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
|
|
125
|
-
it: "Hai {count, plural, one {1 fattura} other {{count} fatture}}",
|
|
126
|
-
}
|
|
127
|
-
)
|
|
128
|
-
).toThrow("[i18n] ICU args mismatch on patch:");
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
describe("assertPatchKeyMulti", () => {
|
|
133
|
-
const preloadedKeys = new Set([
|
|
134
|
-
"billing:invoice_summary:en",
|
|
135
|
-
"billing:invoice_summary:it",
|
|
136
|
-
"default:login_button:en",
|
|
137
|
-
]);
|
|
138
|
-
|
|
139
|
-
it("accepts a valid patch for a preloaded namespace key", () => {
|
|
140
|
-
expect(() =>
|
|
141
|
-
assertPatchKeyMulti(
|
|
142
|
-
"billing",
|
|
143
|
-
"invoice_summary",
|
|
144
|
-
"en",
|
|
145
|
-
"You have {count, plural, one {1 bill} other {{count} bills}} for {name}",
|
|
146
|
-
preloadedKeys,
|
|
147
|
-
{
|
|
148
|
-
en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
|
|
149
|
-
it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
|
|
150
|
-
}
|
|
151
|
-
)
|
|
152
|
-
).not.toThrow();
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("rejects a namespace key that was not preloaded", () => {
|
|
156
|
-
expect(() =>
|
|
157
|
-
assertPatchKeyMulti("billing", "invoice_summary", "fr", "Facture {count}", preloadedKeys, {
|
|
158
|
-
en: "You have {count} invoices",
|
|
159
|
-
})
|
|
160
|
-
).toThrow("[i18n] Key not preloaded: billing.invoice_summary (fr)");
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("rejects invalid ICU syntax", () => {
|
|
164
|
-
expect(() =>
|
|
165
|
-
assertPatchKeyMulti("default", "login_button", "en", "Sign {in", preloadedKeys, {
|
|
166
|
-
en: "Login",
|
|
167
|
-
})
|
|
168
|
-
).toThrow("[i18n] ICU syntax error on patch:");
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("rejects incompatible ICU args against other locales for the same key", () => {
|
|
172
|
-
expect(() =>
|
|
173
|
-
assertPatchKeyMulti(
|
|
174
|
-
"billing",
|
|
175
|
-
"invoice_summary",
|
|
176
|
-
"en",
|
|
177
|
-
"{count, select, other {{count}}}",
|
|
178
|
-
preloadedKeys,
|
|
179
|
-
{
|
|
180
|
-
en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
|
|
181
|
-
it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
|
|
182
|
-
}
|
|
183
|
-
)
|
|
184
|
-
).toThrow("[i18n] ICU args mismatch on patch:");
|
|
185
|
-
});
|
|
186
|
-
});
|
package/src/patch-key.ts
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { mergeVariableMetaAcrossLocales, type VariableMetaSpec } from "./icu/extract-variables.js";
|
|
2
|
-
import { parseTemplate } from "./icu/parse-template.js";
|
|
3
|
-
import type { KeyDictionary, PartialKeyDictionary } from "./types.js";
|
|
4
|
-
|
|
5
|
-
export function preloadKeySingle(key: string, locale: string): string {
|
|
6
|
-
return `${key}:${locale}`;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function preloadKeyMulti(namespace: string, key: string, locale: string): string {
|
|
10
|
-
return `${namespace}:${key}:${locale}`;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function seedPreloadedKeysSingle(
|
|
14
|
-
dictionary: KeyDictionary,
|
|
15
|
-
preloadedKeys: Set<string>
|
|
16
|
-
): void {
|
|
17
|
-
for (const [key, localeByKey] of Object.entries(dictionary)) {
|
|
18
|
-
if (localeByKey === undefined || typeof localeByKey !== "object") {
|
|
19
|
-
continue;
|
|
20
|
-
}
|
|
21
|
-
for (const locale of Object.keys(localeByKey)) {
|
|
22
|
-
preloadedKeys.add(preloadKeySingle(key, locale));
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function seedPreloadedKeysMulti(
|
|
28
|
-
dictionary: Record<string, KeyDictionary>,
|
|
29
|
-
preloadedKeys: Set<string>
|
|
30
|
-
): void {
|
|
31
|
-
for (const [namespace, keyDictionary] of Object.entries(dictionary)) {
|
|
32
|
-
if (keyDictionary === undefined || typeof keyDictionary !== "object") {
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
for (const [key, localeByKey] of Object.entries(keyDictionary)) {
|
|
36
|
-
if (localeByKey === undefined || typeof localeByKey !== "object") {
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
for (const locale of Object.keys(localeByKey)) {
|
|
40
|
-
preloadedKeys.add(preloadKeyMulti(namespace, key, locale));
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function recordPreloadedKeysSingle(
|
|
47
|
-
partial: PartialKeyDictionary<KeyDictionary, string>,
|
|
48
|
-
preloadedKeys: Set<string>
|
|
49
|
-
): void {
|
|
50
|
-
for (const [key, incomingLocales] of Object.entries(partial)) {
|
|
51
|
-
if (incomingLocales === undefined || typeof incomingLocales !== "object") {
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
for (const locale of Object.keys(incomingLocales)) {
|
|
55
|
-
preloadedKeys.add(preloadKeySingle(key, locale));
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function recordPreloadedKeysMulti(
|
|
61
|
-
namespace: string,
|
|
62
|
-
partial: PartialKeyDictionary<KeyDictionary, string>,
|
|
63
|
-
preloadedKeys: Set<string>
|
|
64
|
-
): void {
|
|
65
|
-
for (const [key, incomingLocales] of Object.entries(partial)) {
|
|
66
|
-
if (incomingLocales === undefined || typeof incomingLocales !== "object") {
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
for (const locale of Object.keys(incomingLocales)) {
|
|
70
|
-
preloadedKeys.add(preloadKeyMulti(namespace, key, locale));
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function collectLocaleMetasForKey(
|
|
76
|
-
localeByKey: Record<string, string>,
|
|
77
|
-
patchedLocale: string,
|
|
78
|
-
patchedMeta: VariableMetaSpec
|
|
79
|
-
): VariableMetaSpec[] {
|
|
80
|
-
const localeMetas: VariableMetaSpec[] = [patchedMeta];
|
|
81
|
-
|
|
82
|
-
for (const [locale, template] of Object.entries(localeByKey)) {
|
|
83
|
-
if (locale === patchedLocale) {
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
const parsed = parseTemplate(template);
|
|
87
|
-
if (parsed.ok) {
|
|
88
|
-
localeMetas.push(parsed.meta);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return localeMetas;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function assertPatchKeySingle(
|
|
96
|
-
key: string,
|
|
97
|
-
locale: string,
|
|
98
|
-
template: string,
|
|
99
|
-
preloadedKeys: Set<string>,
|
|
100
|
-
localeByKey: Record<string, string> | undefined
|
|
101
|
-
): void {
|
|
102
|
-
if (!preloadedKeys.has(preloadKeySingle(key, locale))) {
|
|
103
|
-
throw new Error(`[i18n] Key not preloaded: ${key} (${locale})`);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const parsed = parseTemplate(template);
|
|
107
|
-
if (!parsed.ok) {
|
|
108
|
-
throw new Error(`[i18n] ICU syntax error on patch: ${parsed.message}`);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const localeMetas = collectLocaleMetasForKey(localeByKey ?? {}, locale, parsed.meta);
|
|
112
|
-
const merged = mergeVariableMetaAcrossLocales(localeMetas);
|
|
113
|
-
if (!merged.ok) {
|
|
114
|
-
throw new Error(`[i18n] ICU args mismatch on patch: ${merged.message}`);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function assertPatchKeyMulti(
|
|
119
|
-
namespace: string,
|
|
120
|
-
key: string,
|
|
121
|
-
locale: string,
|
|
122
|
-
template: string,
|
|
123
|
-
preloadedKeys: Set<string>,
|
|
124
|
-
localeByKey: Record<string, string> | undefined
|
|
125
|
-
): void {
|
|
126
|
-
if (!preloadedKeys.has(preloadKeyMulti(namespace, key, locale))) {
|
|
127
|
-
throw new Error(`[i18n] Key not preloaded: ${namespace}.${key} (${locale})`);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const parsed = parseTemplate(template);
|
|
131
|
-
if (!parsed.ok) {
|
|
132
|
-
throw new Error(`[i18n] ICU syntax error on patch: ${parsed.message}`);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const localeMetas = collectLocaleMetasForKey(localeByKey ?? {}, locale, parsed.meta);
|
|
136
|
-
const merged = mergeVariableMetaAcrossLocales(localeMetas);
|
|
137
|
-
if (!merged.ok) {
|
|
138
|
-
throw new Error(`[i18n] ICU args mismatch on patch: ${merged.message}`);
|
|
139
|
-
}
|
|
140
|
-
}
|