@inlang/paraglide-js 2.25.4 → 2.27.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.
Files changed (124) hide show
  1. package/README.md +2 -1
  2. package/dist/bundler-plugins/per-locale-build/vite-environments.test.js +2 -2
  3. package/dist/bundler-plugins/per-locale-build/vite-environments.test.js.map +1 -1
  4. package/dist/cli/commands/init/command.js +19 -1
  5. package/dist/cli/commands/init/command.js.map +1 -1
  6. package/dist/cli/defaults.js +2 -2
  7. package/dist/cli/defaults.js.map +1 -1
  8. package/dist/cli/steps/add-inlang-check.d.ts +19 -0
  9. package/dist/cli/steps/add-inlang-check.js +60 -0
  10. package/dist/cli/steps/add-inlang-check.js.map +1 -0
  11. package/dist/cli/steps/add-inlang-check.test.d.ts +1 -0
  12. package/dist/cli/steps/add-inlang-check.test.js +88 -0
  13. package/dist/cli/steps/add-inlang-check.test.js.map +1 -0
  14. package/dist/cli/steps/agents-md-suggestion.d.ts +11 -0
  15. package/dist/cli/steps/agents-md-suggestion.js +19 -0
  16. package/dist/cli/steps/agents-md-suggestion.js.map +1 -0
  17. package/dist/cli/steps/agents-md-suggestion.test.d.ts +1 -0
  18. package/dist/cli/steps/agents-md-suggestion.test.js +36 -0
  19. package/dist/cli/steps/agents-md-suggestion.test.js.map +1 -0
  20. package/dist/cli/steps/maybe-add-machine-translation.js +3 -2
  21. package/dist/cli/steps/maybe-add-machine-translation.js.map +1 -1
  22. package/dist/cli/steps/maybe-add-sherlock.js +1 -1
  23. package/dist/cli/steps/maybe-add-sherlock.js.map +1 -1
  24. package/dist/cli/utils.d.ts +9 -0
  25. package/dist/cli/utils.js +15 -0
  26. package/dist/cli/utils.js.map +1 -1
  27. package/dist/compiler/compile-annotation.d.ts +48 -1
  28. package/dist/compiler/compile-annotation.js +238 -10
  29. package/dist/compiler/compile-annotation.js.map +1 -1
  30. package/dist/compiler/compile-bundle.d.ts +3 -2
  31. package/dist/compiler/compile-bundle.js +30 -1
  32. package/dist/compiler/compile-bundle.js.map +1 -1
  33. package/dist/compiler/compile-bundle.test.js +18 -18
  34. package/dist/compiler/compile-bundle.test.js.map +1 -1
  35. package/dist/compiler/compile-local-variable.d.ts +3 -0
  36. package/dist/compiler/compile-local-variable.js +5 -2
  37. package/dist/compiler/compile-local-variable.js.map +1 -1
  38. package/dist/compiler/compile-local-variable.test.js +44 -4
  39. package/dist/compiler/compile-local-variable.test.js.map +1 -1
  40. package/dist/compiler/compile-message.d.ts +29 -1
  41. package/dist/compiler/compile-message.exact-number.test.d.ts +1 -0
  42. package/dist/compiler/compile-message.exact-number.test.js +786 -0
  43. package/dist/compiler/compile-message.exact-number.test.js.map +1 -0
  44. package/dist/compiler/compile-message.js +162 -66
  45. package/dist/compiler/compile-message.js.map +1 -1
  46. package/dist/compiler/compile-message.locals.test.d.ts +1 -0
  47. package/dist/compiler/compile-message.locals.test.js +498 -0
  48. package/dist/compiler/compile-message.locals.test.js.map +1 -0
  49. package/dist/compiler/compile-message.test.js +111 -20
  50. package/dist/compiler/compile-message.test.js.map +1 -1
  51. package/dist/compiler/compile-pattern.d.ts +5 -0
  52. package/dist/compiler/compile-pattern.js +6 -18
  53. package/dist/compiler/compile-pattern.js.map +1 -1
  54. package/dist/compiler/compile-pattern.test.js +165 -1
  55. package/dist/compiler/compile-pattern.test.js.map +1 -1
  56. package/dist/compiler/compile-project.js +4 -1
  57. package/dist/compiler/compile-project.js.map +1 -1
  58. package/dist/compiler/compile-project.test.js +1013 -31
  59. package/dist/compiler/compile-project.test.js.map +1 -1
  60. package/dist/compiler/compile.test.js +1 -1
  61. package/dist/compiler/compile.test.js.map +1 -1
  62. package/dist/compiler/create-readme.d.ts +4 -1
  63. package/dist/compiler/create-readme.js +33 -125
  64. package/dist/compiler/create-readme.js.map +1 -1
  65. package/dist/compiler/emit-ts-declarations.interop.test.js +2 -2
  66. package/dist/compiler/emit-ts-declarations.interop.test.js.map +1 -1
  67. package/dist/compiler/emit-ts-declarations.test.js +4 -4
  68. package/dist/compiler/emit-ts-declarations.test.js.map +1 -1
  69. package/dist/compiler/index.d.ts +1 -1
  70. package/dist/compiler/index.js +1 -1
  71. package/dist/compiler/index.js.map +1 -1
  72. package/dist/compiler/input-alias.d.ts +21 -0
  73. package/dist/compiler/input-alias.js +42 -0
  74. package/dist/compiler/input-alias.js.map +1 -0
  75. package/dist/compiler/input-alias.test.d.ts +1 -0
  76. package/dist/compiler/input-alias.test.js +58 -0
  77. package/dist/compiler/input-alias.test.js.map +1 -0
  78. package/dist/compiler/message-locals.d.ts +46 -0
  79. package/dist/compiler/message-locals.js +129 -0
  80. package/dist/compiler/message-locals.js.map +1 -0
  81. package/dist/compiler/output-structure/locale-modules.js +7 -1
  82. package/dist/compiler/output-structure/locale-modules.js.map +1 -1
  83. package/dist/compiler/output-structure/locale-modules.test.js +35 -0
  84. package/dist/compiler/output-structure/locale-modules.test.js.map +1 -1
  85. package/dist/compiler/output-structure/message-modules.js +5 -3
  86. package/dist/compiler/output-structure/message-modules.js.map +1 -1
  87. package/dist/compiler/output-structure/message-modules.test.js +35 -0
  88. package/dist/compiler/output-structure/message-modules.test.js.map +1 -1
  89. package/dist/compiler/registry.d.ts +12 -0
  90. package/dist/compiler/registry.js +97 -28
  91. package/dist/compiler/registry.js.map +1 -1
  92. package/dist/compiler/runtime/create-runtime.js +24 -1
  93. package/dist/compiler/runtime/create-runtime.js.map +1 -1
  94. package/dist/compiler/runtime/extract-locale-from-url.js +11 -4
  95. package/dist/compiler/runtime/extract-locale-from-url.js.map +1 -1
  96. package/dist/compiler/runtime/generate-static-localized-urls.js +5 -3
  97. package/dist/compiler/runtime/generate-static-localized-urls.js.map +1 -1
  98. package/dist/compiler/runtime/localize-url.js +25 -54
  99. package/dist/compiler/runtime/localize-url.js.map +1 -1
  100. package/dist/compiler/runtime/route-strategy.js +9 -3
  101. package/dist/compiler/runtime/route-strategy.js.map +1 -1
  102. package/dist/compiler/runtime/type.test.js +9 -0
  103. package/dist/compiler/runtime/type.test.js.map +1 -1
  104. package/dist/compiler/runtime/url-pattern-cache.d.ts +11 -0
  105. package/dist/compiler/runtime/url-pattern-cache.js +42 -0
  106. package/dist/compiler/runtime/url-pattern-cache.js.map +1 -0
  107. package/dist/compiler/runtime/url-pattern-index.d.ts +24 -0
  108. package/dist/compiler/runtime/url-pattern-index.differential.test.d.ts +1 -0
  109. package/dist/compiler/runtime/url-pattern-index.differential.test.js +152 -0
  110. package/dist/compiler/runtime/url-pattern-index.differential.test.js.map +1 -0
  111. package/dist/compiler/runtime/url-pattern-index.js +168 -0
  112. package/dist/compiler/runtime/url-pattern-index.js.map +1 -0
  113. package/dist/compiler/runtime/url-pattern-index.test.d.ts +1 -0
  114. package/dist/compiler/runtime/url-pattern-index.test.js +262 -0
  115. package/dist/compiler/runtime/url-pattern-index.test.js.map +1 -0
  116. package/dist/compiler/server/create-server-file.js +25 -1
  117. package/dist/compiler/server/create-server-file.js.map +1 -1
  118. package/dist/compiler/server/create-server-file.test.js +20 -2
  119. package/dist/compiler/server/create-server-file.test.js.map +1 -1
  120. package/dist/compiler/server/middleware.js +29 -4
  121. package/dist/compiler/server/middleware.js.map +1 -1
  122. package/dist/services/env-variables/index.js +1 -1
  123. package/dist/services/env-variables/index.js.map +1 -1
  124. package/package.json +3 -3
