@xndrjs/i18n 0.8.0 → 0.8.2-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 +1 -2
- package/dist/cli/audit.js +739 -0
- package/dist/cli/audit.js.map +1 -0
- package/dist/cli/codegen.js +1471 -0
- package/dist/cli/codegen.js.map +1 -0
- package/dist/cli/setup.js +148 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/codegen/index.js +43 -40
- package/dist/codegen/index.js.map +1 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.js +56 -10
- package/dist/index.js.map +1 -1
- package/package.json +6 -9
- package/bin/audit.mjs +0 -14
- package/bin/codegen.mjs +0 -17
- package/bin/setup.mjs +0 -14
- package/src/IcuTranslationProviderMulti.test.ts +0 -215
- package/src/IcuTranslationProviderMulti.ts +0 -176
- package/src/audit/audit-dictionaries.test.ts +0 -134
- package/src/audit/audit-dictionaries.ts +0 -178
- package/src/audit/run-audit.test.ts +0 -169
- package/src/audit/run-audit.ts +0 -94
- package/src/builder-load-registry.ts +0 -39
- package/src/builder-loaders.ts +0 -15
- package/src/builder-types.test.ts +0 -49
- package/src/builder-types.ts +0 -24
- package/src/builder.test.ts +0 -260
- package/src/builder.ts +0 -10
- package/src/codegen/codegen-config-schema.ts +0 -156
- package/src/codegen/config.test.ts +0 -231
- package/src/codegen/config.ts +0 -42
- package/src/codegen/constants.ts +0 -9
- package/src/codegen/delivery-artifacts.test.ts +0 -167
- package/src/codegen/delivery-artifacts.ts +0 -154
- package/src/codegen/dictionary-spec-contract.test.ts +0 -28
- package/src/codegen/dictionary-spec-contract.ts +0 -138
- package/src/codegen/emit/dictionary-schema-file.ts +0 -113
- package/src/codegen/emit/instance-file.test.ts +0 -71
- package/src/codegen/emit/instance-file.ts +0 -257
- package/src/codegen/emit/namespace-loaders-file.test.ts +0 -251
- package/src/codegen/emit/namespace-loaders-file.ts +0 -229
- package/src/codegen/emit/types-file.test.ts +0 -96
- package/src/codegen/emit/types-file.ts +0 -142
- package/src/codegen/fixtures/billing-namespace.json +0 -6
- package/src/codegen/generate-i18n-types.test.ts +0 -1137
- package/src/codegen/generate-i18n-types.ts +0 -20
- package/src/codegen/icu-analysis.ts +0 -110
- package/src/codegen/locale-fallback.ts +0 -73
- package/src/codegen/locale-policy.test.ts +0 -35
- package/src/codegen/locale-policy.ts +0 -30
- package/src/codegen/paths.ts +0 -44
- package/src/codegen/project-locales-set-namespace.test.ts +0 -134
- package/src/codegen/read-dictionary.test.ts +0 -594
- package/src/codegen/read-dictionary.ts +0 -260
- package/src/codegen/regenerate-namespaces.ts +0 -180
- package/src/codegen/run-codegen.test.ts +0 -230
- package/src/codegen/run-codegen.ts +0 -252
- package/src/codegen/types.ts +0 -10
- package/src/codegen/write-file-if-changed.test.ts +0 -42
- package/src/codegen/write-file-if-changed.ts +0 -20
- package/src/codegen-config/build-config.ts +0 -15
- package/src/codegen-config/codegen-config.test.ts +0 -36
- package/src/codegen-config/index.ts +0 -37
- package/src/codegen-config/type-names.ts +0 -20
- package/src/codegen-config/write-config.ts +0 -8
- package/src/engine.ts +0 -47
- package/src/fetch-artifact.ts +0 -16
- package/src/format-core.ts +0 -91
- package/src/i18n-handle.ts +0 -202
- package/src/icu/extract-variables.test.ts +0 -199
- package/src/icu/extract-variables.ts +0 -227
- package/src/icu/parse-template.ts +0 -31
- package/src/index.ts +0 -58
- package/src/project-locales.test.ts +0 -280
- package/src/project-locales.ts +0 -211
- package/src/resolve-locale.test.ts +0 -91
- package/src/resolve-locale.ts +0 -88
- package/src/scope-multi.ts +0 -105
- package/src/scope-types.ts +0 -35
- package/src/scope.test.ts +0 -172
- package/src/serialized-state.test.ts +0 -114
- package/src/serialized-state.ts +0 -28
- package/src/setup/setup-i18n.test.ts +0 -85
- package/src/setup/setup-i18n.ts +0 -156
- package/src/types.ts +0 -65
- package/src/validation/create-args-schema.ts +0 -76
- package/src/validation/create-normalized-schema.ts +0 -52
- package/src/validation/errors.ts +0 -43
- package/src/validation/index.ts +0 -202
- package/src/validation/normalize.ts +0 -299
- package/src/validation/to-dictionary.ts +0 -67
- package/src/validation/types.ts +0 -82
- package/src/validation/validate-normalized.ts +0 -133
- package/src/validation/validation.test.ts +0 -562
package/src/i18n-handle.ts
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
import { invokeNamespaceLoader, type NamespaceLoader } from "./builder-loaders.js";
|
|
2
|
-
import type { I18nEngineMultiImpl } from "./engine.js";
|
|
3
|
-
import type {
|
|
4
|
-
LocaleFallbackMap,
|
|
5
|
-
LocaleOfMulti,
|
|
6
|
-
MultiDictionary,
|
|
7
|
-
PartialKeyDictionary,
|
|
8
|
-
PartialMultiDictionary,
|
|
9
|
-
} from "./types.js";
|
|
10
|
-
import type { I18nScopeMultiForLocale } from "./scope-multi.js";
|
|
11
|
-
import type { MultiParams, ParamsForNamespaces, SchemaForNamespaces } from "./scope-types.js";
|
|
12
|
-
|
|
13
|
-
/** Engine handle accepted by {@link createI18nHandle} (with or without localeFallback). */
|
|
14
|
-
type HandleEngine<
|
|
15
|
-
Schema extends MultiDictionary,
|
|
16
|
-
Params extends MultiParams<Schema>,
|
|
17
|
-
RequestLocales extends string,
|
|
18
|
-
> = I18nEngineMultiImpl<Schema, Params, RequestLocales, LocaleFallbackMap | undefined>;
|
|
19
|
-
|
|
20
|
-
/** Non-empty namespace list — required by {@link I18nHandle.load} / {@link I18nHandle.peek}. */
|
|
21
|
-
type NonEmptyNamespaces<NS extends readonly string[]> = NS extends readonly [] ? never : NS;
|
|
22
|
-
|
|
23
|
-
/** Maps a request locale to the delivery partition key (locale itself, or a custom area). */
|
|
24
|
-
export type PartitionForLocale<RequestLocales extends string> = (locale: RequestLocales) => string;
|
|
25
|
-
|
|
26
|
-
export type I18nHandleOptions<
|
|
27
|
-
Schema extends MultiDictionary,
|
|
28
|
-
RequestLocales extends string = LocaleOfMulti<Schema>,
|
|
29
|
-
> = {
|
|
30
|
-
namespaceLoaders?: {
|
|
31
|
-
[NS in keyof Schema & string]?: NamespaceLoader<
|
|
32
|
-
PartialKeyDictionary<Schema[NS], RequestLocales> | Schema[NS]
|
|
33
|
-
>;
|
|
34
|
-
};
|
|
35
|
-
/**
|
|
36
|
-
* Resolves the loader partition for a locale.
|
|
37
|
-
* Default: identity (`locale → locale`) for split-by-locale.
|
|
38
|
-
* Custom delivery: codegen injects `locale → LOCALE_DELIVERY_AREA[locale]`.
|
|
39
|
-
*/
|
|
40
|
-
partitionForLocale?: PartitionForLocale<RequestLocales>;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export type LoadNamespacesInput<
|
|
44
|
-
Schema extends MultiDictionary,
|
|
45
|
-
NsList extends readonly (keyof Schema & string)[],
|
|
46
|
-
Locale extends string,
|
|
47
|
-
> = {
|
|
48
|
-
namespaces: NonEmptyNamespaces<NsList>;
|
|
49
|
-
locale: Locale;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/** Locale-bound scope returned by {@link I18nHandle.load} / {@link I18nHandle.peek}. */
|
|
53
|
-
export type ScopeForLocale<
|
|
54
|
-
Schema extends MultiDictionary,
|
|
55
|
-
Params extends MultiParams<Schema>,
|
|
56
|
-
RequestLocales extends string,
|
|
57
|
-
NsList extends readonly (keyof Schema & string)[],
|
|
58
|
-
Locale extends RequestLocales,
|
|
59
|
-
> = I18nScopeMultiForLocale<
|
|
60
|
-
SchemaForNamespaces<Schema, NsList>,
|
|
61
|
-
ParamsForNamespaces<Schema, Params, NsList>,
|
|
62
|
-
RequestLocales,
|
|
63
|
-
Locale
|
|
64
|
-
>;
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Single runtime handle: pass `namespaces` + `locale`; partition is derived via
|
|
68
|
-
* {@link I18nHandleOptions.partitionForLocale}.
|
|
69
|
-
*/
|
|
70
|
-
export interface I18nHandle<
|
|
71
|
-
Schema extends MultiDictionary,
|
|
72
|
-
Params extends MultiParams<Schema>,
|
|
73
|
-
RequestLocales extends string = LocaleOfMulti<Schema>,
|
|
74
|
-
> {
|
|
75
|
-
load<const NsList extends readonly (keyof Schema & string)[], Locale extends RequestLocales>(
|
|
76
|
-
input: LoadNamespacesInput<Schema, NsList, Locale>
|
|
77
|
-
): Promise<ScopeForLocale<Schema, Params, RequestLocales, NsList, Locale>>;
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Sync scope when every requested resource is already marked loaded (e.g. hydrate seed).
|
|
81
|
-
* Returns `null` when a normal async {@link load} is still required.
|
|
82
|
-
*/
|
|
83
|
-
peek<const NsList extends readonly (keyof Schema & string)[], Locale extends RequestLocales>(
|
|
84
|
-
input: LoadNamespacesInput<Schema, NsList, Locale>
|
|
85
|
-
): ScopeForLocale<Schema, Params, RequestLocales, NsList, Locale> | null;
|
|
86
|
-
|
|
87
|
-
/** Dictionary (loaded namespaces) + resources for SSR→CSR hydrate. */
|
|
88
|
-
serialize(): {
|
|
89
|
-
dictionary: PartialMultiDictionary<Schema, RequestLocales>;
|
|
90
|
-
resources: readonly (readonly [string, string])[];
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export class I18nHandleImpl<
|
|
95
|
-
Schema extends MultiDictionary,
|
|
96
|
-
Params extends MultiParams<Schema>,
|
|
97
|
-
RequestLocales extends string = LocaleOfMulti<Schema>,
|
|
98
|
-
> implements I18nHandle<Schema, Params, RequestLocales> {
|
|
99
|
-
private readonly partitionForLocale: PartitionForLocale<RequestLocales>;
|
|
100
|
-
|
|
101
|
-
constructor(
|
|
102
|
-
private readonly engine: HandleEngine<Schema, Params, RequestLocales>,
|
|
103
|
-
private readonly options?: I18nHandleOptions<Schema, RequestLocales>
|
|
104
|
-
) {
|
|
105
|
-
this.partitionForLocale = options?.partitionForLocale ?? ((locale) => locale);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async load<
|
|
109
|
-
const NsList extends readonly (keyof Schema & string)[],
|
|
110
|
-
Locale extends RequestLocales,
|
|
111
|
-
>(
|
|
112
|
-
input: LoadNamespacesInput<Schema, NsList, Locale>
|
|
113
|
-
): Promise<ScopeForLocale<Schema, Params, RequestLocales, NsList, Locale>> {
|
|
114
|
-
await applyHandleLoad(this.engine, this.options, input, this.partitionForLocale);
|
|
115
|
-
return this.engine.toScope({ namespaces: input.namespaces }).forLocale(input.locale);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
peek<const NsList extends readonly (keyof Schema & string)[], Locale extends RequestLocales>(
|
|
119
|
-
input: LoadNamespacesInput<Schema, NsList, Locale>
|
|
120
|
-
): ScopeForLocale<Schema, Params, RequestLocales, NsList, Locale> | null {
|
|
121
|
-
if (!areHandleResourcesReady(this.engine, this.options, input, this.partitionForLocale)) {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
return this.engine.toScope({ namespaces: input.namespaces }).forLocale(input.locale);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
serialize() {
|
|
128
|
-
return this.engine.serialize();
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function createI18nHandle<
|
|
133
|
-
Schema extends MultiDictionary,
|
|
134
|
-
Params extends MultiParams<Schema>,
|
|
135
|
-
RequestLocales extends string = LocaleOfMulti<Schema>,
|
|
136
|
-
>(
|
|
137
|
-
engine: HandleEngine<Schema, Params, RequestLocales>,
|
|
138
|
-
options?: I18nHandleOptions<Schema, RequestLocales>
|
|
139
|
-
): I18nHandle<Schema, Params, RequestLocales> {
|
|
140
|
-
return new I18nHandleImpl(engine, options);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function areHandleResourcesReady<
|
|
144
|
-
Schema extends MultiDictionary,
|
|
145
|
-
Params extends MultiParams<Schema>,
|
|
146
|
-
RequestLocales extends string,
|
|
147
|
-
NsList extends readonly (keyof Schema & string)[],
|
|
148
|
-
Locale extends RequestLocales,
|
|
149
|
-
>(
|
|
150
|
-
engine: HandleEngine<Schema, Params, RequestLocales>,
|
|
151
|
-
options: I18nHandleOptions<Schema, RequestLocales> | undefined,
|
|
152
|
-
input: LoadNamespacesInput<Schema, NsList, Locale>,
|
|
153
|
-
partitionForLocale: PartitionForLocale<RequestLocales>
|
|
154
|
-
): boolean {
|
|
155
|
-
if (input.namespaces.length === 0) {
|
|
156
|
-
return false;
|
|
157
|
-
}
|
|
158
|
-
const partition = partitionForLocale(input.locale);
|
|
159
|
-
return input.namespaces.every((namespace) => {
|
|
160
|
-
const loader = options?.namespaceLoaders?.[namespace];
|
|
161
|
-
if (loader === undefined) {
|
|
162
|
-
return true;
|
|
163
|
-
}
|
|
164
|
-
return engine.hasBuilderResourceLoaded(namespace, partition);
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
async function applyHandleLoad<
|
|
169
|
-
Schema extends MultiDictionary,
|
|
170
|
-
Params extends MultiParams<Schema>,
|
|
171
|
-
RequestLocales extends string,
|
|
172
|
-
NsList extends readonly (keyof Schema & string)[],
|
|
173
|
-
Locale extends RequestLocales,
|
|
174
|
-
>(
|
|
175
|
-
engine: HandleEngine<Schema, Params, RequestLocales>,
|
|
176
|
-
options: I18nHandleOptions<Schema, RequestLocales> | undefined,
|
|
177
|
-
input: LoadNamespacesInput<Schema, NsList, Locale>,
|
|
178
|
-
partitionForLocale: PartitionForLocale<RequestLocales>
|
|
179
|
-
): Promise<void> {
|
|
180
|
-
if (input.namespaces.length === 0) {
|
|
181
|
-
throw new Error("[i18n] load() requires a non-empty namespaces array.");
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const partition = partitionForLocale(input.locale);
|
|
185
|
-
|
|
186
|
-
await Promise.all(
|
|
187
|
-
input.namespaces.map(async (namespace) => {
|
|
188
|
-
const loader = options?.namespaceLoaders?.[namespace];
|
|
189
|
-
if (loader === undefined) {
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (engine.hasBuilderResourceLoaded(namespace, partition)) {
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const data = await invokeNamespaceLoader(loader, partition, { locale: input.locale });
|
|
198
|
-
engine.applyLoadMergeNamespace(namespace, data);
|
|
199
|
-
engine.markBuilderResourceLoaded(namespace, partition);
|
|
200
|
-
})
|
|
201
|
-
);
|
|
202
|
-
}
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import { parse } from "@formatjs/icu-messageformat-parser";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
4
|
-
extractVariableMeta,
|
|
5
|
-
inferMergedVariableType,
|
|
6
|
-
mergeVariableMetaAcrossLocales,
|
|
7
|
-
variableMetaToSpec,
|
|
8
|
-
type VariableRole,
|
|
9
|
-
} from "./extract-variables.js";
|
|
10
|
-
|
|
11
|
-
function metaFor(template: string) {
|
|
12
|
-
return extractVariableMeta(parse(template));
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function mergeTemplates(...templates: string[]) {
|
|
16
|
-
return mergeVariableMetaAcrossLocales(templates.map((template) => metaFor(template)));
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function roles(...items: VariableRole[]) {
|
|
20
|
-
return new Set(items);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
describe("inferMergedVariableType", () => {
|
|
24
|
-
describe("string family", () => {
|
|
25
|
-
it.each<[VariableRole[], "string"]>([
|
|
26
|
-
[["simple"], "string"],
|
|
27
|
-
[["select"], "string"],
|
|
28
|
-
[["simple", "select"], "string"],
|
|
29
|
-
])("accepts %j → %s", (items, expected) => {
|
|
30
|
-
expect(inferMergedVariableType(roles(...items))).toBe(expected);
|
|
31
|
-
});
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
describe("number family", () => {
|
|
35
|
-
it.each<[VariableRole[], "number"]>([
|
|
36
|
-
[["plural"], "number"],
|
|
37
|
-
[["selectordinal"], "number"],
|
|
38
|
-
[["number"], "number"],
|
|
39
|
-
[["simple", "plural"], "number"],
|
|
40
|
-
[["simple", "selectordinal"], "number"],
|
|
41
|
-
[["simple", "number"], "number"],
|
|
42
|
-
[["plural", "number"], "number"],
|
|
43
|
-
[["selectordinal", "number"], "number"],
|
|
44
|
-
])("accepts %j → %s", (items, expected) => {
|
|
45
|
-
expect(inferMergedVariableType(roles(...items))).toBe(expected);
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
describe("date family", () => {
|
|
50
|
-
it.each<[VariableRole[], "date"]>([
|
|
51
|
-
[["date"], "date"],
|
|
52
|
-
[["time"], "date"],
|
|
53
|
-
[["date", "time"], "date"],
|
|
54
|
-
])("accepts %j → %s", (items, expected) => {
|
|
55
|
-
expect(inferMergedVariableType(roles(...items))).toBe(expected);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe("conflicts", () => {
|
|
60
|
-
it.each<{ label: string; roles: VariableRole[] }>([
|
|
61
|
-
{ label: "plural + select", roles: ["plural", "select"] },
|
|
62
|
-
{ label: "selectordinal + select", roles: ["selectordinal", "select"] },
|
|
63
|
-
{ label: "plural + selectordinal", roles: ["plural", "selectordinal"] },
|
|
64
|
-
{ label: "select + number", roles: ["select", "number"] },
|
|
65
|
-
{ label: "select + simple + number", roles: ["select", "simple", "number"] },
|
|
66
|
-
{ label: "plural + simple + select", roles: ["plural", "simple", "select"] },
|
|
67
|
-
{ label: "date + simple", roles: ["date", "simple"] },
|
|
68
|
-
{ label: "date + plural", roles: ["date", "plural"] },
|
|
69
|
-
{ label: "time + select", roles: ["time", "select"] },
|
|
70
|
-
{ label: "date + number", roles: ["date", "number"] },
|
|
71
|
-
])("rejects $label", ({ roles: roleList }) => {
|
|
72
|
-
expect(inferMergedVariableType(roles(...roleList))).toBe("CONFLICT");
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe("mergeVariableMetaAcrossLocales", () => {
|
|
78
|
-
describe("compatible cross-locale merges", () => {
|
|
79
|
-
it("infers string for simple interpolation in every locale", () => {
|
|
80
|
-
const result = mergeTemplates("Welcome {name}!", "Benvenuto {name}!");
|
|
81
|
-
|
|
82
|
-
expect(result).toEqual({ ok: true, merged: { name: "string" } });
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("infers string when one locale uses select and another uses simple interpolation", () => {
|
|
86
|
-
const result = mergeTemplates(
|
|
87
|
-
"{gender, select, female {she} other {they}}",
|
|
88
|
-
"Pronome: {gender}"
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
expect(result).toEqual({ ok: true, merged: { gender: "string" } });
|
|
92
|
-
expect(variableMetaToSpec(metaFor("Pronome: {gender}"))).toEqual({ gender: "string" });
|
|
93
|
-
expect(variableMetaToSpec(metaFor("{gender, select, other {x}}"))).toEqual({
|
|
94
|
-
gender: "string",
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("infers number when English uses plural and Italian uses simple interpolation", () => {
|
|
99
|
-
const result = mergeTemplates(
|
|
100
|
-
"You have {count, plural, one {1 invoice} other {# invoices}}",
|
|
101
|
-
"Hai {count} fatture"
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
expect(result).toEqual({ ok: true, merged: { count: "number" } });
|
|
105
|
-
expect(variableMetaToSpec(metaFor("Hai {count} fatture"))).toEqual({ count: "string" });
|
|
106
|
-
expect(
|
|
107
|
-
variableMetaToSpec(metaFor("You have {count, plural, one {1 invoice} other {# invoices}}"))
|
|
108
|
-
).toEqual({ count: "number" });
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("infers number when one locale uses selectordinal and another uses simple interpolation", () => {
|
|
112
|
-
const result = mergeTemplates(
|
|
113
|
-
"You finished {position, selectordinal, one {#st} other {#th}}",
|
|
114
|
-
"Sei al {position} posto"
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
expect(result).toEqual({ ok: true, merged: { position: "number" } });
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("infers number when one locale uses plural and another uses number format", () => {
|
|
121
|
-
const result = mergeTemplates(
|
|
122
|
-
"{amount, plural, one {1} other {#}}",
|
|
123
|
-
"Totale: {amount, number}"
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
expect(result).toEqual({ ok: true, merged: { amount: "number" } });
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("infers date when locales use date and time on the same variable", () => {
|
|
130
|
-
const result = mergeTemplates("{due, date, short}", "{due, time, short}");
|
|
131
|
-
|
|
132
|
-
expect(result).toEqual({ ok: true, merged: { due: "date" } });
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
describe("incompatible cross-locale merges", () => {
|
|
137
|
-
it("rejects plural and select on the same variable", () => {
|
|
138
|
-
const result = mergeTemplates(
|
|
139
|
-
"{myVar, select, other {x}}",
|
|
140
|
-
"{myVar, plural, one {1} other {#}}"
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
expect(result.ok).toBe(false);
|
|
144
|
-
if (!result.ok) {
|
|
145
|
-
expect(result.message).toContain('Incompatible ICU variable "myVar"');
|
|
146
|
-
}
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("rejects plural and selectordinal on the same variable", () => {
|
|
150
|
-
const result = mergeTemplates(
|
|
151
|
-
"{rank, plural, one {1} other {#}}",
|
|
152
|
-
"{rank, selectordinal, one {#st} other {#th}}"
|
|
153
|
-
);
|
|
154
|
-
|
|
155
|
-
expect(result.ok).toBe(false);
|
|
156
|
-
if (!result.ok) {
|
|
157
|
-
expect(result.message).toContain('Incompatible ICU variable "rank"');
|
|
158
|
-
expect(result.message).toContain("plural");
|
|
159
|
-
expect(result.message).toContain("selectordinal");
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("rejects select and number format on the same variable", () => {
|
|
164
|
-
const result = mergeTemplates("{value, select, other {x}}", "Valore: {value, number}");
|
|
165
|
-
|
|
166
|
-
expect(result.ok).toBe(false);
|
|
167
|
-
if (!result.ok) {
|
|
168
|
-
expect(result.message).toContain('Incompatible ICU variable "value"');
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("rejects date and simple interpolation on the same variable", () => {
|
|
173
|
-
const result = mergeTemplates("{due, date, short}", "Scadenza {due}");
|
|
174
|
-
|
|
175
|
-
expect(result.ok).toBe(false);
|
|
176
|
-
if (!result.ok) {
|
|
177
|
-
expect(result.message).toContain('Incompatible ICU variable "due"');
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("rejects different variable names across locales", () => {
|
|
182
|
-
const result = mergeTemplates("Welcome {name}!", "Benvenuto {nome}!");
|
|
183
|
-
|
|
184
|
-
expect(result.ok).toBe(false);
|
|
185
|
-
if (!result.ok) {
|
|
186
|
-
expect(result.message).toContain("Inconsistent ICU variable names across locales");
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("rejects when one locale omits a variable present in another", () => {
|
|
191
|
-
const result = mergeTemplates("Welcome {name}!", "{name} and {count}");
|
|
192
|
-
|
|
193
|
-
expect(result.ok).toBe(false);
|
|
194
|
-
if (!result.ok) {
|
|
195
|
-
expect(result.message).toContain("Inconsistent ICU variable names across locales");
|
|
196
|
-
}
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
});
|
|
@@ -1,227 +0,0 @@
|
|
|
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
|
-
export type VariableRole =
|
|
7
|
-
| "simple"
|
|
8
|
-
| "plural"
|
|
9
|
-
| "selectordinal"
|
|
10
|
-
| "select"
|
|
11
|
-
| "number"
|
|
12
|
-
| "date"
|
|
13
|
-
| "time";
|
|
14
|
-
|
|
15
|
-
export type VariableMeta = {
|
|
16
|
-
type: VariableType;
|
|
17
|
-
roles: Set<VariableRole>;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export type VariableMetaSpec = Record<string, VariableMeta>;
|
|
21
|
-
|
|
22
|
-
type IcuAst = ReturnType<typeof parse>;
|
|
23
|
-
type IcuNode = IcuAst[number];
|
|
24
|
-
|
|
25
|
-
function isPluralOrOrdinalNode(node: IcuNode): node is Extract<IcuNode, { type: 6 }> {
|
|
26
|
-
return node.type === 6 && "pluralType" in node && Boolean(node.pluralType);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function hasVariableName(node: IcuNode): node is Extract<IcuNode, { value: string }> {
|
|
30
|
-
return "value" in node && typeof node.value === "string";
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function addVariableMeta(
|
|
34
|
-
variables: VariableMetaSpec,
|
|
35
|
-
name: string,
|
|
36
|
-
type: VariableType,
|
|
37
|
-
role: VariableRole
|
|
38
|
-
): void {
|
|
39
|
-
const existing = variables[name];
|
|
40
|
-
if (!existing) {
|
|
41
|
-
variables[name] = { type, roles: new Set([role]) };
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
existing.roles.add(role);
|
|
46
|
-
existing.type = mergeVariableTypes(existing.type, type);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function mergeVariableTypes(left: VariableType, right: VariableType): VariableType {
|
|
50
|
-
if (left === right) {
|
|
51
|
-
return left;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if ((left === "string" && right === "number") || (left === "number" && right === "string")) {
|
|
55
|
-
return "number";
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return right;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function extractVariableMeta(nodes: IcuAst): VariableMetaSpec {
|
|
62
|
-
const variables: VariableMetaSpec = {};
|
|
63
|
-
|
|
64
|
-
const walk = (walkNodes: IcuAst) => {
|
|
65
|
-
for (const node of walkNodes) {
|
|
66
|
-
if (node.type === 1 && hasVariableName(node)) {
|
|
67
|
-
addVariableMeta(variables, node.value, "string", "simple");
|
|
68
|
-
} else if (node.type === 2 && hasVariableName(node)) {
|
|
69
|
-
addVariableMeta(variables, node.value, "number", "number");
|
|
70
|
-
} else if (isPluralOrOrdinalNode(node) && hasVariableName(node)) {
|
|
71
|
-
const role = node.pluralType === "ordinal" ? "selectordinal" : "plural";
|
|
72
|
-
addVariableMeta(variables, node.value, "number", role);
|
|
73
|
-
} else if (node.type === 3 && hasVariableName(node)) {
|
|
74
|
-
addVariableMeta(variables, node.value, "date", "date");
|
|
75
|
-
} else if (node.type === 4 && hasVariableName(node)) {
|
|
76
|
-
addVariableMeta(variables, node.value, "date", "time");
|
|
77
|
-
} else if (node.type === 5 && hasVariableName(node)) {
|
|
78
|
-
addVariableMeta(variables, node.value, "string", "select");
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if ("options" in node && node.options) {
|
|
82
|
-
for (const option of Object.values(node.options)) {
|
|
83
|
-
walk(option.value);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if ("children" in node && Array.isArray(node.children)) {
|
|
88
|
-
walk(node.children);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
walk(nodes);
|
|
94
|
-
return variables;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function variableMetaToSpec(meta: VariableMetaSpec): VariableSpec {
|
|
98
|
-
return Object.fromEntries(Object.entries(meta).map(([name, entry]) => [name, entry.type]));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function extractVariables(nodes: IcuAst): VariableSpec {
|
|
102
|
-
return variableMetaToSpec(extractVariableMeta(nodes));
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function inferMergedVariableType(roles: Set<VariableRole>): VariableType | "CONFLICT" {
|
|
106
|
-
const hasPlural = roles.has("plural");
|
|
107
|
-
const hasSelectordinal = roles.has("selectordinal");
|
|
108
|
-
const hasSelect = roles.has("select");
|
|
109
|
-
const hasSimple = roles.has("simple");
|
|
110
|
-
const hasNumber = roles.has("number");
|
|
111
|
-
const hasDate = roles.has("date");
|
|
112
|
-
const hasTime = roles.has("time");
|
|
113
|
-
|
|
114
|
-
// Incompatible numeric ICU constructs on the same variable.
|
|
115
|
-
if (hasPlural && hasSelectordinal) {
|
|
116
|
-
return "CONFLICT";
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Select (string-family) vs plural / ordinal / number-format (number-family).
|
|
120
|
-
if (hasSelect && (hasPlural || hasSelectordinal || hasNumber)) {
|
|
121
|
-
return "CONFLICT";
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Legacy explicit pairs — covered by the rule above, kept for clarity.
|
|
125
|
-
if (hasPlural && hasSelect) {
|
|
126
|
-
return "CONFLICT";
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (hasSelectordinal && hasSelect) {
|
|
130
|
-
return "CONFLICT";
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (hasDate || hasTime) {
|
|
134
|
-
if (hasPlural || hasSelect || hasSelectordinal || hasSimple || hasNumber) {
|
|
135
|
-
return "CONFLICT";
|
|
136
|
-
}
|
|
137
|
-
return "date";
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (hasPlural || hasSelectordinal || hasNumber) {
|
|
141
|
-
return "number";
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (hasSelect || hasSimple) {
|
|
145
|
-
return "string";
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return "string";
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export function mergeVariableMetaAcrossLocales(
|
|
152
|
-
localeMetas: readonly VariableMetaSpec[]
|
|
153
|
-
): { ok: true; merged: VariableSpec } | { ok: false; message: string } {
|
|
154
|
-
const keySignatures = localeMetas.map((meta) => Object.keys(meta).sort().join(","));
|
|
155
|
-
if (new Set(keySignatures).size > 1) {
|
|
156
|
-
return {
|
|
157
|
-
ok: false,
|
|
158
|
-
message: `Inconsistent ICU variable names across locales (keys: ${keySignatures.join(" vs ")})`,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const merged: VariableSpec = {};
|
|
163
|
-
const variableNames = new Set(localeMetas.flatMap((meta) => Object.keys(meta)));
|
|
164
|
-
|
|
165
|
-
for (const variableName of variableNames) {
|
|
166
|
-
const combinedRoles = new Set<VariableRole>();
|
|
167
|
-
|
|
168
|
-
for (const meta of localeMetas) {
|
|
169
|
-
for (const role of meta[variableName]?.roles ?? []) {
|
|
170
|
-
combinedRoles.add(role);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const mergedType = inferMergedVariableType(combinedRoles);
|
|
175
|
-
if (mergedType === "CONFLICT") {
|
|
176
|
-
const rolesByLocale = localeMetas
|
|
177
|
-
.map((meta) => {
|
|
178
|
-
const entry = meta[variableName];
|
|
179
|
-
if (!entry) {
|
|
180
|
-
return null;
|
|
181
|
-
}
|
|
182
|
-
return [...entry.roles].sort().join("|");
|
|
183
|
-
})
|
|
184
|
-
.filter((value): value is string => value !== null);
|
|
185
|
-
|
|
186
|
-
return {
|
|
187
|
-
ok: false,
|
|
188
|
-
message: `Incompatible ICU variable "${variableName}" across locales (roles: ${rolesByLocale.join(" vs ")})`,
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
merged[variableName] = mergedType;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return { ok: true, merged };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export function mergeVariableSpecs(target: VariableSpec, source: VariableSpec): VariableSpec {
|
|
199
|
-
const merged = { ...target };
|
|
200
|
-
|
|
201
|
-
for (const [varName, varType] of Object.entries(source)) {
|
|
202
|
-
const existing = merged[varName];
|
|
203
|
-
if (existing && existing !== varType) {
|
|
204
|
-
merged[varName] = "number";
|
|
205
|
-
} else {
|
|
206
|
-
merged[varName] = varType;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return merged;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export function variableSpecsEqual(a: VariableSpec, b: VariableSpec): boolean {
|
|
214
|
-
const aKeys = Object.keys(a).sort();
|
|
215
|
-
const bKeys = Object.keys(b).sort();
|
|
216
|
-
|
|
217
|
-
if (aKeys.length !== bKeys.length) {
|
|
218
|
-
return false;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
return aKeys.every((key, index) => {
|
|
222
|
-
if (key !== bKeys[index]) {
|
|
223
|
-
return false;
|
|
224
|
-
}
|
|
225
|
-
return a[key] === b[key];
|
|
226
|
-
});
|
|
227
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { parse } from "@formatjs/icu-messageformat-parser";
|
|
2
|
-
import {
|
|
3
|
-
extractVariableMeta,
|
|
4
|
-
variableMetaToSpec,
|
|
5
|
-
type VariableMetaSpec,
|
|
6
|
-
type VariableSpec,
|
|
7
|
-
} from "./extract-variables.js";
|
|
8
|
-
|
|
9
|
-
export type ParseTemplateSuccess = {
|
|
10
|
-
readonly ok: true;
|
|
11
|
-
readonly args: VariableSpec;
|
|
12
|
-
readonly meta: VariableMetaSpec;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export type ParseTemplateFailure = {
|
|
16
|
-
readonly ok: false;
|
|
17
|
-
readonly message: string;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export type ParseTemplateResult = ParseTemplateSuccess | ParseTemplateFailure;
|
|
21
|
-
|
|
22
|
-
export function parseTemplate(template: string): ParseTemplateResult {
|
|
23
|
-
try {
|
|
24
|
-
const ast = parse(template);
|
|
25
|
-
const meta = extractVariableMeta(ast);
|
|
26
|
-
return { ok: true, args: variableMetaToSpec(meta), meta };
|
|
27
|
-
} catch (error) {
|
|
28
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
-
return { ok: false, message };
|
|
30
|
-
}
|
|
31
|
-
}
|