@xndrjs/i18n 0.8.1 → 0.8.2-alpha.1

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.
Files changed (93) hide show
  1. package/README.md +1 -2
  2. package/dist/cli/audit.js +739 -0
  3. package/dist/cli/audit.js.map +1 -0
  4. package/dist/cli/codegen.js +1471 -0
  5. package/dist/cli/codegen.js.map +1 -0
  6. package/dist/cli/setup.js +148 -0
  7. package/dist/cli/setup.js.map +1 -0
  8. package/dist/codegen/index.js +43 -40
  9. package/dist/codegen/index.js.map +1 -1
  10. package/package.json +6 -9
  11. package/bin/audit.mjs +0 -14
  12. package/bin/codegen.mjs +0 -17
  13. package/bin/setup.mjs +0 -14
  14. package/src/IcuTranslationProviderMulti.test.ts +0 -215
  15. package/src/IcuTranslationProviderMulti.ts +0 -188
  16. package/src/audit/audit-dictionaries.test.ts +0 -134
  17. package/src/audit/audit-dictionaries.ts +0 -178
  18. package/src/audit/run-audit.test.ts +0 -169
  19. package/src/audit/run-audit.ts +0 -94
  20. package/src/builder-load-registry.test.ts +0 -44
  21. package/src/builder-load-registry.ts +0 -78
  22. package/src/builder-loaders.ts +0 -15
  23. package/src/builder-types.test.ts +0 -57
  24. package/src/builder-types.ts +0 -24
  25. package/src/builder.test.ts +0 -303
  26. package/src/builder.ts +0 -15
  27. package/src/codegen/codegen-config-schema.ts +0 -156
  28. package/src/codegen/config.test.ts +0 -231
  29. package/src/codegen/config.ts +0 -42
  30. package/src/codegen/constants.ts +0 -9
  31. package/src/codegen/delivery-artifacts.test.ts +0 -167
  32. package/src/codegen/delivery-artifacts.ts +0 -154
  33. package/src/codegen/dictionary-spec-contract.test.ts +0 -28
  34. package/src/codegen/dictionary-spec-contract.ts +0 -138
  35. package/src/codegen/emit/dictionary-schema-file.ts +0 -113
  36. package/src/codegen/emit/instance-file.test.ts +0 -71
  37. package/src/codegen/emit/instance-file.ts +0 -257
  38. package/src/codegen/emit/namespace-loaders-file.test.ts +0 -251
  39. package/src/codegen/emit/namespace-loaders-file.ts +0 -229
  40. package/src/codegen/emit/types-file.test.ts +0 -96
  41. package/src/codegen/emit/types-file.ts +0 -142
  42. package/src/codegen/fixtures/billing-namespace.json +0 -6
  43. package/src/codegen/generate-i18n-types.test.ts +0 -1137
  44. package/src/codegen/generate-i18n-types.ts +0 -20
  45. package/src/codegen/icu-analysis.ts +0 -110
  46. package/src/codegen/locale-fallback.ts +0 -73
  47. package/src/codegen/locale-policy.test.ts +0 -35
  48. package/src/codegen/locale-policy.ts +0 -30
  49. package/src/codegen/paths.ts +0 -44
  50. package/src/codegen/project-locales-set-namespace.test.ts +0 -134
  51. package/src/codegen/read-dictionary.test.ts +0 -594
  52. package/src/codegen/read-dictionary.ts +0 -260
  53. package/src/codegen/regenerate-namespaces.ts +0 -180
  54. package/src/codegen/run-codegen.test.ts +0 -230
  55. package/src/codegen/run-codegen.ts +0 -252
  56. package/src/codegen/types.ts +0 -10
  57. package/src/codegen/write-file-if-changed.test.ts +0 -42
  58. package/src/codegen/write-file-if-changed.ts +0 -20
  59. package/src/codegen-config/build-config.ts +0 -15
  60. package/src/codegen-config/codegen-config.test.ts +0 -36
  61. package/src/codegen-config/index.ts +0 -37
  62. package/src/codegen-config/type-names.ts +0 -20
  63. package/src/codegen-config/write-config.ts +0 -8
  64. package/src/engine.ts +0 -47
  65. package/src/fetch-artifact.ts +0 -16
  66. package/src/format-core.ts +0 -91
  67. package/src/i18n-handle.ts +0 -216
  68. package/src/icu/extract-variables.test.ts +0 -199
  69. package/src/icu/extract-variables.ts +0 -227
  70. package/src/icu/parse-template.ts +0 -31
  71. package/src/index.ts +0 -63
  72. package/src/load-state.ts +0 -15
  73. package/src/project-locales.test.ts +0 -280
  74. package/src/project-locales.ts +0 -211
  75. package/src/resolve-locale.test.ts +0 -91
  76. package/src/resolve-locale.ts +0 -88
  77. package/src/scope-multi.ts +0 -105
  78. package/src/scope-types.ts +0 -35
  79. package/src/scope.test.ts +0 -172
  80. package/src/serialized-state.test.ts +0 -114
  81. package/src/serialized-state.ts +0 -28
  82. package/src/setup/setup-i18n.test.ts +0 -85
  83. package/src/setup/setup-i18n.ts +0 -156
  84. package/src/types.ts +0 -65
  85. package/src/validation/create-args-schema.ts +0 -76
  86. package/src/validation/create-normalized-schema.ts +0 -52
  87. package/src/validation/errors.ts +0 -43
  88. package/src/validation/index.ts +0 -202
  89. package/src/validation/normalize.ts +0 -299
  90. package/src/validation/to-dictionary.ts +0 -67
  91. package/src/validation/types.ts +0 -82
  92. package/src/validation/validate-normalized.ts +0 -133
  93. package/src/validation/validation.test.ts +0 -562