@@ -0,0 +1,786 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { compileMessage } from "./compile-message.js";
3
+ import { compileBundle } from "./compile-bundle.js";
4
+ import { createRegistry } from "./registry.js";
5
+ import { toSafeModuleId } from "./safe-module-id.js";
6
+ /**
7
+ * Compiles a message and imports it together with an inlined registry so the
8
+ * generated code can be executed.
9
+ */
10
+ async function compileAndImport(args) {
11
+ const message = {
12
+ id: "message-id",
13
+ bundleId: "exact_message",
14
+ locale: args.locale,
15
+ selectors: args.selectors.map((name) => ({
16
+ type: "variable-reference",
17
+ name,
18
+ })),
19
+ };
20
+ const variants = args.variants.map((variant, index) => ({
21
+ id: String(index),
22
+ messageId: message.id,
23
+ ...variant,
24
+ }));
25
+ const compiled = compileMessage(args.declarations, message, variants);
26
+ const source = createRegistry() +
27
+ "\nexport const exact_message = " +
28
+ compiled.code.replaceAll("registry.", "");
29
+ const module = await import("data:text/javascript;base64," + Buffer.from(source).toString("base64"));
30
+ return {
31
+ code: compiled.code,
32
+ message: module.exact_message,
33
+ };
34
+ }
35
+ const literal = (key, value) => ({
36
+ type: "literal-match",
37
+ key,
38
+ value,
39
+ });
40
+ const catchall = (key) => ({ type: "catchall-match", key });
41
+ const text = (value) => [{ type: "text", value }];
42
+ const withCount = (before, after = "") => [
43
+ { type: "text", value: before },
44
+ { type: "expression", arg: { type: "variable-reference", name: "count" } },
45
+ { type: "text", value: after },
46
+ ];
47
+ /**
48
+ * The shape inlang's ICU MessageFormat 1 plugin imports
49
+ * `{count, plural, =0 {…} one {…} other {…}}` as, and the shape editors create
50
+ * when a user adds an exact number to a plural message.
51
+ */
52
+ const icuDeclarations = () => [
53
+ { type: "input-variable", name: "count" },
54
+ {
55
+ type: "local-variable",
56
+ name: "countPluralExact",
57
+ value: {
58
+ type: "expression",
59
+ arg: { type: "variable-reference", name: "count" },
60
+ },
61
+ },
62
+ {
63
+ type: "local-variable",
64
+ name: "countPlural",
65
+ value: {
66
+ type: "expression",
67
+ arg: { type: "variable-reference", name: "count" },
68
+ annotation: { type: "function-reference", name: "plural", options: [] },
69
+ },
70
+ },
71
+ ];
72
+ const icuSelectors = ["countPluralExact", "countPlural"];
73
+ /** ICU `=N {…}` */
74
+ const exact = (value, pattern) => ({
75
+ matches: [literal("countPluralExact", value), catchall("countPlural")],
76
+ pattern,
77
+ });
78
+ /** ICU `one {…}`, `few {…}`, ... */
79
+ const category = (value, pattern) => ({
80
+ matches: [catchall("countPluralExact"), literal("countPlural", value)],
81
+ pattern,
82
+ });
83
+ /** ICU `other {…}` */
84
+ const other = (pattern) => ({
85
+ matches: [catchall("countPluralExact"), catchall("countPlural")],
86
+ pattern,
87
+ });
88
+ const render = (message, counts) => Object.fromEntries(counts.map((count) => [String(JSON.stringify(count)), message({ count })]));
89
+ const COUNTS = [0, 1, 2, 5, 21, "0"];
90
+ describe("exact number matches on un-annotated local aliases (ICU =N)", () => {
91
+ test("en: =0, one, other", async () => {
92
+ const { message, code } = await compileAndImport({
93
+ locale: "en",
94
+ declarations: icuDeclarations(),
95
+ selectors: icuSelectors,
96
+ variants: [
97
+ exact("0", text("No items")),
98
+ category("one", withCount("", " item")),
99
+ other(withCount("", " items")),
100
+ ],
101
+ });
102
+ expect(code).toContain('(countPluralExact === 0 || countPluralExact === "0")');
103
+ expect(render(message, COUNTS)).toEqual({
104
+ "0": "No items",
105
+ "1": "1 item",
106
+ "2": "2 items",
107
+ "5": "5 items",
108
+ "21": "21 items",
109
+ '"0"': "No items",
110
+ });
111
+ });
112
+ test("en: =1 wins over one and matches the string '1'", async () => {
113
+ const { message } = await compileAndImport({
114
+ locale: "en",
115
+ declarations: icuDeclarations(),
116
+ selectors: icuSelectors,
117
+ variants: [
118
+ exact("0", text("No items")),
119
+ exact("1", text("Exactly one item")),
120
+ category("one", withCount("", " item")),
121
+ other(withCount("", " items")),
122
+ ],
123
+ });
124
+ expect(render(message, [...COUNTS, "1"])).toEqual({
125
+ "0": "No items",
126
+ "1": "Exactly one item",
127
+ "2": "2 items",
128
+ "5": "5 items",
129
+ "21": "21 items",
130
+ '"0"': "No items",
131
+ '"1"': "Exactly one item",
132
+ });
133
+ });
134
+ test("fr: =0 wins over one, which covers 0 in French", async () => {
135
+ const { message } = await compileAndImport({
136
+ locale: "fr",
137
+ declarations: icuDeclarations(),
138
+ selectors: icuSelectors,
139
+ variants: [
140
+ exact("0", text("Aucun élément")),
141
+ category("one", withCount("", " élément")),
142
+ other(withCount("", " éléments")),
143
+ ],
144
+ });
145
+ expect(render(message, COUNTS)).toEqual({
146
+ "0": "Aucun élément",
147
+ "1": "1 élément",
148
+ "2": "2 éléments",
149
+ "5": "5 éléments",
150
+ "21": "21 éléments",
151
+ '"0"': "Aucun élément",
152
+ });
153
+ });
154
+ test("fr: variant preference follows selector order, not storage order", async () => {
155
+ // editors append the exact variant after the existing plural variants
156
+ const { message } = await compileAndImport({
157
+ locale: "fr",
158
+ declarations: icuDeclarations(),
159
+ selectors: icuSelectors,
160
+ variants: [
161
+ other(withCount("", " éléments")),
162
+ category("one", withCount("", " élément")),
163
+ exact("0", text("Aucun élément")),
164
+ ],
165
+ });
166
+ expect(render(message, COUNTS)).toEqual({
167
+ "0": "Aucun élément",
168
+ "1": "1 élément",
169
+ "2": "2 éléments",
170
+ "5": "5 éléments",
171
+ "21": "21 éléments",
172
+ '"0"': "Aucun élément",
173
+ });
174
+ });
175
+ test("ru: =0 and =1 next to one/few/many/other", async () => {
176
+ const { message } = await compileAndImport({
177
+ locale: "ru",
178
+ declarations: icuDeclarations(),
179
+ selectors: icuSelectors,
180
+ variants: [
181
+ exact("0", text("Нет файлов")),
182
+ exact("1", text("Ровно один файл")),
183
+ category("one", withCount("", " файл")),
184
+ category("few", withCount("", " файла")),
185
+ category("many", withCount("", " файлов")),
186
+ other(withCount("", " файла (other)")),
187
+ ],
188
+ });
189
+ expect(render(message, COUNTS)).toEqual({
190
+ "0": "Нет файлов",
191
+ "1": "Ровно один файл",
192
+ "2": "2 файла",
193
+ "5": "5 файлов",
194
+ "21": "21 файл",
195
+ '"0"': "Нет файлов",
196
+ });
197
+ });
198
+ test("ja: =0 and =1 in a locale with only the other category", async () => {
199
+ const { message } = await compileAndImport({
200
+ locale: "ja",
201
+ declarations: icuDeclarations(),
202
+ selectors: icuSelectors,
203
+ variants: [
204
+ exact("0", text("アイテムなし")),
205
+ exact("1", text("アイテム1つだけ")),
206
+ other(withCount("アイテム", "個")),
207
+ ],
208
+ });
209
+ expect(render(message, COUNTS)).toEqual({
210
+ "0": "アイテムなし",
211
+ "1": "アイテム1つだけ",
212
+ "2": "アイテム2個",
213
+ "5": "アイテム5個",
214
+ "21": "アイテム21個",
215
+ '"0"': "アイテムなし",
216
+ });
217
+ });
218
+ test("exact numbers nested inside a select", async () => {
219
+ // {gender, select, female {{count, plural, =0 {…} one {…} other {…}}} other {…}}
220
+ const declarations = [
221
+ { type: "input-variable", name: "gender" },
222
+ ...icuDeclarations(),
223
+ ];
224
+ const nested = (gender, variant) => ({ ...variant, matches: [gender, ...variant.matches] });
225
+ const female = literal("gender", "female");
226
+ const anyGender = catchall("gender");
227
+ for (const locale of ["en", "fr"]) {
228
+ const { message } = await compileAndImport({
229
+ locale,
230
+ declarations,
231
+ selectors: ["gender", ...icuSelectors],
232
+ variants: [
233
+ nested(female, exact("0", text("She has none"))),
234
+ nested(female, category("one", withCount("She has ", " (one)"))),
235
+ nested(female, other(withCount("She has ", " (other)"))),
236
+ nested(anyGender, exact("0", text("They have none"))),
237
+ nested(anyGender, category("one", withCount("They have ", " (one)"))),
238
+ nested(anyGender, other(withCount("They have ", " (other)"))),
239
+ ],
240
+ });
241
+ const results = (gender) => COUNTS.map((count) => message({ gender, count }));
242
+ // =0 wins in both locales, also where French `one` covers 0
243
+ expect(results("female")).toEqual([
244
+ "She has none",
245
+ "She has 1 (one)",
246
+ "She has 2 (other)",
247
+ "She has 5 (other)",
248
+ "She has 21 (other)",
249
+ "She has none",
250
+ ]);
251
+ expect(results("male")).toEqual([
252
+ "They have none",
253
+ "They have 1 (one)",
254
+ "They have 2 (other)",
255
+ "They have 5 (other)",
256
+ "They have 21 (other)",
257
+ "They have none",
258
+ ]);
259
+ }
260
+ });
261
+ test("resolves chains of un-annotated aliases", async () => {
262
+ const { message, code } = await compileAndImport({
263
+ locale: "fr",
264
+ declarations: [
265
+ { type: "input-variable", name: "count" },
266
+ {
267
+ type: "local-variable",
268
+ name: "countAlias",
269
+ value: {
270
+ type: "expression",
271
+ arg: { type: "variable-reference", name: "count" },
272
+ },
273
+ },
274
+ {
275
+ type: "local-variable",
276
+ name: "countAliasAlias",
277
+ value: {
278
+ type: "expression",
279
+ arg: { type: "variable-reference", name: "countAlias" },
280
+ },
281
+ },
282
+ {
283
+ type: "local-variable",
284
+ name: "countPlural",
285
+ value: {
286
+ type: "expression",
287
+ arg: { type: "variable-reference", name: "count" },
288
+ annotation: {
289
+ type: "function-reference",
290
+ name: "plural",
291
+ options: [],
292
+ },
293
+ },
294
+ },
295
+ ],
296
+ selectors: ["countAliasAlias", "countPlural"],
297
+ variants: [
298
+ {
299
+ matches: [catchall("countAliasAlias"), literal("countPlural", "one")],
300
+ pattern: text("one"),
301
+ },
302
+ {
303
+ matches: [literal("countAliasAlias", "0"), catchall("countPlural")],
304
+ pattern: text("zero"),
305
+ },
306
+ {
307
+ matches: [catchall("countAliasAlias"), catchall("countPlural")],
308
+ pattern: text("other"),
309
+ },
310
+ ],
311
+ });
312
+ expect(code).toContain('(countAliasAlias === 0 || countAliasAlias === "0")');
313
+ expect(COUNTS.map((count) => message({ count }))).toEqual([
314
+ "zero",
315
+ "one",
316
+ "other",
317
+ "other",
318
+ "other",
319
+ "zero",
320
+ ]);
321
+ });
322
+ test("annotated locals and literal locals keep string matching", async () => {
323
+ const { code } = await compileAndImport({
324
+ locale: "en",
325
+ declarations: [
326
+ { type: "input-variable", name: "count" },
327
+ {
328
+ type: "local-variable",
329
+ name: "formatted",
330
+ value: {
331
+ type: "expression",
332
+ arg: { type: "variable-reference", name: "count" },
333
+ annotation: {
334
+ type: "function-reference",
335
+ name: "number",
336
+ options: [],
337
+ },
338
+ },
339
+ },
340
+ {
341
+ // aliases the formatted string, not the input
342
+ type: "local-variable",
343
+ name: "formattedAlias",
344
+ value: {
345
+ type: "expression",
346
+ arg: { type: "variable-reference", name: "formatted" },
347
+ },
348
+ },
349
+ {
350
+ type: "local-variable",
351
+ name: "constant",
352
+ value: {
353
+ type: "expression",
354
+ arg: { type: "literal", value: "0" },
355
+ },
356
+ },
357
+ ],
358
+ selectors: ["formatted", "formattedAlias", "constant"],
359
+ variants: [
360
+ { matches: [literal("formatted", "0")], pattern: text("a") },
361
+ { matches: [literal("formattedAlias", "0")], pattern: text("b") },
362
+ { matches: [literal("constant", "0")], pattern: text("c") },
363
+ { matches: [], pattern: text("d") },
364
+ ],
365
+ });
366
+ expect(code).toContain('if (formatted === "0")');
367
+ expect(code).toContain('if (formattedAlias === "0")');
368
+ expect(code).toContain('if (constant === "0")');
369
+ });
370
+ });
371
+ describe("exact number matches on the input itself (i18next _zero import)", () => {
372
+ // i18next `key_zero`, `key_one`, `key_other` import as selectors
373
+ // `count` (exact 0) and `countPlural`. `_zero` also becomes the Intl `zero`
374
+ // category variant. Variants are listed in import order.
375
+ const declarations = () => [
376
+ { type: "input-variable", name: "count" },
377
+ {
378
+ type: "local-variable",
379
+ name: "countPlural",
380
+ value: {
381
+ type: "expression",
382
+ arg: { type: "variable-reference", name: "count" },
383
+ annotation: { type: "function-reference", name: "plural", options: [] },
384
+ },
385
+ },
386
+ ];
387
+ const variants = (zero, one, others) => [
388
+ {
389
+ matches: [catchall("count"), literal("countPlural", "one")],
390
+ pattern: one,
391
+ },
392
+ {
393
+ matches: [catchall("count"), literal("countPlural", "other")],
394
+ pattern: others,
395
+ },
396
+ {
397
+ matches: [literal("count", "0"), catchall("countPlural")],
398
+ pattern: text(zero),
399
+ },
400
+ {
401
+ matches: [catchall("count"), literal("countPlural", "zero")],
402
+ pattern: text(zero),
403
+ },
404
+ ];
405
+ test("en", async () => {
406
+ const { message } = await compileAndImport({
407
+ locale: "en",
408
+ declarations: declarations(),
409
+ selectors: ["count", "countPlural"],
410
+ variants: variants("No items", withCount("", " item"), withCount("", " items")),
411
+ });
412
+ expect(render(message, COUNTS)).toEqual({
413
+ "0": "No items",
414
+ "1": "1 item",
415
+ "2": "2 items",
416
+ "5": "5 items",
417
+ "21": "21 items",
418
+ '"0"': "No items",
419
+ });
420
+ });
421
+ test("fr: exact 0 wins over one", async () => {
422
+ const { message } = await compileAndImport({
423
+ locale: "fr",
424
+ declarations: declarations(),
425
+ selectors: ["count", "countPlural"],
426
+ variants: variants("Aucun élément", withCount("", " élément"), withCount("", " éléments")),
427
+ });
428
+ expect(render(message, COUNTS)).toEqual({
429
+ "0": "Aucun élément",
430
+ "1": "1 élément",
431
+ "2": "2 éléments",
432
+ "5": "5 éléments",
433
+ "21": "21 éléments",
434
+ '"0"': "Aucun élément",
435
+ });
436
+ });
437
+ });
438
+ describe("variant preference follows selector order", () => {
439
+ test("literal keys win over catchalls selector by selector, not by storage order", async () => {
440
+ const { message } = await compileAndImport({
441
+ locale: "en",
442
+ declarations: [
443
+ { type: "input-variable", name: "platform" },
444
+ { type: "input-variable", name: "gender" },
445
+ ],
446
+ selectors: ["platform", "gender"],
447
+ variants: [
448
+ {
449
+ matches: [catchall("platform"), literal("gender", "male")],
450
+ pattern: text("*,male"),
451
+ },
452
+ {
453
+ matches: [literal("platform", "android"), catchall("gender")],
454
+ pattern: text("android,*"),
455
+ },
456
+ {
457
+ matches: [catchall("platform"), catchall("gender")],
458
+ pattern: text("*,*"),
459
+ },
460
+ ],
461
+ });
462
+ expect(message({ platform: "android", gender: "male" })).toBe("android,*");
463
+ expect(message({ platform: "ios", gender: "male" })).toBe("*,male");
464
+ expect(message({ platform: "ios", gender: "female" })).toBe("*,*");
465
+ });
466
+ test("an ICU select with `other` stored first still reaches the other cases", async () => {
467
+ // {gender, select, other {They} female {She} male {He}}
468
+ const { message } = await compileAndImport({
469
+ locale: "en",
470
+ declarations: [{ type: "input-variable", name: "gender" }],
471
+ selectors: ["gender"],
472
+ variants: [
473
+ { matches: [catchall("gender")], pattern: text("They") },
474
+ { matches: [literal("gender", "female")], pattern: text("She") },
475
+ { matches: [literal("gender", "male")], pattern: text("He") },
476
+ ],
477
+ });
478
+ expect(message({ gender: "female" })).toBe("She");
479
+ expect(message({ gender: "male" })).toBe("He");
480
+ expect(message({ gender: "other" })).toBe("They");
481
+ });
482
+ });
483
+ describe("input match types with alias matches", () => {
484
+ const statusBundle = (fr) => {
485
+ const bundle = {
486
+ id: "status_message",
487
+ declarations: [
488
+ { type: "input-variable", name: "status" },
489
+ {
490
+ type: "local-variable",
491
+ name: "statusExact",
492
+ value: {
493
+ type: "expression",
494
+ arg: { type: "variable-reference", name: "status" },
495
+ },
496
+ },
497
+ ],
498
+ messages: [
499
+ {
500
+ id: "en-id",
501
+ bundle_id: "status_message",
502
+ locale: "en",
503
+ selectors: [{ type: "variable-reference", name: "status" }],
504
+ variants: [
505
+ {
506
+ id: "1",
507
+ message_id: "en-id",
508
+ matches: [literal("status", "active")],
509
+ pattern: text("Active"),
510
+ },
511
+ ],
512
+ },
513
+ {
514
+ id: "fr-id",
515
+ bundle_id: "status_message",
516
+ locale: "fr",
517
+ selectors: [{ type: "variable-reference", name: "statusExact" }],
518
+ variants: fr.map((variant, index) => ({
519
+ id: `fr-${index}`,
520
+ message_id: "fr-id",
521
+ ...variant,
522
+ })),
523
+ },
524
+ ],
525
+ };
526
+ return compileBundle({
527
+ fallbackMap: { en: "en", fr: "fr" },
528
+ bundle,
529
+ messageReferenceExpression: (locale) => `${toSafeModuleId(locale)}.status_message`,
530
+ settings: { locales: ["en", "fr"] },
531
+ }).matchTypes.get("status");
532
+ };
533
+ test("alias literal matches widen an input type narrowed by direct matches", () => {
534
+ const info = statusBundle([
535
+ { matches: [literal("statusExact", "0")], pattern: text("Zéro") },
536
+ ]);
537
+ expect(info?.literals).toEqual(new Set(["active", "0"]));
538
+ expect(info?.hasCatchAll).toBe(false);
539
+ });
540
+ test("an alias catchall widens the input type to any value", () => {
541
+ const info = statusBundle([
542
+ { matches: [literal("statusExact", "0")], pattern: text("Zéro") },
543
+ { matches: [catchall("statusExact")], pattern: text("Autre") },
544
+ ]);
545
+ // status: "inactive" must type-check, fr handles any status
546
+ expect(info?.hasCatchAll).toBe(true);
547
+ });
548
+ });
549
+ describe("ICU plural offset (`{count, plural, offset:1 …}`)", () => {
550
+ /**
551
+ * The shape inlang's ICU MessageFormat 1 plugin imports
552
+ * `{count, plural, offset:1 =0 {…} =1 {…} one {…} other {…}}` as.
553
+ */
554
+ const offsetDeclarations = (offset = "1") => [
555
+ { type: "input-variable", name: "count" },
556
+ {
557
+ type: "local-variable",
558
+ name: "countPluralOffset1",
559
+ value: {
560
+ type: "expression",
561
+ arg: { type: "variable-reference", name: "count" },
562
+ annotation: {
563
+ type: "function-reference",
564
+ name: "plural",
565
+ options: [
566
+ { name: "offset", value: { type: "literal", value: offset } },
567
+ ],
568
+ },
569
+ },
570
+ },
571
+ {
572
+ type: "local-variable",
573
+ name: "countPluralOffset1Exact",
574
+ value: {
575
+ type: "expression",
576
+ arg: { type: "variable-reference", name: "count" },
577
+ },
578
+ },
579
+ ];
580
+ const offsetSelectors = ["countPluralOffset1Exact", "countPluralOffset1"];
581
+ const exactOffset = (value, pattern) => ({
582
+ matches: [
583
+ literal("countPluralOffset1Exact", value),
584
+ catchall("countPluralOffset1"),
585
+ ],
586
+ pattern,
587
+ });
588
+ const categoryOffset = (value, pattern) => ({
589
+ matches: [
590
+ catchall("countPluralOffset1Exact"),
591
+ literal("countPluralOffset1", value),
592
+ ],
593
+ pattern,
594
+ });
595
+ const otherOffset = (pattern) => ({
596
+ matches: [
597
+ catchall("countPluralOffset1Exact"),
598
+ catchall("countPluralOffset1"),
599
+ ],
600
+ pattern,
601
+ });
602
+ test("en: the category is selected on count - offset, exact matches on count", async () => {
603
+ const { message, code } = await compileAndImport({
604
+ locale: "en",
605
+ declarations: offsetDeclarations(),
606
+ selectors: offsetSelectors,
607
+ variants: [
608
+ exactOffset("0", text("Nobody")),
609
+ exactOffset("1", text("Only you")),
610
+ categoryOffset("one", text("You and one other")),
611
+ otherOffset(text("You and others")),
612
+ ],
613
+ });
614
+ // a number, not the string "1"
615
+ expect(code).toContain('registry.plural("en", i?.count, { offset: 1 })');
616
+ expect(render(message, [0, 1, 2, 3, 22, "1", "2"])).toEqual({
617
+ "0": "Nobody",
618
+ "1": "Only you",
619
+ // 2 - 1 = 1 is "one"
620
+ "2": "You and one other",
621
+ "3": "You and others",
622
+ "22": "You and others",
623
+ '"1"': "Only you",
624
+ '"2"': "You and one other",
625
+ });
626
+ });
627
+ test("ru: one/few/many follow count - offset", async () => {
628
+ const { message } = await compileAndImport({
629
+ locale: "ru",
630
+ declarations: offsetDeclarations(),
631
+ selectors: offsetSelectors,
632
+ variants: [
633
+ exactOffset("1", text("only you")),
634
+ categoryOffset("one", text("one")),
635
+ categoryOffset("few", text("few")),
636
+ categoryOffset("many", text("many")),
637
+ otherOffset(text("other")),
638
+ ],
639
+ });
640
+ expect(render(message, [1, 2, 3, 6, 22, 23])).toEqual({
641
+ "1": "only you",
642
+ "2": "one", // 1
643
+ "3": "few", // 2
644
+ "6": "many", // 5
645
+ "22": "one", // 21
646
+ "23": "few", // 22
647
+ });
648
+ });
649
+ test("an offset of 0 and no offset select the same category", async () => {
650
+ const withZero = await compileAndImport({
651
+ locale: "en",
652
+ declarations: offsetDeclarations("0"),
653
+ selectors: offsetSelectors,
654
+ variants: [
655
+ categoryOffset("one", text("one")),
656
+ otherOffset(text("other")),
657
+ ],
658
+ });
659
+ expect(render(withZero.message, [0, 1, 2])).toEqual({
660
+ "0": "other",
661
+ "1": "one",
662
+ "2": "other",
663
+ });
664
+ });
665
+ test("a non-numeric offset fails compilation", async () => {
666
+ await expect(compileAndImport({
667
+ locale: "en",
668
+ declarations: offsetDeclarations("one"),
669
+ selectors: offsetSelectors,
670
+ variants: [
671
+ categoryOffset("one", text("one")),
672
+ otherOffset(text("other")),
673
+ ],
674
+ })).rejects.toThrow('Invalid "plural" offset "one". Expected a number.');
675
+ });
676
+ test("registry.plural subtracts the offset and ignores it otherwise", async () => {
677
+ const registry = await import("data:text/javascript;base64," +
678
+ Buffer.from(createRegistry()).toString("base64"));
679
+ expect(registry.plural("en", 2, { offset: 1 })).toBe("one");
680
+ expect(registry.plural("en", 1, { offset: 1 })).toBe("other");
681
+ expect(registry.plural("en", 1, {})).toBe("one");
682
+ expect(registry.plural("en", 1)).toBe("one");
683
+ expect(registry.plural("en", 3, { type: "ordinal", offset: 1 })).toBe("two");
684
+ });
685
+ const pound = (offset) => ({
686
+ type: "expression",
687
+ arg: { type: "variable-reference", name: "count" },
688
+ annotation: {
689
+ type: "function-reference",
690
+ name: "icu:pound",
691
+ options: offset === undefined
692
+ ? []
693
+ : [{ name: "offset", value: { type: "literal", value: offset } }],
694
+ },
695
+ });
696
+ test("# displays count - offset, formatted like number()", async () => {
697
+ const { message, code } = await compileAndImport({
698
+ locale: "en",
699
+ declarations: offsetDeclarations(),
700
+ selectors: offsetSelectors,
701
+ variants: [
702
+ exactOffset("0", text("Nobody")),
703
+ exactOffset("1", text("You")),
704
+ categoryOffset("one", [
705
+ { type: "text", value: "You and " },
706
+ pound("1"),
707
+ { type: "text", value: " other" },
708
+ ]),
709
+ otherOffset([
710
+ { type: "text", value: "You and " },
711
+ pound("1"),
712
+ { type: "text", value: " others" },
713
+ ]),
714
+ ],
715
+ });
716
+ expect(code).toContain('registry.icuPound("en", i?.count, { offset: 1 })');
717
+ expect(render(message, [0, 1, 2, 3, 1234, "2"])).toEqual({
718
+ "0": "Nobody",
719
+ "1": "You",
720
+ "2": "You and 1 other",
721
+ "3": "You and 2 others",
722
+ "1234": "You and 1,233 others",
723
+ '"2"': "You and 1 other",
724
+ });
725
+ });
726
+ test("# without an offset displays count, formatted like number()", async () => {
727
+ const variants = [
728
+ category("one", [pound(), { type: "text", value: " item" }]),
729
+ other([pound(), { type: "text", value: " items" }]),
730
+ ];
731
+ const en = await compileAndImport({
732
+ locale: "en",
733
+ declarations: icuDeclarations(),
734
+ selectors: icuSelectors,
735
+ variants,
736
+ });
737
+ const de = await compileAndImport({
738
+ locale: "de",
739
+ declarations: icuDeclarations(),
740
+ selectors: icuSelectors,
741
+ variants,
742
+ });
743
+ expect(en.code).toContain('registry.icuPound("en", i?.count, {})');
744
+ expect(render(en.message, [1, 2, 1234, 1.5])).toEqual({
745
+ "1": "1 item",
746
+ "2": "2 items",
747
+ "1234": "1,234 items",
748
+ "1.5": "1.5 items",
749
+ });
750
+ expect(de.message({ count: 1234 })).toBe("1.234 items");
751
+ });
752
+ test("a non-numeric # offset fails compilation", async () => {
753
+ await expect(compileAndImport({
754
+ locale: "en",
755
+ declarations: offsetDeclarations(),
756
+ selectors: offsetSelectors,
757
+ variants: [otherOffset([pound("x")])],
758
+ })).rejects.toThrow('Invalid "icu:pound" offset "x". Expected a number.');
759
+ });
760
+ test("registry.icuPound subtracts the offset and formats like number()", async () => {
761
+ const registry = await import("data:text/javascript;base64," +
762
+ Buffer.from(createRegistry()).toString("base64"));
763
+ expect(registry.icuPound("en", 1234, { offset: 1 })).toBe("1,233");
764
+ expect(registry.icuPound("en", 1234, {})).toBe(registry.number("en", 1234, {}));
765
+ expect(registry.icuPound("en", "3")).toBe("3");
766
+ });
767
+ test("# displays an input that is not a number as is, without the offset", async () => {
768
+ const registry = await import("data:text/javascript;base64," +
769
+ Buffer.from(createRegistry()).toString("base64"));
770
+ const pound = (input) => registry.icuPound("en", input, { offset: 1 });
771
+ // numbers and numeric strings are offset and formatted
772
+ expect(pound(1234)).toBe("1,233");
773
+ expect(pound("1234")).toBe("1,233");
774
+ expect(pound(" 3 ")).toBe("2");
775
+ // anything else is displayed like a plain {count} placeholder
776
+ expect(pound("abc")).toBe("abc");
777
+ expect(pound("1,234")).toBe("1,234");
778
+ expect(pound("")).toBe("");
779
+ expect(pound(undefined)).toBe("undefined");
780
+ expect(pound(null)).toBe("null");
781
+ expect(pound(true)).toBe("true");
782
+ expect(pound(Number.NaN)).toBe("NaN");
783
+ expect(pound(Infinity)).toBe("Infinity");
784
+ });
785
+ });
786
+ //# sourceMappingURL=compile-message.exact-number.test.js.map