@xndrjs/i18n 0.3.3 → 0.5.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 +193 -44
- package/dist/codegen/index.d.ts +85 -0
- package/dist/codegen/index.js +181 -0
- package/dist/codegen/index.js.map +1 -0
- package/dist/index.d.ts +34 -18
- package/dist/index.js +174 -145
- package/dist/index.js.map +1 -1
- package/dist/validation/index.d.ts +86 -4
- package/dist/validation/index.js.map +1 -1
- package/package.json +6 -1
- package/src/IcuTranslationProviderMulti.test.ts +48 -1
- package/src/IcuTranslationProviderMulti.ts +41 -61
- package/src/IcuTranslationProviderSingle.test.ts +67 -0
- package/src/IcuTranslationProviderSingle.ts +27 -51
- package/src/audit/audit-dictionaries.ts +4 -1
- package/src/audit/run-audit.test.ts +35 -1
- package/src/audit/run-audit.ts +15 -7
- package/src/codegen/codegen-config-schema.ts +68 -1
- package/src/codegen/config.test.ts +174 -39
- package/src/codegen/config.ts +14 -5
- package/src/codegen/constants.ts +8 -0
- package/src/codegen/delivery-artifacts.test.ts +142 -0
- package/src/codegen/delivery-artifacts.ts +124 -0
- package/src/codegen/emit/dictionary-file.test.ts +190 -0
- package/src/codegen/emit/dictionary-file.ts +165 -2
- package/src/codegen/emit/dictionary-schema-file.ts +5 -0
- package/src/codegen/emit/instance-file.ts +119 -38
- package/src/codegen/emit/namespace-loaders-file.test.ts +176 -0
- package/src/codegen/emit/namespace-loaders-file.ts +118 -32
- package/src/codegen/emit/types-file.test.ts +114 -0
- package/src/codegen/emit/types-file.ts +48 -11
- package/src/codegen/generate-i18n-types.test.ts +765 -22
- package/src/codegen/generate-i18n-types.ts +111 -58
- package/src/codegen/icu-analysis.ts +9 -2
- package/src/codegen/locale-fallback.ts +19 -10
- package/src/codegen/locale-policy.ts +4 -0
- package/src/codegen/paths.ts +9 -5
- package/src/codegen/project-locales-set-namespace.test.ts +11 -9
- package/src/codegen/read-dictionary.test.ts +474 -2
- package/src/codegen/read-dictionary.ts +164 -15
- package/src/codegen/write-file-if-changed.test.ts +42 -0
- package/src/codegen/write-file-if-changed.ts +20 -0
- package/src/codegen-config/build-config.ts +34 -0
- package/src/codegen-config/codegen-config.test.ts +32 -0
- package/src/codegen-config/index.ts +21 -0
- package/src/codegen-config/write-config.ts +8 -0
- package/src/deep-freeze.test.ts +13 -0
- package/src/deep-freeze.ts +5 -0
- package/src/format-core.ts +91 -0
- package/src/index.ts +8 -5
- package/src/project-locales.test.ts +129 -10
- package/src/project-locales.ts +90 -3
- package/src/setup/setup-i18n.test.ts +1 -1
- package/src/setup/setup-i18n.ts +9 -32
- package/src/types.ts +19 -4
- package/src/validation/normalize.ts +4 -0
- package/dist/types-C1CpXVOJ.d.ts +0 -83
- package/src/ensure-namespace.integration.test.ts +0 -66
- package/src/ensure-namespace.test.ts +0 -125
- package/src/ensure-namespace.ts +0 -70
- /package/src/{setup → codegen-config}/type-names.ts +0 -0
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
projectDictionaryForDeliveryAreaCore,
|
|
4
|
+
projectDictionaryLocalesCore,
|
|
5
|
+
projectNamespaceForDeliveryAreaCore,
|
|
6
|
+
projectNamespaceLocalesCore,
|
|
7
|
+
} from "./project-locales.js";
|
|
3
8
|
|
|
4
|
-
describe("
|
|
9
|
+
describe("projectNamespaceLocalesCore", () => {
|
|
5
10
|
const dictionary = {
|
|
6
11
|
login_button: { en: "Login", it: "Accedi" },
|
|
7
12
|
welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
|
|
@@ -16,7 +21,7 @@ describe("projectLocales", () => {
|
|
|
16
21
|
} as const;
|
|
17
22
|
|
|
18
23
|
it("keeps only the requested locales with direct templates", () => {
|
|
19
|
-
expect(
|
|
24
|
+
expect(projectNamespaceLocalesCore(dictionary, ["en", "it"])).toEqual({
|
|
20
25
|
login_button: { en: "Login", it: "Accedi" },
|
|
21
26
|
welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
|
|
22
27
|
empty_label: { en: "" },
|
|
@@ -24,7 +29,7 @@ describe("projectLocales", () => {
|
|
|
24
29
|
});
|
|
25
30
|
|
|
26
31
|
it("drops locales not listed in the projection", () => {
|
|
27
|
-
expect(
|
|
32
|
+
expect(projectNamespaceLocalesCore(dictionary, ["en"])).toEqual({
|
|
28
33
|
login_button: { en: "Login" },
|
|
29
34
|
welcome: { en: "Welcome {name}!" },
|
|
30
35
|
empty_label: { en: "" },
|
|
@@ -32,7 +37,7 @@ describe("projectLocales", () => {
|
|
|
32
37
|
});
|
|
33
38
|
|
|
34
39
|
it("resolves fallback templates for missing direct locale entries", () => {
|
|
35
|
-
expect(
|
|
40
|
+
expect(projectNamespaceLocalesCore(dictionary, ["de-CH"], fallback)).toEqual({
|
|
36
41
|
login_button: { "de-CH": "Login" },
|
|
37
42
|
welcome: { "de-CH": "Welcome {name}!" },
|
|
38
43
|
empty_label: { "de-CH": "" },
|
|
@@ -40,7 +45,7 @@ describe("projectLocales", () => {
|
|
|
40
45
|
});
|
|
41
46
|
|
|
42
47
|
it("projects multiple locales with mixed direct and fallback resolution", () => {
|
|
43
|
-
expect(
|
|
48
|
+
expect(projectNamespaceLocalesCore(dictionary, ["it", "de-CH"], fallback)).toEqual({
|
|
44
49
|
login_button: { it: "Accedi", "de-CH": "Login" },
|
|
45
50
|
welcome: { it: "Benvenuto {name}!", "de-CH": "Welcome {name}!" },
|
|
46
51
|
empty_label: { it: "", "de-CH": "" },
|
|
@@ -52,13 +57,13 @@ describe("projectLocales", () => {
|
|
|
52
57
|
login_button: { it: "Accedi" },
|
|
53
58
|
};
|
|
54
59
|
|
|
55
|
-
expect(
|
|
60
|
+
expect(projectNamespaceLocalesCore(partial, ["en", "it"], fallback)).toEqual({
|
|
56
61
|
login_button: { it: "Accedi" },
|
|
57
62
|
});
|
|
58
63
|
});
|
|
59
64
|
|
|
60
65
|
it("deduplicates locales in the projection list", () => {
|
|
61
|
-
expect(
|
|
66
|
+
expect(projectNamespaceLocalesCore(dictionary, ["en", "en"])).toEqual({
|
|
62
67
|
login_button: { en: "Login" },
|
|
63
68
|
welcome: { en: "Welcome {name}!" },
|
|
64
69
|
empty_label: { en: "" },
|
|
@@ -66,7 +71,121 @@ describe("projectLocales", () => {
|
|
|
66
71
|
});
|
|
67
72
|
});
|
|
68
73
|
|
|
69
|
-
describe("
|
|
74
|
+
describe("projectNamespaceForDeliveryAreaCore", () => {
|
|
75
|
+
const namespace = {
|
|
76
|
+
some_key: { it: "Ciao", "it-CH": "Ciao CH", fr: "Hallo", "en-US": "Hello" },
|
|
77
|
+
some_other_key: { "en-US": "Computer", fr: "Ordinateur" },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const localeFallback = {
|
|
81
|
+
"en-US": null,
|
|
82
|
+
it: "en-US",
|
|
83
|
+
"it-CH": "it",
|
|
84
|
+
fr: null,
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
it("projects eu area with fallback resolution for full locales", () => {
|
|
88
|
+
expect(
|
|
89
|
+
projectNamespaceForDeliveryAreaCore(namespace, ["it", "fr", "it-CH"], localeFallback)
|
|
90
|
+
).toEqual({
|
|
91
|
+
some_key: { it: "Ciao", fr: "Hallo", "it-CH": "Ciao CH" },
|
|
92
|
+
// it-CH is preserve (fallback it is in the area): copied only when present in the
|
|
93
|
+
// canonical dict; "it" is full (fallback en-US is outside the area) and resolves here.
|
|
94
|
+
some_other_key: { it: "Computer", fr: "Ordinateur" },
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("projects us area with single full locale", () => {
|
|
99
|
+
expect(projectNamespaceForDeliveryAreaCore(namespace, ["en-US"], localeFallback)).toEqual({
|
|
100
|
+
some_key: { "en-US": "Hello" },
|
|
101
|
+
some_other_key: { "en-US": "Computer" },
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("preserves canonical entries for in-area fallback locales without resolving", () => {
|
|
106
|
+
const withInAreaFallback = {
|
|
107
|
+
some_key: { it: "Ciao", "it-CH": "Ciao CH", fr: "Hallo", "en-US": "Hello" },
|
|
108
|
+
some_other_key: {
|
|
109
|
+
it: "Italiano",
|
|
110
|
+
"it-CH": "Svizzero",
|
|
111
|
+
"en-US": "Computer",
|
|
112
|
+
fr: "Ordinateur",
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
expect(
|
|
117
|
+
projectNamespaceForDeliveryAreaCore(
|
|
118
|
+
withInAreaFallback,
|
|
119
|
+
["it", "it-CH", "en-US", "fr"],
|
|
120
|
+
localeFallback
|
|
121
|
+
)
|
|
122
|
+
).toEqual({
|
|
123
|
+
some_key: { it: "Ciao", "it-CH": "Ciao CH", "en-US": "Hello", fr: "Hallo" },
|
|
124
|
+
some_other_key: {
|
|
125
|
+
it: "Italiano",
|
|
126
|
+
"it-CH": "Svizzero",
|
|
127
|
+
"en-US": "Computer",
|
|
128
|
+
fr: "Ordinateur",
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("projectDictionaryForDeliveryAreaCore", () => {
|
|
135
|
+
const localeFallback = {
|
|
136
|
+
"en-US": null,
|
|
137
|
+
it: "en-US",
|
|
138
|
+
"it-CH": "it",
|
|
139
|
+
fr: null,
|
|
140
|
+
} as const;
|
|
141
|
+
|
|
142
|
+
it("projects each namespace independently for a multi-namespace dictionary", () => {
|
|
143
|
+
const dictionary = {
|
|
144
|
+
default: {
|
|
145
|
+
some_key: { it: "Ciao", "it-CH": "Ciao CH", fr: "Hallo", "en-US": "Hello" },
|
|
146
|
+
some_other_key: { "en-US": "Computer", fr: "Ordinateur" },
|
|
147
|
+
},
|
|
148
|
+
billing: {
|
|
149
|
+
invoice_summary: {
|
|
150
|
+
it: "{count} fatture",
|
|
151
|
+
"en-US": "{count} invoices",
|
|
152
|
+
fr: "{count} factures",
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
expect(
|
|
158
|
+
projectDictionaryForDeliveryAreaCore(dictionary, ["it", "fr", "it-CH"], localeFallback)
|
|
159
|
+
).toEqual({
|
|
160
|
+
default: {
|
|
161
|
+
some_key: { it: "Ciao", fr: "Hallo", "it-CH": "Ciao CH" },
|
|
162
|
+
some_other_key: { it: "Computer", fr: "Ordinateur" },
|
|
163
|
+
},
|
|
164
|
+
billing: {
|
|
165
|
+
invoice_summary: { it: "{count} fatture", fr: "{count} factures" },
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("matches projectNamespaceForDeliveryAreaCore applied per namespace", () => {
|
|
171
|
+
const dictionary = {
|
|
172
|
+
default: {
|
|
173
|
+
some_key: { it: "Ciao", "en-US": "Hello" },
|
|
174
|
+
},
|
|
175
|
+
billing: {
|
|
176
|
+
invoice_summary: { it: "{count} fatture", "en-US": "{count} invoices" },
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const areaLocales = ["en-US"] as const;
|
|
180
|
+
|
|
181
|
+
expect(projectDictionaryForDeliveryAreaCore(dictionary, areaLocales, localeFallback)).toEqual({
|
|
182
|
+
default: projectNamespaceForDeliveryAreaCore(dictionary.default, areaLocales, localeFallback),
|
|
183
|
+
billing: projectNamespaceForDeliveryAreaCore(dictionary.billing, areaLocales, localeFallback),
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("projectDictionaryLocalesCore", () => {
|
|
70
189
|
it("projects each namespace independently", () => {
|
|
71
190
|
const dictionary = {
|
|
72
191
|
default: {
|
|
@@ -77,7 +196,7 @@ describe("projectNamespacesLocales", () => {
|
|
|
77
196
|
},
|
|
78
197
|
};
|
|
79
198
|
|
|
80
|
-
expect(
|
|
199
|
+
expect(projectDictionaryLocalesCore(dictionary, ["en"])).toEqual({
|
|
81
200
|
default: { login_button: { en: "Login" } },
|
|
82
201
|
billing: { invoice_summary: { en: "{count} invoices" } },
|
|
83
202
|
});
|
package/src/project-locales.ts
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { resolveLocaleTemplate } from "./resolve-locale.js";
|
|
2
2
|
import type { KeyDictionary, LocaleFallbackMap, MultiDictionary } from "./types.js";
|
|
3
3
|
|
|
4
|
+
function classifyAreaLocales(
|
|
5
|
+
areaLocales: readonly string[],
|
|
6
|
+
localeFallback?: LocaleFallbackMap
|
|
7
|
+
): { full: string[]; preserve: string[] } {
|
|
8
|
+
const areaSet = new Set(areaLocales);
|
|
9
|
+
const full: string[] = [];
|
|
10
|
+
const preserve: string[] = [];
|
|
11
|
+
|
|
12
|
+
for (const locale of areaLocales) {
|
|
13
|
+
const fallback = localeFallback?.[locale];
|
|
14
|
+
if (fallback !== null && fallback !== undefined && areaSet.has(fallback)) {
|
|
15
|
+
preserve.push(locale);
|
|
16
|
+
} else {
|
|
17
|
+
full.push(locale);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return { full, preserve };
|
|
22
|
+
}
|
|
23
|
+
|
|
4
24
|
function projectKeyDictionary<T extends KeyDictionary>(
|
|
5
25
|
dictionary: T,
|
|
6
26
|
locales: readonly string[],
|
|
@@ -28,11 +48,11 @@ function projectKeyDictionary<T extends KeyDictionary>(
|
|
|
28
48
|
}
|
|
29
49
|
|
|
30
50
|
/**
|
|
31
|
-
* Builds a dictionary that contains only the requested locales.
|
|
51
|
+
* Builds a namespace dictionary that contains only the requested locales.
|
|
32
52
|
* For each key and target locale, copies the direct template when present;
|
|
33
53
|
* otherwise resolves through `localeFallback` (same rules as `.get()` at runtime).
|
|
34
54
|
*/
|
|
35
|
-
export function
|
|
55
|
+
export function projectNamespaceLocalesCore<T extends KeyDictionary>(
|
|
36
56
|
dictionary: T,
|
|
37
57
|
locales: readonly string[],
|
|
38
58
|
localeFallback?: LocaleFallbackMap
|
|
@@ -40,10 +60,77 @@ export function projectLocales<T extends KeyDictionary>(
|
|
|
40
60
|
return projectKeyDictionary(dictionary, locales, localeFallback);
|
|
41
61
|
}
|
|
42
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Projects a single namespace for a custom delivery area.
|
|
65
|
+
* Full locales resolve through `localeFallback` (e.g. external chains like `it → en-US`);
|
|
66
|
+
* preserve locales copy only their direct canonical entry when present.
|
|
67
|
+
*/
|
|
68
|
+
export function projectNamespaceForDeliveryAreaCore<T extends KeyDictionary>(
|
|
69
|
+
dictionary: T,
|
|
70
|
+
areaLocales: readonly string[],
|
|
71
|
+
localeFallback?: LocaleFallbackMap
|
|
72
|
+
): T {
|
|
73
|
+
const { full, preserve } = classifyAreaLocales(areaLocales, localeFallback);
|
|
74
|
+
const fullProjection =
|
|
75
|
+
full.length > 0 ? projectKeyDictionary(dictionary, full, localeFallback) : undefined;
|
|
76
|
+
const result = {} as T;
|
|
77
|
+
|
|
78
|
+
for (const [key, localesByKey] of Object.entries(dictionary)) {
|
|
79
|
+
const projected: Record<string, string> = {};
|
|
80
|
+
|
|
81
|
+
if (fullProjection !== undefined) {
|
|
82
|
+
const fullEntry = fullProjection[key as keyof T];
|
|
83
|
+
if (fullEntry !== undefined) {
|
|
84
|
+
Object.assign(projected, fullEntry);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const locale of preserve) {
|
|
89
|
+
const template = localesByKey[locale];
|
|
90
|
+
if (template !== undefined) {
|
|
91
|
+
projected[locale] = template;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (Object.keys(projected).length > 0) {
|
|
96
|
+
result[key as keyof T] = projected as T[keyof T];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Projects a multi-namespace dictionary for a custom delivery area.
|
|
105
|
+
* Each namespace uses the same delivery-area rules as `projectNamespaceForDeliveryAreaCore`.
|
|
106
|
+
*/
|
|
107
|
+
export function projectDictionaryForDeliveryAreaCore<T extends MultiDictionary>(
|
|
108
|
+
dictionary: T,
|
|
109
|
+
areaLocales: readonly string[],
|
|
110
|
+
localeFallback?: LocaleFallbackMap
|
|
111
|
+
): T {
|
|
112
|
+
const result = {} as T;
|
|
113
|
+
|
|
114
|
+
for (const namespace of Object.keys(dictionary) as (keyof T & string)[]) {
|
|
115
|
+
const namespaceDictionary = dictionary[namespace];
|
|
116
|
+
if (namespaceDictionary === undefined) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
result[namespace] = projectNamespaceForDeliveryAreaCore(
|
|
121
|
+
namespaceDictionary,
|
|
122
|
+
areaLocales,
|
|
123
|
+
localeFallback
|
|
124
|
+
) as T[typeof namespace];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
43
130
|
/**
|
|
44
131
|
* Projects every namespace in a multi-namespace dictionary.
|
|
45
132
|
*/
|
|
46
|
-
export function
|
|
133
|
+
export function projectDictionaryLocalesCore<T extends MultiDictionary>(
|
|
47
134
|
dictionary: T,
|
|
48
135
|
locales: readonly string[],
|
|
49
136
|
localeFallback?: LocaleFallbackMap
|
|
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { describe, expect, it, afterEach } from "vitest";
|
|
5
5
|
import { buildCodegenConfig, parseSetupArgs, runSetup } from "./setup-i18n.js";
|
|
6
|
-
import { inferProjectName, typeNamesForProject } from "
|
|
6
|
+
import { inferProjectName, typeNamesForProject } from "../codegen-config/type-names.js";
|
|
7
7
|
|
|
8
8
|
describe("type-names", () => {
|
|
9
9
|
it("derives PascalCase project names from directory names", () => {
|
package/src/setup/setup-i18n.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { buildCodegenConfig, type SetupMode } from "../codegen-config/build-config.js";
|
|
5
|
+
import { inferProjectName } from "../codegen-config/type-names.js";
|
|
6
|
+
import { writeCodegenConfig } from "../codegen-config/write-config.js";
|
|
4
7
|
import { DEFAULT_IMPORT_EXTENSION, importExtensionSuffix } from "../codegen/paths.js";
|
|
5
|
-
import { inferProjectName, typeNamesForProject } from "./type-names.js";
|
|
6
8
|
|
|
7
|
-
export type SetupMode
|
|
9
|
+
export type { SetupMode } from "../codegen-config/build-config.js";
|
|
10
|
+
export { buildCodegenConfig } from "../codegen-config/build-config.js";
|
|
8
11
|
|
|
9
12
|
export interface SetupOptions {
|
|
10
13
|
mode: SetupMode;
|
|
@@ -32,11 +35,12 @@ const DEFAULT_STARTER = {
|
|
|
32
35
|
const DEFAULT_IMPORT_SUFFIX = importExtensionSuffix(DEFAULT_IMPORT_EXTENSION);
|
|
33
36
|
|
|
34
37
|
const INDEX_TS =
|
|
35
|
-
`import { createI18n } from "./generated/instance.generated${DEFAULT_IMPORT_SUFFIX}";\n
|
|
38
|
+
`import { createI18n } from "./generated/instance.generated${DEFAULT_IMPORT_SUFFIX}";\n` +
|
|
39
|
+
`import { defaultDictionary } from "./generated/dictionary.generated${DEFAULT_IMPORT_SUFFIX}";\n\n` +
|
|
36
40
|
`export * from "./generated/instance.generated${DEFAULT_IMPORT_SUFFIX}";\n` +
|
|
37
41
|
`export * from "./generated/dictionary.generated${DEFAULT_IMPORT_SUFFIX}";\n` +
|
|
38
42
|
`export * from "./generated/i18n-types.generated${DEFAULT_IMPORT_SUFFIX}";\n\n` +
|
|
39
|
-
`export const i18n = createI18n();\n`;
|
|
43
|
+
`export const i18n = createI18n(defaultDictionary);\n`;
|
|
40
44
|
|
|
41
45
|
function writeJson(filePath: string, value: unknown): void {
|
|
42
46
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -52,33 +56,6 @@ function relative(fromRoot: string, filePath: string): string {
|
|
|
52
56
|
return path.relative(fromRoot, filePath).replace(/\\/g, "/");
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
export function buildCodegenConfig(mode: SetupMode, project: string): Record<string, unknown> {
|
|
56
|
-
const typeNames = typeNamesForProject(project);
|
|
57
|
-
const base = {
|
|
58
|
-
typesOutput: `${GENERATED_DIR}/i18n-types.generated.ts`,
|
|
59
|
-
dictionaryOutput: `${GENERATED_DIR}/dictionary.generated.ts`,
|
|
60
|
-
instanceOutput: `${GENERATED_DIR}/instance.generated.ts`,
|
|
61
|
-
paramsTypeName: typeNames.paramsTypeName,
|
|
62
|
-
schemaTypeName: typeNames.schemaTypeName,
|
|
63
|
-
localeTypeName: typeNames.localeTypeName,
|
|
64
|
-
factoryName: "createI18n",
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
if (mode === "single") {
|
|
68
|
-
return {
|
|
69
|
-
dictionary: `${TRANSLATIONS_DIR}/translations.json`,
|
|
70
|
-
...base,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return {
|
|
75
|
-
namespaces: {
|
|
76
|
-
default: `${TRANSLATIONS_DIR}/default.json`,
|
|
77
|
-
},
|
|
78
|
-
...base,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
59
|
export function runSetup(options: SetupOptions): SetupResult {
|
|
83
60
|
const targetDir = path.resolve(options.targetDir);
|
|
84
61
|
const project =
|
|
@@ -99,7 +76,7 @@ export function runSetup(options: SetupOptions): SetupResult {
|
|
|
99
76
|
|
|
100
77
|
const created: string[] = [];
|
|
101
78
|
|
|
102
|
-
|
|
79
|
+
writeCodegenConfig(configPath, buildCodegenConfig(options.mode, project));
|
|
103
80
|
created.push(relative(targetDir, configPath));
|
|
104
81
|
|
|
105
82
|
if (options.mode === "single") {
|
package/src/types.ts
CHANGED
|
@@ -18,15 +18,30 @@ export type LocaleOfMulti<Schema extends MultiDictionary> = {
|
|
|
18
18
|
|
|
19
19
|
export type LocaleFallbackMap = Record<string, string | null>;
|
|
20
20
|
|
|
21
|
-
export type
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
export type MissingTranslationContext = {
|
|
22
|
+
/** Only set by the multi-namespace provider. */
|
|
23
|
+
namespace?: string;
|
|
24
|
+
key: string;
|
|
25
|
+
locale: string;
|
|
26
|
+
fallbackChain: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type OnMissingTranslation =
|
|
30
|
+
| "throw"
|
|
31
|
+
| "key"
|
|
32
|
+
| ((context: MissingTranslationContext) => string);
|
|
25
33
|
|
|
26
34
|
export type IcuTranslationProviderOptions<
|
|
27
35
|
Fallback extends LocaleFallbackMap | undefined = undefined,
|
|
28
36
|
> = {
|
|
29
37
|
localeFallback?: Fallback;
|
|
38
|
+
/**
|
|
39
|
+
* Strategy when no template resolves for a key/locale (after walking the
|
|
40
|
+
* fallback chain). "throw" (default) raises an error, "key" returns the
|
|
41
|
+
* translation key itself, a function receives the context and returns the
|
|
42
|
+
* string to display. ICU syntax and formatting errors always throw.
|
|
43
|
+
*/
|
|
44
|
+
onMissing?: OnMissingTranslation;
|
|
30
45
|
};
|
|
31
46
|
|
|
32
47
|
export type LocaleCache = Record<string, IntlMessageFormat>;
|
|
@@ -107,6 +107,10 @@ function normalizeKeyDictionary(
|
|
|
107
107
|
return { ok: true, data: keys };
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Validation step 1: coerce external input into a normalized dictionary shape
|
|
112
|
+
* (keys, locales, ICU args) against a `DictionarySpec`.
|
|
113
|
+
*/
|
|
110
114
|
export function normalizeDictionary(
|
|
111
115
|
input: unknown,
|
|
112
116
|
spec: DictionarySpec
|
package/dist/types-C1CpXVOJ.d.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { parse } from '@formatjs/icu-messageformat-parser';
|
|
2
|
-
|
|
3
|
-
type VariableType = "string" | "number" | "date";
|
|
4
|
-
type VariableSpec = Record<string, VariableType>;
|
|
5
|
-
type VariableRole = "simple" | "plural" | "selectordinal" | "select" | "number" | "date" | "time";
|
|
6
|
-
type VariableMeta = {
|
|
7
|
-
type: VariableType;
|
|
8
|
-
roles: Set<VariableRole>;
|
|
9
|
-
};
|
|
10
|
-
type VariableMetaSpec = Record<string, VariableMeta>;
|
|
11
|
-
type IcuAst = ReturnType<typeof parse>;
|
|
12
|
-
declare function extractVariables(nodes: IcuAst): VariableSpec;
|
|
13
|
-
declare function mergeVariableSpecs(target: VariableSpec, source: VariableSpec): VariableSpec;
|
|
14
|
-
declare function variableSpecsEqual(a: VariableSpec, b: VariableSpec): boolean;
|
|
15
|
-
|
|
16
|
-
type ParsedLocaleEntry = {
|
|
17
|
-
readonly template: string;
|
|
18
|
-
readonly args: VariableSpec;
|
|
19
|
-
};
|
|
20
|
-
type ParsedKeyEntry = {
|
|
21
|
-
readonly locales: Readonly<Record<string, ParsedLocaleEntry>>;
|
|
22
|
-
readonly mergedArgs: VariableSpec;
|
|
23
|
-
};
|
|
24
|
-
type NormalizedKeyDictionary = Readonly<Record<string, ParsedKeyEntry>>;
|
|
25
|
-
type NormalizedMultiDictionary = Readonly<Record<string, NormalizedKeyDictionary>>;
|
|
26
|
-
type NormalizedDictionary = {
|
|
27
|
-
readonly mode: "single";
|
|
28
|
-
readonly keys: NormalizedKeyDictionary;
|
|
29
|
-
} | {
|
|
30
|
-
readonly mode: "multi";
|
|
31
|
-
readonly namespaces: NormalizedMultiDictionary;
|
|
32
|
-
};
|
|
33
|
-
type DictionarySpec = {
|
|
34
|
-
readonly mode: "single";
|
|
35
|
-
readonly requiredKeys: readonly string[];
|
|
36
|
-
readonly argsByKey: Readonly<Record<string, VariableSpec>>;
|
|
37
|
-
} | {
|
|
38
|
-
readonly mode: "multi";
|
|
39
|
-
readonly requiredKeys: Readonly<Record<string, readonly string[]>>;
|
|
40
|
-
readonly argsByKey: Readonly<Record<string, Readonly<Record<string, VariableSpec>>>>;
|
|
41
|
-
};
|
|
42
|
-
type ValidationIssue = {
|
|
43
|
-
readonly kind: "invalid_input";
|
|
44
|
-
readonly message: string;
|
|
45
|
-
} | {
|
|
46
|
-
readonly kind: "missing_key";
|
|
47
|
-
readonly path: readonly string[];
|
|
48
|
-
} | {
|
|
49
|
-
readonly kind: "invalid_locale_value";
|
|
50
|
-
readonly path: readonly string[];
|
|
51
|
-
readonly message: string;
|
|
52
|
-
} | {
|
|
53
|
-
readonly kind: "icu_syntax_error";
|
|
54
|
-
readonly path: readonly string[];
|
|
55
|
-
readonly message: string;
|
|
56
|
-
} | {
|
|
57
|
-
readonly kind: "locale_args_mismatch";
|
|
58
|
-
readonly path: readonly string[];
|
|
59
|
-
readonly locales: Readonly<Record<string, VariableSpec>>;
|
|
60
|
-
readonly message: string;
|
|
61
|
-
} | {
|
|
62
|
-
readonly kind: "variable_mismatch";
|
|
63
|
-
readonly path: readonly string[];
|
|
64
|
-
readonly expected: VariableSpec;
|
|
65
|
-
readonly found: VariableSpec;
|
|
66
|
-
readonly message: string;
|
|
67
|
-
} | {
|
|
68
|
-
readonly kind: "variable_type_mismatch";
|
|
69
|
-
readonly path: readonly string[];
|
|
70
|
-
readonly variable: string;
|
|
71
|
-
readonly expected: VariableType;
|
|
72
|
-
readonly found: VariableType;
|
|
73
|
-
readonly message: string;
|
|
74
|
-
};
|
|
75
|
-
type ValidationResult<T> = {
|
|
76
|
-
readonly ok: true;
|
|
77
|
-
readonly data: T;
|
|
78
|
-
} | {
|
|
79
|
-
readonly ok: false;
|
|
80
|
-
readonly issues: readonly ValidationIssue[];
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
export { type DictionarySpec as D, type NormalizedDictionary as N, type ParsedKeyEntry as P, type ValidationResult as V, type VariableSpec as a, type ValidationIssue as b, type VariableMetaSpec as c, type NormalizedKeyDictionary as d, type NormalizedMultiDictionary as e, type ParsedLocaleEntry as f, type VariableType as g, extractVariables as h, mergeVariableSpecs as m, variableSpecsEqual as v };
|
|
@@ -1,66 +0,0 @@
|
|
|
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
|
-
});
|