package/src/index.ts DELETED
@@ -1,63 +0,0 @@
1
- export type {
2
- DeliveryArtifactsMap,
3
- LocalesForDeliveryArea,
4
- LocalesForDeliveryAreaOrAll,
5
- } from "./builder-types.js";
6
-
7
- export type { I18nEngineMulti, I18nEngineMultiImpl } from "./engine.js";
8
-
9
- export type {
10
- I18nHandle,
11
- I18nHandleOptions,
12
- LoadNamespacesInput,
13
- NamespaceLoader,
14
- PartitionForLocale,
15
- PartitionedLoader,
16
- ScopeForLocale,
17
- } from "./builder.js";
18
- export { createI18nHandle, invokeNamespaceLoader } from "./builder.js";
19
-
20
- export type { BuilderResourceEntry } from "./builder-load-registry.js";
21
- export type { I18nCreateInput, I18nSerializedState } from "./serialized-state.js";
22
- export { normalizeI18nCreateInput } from "./serialized-state.js";
23
- export type {
24
- I18nLoadState,
25
- I18nResourceLoadRecord,
26
- I18nResourceLoadStatus,
27
- } from "./load-state.js";
28
-
29
- export { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
30
-
31
- export type { I18nScopeMulti, I18nScopeMultiForLocale } from "./scope-multi.js";
32
-
33
- export type {
34
- MultiParams,
35
- NamespaceBoundTranslateFn,
36
- ParamsForNamespaces,
37
- SchemaForNamespaces,
38
- } from "./scope-types.js";
39
-
40
- export type {
41
- IcuTranslationProviderOptions,
42
- KeyDictionary,
43
- LocaleDictionary,
44
- LocaleFallbackMap,
45
- LocaleOfKeys,
46
- LocaleOfMulti,
47
- MissingTranslationContext,
48
- MultiDictionary,
49
- PartialKeyDictionary,
50
- PartialMultiDictionary,
51
- OnMissingTranslation,
52
- } from "./types.js";
53
-
54
- export {
55
- formatLocaleFallbackChain,
56
- resolveLocaleTemplate,
57
- validateLocaleFallback,
58
- } from "./resolve-locale.js";
59
- export type { ResolvedLocaleTemplate } from "./resolve-locale.js";
60
-
61
- export { mergeDictionaryLocalesCore, mergeNamespaceLocalesCore } from "./project-locales.js";
62
-
63
- export type { FetchArtifact, DeliveryResourceId } from "./fetch-artifact.js";
package/src/load-state.ts DELETED
@@ -1,15 +0,0 @@
1
- /** Status of a lazy builder resource (`namespace` × partition). */
2
- export type I18nResourceLoadStatus = "pending" | "loaded" | "error";
3
-
4
- /** One tracked resource entry in {@link I18nLoadState}. */
5
- export interface I18nResourceLoadRecord {
6
- namespace: string;
7
- partition: string;
8
- status: I18nResourceLoadStatus;
9
- error?: unknown;
10
- }
11
-
12
- /** Global snapshot of resource loads attempted on an i18n handle. */
13
- export interface I18nLoadState {
14
- resources: readonly I18nResourceLoadRecord[];
15
- }
@@ -1,280 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- mergeDictionaryLocalesCore,
4
- mergeNamespaceLocalesCore,
5
- projectDictionaryForDeliveryAreaCore,
6
- projectDictionaryLocalesCore,
7
- projectNamespaceForDeliveryAreaCore,
8
- projectNamespaceLocalesCore,
9
- } from "./project-locales.js";
10
-
11
- describe("projectNamespaceLocalesCore", () => {
12
- const dictionary = {
13
- login_button: { en: "Login", it: "Accedi" },
14
- welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
15
- empty_label: { en: "" },
16
- };
17
-
18
- const fallback = {
19
- en: null,
20
- "de-DE": "en",
21
- "de-CH": "de-DE",
22
- it: "en",
23
- } as const;
24
-
25
- it("keeps only the requested locales with direct templates", () => {
26
- expect(projectNamespaceLocalesCore(dictionary, ["en", "it"])).toEqual({
27
- login_button: { en: "Login", it: "Accedi" },
28
- welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
29
- empty_label: { en: "" },
30
- });
31
- });
32
-
33
- it("drops locales not listed in the projection", () => {
34
- expect(projectNamespaceLocalesCore(dictionary, ["en"])).toEqual({
35
- login_button: { en: "Login" },
36
- welcome: { en: "Welcome {name}!" },
37
- empty_label: { en: "" },
38
- });
39
- });
40
-
41
- it("resolves fallback templates for missing direct locale entries", () => {
42
- expect(projectNamespaceLocalesCore(dictionary, ["de-CH"], fallback)).toEqual({
43
- login_button: { "de-CH": "Login" },
44
- welcome: { "de-CH": "Welcome {name}!" },
45
- empty_label: { "de-CH": "" },
46
- });
47
- });
48
-
49
- it("projects multiple locales with mixed direct and fallback resolution", () => {
50
- expect(projectNamespaceLocalesCore(dictionary, ["it", "de-CH"], fallback)).toEqual({
51
- login_button: { it: "Accedi", "de-CH": "Login" },
52
- welcome: { it: "Benvenuto {name}!", "de-CH": "Welcome {name}!" },
53
- empty_label: { it: "", "de-CH": "" },
54
- });
55
- });
56
-
57
- it("omits a target locale when fallback cannot resolve a template", () => {
58
- const partial = {
59
- login_button: { it: "Accedi" },
60
- };
61
-
62
- expect(projectNamespaceLocalesCore(partial, ["en", "it"], fallback)).toEqual({
63
- login_button: { it: "Accedi" },
64
- });
65
- });
66
-
67
- it("deduplicates locales in the projection list", () => {
68
- expect(projectNamespaceLocalesCore(dictionary, ["en", "en"])).toEqual({
69
- login_button: { en: "Login" },
70
- welcome: { en: "Welcome {name}!" },
71
- empty_label: { en: "" },
72
- });
73
- });
74
- });
75
-
76
- describe("projectNamespaceForDeliveryAreaCore", () => {
77
- const namespace = {
78
- some_key: { it: "Ciao", "it-CH": "Ciao CH", fr: "Hallo", "en-US": "Hello" },
79
- some_other_key: { "en-US": "Computer", fr: "Ordinateur" },
80
- };
81
-
82
- const localeFallback = {
83
- "en-US": null,
84
- it: "en-US",
85
- "it-CH": "it",
86
- fr: null,
87
- } as const;
88
-
89
- it("projects eu area with fallback resolution for full locales", () => {
90
- expect(
91
- projectNamespaceForDeliveryAreaCore(namespace, ["it", "fr", "it-CH"], localeFallback)
92
- ).toEqual({
93
- some_key: { it: "Ciao", fr: "Hallo", "it-CH": "Ciao CH" },
94
- // it-CH is preserve (fallback it is in the area): copied only when present in the
95
- // canonical dict; "it" is full (fallback en-US is outside the area) and resolves here.
96
- some_other_key: { it: "Computer", fr: "Ordinateur" },
97
- });
98
- });
99
-
100
- it("projects us area with single full locale", () => {
101
- expect(projectNamespaceForDeliveryAreaCore(namespace, ["en-US"], localeFallback)).toEqual({
102
- some_key: { "en-US": "Hello" },
103
- some_other_key: { "en-US": "Computer" },
104
- });
105
- });
106
-
107
- it("preserves canonical entries for in-area fallback locales without resolving", () => {
108
- const withInAreaFallback = {
109
- some_key: { it: "Ciao", "it-CH": "Ciao CH", fr: "Hallo", "en-US": "Hello" },
110
- some_other_key: {
111
- it: "Italiano",
112
- "it-CH": "Svizzero",
113
- "en-US": "Computer",
114
- fr: "Ordinateur",
115
- },
116
- };
117
-
118
- expect(
119
- projectNamespaceForDeliveryAreaCore(
120
- withInAreaFallback,
121
- ["it", "it-CH", "en-US", "fr"],
122
- localeFallback
123
- )
124
- ).toEqual({
125
- some_key: { it: "Ciao", "it-CH": "Ciao CH", "en-US": "Hello", fr: "Hallo" },
126
- some_other_key: {
127
- it: "Italiano",
128
- "it-CH": "Svizzero",
129
- "en-US": "Computer",
130
- fr: "Ordinateur",
131
- },
132
- });
133
- });
134
- });
135
-
136
- describe("projectDictionaryForDeliveryAreaCore", () => {
137
- const localeFallback = {
138
- "en-US": null,
139
- it: "en-US",
140
- "it-CH": "it",
141
- fr: null,
142
- } as const;
143
-
144
- it("projects each namespace independently for a multi-namespace dictionary", () => {
145
- const dictionary = {
146
- default: {
147
- some_key: { it: "Ciao", "it-CH": "Ciao CH", fr: "Hallo", "en-US": "Hello" },
148
- some_other_key: { "en-US": "Computer", fr: "Ordinateur" },
149
- },
150
- billing: {
151
- invoice_summary: {
152
- it: "{count} fatture",
153
- "en-US": "{count} invoices",
154
- fr: "{count} factures",
155
- },
156
- },
157
- };
158
-
159
- expect(
160
- projectDictionaryForDeliveryAreaCore(dictionary, ["it", "fr", "it-CH"], localeFallback)
161
- ).toEqual({
162
- default: {
163
- some_key: { it: "Ciao", fr: "Hallo", "it-CH": "Ciao CH" },
164
- some_other_key: { it: "Computer", fr: "Ordinateur" },
165
- },
166
- billing: {
167
- invoice_summary: { it: "{count} fatture", fr: "{count} factures" },
168
- },
169
- });
170
- });
171
-
172
- it("matches projectNamespaceForDeliveryAreaCore applied per namespace", () => {
173
- const dictionary = {
174
- default: {
175
- some_key: { it: "Ciao", "en-US": "Hello" },
176
- },
177
- billing: {
178
- invoice_summary: { it: "{count} fatture", "en-US": "{count} invoices" },
179
- },
180
- };
181
- const areaLocales = ["en-US"] as const;
182
-
183
- expect(projectDictionaryForDeliveryAreaCore(dictionary, areaLocales, localeFallback)).toEqual({
184
- default: projectNamespaceForDeliveryAreaCore(dictionary.default, areaLocales, localeFallback),
185
- billing: projectNamespaceForDeliveryAreaCore(dictionary.billing, areaLocales, localeFallback),
186
- });
187
- });
188
- });
189
-
190
- describe("projectDictionaryLocalesCore", () => {
191
- it("projects each namespace independently", () => {
192
- const dictionary = {
193
- default: {
194
- login_button: { en: "Login", it: "Accedi" },
195
- },
196
- billing: {
197
- invoice_summary: { en: "{count} invoices", it: "{count} fatture" },
198
- },
199
- };
200
-
201
- expect(projectDictionaryLocalesCore(dictionary, ["en"])).toEqual({
202
- default: { login_button: { en: "Login" } },
203
- billing: { invoice_summary: { en: "{count} invoices" } },
204
- });
205
- });
206
- });
207
-
208
- describe("mergeNamespaceLocalesCore", () => {
209
- type TestDictionary = {
210
- welcome: Partial<Record<"en" | "it", string>>;
211
- login_button: Partial<Record<"en" | "it", string>>;
212
- };
213
-
214
- it("merges locale maps per key without dropping existing locales", () => {
215
- const existing: TestDictionary = {
216
- welcome: { en: "Welcome {name}!" },
217
- login_button: { en: "Login", it: "Accedi" },
218
- };
219
- const incoming = {
220
- welcome: { it: "Benvenuto {name}!" },
221
- };
222
-
223
- expect(mergeNamespaceLocalesCore(existing, incoming)).toEqual({
224
- welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
225
- login_button: { en: "Login", it: "Accedi" },
226
- });
227
- });
228
-
229
- it("overwrites the same locale when incoming provides a new template", () => {
230
- const existing = {
231
- welcome: { en: "Welcome {name}!" },
232
- };
233
- const incoming = {
234
- welcome: { en: "Hello {name}!" },
235
- };
236
-
237
- expect(mergeNamespaceLocalesCore(existing, incoming)).toEqual({
238
- welcome: { en: "Hello {name}!" },
239
- });
240
- });
241
- });
242
-
243
- describe("mergeDictionaryLocalesCore", () => {
244
- type TestMultiDictionary = {
245
- default: {
246
- welcome: Partial<Record<"en" | "it", string>>;
247
- };
248
- billing: {
249
- invoice_summary: Partial<Record<"en" | "it", string>>;
250
- };
251
- };
252
-
253
- it("merges each namespace without dropping existing locales", () => {
254
- const existing: TestMultiDictionary = {
255
- default: {
256
- welcome: { en: "Welcome {name}!" },
257
- },
258
- billing: {
259
- invoice_summary: { en: "{count} invoices" },
260
- },
261
- };
262
- const incoming = {
263
- default: {
264
- welcome: { it: "Benvenuto {name}!" },
265
- },
266
- billing: {
267
- invoice_summary: { it: "{count} fatture" },
268
- },
269
- };
270
-
271
- expect(mergeDictionaryLocalesCore(existing, incoming)).toEqual({
272
- default: {
273
- welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
274
- },
275
- billing: {
276
- invoice_summary: { en: "{count} invoices", it: "{count} fatture" },
277
- },
278
- });
279
- });
280
- });
@@ -1,211 +0,0 @@
1
- import { resolveLocaleTemplate } from "./resolve-locale.js";
2
- import type {
3
- KeyDictionary,
4
- LocaleFallbackMap,
5
- LocaleOfMulti,
6
- LocaleOfKeys,
7
- MultiDictionary,
8
- PartialKeyDictionary,
9
- PartialMultiDictionary,
10
- } from "./types.js";
11
-
12
- function classifyAreaLocales(
13
- areaLocales: readonly string[],
14
- localeFallback?: LocaleFallbackMap
15
- ): { full: string[]; preserve: string[] } {
16
- const areaSet = new Set(areaLocales);
17
- const full: string[] = [];
18
- const preserve: string[] = [];
19
-
20
- for (const locale of areaLocales) {
21
- const fallback = localeFallback?.[locale];
22
- if (fallback !== null && fallback !== undefined && areaSet.has(fallback)) {
23
- preserve.push(locale);
24
- } else {
25
- full.push(locale);
26
- }
27
- }
28
-
29
- return { full, preserve };
30
- }
31
-
32
- function projectKeyDictionary<T extends KeyDictionary>(
33
- dictionary: T,
34
- locales: readonly string[],
35
- localeFallback?: LocaleFallbackMap
36
- ): T {
37
- const targetLocales = [...new Set(locales)];
38
- const result = {} as T;
39
-
40
- for (const [key, localesByKey] of Object.entries(dictionary)) {
41
- const projected: Record<string, string> = {};
42
-
43
- for (const locale of targetLocales) {
44
- const resolved = resolveLocaleTemplate(localesByKey, locale, localeFallback);
45
- if (resolved !== undefined) {
46
- projected[locale] = resolved.template;
47
- }
48
- }
49
-
50
- if (Object.keys(projected).length > 0) {
51
- result[key as keyof T] = projected as T[keyof T];
52
- }
53
- }
54
-
55
- return result;
56
- }
57
-
58
- /**
59
- * Builds a namespace dictionary that contains only the requested locales.
60
- * For each key and target locale, copies the direct template when present;
61
- * otherwise resolves through `localeFallback` (same rules as `.get()` at runtime).
62
- */
63
- export function projectNamespaceLocalesCore<T extends KeyDictionary>(
64
- dictionary: T,
65
- locales: readonly string[],
66
- localeFallback?: LocaleFallbackMap
67
- ): T {
68
- return projectKeyDictionary(dictionary, locales, localeFallback);
69
- }
70
-
71
- /**
72
- * Projects a single namespace for a custom delivery area.
73
- * Full locales resolve through `localeFallback` (e.g. external chains like `it → en-US`);
74
- * preserve locales copy only their direct canonical entry when present.
75
- */
76
- export function projectNamespaceForDeliveryAreaCore<T extends KeyDictionary>(
77
- dictionary: T,
78
- areaLocales: readonly string[],
79
- localeFallback?: LocaleFallbackMap
80
- ): T {
81
- const { full, preserve } = classifyAreaLocales(areaLocales, localeFallback);
82
- const fullProjection =
83
- full.length > 0 ? projectKeyDictionary(dictionary, full, localeFallback) : undefined;
84
- const result = {} as T;
85
-
86
- for (const [key, localesByKey] of Object.entries(dictionary)) {
87
- const projected: Record<string, string> = {};
88
-
89
- if (fullProjection !== undefined) {
90
- const fullEntry = fullProjection[key as keyof T];
91
- if (fullEntry !== undefined) {
92
- Object.assign(projected, fullEntry);
93
- }
94
- }
95
-
96
- for (const locale of preserve) {
97
- const template = localesByKey[locale];
98
- if (template !== undefined) {
99
- projected[locale] = template;
100
- }
101
- }
102
-
103
- if (Object.keys(projected).length > 0) {
104
- result[key as keyof T] = projected as T[keyof T];
105
- }
106
- }
107
-
108
- return result;
109
- }
110
-
111
- /**
112
- * Projects a multi-namespace dictionary for a custom delivery area.
113
- * Each namespace uses the same delivery-area rules as `projectNamespaceForDeliveryAreaCore`.
114
- */
115
- export function projectDictionaryForDeliveryAreaCore<T extends MultiDictionary>(
116
- dictionary: T,
117
- areaLocales: readonly string[],
118
- localeFallback?: LocaleFallbackMap
119
- ): T {
120
- const result = {} as T;
121
-
122
- for (const namespace of Object.keys(dictionary) as (keyof T & string)[]) {
123
- const namespaceDictionary = dictionary[namespace];
124
- if (namespaceDictionary === undefined) {
125
- continue;
126
- }
127
-
128
- result[namespace] = projectNamespaceForDeliveryAreaCore(
129
- namespaceDictionary,
130
- areaLocales,
131
- localeFallback
132
- ) as T[typeof namespace];
133
- }
134
-
135
- return result;
136
- }
137
-
138
- /**
139
- * Projects every namespace in a multi-namespace dictionary.
140
- */
141
- export function projectDictionaryLocalesCore<T extends MultiDictionary>(
142
- dictionary: T,
143
- locales: readonly string[],
144
- localeFallback?: LocaleFallbackMap
145
- ): T {
146
- const result = {} as T;
147
-
148
- for (const namespace of Object.keys(dictionary) as (keyof T & string)[]) {
149
- const namespaceDictionary = dictionary[namespace];
150
- if (namespaceDictionary === undefined) {
151
- continue;
152
- }
153
-
154
- result[namespace] = projectKeyDictionary(
155
- namespaceDictionary,
156
- locales,
157
- localeFallback
158
- ) as T[typeof namespace];
159
- }
160
-
161
- return result;
162
- }
163
-
164
- /**
165
- * Merges locale entries per translation key without dropping keys or locales
166
- * already present in `existing`. Incoming locales overwrite the same locale on conflict.
167
- */
168
- export function mergeNamespaceLocalesCore<
169
- T extends KeyDictionary,
170
- Locales extends string = LocaleOfKeys<T>,
171
- >(existing: T, incoming: PartialKeyDictionary<T, Locales>): T {
172
- const merged = structuredClone(existing);
173
-
174
- for (const [key, incomingLocales] of Object.entries(incoming)) {
175
- if (incomingLocales === undefined || typeof incomingLocales !== "object") {
176
- continue;
177
- }
178
-
179
- merged[key as keyof T] = {
180
- ...(merged[key as keyof T] ?? {}),
181
- ...incomingLocales,
182
- } as T[keyof T];
183
- }
184
-
185
- return merged;
186
- }
187
-
188
- /**
189
- * Merges locale entries per key in every namespace present in `incoming`.
190
- * Namespaces and keys not in `incoming` are preserved from `existing`.
191
- */
192
- export function mergeDictionaryLocalesCore<
193
- T extends MultiDictionary,
194
- Locales extends string = LocaleOfMulti<T>,
195
- >(existing: T, incoming: PartialMultiDictionary<T, Locales>): T {
196
- const merged = structuredClone(existing) as T;
197
-
198
- for (const namespace of Object.keys(incoming) as (keyof T & string)[]) {
199
- const incomingNamespace = incoming[namespace];
200
- if (incomingNamespace === undefined) {
201
- continue;
202
- }
203
-
204
- merged[namespace] = mergeNamespaceLocalesCore(
205
- (merged[namespace] ?? {}) as T[typeof namespace],
206
- incomingNamespace as T[typeof namespace]
207
- ) as T[typeof namespace];
208
- }
209
-
210
- return merged;
211
- }
@@ -1,91 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- formatLocaleFallbackChain,
4
- resolveLocaleTemplate,
5
- validateLocaleFallback,
6
- } from "./resolve-locale.js";
7
-
8
- const fallbackMap = {
9
- en: null,
10
- "de-DE": "en",
11
- "de-CH": "de-DE",
12
- it: "en",
13
- } as const;
14
-
15
- const localeByKey = {
16
- en: "Hello",
17
- it: "Ciao",
18
- };
19
-
20
- describe("resolveLocaleTemplate", () => {
21
- it("returns the template for the requested locale when present", () => {
22
- expect(resolveLocaleTemplate(localeByKey, "en")).toEqual({
23
- template: "Hello",
24
- resolvedLocale: "en",
25
- fallbackChain: ["en"],
26
- });
27
- });
28
-
29
- it("follows the fallback chain until a template is found", () => {
30
- expect(resolveLocaleTemplate(localeByKey, "de-CH", fallbackMap)).toEqual({
31
- template: "Hello",
32
- resolvedLocale: "en",
33
- fallbackChain: ["de-CH", "de-DE", "en"],
34
- });
35
- });
36
-
37
- it("uses an intermediate locale when it has a template", () => {
38
- const withGerman = { ...localeByKey, "de-DE": "Hallo" };
39
- expect(resolveLocaleTemplate(withGerman, "de-CH", fallbackMap)).toEqual({
40
- template: "Hallo",
41
- resolvedLocale: "de-DE",
42
- fallbackChain: ["de-CH", "de-DE"],
43
- });
44
- });
45
-
46
- it("treats an empty string template as present", () => {
47
- expect(resolveLocaleTemplate({ en: "" }, "en")).toEqual({
48
- template: "",
49
- resolvedLocale: "en",
50
- fallbackChain: ["en"],
51
- });
52
- });
53
-
54
- it("returns undefined when the chain ends with null fallback", () => {
55
- expect(resolveLocaleTemplate(localeByKey, "de-CH", { "de-CH": null })).toBeUndefined();
56
- });
57
-
58
- it("returns undefined when the locale is unknown and has no fallback map entry", () => {
59
- expect(resolveLocaleTemplate(localeByKey, "fr", fallbackMap)).toBeUndefined();
60
- });
61
-
62
- it("throws on circular fallback chains", () => {
63
- expect(() =>
64
- resolveLocaleTemplate(localeByKey, "de-CH", {
65
- "de-CH": "de-DE",
66
- "de-DE": "de-CH",
67
- })
68
- ).toThrow("[i18n] Circular locale fallback detected");
69
- });
70
- });
71
-
72
- describe("validateLocaleFallback", () => {
73
- it("accepts acyclic fallback maps", () => {
74
- expect(() => validateLocaleFallback(fallbackMap)).not.toThrow();
75
- });
76
-
77
- it("rejects circular fallback maps at construction time", () => {
78
- expect(() =>
79
- validateLocaleFallback({
80
- a: "b",
81
- b: "a",
82
- })
83
- ).toThrow("[i18n] Circular locale fallback detected");
84
- });
85
- });
86
-
87
- describe("formatLocaleFallbackChain", () => {
88
- it("formats the fallback chain for error messages", () => {
89
- expect(formatLocaleFallbackChain("de-CH", fallbackMap)).toBe("de-CH → de-DE → en");
90
- });
91
- });