@zerolith/i18n 0.0.0-stage → 0.1.0-alpha.10
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/LICENSE +201 -0
- package/README.md +30 -2
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +151 -0
- package/dist/cli.js.map +1 -0
- package/dist/compile.d.ts +46 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +826 -0
- package/dist/compile.js.map +1 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/production/compile.js +167 -0
- package/dist/production/compile.js.map +1 -0
- package/dist/production/index.js +2 -0
- package/dist/production/index.js.map +1 -0
- package/package.json +57 -5
- package/src/cli.ts +203 -0
- package/src/compile.ts +1066 -0
- package/src/index.ts +241 -0
package/src/compile.ts
ADDED
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The message compiler: MessageFormat 2 catalogs in, one module per locale
|
|
3
|
+
* out, with a plain function per message whose arguments are typed from
|
|
4
|
+
* the placeholders. Formatting and plural selection call `Intl` in the
|
|
5
|
+
* generated module, so no message runtime is shipped. The subset
|
|
6
|
+
* implemented is the one catalogs use: text and placeholders, quoted and
|
|
7
|
+
* unquoted literals, `.input` and `.local` declarations, `.match` with
|
|
8
|
+
* numeric and string selectors, and the default functions `:string`,
|
|
9
|
+
* `:number`, `:integer`, `:percent`, `:currency`, `:offset`, `:date`,
|
|
10
|
+
* `:time`, and `:datetime`. Markup is accepted and formats to nothing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A catalog: message key to MessageFormat 2 source. */
|
|
14
|
+
export type Catalog = Readonly<Record<string, string>>;
|
|
15
|
+
|
|
16
|
+
/** Options for `compileMessages`. */
|
|
17
|
+
export interface CompileOptions
|
|
18
|
+
{
|
|
19
|
+
/** `ts` emits TypeScript (the default); `js` emits JavaScript with JSDoc types for buildless projects. */
|
|
20
|
+
language?: "ts" | "js";
|
|
21
|
+
/** Named in the generated header. */
|
|
22
|
+
source?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The result of compiling one catalog. */
|
|
26
|
+
export interface CompiledCatalog
|
|
27
|
+
{
|
|
28
|
+
/** The generated module's source, TypeScript or JavaScript as the `language` option chose; the `zerolith-i18n` CLI writes it to `<locale>.ts` or `<locale>.js`. */
|
|
29
|
+
code: string;
|
|
30
|
+
/** The message keys as function names, in source order. */
|
|
31
|
+
names: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type ArgumentType = "number" | "date" | "string";
|
|
35
|
+
|
|
36
|
+
interface Literal
|
|
37
|
+
{
|
|
38
|
+
kind: "literal";
|
|
39
|
+
value: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Variable
|
|
43
|
+
{
|
|
44
|
+
kind: "variable";
|
|
45
|
+
name: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Expression
|
|
49
|
+
{
|
|
50
|
+
operand: Literal | Variable | undefined;
|
|
51
|
+
fn: string | undefined;
|
|
52
|
+
options: Record<string, Literal | Variable>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Part = string | Expression;
|
|
56
|
+
|
|
57
|
+
interface Variant
|
|
58
|
+
{
|
|
59
|
+
keys: string[];
|
|
60
|
+
pattern: Part[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Message
|
|
64
|
+
{
|
|
65
|
+
declarations: Map<string, { expression: Expression; local: boolean }>;
|
|
66
|
+
pattern: Part[] | undefined;
|
|
67
|
+
selectors: Expression[];
|
|
68
|
+
variants: Variant[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class Parser
|
|
72
|
+
{
|
|
73
|
+
private index = 0;
|
|
74
|
+
private readonly text: string;
|
|
75
|
+
private readonly key: string;
|
|
76
|
+
|
|
77
|
+
constructor(text: string, key: string)
|
|
78
|
+
{
|
|
79
|
+
this.text = text;
|
|
80
|
+
this.key = key;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private fail(message: string): never
|
|
84
|
+
{
|
|
85
|
+
throw new Error(`zerolith/i18n: message "${this.key}": ${message} at offset ${this.index}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private peek(offset = 0): string
|
|
89
|
+
{
|
|
90
|
+
return this.text.charAt(this.index + offset);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private skipSpace(): void
|
|
94
|
+
{
|
|
95
|
+
while (/[ \t\r\n ]/.test(this.peek()))
|
|
96
|
+
{
|
|
97
|
+
this.index++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private expect(token: string): void
|
|
102
|
+
{
|
|
103
|
+
if (!this.text.startsWith(token, this.index))
|
|
104
|
+
{
|
|
105
|
+
this.fail(`expected ${JSON.stringify(token)}`);
|
|
106
|
+
}
|
|
107
|
+
this.index += token.length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private name(): string
|
|
111
|
+
{
|
|
112
|
+
const match = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(this.text.slice(this.index));
|
|
113
|
+
if (match === null)
|
|
114
|
+
{
|
|
115
|
+
this.fail("expected a name");
|
|
116
|
+
}
|
|
117
|
+
this.index += match[0].length;
|
|
118
|
+
return match[0];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private literal(): Literal
|
|
122
|
+
{
|
|
123
|
+
if (this.peek() === "|")
|
|
124
|
+
{
|
|
125
|
+
this.index++;
|
|
126
|
+
let value = "";
|
|
127
|
+
while (this.index < this.text.length && this.peek() !== "|")
|
|
128
|
+
{
|
|
129
|
+
if (this.peek() === "\\")
|
|
130
|
+
{
|
|
131
|
+
value += this.peek(1);
|
|
132
|
+
this.index += 2;
|
|
133
|
+
}
|
|
134
|
+
else
|
|
135
|
+
{
|
|
136
|
+
value += this.peek();
|
|
137
|
+
this.index++;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
this.expect("|");
|
|
141
|
+
return { kind: "literal", value };
|
|
142
|
+
}
|
|
143
|
+
const match = /^[A-Za-z0-9_.+:@%-]+/.exec(this.text.slice(this.index));
|
|
144
|
+
if (match === null)
|
|
145
|
+
{
|
|
146
|
+
this.fail("expected a literal");
|
|
147
|
+
}
|
|
148
|
+
this.index += match[0].length;
|
|
149
|
+
return { kind: "literal", value: match[0] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private variable(): Variable
|
|
153
|
+
{
|
|
154
|
+
this.expect("$");
|
|
155
|
+
return { kind: "variable", name: this.name() };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Parses the inside of `{ ... }` after the opening brace. */
|
|
159
|
+
private expression(): Expression | undefined
|
|
160
|
+
{
|
|
161
|
+
this.skipSpace();
|
|
162
|
+
if (this.peek() === "#" || this.peek() === "/")
|
|
163
|
+
{
|
|
164
|
+
while (this.index < this.text.length && this.peek() !== "}")
|
|
165
|
+
{
|
|
166
|
+
this.index++;
|
|
167
|
+
}
|
|
168
|
+
this.expect("}");
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
let operand: Literal | Variable | undefined;
|
|
172
|
+
if (this.peek() === "$")
|
|
173
|
+
{
|
|
174
|
+
operand = this.variable();
|
|
175
|
+
}
|
|
176
|
+
else if (this.peek() !== ":")
|
|
177
|
+
{
|
|
178
|
+
operand = this.literal();
|
|
179
|
+
}
|
|
180
|
+
this.skipSpace();
|
|
181
|
+
let fn: string | undefined;
|
|
182
|
+
const options: Record<string, Literal | Variable> = {};
|
|
183
|
+
if (this.peek() === ":")
|
|
184
|
+
{
|
|
185
|
+
this.index++;
|
|
186
|
+
fn = this.name();
|
|
187
|
+
this.skipSpace();
|
|
188
|
+
while (this.peek() !== "}" && this.peek() !== "@" && this.index < this.text.length)
|
|
189
|
+
{
|
|
190
|
+
const option = this.name();
|
|
191
|
+
this.skipSpace();
|
|
192
|
+
this.expect("=");
|
|
193
|
+
this.skipSpace();
|
|
194
|
+
options[option] = this.peek() === "$" ? this.variable() : this.literal();
|
|
195
|
+
this.skipSpace();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
while (this.peek() === "@")
|
|
199
|
+
{
|
|
200
|
+
this.index++;
|
|
201
|
+
this.name();
|
|
202
|
+
this.skipSpace();
|
|
203
|
+
if (this.peek() === "=")
|
|
204
|
+
{
|
|
205
|
+
this.index++;
|
|
206
|
+
this.skipSpace();
|
|
207
|
+
if (this.peek() === "$")
|
|
208
|
+
{
|
|
209
|
+
this.variable();
|
|
210
|
+
}
|
|
211
|
+
else
|
|
212
|
+
{
|
|
213
|
+
this.literal();
|
|
214
|
+
}
|
|
215
|
+
this.skipSpace();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
this.expect("}");
|
|
219
|
+
if (operand === undefined && fn === undefined)
|
|
220
|
+
{
|
|
221
|
+
this.fail("an expression needs an operand or a function");
|
|
222
|
+
}
|
|
223
|
+
return { operand, fn, options };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Parses pattern text up to `end` (`}}` for quoted patterns, the end of input for simple ones). */
|
|
227
|
+
private pattern(quoted: boolean): Part[]
|
|
228
|
+
{
|
|
229
|
+
const parts: Part[] = [];
|
|
230
|
+
let text = "";
|
|
231
|
+
while (this.index < this.text.length)
|
|
232
|
+
{
|
|
233
|
+
const char = this.peek();
|
|
234
|
+
if (quoted && char === "}" && this.peek(1) === "}")
|
|
235
|
+
{
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
if (char === "\\")
|
|
239
|
+
{
|
|
240
|
+
const next = this.peek(1);
|
|
241
|
+
if (next !== "{" && next !== "}" && next !== "\\" && next !== "|")
|
|
242
|
+
{
|
|
243
|
+
this.fail("only \\{ \\} \\\\ and \\| can be escaped");
|
|
244
|
+
}
|
|
245
|
+
text += next;
|
|
246
|
+
this.index += 2;
|
|
247
|
+
}
|
|
248
|
+
else if (char === "{")
|
|
249
|
+
{
|
|
250
|
+
if (text !== "")
|
|
251
|
+
{
|
|
252
|
+
parts.push(text);
|
|
253
|
+
text = "";
|
|
254
|
+
}
|
|
255
|
+
this.index++;
|
|
256
|
+
const expression = this.expression();
|
|
257
|
+
if (expression !== undefined)
|
|
258
|
+
{
|
|
259
|
+
parts.push(expression);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
else if (char === "}")
|
|
263
|
+
{
|
|
264
|
+
this.fail("a lone } must be escaped as \\}");
|
|
265
|
+
}
|
|
266
|
+
else
|
|
267
|
+
{
|
|
268
|
+
text += char;
|
|
269
|
+
this.index++;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (text !== "")
|
|
273
|
+
{
|
|
274
|
+
parts.push(text);
|
|
275
|
+
}
|
|
276
|
+
return parts;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private quotedPattern(): Part[]
|
|
280
|
+
{
|
|
281
|
+
this.expect("{{");
|
|
282
|
+
const parts = this.pattern(true);
|
|
283
|
+
this.expect("}}");
|
|
284
|
+
return parts;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
parse(): Message
|
|
288
|
+
{
|
|
289
|
+
const message: Message = { declarations: new Map(), pattern: undefined, selectors: [], variants: [] };
|
|
290
|
+
const trimmed = this.text.trimStart();
|
|
291
|
+
if (!trimmed.startsWith(".") && !trimmed.startsWith("{{"))
|
|
292
|
+
{
|
|
293
|
+
message.pattern = this.pattern(false);
|
|
294
|
+
return message;
|
|
295
|
+
}
|
|
296
|
+
this.skipSpace();
|
|
297
|
+
while (this.text.startsWith(".input", this.index) || this.text.startsWith(".local", this.index))
|
|
298
|
+
{
|
|
299
|
+
const local = this.text.startsWith(".local", this.index);
|
|
300
|
+
this.index += 6;
|
|
301
|
+
this.skipSpace();
|
|
302
|
+
let name: string;
|
|
303
|
+
if (local)
|
|
304
|
+
{
|
|
305
|
+
name = this.variable().name;
|
|
306
|
+
this.skipSpace();
|
|
307
|
+
this.expect("=");
|
|
308
|
+
this.skipSpace();
|
|
309
|
+
}
|
|
310
|
+
else
|
|
311
|
+
{
|
|
312
|
+
name = "";
|
|
313
|
+
}
|
|
314
|
+
this.expect("{");
|
|
315
|
+
const expression = this.expression();
|
|
316
|
+
if (expression === undefined)
|
|
317
|
+
{
|
|
318
|
+
this.fail("a declaration cannot be markup");
|
|
319
|
+
}
|
|
320
|
+
if (!local)
|
|
321
|
+
{
|
|
322
|
+
if (expression.operand?.kind !== "variable")
|
|
323
|
+
{
|
|
324
|
+
this.fail(".input needs a variable operand");
|
|
325
|
+
}
|
|
326
|
+
name = expression.operand.name;
|
|
327
|
+
}
|
|
328
|
+
if (message.declarations.has(name))
|
|
329
|
+
{
|
|
330
|
+
this.fail(`$${name} is declared twice`);
|
|
331
|
+
}
|
|
332
|
+
message.declarations.set(name, { expression, local });
|
|
333
|
+
this.skipSpace();
|
|
334
|
+
}
|
|
335
|
+
if (this.text.startsWith(".match", this.index))
|
|
336
|
+
{
|
|
337
|
+
this.index += 6;
|
|
338
|
+
this.skipSpace();
|
|
339
|
+
while (this.peek() === "$" || this.peek() === "{")
|
|
340
|
+
{
|
|
341
|
+
if (this.peek() === "{")
|
|
342
|
+
{
|
|
343
|
+
this.index++;
|
|
344
|
+
const expression = this.expression();
|
|
345
|
+
if (expression === undefined)
|
|
346
|
+
{
|
|
347
|
+
this.fail("a selector cannot be markup");
|
|
348
|
+
}
|
|
349
|
+
message.selectors.push(expression);
|
|
350
|
+
}
|
|
351
|
+
else
|
|
352
|
+
{
|
|
353
|
+
message.selectors.push({ operand: this.variable(), fn: undefined, options: {} });
|
|
354
|
+
}
|
|
355
|
+
this.skipSpace();
|
|
356
|
+
}
|
|
357
|
+
if (message.selectors.length === 0)
|
|
358
|
+
{
|
|
359
|
+
this.fail(".match needs at least one selector");
|
|
360
|
+
}
|
|
361
|
+
while (this.index < this.text.length)
|
|
362
|
+
{
|
|
363
|
+
const keys: string[] = [];
|
|
364
|
+
while (this.peek() !== "{")
|
|
365
|
+
{
|
|
366
|
+
if (this.peek() === "*")
|
|
367
|
+
{
|
|
368
|
+
this.index++;
|
|
369
|
+
keys.push("*");
|
|
370
|
+
}
|
|
371
|
+
else
|
|
372
|
+
{
|
|
373
|
+
keys.push(this.literal().value);
|
|
374
|
+
}
|
|
375
|
+
this.skipSpace();
|
|
376
|
+
}
|
|
377
|
+
if (keys.length !== message.selectors.length)
|
|
378
|
+
{
|
|
379
|
+
this.fail(`a variant needs ${message.selectors.length} key(s), found ${keys.length}`);
|
|
380
|
+
}
|
|
381
|
+
message.variants.push({ keys, pattern: this.quotedPattern() });
|
|
382
|
+
this.skipSpace();
|
|
383
|
+
}
|
|
384
|
+
if (!message.variants.some((variant) => variant.keys.every((key) => key === "*")))
|
|
385
|
+
{
|
|
386
|
+
this.fail("a matcher needs a variant whose keys are all *");
|
|
387
|
+
}
|
|
388
|
+
return message;
|
|
389
|
+
}
|
|
390
|
+
message.pattern = this.quotedPattern();
|
|
391
|
+
this.skipSpace();
|
|
392
|
+
if (this.index < this.text.length)
|
|
393
|
+
{
|
|
394
|
+
this.fail("unexpected text after the pattern");
|
|
395
|
+
}
|
|
396
|
+
return message;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const NUMBER_FUNCTIONS = new Set(["number", "integer", "percent", "currency", "offset"]);
|
|
401
|
+
const DATE_FUNCTIONS = new Set(["date", "time", "datetime"]);
|
|
402
|
+
const NUMBER_OPTIONS: Record<string, string> = {
|
|
403
|
+
minimumFractionDigits: "minimumFractionDigits",
|
|
404
|
+
maximumFractionDigits: "maximumFractionDigits",
|
|
405
|
+
minimumIntegerDigits: "minimumIntegerDigits",
|
|
406
|
+
minimumSignificantDigits: "minimumSignificantDigits",
|
|
407
|
+
maximumSignificantDigits: "maximumSignificantDigits",
|
|
408
|
+
signDisplay: "signDisplay",
|
|
409
|
+
trailingZeroDisplay: "trailingZeroDisplay",
|
|
410
|
+
roundingMode: "roundingMode",
|
|
411
|
+
roundingIncrement: "roundingIncrement",
|
|
412
|
+
roundingPriority: "roundingPriority",
|
|
413
|
+
currencyDisplay: "currencyDisplay",
|
|
414
|
+
currencySign: "currencySign",
|
|
415
|
+
numberingSystem: "numberingSystem"
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
/** Names a module cannot bind; a message named so is exported under its name from a binding of its own. */
|
|
419
|
+
const RESERVED: ReadonlySet<string> = new Set(["arguments", "await", "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", "else", "enum", "eval", "export", "extends", "false", "finally", "for", "function", "if", "implements", "import", "in", "instanceof", "interface", "let", "new", "null", "package", "private", "protected", "public", "return", "static", "super", "switch", "this", "throw", "true", "try", "typeof", "var", "void", "while", "with", "yield"]);
|
|
420
|
+
|
|
421
|
+
/** Characters an HTML parser reads inside an inline script, and the line separators older engines refused in string literals. */
|
|
422
|
+
const SCRIPT_UNSAFE = new RegExp(`[<>${String.fromCodePoint(0x2028, 0x2029)}]`, "g");
|
|
423
|
+
|
|
424
|
+
/** The line breaks of a message's source, each starting a new line of its doc comment. */
|
|
425
|
+
const SOURCE_LINES = /\r\n|[\n\r\u2028\u2029]/;
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* A value written as JavaScript source: its JSON, with `<`, `>`, U+2028, and
|
|
429
|
+
* U+2029 escaped, so generated code stays inert even when a page inlines
|
|
430
|
+
* it in a script element.
|
|
431
|
+
*/
|
|
432
|
+
function jsLiteral(value: unknown): string
|
|
433
|
+
{
|
|
434
|
+
return JSON.stringify(value).replace(SCRIPT_UNSAFE, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Text for a `//` comment, kept on one line and with `<` written as `<`, so it can end neither the comment nor an inline script. */
|
|
438
|
+
function commentText(text: string): string
|
|
439
|
+
{
|
|
440
|
+
return text.split(SOURCE_LINES).join(" ").replaceAll("<", "<");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** An argument or local name JavaScript can write as it is; MessageFormat also allows `-` and `.`, as in `$first-name`. */
|
|
444
|
+
const PLAIN_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
445
|
+
|
|
446
|
+
/** A property name as code: as it is when plain, quoted otherwise. */
|
|
447
|
+
function property(name: string): string
|
|
448
|
+
{
|
|
449
|
+
return PLAIN_NAME.test(name) ? name : jsLiteral(name);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function identifier(key: string): string
|
|
453
|
+
{
|
|
454
|
+
const words = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter((word) => word !== "");
|
|
455
|
+
const name = words.map((word, index) => (index === 0 ? word.charAt(0).toLowerCase() + word.slice(1) : word.charAt(0).toUpperCase() + word.slice(1))).join("");
|
|
456
|
+
return /^[0-9]/.test(name) ? `_${name}` : name === "" ? "_" : name;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
class Emitter
|
|
460
|
+
{
|
|
461
|
+
readonly arguments: Map<string, ArgumentType> = new Map();
|
|
462
|
+
readonly locals: Map<string, string> = new Map();
|
|
463
|
+
readonly prelude: string[] = [];
|
|
464
|
+
private readonly key: string;
|
|
465
|
+
private readonly message: Message;
|
|
466
|
+
private readonly formatters: Map<string, string>;
|
|
467
|
+
private readonly used: Set<Helper>;
|
|
468
|
+
private reads = 0;
|
|
469
|
+
|
|
470
|
+
constructor(key: string, message: Message, formatters: Map<string, string>, used: Set<Helper>)
|
|
471
|
+
{
|
|
472
|
+
this.key = key;
|
|
473
|
+
this.message = message;
|
|
474
|
+
this.formatters = formatters;
|
|
475
|
+
this.used = used;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The module-level binding of a formatter whose options are known at compile time, created on first use and shared by every message that creates it the same way. */
|
|
479
|
+
private shared(create: string): string
|
|
480
|
+
{
|
|
481
|
+
this.used.add("once");
|
|
482
|
+
let binding = this.formatters.get(create);
|
|
483
|
+
if (binding === undefined)
|
|
484
|
+
{
|
|
485
|
+
binding = `$f${this.formatters.size}`;
|
|
486
|
+
this.formatters.set(create, binding);
|
|
487
|
+
}
|
|
488
|
+
return binding;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private fail(text: string): never
|
|
492
|
+
{
|
|
493
|
+
throw new Error(`zerolith/i18n: message "${this.key}": ${text}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private argument(name: string, type: ArgumentType): string
|
|
497
|
+
{
|
|
498
|
+
this.reads++;
|
|
499
|
+
const local = this.locals.get(name);
|
|
500
|
+
if (local !== undefined)
|
|
501
|
+
{
|
|
502
|
+
return local;
|
|
503
|
+
}
|
|
504
|
+
const existing = this.arguments.get(name);
|
|
505
|
+
if (existing === undefined || existing === "string")
|
|
506
|
+
{
|
|
507
|
+
this.arguments.set(name, type);
|
|
508
|
+
}
|
|
509
|
+
else if (existing !== type && type !== "string")
|
|
510
|
+
{
|
|
511
|
+
this.fail(`$${name} is used both as ${existing} and as ${type}`);
|
|
512
|
+
}
|
|
513
|
+
return PLAIN_NAME.test(name) ? `args.${name}` : `args[${jsLiteral(name)}]`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private optionValue(value: Literal | Variable | undefined): string | undefined
|
|
517
|
+
{
|
|
518
|
+
if (value === undefined)
|
|
519
|
+
{
|
|
520
|
+
return undefined;
|
|
521
|
+
}
|
|
522
|
+
return value.kind === "literal" ? jsLiteral(value.value) : `String(${this.argument(value.name, "string")})`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private numberOptions(expression: Expression): string
|
|
526
|
+
{
|
|
527
|
+
const entries: string[] = [];
|
|
528
|
+
const fn = expression.fn ?? "number";
|
|
529
|
+
if (fn === "integer")
|
|
530
|
+
{
|
|
531
|
+
entries.push("maximumFractionDigits: 0");
|
|
532
|
+
}
|
|
533
|
+
else if (fn === "percent")
|
|
534
|
+
{
|
|
535
|
+
entries.push("style: \"percent\"");
|
|
536
|
+
}
|
|
537
|
+
else if (fn === "currency")
|
|
538
|
+
{
|
|
539
|
+
const currency = this.optionValue(expression.options["currency"]);
|
|
540
|
+
if (currency === undefined)
|
|
541
|
+
{
|
|
542
|
+
this.fail(":currency needs a currency option");
|
|
543
|
+
}
|
|
544
|
+
entries.push("style: \"currency\"", `currency: ${currency}`);
|
|
545
|
+
}
|
|
546
|
+
for (const [option, value] of Object.entries(expression.options))
|
|
547
|
+
{
|
|
548
|
+
const mapped = NUMBER_OPTIONS[option];
|
|
549
|
+
if (mapped !== undefined)
|
|
550
|
+
{
|
|
551
|
+
const literal = value.kind === "literal" ? value.value : undefined;
|
|
552
|
+
const numeric = literal !== undefined && /^[0-9]+$/.test(literal) ? literal : undefined;
|
|
553
|
+
entries.push(`${mapped}: ${numeric ?? this.optionValue(value) ?? "undefined"}`);
|
|
554
|
+
}
|
|
555
|
+
else if (option === "useGrouping" && value.kind === "literal")
|
|
556
|
+
{
|
|
557
|
+
entries.push(`useGrouping: ${value.value === "never" ? "false" : value.value === "always" ? "true" : jsLiteral(value.value)}`);
|
|
558
|
+
}
|
|
559
|
+
else if (option === "fractionDigits" && value.kind === "literal" && /^[0-9]+$/.test(value.value))
|
|
560
|
+
{
|
|
561
|
+
entries.push(`minimumFractionDigits: ${value.value}`, `maximumFractionDigits: ${value.value}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private dateOptions(expression: Expression): string
|
|
568
|
+
{
|
|
569
|
+
const entries: string[] = [];
|
|
570
|
+
const fn = expression.fn ?? "date";
|
|
571
|
+
const literal = (name: string): string | undefined =>
|
|
572
|
+
{
|
|
573
|
+
const value = expression.options[name];
|
|
574
|
+
return value?.kind === "literal" ? value.value : undefined;
|
|
575
|
+
};
|
|
576
|
+
const length = literal(fn === "datetime" ? "dateLength" : "length") ?? (fn === "time" ? undefined : "medium");
|
|
577
|
+
if (length !== undefined && fn !== "time")
|
|
578
|
+
{
|
|
579
|
+
entries.push(`dateStyle: ${jsLiteral(length)}`);
|
|
580
|
+
}
|
|
581
|
+
const precision = literal(fn === "datetime" ? "timePrecision" : "precision") ?? (fn === "date" ? undefined : "minute");
|
|
582
|
+
if (precision !== undefined && fn !== "date")
|
|
583
|
+
{
|
|
584
|
+
entries.push(`timeStyle: ${jsLiteral(precision === "second" ? "medium" : "short")}`);
|
|
585
|
+
}
|
|
586
|
+
const zone = literal("timeZone");
|
|
587
|
+
if (zone !== undefined && zone !== "input")
|
|
588
|
+
{
|
|
589
|
+
entries.push(`timeZone: ${jsLiteral(zone)}`);
|
|
590
|
+
}
|
|
591
|
+
const hour12 = literal("hour12");
|
|
592
|
+
if (hour12 !== undefined)
|
|
593
|
+
{
|
|
594
|
+
entries.push(`hour12: ${hour12 === "true"}`);
|
|
595
|
+
}
|
|
596
|
+
const style = literal("timeZoneStyle");
|
|
597
|
+
if (style !== undefined)
|
|
598
|
+
{
|
|
599
|
+
entries.push(`timeZoneName: ${jsLiteral(style)}`);
|
|
600
|
+
}
|
|
601
|
+
return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** The JavaScript for an expression's numeric value, before formatting. */
|
|
605
|
+
private numeric(expression: Expression): string
|
|
606
|
+
{
|
|
607
|
+
const operand = expression.operand;
|
|
608
|
+
let value: string;
|
|
609
|
+
if (operand === undefined)
|
|
610
|
+
{
|
|
611
|
+
this.fail(`:${expression.fn ?? ""} needs an operand`);
|
|
612
|
+
}
|
|
613
|
+
else if (operand.kind === "literal")
|
|
614
|
+
{
|
|
615
|
+
value = Number.isFinite(Number(operand.value)) ? String(Number(operand.value)) : this.fail(`${JSON.stringify(operand.value)} is not a number`);
|
|
616
|
+
}
|
|
617
|
+
else
|
|
618
|
+
{
|
|
619
|
+
value = this.argument(operand.name, "number");
|
|
620
|
+
}
|
|
621
|
+
if (expression.fn === "offset")
|
|
622
|
+
{
|
|
623
|
+
const add = this.optionValue(expression.options["add"]);
|
|
624
|
+
const subtract = this.optionValue(expression.options["subtract"]);
|
|
625
|
+
if ((add === undefined) === (subtract === undefined))
|
|
626
|
+
{
|
|
627
|
+
this.fail(":offset needs exactly one of add or subtract");
|
|
628
|
+
}
|
|
629
|
+
value = `(${value} ${add === undefined ? "-" : "+"} Number(${add ?? subtract ?? "0"}))`;
|
|
630
|
+
}
|
|
631
|
+
return value;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Resolves a plain `{$x}` to its `.input` declaration when one exists, or with `locals` to its `.local` one too, so a selector on a local selects as its expression does. */
|
|
635
|
+
private resolve(expression: Expression, locals = false): Expression
|
|
636
|
+
{
|
|
637
|
+
if (expression.fn === undefined && expression.operand?.kind === "variable")
|
|
638
|
+
{
|
|
639
|
+
const declared = this.message.declarations.get(expression.operand.name);
|
|
640
|
+
if (declared !== undefined && (locals || !declared.local))
|
|
641
|
+
{
|
|
642
|
+
return declared.expression;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return expression;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** The JavaScript producing the formatted string of an expression. */
|
|
649
|
+
format(raw: Expression): string
|
|
650
|
+
{
|
|
651
|
+
const expression = this.resolve(raw);
|
|
652
|
+
const fn = expression.fn;
|
|
653
|
+
if (fn === undefined || fn === "string")
|
|
654
|
+
{
|
|
655
|
+
const operand = expression.operand;
|
|
656
|
+
if (operand === undefined)
|
|
657
|
+
{
|
|
658
|
+
this.fail(":string needs an operand");
|
|
659
|
+
}
|
|
660
|
+
return operand.kind === "literal" ? jsLiteral(operand.value) : `String(${this.argument(operand.name, "string")})`;
|
|
661
|
+
}
|
|
662
|
+
if (NUMBER_FUNCTIONS.has(fn))
|
|
663
|
+
{
|
|
664
|
+
const value = this.numeric(expression);
|
|
665
|
+
const reads = this.reads;
|
|
666
|
+
const options = this.numberOptions(expression);
|
|
667
|
+
if (this.reads === reads)
|
|
668
|
+
{
|
|
669
|
+
return `${this.shared(`new Intl.NumberFormat(locale, ${options})`)}().format(${value})`;
|
|
670
|
+
}
|
|
671
|
+
this.used.add("number");
|
|
672
|
+
return `$number(${value}, ${options})`;
|
|
673
|
+
}
|
|
674
|
+
if (DATE_FUNCTIONS.has(fn))
|
|
675
|
+
{
|
|
676
|
+
const operand = expression.operand;
|
|
677
|
+
if (operand === undefined)
|
|
678
|
+
{
|
|
679
|
+
this.fail(`:${fn} needs an operand`);
|
|
680
|
+
}
|
|
681
|
+
const value = operand.kind === "literal" ? `new Date(${jsLiteral(operand.value)})` : this.argument(operand.name, "date");
|
|
682
|
+
return `${this.shared(`new Intl.DateTimeFormat(locale, ${this.dateOptions(expression)})`)}().format(${value})`;
|
|
683
|
+
}
|
|
684
|
+
this.fail(`unknown function :${fn}`);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** The JavaScript for a selector: its comparable value and the categories it matches. */
|
|
688
|
+
selector(raw: Expression): string
|
|
689
|
+
{
|
|
690
|
+
const expression = this.resolve(raw, true);
|
|
691
|
+
const fn = expression.fn ?? "string";
|
|
692
|
+
if (NUMBER_FUNCTIONS.has(fn))
|
|
693
|
+
{
|
|
694
|
+
const select = expression.options["select"];
|
|
695
|
+
const mode = select?.kind === "literal" ? select.value : "plural";
|
|
696
|
+
const value = fn === "percent" ? `(${this.numeric(expression)}) * 100` : this.numeric(expression);
|
|
697
|
+
if (mode === "exact")
|
|
698
|
+
{
|
|
699
|
+
return `{ value: String(${value}), categories: [] }`;
|
|
700
|
+
}
|
|
701
|
+
const type = jsLiteral(mode === "ordinal" ? "ordinal" : "cardinal");
|
|
702
|
+
const reads = this.reads;
|
|
703
|
+
const options = this.numberOptions(expression);
|
|
704
|
+
if (this.reads === reads)
|
|
705
|
+
{
|
|
706
|
+
return `{ value: String(${value}), categories: [${this.shared(`new Intl.PluralRules(locale, { ...new Intl.NumberFormat(locale, ${options}).resolvedOptions(), type: ${type} })`)}().select(${value})] }`;
|
|
707
|
+
}
|
|
708
|
+
this.used.add("plural");
|
|
709
|
+
return `{ value: String(${value}), categories: [$plural(${value}, ${type}, ${options})] }`;
|
|
710
|
+
}
|
|
711
|
+
if (fn === "string")
|
|
712
|
+
{
|
|
713
|
+
const operand = expression.operand;
|
|
714
|
+
if (operand === undefined)
|
|
715
|
+
{
|
|
716
|
+
this.fail("a selector needs an operand");
|
|
717
|
+
}
|
|
718
|
+
const value = operand.kind === "literal" ? jsLiteral(operand.value) : `String(${this.argument(operand.name, "string")})`;
|
|
719
|
+
return `{ value: ${value}, categories: [] }`;
|
|
720
|
+
}
|
|
721
|
+
this.fail(`:${fn} cannot select`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
pattern(parts: Part[]): string
|
|
725
|
+
{
|
|
726
|
+
if (parts.length === 0)
|
|
727
|
+
{
|
|
728
|
+
return "\"\"";
|
|
729
|
+
}
|
|
730
|
+
return parts.map((part) => (typeof part === "string" ? jsLiteral(part) : this.format(part))).join(" + ");
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
body(): string
|
|
734
|
+
{
|
|
735
|
+
for (const [name, declaration] of this.message.declarations)
|
|
736
|
+
{
|
|
737
|
+
if (declaration.local)
|
|
738
|
+
{
|
|
739
|
+
const value = this.format(declaration.expression);
|
|
740
|
+
const binding = PLAIN_NAME.test(name) ? `local_${name}` : `local_${this.locals.size}`;
|
|
741
|
+
this.locals.set(name, binding);
|
|
742
|
+
this.prelude.push(`const ${binding} = ${value};`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (this.message.pattern !== undefined)
|
|
746
|
+
{
|
|
747
|
+
return this.pattern(this.message.pattern);
|
|
748
|
+
}
|
|
749
|
+
const selectors = this.message.selectors.map((selector) => this.selector(selector));
|
|
750
|
+
const variants = this.message.variants.map((variant) => `{ keys: ${jsLiteral(variant.keys)}, pattern: () => ${this.pattern(variant.pattern)} }`);
|
|
751
|
+
this.used.add("select");
|
|
752
|
+
return `$select([${selectors.join(", ")}], [${variants.join(", ")}])`;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** A group of helpers a compiled module shares; each is written only when one of the module's messages uses it. */
|
|
757
|
+
type Helper = "cache" | "once" | "number" | "plural" | "select";
|
|
758
|
+
|
|
759
|
+
/** The helpers in JavaScript and in TypeScript, in the order a module writes them. */
|
|
760
|
+
const HELPERS: Record<Helper, { js: string; ts: string }> = {
|
|
761
|
+
cache: {
|
|
762
|
+
js: `const $formats = new Map();
|
|
763
|
+
function $cached(kind, options, create)
|
|
764
|
+
{
|
|
765
|
+
const key = kind + JSON.stringify(options);
|
|
766
|
+
let instance = $formats.get(key);
|
|
767
|
+
if (instance === undefined)
|
|
768
|
+
{
|
|
769
|
+
instance = create();
|
|
770
|
+
$formats.set(key, instance);
|
|
771
|
+
}
|
|
772
|
+
return instance;
|
|
773
|
+
}`,
|
|
774
|
+
ts: `const $formats: Map<string, unknown> = new Map();
|
|
775
|
+
function $cached<T>(kind: string, options: object, create: () => T): T
|
|
776
|
+
{
|
|
777
|
+
const key = kind + JSON.stringify(options);
|
|
778
|
+
let instance = $formats.get(key) as T | undefined;
|
|
779
|
+
if (instance === undefined)
|
|
780
|
+
{
|
|
781
|
+
instance = create();
|
|
782
|
+
$formats.set(key, instance);
|
|
783
|
+
}
|
|
784
|
+
return instance;
|
|
785
|
+
}`
|
|
786
|
+
},
|
|
787
|
+
once: {
|
|
788
|
+
js: `function $once(create)
|
|
789
|
+
{
|
|
790
|
+
let instance;
|
|
791
|
+
return () => (instance ??= create());
|
|
792
|
+
}`,
|
|
793
|
+
ts: `function $once<T>(create: () => T): () => T
|
|
794
|
+
{
|
|
795
|
+
let instance: T | undefined;
|
|
796
|
+
return () => (instance ??= create());
|
|
797
|
+
}`
|
|
798
|
+
},
|
|
799
|
+
number: {
|
|
800
|
+
js: `function $number(value, options)
|
|
801
|
+
{
|
|
802
|
+
return $cached("n", options, () => new Intl.NumberFormat(locale, options)).format(value);
|
|
803
|
+
}`,
|
|
804
|
+
ts: `function $number(value: number, options: Intl.NumberFormatOptions): string
|
|
805
|
+
{
|
|
806
|
+
return $cached("n", options, () => new Intl.NumberFormat(locale, options)).format(value);
|
|
807
|
+
}`
|
|
808
|
+
},
|
|
809
|
+
plural: {
|
|
810
|
+
js: `function $plural(value, type, options)
|
|
811
|
+
{
|
|
812
|
+
return $cached("p" + type, options, () => new Intl.PluralRules(locale, { ...$cached("n", options, () => new Intl.NumberFormat(locale, options)).resolvedOptions(), type })).select(value);
|
|
813
|
+
}`,
|
|
814
|
+
ts: `function $plural(value: number, type: "cardinal" | "ordinal", options: Intl.NumberFormatOptions): string
|
|
815
|
+
{
|
|
816
|
+
return $cached("p" + type, options, () => new Intl.PluralRules(locale, { ...$cached("n", options, () => new Intl.NumberFormat(locale, options)).resolvedOptions(), type })).select(value);
|
|
817
|
+
}`
|
|
818
|
+
},
|
|
819
|
+
select: {
|
|
820
|
+
js: `function $select(selectors, variants)
|
|
821
|
+
{
|
|
822
|
+
let best;
|
|
823
|
+
let bestRank;
|
|
824
|
+
for (const variant of variants)
|
|
825
|
+
{
|
|
826
|
+
const rank = [];
|
|
827
|
+
let matches = true;
|
|
828
|
+
for (const [index, key] of variant.keys.entries())
|
|
829
|
+
{
|
|
830
|
+
const selector = selectors[index];
|
|
831
|
+
if (key === "*")
|
|
832
|
+
{
|
|
833
|
+
rank.push(0);
|
|
834
|
+
}
|
|
835
|
+
else if (key === selector.value)
|
|
836
|
+
{
|
|
837
|
+
rank.push(2);
|
|
838
|
+
}
|
|
839
|
+
else if (selector.categories.includes(key))
|
|
840
|
+
{
|
|
841
|
+
rank.push(1);
|
|
842
|
+
}
|
|
843
|
+
else
|
|
844
|
+
{
|
|
845
|
+
matches = false;
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (!matches)
|
|
850
|
+
{
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (matches && $better(rank, bestRank))
|
|
854
|
+
{
|
|
855
|
+
best = variant;
|
|
856
|
+
bestRank = rank;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return best === undefined ? "" : best.pattern();
|
|
860
|
+
}
|
|
861
|
+
function $better(rank, current)
|
|
862
|
+
{
|
|
863
|
+
if (current === undefined)
|
|
864
|
+
{
|
|
865
|
+
return true;
|
|
866
|
+
}
|
|
867
|
+
for (const [index, score] of rank.entries())
|
|
868
|
+
{
|
|
869
|
+
const other = current[index] ?? 0;
|
|
870
|
+
if (score !== other)
|
|
871
|
+
{
|
|
872
|
+
return score > other;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return false;
|
|
876
|
+
}`,
|
|
877
|
+
ts: `interface Selector
|
|
878
|
+
{
|
|
879
|
+
value: string;
|
|
880
|
+
categories: string[];
|
|
881
|
+
}
|
|
882
|
+
interface Variant
|
|
883
|
+
{
|
|
884
|
+
keys: string[];
|
|
885
|
+
pattern: () => string;
|
|
886
|
+
}
|
|
887
|
+
function $better(rank: number[], current: number[] | undefined): boolean
|
|
888
|
+
{
|
|
889
|
+
if (current === undefined)
|
|
890
|
+
{
|
|
891
|
+
return true;
|
|
892
|
+
}
|
|
893
|
+
for (const [index, score] of rank.entries())
|
|
894
|
+
{
|
|
895
|
+
const other = current[index] ?? 0;
|
|
896
|
+
if (score !== other)
|
|
897
|
+
{
|
|
898
|
+
return score > other;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return false;
|
|
902
|
+
}
|
|
903
|
+
function $select(selectors: Selector[], variants: Variant[]): string
|
|
904
|
+
{
|
|
905
|
+
let best: Variant | undefined;
|
|
906
|
+
let bestRank: number[] | undefined;
|
|
907
|
+
for (const variant of variants)
|
|
908
|
+
{
|
|
909
|
+
const rank: number[] = [];
|
|
910
|
+
let matches = true;
|
|
911
|
+
for (const [index, key] of variant.keys.entries())
|
|
912
|
+
{
|
|
913
|
+
const selector = selectors[index];
|
|
914
|
+
if (key === "*")
|
|
915
|
+
{
|
|
916
|
+
rank.push(0);
|
|
917
|
+
}
|
|
918
|
+
else if (selector !== undefined && key === selector.value)
|
|
919
|
+
{
|
|
920
|
+
rank.push(2);
|
|
921
|
+
}
|
|
922
|
+
else if (selector !== undefined && selector.categories.includes(key))
|
|
923
|
+
{
|
|
924
|
+
rank.push(1);
|
|
925
|
+
}
|
|
926
|
+
else
|
|
927
|
+
{
|
|
928
|
+
matches = false;
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (matches && $better(rank, bestRank))
|
|
933
|
+
{
|
|
934
|
+
best = variant;
|
|
935
|
+
bestRank = rank;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return best === undefined ? "" : best.pattern();
|
|
939
|
+
}`
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
/** The helpers a module's messages use, with the cache that `$number` and `$plural` share. */
|
|
944
|
+
function helpersFor(used: ReadonlySet<Helper>, javascript: boolean): string
|
|
945
|
+
{
|
|
946
|
+
const needed = new Set(used);
|
|
947
|
+
if (needed.has("number") || needed.has("plural"))
|
|
948
|
+
{
|
|
949
|
+
needed.add("cache");
|
|
950
|
+
}
|
|
951
|
+
return (Object.keys(HELPERS) as Helper[]).filter((name) => needed.has(name)).map((name) => HELPERS[name][javascript ? "js" : "ts"]).join("\n");
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function typeOf(type: ArgumentType): string
|
|
955
|
+
{
|
|
956
|
+
return type === "number" ? "number" : type === "date" ? "Date | number" : "string | number";
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Compiles one locale's catalog into a module: `locale`, one exported
|
|
961
|
+
* function per message named from its key, and the `Intl` helpers they
|
|
962
|
+
* share. Each function takes an `args` object typed from the message's
|
|
963
|
+
* placeholders, or nothing when the message has none. A formatter whose
|
|
964
|
+
* options are all literals is a module-level binding created on first use
|
|
965
|
+
* and shared by every message that formats the same way; options that read
|
|
966
|
+
* an argument find their formatter in a cache keyed by the options. Only
|
|
967
|
+
* the helpers the messages use are written.
|
|
968
|
+
*/
|
|
969
|
+
export function compileMessages(locale: string, catalog: Catalog, options: CompileOptions = {}): CompiledCatalog
|
|
970
|
+
{
|
|
971
|
+
const javascript = options.language === "js";
|
|
972
|
+
const out: string[] = [];
|
|
973
|
+
out.push(`// Generated by @zerolith/i18n from ${commentText(options.source ?? `the ${locale} catalog`)}. Do not edit; regenerate instead.`);
|
|
974
|
+
if (javascript)
|
|
975
|
+
{
|
|
976
|
+
out.push("// @ts-check");
|
|
977
|
+
}
|
|
978
|
+
out.push(`export const locale${javascript ? "" : ": string"} = ${jsLiteral(locale)};`);
|
|
979
|
+
out.push("");
|
|
980
|
+
const head = out.length;
|
|
981
|
+
const formatters = new Map<string, string>();
|
|
982
|
+
const used = new Set<Helper>();
|
|
983
|
+
const names: string[] = [];
|
|
984
|
+
const seen = new Set<string>();
|
|
985
|
+
for (const [key, source] of Object.entries(catalog))
|
|
986
|
+
{
|
|
987
|
+
const name = identifier(key);
|
|
988
|
+
if (seen.has(name))
|
|
989
|
+
{
|
|
990
|
+
throw new Error(`zerolith/i18n: keys "${key}" and another one both become ${name}`);
|
|
991
|
+
}
|
|
992
|
+
if (name === "locale")
|
|
993
|
+
{
|
|
994
|
+
throw new Error(`zerolith/i18n: key "${key}" becomes locale, which every compiled module exports; rename the key`);
|
|
995
|
+
}
|
|
996
|
+
const binding = RESERVED.has(name) ? `message_${name}` : name;
|
|
997
|
+
seen.add(name);
|
|
998
|
+
names.push(name);
|
|
999
|
+
const message = new Parser(source, key).parse();
|
|
1000
|
+
const emitter = new Emitter(key, message, formatters, used);
|
|
1001
|
+
const body = emitter.body();
|
|
1002
|
+
const args = [...emitter.arguments.entries()].toSorted(([a], [b]) => a.localeCompare(b));
|
|
1003
|
+
const doc = source.replace(/\*\//g, "* /").replaceAll("<", "<").split(SOURCE_LINES).map((line) => ` * ${line}`.trimEnd()).join("\n");
|
|
1004
|
+
const argumentType = `{ ${args.map(([argument, type]) => `${property(argument)}: ${typeOf(type)}`).join("; ")} }`;
|
|
1005
|
+
if (javascript)
|
|
1006
|
+
{
|
|
1007
|
+
out.push(`/**\n${doc}${args.length > 0 ? `\n * @param {${argumentType}} args` : ""}\n * @returns {string}\n */`);
|
|
1008
|
+
out.push(`${binding === name ? "export " : ""}function ${binding}(${args.length > 0 ? "args" : ""})`);
|
|
1009
|
+
}
|
|
1010
|
+
else
|
|
1011
|
+
{
|
|
1012
|
+
out.push(`/**\n${doc}\n */`);
|
|
1013
|
+
out.push(`${binding === name ? "export " : ""}function ${binding}(${args.length > 0 ? `args: ${argumentType}` : ""}): string`);
|
|
1014
|
+
}
|
|
1015
|
+
out.push("{");
|
|
1016
|
+
for (const line of emitter.prelude)
|
|
1017
|
+
{
|
|
1018
|
+
out.push(` ${line}`);
|
|
1019
|
+
}
|
|
1020
|
+
out.push(` return ${body};`);
|
|
1021
|
+
out.push("}");
|
|
1022
|
+
if (binding !== name)
|
|
1023
|
+
{
|
|
1024
|
+
out.push(`export { ${binding} as ${name} };`);
|
|
1025
|
+
}
|
|
1026
|
+
out.push("");
|
|
1027
|
+
}
|
|
1028
|
+
const helpers = helpersFor(used, javascript);
|
|
1029
|
+
const shared = Array.from(formatters, ([create, binding]) => `const ${binding} = $once(() => ${create});`);
|
|
1030
|
+
out.splice(head, 0, ...(helpers === "" ? [] : [helpers, ""]), ...(shared.length === 0 ? [] : [...shared, ""]));
|
|
1031
|
+
return { code: out.join("\n"), names };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** The keys of a catalog as function names, for comparing locales. */
|
|
1035
|
+
export function messageNames(catalog: Catalog): string[]
|
|
1036
|
+
{
|
|
1037
|
+
return Object.keys(catalog).map(identifier);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* The index module for a set of locales: the list of locales and one lazy
|
|
1042
|
+
* loader per locale, so each locale is its own chunk.
|
|
1043
|
+
*/
|
|
1044
|
+
export function compileIndex(locales: readonly string[], options: CompileOptions = {}): string
|
|
1045
|
+
{
|
|
1046
|
+
const javascript = options.language === "js";
|
|
1047
|
+
const extension = javascript ? "js" : "ts";
|
|
1048
|
+
const lines = [`// Generated by @zerolith/i18n. Do not edit; regenerate instead.`];
|
|
1049
|
+
if (javascript)
|
|
1050
|
+
{
|
|
1051
|
+
lines.push("// @ts-check");
|
|
1052
|
+
}
|
|
1053
|
+
if (!javascript)
|
|
1054
|
+
{
|
|
1055
|
+
lines.push(`import type * as first from ${jsLiteral(`./${locales[0] ?? "en"}.${extension}`)};`);
|
|
1056
|
+
lines.push("");
|
|
1057
|
+
}
|
|
1058
|
+
lines.push(`export const locales${javascript ? "" : ": readonly string[]"} = ${jsLiteral(locales)};`);
|
|
1059
|
+
lines.push("");
|
|
1060
|
+
lines.push(`/** Loads one locale's messages; each locale is its own module. */`);
|
|
1061
|
+
lines.push(javascript ? "export const loaders = {" : "export const loaders: Readonly<Record<string, () => Promise<typeof first>>> = {");
|
|
1062
|
+
lines.push(locales.map((locale) => ` ${jsLiteral(locale)}: () => import(${jsLiteral(`./${locale}.${extension}`)})`).join(",\n"));
|
|
1063
|
+
lines.push("};");
|
|
1064
|
+
lines.push("");
|
|
1065
|
+
return lines.join("\n");
|
|
1066
|
+
}
|