@moku-labs/game 0.0.1 → 0.0.2
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 +424 -37
- package/dist/assets.d.mts +161 -0
- package/dist/assets.mjs +4729 -0
- package/dist/component-DGg5DqKK.mjs +44 -0
- package/dist/control.d.mts +162 -0
- package/dist/control.mjs +758 -0
- package/dist/define-sFoO3y6X.d.mts +263 -0
- package/dist/headless-CvamCUcR.mjs +186 -0
- package/dist/headless-KaSWcd0s.d.mts +181 -0
- package/dist/index.d.mts +1204 -50
- package/dist/index.mjs +20978 -2010
- package/dist/inspect.d.mts +109 -0
- package/dist/inspect.mjs +519 -0
- package/dist/jsx-dev-runtime.d.mts +2 -0
- package/dist/jsx-dev-runtime.mjs +2 -0
- package/dist/jsx-runtime.d.mts +2 -0
- package/dist/jsx-runtime.mjs +2 -0
- package/dist/memory-CvgdnsQO.mjs +259 -0
- package/dist/registry-DlpRCibU.mjs +384 -0
- package/dist/runtime-DRlwxkIv.mjs +182 -0
- package/dist/runtime-DiOTkDZz.d.mts +77 -0
- package/dist/session-DmAxY6Ll.mjs +145 -0
- package/dist/testing.d.mts +23 -111
- package/dist/testing.mjs +12 -289
- package/dist/types-BxkNNYul.d.mts +2840 -0
- package/dist/types-CTPS9GBu.d.mts +742 -0
- package/dist/types-DD-QrG_z.d.mts +588 -0
- package/dist/types-DWILGrPn.d.mts +622 -0
- package/dist/types-DYLnSgMI.d.mts +570 -0
- package/dist/types-JNc_UQBo.d.mts +2896 -0
- package/dist/types-yg_ywtT-.d.mts +1859 -0
- package/dist/visual-BDUHSRvf.mjs +734 -0
- package/package.json +26 -4
- package/dist/registry-DWV5C0Mf.mjs +0 -666
- package/dist/types-BfsmUzLC.d.mts +0 -1908
package/dist/assets.mjs
ADDED
|
@@ -0,0 +1,4729 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createBrandConsole } from "@moku-labs/common/cli";
|
|
3
|
+
import { mkdir, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
//#region src/plugins/assets/scan/emit.ts
|
|
6
|
+
const HEADER$1 = "// generated by @moku-labs/game/assets, do not edit";
|
|
7
|
+
/** How the generated table ends, so the keys stay literal types for the game. */
|
|
8
|
+
const FROZEN = " as const;";
|
|
9
|
+
/**
|
|
10
|
+
* Compares two names, so every list the scanner writes has one order.
|
|
11
|
+
*
|
|
12
|
+
* @param left - First name.
|
|
13
|
+
* @param right - Second name.
|
|
14
|
+
* @returns The sort order.
|
|
15
|
+
*/
|
|
16
|
+
function byName$2(left, right) {
|
|
17
|
+
return left.localeCompare(right);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Writes one file entry with a fixed field order. A texture says nothing about its kind, which
|
|
21
|
+
* is what keeps a manifest written before fonts and audio readable. The reserved `atlas` field
|
|
22
|
+
* is dropped: this version never packs an atlas.
|
|
23
|
+
*
|
|
24
|
+
* @param file - The file.
|
|
25
|
+
* @returns The entry as JSON data.
|
|
26
|
+
*/
|
|
27
|
+
function plainFile(file) {
|
|
28
|
+
const kind = file.kind ?? "texture";
|
|
29
|
+
if (kind !== "texture") return {
|
|
30
|
+
key: file.key,
|
|
31
|
+
path: file.path,
|
|
32
|
+
kind,
|
|
33
|
+
mb: file.mb,
|
|
34
|
+
...file.pages === void 0 ? {} : { pages: file.pages.map((page) => ({ ...page })) }
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
key: file.key,
|
|
38
|
+
path: file.path,
|
|
39
|
+
width: file.width,
|
|
40
|
+
height: file.height,
|
|
41
|
+
mb: file.mb,
|
|
42
|
+
...file.nine === void 0 ? {} : { nine: file.nine }
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Writes one bundle with a fixed field order and its files sorted by key.
|
|
47
|
+
*
|
|
48
|
+
* @param bundle - The bundle.
|
|
49
|
+
* @returns The bundle as JSON data.
|
|
50
|
+
*/
|
|
51
|
+
function plainBundle(bundle) {
|
|
52
|
+
return {
|
|
53
|
+
feature: bundle.feature,
|
|
54
|
+
tier: bundle.tier,
|
|
55
|
+
mb: bundle.mb,
|
|
56
|
+
files: bundle.files.toSorted((left, right) => byName$2(left.key, right.key)).map((file) => plainFile(file))
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Lists the bundle names of a manifest, sorted.
|
|
61
|
+
*
|
|
62
|
+
* @param manifest - The manifest.
|
|
63
|
+
* @returns The names.
|
|
64
|
+
*/
|
|
65
|
+
function bundleNames(manifest) {
|
|
66
|
+
return Object.keys(manifest.bundles).toSorted(byName$2);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Lists the bundles of a manifest with their names, sorted by name.
|
|
70
|
+
*
|
|
71
|
+
* @param manifest - The manifest.
|
|
72
|
+
* @returns The pairs.
|
|
73
|
+
*/
|
|
74
|
+
function sortedBundles(manifest) {
|
|
75
|
+
return Object.entries(manifest.bundles).toSorted((left, right) => byName$2(left[0], right[0]));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Lists every file of a manifest, bundle by bundle in name order.
|
|
79
|
+
*
|
|
80
|
+
* @param manifest - The manifest.
|
|
81
|
+
* @returns The files.
|
|
82
|
+
*/
|
|
83
|
+
function allFiles(manifest) {
|
|
84
|
+
const files = [];
|
|
85
|
+
for (const [, bundle] of sortedBundles(manifest)) files.push(...bundle.files);
|
|
86
|
+
return files;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Writes one union type, or `never` when the game has no key of that kind yet.
|
|
90
|
+
*
|
|
91
|
+
* @param name - Name of the type.
|
|
92
|
+
* @param values - The members, in any order.
|
|
93
|
+
* @returns The declaration.
|
|
94
|
+
*/
|
|
95
|
+
function union(name, values) {
|
|
96
|
+
const members = [...new Set(values)].toSorted(byName$2);
|
|
97
|
+
if (members.length === 0) return `export type ${name} = never;`;
|
|
98
|
+
return `export type ${name} =\n${members.map((member) => ` | "${member}"`).join("\n")};`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Lists the keys of one kind. They are the narrow unions a text style and a sound are typed by,
|
|
102
|
+
* so a texture key does not compile where a font is meant.
|
|
103
|
+
*
|
|
104
|
+
* @param files - Every file of the manifest.
|
|
105
|
+
* @param kind - The kind to collect.
|
|
106
|
+
* @returns The asset keys of that kind.
|
|
107
|
+
* @example
|
|
108
|
+
* ```ts
|
|
109
|
+
* keysOfKind([{ key: "ui.body", path: "b.fnt", kind: "font", width: 0, height: 0, mb: 0 }], "font");
|
|
110
|
+
* // ["ui.body"]
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
function keysOfKind(files, kind) {
|
|
114
|
+
return files.filter((file) => file.kind === kind).map((file) => file.key);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Writes the nine-slice table the kit reads when it builds a nine-slice sprite.
|
|
118
|
+
*
|
|
119
|
+
* @param entries - The keys that carry borders.
|
|
120
|
+
* @returns The declaration.
|
|
121
|
+
*/
|
|
122
|
+
function nineTable(entries) {
|
|
123
|
+
if (entries.length === 0) return `export const nineSlice = {}${FROZEN}`;
|
|
124
|
+
return `export const nineSlice = {\n${entries.toSorted((left, right) => byName$2(left.key, right.key)).map(({ key, nine }) => ` "${key}": { left: ${nine.left}, top: ${nine.top}, right: ${nine.right}, bottom: ${nine.bottom} }`).join(",\n")}\n}${FROZEN}`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Writes `manifest.json`: two-space JSON with a trailing newline, bundles sorted by name and
|
|
128
|
+
* files sorted by key.
|
|
129
|
+
*
|
|
130
|
+
* @param manifest - What the scan produced.
|
|
131
|
+
* @returns The text of the file.
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* emitManifest({ version: 1, bundles: {} }); // '{\n "version": 1,\n "bundles": {}\n}\n'
|
|
135
|
+
* ```
|
|
136
|
+
*/
|
|
137
|
+
function emitManifest(manifest) {
|
|
138
|
+
const bundles = {};
|
|
139
|
+
for (const [name, bundle] of sortedBundles(manifest)) bundles[name] = plainBundle(bundle);
|
|
140
|
+
return `${JSON.stringify({
|
|
141
|
+
version: manifest.version,
|
|
142
|
+
bundles
|
|
143
|
+
}, void 0, 2)}\n`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Writes `generated/assets.ts`: `AssetKey` over every kind, the narrower `FontKey` and `AudioKey`
|
|
147
|
+
* next to it, `BundleKey`, and the nine-slice table. A game commits this file, so `defineGame` can
|
|
148
|
+
* type `Sprite`, a text style, a sound, `defineBundles` and the `load` effect from it.
|
|
149
|
+
*
|
|
150
|
+
* @param manifest - What the scan produced.
|
|
151
|
+
* @returns The text of the module.
|
|
152
|
+
* @example
|
|
153
|
+
* ```ts
|
|
154
|
+
* emitKeys({ version: 1, bundles: {} });
|
|
155
|
+
* // the header, four unions that are all "never" and an empty nine-slice table
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
function emitKeys(manifest) {
|
|
159
|
+
const files = allFiles(manifest);
|
|
160
|
+
const nine = [];
|
|
161
|
+
for (const file of files) if (file.nine !== void 0) nine.push({
|
|
162
|
+
key: file.key,
|
|
163
|
+
nine: file.nine
|
|
164
|
+
});
|
|
165
|
+
return `${[
|
|
166
|
+
HEADER$1,
|
|
167
|
+
union("AssetKey", files.map((file) => file.key)),
|
|
168
|
+
union("FontKey", keysOfKind(files, "font")),
|
|
169
|
+
union("AudioKey", keysOfKind(files, "audio")),
|
|
170
|
+
union("BundleKey", bundleNames(manifest)),
|
|
171
|
+
nineTable(nine)
|
|
172
|
+
].join("\n\n")}\n`;
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/plugins/assets/scan/fonts.ts
|
|
176
|
+
/**
|
|
177
|
+
* @file assets plugin, build time — the page images a BMFont file names. A `.fnt` and its pages
|
|
178
|
+
* are one asset, so the scanner has to read the file to know which images belong to it. Pure and
|
|
179
|
+
* free of the file system: the three formats a font exporter writes are all text.
|
|
180
|
+
*/
|
|
181
|
+
/** `page id=0 file="body_0.png"` in the text format, `<page id="0" file="body_0.png"/>` in XML. */
|
|
182
|
+
const PAGE_FILE = /\bpage\b[^\n>]*?\bfile\s*=\s*"([^"]+)"/g;
|
|
183
|
+
/**
|
|
184
|
+
* Wraps a scanner problem in the message shape of the framework.
|
|
185
|
+
*
|
|
186
|
+
* @param message - One sentence naming the file.
|
|
187
|
+
* @returns The error to throw.
|
|
188
|
+
*/
|
|
189
|
+
function problem$5(message) {
|
|
190
|
+
return /* @__PURE__ */ new Error(`[game] assets: ${message}`);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Reads the page names of a BMFont JSON file. A page is a file name, or an object with one.
|
|
194
|
+
*
|
|
195
|
+
* @param source - The file contents.
|
|
196
|
+
* @param file - Path of the file, for the message.
|
|
197
|
+
* @returns The page file names, in the order the font declares them.
|
|
198
|
+
* @throws {Error} When the JSON cannot be read.
|
|
199
|
+
*/
|
|
200
|
+
function jsonPages(source, file) {
|
|
201
|
+
let parsed;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(source);
|
|
204
|
+
} catch {
|
|
205
|
+
throw problem$5(`the font "${file}" is not readable BMFont JSON.`);
|
|
206
|
+
}
|
|
207
|
+
const pages = parsed.pages;
|
|
208
|
+
if (!Array.isArray(pages)) return [];
|
|
209
|
+
return pages.map((page) => typeof page === "string" ? page : page.file).filter((page) => typeof page === "string");
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Reads the page images one `.fnt` file names, whichever of the three BMFont formats it is in.
|
|
213
|
+
*
|
|
214
|
+
* @param source - The file contents.
|
|
215
|
+
* @param file - Path of the file from the scan root, for the messages.
|
|
216
|
+
* @returns The page file names, relative to the folder of the font, in declaration order.
|
|
217
|
+
* @throws {Error} When the file names no page, or its JSON cannot be read.
|
|
218
|
+
* @example
|
|
219
|
+
* ```ts
|
|
220
|
+
* pagesOfFont('page id=0 file="body_0.png"', "features/ui/assets/body.fnt"); // ["body_0.png"]
|
|
221
|
+
* ```
|
|
222
|
+
*/
|
|
223
|
+
function pagesOfFont(source, file) {
|
|
224
|
+
const text = source.trimStart();
|
|
225
|
+
const pages = text.startsWith("{") ? jsonPages(text, file) : [...source.matchAll(PAGE_FILE)].map((match) => match[1] ?? "");
|
|
226
|
+
if (pages.length === 0) throw problem$5(`the font "${file}" declares no page.`);
|
|
227
|
+
return pages;
|
|
228
|
+
}
|
|
229
|
+
const PNG_SIGNATURE = [
|
|
230
|
+
137,
|
|
231
|
+
80,
|
|
232
|
+
78,
|
|
233
|
+
71,
|
|
234
|
+
13,
|
|
235
|
+
10,
|
|
236
|
+
26,
|
|
237
|
+
10
|
|
238
|
+
];
|
|
239
|
+
const VP8_SYNC = [
|
|
240
|
+
157,
|
|
241
|
+
1,
|
|
242
|
+
42
|
|
243
|
+
];
|
|
244
|
+
const VP8L_SIGNATURE = 47;
|
|
245
|
+
/** Both WebP frame headers carry the width and the height in 14 bits each. */
|
|
246
|
+
const SIZE_MASK = 16383;
|
|
247
|
+
const BYTES_PER_PIXEL = 4;
|
|
248
|
+
const BYTES_PER_MB = 1048576;
|
|
249
|
+
/**
|
|
250
|
+
* Wraps a scanner problem in the message shape of the framework.
|
|
251
|
+
*
|
|
252
|
+
* @param message - One sentence naming the file.
|
|
253
|
+
* @returns The error to throw.
|
|
254
|
+
*/
|
|
255
|
+
function problem$4(message) {
|
|
256
|
+
return /* @__PURE__ */ new Error(`[game] assets: ${message}`);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Reads the bytes as a data view.
|
|
260
|
+
*
|
|
261
|
+
* @param head - The first bytes of the file.
|
|
262
|
+
* @returns A view over exactly those bytes.
|
|
263
|
+
*/
|
|
264
|
+
function viewOf(head) {
|
|
265
|
+
return new DataView(head.buffer, head.byteOffset, head.byteLength);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Tells whether a run of bytes stands at an offset.
|
|
269
|
+
*
|
|
270
|
+
* @param head - The first bytes of the file.
|
|
271
|
+
* @param offset - Where to look.
|
|
272
|
+
* @param bytes - What to find.
|
|
273
|
+
* @returns True when every byte matches.
|
|
274
|
+
*/
|
|
275
|
+
function hasBytes(head, offset, bytes) {
|
|
276
|
+
return bytes.every((byte, index) => head[offset + index] === byte);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Reads a run of bytes as ASCII, which is what the chunk names of these formats are.
|
|
280
|
+
*
|
|
281
|
+
* @param head - The first bytes of the file.
|
|
282
|
+
* @param offset - Where to start.
|
|
283
|
+
* @param length - How many bytes to read.
|
|
284
|
+
* @returns The text.
|
|
285
|
+
*/
|
|
286
|
+
function ascii(head, offset, length) {
|
|
287
|
+
return String.fromCodePoint(...head.subarray(offset, offset + length));
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Reads a 24-bit little-endian number, the shape the `VP8X` canvas size uses.
|
|
291
|
+
*
|
|
292
|
+
* @param view - A view over the header bytes.
|
|
293
|
+
* @param offset - Where the number starts.
|
|
294
|
+
* @returns The number.
|
|
295
|
+
*/
|
|
296
|
+
function uint24Le(view, offset) {
|
|
297
|
+
return view.getUint16(offset, true) | view.getUint8(offset + 2) << 16;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Reads the size out of the IHDR chunk of a PNG.
|
|
301
|
+
*
|
|
302
|
+
* @param head - The first bytes of the file.
|
|
303
|
+
* @returns The size.
|
|
304
|
+
*/
|
|
305
|
+
function readPng(head) {
|
|
306
|
+
const view = viewOf(head);
|
|
307
|
+
return {
|
|
308
|
+
width: view.getUint32(16),
|
|
309
|
+
height: view.getUint32(20)
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Reads the size out of the frame header of a WebP, whichever of the three chunks it carries.
|
|
314
|
+
*
|
|
315
|
+
* @param head - The first bytes of the file.
|
|
316
|
+
* @param file - Path of the file, for the messages.
|
|
317
|
+
* @returns The size.
|
|
318
|
+
* @throws {Error} When the chunk is unknown or its frame header is damaged.
|
|
319
|
+
*/
|
|
320
|
+
function readWebp(head, file) {
|
|
321
|
+
const view = viewOf(head);
|
|
322
|
+
const chunk = ascii(head, 12, 4);
|
|
323
|
+
if (chunk === "VP8 ") {
|
|
324
|
+
if (!hasBytes(head, 23, VP8_SYNC)) throw problem$4(`the VP8 frame of "${file}" is damaged.`);
|
|
325
|
+
return {
|
|
326
|
+
width: view.getUint16(26, true) & SIZE_MASK,
|
|
327
|
+
height: view.getUint16(28, true) & SIZE_MASK
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (chunk === "VP8L") {
|
|
331
|
+
if (head[20] !== VP8L_SIGNATURE) throw problem$4(`the VP8L frame of "${file}" is damaged.`);
|
|
332
|
+
const bits = view.getUint32(21, true);
|
|
333
|
+
return {
|
|
334
|
+
width: (bits & SIZE_MASK) + 1,
|
|
335
|
+
height: (bits >>> 14 & SIZE_MASK) + 1
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if (chunk === "VP8X") return {
|
|
339
|
+
width: uint24Le(view, 24) + 1,
|
|
340
|
+
height: uint24Le(view, 27) + 1
|
|
341
|
+
};
|
|
342
|
+
throw problem$4(`"${file}" carries the WebP chunk "${chunk}", which this version cannot read.`);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Reads the pixel size of an image out of its header bytes.
|
|
346
|
+
*
|
|
347
|
+
* @param head - The first `HEADER_BYTES` bytes of the file.
|
|
348
|
+
* @param file - Path of the file, for the messages.
|
|
349
|
+
* @returns The size.
|
|
350
|
+
* @throws {Error} When the bytes are neither a PNG nor a WebP the scanner reads.
|
|
351
|
+
* @example
|
|
352
|
+
* ```ts
|
|
353
|
+
* readImageSize(await readHead("panel.png"), "features/ui/assets/panel.png");
|
|
354
|
+
* // { width: 256, height: 128 }
|
|
355
|
+
* ```
|
|
356
|
+
*/
|
|
357
|
+
function readImageSize(head, file) {
|
|
358
|
+
if (head.length >= 30) {
|
|
359
|
+
if (hasBytes(head, 0, PNG_SIGNATURE) && ascii(head, 12, 4) === "IHDR") return readPng(head);
|
|
360
|
+
if (ascii(head, 0, 4) === "RIFF" && ascii(head, 8, 4) === "WEBP") return readWebp(head, file);
|
|
361
|
+
}
|
|
362
|
+
throw problem$4(`"${file}" is not a PNG or a WebP file.`);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Estimates what one image costs on the GPU: four bytes per pixel, no mipmaps, rounded to three
|
|
366
|
+
* decimals so two scans of the same tree write the same bytes.
|
|
367
|
+
*
|
|
368
|
+
* @param width - Width in pixels.
|
|
369
|
+
* @param height - Height in pixels.
|
|
370
|
+
* @returns The estimated texture memory in MB.
|
|
371
|
+
* @example
|
|
372
|
+
* ```ts
|
|
373
|
+
* textureMb(256, 128); // 0.125
|
|
374
|
+
* ```
|
|
375
|
+
*/
|
|
376
|
+
function textureMb(width, height) {
|
|
377
|
+
return Math.round(width * height * BYTES_PER_PIXEL / BYTES_PER_MB * 1e3) / 1e3;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* What a file costs in memory when it is kept as it is: an audio file is held undecoded, so its
|
|
381
|
+
* bytes are the estimate. Rounded to three decimals, like every other number of the manifest.
|
|
382
|
+
*
|
|
383
|
+
* @param bytes - Size of the file in bytes.
|
|
384
|
+
* @returns The size in MB.
|
|
385
|
+
* @example
|
|
386
|
+
* ```ts
|
|
387
|
+
* bytesMb(8192); // 0.008
|
|
388
|
+
* ```
|
|
389
|
+
*/
|
|
390
|
+
function bytesMb(bytes) {
|
|
391
|
+
return Math.round(bytes / BYTES_PER_MB * 1e3) / 1e3;
|
|
392
|
+
}
|
|
393
|
+
//#endregion
|
|
394
|
+
//#region src/plugins/assets/scan/keys.ts
|
|
395
|
+
const TEXTURE = /\.(?:png|webp)$/i;
|
|
396
|
+
const FONT = /\.fnt$/i;
|
|
397
|
+
const AUDIO = /\.mp3$/i;
|
|
398
|
+
const TAG_GROUPS = /^(?:\{[^{}]*\})*$/;
|
|
399
|
+
const WHOLE_NUMBER = /^\d+$/;
|
|
400
|
+
/**
|
|
401
|
+
* Which number of a nine tag each side takes, in the order left, top, right, bottom, by how many
|
|
402
|
+
* numbers the tag carries: `{nine=N}`, `{nine=H,V}` and `{nine=L,T,R,B}`.
|
|
403
|
+
*/
|
|
404
|
+
const NINE_SIDES = {
|
|
405
|
+
1: [
|
|
406
|
+
0,
|
|
407
|
+
0,
|
|
408
|
+
0,
|
|
409
|
+
0
|
|
410
|
+
],
|
|
411
|
+
2: [
|
|
412
|
+
0,
|
|
413
|
+
1,
|
|
414
|
+
0,
|
|
415
|
+
1
|
|
416
|
+
],
|
|
417
|
+
4: [
|
|
418
|
+
0,
|
|
419
|
+
1,
|
|
420
|
+
2,
|
|
421
|
+
3
|
|
422
|
+
]
|
|
423
|
+
};
|
|
424
|
+
/**
|
|
425
|
+
* Wraps a scanner problem in the message shape of the framework.
|
|
426
|
+
*
|
|
427
|
+
* @param message - One sentence naming the file.
|
|
428
|
+
* @returns The error to throw.
|
|
429
|
+
*/
|
|
430
|
+
function problem$3(message) {
|
|
431
|
+
return /* @__PURE__ */ new Error(`[game] assets: ${message}`);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Tells what the scanner makes of a file: one texture per PNG or WebP, one font per `.fnt` with
|
|
435
|
+
* the pages it names, one sound per MP3. Everything else is left out with a note.
|
|
436
|
+
*
|
|
437
|
+
* @param fileName - Name of the file, with its extension.
|
|
438
|
+
* @returns The kind of the asset, or `undefined` when the scanner reads no such file.
|
|
439
|
+
* @example
|
|
440
|
+
* ```ts
|
|
441
|
+
* assetKindOf("body.fnt"); // "font"
|
|
442
|
+
* assetKindOf("theme.ogg"); // undefined
|
|
443
|
+
* ```
|
|
444
|
+
*/
|
|
445
|
+
function assetKindOf(fileName) {
|
|
446
|
+
if (TEXTURE.test(fileName)) return "texture";
|
|
447
|
+
if (FONT.test(fileName)) return "font";
|
|
448
|
+
if (AUDIO.test(fileName)) return "audio";
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Tells whether the scanner reads this file.
|
|
452
|
+
*
|
|
453
|
+
* @param fileName - Name of the file, with its extension.
|
|
454
|
+
* @returns True for a PNG, a WebP, a `.fnt` or an MP3.
|
|
455
|
+
* @example
|
|
456
|
+
* ```ts
|
|
457
|
+
* isAssetFile("star-on.webp"); // true
|
|
458
|
+
* isAssetFile("notes.md"); // false
|
|
459
|
+
* ```
|
|
460
|
+
*/
|
|
461
|
+
function isAssetFile(fileName) {
|
|
462
|
+
return assetKindOf(fileName) !== void 0;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Reads the borders of a nine tag: one number for every side, two for the horizontal and the
|
|
466
|
+
* vertical sides, or four in the order left, top, right, bottom.
|
|
467
|
+
*
|
|
468
|
+
* @param value - What stood behind the `=`.
|
|
469
|
+
* @returns The four borders, or `undefined` when the value is none of the three forms.
|
|
470
|
+
* @example
|
|
471
|
+
* ```ts
|
|
472
|
+
* readNine("24,12"); // { left: 24, top: 12, right: 24, bottom: 12 }
|
|
473
|
+
* ```
|
|
474
|
+
*/
|
|
475
|
+
function readNine(value) {
|
|
476
|
+
const numbers = value.split(",");
|
|
477
|
+
const sides = NINE_SIDES[numbers.length];
|
|
478
|
+
if (sides === void 0 || !numbers.every((number) => WHOLE_NUMBER.test(number))) return void 0;
|
|
479
|
+
const side = (at) => Number(numbers[at]);
|
|
480
|
+
return {
|
|
481
|
+
left: side(sides[0]),
|
|
482
|
+
top: side(sides[1]),
|
|
483
|
+
right: side(sides[2]),
|
|
484
|
+
bottom: side(sides[3])
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Reads one tag group.
|
|
489
|
+
*
|
|
490
|
+
* @param content - What stood between the braces.
|
|
491
|
+
* @param file - Path of the file, for the message.
|
|
492
|
+
* @returns The borders the group describes, or `undefined` when the group is malformed: not
|
|
493
|
+
* `name=value`, or a nine value of none of the three forms.
|
|
494
|
+
* @throws {Error} When the tag is `name=value` but its name is not `nine`.
|
|
495
|
+
*/
|
|
496
|
+
function readTag(content, file) {
|
|
497
|
+
const equals = content.indexOf("=");
|
|
498
|
+
if (equals <= 0) return void 0;
|
|
499
|
+
const name = content.slice(0, equals);
|
|
500
|
+
if (name !== "nine") throw problem$3(`"${file}" has the unknown tag "${name}".`);
|
|
501
|
+
return readNine(content.slice(equals + 1));
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Says in one line why a file keeps its whole name.
|
|
505
|
+
*
|
|
506
|
+
* @param file - Path of the file.
|
|
507
|
+
* @param content - What stood between the braces of the malformed group.
|
|
508
|
+
* @returns The note.
|
|
509
|
+
* @example
|
|
510
|
+
* ```ts
|
|
511
|
+
* malformedNote("features/ui/assets/bar{nine=1,2,3}.png", "nine=1,2,3");
|
|
512
|
+
* // 'kept the whole name of "features/ui/assets/bar{nine=1,2,3}.png": the tag "{nine=1,2,3}" is not {nine=N}, {nine=H,V} or {nine=L,T,R,B}.'
|
|
513
|
+
* ```
|
|
514
|
+
*/
|
|
515
|
+
function malformedNote(file, content) {
|
|
516
|
+
return `kept the whole name of "${file}": the tag "{${content}}" is not {nine=N}, {nine=H,V} or {nine=L,T,R,B}.`;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Splits a file name into the name the key is built from and the metadata its tags carry. A
|
|
520
|
+
* malformed tag is not a problem: the name stays whole, so the key shows the mistake, and one note
|
|
521
|
+
* says why.
|
|
522
|
+
*
|
|
523
|
+
* @param fileName - Name of the file, with its extension.
|
|
524
|
+
* @param file - Path of the file, for the messages.
|
|
525
|
+
* @returns The stem, the nine-slice borders and the note of a malformed tag.
|
|
526
|
+
* @throws {Error} When a brace is not a trailing tag group, a tag is unknown, the name is only
|
|
527
|
+
* tags, the name before the tags carries a "." that would fake a folder, or a malformed tag
|
|
528
|
+
* carries a "." that cannot stay in the whole name.
|
|
529
|
+
* @example
|
|
530
|
+
* ```ts
|
|
531
|
+
* parseTags("panel{nine=48}.png", "features/ui/assets/panel{nine=48}.png");
|
|
532
|
+
* // { stem: "panel", nine: { left: 48, top: 48, right: 48, bottom: 48 }, note: undefined }
|
|
533
|
+
* parseTags("sign{nine=30,10,40,20}.png", "features/ui/assets/sign{nine=30,10,40,20}.png").nine;
|
|
534
|
+
* // { left: 30, top: 10, right: 40, bottom: 20 }
|
|
535
|
+
* ```
|
|
536
|
+
*/
|
|
537
|
+
function parseTags(fileName, file) {
|
|
538
|
+
const dot = fileName.lastIndexOf(".");
|
|
539
|
+
const base = dot > 0 ? fileName.slice(0, dot) : fileName;
|
|
540
|
+
const open = base.indexOf("{");
|
|
541
|
+
const stem = open === -1 ? base : base.slice(0, open);
|
|
542
|
+
const tags = open === -1 ? "" : base.slice(open);
|
|
543
|
+
if (stem.includes("}") || !TAG_GROUPS.test(tags)) throw problem$3(`the name of "${file}" has a brace that is not a tag group.`);
|
|
544
|
+
if (stem === "") throw problem$3(`"${file}" has no name before its tags.`);
|
|
545
|
+
if (stem.includes(".")) throw problem$3(`"${file}" has a "." in "${stem}", which would fake a folder.`);
|
|
546
|
+
const contents = tags === "" ? [] : tags.slice(1, -1).split("}{");
|
|
547
|
+
const borders = contents.map((content) => readTag(content, file));
|
|
548
|
+
const malformed = contents.find((_content, at) => borders[at] === void 0);
|
|
549
|
+
if (malformed === void 0) return {
|
|
550
|
+
stem,
|
|
551
|
+
nine: borders.at(-1),
|
|
552
|
+
note: void 0
|
|
553
|
+
};
|
|
554
|
+
const dotted = contents.find((content) => content.includes("."));
|
|
555
|
+
if (dotted !== void 0) throw problem$3(`"${file}" has the malformed tag "{${dotted}}", whose "." cannot stay in a key. Use {nine=N}, {nine=H,V} or {nine=L,T,R,B} with whole numbers.`);
|
|
556
|
+
return {
|
|
557
|
+
stem: base,
|
|
558
|
+
nine: void 0,
|
|
559
|
+
note: malformedNote(file, malformed)
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Builds the asset key of one file.
|
|
564
|
+
*
|
|
565
|
+
* @param feature - Name of the feature folder.
|
|
566
|
+
* @param relative - POSIX path of the file inside the feature's `assets/`.
|
|
567
|
+
* @param file - Path of the file from the scan root, for the messages.
|
|
568
|
+
* @returns The asset key.
|
|
569
|
+
* @throws {Error} When a folder or the file name carries a "." that would fake a folder, or a tag
|
|
570
|
+
* is unknown.
|
|
571
|
+
* @example
|
|
572
|
+
* ```ts
|
|
573
|
+
* keyOf("ui", "button/primary.png", "features/ui/assets/button/primary.png"); // "ui.button.primary"
|
|
574
|
+
* ```
|
|
575
|
+
*/
|
|
576
|
+
function keyOf(feature, relative, file) {
|
|
577
|
+
const cut = relative.lastIndexOf("/");
|
|
578
|
+
const folders = cut === -1 ? [] : relative.slice(0, cut).split("/");
|
|
579
|
+
for (const folder of folders) if (folder.includes(".")) throw problem$3(`"${file}" has a "." in "${folder}", which would fake a folder.`);
|
|
580
|
+
return [
|
|
581
|
+
feature,
|
|
582
|
+
...folders,
|
|
583
|
+
parseTags(relative.slice(cut + 1), file).stem
|
|
584
|
+
].join(".");
|
|
585
|
+
}
|
|
586
|
+
//#endregion
|
|
587
|
+
//#region src/plugins/assets/scan/scan.ts
|
|
588
|
+
/**
|
|
589
|
+
* @file assets plugin, build time — the walk. `features/*` with an `assets/` folder becomes the
|
|
590
|
+
* manifest: one bundle per feature, split by the optional `assets.ts` that feature exports.
|
|
591
|
+
* Node and Bun only. Nothing under `src/` outside `scan/` imports it, so no game bundles it.
|
|
592
|
+
*/
|
|
593
|
+
const TIERS = [
|
|
594
|
+
"boot",
|
|
595
|
+
"core",
|
|
596
|
+
"scene",
|
|
597
|
+
"feature",
|
|
598
|
+
"lazy"
|
|
599
|
+
];
|
|
600
|
+
const PREFIX$1 = "[game] assets: ";
|
|
601
|
+
const ESCAPE = /[.+^${}()|[\]\\]/g;
|
|
602
|
+
/** The formats a game brings instead of an MP3. One format decodes everywhere, so MP3 it is. */
|
|
603
|
+
const OTHER_AUDIO = /\.(?:ogg|wav|m4a|aac|opus|flac)$/i;
|
|
604
|
+
/** A vector font. The engine draws bitmap text, so a font arrives as a `.fnt` with its pages. */
|
|
605
|
+
const OTHER_FONT = /\.(?:ttf|otf|woff2?)$/i;
|
|
606
|
+
/** A page name that says "this folder". */
|
|
607
|
+
const HERE = /^\.\//;
|
|
608
|
+
/**
|
|
609
|
+
* Wraps a scanner problem in the message shape of the framework.
|
|
610
|
+
*
|
|
611
|
+
* @param message - One sentence naming the file or the bundle.
|
|
612
|
+
* @returns The error to throw.
|
|
613
|
+
*/
|
|
614
|
+
function problem$2(message) {
|
|
615
|
+
return /* @__PURE__ */ new Error(`${PREFIX$1}${message}`);
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Reads the message of anything that was thrown.
|
|
619
|
+
*
|
|
620
|
+
* @param failure - What the `catch` caught.
|
|
621
|
+
* @returns The message, without the framework prefix.
|
|
622
|
+
*/
|
|
623
|
+
function detailOf$2(failure) {
|
|
624
|
+
const message = failure instanceof Error ? failure.message : String(failure);
|
|
625
|
+
return message.startsWith(PREFIX$1) ? message.slice(15) : message;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Creates the state of one scan. It is its own function because lint rule L5 refuses a collection
|
|
629
|
+
* built inside an exported declaration.
|
|
630
|
+
*
|
|
631
|
+
* @param root - Absolute or relative path of the game source root.
|
|
632
|
+
* @param features - Name of the features folder.
|
|
633
|
+
* @returns The empty state.
|
|
634
|
+
*/
|
|
635
|
+
function createState(root, features) {
|
|
636
|
+
return {
|
|
637
|
+
root,
|
|
638
|
+
featuresFolder: path.join(root, features),
|
|
639
|
+
drafts: /* @__PURE__ */ new Map(),
|
|
640
|
+
keyOwner: /* @__PURE__ */ new Map(),
|
|
641
|
+
problems: [],
|
|
642
|
+
notes: []
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Turns a path of this machine into the POSIX path the manifest carries.
|
|
647
|
+
*
|
|
648
|
+
* @param value - A path as `node:path` built it.
|
|
649
|
+
* @returns The same path with forward slashes.
|
|
650
|
+
*/
|
|
651
|
+
function toPosix(value) {
|
|
652
|
+
return value.split(path.sep).join("/");
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Compares two names, so every list the scanner writes has one order.
|
|
656
|
+
*
|
|
657
|
+
* @param left - First name.
|
|
658
|
+
* @param right - Second name.
|
|
659
|
+
* @returns The sort order.
|
|
660
|
+
*/
|
|
661
|
+
function byName$1(left, right) {
|
|
662
|
+
return left.localeCompare(right);
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Lists the feature folders, sorted. A missing features folder is an empty game, not a problem.
|
|
666
|
+
*
|
|
667
|
+
* @param dir - The features folder.
|
|
668
|
+
* @returns The folder names.
|
|
669
|
+
*/
|
|
670
|
+
async function listFeatures(dir) {
|
|
671
|
+
return (await readdir(dir, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).toSorted(byName$1);
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Walks one `assets/` folder.
|
|
675
|
+
*
|
|
676
|
+
* @param dir - The folder to read.
|
|
677
|
+
* @param prefix - POSIX path of that folder inside `assets/`.
|
|
678
|
+
* @returns The POSIX paths of every file below it, sorted.
|
|
679
|
+
*/
|
|
680
|
+
async function walkAssets(dir, prefix) {
|
|
681
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
682
|
+
const files = [];
|
|
683
|
+
for (const entry of entries.toSorted((left, right) => byName$1(left.name, right.name))) {
|
|
684
|
+
const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
685
|
+
if (entry.isDirectory()) files.push(...await walkAssets(path.join(dir, entry.name), relative));
|
|
686
|
+
else files.push(relative);
|
|
687
|
+
}
|
|
688
|
+
return files;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Reads the first bytes of a file, which is all the size needs.
|
|
692
|
+
*
|
|
693
|
+
* @param file - Absolute path of the file.
|
|
694
|
+
* @returns The header bytes, fewer when the file is shorter.
|
|
695
|
+
*/
|
|
696
|
+
async function readHead(file) {
|
|
697
|
+
const handle = await open(file, "r");
|
|
698
|
+
try {
|
|
699
|
+
const head = /* @__PURE__ */ new Uint8Array(30);
|
|
700
|
+
const { bytesRead } = await handle.read(head, 0, 30, 0);
|
|
701
|
+
return head.subarray(0, bytesRead);
|
|
702
|
+
} finally {
|
|
703
|
+
await handle.close();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Tells whether a value is a plain object.
|
|
708
|
+
*
|
|
709
|
+
* @param value - Anything a description exported.
|
|
710
|
+
* @returns True for an object that is not an array.
|
|
711
|
+
*/
|
|
712
|
+
function isRecord(value) {
|
|
713
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Imports the optional `assets.ts` of a feature. Node strips the types of a `.ts` file, so the
|
|
717
|
+
* description a game writes runs as it stands.
|
|
718
|
+
*
|
|
719
|
+
* @param file - Absolute path of the description.
|
|
720
|
+
* @returns Its exports, or `undefined` when the feature brought none.
|
|
721
|
+
* @throws {Error} When the file exists but cannot be imported.
|
|
722
|
+
*/
|
|
723
|
+
async function importDescription(file) {
|
|
724
|
+
if (!await stat(file).then((entry) => entry.isFile(), () => false)) return void 0;
|
|
725
|
+
return await import(
|
|
726
|
+
/* @vite-ignore */
|
|
727
|
+
pathToFileURL(file).href
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Reads one declared bundle into the specs of its feature.
|
|
732
|
+
*
|
|
733
|
+
* @param scan - State of the scan.
|
|
734
|
+
* @param feature - Name of the feature.
|
|
735
|
+
* @param name - Name of the bundle.
|
|
736
|
+
* @param raw - What the description carried under that name.
|
|
737
|
+
* @param specs - Where a valid bundle goes.
|
|
738
|
+
*/
|
|
739
|
+
function readSpec(scan, feature, name, raw, specs) {
|
|
740
|
+
if (name !== feature && !name.startsWith(`${feature}.`)) {
|
|
741
|
+
scan.problems.push(`the feature "${feature}" declares the bundle "${name}", which is neither "${feature}" nor a name starting with "${feature}.".`);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const tier = isRecord(raw) ? raw.tier : void 0;
|
|
745
|
+
if (typeof tier !== "string" || !TIERS.includes(tier)) {
|
|
746
|
+
scan.problems.push(`the bundle "${name}" has the unknown tier "${String(tier)}". Use one of ${TIERS.join(", ")}.`);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const files = isRecord(raw) && Array.isArray(raw.files) ? raw.files.map(String) : void 0;
|
|
750
|
+
specs.set(name, files === void 0 ? { tier } : {
|
|
751
|
+
tier,
|
|
752
|
+
files
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Reads the bundles a feature declares. Every export that is a `defineBundles` result counts.
|
|
757
|
+
*
|
|
758
|
+
* @param scan - State of the scan.
|
|
759
|
+
* @param feature - Name of the feature.
|
|
760
|
+
* @returns The declared bundles, empty when the feature brought no description.
|
|
761
|
+
*/
|
|
762
|
+
async function readBundleSpecs(scan, feature) {
|
|
763
|
+
const specs = /* @__PURE__ */ new Map();
|
|
764
|
+
const file = path.join(scan.featuresFolder, feature, "assets.ts");
|
|
765
|
+
try {
|
|
766
|
+
const loaded = await importDescription(file);
|
|
767
|
+
for (const value of Object.values(loaded ?? {})) {
|
|
768
|
+
if (!isRecord(value) || value.kind !== "bundles" || !isRecord(value.map)) continue;
|
|
769
|
+
for (const [name, raw] of Object.entries(value.map)) readSpec(scan, feature, name, raw, specs);
|
|
770
|
+
}
|
|
771
|
+
} catch (error) {
|
|
772
|
+
const relative = toPosix(path.relative(scan.root, file));
|
|
773
|
+
scan.problems.push(`the description "${relative}" could not be read: ${detailOf$2(error)}`);
|
|
774
|
+
}
|
|
775
|
+
return specs;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Turns one glob of a declared bundle into a matcher. `**` crosses folders, `*` and `?` do not.
|
|
779
|
+
*
|
|
780
|
+
* @param glob - The glob, relative to the feature's `assets/`.
|
|
781
|
+
* @returns The matcher.
|
|
782
|
+
*/
|
|
783
|
+
function globToRegExp(glob) {
|
|
784
|
+
const parts = [];
|
|
785
|
+
let index = 0;
|
|
786
|
+
while (index < glob.length) {
|
|
787
|
+
const rest = glob.slice(index);
|
|
788
|
+
if (rest.startsWith("**/")) {
|
|
789
|
+
parts.push("(?:.*/)?");
|
|
790
|
+
index += 3;
|
|
791
|
+
} else if (rest.startsWith("**")) {
|
|
792
|
+
parts.push(".*");
|
|
793
|
+
index += 2;
|
|
794
|
+
} else if (rest.startsWith("*")) {
|
|
795
|
+
parts.push("[^/]*");
|
|
796
|
+
index += 1;
|
|
797
|
+
} else if (rest.startsWith("?")) {
|
|
798
|
+
parts.push("[^/]");
|
|
799
|
+
index += 1;
|
|
800
|
+
} else {
|
|
801
|
+
parts.push(rest.slice(0, 1).replaceAll(ESCAPE, String.raw`\$&`));
|
|
802
|
+
index += 1;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return new RegExp(`^${parts.join("")}$`);
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Builds the matchers of the declared bundles that take files out of the default bundle.
|
|
809
|
+
*
|
|
810
|
+
* @param specs - The declared bundles of one feature.
|
|
811
|
+
* @returns The splits, sorted by bundle name.
|
|
812
|
+
*/
|
|
813
|
+
function splitsOf(specs) {
|
|
814
|
+
const splits = [];
|
|
815
|
+
for (const [name, spec] of [...specs].toSorted((left, right) => byName$1(left[0], right[0]))) {
|
|
816
|
+
if (spec.files === void 0 || spec.files.length === 0) continue;
|
|
817
|
+
splits.push({
|
|
818
|
+
name,
|
|
819
|
+
patterns: spec.files.map((glob) => globToRegExp(glob))
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
return splits;
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Works out which declared bundle claims a file.
|
|
826
|
+
*
|
|
827
|
+
* @param splits - The splits of the feature.
|
|
828
|
+
* @param relative - POSIX path of the file inside `assets/`.
|
|
829
|
+
* @param file - Path of the file from the scan root, for the message.
|
|
830
|
+
* @returns The bundle name, or `undefined` when the file stays in the default bundle.
|
|
831
|
+
* @throws {Error} When two bundles claim the same file.
|
|
832
|
+
*/
|
|
833
|
+
function ownerOf(splits, relative, file) {
|
|
834
|
+
const matched = splits.filter((split) => split.patterns.some((pattern) => pattern.test(relative))).map((split) => split.name);
|
|
835
|
+
if (matched.length > 1) {
|
|
836
|
+
const [first, second] = matched;
|
|
837
|
+
throw problem$2(`file "${file}" is claimed by the bundles "${String(first)}" and "${String(second)}".`);
|
|
838
|
+
}
|
|
839
|
+
return matched[0];
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Refuses nine-slice borders that leave no centre: left and right together, and top and bottom
|
|
843
|
+
* together, must stay below the sides of the image.
|
|
844
|
+
*
|
|
845
|
+
* @param nine - The borders of the file, when it carries a tag.
|
|
846
|
+
* @param size - The pixel size of the file.
|
|
847
|
+
* @param file - Path of the file, for the message.
|
|
848
|
+
* @throws {Error} When the borders of one axis reach its side.
|
|
849
|
+
*/
|
|
850
|
+
function checkNine(nine, size, file) {
|
|
851
|
+
if (nine === void 0) return;
|
|
852
|
+
const { left, top, right, bottom } = nine;
|
|
853
|
+
if (left + right < size.width && top + bottom < size.height) return;
|
|
854
|
+
throw problem$2(`the nine-slice ${left === top && top === right && right === bottom ? `${left} of "${file}" must be smaller than half of` : `${left},${top},${right},${bottom} of "${file}" must leave a centre inside`} ${size.width}×${size.height}.`);
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Finds or starts the draft of one bundle.
|
|
858
|
+
*
|
|
859
|
+
* @param scan - State of the scan.
|
|
860
|
+
* @param name - Name of the bundle.
|
|
861
|
+
* @param feature - Feature that owns it.
|
|
862
|
+
* @param tier - When it loads.
|
|
863
|
+
* @returns The draft.
|
|
864
|
+
*/
|
|
865
|
+
function draftOf(scan, name, feature, tier) {
|
|
866
|
+
const found = scan.drafts.get(name);
|
|
867
|
+
if (found !== void 0) return found;
|
|
868
|
+
const draft = {
|
|
869
|
+
feature,
|
|
870
|
+
tier,
|
|
871
|
+
files: []
|
|
872
|
+
};
|
|
873
|
+
scan.drafts.set(name, draft);
|
|
874
|
+
return draft;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Says in one line why a file of an `assets/` folder is not in the manifest. Nothing here stops
|
|
878
|
+
* a build: the note is read once, when the file was meant to be an asset.
|
|
879
|
+
*
|
|
880
|
+
* @param file - Path of the file from the scan root.
|
|
881
|
+
* @returns The note.
|
|
882
|
+
* @example
|
|
883
|
+
* ```ts
|
|
884
|
+
* ignoredNote("features/ui/assets/click.wav");
|
|
885
|
+
* // 'ignored "features/ui/assets/click.wav": audio is .mp3 only.'
|
|
886
|
+
* ```
|
|
887
|
+
*/
|
|
888
|
+
function ignoredNote(file) {
|
|
889
|
+
if (OTHER_AUDIO.test(file)) return `ignored "${file}": audio is .mp3 only.`;
|
|
890
|
+
if (OTHER_FONT.test(file)) return `ignored "${file}": a font is a .fnt file with its .png pages.`;
|
|
891
|
+
return `ignored "${file}": the scanner reads .png, .webp, .fnt and .mp3 only.`;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Works out where a page of a font lives: page names are relative to the `.fnt` file.
|
|
895
|
+
*
|
|
896
|
+
* @param font - POSIX path of the `.fnt` file inside `assets/`.
|
|
897
|
+
* @param page - The page name the font declared.
|
|
898
|
+
* @returns The POSIX path of the page inside `assets/`.
|
|
899
|
+
* @example
|
|
900
|
+
* ```ts
|
|
901
|
+
* resolvePage("fonts/body.fnt", "body_0.png"); // "fonts/body_0.png"
|
|
902
|
+
* ```
|
|
903
|
+
*/
|
|
904
|
+
function resolvePage(font, page) {
|
|
905
|
+
const cut = font.lastIndexOf("/");
|
|
906
|
+
return `${cut === -1 ? "" : font.slice(0, cut + 1)}${page.replace(HERE, "")}`;
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Reads every `.fnt` of a feature before its files are described: a page image belongs to its
|
|
910
|
+
* font and never becomes an asset key of its own, so the walk has to know the pages first.
|
|
911
|
+
*
|
|
912
|
+
* @param scan - State of the scan.
|
|
913
|
+
* @param assetsFolder - Absolute path of the feature's `assets/`.
|
|
914
|
+
* @param files - Every file of that folder, as POSIX paths inside it.
|
|
915
|
+
* @returns The pages of each font, in declaration order.
|
|
916
|
+
*/
|
|
917
|
+
async function readFonts(scan, assetsFolder, files) {
|
|
918
|
+
const fonts = /* @__PURE__ */ new Map();
|
|
919
|
+
const known = new Set(files);
|
|
920
|
+
for (const relative of files) {
|
|
921
|
+
if (assetKindOf(relative) !== "font") continue;
|
|
922
|
+
const absolute = path.join(assetsFolder, ...relative.split("/"));
|
|
923
|
+
const file = toPosix(path.relative(scan.root, absolute));
|
|
924
|
+
try {
|
|
925
|
+
const pages = [];
|
|
926
|
+
for (const declared of pagesOfFont(await readFile(absolute, "utf8"), file)) {
|
|
927
|
+
const page = resolvePage(relative, declared);
|
|
928
|
+
if (known.has(page)) pages.push(page);
|
|
929
|
+
else scan.problems.push(`the font "${file}" names the page "${declared}", which is not next to it.`);
|
|
930
|
+
}
|
|
931
|
+
fonts.set(relative, pages);
|
|
932
|
+
} catch (error) {
|
|
933
|
+
scan.problems.push(detailOf$2(error));
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return fonts;
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Reads the pages of one font into manifest entries. The pages carry no key: they are listed
|
|
940
|
+
* under the `.fnt` file and loaded with it.
|
|
941
|
+
*
|
|
942
|
+
* @param scan - State of the scan.
|
|
943
|
+
* @param pass - What this feature is read with.
|
|
944
|
+
* @param relative - POSIX path of the `.fnt` file inside `assets/`.
|
|
945
|
+
* @returns The pages, in the order the font declares them.
|
|
946
|
+
* @throws {Error} When a page is not a PNG or a WebP the scanner reads.
|
|
947
|
+
*/
|
|
948
|
+
async function describePages(scan, pass, relative) {
|
|
949
|
+
const pages = [];
|
|
950
|
+
for (const page of pass.fonts.get(relative) ?? []) {
|
|
951
|
+
const absolute = path.join(pass.assetsFolder, ...page.split("/"));
|
|
952
|
+
const file = toPosix(path.relative(scan.root, absolute));
|
|
953
|
+
const size = readImageSize(await readHead(absolute), file);
|
|
954
|
+
pages.push({
|
|
955
|
+
path: file,
|
|
956
|
+
width: size.width,
|
|
957
|
+
height: size.height,
|
|
958
|
+
mb: textureMb(size.width, size.height)
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
return pages;
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Reads one asset file into a manifest entry: a texture by its pixels, a font with its pages, an
|
|
965
|
+
* audio file by its bytes.
|
|
966
|
+
*
|
|
967
|
+
* @param scan - State of the scan.
|
|
968
|
+
* @param pass - What this feature is read with.
|
|
969
|
+
* @param relative - POSIX path of the file inside `assets/`.
|
|
970
|
+
* @returns The entry.
|
|
971
|
+
* @throws {Error} When the key, the tags, the header bytes or the nine-slice are wrong.
|
|
972
|
+
*/
|
|
973
|
+
async function describeFile(scan, pass, relative) {
|
|
974
|
+
const absolute = path.join(pass.assetsFolder, ...relative.split("/"));
|
|
975
|
+
const file = toPosix(path.relative(scan.root, absolute));
|
|
976
|
+
const fileName = relative.slice(relative.lastIndexOf("/") + 1);
|
|
977
|
+
const key = keyOf(pass.feature, relative, file);
|
|
978
|
+
const owner = scan.keyOwner.get(key);
|
|
979
|
+
if (owner !== void 0) throw problem$2(`key "${key}" comes from two files: ${owner} and ${file}.`);
|
|
980
|
+
const kind = assetKindOf(fileName);
|
|
981
|
+
const { nine, note } = parseTags(fileName, file);
|
|
982
|
+
if (note !== void 0) scan.notes.push(note);
|
|
983
|
+
if (kind === "font") {
|
|
984
|
+
const pages = await describePages(scan, pass, relative);
|
|
985
|
+
scan.keyOwner.set(key, file);
|
|
986
|
+
return {
|
|
987
|
+
key,
|
|
988
|
+
path: file,
|
|
989
|
+
kind,
|
|
990
|
+
width: 0,
|
|
991
|
+
height: 0,
|
|
992
|
+
mb: sumMb(pages),
|
|
993
|
+
pages
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
if (kind === "audio") {
|
|
997
|
+
const { size } = await stat(absolute);
|
|
998
|
+
scan.keyOwner.set(key, file);
|
|
999
|
+
return {
|
|
1000
|
+
key,
|
|
1001
|
+
path: file,
|
|
1002
|
+
kind,
|
|
1003
|
+
width: 0,
|
|
1004
|
+
height: 0,
|
|
1005
|
+
mb: bytesMb(size)
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
const size = readImageSize(await readHead(absolute), file);
|
|
1009
|
+
checkNine(nine, size, file);
|
|
1010
|
+
scan.keyOwner.set(key, file);
|
|
1011
|
+
return {
|
|
1012
|
+
key,
|
|
1013
|
+
path: file,
|
|
1014
|
+
width: size.width,
|
|
1015
|
+
height: size.height,
|
|
1016
|
+
mb: textureMb(size.width, size.height),
|
|
1017
|
+
...nine === void 0 ? {} : { nine }
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Reads one file of a feature into the bundle that claims it.
|
|
1022
|
+
*
|
|
1023
|
+
* @param scan - State of the scan.
|
|
1024
|
+
* @param pass - What this feature is read with.
|
|
1025
|
+
* @param relative - POSIX path of the file inside `assets/`.
|
|
1026
|
+
*/
|
|
1027
|
+
async function addFile(scan, pass, relative) {
|
|
1028
|
+
const absolute = path.join(pass.assetsFolder, ...relative.split("/"));
|
|
1029
|
+
const file = toPosix(path.relative(scan.root, absolute));
|
|
1030
|
+
if (!isAssetFile(relative)) {
|
|
1031
|
+
scan.notes.push(ignoredNote(file));
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
try {
|
|
1035
|
+
const name = ownerOf(pass.splits, relative, file) ?? pass.feature;
|
|
1036
|
+
const entry = await describeFile(scan, pass, relative);
|
|
1037
|
+
draftOf(scan, name, pass.feature, pass.defaultTier).files.push(entry);
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
scan.problems.push(detailOf$2(error));
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Reads one feature: its description first, then its fonts, then every file of its `assets/`.
|
|
1044
|
+
*
|
|
1045
|
+
* @param scan - State of the scan.
|
|
1046
|
+
* @param feature - Name of the feature folder.
|
|
1047
|
+
*/
|
|
1048
|
+
async function scanFeature(scan, feature) {
|
|
1049
|
+
const specs = await readBundleSpecs(scan, feature);
|
|
1050
|
+
const assetsFolder = path.join(scan.featuresFolder, feature, "assets");
|
|
1051
|
+
for (const [name, spec] of specs) draftOf(scan, name, feature, spec.tier);
|
|
1052
|
+
const files = await walkAssets(assetsFolder, "");
|
|
1053
|
+
const fonts = await readFonts(scan, assetsFolder, files);
|
|
1054
|
+
const pages = new Set([...fonts.values()].flat());
|
|
1055
|
+
const pass = {
|
|
1056
|
+
feature,
|
|
1057
|
+
assetsFolder,
|
|
1058
|
+
splits: splitsOf(specs),
|
|
1059
|
+
defaultTier: specs.get(feature)?.tier ?? "feature",
|
|
1060
|
+
fonts
|
|
1061
|
+
};
|
|
1062
|
+
for (const relative of files) {
|
|
1063
|
+
if (pages.has(relative)) continue;
|
|
1064
|
+
await addFile(scan, pass, relative);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Adds up what a list of files costs: the files of a bundle, or the pages of a font.
|
|
1069
|
+
*
|
|
1070
|
+
* @param files - Anything that carries an `mb`.
|
|
1071
|
+
* @returns The sum in MB, rounded to three decimals.
|
|
1072
|
+
*/
|
|
1073
|
+
function sumMb(files) {
|
|
1074
|
+
let total = 0;
|
|
1075
|
+
for (const file of files) total += file.mb;
|
|
1076
|
+
return Math.round(total * 1e3) / 1e3;
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Turns the drafts into the manifest, sorted for byte-identical output.
|
|
1080
|
+
*
|
|
1081
|
+
* @param scan - State of the scan.
|
|
1082
|
+
* @returns The manifest.
|
|
1083
|
+
*/
|
|
1084
|
+
function toManifest(scan) {
|
|
1085
|
+
const bundles = {};
|
|
1086
|
+
for (const [name, draft] of [...scan.drafts].toSorted((left, right) => byName$1(left[0], right[0]))) {
|
|
1087
|
+
const files = draft.files.toSorted((left, right) => byName$1(left.key, right.key));
|
|
1088
|
+
bundles[name] = {
|
|
1089
|
+
feature: draft.feature,
|
|
1090
|
+
tier: draft.tier,
|
|
1091
|
+
mb: sumMb(files),
|
|
1092
|
+
files
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
return {
|
|
1096
|
+
version: 1,
|
|
1097
|
+
bundles
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Collects every problem of a scan into one error, so a game fixes its assets in one round.
|
|
1102
|
+
*
|
|
1103
|
+
* @param problems - The problems, in scan order.
|
|
1104
|
+
* @returns The error to reject with.
|
|
1105
|
+
*/
|
|
1106
|
+
function collected$1(problems) {
|
|
1107
|
+
const count = problems.length;
|
|
1108
|
+
const lines = problems.map((text) => ` ${text}`).join("\n");
|
|
1109
|
+
return /* @__PURE__ */ new Error(`${PREFIX$1}the scan found ${count} problem${count === 1 ? "" : "s"}.\n${lines}\n Fix them and run "bun run assets:keys" again.`);
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Reads an output file that may not exist yet.
|
|
1113
|
+
*
|
|
1114
|
+
* @param file - Path of the file.
|
|
1115
|
+
* @returns Its text, or `undefined` when it is not there.
|
|
1116
|
+
*/
|
|
1117
|
+
async function readCurrent$1(file) {
|
|
1118
|
+
try {
|
|
1119
|
+
return await readFile(file, "utf8");
|
|
1120
|
+
} catch {
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Writes the outputs whose text changed, and tells whether any of them differed.
|
|
1126
|
+
*
|
|
1127
|
+
* @param outputs - Where each output goes and what it should contain.
|
|
1128
|
+
* @param write - False for a check run: nothing is written.
|
|
1129
|
+
* @returns True when an output on disk differs from what the scan produced.
|
|
1130
|
+
*/
|
|
1131
|
+
async function applyOutputs(outputs, write) {
|
|
1132
|
+
let changed = false;
|
|
1133
|
+
for (const output of outputs) {
|
|
1134
|
+
if (await readCurrent$1(output.file) === output.text) continue;
|
|
1135
|
+
changed = true;
|
|
1136
|
+
if (!write) continue;
|
|
1137
|
+
await mkdir(path.dirname(output.file), { recursive: true });
|
|
1138
|
+
await writeFile(output.file, output.text);
|
|
1139
|
+
}
|
|
1140
|
+
return changed;
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Walks the features of a game and writes its two generated files: one bundle per feature, split
|
|
1144
|
+
* by the optional `assets.ts` of that feature, every file keyed by the key rule. A check run
|
|
1145
|
+
* (`write: false`) writes nothing and only reports whether something would change.
|
|
1146
|
+
*
|
|
1147
|
+
* @param options - The game source root, the two output paths and whether to write.
|
|
1148
|
+
* @returns The manifest, the text of the key module and whether an output differed.
|
|
1149
|
+
* @throws {Error} One error that lists every problem the scan found.
|
|
1150
|
+
* @example
|
|
1151
|
+
* ```ts
|
|
1152
|
+
* const { manifest, changed } = await scanAssets({
|
|
1153
|
+
* root: "src",
|
|
1154
|
+
* manifest: "public/assets/manifest.json",
|
|
1155
|
+
* keys: "src/generated/assets.ts"
|
|
1156
|
+
* });
|
|
1157
|
+
* // manifest.bundles.ui.files[0].key: "ui.button.primary", changed: true on the first run
|
|
1158
|
+
* ```
|
|
1159
|
+
*/
|
|
1160
|
+
async function scanAssets(options) {
|
|
1161
|
+
const scan = createState(options.root, options.features ?? "features");
|
|
1162
|
+
for (const feature of await listFeatures(scan.featuresFolder)) await scanFeature(scan, feature);
|
|
1163
|
+
if (scan.problems.length > 0) throw collected$1(scan.problems);
|
|
1164
|
+
const manifest = toManifest(scan);
|
|
1165
|
+
const manifestSource = emitManifest(manifest);
|
|
1166
|
+
const keysSource = emitKeys(manifest);
|
|
1167
|
+
return {
|
|
1168
|
+
manifest,
|
|
1169
|
+
keysSource,
|
|
1170
|
+
changed: await applyOutputs([{
|
|
1171
|
+
file: options.manifest,
|
|
1172
|
+
text: manifestSource
|
|
1173
|
+
}, {
|
|
1174
|
+
file: options.keys,
|
|
1175
|
+
text: keysSource
|
|
1176
|
+
}], options.write !== false),
|
|
1177
|
+
notes: scan.notes
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
//#endregion
|
|
1181
|
+
//#region src/plugins/assets/scan/cli.ts
|
|
1182
|
+
/**
|
|
1183
|
+
* @file assets plugin, build time — the command line of the scanner. It is the only file here
|
|
1184
|
+
* that touches the terminal: the scan, the emitters and the key rule are pure. Output goes
|
|
1185
|
+
* through the branded console of `@moku-labs/common`, and `runCli` returns the exit code
|
|
1186
|
+
* instead of taking it. The string compiler of i18n comes in as a parameter: i18n sits above
|
|
1187
|
+
* assets, so the door `src/assets.ts` hands it over instead of this file importing it.
|
|
1188
|
+
*/
|
|
1189
|
+
const MANIFEST_FILE = "manifest.json";
|
|
1190
|
+
const KEYS_FILE = path.join("generated", "assets.ts");
|
|
1191
|
+
/**
|
|
1192
|
+
* Wraps a command line problem in the message shape of the framework.
|
|
1193
|
+
*
|
|
1194
|
+
* @param message - One sentence naming the option.
|
|
1195
|
+
* @returns The error to throw.
|
|
1196
|
+
*/
|
|
1197
|
+
function problem$1(message) {
|
|
1198
|
+
return /* @__PURE__ */ new Error(`[game] assets: ${message}`);
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Reads the message of anything that was thrown.
|
|
1202
|
+
*
|
|
1203
|
+
* @param failure - What the `catch` caught.
|
|
1204
|
+
* @returns The message.
|
|
1205
|
+
*/
|
|
1206
|
+
function messageOf(failure) {
|
|
1207
|
+
return failure instanceof Error ? failure.message : String(failure);
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Tells whether an argument is one of the flags that take a path.
|
|
1211
|
+
*
|
|
1212
|
+
* @param flag - The argument.
|
|
1213
|
+
* @returns True for `--root`, `--manifest` and `--keys`.
|
|
1214
|
+
*/
|
|
1215
|
+
function isPathFlag(flag) {
|
|
1216
|
+
return flag === "--root" || flag === "--manifest" || flag === "--keys";
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Reads the flags. `--manifest` and `--keys` default to `manifest.json` and `generated/assets.ts`
|
|
1220
|
+
* inside the root.
|
|
1221
|
+
*
|
|
1222
|
+
* @param argv - The arguments after the script name.
|
|
1223
|
+
* @returns The resolved paths and whether this is a check run.
|
|
1224
|
+
* @throws {Error} When an option is unknown or misses its value.
|
|
1225
|
+
*/
|
|
1226
|
+
function parseArgv(argv) {
|
|
1227
|
+
const given = {
|
|
1228
|
+
"--root": void 0,
|
|
1229
|
+
"--manifest": void 0,
|
|
1230
|
+
"--keys": void 0
|
|
1231
|
+
};
|
|
1232
|
+
let check = false;
|
|
1233
|
+
let index = 0;
|
|
1234
|
+
while (index < argv.length) {
|
|
1235
|
+
const flag = argv[index];
|
|
1236
|
+
if (flag === "--check") {
|
|
1237
|
+
check = true;
|
|
1238
|
+
index += 1;
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
if (!isPathFlag(flag)) throw problem$1(`unknown option "${String(flag)}".`);
|
|
1242
|
+
const value = argv[index + 1];
|
|
1243
|
+
if (value === void 0 || value.startsWith("--")) throw problem$1(`"${flag}" needs a path.`);
|
|
1244
|
+
given[flag] = value;
|
|
1245
|
+
index += 2;
|
|
1246
|
+
}
|
|
1247
|
+
const root = path.resolve(given["--root"] ?? ".");
|
|
1248
|
+
return {
|
|
1249
|
+
root,
|
|
1250
|
+
manifest: path.resolve(given["--manifest"] ?? path.join(root, MANIFEST_FILE)),
|
|
1251
|
+
keys: path.resolve(given["--keys"] ?? path.join(root, KEYS_FILE)),
|
|
1252
|
+
check
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Counts a thing in words, so the summary reads as a sentence.
|
|
1257
|
+
*
|
|
1258
|
+
* @param count - How many.
|
|
1259
|
+
* @param word - The singular.
|
|
1260
|
+
* @returns The count and the word.
|
|
1261
|
+
*/
|
|
1262
|
+
function plural(count, word) {
|
|
1263
|
+
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Sums up what the scan found, for the closing line.
|
|
1267
|
+
*
|
|
1268
|
+
* @param manifest - What the scan produced.
|
|
1269
|
+
* @returns The summary line.
|
|
1270
|
+
*/
|
|
1271
|
+
function summary(manifest) {
|
|
1272
|
+
const bundles = Object.values(manifest.bundles);
|
|
1273
|
+
let files = 0;
|
|
1274
|
+
let mb = 0;
|
|
1275
|
+
for (const bundle of bundles) {
|
|
1276
|
+
files += bundle.files.length;
|
|
1277
|
+
mb += bundle.mb;
|
|
1278
|
+
}
|
|
1279
|
+
return `${plural(bundles.length, "bundle")}, ${plural(files, "file")}, ${Math.round(mb * 1e3) / 1e3} MB of textures.`;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Names both output files, for the lines about them.
|
|
1283
|
+
*
|
|
1284
|
+
* @param options - The resolved paths.
|
|
1285
|
+
* @returns The two paths in one phrase.
|
|
1286
|
+
*/
|
|
1287
|
+
function outputNames(options) {
|
|
1288
|
+
return `"${options.manifest}" and "${options.keys}"`;
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* One line about the compiled strings.
|
|
1292
|
+
*
|
|
1293
|
+
* @param report - What the compile produced.
|
|
1294
|
+
* @returns The line for the console.
|
|
1295
|
+
* @example
|
|
1296
|
+
* ```ts
|
|
1297
|
+
* stringsSummary({ changed: false, locales: ["en", "ru"], keys: ["a", "b"], notes: [] }); // "2 strings in 2 locales (en, ru)."
|
|
1298
|
+
* ```
|
|
1299
|
+
*/
|
|
1300
|
+
function stringsSummary(report) {
|
|
1301
|
+
return `${report.keys.length} strings in ${report.locales.length} locales (${report.locales.join(", ")}).`;
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Runs the asset key scanner: walk the features, write the manifest and the key module, then
|
|
1305
|
+
* compile the feature strings next to the key module, or check that all of it is current.
|
|
1306
|
+
* Nothing here calls `process.exit`; the caller does.
|
|
1307
|
+
*
|
|
1308
|
+
* @param argv - The arguments after the script name.
|
|
1309
|
+
* @param compile - The strings compiler, `compileStrings` of i18n.
|
|
1310
|
+
* @param ui - Where the lines go. The branded console by default.
|
|
1311
|
+
* @returns The exit code: `1` when the scan or the compile failed or `--check` found a difference, else `0`.
|
|
1312
|
+
* @example
|
|
1313
|
+
* ```ts
|
|
1314
|
+
* await runCli(["--root", "src", "--manifest", "public/assets/manifest.json", "--check"], compileStrings);
|
|
1315
|
+
* // 1 when public/assets/manifest.json is older than the files in src/features
|
|
1316
|
+
* ```
|
|
1317
|
+
*/
|
|
1318
|
+
async function runCli(argv, compile, ui = createBrandConsole()) {
|
|
1319
|
+
try {
|
|
1320
|
+
const options = parseArgv(argv);
|
|
1321
|
+
const { manifest, changed, notes } = await scanAssets({
|
|
1322
|
+
root: options.root,
|
|
1323
|
+
manifest: options.manifest,
|
|
1324
|
+
keys: options.keys,
|
|
1325
|
+
write: !options.check
|
|
1326
|
+
});
|
|
1327
|
+
const strings = await compile(options.root, path.dirname(options.keys), { check: options.check });
|
|
1328
|
+
for (const note of [...notes, ...strings.notes]) ui.warn(note);
|
|
1329
|
+
if ((changed || strings.changed) && options.check) {
|
|
1330
|
+
ui.error(`${changed ? outputNames(options) : "the generated strings"} are out of date.\n Run "bun run assets:keys" and commit the result.`);
|
|
1331
|
+
return 1;
|
|
1332
|
+
}
|
|
1333
|
+
ui.info(changed ? `wrote ${outputNames(options)}.` : `${outputNames(options)} are up to date.`);
|
|
1334
|
+
ui.info(summary(manifest));
|
|
1335
|
+
if (strings.keys.length > 0) ui.info(stringsSummary(strings));
|
|
1336
|
+
return 0;
|
|
1337
|
+
} catch (error) {
|
|
1338
|
+
ui.error(messageOf(error));
|
|
1339
|
+
return 1;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
//#endregion
|
|
1343
|
+
//#region src/plugins/i18n/compile/emit.ts
|
|
1344
|
+
/**
|
|
1345
|
+
* @file i18n plugin, build time — the two output kinds: the compiled module of one locale and
|
|
1346
|
+
* the types module a game's `defineGame` reads. Both are sorted and carry no timestamp and no
|
|
1347
|
+
* absolute path, so two compiles of the same files write the same bytes.
|
|
1348
|
+
*/
|
|
1349
|
+
/** How every generated file starts. */
|
|
1350
|
+
const HEADER = "// generated by @moku-labs/game/assets, do not edit";
|
|
1351
|
+
/** The TypeScript cast keyword, kept apart so this file writes one and holds none. */
|
|
1352
|
+
const CAST$1 = "as";
|
|
1353
|
+
/** How a key with no parameters is typed, so `tr` takes it with no second argument. */
|
|
1354
|
+
const NO_PARAMS = "Record<string, never>";
|
|
1355
|
+
/** The name of the union a plain argument is typed by. */
|
|
1356
|
+
const ARGUMENT = "Argument";
|
|
1357
|
+
/**
|
|
1358
|
+
* The helper a compiled message calls for a plain argument: an element node keeps its place, a
|
|
1359
|
+
* list and a number go through `Intl`, everything else is read as text.
|
|
1360
|
+
*
|
|
1361
|
+
* @returns The helper as source.
|
|
1362
|
+
*/
|
|
1363
|
+
function argumentHelper() {
|
|
1364
|
+
return [
|
|
1365
|
+
"/** One plain argument: an element stays an element, a list and a number go through Intl. */",
|
|
1366
|
+
"function argument(value: unknown, intl: I18n.IntlKit): I18n.Part {",
|
|
1367
|
+
" if (Array.isArray(value)) {",
|
|
1368
|
+
` return { kind: "text", text: intl.list().format(value ${CAST$1} readonly string[]) };`,
|
|
1369
|
+
" }",
|
|
1370
|
+
"",
|
|
1371
|
+
" if (typeof value === \"number\") return { kind: \"text\", text: intl.number().format(value) };",
|
|
1372
|
+
"",
|
|
1373
|
+
" if (",
|
|
1374
|
+
" typeof value === \"object\" &&",
|
|
1375
|
+
" value !== null &&",
|
|
1376
|
+
" \"type\" in value &&",
|
|
1377
|
+
" \"props\" in value &&",
|
|
1378
|
+
" \"children\" in value",
|
|
1379
|
+
" ) {",
|
|
1380
|
+
` return { kind: "element", node: value ${CAST$1} I18n.ElementNode };`,
|
|
1381
|
+
" }",
|
|
1382
|
+
"",
|
|
1383
|
+
" return { kind: \"text\", text: String(value) };",
|
|
1384
|
+
"}"
|
|
1385
|
+
].join("\n");
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Compares two keys, so every list the compiler writes has one order.
|
|
1389
|
+
*
|
|
1390
|
+
* @param left - First key.
|
|
1391
|
+
* @param right - Second key.
|
|
1392
|
+
* @returns The sort order.
|
|
1393
|
+
*/
|
|
1394
|
+
function byName(left, right) {
|
|
1395
|
+
if (left === right) return 0;
|
|
1396
|
+
return left < right ? -1 : 1;
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Writes `generated/strings.<locale>.ts`: one arrow per key, keys sorted, the helper only when a
|
|
1400
|
+
* message needs it.
|
|
1401
|
+
*
|
|
1402
|
+
* @param entries - The keys of the locale, in any order.
|
|
1403
|
+
* @returns The text of the module.
|
|
1404
|
+
* @example
|
|
1405
|
+
* ```ts
|
|
1406
|
+
* emitLocale([{ key: "home.play", compiled: compileMessage("Играть") }]);
|
|
1407
|
+
* // the header, the import type and one key whose arrow returns one text part
|
|
1408
|
+
* ```
|
|
1409
|
+
*/
|
|
1410
|
+
function emitLocale(entries) {
|
|
1411
|
+
const sorted = [...entries].toSorted((left, right) => byName(left.key, right.key));
|
|
1412
|
+
const rows = sorted.map((entry) => ` ${JSON.stringify(entry.key)}: ${entry.compiled.source}`);
|
|
1413
|
+
const blocks = [`${HEADER}\nimport type { I18n } from "@moku-labs/game";`];
|
|
1414
|
+
if (sorted.some((entry) => entry.compiled.usesArgument)) blocks.push(argumentHelper());
|
|
1415
|
+
blocks.push(`export default {\n${rows.join(",\n")}\n} satisfies I18n.CompiledMessages;`);
|
|
1416
|
+
return `${blocks.join("\n\n")}\n`;
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Writes the type of one parameter.
|
|
1420
|
+
*
|
|
1421
|
+
* @param type - What the compiler read for it.
|
|
1422
|
+
* @returns The TypeScript type.
|
|
1423
|
+
* @example
|
|
1424
|
+
* ```ts
|
|
1425
|
+
* typeOf({ kind: "select", options: ["audio"] }); // '"audio"'
|
|
1426
|
+
* ```
|
|
1427
|
+
*/
|
|
1428
|
+
function typeOf(type) {
|
|
1429
|
+
if (type.kind === "argument") return ARGUMENT;
|
|
1430
|
+
if (type.kind === "number") return "number";
|
|
1431
|
+
if (type.kind === "date") return "Date | number";
|
|
1432
|
+
return type.options.map((option) => JSON.stringify(option)).join(" | ");
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* A parameter name as a type field: quoted when it is not a plain identifier, so the types module
|
|
1436
|
+
* compiles for the same names the locale module escapes.
|
|
1437
|
+
*
|
|
1438
|
+
* @param name - The parameter name of the message.
|
|
1439
|
+
* @returns The field name text.
|
|
1440
|
+
* @example
|
|
1441
|
+
* ```ts
|
|
1442
|
+
* fieldName("n"); // "n"
|
|
1443
|
+
* fieldName("a-b"); // "\"a-b\""
|
|
1444
|
+
* ```
|
|
1445
|
+
*/
|
|
1446
|
+
function fieldName(name) {
|
|
1447
|
+
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Writes the parameters object of one key.
|
|
1451
|
+
*
|
|
1452
|
+
* @param params - The parameters the key declares.
|
|
1453
|
+
* @returns The TypeScript type of the parameters.
|
|
1454
|
+
*/
|
|
1455
|
+
function paramsOf(params) {
|
|
1456
|
+
const names = Object.keys(params).toSorted(byName);
|
|
1457
|
+
if (names.length === 0) return NO_PARAMS;
|
|
1458
|
+
return `{ ${names.map((name) => `${fieldName(name)}: ${typeOf(params[name])}`).join("; ")} }`;
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Writes `generated/strings.ts`: the key-to-parameters table a game passes to `defineGame`, and
|
|
1462
|
+
* the key union next to it. A key with a plain argument pulls in `DescriptionNode`, so an icon
|
|
1463
|
+
* may be passed where the message expects one.
|
|
1464
|
+
*
|
|
1465
|
+
* @param entries - Every key of the game, in any order.
|
|
1466
|
+
* @returns The text of the module.
|
|
1467
|
+
* @example
|
|
1468
|
+
* ```ts
|
|
1469
|
+
* emitTypes([{ key: "hud.orders", params: { n: { kind: "number" } } }]);
|
|
1470
|
+
* // the header and `export type Strings = { "hud.orders": { n: number } };`
|
|
1471
|
+
* ```
|
|
1472
|
+
*/
|
|
1473
|
+
function emitTypes(entries) {
|
|
1474
|
+
const sorted = [...entries].toSorted((left, right) => byName(left.key, right.key));
|
|
1475
|
+
const rows = sorted.map((entry) => ` ${JSON.stringify(entry.key)}: ${paramsOf(entry.params)};`);
|
|
1476
|
+
const blocks = sorted.some((entry) => Object.values(entry.params).some((entry) => entry.kind === "argument")) ? [`${HEADER}\nimport type { DescriptionNode } from "@moku-labs/game";`, `type ${ARGUMENT} = string | number | readonly string[] | DescriptionNode;`] : [HEADER];
|
|
1477
|
+
blocks.push(`export type Strings = {\n${rows.join("\n")}\n};`, "export type StringKey = keyof Strings;");
|
|
1478
|
+
return `${blocks.join("\n\n")}\n`;
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Reads an output file that may not exist yet.
|
|
1482
|
+
*
|
|
1483
|
+
* @param file - Path of the file.
|
|
1484
|
+
* @returns Its text, or `undefined` when it is not there.
|
|
1485
|
+
*/
|
|
1486
|
+
async function readCurrent(file) {
|
|
1487
|
+
try {
|
|
1488
|
+
return await readFile(file, "utf8");
|
|
1489
|
+
} catch {
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Writes one output file when its text changed, and tells whether it differed.
|
|
1495
|
+
*
|
|
1496
|
+
* @param file - Path of the file.
|
|
1497
|
+
* @param text - What it should contain.
|
|
1498
|
+
* @param write - False for a check run: nothing is written.
|
|
1499
|
+
* @returns True when the file on disk differs from the text.
|
|
1500
|
+
* @example
|
|
1501
|
+
* ```ts
|
|
1502
|
+
* await writeIfChanged("src/generated/strings.ts", "// generated\n", true); // true on the first run
|
|
1503
|
+
* ```
|
|
1504
|
+
*/
|
|
1505
|
+
async function writeIfChanged(file, text, write) {
|
|
1506
|
+
if (await readCurrent(file) === text) return false;
|
|
1507
|
+
if (write) {
|
|
1508
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
1509
|
+
await writeFile(file, text);
|
|
1510
|
+
}
|
|
1511
|
+
return true;
|
|
1512
|
+
}
|
|
1513
|
+
//#endregion
|
|
1514
|
+
//#region node_modules/@formatjs/icu-skeleton-parser/index.js
|
|
1515
|
+
/**
|
|
1516
|
+
* https://unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table
|
|
1517
|
+
* Credit: https://github.com/caridy/intl-datetimeformat-pattern/blob/master/index.js
|
|
1518
|
+
* with some tweaks
|
|
1519
|
+
*/
|
|
1520
|
+
const DATE_TIME_REGEX = /(?:[Eec]{1,6}|G{1,5}|[Qq]{1,5}|(?:[yYur]+|U{1,5})|[ML]{1,5}|d{1,2}|D{1,3}|F{1}|[abB]{1,5}|[hkHK]{1,2}|w{1,2}|W{1}|m{1,2}|s{1,2}|[zZOvVxX]{1,4})(?=([^']*'[^']*')*[^']*$)/g;
|
|
1521
|
+
/**
|
|
1522
|
+
* Parse Date time skeleton into Intl.DateTimeFormatOptions
|
|
1523
|
+
* Ref: https://unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table
|
|
1524
|
+
* @public
|
|
1525
|
+
* @param skeleton skeleton string
|
|
1526
|
+
*/
|
|
1527
|
+
function parseDateTimeSkeleton(skeleton) {
|
|
1528
|
+
const result = {};
|
|
1529
|
+
skeleton.replace(DATE_TIME_REGEX, (match) => {
|
|
1530
|
+
const len = match.length;
|
|
1531
|
+
switch (match[0]) {
|
|
1532
|
+
case "G":
|
|
1533
|
+
result.era = len === 4 ? "long" : len === 5 ? "narrow" : "short";
|
|
1534
|
+
break;
|
|
1535
|
+
case "y":
|
|
1536
|
+
result.year = len === 2 ? "2-digit" : "numeric";
|
|
1537
|
+
break;
|
|
1538
|
+
case "Y":
|
|
1539
|
+
case "u":
|
|
1540
|
+
case "U":
|
|
1541
|
+
case "r": throw new RangeError("`Y/u/U/r` (year) patterns are not supported, use `y` instead");
|
|
1542
|
+
case "q":
|
|
1543
|
+
case "Q": throw new RangeError("`q/Q` (quarter) patterns are not supported");
|
|
1544
|
+
case "M":
|
|
1545
|
+
case "L":
|
|
1546
|
+
result.month = [
|
|
1547
|
+
"numeric",
|
|
1548
|
+
"2-digit",
|
|
1549
|
+
"short",
|
|
1550
|
+
"long",
|
|
1551
|
+
"narrow"
|
|
1552
|
+
][len - 1];
|
|
1553
|
+
break;
|
|
1554
|
+
case "w":
|
|
1555
|
+
case "W": throw new RangeError("`w/W` (week) patterns are not supported");
|
|
1556
|
+
case "d":
|
|
1557
|
+
result.day = ["numeric", "2-digit"][len - 1];
|
|
1558
|
+
break;
|
|
1559
|
+
case "D":
|
|
1560
|
+
case "F":
|
|
1561
|
+
case "g": throw new RangeError("`D/F/g` (day) patterns are not supported, use `d` instead");
|
|
1562
|
+
case "E":
|
|
1563
|
+
result.weekday = len === 4 ? "long" : len === 5 ? "narrow" : "short";
|
|
1564
|
+
break;
|
|
1565
|
+
case "e":
|
|
1566
|
+
if (len < 4) throw new RangeError("`e..eee` (weekday) patterns are not supported");
|
|
1567
|
+
result.weekday = [
|
|
1568
|
+
"short",
|
|
1569
|
+
"long",
|
|
1570
|
+
"narrow",
|
|
1571
|
+
"short"
|
|
1572
|
+
][len - 3];
|
|
1573
|
+
break;
|
|
1574
|
+
case "c":
|
|
1575
|
+
if (len < 4) throw new RangeError("`c..ccc` (weekday) patterns are not supported");
|
|
1576
|
+
result.weekday = [
|
|
1577
|
+
"short",
|
|
1578
|
+
"long",
|
|
1579
|
+
"narrow",
|
|
1580
|
+
"short"
|
|
1581
|
+
][len - 3];
|
|
1582
|
+
break;
|
|
1583
|
+
case "a":
|
|
1584
|
+
result.hour12 = true;
|
|
1585
|
+
break;
|
|
1586
|
+
case "b":
|
|
1587
|
+
case "B": throw new RangeError("`b/B` (period) patterns are not supported, use `a` instead");
|
|
1588
|
+
case "h":
|
|
1589
|
+
result.hourCycle = "h12";
|
|
1590
|
+
result.hour = ["numeric", "2-digit"][len - 1];
|
|
1591
|
+
break;
|
|
1592
|
+
case "H":
|
|
1593
|
+
result.hourCycle = "h23";
|
|
1594
|
+
result.hour = ["numeric", "2-digit"][len - 1];
|
|
1595
|
+
break;
|
|
1596
|
+
case "K":
|
|
1597
|
+
result.hourCycle = "h11";
|
|
1598
|
+
result.hour = ["numeric", "2-digit"][len - 1];
|
|
1599
|
+
break;
|
|
1600
|
+
case "k":
|
|
1601
|
+
result.hourCycle = "h24";
|
|
1602
|
+
result.hour = ["numeric", "2-digit"][len - 1];
|
|
1603
|
+
break;
|
|
1604
|
+
case "j":
|
|
1605
|
+
case "J":
|
|
1606
|
+
case "C": throw new RangeError("`j/J/C` (hour) patterns are not supported, use `h/H/K/k` instead");
|
|
1607
|
+
case "m":
|
|
1608
|
+
result.minute = ["numeric", "2-digit"][len - 1];
|
|
1609
|
+
break;
|
|
1610
|
+
case "s":
|
|
1611
|
+
result.second = ["numeric", "2-digit"][len - 1];
|
|
1612
|
+
break;
|
|
1613
|
+
case "S":
|
|
1614
|
+
case "A": throw new RangeError("`S/A` (second) patterns are not supported, use `s` instead");
|
|
1615
|
+
case "z":
|
|
1616
|
+
result.timeZoneName = len < 4 ? "short" : "long";
|
|
1617
|
+
break;
|
|
1618
|
+
case "Z":
|
|
1619
|
+
case "O":
|
|
1620
|
+
case "v":
|
|
1621
|
+
case "V":
|
|
1622
|
+
case "X":
|
|
1623
|
+
case "x": throw new RangeError("`Z/O/v/V/X/x` (timeZone) patterns are not supported, use `z` instead");
|
|
1624
|
+
}
|
|
1625
|
+
return "";
|
|
1626
|
+
});
|
|
1627
|
+
return result;
|
|
1628
|
+
}
|
|
1629
|
+
const WHITE_SPACE_REGEX = /[\t-\r \x85\u200E\u200F\u2028\u2029]/i;
|
|
1630
|
+
function parseNumberSkeletonFromString(skeleton) {
|
|
1631
|
+
if (skeleton.length === 0) throw new Error("Number skeleton cannot be empty");
|
|
1632
|
+
const stringTokens = skeleton.split(WHITE_SPACE_REGEX).filter((x) => x.length > 0);
|
|
1633
|
+
const tokens = [];
|
|
1634
|
+
for (const stringToken of stringTokens) {
|
|
1635
|
+
let stemAndOptions = stringToken.split("/");
|
|
1636
|
+
if (stemAndOptions.length === 0) throw new Error("Invalid number skeleton");
|
|
1637
|
+
const [stem, ...options] = stemAndOptions;
|
|
1638
|
+
for (const option of options) if (option.length === 0) throw new Error("Invalid number skeleton");
|
|
1639
|
+
tokens.push({
|
|
1640
|
+
stem,
|
|
1641
|
+
options
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
return tokens;
|
|
1645
|
+
}
|
|
1646
|
+
function icuUnitToEcma(unit) {
|
|
1647
|
+
return unit.replace(/^(.*?)-/, "");
|
|
1648
|
+
}
|
|
1649
|
+
const FRACTION_PRECISION_REGEX = /^\.(?:(0+)(\*)?|(#+)|(0+)(#+))$/g;
|
|
1650
|
+
const SIGNIFICANT_PRECISION_REGEX = /^(@+)?(\+|#+)?[rs]?$/g;
|
|
1651
|
+
const INTEGER_WIDTH_REGEX = /(\*)(0+)|(#+)(0+)|(0+)/g;
|
|
1652
|
+
const CONCISE_INTEGER_WIDTH_REGEX = /^(0+)$/;
|
|
1653
|
+
function parseSignificantPrecision(str) {
|
|
1654
|
+
const result = {};
|
|
1655
|
+
if (str[str.length - 1] === "r") result.roundingPriority = "morePrecision";
|
|
1656
|
+
else if (str[str.length - 1] === "s") result.roundingPriority = "lessPrecision";
|
|
1657
|
+
str.replace(SIGNIFICANT_PRECISION_REGEX, function(_, g1, g2) {
|
|
1658
|
+
if (typeof g2 !== "string") {
|
|
1659
|
+
result.minimumSignificantDigits = g1.length;
|
|
1660
|
+
result.maximumSignificantDigits = g1.length;
|
|
1661
|
+
} else if (g2 === "+") result.minimumSignificantDigits = g1.length;
|
|
1662
|
+
else if (g1[0] === "#") result.maximumSignificantDigits = g1.length;
|
|
1663
|
+
else {
|
|
1664
|
+
result.minimumSignificantDigits = g1.length;
|
|
1665
|
+
result.maximumSignificantDigits = g1.length + (typeof g2 === "string" ? g2.length : 0);
|
|
1666
|
+
}
|
|
1667
|
+
return "";
|
|
1668
|
+
});
|
|
1669
|
+
return result;
|
|
1670
|
+
}
|
|
1671
|
+
function parseSign(str) {
|
|
1672
|
+
switch (str) {
|
|
1673
|
+
case "sign-auto": return { signDisplay: "auto" };
|
|
1674
|
+
case "sign-accounting":
|
|
1675
|
+
case "()": return { currencySign: "accounting" };
|
|
1676
|
+
case "sign-always":
|
|
1677
|
+
case "+!": return { signDisplay: "always" };
|
|
1678
|
+
case "sign-accounting-always":
|
|
1679
|
+
case "()!": return {
|
|
1680
|
+
signDisplay: "always",
|
|
1681
|
+
currencySign: "accounting"
|
|
1682
|
+
};
|
|
1683
|
+
case "sign-except-zero":
|
|
1684
|
+
case "+?": return { signDisplay: "exceptZero" };
|
|
1685
|
+
case "sign-accounting-except-zero":
|
|
1686
|
+
case "()?": return {
|
|
1687
|
+
signDisplay: "exceptZero",
|
|
1688
|
+
currencySign: "accounting"
|
|
1689
|
+
};
|
|
1690
|
+
case "sign-never":
|
|
1691
|
+
case "+_": return { signDisplay: "never" };
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
function parseConciseScientificAndEngineeringStem(stem) {
|
|
1695
|
+
let result;
|
|
1696
|
+
if (stem[0] === "E" && stem[1] === "E") {
|
|
1697
|
+
result = { notation: "engineering" };
|
|
1698
|
+
stem = stem.slice(2);
|
|
1699
|
+
} else if (stem[0] === "E") {
|
|
1700
|
+
result = { notation: "scientific" };
|
|
1701
|
+
stem = stem.slice(1);
|
|
1702
|
+
}
|
|
1703
|
+
if (result) {
|
|
1704
|
+
const signDisplay = stem.slice(0, 2);
|
|
1705
|
+
if (signDisplay === "+!") {
|
|
1706
|
+
result.signDisplay = "always";
|
|
1707
|
+
stem = stem.slice(2);
|
|
1708
|
+
} else if (signDisplay === "+?") {
|
|
1709
|
+
result.signDisplay = "exceptZero";
|
|
1710
|
+
stem = stem.slice(2);
|
|
1711
|
+
}
|
|
1712
|
+
if (!CONCISE_INTEGER_WIDTH_REGEX.test(stem)) throw new Error("Malformed concise eng/scientific notation");
|
|
1713
|
+
result.minimumIntegerDigits = stem.length;
|
|
1714
|
+
}
|
|
1715
|
+
return result;
|
|
1716
|
+
}
|
|
1717
|
+
function parseNotationOptions(opt) {
|
|
1718
|
+
const result = {};
|
|
1719
|
+
const signOpts = parseSign(opt);
|
|
1720
|
+
if (signOpts) return signOpts;
|
|
1721
|
+
return result;
|
|
1722
|
+
}
|
|
1723
|
+
/**
|
|
1724
|
+
* https://github.com/unicode-org/icu/blob/master/docs/userguide/format_parse/numbers/skeletons.md#skeleton-stems-and-options
|
|
1725
|
+
*/
|
|
1726
|
+
function parseNumberSkeleton(tokens) {
|
|
1727
|
+
let result = {};
|
|
1728
|
+
for (const token of tokens) {
|
|
1729
|
+
switch (token.stem) {
|
|
1730
|
+
case "percent":
|
|
1731
|
+
case "%":
|
|
1732
|
+
result.style = "percent";
|
|
1733
|
+
continue;
|
|
1734
|
+
case "%x100":
|
|
1735
|
+
result.style = "percent";
|
|
1736
|
+
result.scale = 100;
|
|
1737
|
+
continue;
|
|
1738
|
+
case "currency":
|
|
1739
|
+
result.style = "currency";
|
|
1740
|
+
result.currency = token.options[0];
|
|
1741
|
+
continue;
|
|
1742
|
+
case "group-off":
|
|
1743
|
+
case ",_":
|
|
1744
|
+
result.useGrouping = false;
|
|
1745
|
+
continue;
|
|
1746
|
+
case "precision-integer":
|
|
1747
|
+
case ".":
|
|
1748
|
+
result.maximumFractionDigits = 0;
|
|
1749
|
+
continue;
|
|
1750
|
+
case "measure-unit":
|
|
1751
|
+
case "unit":
|
|
1752
|
+
result.style = "unit";
|
|
1753
|
+
result.unit = icuUnitToEcma(token.options[0]);
|
|
1754
|
+
continue;
|
|
1755
|
+
case "compact-short":
|
|
1756
|
+
case "K":
|
|
1757
|
+
result.notation = "compact";
|
|
1758
|
+
result.compactDisplay = "short";
|
|
1759
|
+
continue;
|
|
1760
|
+
case "compact-long":
|
|
1761
|
+
case "KK":
|
|
1762
|
+
result.notation = "compact";
|
|
1763
|
+
result.compactDisplay = "long";
|
|
1764
|
+
continue;
|
|
1765
|
+
case "scientific":
|
|
1766
|
+
result = {
|
|
1767
|
+
...result,
|
|
1768
|
+
notation: "scientific",
|
|
1769
|
+
...token.options.reduce((all, opt) => ({
|
|
1770
|
+
...all,
|
|
1771
|
+
...parseNotationOptions(opt)
|
|
1772
|
+
}), {})
|
|
1773
|
+
};
|
|
1774
|
+
continue;
|
|
1775
|
+
case "engineering":
|
|
1776
|
+
result = {
|
|
1777
|
+
...result,
|
|
1778
|
+
notation: "engineering",
|
|
1779
|
+
...token.options.reduce((all, opt) => ({
|
|
1780
|
+
...all,
|
|
1781
|
+
...parseNotationOptions(opt)
|
|
1782
|
+
}), {})
|
|
1783
|
+
};
|
|
1784
|
+
continue;
|
|
1785
|
+
case "notation-simple":
|
|
1786
|
+
result.notation = "standard";
|
|
1787
|
+
continue;
|
|
1788
|
+
case "unit-width-narrow":
|
|
1789
|
+
result.currencyDisplay = "narrowSymbol";
|
|
1790
|
+
result.unitDisplay = "narrow";
|
|
1791
|
+
continue;
|
|
1792
|
+
case "unit-width-short":
|
|
1793
|
+
result.currencyDisplay = "code";
|
|
1794
|
+
result.unitDisplay = "short";
|
|
1795
|
+
continue;
|
|
1796
|
+
case "unit-width-full-name":
|
|
1797
|
+
result.currencyDisplay = "name";
|
|
1798
|
+
result.unitDisplay = "long";
|
|
1799
|
+
continue;
|
|
1800
|
+
case "unit-width-iso-code":
|
|
1801
|
+
result.currencyDisplay = "symbol";
|
|
1802
|
+
continue;
|
|
1803
|
+
case "scale":
|
|
1804
|
+
result.scale = parseFloat(token.options[0]);
|
|
1805
|
+
continue;
|
|
1806
|
+
case "rounding-mode-floor":
|
|
1807
|
+
result.roundingMode = "floor";
|
|
1808
|
+
continue;
|
|
1809
|
+
case "rounding-mode-ceiling":
|
|
1810
|
+
result.roundingMode = "ceil";
|
|
1811
|
+
continue;
|
|
1812
|
+
case "rounding-mode-down":
|
|
1813
|
+
result.roundingMode = "trunc";
|
|
1814
|
+
continue;
|
|
1815
|
+
case "rounding-mode-up":
|
|
1816
|
+
result.roundingMode = "expand";
|
|
1817
|
+
continue;
|
|
1818
|
+
case "rounding-mode-half-even":
|
|
1819
|
+
result.roundingMode = "halfEven";
|
|
1820
|
+
continue;
|
|
1821
|
+
case "rounding-mode-half-down":
|
|
1822
|
+
result.roundingMode = "halfTrunc";
|
|
1823
|
+
continue;
|
|
1824
|
+
case "rounding-mode-half-up":
|
|
1825
|
+
result.roundingMode = "halfExpand";
|
|
1826
|
+
continue;
|
|
1827
|
+
case "integer-width":
|
|
1828
|
+
if (token.options.length > 1) throw new RangeError("integer-width stems only accept a single optional option");
|
|
1829
|
+
token.options[0].replace(INTEGER_WIDTH_REGEX, function(_, g1, g2, g3, g4, g5) {
|
|
1830
|
+
if (g1) result.minimumIntegerDigits = g2.length;
|
|
1831
|
+
else if (g3 && g4) throw new Error("We currently do not support maximum integer digits");
|
|
1832
|
+
else if (g5) throw new Error("We currently do not support exact integer digits");
|
|
1833
|
+
return "";
|
|
1834
|
+
});
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
if (CONCISE_INTEGER_WIDTH_REGEX.test(token.stem)) {
|
|
1838
|
+
result.minimumIntegerDigits = token.stem.length;
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1841
|
+
if (FRACTION_PRECISION_REGEX.test(token.stem)) {
|
|
1842
|
+
if (token.options.length > 1) throw new RangeError("Fraction-precision stems only accept a single optional option");
|
|
1843
|
+
token.stem.replace(FRACTION_PRECISION_REGEX, function(_, g1, g2, g3, g4, g5) {
|
|
1844
|
+
if (g2 === "*") result.minimumFractionDigits = g1.length;
|
|
1845
|
+
else if (g3 && g3[0] === "#") result.maximumFractionDigits = g3.length;
|
|
1846
|
+
else if (g4 && g5) {
|
|
1847
|
+
result.minimumFractionDigits = g4.length;
|
|
1848
|
+
result.maximumFractionDigits = g4.length + g5.length;
|
|
1849
|
+
} else {
|
|
1850
|
+
result.minimumFractionDigits = g1.length;
|
|
1851
|
+
result.maximumFractionDigits = g1.length;
|
|
1852
|
+
}
|
|
1853
|
+
return "";
|
|
1854
|
+
});
|
|
1855
|
+
const opt = token.options[0];
|
|
1856
|
+
if (opt === "w") result = {
|
|
1857
|
+
...result,
|
|
1858
|
+
trailingZeroDisplay: "stripIfInteger"
|
|
1859
|
+
};
|
|
1860
|
+
else if (opt) result = {
|
|
1861
|
+
...result,
|
|
1862
|
+
...parseSignificantPrecision(opt)
|
|
1863
|
+
};
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
if (SIGNIFICANT_PRECISION_REGEX.test(token.stem)) {
|
|
1867
|
+
result = {
|
|
1868
|
+
...result,
|
|
1869
|
+
...parseSignificantPrecision(token.stem)
|
|
1870
|
+
};
|
|
1871
|
+
continue;
|
|
1872
|
+
}
|
|
1873
|
+
const signOpts = parseSign(token.stem);
|
|
1874
|
+
if (signOpts) result = {
|
|
1875
|
+
...result,
|
|
1876
|
+
...signOpts
|
|
1877
|
+
};
|
|
1878
|
+
const conciseScientificAndEngineeringOpts = parseConciseScientificAndEngineeringStem(token.stem);
|
|
1879
|
+
if (conciseScientificAndEngineeringOpts) result = {
|
|
1880
|
+
...result,
|
|
1881
|
+
...conciseScientificAndEngineeringOpts
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
return result;
|
|
1885
|
+
}
|
|
1886
|
+
//#endregion
|
|
1887
|
+
//#region node_modules/@formatjs/icu-messageformat-parser/index.js
|
|
1888
|
+
let ErrorKind = /* @__PURE__ */ function(ErrorKind) {
|
|
1889
|
+
/** Argument is unclosed (e.g. `{0`) */
|
|
1890
|
+
ErrorKind[ErrorKind["EXPECT_ARGUMENT_CLOSING_BRACE"] = 1] = "EXPECT_ARGUMENT_CLOSING_BRACE";
|
|
1891
|
+
/** Argument is empty (e.g. `{}`). */
|
|
1892
|
+
ErrorKind[ErrorKind["EMPTY_ARGUMENT"] = 2] = "EMPTY_ARGUMENT";
|
|
1893
|
+
/** Argument is malformed (e.g. `{foo!}``) */
|
|
1894
|
+
ErrorKind[ErrorKind["MALFORMED_ARGUMENT"] = 3] = "MALFORMED_ARGUMENT";
|
|
1895
|
+
/** Expect an argument type (e.g. `{foo,}`) */
|
|
1896
|
+
ErrorKind[ErrorKind["EXPECT_ARGUMENT_TYPE"] = 4] = "EXPECT_ARGUMENT_TYPE";
|
|
1897
|
+
/** Unsupported argument type (e.g. `{foo,foo}`) */
|
|
1898
|
+
ErrorKind[ErrorKind["INVALID_ARGUMENT_TYPE"] = 5] = "INVALID_ARGUMENT_TYPE";
|
|
1899
|
+
/** Expect an argument style (e.g. `{foo, number, }`) */
|
|
1900
|
+
ErrorKind[ErrorKind["EXPECT_ARGUMENT_STYLE"] = 6] = "EXPECT_ARGUMENT_STYLE";
|
|
1901
|
+
/** The number skeleton is invalid. */
|
|
1902
|
+
ErrorKind[ErrorKind["INVALID_NUMBER_SKELETON"] = 7] = "INVALID_NUMBER_SKELETON";
|
|
1903
|
+
/** The date time skeleton is invalid. */
|
|
1904
|
+
ErrorKind[ErrorKind["INVALID_DATE_TIME_SKELETON"] = 8] = "INVALID_DATE_TIME_SKELETON";
|
|
1905
|
+
/** Exepct a number skeleton following the `::` (e.g. `{foo, number, ::}`) */
|
|
1906
|
+
ErrorKind[ErrorKind["EXPECT_NUMBER_SKELETON"] = 9] = "EXPECT_NUMBER_SKELETON";
|
|
1907
|
+
/** Exepct a date time skeleton following the `::` (e.g. `{foo, date, ::}`) */
|
|
1908
|
+
ErrorKind[ErrorKind["EXPECT_DATE_TIME_SKELETON"] = 10] = "EXPECT_DATE_TIME_SKELETON";
|
|
1909
|
+
/** Unmatched apostrophes in the argument style (e.g. `{foo, number, 'test`) */
|
|
1910
|
+
ErrorKind[ErrorKind["UNCLOSED_QUOTE_IN_ARGUMENT_STYLE"] = 11] = "UNCLOSED_QUOTE_IN_ARGUMENT_STYLE";
|
|
1911
|
+
/** Missing select argument options (e.g. `{foo, select}`) */
|
|
1912
|
+
ErrorKind[ErrorKind["EXPECT_SELECT_ARGUMENT_OPTIONS"] = 12] = "EXPECT_SELECT_ARGUMENT_OPTIONS";
|
|
1913
|
+
/** Expecting an offset value in `plural` or `selectordinal` argument (e.g `{foo, plural, offset}`) */
|
|
1914
|
+
ErrorKind[ErrorKind["EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE"] = 13] = "EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE";
|
|
1915
|
+
/** Offset value in `plural` or `selectordinal` is invalid (e.g. `{foo, plural, offset: x}`) */
|
|
1916
|
+
ErrorKind[ErrorKind["INVALID_PLURAL_ARGUMENT_OFFSET_VALUE"] = 14] = "INVALID_PLURAL_ARGUMENT_OFFSET_VALUE";
|
|
1917
|
+
/** Expecting a selector in `select` argument (e.g `{foo, select}`) */
|
|
1918
|
+
ErrorKind[ErrorKind["EXPECT_SELECT_ARGUMENT_SELECTOR"] = 15] = "EXPECT_SELECT_ARGUMENT_SELECTOR";
|
|
1919
|
+
/** Expecting a selector in `plural` or `selectordinal` argument (e.g `{foo, plural}`) */
|
|
1920
|
+
ErrorKind[ErrorKind["EXPECT_PLURAL_ARGUMENT_SELECTOR"] = 16] = "EXPECT_PLURAL_ARGUMENT_SELECTOR";
|
|
1921
|
+
/** Expecting a message fragment after the `select` selector (e.g. `{foo, select, apple}`) */
|
|
1922
|
+
ErrorKind[ErrorKind["EXPECT_SELECT_ARGUMENT_SELECTOR_FRAGMENT"] = 17] = "EXPECT_SELECT_ARGUMENT_SELECTOR_FRAGMENT";
|
|
1923
|
+
/**
|
|
1924
|
+
* Expecting a message fragment after the `plural` or `selectordinal` selector
|
|
1925
|
+
* (e.g. `{foo, plural, one}`)
|
|
1926
|
+
*/
|
|
1927
|
+
ErrorKind[ErrorKind["EXPECT_PLURAL_ARGUMENT_SELECTOR_FRAGMENT"] = 18] = "EXPECT_PLURAL_ARGUMENT_SELECTOR_FRAGMENT";
|
|
1928
|
+
/** Selector in `plural` or `selectordinal` is malformed (e.g. `{foo, plural, =x {#}}`) */
|
|
1929
|
+
ErrorKind[ErrorKind["INVALID_PLURAL_ARGUMENT_SELECTOR"] = 19] = "INVALID_PLURAL_ARGUMENT_SELECTOR";
|
|
1930
|
+
/**
|
|
1931
|
+
* Duplicate selectors in `plural` or `selectordinal` argument.
|
|
1932
|
+
* (e.g. {foo, plural, one {#} one {#}})
|
|
1933
|
+
*/
|
|
1934
|
+
ErrorKind[ErrorKind["DUPLICATE_PLURAL_ARGUMENT_SELECTOR"] = 20] = "DUPLICATE_PLURAL_ARGUMENT_SELECTOR";
|
|
1935
|
+
/** Duplicate selectors in `select` argument.
|
|
1936
|
+
* (e.g. {foo, select, apple {apple} apple {apple}})
|
|
1937
|
+
*/
|
|
1938
|
+
ErrorKind[ErrorKind["DUPLICATE_SELECT_ARGUMENT_SELECTOR"] = 21] = "DUPLICATE_SELECT_ARGUMENT_SELECTOR";
|
|
1939
|
+
/** Plural or select argument option must have `other` clause. */
|
|
1940
|
+
ErrorKind[ErrorKind["MISSING_OTHER_CLAUSE"] = 22] = "MISSING_OTHER_CLAUSE";
|
|
1941
|
+
/** The tag is malformed. (e.g. `<bold!>foo</bold!>) */
|
|
1942
|
+
ErrorKind[ErrorKind["INVALID_TAG"] = 23] = "INVALID_TAG";
|
|
1943
|
+
/** The tag name is invalid. (e.g. `<123>foo</123>`) */
|
|
1944
|
+
ErrorKind[ErrorKind["INVALID_TAG_NAME"] = 25] = "INVALID_TAG_NAME";
|
|
1945
|
+
/** The closing tag does not match the opening tag. (e.g. `<bold>foo</italic>`) */
|
|
1946
|
+
ErrorKind[ErrorKind["UNMATCHED_CLOSING_TAG"] = 26] = "UNMATCHED_CLOSING_TAG";
|
|
1947
|
+
/** The opening tag has unmatched closing tag. (e.g. `<bold>foo`) */
|
|
1948
|
+
ErrorKind[ErrorKind["UNCLOSED_TAG"] = 27] = "UNCLOSED_TAG";
|
|
1949
|
+
return ErrorKind;
|
|
1950
|
+
}({});
|
|
1951
|
+
let TYPE = /* @__PURE__ */ function(TYPE) {
|
|
1952
|
+
/**
|
|
1953
|
+
* Raw text
|
|
1954
|
+
*/
|
|
1955
|
+
TYPE[TYPE["literal"] = 0] = "literal";
|
|
1956
|
+
/**
|
|
1957
|
+
* Variable w/o any format, e.g `var` in `this is a {var}`
|
|
1958
|
+
*/
|
|
1959
|
+
TYPE[TYPE["argument"] = 1] = "argument";
|
|
1960
|
+
/**
|
|
1961
|
+
* Variable w/ number format
|
|
1962
|
+
*/
|
|
1963
|
+
TYPE[TYPE["number"] = 2] = "number";
|
|
1964
|
+
/**
|
|
1965
|
+
* Variable w/ date format
|
|
1966
|
+
*/
|
|
1967
|
+
TYPE[TYPE["date"] = 3] = "date";
|
|
1968
|
+
/**
|
|
1969
|
+
* Variable w/ time format
|
|
1970
|
+
*/
|
|
1971
|
+
TYPE[TYPE["time"] = 4] = "time";
|
|
1972
|
+
/**
|
|
1973
|
+
* Variable w/ select format
|
|
1974
|
+
*/
|
|
1975
|
+
TYPE[TYPE["select"] = 5] = "select";
|
|
1976
|
+
/**
|
|
1977
|
+
* Variable w/ plural format
|
|
1978
|
+
*/
|
|
1979
|
+
TYPE[TYPE["plural"] = 6] = "plural";
|
|
1980
|
+
/**
|
|
1981
|
+
* Only possible within plural argument.
|
|
1982
|
+
* This is the `#` symbol that will be substituted with the count.
|
|
1983
|
+
*/
|
|
1984
|
+
TYPE[TYPE["pound"] = 7] = "pound";
|
|
1985
|
+
/**
|
|
1986
|
+
* XML-like tag
|
|
1987
|
+
*/
|
|
1988
|
+
TYPE[TYPE["tag"] = 8] = "tag";
|
|
1989
|
+
return TYPE;
|
|
1990
|
+
}({});
|
|
1991
|
+
function isNumberElement(el) {
|
|
1992
|
+
return el.type === 2;
|
|
1993
|
+
}
|
|
1994
|
+
function isDateElement(el) {
|
|
1995
|
+
return el.type === 3;
|
|
1996
|
+
}
|
|
1997
|
+
function isTimeElement(el) {
|
|
1998
|
+
return el.type === 4;
|
|
1999
|
+
}
|
|
2000
|
+
function isSelectElement(el) {
|
|
2001
|
+
return el.type === 5;
|
|
2002
|
+
}
|
|
2003
|
+
function isPluralElement(el) {
|
|
2004
|
+
return el.type === 6;
|
|
2005
|
+
}
|
|
2006
|
+
function isTagElement(el) {
|
|
2007
|
+
return el.type === 8;
|
|
2008
|
+
}
|
|
2009
|
+
function isNumberSkeleton(el) {
|
|
2010
|
+
return !!(el && typeof el === "object" && el.type === 0);
|
|
2011
|
+
}
|
|
2012
|
+
function isDateTimeSkeleton(el) {
|
|
2013
|
+
return !!(el && typeof el === "object" && el.type === 1);
|
|
2014
|
+
}
|
|
2015
|
+
const SPACE_SEPARATOR_REGEX = /[ \xA0\u1680\u2000-\u200A\u202F\u205F\u3000]/;
|
|
2016
|
+
const IDENTIFIER_PREFIX_REGEX = /([^\t-\r -\/:-@\[-\^`\{-~\x85\xA0-\xA7\xA9\xAB\xAC\xAE\xB0\xB1\xB6\xBB\xBF\xD7\xF7\u1680\u2000-\u200A\u2010-\u2029\u202F-\u203E\u2041-\u2053\u2055-\u205F\u2190-\u245F\u2500-\u2775\u2794-\u2BFF\u2E00-\u2E7F\u3000-\u3003\u3008-\u3020\u3030\uFD3E\uFD3F\uFE45\uFE46]*)/g;
|
|
2017
|
+
const timeData = {
|
|
2018
|
+
"001": ["H", "h"],
|
|
2019
|
+
"419": [
|
|
2020
|
+
"h",
|
|
2021
|
+
"H",
|
|
2022
|
+
"hB",
|
|
2023
|
+
"hb"
|
|
2024
|
+
],
|
|
2025
|
+
"AC": [
|
|
2026
|
+
"H",
|
|
2027
|
+
"h",
|
|
2028
|
+
"hb",
|
|
2029
|
+
"hB"
|
|
2030
|
+
],
|
|
2031
|
+
"AD": ["H", "hB"],
|
|
2032
|
+
"AE": [
|
|
2033
|
+
"h",
|
|
2034
|
+
"hB",
|
|
2035
|
+
"hb",
|
|
2036
|
+
"H"
|
|
2037
|
+
],
|
|
2038
|
+
"AF": [
|
|
2039
|
+
"H",
|
|
2040
|
+
"hb",
|
|
2041
|
+
"hB",
|
|
2042
|
+
"h"
|
|
2043
|
+
],
|
|
2044
|
+
"AG": [
|
|
2045
|
+
"h",
|
|
2046
|
+
"hb",
|
|
2047
|
+
"H",
|
|
2048
|
+
"hB"
|
|
2049
|
+
],
|
|
2050
|
+
"AI": [
|
|
2051
|
+
"H",
|
|
2052
|
+
"h",
|
|
2053
|
+
"hb",
|
|
2054
|
+
"hB"
|
|
2055
|
+
],
|
|
2056
|
+
"AL": [
|
|
2057
|
+
"h",
|
|
2058
|
+
"H",
|
|
2059
|
+
"hB"
|
|
2060
|
+
],
|
|
2061
|
+
"AM": ["H", "hB"],
|
|
2062
|
+
"AO": ["H", "hB"],
|
|
2063
|
+
"AR": [
|
|
2064
|
+
"h",
|
|
2065
|
+
"H",
|
|
2066
|
+
"hB",
|
|
2067
|
+
"hb"
|
|
2068
|
+
],
|
|
2069
|
+
"AS": ["h", "H"],
|
|
2070
|
+
"AT": ["H", "hB"],
|
|
2071
|
+
"AU": [
|
|
2072
|
+
"h",
|
|
2073
|
+
"hb",
|
|
2074
|
+
"H",
|
|
2075
|
+
"hB"
|
|
2076
|
+
],
|
|
2077
|
+
"AW": ["H", "hB"],
|
|
2078
|
+
"AX": ["H"],
|
|
2079
|
+
"AZ": [
|
|
2080
|
+
"H",
|
|
2081
|
+
"hB",
|
|
2082
|
+
"h"
|
|
2083
|
+
],
|
|
2084
|
+
"BA": [
|
|
2085
|
+
"H",
|
|
2086
|
+
"hB",
|
|
2087
|
+
"h"
|
|
2088
|
+
],
|
|
2089
|
+
"BB": [
|
|
2090
|
+
"h",
|
|
2091
|
+
"hb",
|
|
2092
|
+
"H",
|
|
2093
|
+
"hB"
|
|
2094
|
+
],
|
|
2095
|
+
"BD": [
|
|
2096
|
+
"h",
|
|
2097
|
+
"hB",
|
|
2098
|
+
"H"
|
|
2099
|
+
],
|
|
2100
|
+
"BE": ["H", "hB"],
|
|
2101
|
+
"BF": ["H", "hB"],
|
|
2102
|
+
"BG": [
|
|
2103
|
+
"H",
|
|
2104
|
+
"hB",
|
|
2105
|
+
"h"
|
|
2106
|
+
],
|
|
2107
|
+
"BH": [
|
|
2108
|
+
"h",
|
|
2109
|
+
"hB",
|
|
2110
|
+
"hb",
|
|
2111
|
+
"H"
|
|
2112
|
+
],
|
|
2113
|
+
"BI": ["H", "h"],
|
|
2114
|
+
"BJ": ["H", "hB"],
|
|
2115
|
+
"BL": ["H", "hB"],
|
|
2116
|
+
"BM": [
|
|
2117
|
+
"h",
|
|
2118
|
+
"hb",
|
|
2119
|
+
"H",
|
|
2120
|
+
"hB"
|
|
2121
|
+
],
|
|
2122
|
+
"BN": [
|
|
2123
|
+
"hb",
|
|
2124
|
+
"hB",
|
|
2125
|
+
"h",
|
|
2126
|
+
"H"
|
|
2127
|
+
],
|
|
2128
|
+
"BO": [
|
|
2129
|
+
"h",
|
|
2130
|
+
"H",
|
|
2131
|
+
"hB",
|
|
2132
|
+
"hb"
|
|
2133
|
+
],
|
|
2134
|
+
"BQ": ["H"],
|
|
2135
|
+
"BR": ["H", "hB"],
|
|
2136
|
+
"BS": [
|
|
2137
|
+
"h",
|
|
2138
|
+
"hb",
|
|
2139
|
+
"H",
|
|
2140
|
+
"hB"
|
|
2141
|
+
],
|
|
2142
|
+
"BT": ["h", "H"],
|
|
2143
|
+
"BW": [
|
|
2144
|
+
"H",
|
|
2145
|
+
"h",
|
|
2146
|
+
"hb",
|
|
2147
|
+
"hB"
|
|
2148
|
+
],
|
|
2149
|
+
"BY": ["H", "h"],
|
|
2150
|
+
"BZ": [
|
|
2151
|
+
"H",
|
|
2152
|
+
"h",
|
|
2153
|
+
"hb",
|
|
2154
|
+
"hB"
|
|
2155
|
+
],
|
|
2156
|
+
"CA": [
|
|
2157
|
+
"h",
|
|
2158
|
+
"hb",
|
|
2159
|
+
"H",
|
|
2160
|
+
"hB"
|
|
2161
|
+
],
|
|
2162
|
+
"CC": [
|
|
2163
|
+
"H",
|
|
2164
|
+
"h",
|
|
2165
|
+
"hb",
|
|
2166
|
+
"hB"
|
|
2167
|
+
],
|
|
2168
|
+
"CD": ["hB", "H"],
|
|
2169
|
+
"CF": [
|
|
2170
|
+
"H",
|
|
2171
|
+
"h",
|
|
2172
|
+
"hB"
|
|
2173
|
+
],
|
|
2174
|
+
"CG": ["H", "hB"],
|
|
2175
|
+
"CH": [
|
|
2176
|
+
"H",
|
|
2177
|
+
"hB",
|
|
2178
|
+
"h"
|
|
2179
|
+
],
|
|
2180
|
+
"CI": ["H", "hB"],
|
|
2181
|
+
"CK": [
|
|
2182
|
+
"H",
|
|
2183
|
+
"h",
|
|
2184
|
+
"hb",
|
|
2185
|
+
"hB"
|
|
2186
|
+
],
|
|
2187
|
+
"CL": [
|
|
2188
|
+
"h",
|
|
2189
|
+
"H",
|
|
2190
|
+
"hB",
|
|
2191
|
+
"hb"
|
|
2192
|
+
],
|
|
2193
|
+
"CM": [
|
|
2194
|
+
"H",
|
|
2195
|
+
"h",
|
|
2196
|
+
"hB"
|
|
2197
|
+
],
|
|
2198
|
+
"CN": [
|
|
2199
|
+
"H",
|
|
2200
|
+
"hB",
|
|
2201
|
+
"hb",
|
|
2202
|
+
"h"
|
|
2203
|
+
],
|
|
2204
|
+
"CO": [
|
|
2205
|
+
"h",
|
|
2206
|
+
"H",
|
|
2207
|
+
"hB",
|
|
2208
|
+
"hb"
|
|
2209
|
+
],
|
|
2210
|
+
"CP": ["H"],
|
|
2211
|
+
"CR": [
|
|
2212
|
+
"h",
|
|
2213
|
+
"H",
|
|
2214
|
+
"hB",
|
|
2215
|
+
"hb"
|
|
2216
|
+
],
|
|
2217
|
+
"CU": [
|
|
2218
|
+
"h",
|
|
2219
|
+
"H",
|
|
2220
|
+
"hB",
|
|
2221
|
+
"hb"
|
|
2222
|
+
],
|
|
2223
|
+
"CV": ["H", "hB"],
|
|
2224
|
+
"CW": ["H", "hB"],
|
|
2225
|
+
"CX": [
|
|
2226
|
+
"H",
|
|
2227
|
+
"h",
|
|
2228
|
+
"hb",
|
|
2229
|
+
"hB"
|
|
2230
|
+
],
|
|
2231
|
+
"CY": [
|
|
2232
|
+
"h",
|
|
2233
|
+
"H",
|
|
2234
|
+
"hb",
|
|
2235
|
+
"hB"
|
|
2236
|
+
],
|
|
2237
|
+
"CZ": ["H"],
|
|
2238
|
+
"DE": ["H", "hB"],
|
|
2239
|
+
"DG": [
|
|
2240
|
+
"H",
|
|
2241
|
+
"h",
|
|
2242
|
+
"hb",
|
|
2243
|
+
"hB"
|
|
2244
|
+
],
|
|
2245
|
+
"DJ": ["h", "H"],
|
|
2246
|
+
"DK": ["H"],
|
|
2247
|
+
"DM": [
|
|
2248
|
+
"h",
|
|
2249
|
+
"hb",
|
|
2250
|
+
"H",
|
|
2251
|
+
"hB"
|
|
2252
|
+
],
|
|
2253
|
+
"DO": [
|
|
2254
|
+
"h",
|
|
2255
|
+
"H",
|
|
2256
|
+
"hB",
|
|
2257
|
+
"hb"
|
|
2258
|
+
],
|
|
2259
|
+
"DZ": [
|
|
2260
|
+
"h",
|
|
2261
|
+
"hB",
|
|
2262
|
+
"hb",
|
|
2263
|
+
"H"
|
|
2264
|
+
],
|
|
2265
|
+
"EA": [
|
|
2266
|
+
"H",
|
|
2267
|
+
"h",
|
|
2268
|
+
"hB",
|
|
2269
|
+
"hb"
|
|
2270
|
+
],
|
|
2271
|
+
"EC": [
|
|
2272
|
+
"h",
|
|
2273
|
+
"H",
|
|
2274
|
+
"hB",
|
|
2275
|
+
"hb"
|
|
2276
|
+
],
|
|
2277
|
+
"EE": ["H", "hB"],
|
|
2278
|
+
"EG": [
|
|
2279
|
+
"h",
|
|
2280
|
+
"hB",
|
|
2281
|
+
"hb",
|
|
2282
|
+
"H"
|
|
2283
|
+
],
|
|
2284
|
+
"EH": [
|
|
2285
|
+
"h",
|
|
2286
|
+
"hB",
|
|
2287
|
+
"hb",
|
|
2288
|
+
"H"
|
|
2289
|
+
],
|
|
2290
|
+
"ER": ["h", "H"],
|
|
2291
|
+
"ES": [
|
|
2292
|
+
"H",
|
|
2293
|
+
"hB",
|
|
2294
|
+
"h",
|
|
2295
|
+
"hb"
|
|
2296
|
+
],
|
|
2297
|
+
"ET": [
|
|
2298
|
+
"hB",
|
|
2299
|
+
"hb",
|
|
2300
|
+
"h",
|
|
2301
|
+
"H"
|
|
2302
|
+
],
|
|
2303
|
+
"FI": ["H"],
|
|
2304
|
+
"FJ": [
|
|
2305
|
+
"h",
|
|
2306
|
+
"hb",
|
|
2307
|
+
"H",
|
|
2308
|
+
"hB"
|
|
2309
|
+
],
|
|
2310
|
+
"FK": [
|
|
2311
|
+
"H",
|
|
2312
|
+
"h",
|
|
2313
|
+
"hb",
|
|
2314
|
+
"hB"
|
|
2315
|
+
],
|
|
2316
|
+
"FM": [
|
|
2317
|
+
"h",
|
|
2318
|
+
"hb",
|
|
2319
|
+
"H",
|
|
2320
|
+
"hB"
|
|
2321
|
+
],
|
|
2322
|
+
"FO": ["H", "h"],
|
|
2323
|
+
"FR": ["H", "hB"],
|
|
2324
|
+
"GA": ["H", "hB"],
|
|
2325
|
+
"GB": [
|
|
2326
|
+
"H",
|
|
2327
|
+
"h",
|
|
2328
|
+
"hb",
|
|
2329
|
+
"hB"
|
|
2330
|
+
],
|
|
2331
|
+
"GD": [
|
|
2332
|
+
"h",
|
|
2333
|
+
"hb",
|
|
2334
|
+
"H",
|
|
2335
|
+
"hB"
|
|
2336
|
+
],
|
|
2337
|
+
"GE": [
|
|
2338
|
+
"H",
|
|
2339
|
+
"hB",
|
|
2340
|
+
"h"
|
|
2341
|
+
],
|
|
2342
|
+
"GF": ["H", "hB"],
|
|
2343
|
+
"GG": [
|
|
2344
|
+
"H",
|
|
2345
|
+
"h",
|
|
2346
|
+
"hb",
|
|
2347
|
+
"hB"
|
|
2348
|
+
],
|
|
2349
|
+
"GH": ["h", "H"],
|
|
2350
|
+
"GI": [
|
|
2351
|
+
"H",
|
|
2352
|
+
"h",
|
|
2353
|
+
"hb",
|
|
2354
|
+
"hB"
|
|
2355
|
+
],
|
|
2356
|
+
"GL": ["H", "h"],
|
|
2357
|
+
"GM": [
|
|
2358
|
+
"h",
|
|
2359
|
+
"hb",
|
|
2360
|
+
"H",
|
|
2361
|
+
"hB"
|
|
2362
|
+
],
|
|
2363
|
+
"GN": ["H", "hB"],
|
|
2364
|
+
"GP": ["H", "hB"],
|
|
2365
|
+
"GQ": [
|
|
2366
|
+
"H",
|
|
2367
|
+
"hB",
|
|
2368
|
+
"h",
|
|
2369
|
+
"hb"
|
|
2370
|
+
],
|
|
2371
|
+
"GR": [
|
|
2372
|
+
"h",
|
|
2373
|
+
"H",
|
|
2374
|
+
"hb",
|
|
2375
|
+
"hB"
|
|
2376
|
+
],
|
|
2377
|
+
"GS": [
|
|
2378
|
+
"H",
|
|
2379
|
+
"h",
|
|
2380
|
+
"hb",
|
|
2381
|
+
"hB"
|
|
2382
|
+
],
|
|
2383
|
+
"GT": [
|
|
2384
|
+
"h",
|
|
2385
|
+
"H",
|
|
2386
|
+
"hB",
|
|
2387
|
+
"hb"
|
|
2388
|
+
],
|
|
2389
|
+
"GU": [
|
|
2390
|
+
"h",
|
|
2391
|
+
"hb",
|
|
2392
|
+
"H",
|
|
2393
|
+
"hB"
|
|
2394
|
+
],
|
|
2395
|
+
"GW": ["H", "hB"],
|
|
2396
|
+
"GY": [
|
|
2397
|
+
"h",
|
|
2398
|
+
"hb",
|
|
2399
|
+
"H",
|
|
2400
|
+
"hB"
|
|
2401
|
+
],
|
|
2402
|
+
"HK": [
|
|
2403
|
+
"h",
|
|
2404
|
+
"hB",
|
|
2405
|
+
"hb",
|
|
2406
|
+
"H"
|
|
2407
|
+
],
|
|
2408
|
+
"HN": [
|
|
2409
|
+
"h",
|
|
2410
|
+
"H",
|
|
2411
|
+
"hB",
|
|
2412
|
+
"hb"
|
|
2413
|
+
],
|
|
2414
|
+
"HR": ["H", "hB"],
|
|
2415
|
+
"HU": ["H", "h"],
|
|
2416
|
+
"IC": [
|
|
2417
|
+
"H",
|
|
2418
|
+
"h",
|
|
2419
|
+
"hB",
|
|
2420
|
+
"hb"
|
|
2421
|
+
],
|
|
2422
|
+
"ID": ["H"],
|
|
2423
|
+
"IE": [
|
|
2424
|
+
"H",
|
|
2425
|
+
"h",
|
|
2426
|
+
"hb",
|
|
2427
|
+
"hB"
|
|
2428
|
+
],
|
|
2429
|
+
"IL": ["H", "hB"],
|
|
2430
|
+
"IM": [
|
|
2431
|
+
"H",
|
|
2432
|
+
"h",
|
|
2433
|
+
"hb",
|
|
2434
|
+
"hB"
|
|
2435
|
+
],
|
|
2436
|
+
"IN": ["h", "H"],
|
|
2437
|
+
"IO": [
|
|
2438
|
+
"H",
|
|
2439
|
+
"h",
|
|
2440
|
+
"hb",
|
|
2441
|
+
"hB"
|
|
2442
|
+
],
|
|
2443
|
+
"IQ": [
|
|
2444
|
+
"h",
|
|
2445
|
+
"hB",
|
|
2446
|
+
"hb",
|
|
2447
|
+
"H"
|
|
2448
|
+
],
|
|
2449
|
+
"IR": ["hB", "H"],
|
|
2450
|
+
"IS": ["H"],
|
|
2451
|
+
"IT": ["H", "hB"],
|
|
2452
|
+
"JE": [
|
|
2453
|
+
"H",
|
|
2454
|
+
"h",
|
|
2455
|
+
"hb",
|
|
2456
|
+
"hB"
|
|
2457
|
+
],
|
|
2458
|
+
"JM": [
|
|
2459
|
+
"h",
|
|
2460
|
+
"hb",
|
|
2461
|
+
"H",
|
|
2462
|
+
"hB"
|
|
2463
|
+
],
|
|
2464
|
+
"JO": [
|
|
2465
|
+
"h",
|
|
2466
|
+
"hB",
|
|
2467
|
+
"hb",
|
|
2468
|
+
"H"
|
|
2469
|
+
],
|
|
2470
|
+
"JP": [
|
|
2471
|
+
"H",
|
|
2472
|
+
"K",
|
|
2473
|
+
"h"
|
|
2474
|
+
],
|
|
2475
|
+
"KE": [
|
|
2476
|
+
"hB",
|
|
2477
|
+
"hb",
|
|
2478
|
+
"H",
|
|
2479
|
+
"h"
|
|
2480
|
+
],
|
|
2481
|
+
"KG": [
|
|
2482
|
+
"H",
|
|
2483
|
+
"h",
|
|
2484
|
+
"hB",
|
|
2485
|
+
"hb"
|
|
2486
|
+
],
|
|
2487
|
+
"KH": [
|
|
2488
|
+
"hB",
|
|
2489
|
+
"h",
|
|
2490
|
+
"H",
|
|
2491
|
+
"hb"
|
|
2492
|
+
],
|
|
2493
|
+
"KI": [
|
|
2494
|
+
"h",
|
|
2495
|
+
"hb",
|
|
2496
|
+
"H",
|
|
2497
|
+
"hB"
|
|
2498
|
+
],
|
|
2499
|
+
"KM": [
|
|
2500
|
+
"H",
|
|
2501
|
+
"h",
|
|
2502
|
+
"hB",
|
|
2503
|
+
"hb"
|
|
2504
|
+
],
|
|
2505
|
+
"KN": [
|
|
2506
|
+
"h",
|
|
2507
|
+
"hb",
|
|
2508
|
+
"H",
|
|
2509
|
+
"hB"
|
|
2510
|
+
],
|
|
2511
|
+
"KP": [
|
|
2512
|
+
"h",
|
|
2513
|
+
"H",
|
|
2514
|
+
"hB",
|
|
2515
|
+
"hb"
|
|
2516
|
+
],
|
|
2517
|
+
"KR": [
|
|
2518
|
+
"h",
|
|
2519
|
+
"H",
|
|
2520
|
+
"hB",
|
|
2521
|
+
"hb"
|
|
2522
|
+
],
|
|
2523
|
+
"KW": [
|
|
2524
|
+
"h",
|
|
2525
|
+
"hB",
|
|
2526
|
+
"hb",
|
|
2527
|
+
"H"
|
|
2528
|
+
],
|
|
2529
|
+
"KY": [
|
|
2530
|
+
"h",
|
|
2531
|
+
"hb",
|
|
2532
|
+
"H",
|
|
2533
|
+
"hB"
|
|
2534
|
+
],
|
|
2535
|
+
"KZ": ["H", "hB"],
|
|
2536
|
+
"LA": [
|
|
2537
|
+
"H",
|
|
2538
|
+
"hb",
|
|
2539
|
+
"hB",
|
|
2540
|
+
"h"
|
|
2541
|
+
],
|
|
2542
|
+
"LB": [
|
|
2543
|
+
"h",
|
|
2544
|
+
"hB",
|
|
2545
|
+
"hb",
|
|
2546
|
+
"H"
|
|
2547
|
+
],
|
|
2548
|
+
"LC": [
|
|
2549
|
+
"h",
|
|
2550
|
+
"hb",
|
|
2551
|
+
"H",
|
|
2552
|
+
"hB"
|
|
2553
|
+
],
|
|
2554
|
+
"LI": [
|
|
2555
|
+
"H",
|
|
2556
|
+
"hB",
|
|
2557
|
+
"h"
|
|
2558
|
+
],
|
|
2559
|
+
"LK": [
|
|
2560
|
+
"H",
|
|
2561
|
+
"h",
|
|
2562
|
+
"hB",
|
|
2563
|
+
"hb"
|
|
2564
|
+
],
|
|
2565
|
+
"LR": [
|
|
2566
|
+
"h",
|
|
2567
|
+
"hb",
|
|
2568
|
+
"H",
|
|
2569
|
+
"hB"
|
|
2570
|
+
],
|
|
2571
|
+
"LS": ["h", "H"],
|
|
2572
|
+
"LT": [
|
|
2573
|
+
"H",
|
|
2574
|
+
"h",
|
|
2575
|
+
"hb",
|
|
2576
|
+
"hB"
|
|
2577
|
+
],
|
|
2578
|
+
"LU": [
|
|
2579
|
+
"H",
|
|
2580
|
+
"h",
|
|
2581
|
+
"hB"
|
|
2582
|
+
],
|
|
2583
|
+
"LV": [
|
|
2584
|
+
"H",
|
|
2585
|
+
"hB",
|
|
2586
|
+
"hb",
|
|
2587
|
+
"h"
|
|
2588
|
+
],
|
|
2589
|
+
"LY": [
|
|
2590
|
+
"h",
|
|
2591
|
+
"hB",
|
|
2592
|
+
"hb",
|
|
2593
|
+
"H"
|
|
2594
|
+
],
|
|
2595
|
+
"MA": [
|
|
2596
|
+
"H",
|
|
2597
|
+
"h",
|
|
2598
|
+
"hB",
|
|
2599
|
+
"hb"
|
|
2600
|
+
],
|
|
2601
|
+
"MC": ["H", "hB"],
|
|
2602
|
+
"MD": ["H", "hB"],
|
|
2603
|
+
"ME": [
|
|
2604
|
+
"H",
|
|
2605
|
+
"hB",
|
|
2606
|
+
"h"
|
|
2607
|
+
],
|
|
2608
|
+
"MF": ["H", "hB"],
|
|
2609
|
+
"MG": ["H", "h"],
|
|
2610
|
+
"MH": [
|
|
2611
|
+
"h",
|
|
2612
|
+
"hb",
|
|
2613
|
+
"H",
|
|
2614
|
+
"hB"
|
|
2615
|
+
],
|
|
2616
|
+
"MK": [
|
|
2617
|
+
"H",
|
|
2618
|
+
"h",
|
|
2619
|
+
"hb",
|
|
2620
|
+
"hB"
|
|
2621
|
+
],
|
|
2622
|
+
"ML": ["H"],
|
|
2623
|
+
"MM": [
|
|
2624
|
+
"hB",
|
|
2625
|
+
"hb",
|
|
2626
|
+
"H",
|
|
2627
|
+
"h"
|
|
2628
|
+
],
|
|
2629
|
+
"MN": [
|
|
2630
|
+
"H",
|
|
2631
|
+
"h",
|
|
2632
|
+
"hb",
|
|
2633
|
+
"hB"
|
|
2634
|
+
],
|
|
2635
|
+
"MO": [
|
|
2636
|
+
"h",
|
|
2637
|
+
"hB",
|
|
2638
|
+
"hb",
|
|
2639
|
+
"H"
|
|
2640
|
+
],
|
|
2641
|
+
"MP": [
|
|
2642
|
+
"h",
|
|
2643
|
+
"hb",
|
|
2644
|
+
"H",
|
|
2645
|
+
"hB"
|
|
2646
|
+
],
|
|
2647
|
+
"MQ": ["H", "hB"],
|
|
2648
|
+
"MR": [
|
|
2649
|
+
"h",
|
|
2650
|
+
"hB",
|
|
2651
|
+
"hb",
|
|
2652
|
+
"H"
|
|
2653
|
+
],
|
|
2654
|
+
"MS": [
|
|
2655
|
+
"H",
|
|
2656
|
+
"h",
|
|
2657
|
+
"hb",
|
|
2658
|
+
"hB"
|
|
2659
|
+
],
|
|
2660
|
+
"MT": ["H", "h"],
|
|
2661
|
+
"MU": ["H", "h"],
|
|
2662
|
+
"MV": ["H", "h"],
|
|
2663
|
+
"MW": [
|
|
2664
|
+
"h",
|
|
2665
|
+
"hb",
|
|
2666
|
+
"H",
|
|
2667
|
+
"hB"
|
|
2668
|
+
],
|
|
2669
|
+
"MX": [
|
|
2670
|
+
"h",
|
|
2671
|
+
"H",
|
|
2672
|
+
"hB",
|
|
2673
|
+
"hb"
|
|
2674
|
+
],
|
|
2675
|
+
"MY": [
|
|
2676
|
+
"hb",
|
|
2677
|
+
"hB",
|
|
2678
|
+
"h",
|
|
2679
|
+
"H"
|
|
2680
|
+
],
|
|
2681
|
+
"MZ": ["H", "hB"],
|
|
2682
|
+
"NA": [
|
|
2683
|
+
"h",
|
|
2684
|
+
"H",
|
|
2685
|
+
"hB",
|
|
2686
|
+
"hb"
|
|
2687
|
+
],
|
|
2688
|
+
"NC": ["H", "hB"],
|
|
2689
|
+
"NE": ["H"],
|
|
2690
|
+
"NF": [
|
|
2691
|
+
"H",
|
|
2692
|
+
"h",
|
|
2693
|
+
"hb",
|
|
2694
|
+
"hB"
|
|
2695
|
+
],
|
|
2696
|
+
"NG": [
|
|
2697
|
+
"H",
|
|
2698
|
+
"h",
|
|
2699
|
+
"hb",
|
|
2700
|
+
"hB"
|
|
2701
|
+
],
|
|
2702
|
+
"NI": [
|
|
2703
|
+
"h",
|
|
2704
|
+
"H",
|
|
2705
|
+
"hB",
|
|
2706
|
+
"hb"
|
|
2707
|
+
],
|
|
2708
|
+
"NL": ["H", "hB"],
|
|
2709
|
+
"NO": ["H", "h"],
|
|
2710
|
+
"NP": [
|
|
2711
|
+
"H",
|
|
2712
|
+
"h",
|
|
2713
|
+
"hB"
|
|
2714
|
+
],
|
|
2715
|
+
"NR": [
|
|
2716
|
+
"H",
|
|
2717
|
+
"h",
|
|
2718
|
+
"hb",
|
|
2719
|
+
"hB"
|
|
2720
|
+
],
|
|
2721
|
+
"NU": [
|
|
2722
|
+
"H",
|
|
2723
|
+
"h",
|
|
2724
|
+
"hb",
|
|
2725
|
+
"hB"
|
|
2726
|
+
],
|
|
2727
|
+
"NZ": [
|
|
2728
|
+
"h",
|
|
2729
|
+
"hb",
|
|
2730
|
+
"H",
|
|
2731
|
+
"hB"
|
|
2732
|
+
],
|
|
2733
|
+
"OM": [
|
|
2734
|
+
"h",
|
|
2735
|
+
"hB",
|
|
2736
|
+
"hb",
|
|
2737
|
+
"H"
|
|
2738
|
+
],
|
|
2739
|
+
"PA": [
|
|
2740
|
+
"h",
|
|
2741
|
+
"H",
|
|
2742
|
+
"hB",
|
|
2743
|
+
"hb"
|
|
2744
|
+
],
|
|
2745
|
+
"PE": [
|
|
2746
|
+
"h",
|
|
2747
|
+
"H",
|
|
2748
|
+
"hB",
|
|
2749
|
+
"hb"
|
|
2750
|
+
],
|
|
2751
|
+
"PF": [
|
|
2752
|
+
"H",
|
|
2753
|
+
"h",
|
|
2754
|
+
"hB"
|
|
2755
|
+
],
|
|
2756
|
+
"PG": ["h", "H"],
|
|
2757
|
+
"PH": [
|
|
2758
|
+
"h",
|
|
2759
|
+
"hB",
|
|
2760
|
+
"hb",
|
|
2761
|
+
"H"
|
|
2762
|
+
],
|
|
2763
|
+
"PK": [
|
|
2764
|
+
"h",
|
|
2765
|
+
"hB",
|
|
2766
|
+
"H"
|
|
2767
|
+
],
|
|
2768
|
+
"PL": ["H", "h"],
|
|
2769
|
+
"PM": ["H", "hB"],
|
|
2770
|
+
"PN": [
|
|
2771
|
+
"H",
|
|
2772
|
+
"h",
|
|
2773
|
+
"hb",
|
|
2774
|
+
"hB"
|
|
2775
|
+
],
|
|
2776
|
+
"PR": [
|
|
2777
|
+
"h",
|
|
2778
|
+
"H",
|
|
2779
|
+
"hB",
|
|
2780
|
+
"hb"
|
|
2781
|
+
],
|
|
2782
|
+
"PS": [
|
|
2783
|
+
"h",
|
|
2784
|
+
"hB",
|
|
2785
|
+
"hb",
|
|
2786
|
+
"H"
|
|
2787
|
+
],
|
|
2788
|
+
"PT": ["H", "hB"],
|
|
2789
|
+
"PW": ["h", "H"],
|
|
2790
|
+
"PY": [
|
|
2791
|
+
"h",
|
|
2792
|
+
"H",
|
|
2793
|
+
"hB",
|
|
2794
|
+
"hb"
|
|
2795
|
+
],
|
|
2796
|
+
"QA": [
|
|
2797
|
+
"h",
|
|
2798
|
+
"hB",
|
|
2799
|
+
"hb",
|
|
2800
|
+
"H"
|
|
2801
|
+
],
|
|
2802
|
+
"RE": ["H", "hB"],
|
|
2803
|
+
"RO": ["H", "hB"],
|
|
2804
|
+
"RS": [
|
|
2805
|
+
"H",
|
|
2806
|
+
"hB",
|
|
2807
|
+
"h"
|
|
2808
|
+
],
|
|
2809
|
+
"RU": ["H"],
|
|
2810
|
+
"RW": ["H", "h"],
|
|
2811
|
+
"SA": [
|
|
2812
|
+
"h",
|
|
2813
|
+
"hB",
|
|
2814
|
+
"hb",
|
|
2815
|
+
"H"
|
|
2816
|
+
],
|
|
2817
|
+
"SB": [
|
|
2818
|
+
"h",
|
|
2819
|
+
"hb",
|
|
2820
|
+
"H",
|
|
2821
|
+
"hB"
|
|
2822
|
+
],
|
|
2823
|
+
"SC": [
|
|
2824
|
+
"H",
|
|
2825
|
+
"h",
|
|
2826
|
+
"hB"
|
|
2827
|
+
],
|
|
2828
|
+
"SD": [
|
|
2829
|
+
"h",
|
|
2830
|
+
"hB",
|
|
2831
|
+
"hb",
|
|
2832
|
+
"H"
|
|
2833
|
+
],
|
|
2834
|
+
"SE": ["H"],
|
|
2835
|
+
"SG": [
|
|
2836
|
+
"h",
|
|
2837
|
+
"hb",
|
|
2838
|
+
"H",
|
|
2839
|
+
"hB"
|
|
2840
|
+
],
|
|
2841
|
+
"SH": [
|
|
2842
|
+
"H",
|
|
2843
|
+
"h",
|
|
2844
|
+
"hb",
|
|
2845
|
+
"hB"
|
|
2846
|
+
],
|
|
2847
|
+
"SI": ["H", "hB"],
|
|
2848
|
+
"SJ": ["H"],
|
|
2849
|
+
"SK": ["H"],
|
|
2850
|
+
"SL": [
|
|
2851
|
+
"h",
|
|
2852
|
+
"hb",
|
|
2853
|
+
"H",
|
|
2854
|
+
"hB"
|
|
2855
|
+
],
|
|
2856
|
+
"SM": [
|
|
2857
|
+
"H",
|
|
2858
|
+
"h",
|
|
2859
|
+
"hB"
|
|
2860
|
+
],
|
|
2861
|
+
"SN": [
|
|
2862
|
+
"H",
|
|
2863
|
+
"h",
|
|
2864
|
+
"hB"
|
|
2865
|
+
],
|
|
2866
|
+
"SO": ["h", "H"],
|
|
2867
|
+
"SR": ["H", "hB"],
|
|
2868
|
+
"SS": [
|
|
2869
|
+
"h",
|
|
2870
|
+
"hb",
|
|
2871
|
+
"H",
|
|
2872
|
+
"hB"
|
|
2873
|
+
],
|
|
2874
|
+
"ST": ["H", "hB"],
|
|
2875
|
+
"SV": [
|
|
2876
|
+
"h",
|
|
2877
|
+
"H",
|
|
2878
|
+
"hB",
|
|
2879
|
+
"hb"
|
|
2880
|
+
],
|
|
2881
|
+
"SX": [
|
|
2882
|
+
"H",
|
|
2883
|
+
"h",
|
|
2884
|
+
"hb",
|
|
2885
|
+
"hB"
|
|
2886
|
+
],
|
|
2887
|
+
"SY": [
|
|
2888
|
+
"h",
|
|
2889
|
+
"hB",
|
|
2890
|
+
"hb",
|
|
2891
|
+
"H"
|
|
2892
|
+
],
|
|
2893
|
+
"SZ": [
|
|
2894
|
+
"h",
|
|
2895
|
+
"hb",
|
|
2896
|
+
"H",
|
|
2897
|
+
"hB"
|
|
2898
|
+
],
|
|
2899
|
+
"TA": [
|
|
2900
|
+
"H",
|
|
2901
|
+
"h",
|
|
2902
|
+
"hb",
|
|
2903
|
+
"hB"
|
|
2904
|
+
],
|
|
2905
|
+
"TC": [
|
|
2906
|
+
"h",
|
|
2907
|
+
"hb",
|
|
2908
|
+
"H",
|
|
2909
|
+
"hB"
|
|
2910
|
+
],
|
|
2911
|
+
"TD": [
|
|
2912
|
+
"h",
|
|
2913
|
+
"H",
|
|
2914
|
+
"hB"
|
|
2915
|
+
],
|
|
2916
|
+
"TF": [
|
|
2917
|
+
"H",
|
|
2918
|
+
"h",
|
|
2919
|
+
"hB"
|
|
2920
|
+
],
|
|
2921
|
+
"TG": ["H", "hB"],
|
|
2922
|
+
"TH": ["H", "h"],
|
|
2923
|
+
"TJ": ["H", "h"],
|
|
2924
|
+
"TL": [
|
|
2925
|
+
"H",
|
|
2926
|
+
"hB",
|
|
2927
|
+
"hb",
|
|
2928
|
+
"h"
|
|
2929
|
+
],
|
|
2930
|
+
"TM": ["H", "h"],
|
|
2931
|
+
"TN": [
|
|
2932
|
+
"h",
|
|
2933
|
+
"hB",
|
|
2934
|
+
"hb",
|
|
2935
|
+
"H"
|
|
2936
|
+
],
|
|
2937
|
+
"TO": ["h", "H"],
|
|
2938
|
+
"TR": ["H", "hB"],
|
|
2939
|
+
"TT": [
|
|
2940
|
+
"h",
|
|
2941
|
+
"hb",
|
|
2942
|
+
"H",
|
|
2943
|
+
"hB"
|
|
2944
|
+
],
|
|
2945
|
+
"TW": [
|
|
2946
|
+
"hB",
|
|
2947
|
+
"hb",
|
|
2948
|
+
"h",
|
|
2949
|
+
"H"
|
|
2950
|
+
],
|
|
2951
|
+
"TZ": [
|
|
2952
|
+
"hB",
|
|
2953
|
+
"hb",
|
|
2954
|
+
"H",
|
|
2955
|
+
"h"
|
|
2956
|
+
],
|
|
2957
|
+
"UA": [
|
|
2958
|
+
"H",
|
|
2959
|
+
"hB",
|
|
2960
|
+
"h"
|
|
2961
|
+
],
|
|
2962
|
+
"UG": [
|
|
2963
|
+
"hB",
|
|
2964
|
+
"hb",
|
|
2965
|
+
"H",
|
|
2966
|
+
"h"
|
|
2967
|
+
],
|
|
2968
|
+
"UM": [
|
|
2969
|
+
"h",
|
|
2970
|
+
"hb",
|
|
2971
|
+
"H",
|
|
2972
|
+
"hB"
|
|
2973
|
+
],
|
|
2974
|
+
"US": [
|
|
2975
|
+
"h",
|
|
2976
|
+
"hb",
|
|
2977
|
+
"H",
|
|
2978
|
+
"hB"
|
|
2979
|
+
],
|
|
2980
|
+
"UY": [
|
|
2981
|
+
"h",
|
|
2982
|
+
"H",
|
|
2983
|
+
"hB",
|
|
2984
|
+
"hb"
|
|
2985
|
+
],
|
|
2986
|
+
"UZ": [
|
|
2987
|
+
"H",
|
|
2988
|
+
"hB",
|
|
2989
|
+
"h"
|
|
2990
|
+
],
|
|
2991
|
+
"VA": [
|
|
2992
|
+
"H",
|
|
2993
|
+
"h",
|
|
2994
|
+
"hB"
|
|
2995
|
+
],
|
|
2996
|
+
"VC": [
|
|
2997
|
+
"h",
|
|
2998
|
+
"hb",
|
|
2999
|
+
"H",
|
|
3000
|
+
"hB"
|
|
3001
|
+
],
|
|
3002
|
+
"VE": [
|
|
3003
|
+
"h",
|
|
3004
|
+
"H",
|
|
3005
|
+
"hB",
|
|
3006
|
+
"hb"
|
|
3007
|
+
],
|
|
3008
|
+
"VG": [
|
|
3009
|
+
"h",
|
|
3010
|
+
"hb",
|
|
3011
|
+
"H",
|
|
3012
|
+
"hB"
|
|
3013
|
+
],
|
|
3014
|
+
"VI": [
|
|
3015
|
+
"h",
|
|
3016
|
+
"hb",
|
|
3017
|
+
"H",
|
|
3018
|
+
"hB"
|
|
3019
|
+
],
|
|
3020
|
+
"VN": ["H", "h"],
|
|
3021
|
+
"VU": ["h", "H"],
|
|
3022
|
+
"WF": ["H", "hB"],
|
|
3023
|
+
"WS": ["h", "H"],
|
|
3024
|
+
"XK": [
|
|
3025
|
+
"H",
|
|
3026
|
+
"hB",
|
|
3027
|
+
"h"
|
|
3028
|
+
],
|
|
3029
|
+
"YE": [
|
|
3030
|
+
"h",
|
|
3031
|
+
"hB",
|
|
3032
|
+
"hb",
|
|
3033
|
+
"H"
|
|
3034
|
+
],
|
|
3035
|
+
"YT": ["H", "hB"],
|
|
3036
|
+
"ZA": [
|
|
3037
|
+
"H",
|
|
3038
|
+
"h",
|
|
3039
|
+
"hb",
|
|
3040
|
+
"hB"
|
|
3041
|
+
],
|
|
3042
|
+
"ZM": [
|
|
3043
|
+
"h",
|
|
3044
|
+
"hb",
|
|
3045
|
+
"H",
|
|
3046
|
+
"hB"
|
|
3047
|
+
],
|
|
3048
|
+
"ZW": ["H", "h"],
|
|
3049
|
+
"af-ZA": [
|
|
3050
|
+
"H",
|
|
3051
|
+
"h",
|
|
3052
|
+
"hB",
|
|
3053
|
+
"hb"
|
|
3054
|
+
],
|
|
3055
|
+
"ar-001": [
|
|
3056
|
+
"h",
|
|
3057
|
+
"hB",
|
|
3058
|
+
"hb",
|
|
3059
|
+
"H"
|
|
3060
|
+
],
|
|
3061
|
+
"ca-ES": [
|
|
3062
|
+
"H",
|
|
3063
|
+
"h",
|
|
3064
|
+
"hB"
|
|
3065
|
+
],
|
|
3066
|
+
"en-001": [
|
|
3067
|
+
"h",
|
|
3068
|
+
"hb",
|
|
3069
|
+
"H",
|
|
3070
|
+
"hB"
|
|
3071
|
+
],
|
|
3072
|
+
"en-HK": [
|
|
3073
|
+
"h",
|
|
3074
|
+
"hb",
|
|
3075
|
+
"H",
|
|
3076
|
+
"hB"
|
|
3077
|
+
],
|
|
3078
|
+
"en-IL": [
|
|
3079
|
+
"H",
|
|
3080
|
+
"h",
|
|
3081
|
+
"hb",
|
|
3082
|
+
"hB"
|
|
3083
|
+
],
|
|
3084
|
+
"en-MY": [
|
|
3085
|
+
"h",
|
|
3086
|
+
"hb",
|
|
3087
|
+
"H",
|
|
3088
|
+
"hB"
|
|
3089
|
+
],
|
|
3090
|
+
"es-BR": [
|
|
3091
|
+
"H",
|
|
3092
|
+
"h",
|
|
3093
|
+
"hB",
|
|
3094
|
+
"hb"
|
|
3095
|
+
],
|
|
3096
|
+
"es-ES": [
|
|
3097
|
+
"H",
|
|
3098
|
+
"h",
|
|
3099
|
+
"hB",
|
|
3100
|
+
"hb"
|
|
3101
|
+
],
|
|
3102
|
+
"es-GQ": [
|
|
3103
|
+
"H",
|
|
3104
|
+
"h",
|
|
3105
|
+
"hB",
|
|
3106
|
+
"hb"
|
|
3107
|
+
],
|
|
3108
|
+
"fr-CA": [
|
|
3109
|
+
"H",
|
|
3110
|
+
"h",
|
|
3111
|
+
"hB"
|
|
3112
|
+
],
|
|
3113
|
+
"gl-ES": [
|
|
3114
|
+
"H",
|
|
3115
|
+
"h",
|
|
3116
|
+
"hB"
|
|
3117
|
+
],
|
|
3118
|
+
"gu-IN": [
|
|
3119
|
+
"hB",
|
|
3120
|
+
"hb",
|
|
3121
|
+
"h",
|
|
3122
|
+
"H"
|
|
3123
|
+
],
|
|
3124
|
+
"hi-IN": [
|
|
3125
|
+
"hB",
|
|
3126
|
+
"h",
|
|
3127
|
+
"H"
|
|
3128
|
+
],
|
|
3129
|
+
"it-CH": [
|
|
3130
|
+
"H",
|
|
3131
|
+
"h",
|
|
3132
|
+
"hB"
|
|
3133
|
+
],
|
|
3134
|
+
"it-IT": [
|
|
3135
|
+
"H",
|
|
3136
|
+
"h",
|
|
3137
|
+
"hB"
|
|
3138
|
+
],
|
|
3139
|
+
"kn-IN": [
|
|
3140
|
+
"hB",
|
|
3141
|
+
"h",
|
|
3142
|
+
"H"
|
|
3143
|
+
],
|
|
3144
|
+
"ku-SY": ["H", "hB"],
|
|
3145
|
+
"ml-IN": [
|
|
3146
|
+
"hB",
|
|
3147
|
+
"h",
|
|
3148
|
+
"H"
|
|
3149
|
+
],
|
|
3150
|
+
"mr-IN": [
|
|
3151
|
+
"hB",
|
|
3152
|
+
"hb",
|
|
3153
|
+
"h",
|
|
3154
|
+
"H"
|
|
3155
|
+
],
|
|
3156
|
+
"pa-IN": [
|
|
3157
|
+
"hB",
|
|
3158
|
+
"hb",
|
|
3159
|
+
"h",
|
|
3160
|
+
"H"
|
|
3161
|
+
],
|
|
3162
|
+
"ta-IN": [
|
|
3163
|
+
"hB",
|
|
3164
|
+
"h",
|
|
3165
|
+
"hb",
|
|
3166
|
+
"H"
|
|
3167
|
+
],
|
|
3168
|
+
"te-IN": [
|
|
3169
|
+
"hB",
|
|
3170
|
+
"h",
|
|
3171
|
+
"H"
|
|
3172
|
+
],
|
|
3173
|
+
"zu-ZA": [
|
|
3174
|
+
"H",
|
|
3175
|
+
"hB",
|
|
3176
|
+
"hb",
|
|
3177
|
+
"h"
|
|
3178
|
+
]
|
|
3179
|
+
};
|
|
3180
|
+
/**
|
|
3181
|
+
* Returns the best matching date time pattern if a date time skeleton
|
|
3182
|
+
* pattern is provided with a locale. Follows the Unicode specification:
|
|
3183
|
+
* https://www.unicode.org/reports/tr35/tr35-dates.html#table-mapping-requested-time-skeletons-to-patterns
|
|
3184
|
+
* @param skeleton date time skeleton pattern that possibly includes j, J or C
|
|
3185
|
+
* @param locale
|
|
3186
|
+
*/
|
|
3187
|
+
function getBestPattern(skeleton, locale) {
|
|
3188
|
+
let skeletonCopy = "";
|
|
3189
|
+
for (let patternPos = 0; patternPos < skeleton.length; patternPos++) {
|
|
3190
|
+
const patternChar = skeleton.charAt(patternPos);
|
|
3191
|
+
if (patternChar === "j") {
|
|
3192
|
+
let extraLength = 0;
|
|
3193
|
+
while (patternPos + 1 < skeleton.length && skeleton.charAt(patternPos + 1) === patternChar) {
|
|
3194
|
+
extraLength++;
|
|
3195
|
+
patternPos++;
|
|
3196
|
+
}
|
|
3197
|
+
let hourLen = 1 + (extraLength & 1);
|
|
3198
|
+
let dayPeriodLen = extraLength < 2 ? 1 : 3 + (extraLength >> 1);
|
|
3199
|
+
let dayPeriodChar = "a";
|
|
3200
|
+
let hourChar = getDefaultHourSymbolFromLocale(locale);
|
|
3201
|
+
if (hourChar == "H" || hourChar == "k") dayPeriodLen = 0;
|
|
3202
|
+
while (dayPeriodLen-- > 0) skeletonCopy += dayPeriodChar;
|
|
3203
|
+
while (hourLen-- > 0) skeletonCopy = hourChar + skeletonCopy;
|
|
3204
|
+
} else if (patternChar === "J") skeletonCopy += "H";
|
|
3205
|
+
else skeletonCopy += patternChar;
|
|
3206
|
+
}
|
|
3207
|
+
return skeletonCopy;
|
|
3208
|
+
}
|
|
3209
|
+
/**
|
|
3210
|
+
* Maps the [hour cycle type](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/hourCycle)
|
|
3211
|
+
* of the given `locale` to the corresponding time pattern.
|
|
3212
|
+
* @param locale
|
|
3213
|
+
*/
|
|
3214
|
+
function getDefaultHourSymbolFromLocale(locale) {
|
|
3215
|
+
let hourCycle = locale.hourCycle;
|
|
3216
|
+
if (hourCycle === void 0) {
|
|
3217
|
+
const localeWithHourCycles = locale;
|
|
3218
|
+
hourCycle = localeWithHourCycles.getHourCycles?.()[0] ?? localeWithHourCycles.hourCycles?.[0];
|
|
3219
|
+
}
|
|
3220
|
+
if (hourCycle) switch (hourCycle) {
|
|
3221
|
+
case "h24": return "k";
|
|
3222
|
+
case "h23": return "H";
|
|
3223
|
+
case "h12": return "h";
|
|
3224
|
+
case "h11": return "K";
|
|
3225
|
+
default: throw new Error("Invalid hourCycle");
|
|
3226
|
+
}
|
|
3227
|
+
const languageTag = locale.language;
|
|
3228
|
+
let regionTag;
|
|
3229
|
+
if (languageTag !== "root") regionTag = locale.maximize().region;
|
|
3230
|
+
return (timeData[`${languageTag}-${regionTag}`] || timeData[regionTag || ""] || timeData[languageTag || ""] || timeData[`${languageTag}-001`] || timeData["001"])[0].charAt(0);
|
|
3231
|
+
}
|
|
3232
|
+
const SPACE_SEPARATOR_START_REGEX = new RegExp(`^${SPACE_SEPARATOR_REGEX.source}*`);
|
|
3233
|
+
const SPACE_SEPARATOR_END_REGEX = new RegExp(`${SPACE_SEPARATOR_REGEX.source}*$`);
|
|
3234
|
+
function createLocation(start, end) {
|
|
3235
|
+
return {
|
|
3236
|
+
start,
|
|
3237
|
+
end
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
const hasNativeFromEntries = !!Object.fromEntries;
|
|
3241
|
+
const hasTrimStart = !!String.prototype.trimStart;
|
|
3242
|
+
const hasTrimEnd = !!String.prototype.trimEnd;
|
|
3243
|
+
const fromEntries = hasNativeFromEntries ? Object.fromEntries : function fromEntries(entries) {
|
|
3244
|
+
const obj = {};
|
|
3245
|
+
for (const [k, v] of entries) obj[k] = v;
|
|
3246
|
+
return obj;
|
|
3247
|
+
};
|
|
3248
|
+
const trimStart = hasTrimStart ? function trimStart(s) {
|
|
3249
|
+
return s.trimStart();
|
|
3250
|
+
} : function trimStart(s) {
|
|
3251
|
+
return s.replace(SPACE_SEPARATOR_START_REGEX, "");
|
|
3252
|
+
};
|
|
3253
|
+
const trimEnd = hasTrimEnd ? function trimEnd(s) {
|
|
3254
|
+
return s.trimEnd();
|
|
3255
|
+
} : function trimEnd(s) {
|
|
3256
|
+
return s.replace(SPACE_SEPARATOR_END_REGEX, "");
|
|
3257
|
+
};
|
|
3258
|
+
let IDENTIFIER_PREFIX_RE = createIdentifierPrefixRegex();
|
|
3259
|
+
function createIdentifierPrefixRegex() {
|
|
3260
|
+
try {
|
|
3261
|
+
const regex = /* @__PURE__ */ new RegExp("([^\\p{White_Space}\\p{Pattern_Syntax}]*)", "yu");
|
|
3262
|
+
if (regex.exec("a ")?.[1] === "a") return regex;
|
|
3263
|
+
} catch {}
|
|
3264
|
+
return IDENTIFIER_PREFIX_REGEX;
|
|
3265
|
+
}
|
|
3266
|
+
function matchIdentifierAtIndex(s, index) {
|
|
3267
|
+
IDENTIFIER_PREFIX_RE.lastIndex = index;
|
|
3268
|
+
return IDENTIFIER_PREFIX_RE.exec(s)[1] ?? "";
|
|
3269
|
+
}
|
|
3270
|
+
function plainTopLevelEndPosition(message) {
|
|
3271
|
+
if (message.length === 0) return null;
|
|
3272
|
+
let line = 1;
|
|
3273
|
+
let column = 1;
|
|
3274
|
+
for (let offset = 0; offset < message.length;) {
|
|
3275
|
+
const code = message.charCodeAt(offset);
|
|
3276
|
+
switch (code) {
|
|
3277
|
+
case 35:
|
|
3278
|
+
case 39:
|
|
3279
|
+
case 60:
|
|
3280
|
+
case 123:
|
|
3281
|
+
case 125: return null;
|
|
3282
|
+
}
|
|
3283
|
+
if (code === 10) {
|
|
3284
|
+
line++;
|
|
3285
|
+
column = 1;
|
|
3286
|
+
offset++;
|
|
3287
|
+
} else {
|
|
3288
|
+
column++;
|
|
3289
|
+
if (code >= 55296 && code <= 56319 && offset + 1 < message.length) {
|
|
3290
|
+
const next = message.charCodeAt(offset + 1);
|
|
3291
|
+
offset += next >= 56320 && next <= 57343 ? 2 : 1;
|
|
3292
|
+
} else offset++;
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
3295
|
+
return {
|
|
3296
|
+
offset: message.length,
|
|
3297
|
+
line,
|
|
3298
|
+
column
|
|
3299
|
+
};
|
|
3300
|
+
}
|
|
3301
|
+
var Parser = class {
|
|
3302
|
+
constructor(message, options = {}) {
|
|
3303
|
+
this.message = message;
|
|
3304
|
+
this.position = {
|
|
3305
|
+
offset: 0,
|
|
3306
|
+
line: 1,
|
|
3307
|
+
column: 1
|
|
3308
|
+
};
|
|
3309
|
+
this.ignoreTag = !!options.ignoreTag;
|
|
3310
|
+
this.locale = options.locale;
|
|
3311
|
+
this.requiresOtherClause = !!options.requiresOtherClause;
|
|
3312
|
+
this.shouldParseSkeletons = !!options.shouldParseSkeletons;
|
|
3313
|
+
}
|
|
3314
|
+
parse() {
|
|
3315
|
+
if (this.offset() !== 0) throw Error("parser can only be used once");
|
|
3316
|
+
if (this.message.length > 0) {
|
|
3317
|
+
const firstCode = this.message.charCodeAt(0);
|
|
3318
|
+
if (firstCode !== 35 && firstCode !== 39 && firstCode !== 60 && firstCode !== 123 && firstCode !== 125) {
|
|
3319
|
+
const plainEndPosition = plainTopLevelEndPosition(this.message);
|
|
3320
|
+
if (plainEndPosition) {
|
|
3321
|
+
const start = this.clonePosition();
|
|
3322
|
+
this.position = plainEndPosition;
|
|
3323
|
+
return {
|
|
3324
|
+
val: [{
|
|
3325
|
+
type: 0,
|
|
3326
|
+
value: this.message,
|
|
3327
|
+
location: createLocation(start, this.clonePosition())
|
|
3328
|
+
}],
|
|
3329
|
+
err: null
|
|
3330
|
+
};
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
return this.parseMessage(0, "", false);
|
|
3335
|
+
}
|
|
3336
|
+
parseMessage(nestingLevel, parentArgType, expectingCloseTag) {
|
|
3337
|
+
let elements = [];
|
|
3338
|
+
while (!this.isEOF()) {
|
|
3339
|
+
const char = this.char();
|
|
3340
|
+
if (char === 123) {
|
|
3341
|
+
const result = this.parseArgument(nestingLevel, expectingCloseTag);
|
|
3342
|
+
if (result.err) return result;
|
|
3343
|
+
elements.push(result.val);
|
|
3344
|
+
} else if (char === 125 && nestingLevel > 0) break;
|
|
3345
|
+
else if (char === 35 && (parentArgType === "plural" || parentArgType === "selectordinal")) {
|
|
3346
|
+
const position = this.clonePosition();
|
|
3347
|
+
this.bump();
|
|
3348
|
+
elements.push({
|
|
3349
|
+
type: 7,
|
|
3350
|
+
location: createLocation(position, this.clonePosition())
|
|
3351
|
+
});
|
|
3352
|
+
} else if (char === 60 && !this.ignoreTag && this.peek() === 47) {
|
|
3353
|
+
if (expectingCloseTag) break;
|
|
3354
|
+
else return this.error(26, createLocation(this.clonePosition(), this.clonePosition()));
|
|
3355
|
+
} else if (char === 60 && !this.ignoreTag && _isAlpha(this.peek() || 0)) {
|
|
3356
|
+
const result = this.parseTag(nestingLevel, parentArgType);
|
|
3357
|
+
if (result.err) return result;
|
|
3358
|
+
elements.push(result.val);
|
|
3359
|
+
} else {
|
|
3360
|
+
const result = this.parseLiteral(nestingLevel, parentArgType);
|
|
3361
|
+
if (result.err) return result;
|
|
3362
|
+
elements.push(result.val);
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
return {
|
|
3366
|
+
val: elements,
|
|
3367
|
+
err: null
|
|
3368
|
+
};
|
|
3369
|
+
}
|
|
3370
|
+
/**
|
|
3371
|
+
* A tag name must start with an ASCII lower/upper case letter. The grammar is based on the
|
|
3372
|
+
* [custom element name][] except that a dash is NOT always mandatory and uppercase letters
|
|
3373
|
+
* are accepted:
|
|
3374
|
+
*
|
|
3375
|
+
* ```
|
|
3376
|
+
* tag ::= "<" tagName (whitespace)* "/>" | "<" tagName (whitespace)* ">" message "</" tagName (whitespace)* ">"
|
|
3377
|
+
* tagName ::= [a-z] (PENChar)*
|
|
3378
|
+
* PENChar ::=
|
|
3379
|
+
* "-" | "." | [0-9] | "_" | [a-z] | [A-Z] | #xB7 | [#xC0-#xD6] | [#xD8-#xF6] | [#xF8-#x37D] |
|
|
3380
|
+
* [#x37F-#x1FFF] | [#x200C-#x200D] | [#x203F-#x2040] | [#x2070-#x218F] | [#x2C00-#x2FEF] |
|
|
3381
|
+
* [#x3001-#xD7FF] | [#xF900-#xFDCF] | [#xFDF0-#xFFFD] | [#x10000-#xEFFFF]
|
|
3382
|
+
* ```
|
|
3383
|
+
*
|
|
3384
|
+
* [custom element name]: https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
|
|
3385
|
+
* NOTE: We're a bit more lax here since HTML technically does not allow uppercase HTML element but we do
|
|
3386
|
+
* since other tag-based engines like React allow it
|
|
3387
|
+
*/
|
|
3388
|
+
parseTag(nestingLevel, parentArgType) {
|
|
3389
|
+
const startPosition = this.clonePosition();
|
|
3390
|
+
this.bump();
|
|
3391
|
+
const tagName = this.parseTagName();
|
|
3392
|
+
this.bumpSpace();
|
|
3393
|
+
if (this.bumpIf("/>")) return {
|
|
3394
|
+
val: {
|
|
3395
|
+
type: 0,
|
|
3396
|
+
value: `<${tagName}/>`,
|
|
3397
|
+
location: createLocation(startPosition, this.clonePosition())
|
|
3398
|
+
},
|
|
3399
|
+
err: null
|
|
3400
|
+
};
|
|
3401
|
+
else if (this.bumpIf(">")) {
|
|
3402
|
+
const childrenResult = this.parseMessage(nestingLevel + 1, parentArgType, true);
|
|
3403
|
+
if (childrenResult.err) return childrenResult;
|
|
3404
|
+
const children = childrenResult.val;
|
|
3405
|
+
const endTagStartPosition = this.clonePosition();
|
|
3406
|
+
if (this.bumpIf("</")) {
|
|
3407
|
+
if (this.isEOF() || !_isAlpha(this.char())) return this.error(23, createLocation(endTagStartPosition, this.clonePosition()));
|
|
3408
|
+
const closingTagNameStartPosition = this.clonePosition();
|
|
3409
|
+
if (tagName !== this.parseTagName()) return this.error(26, createLocation(closingTagNameStartPosition, this.clonePosition()));
|
|
3410
|
+
this.bumpSpace();
|
|
3411
|
+
if (!this.bumpIf(">")) return this.error(23, createLocation(endTagStartPosition, this.clonePosition()));
|
|
3412
|
+
return {
|
|
3413
|
+
val: {
|
|
3414
|
+
type: 8,
|
|
3415
|
+
value: tagName,
|
|
3416
|
+
children,
|
|
3417
|
+
location: createLocation(startPosition, this.clonePosition())
|
|
3418
|
+
},
|
|
3419
|
+
err: null
|
|
3420
|
+
};
|
|
3421
|
+
} else return this.error(27, createLocation(startPosition, this.clonePosition()));
|
|
3422
|
+
} else return this.error(23, createLocation(startPosition, this.clonePosition()));
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3425
|
+
* This method assumes that the caller has peeked ahead for the first tag character.
|
|
3426
|
+
*/
|
|
3427
|
+
parseTagName() {
|
|
3428
|
+
const startOffset = this.offset();
|
|
3429
|
+
this.bump();
|
|
3430
|
+
while (!this.isEOF() && _isPotentialElementNameChar(this.char())) this.bump();
|
|
3431
|
+
return this.message.slice(startOffset, this.offset());
|
|
3432
|
+
}
|
|
3433
|
+
parseLiteral(nestingLevel, parentArgType) {
|
|
3434
|
+
const start = this.clonePosition();
|
|
3435
|
+
let value = "";
|
|
3436
|
+
while (true) {
|
|
3437
|
+
const parseQuoteResult = this.tryParseQuote(parentArgType);
|
|
3438
|
+
if (parseQuoteResult) {
|
|
3439
|
+
value += parseQuoteResult;
|
|
3440
|
+
continue;
|
|
3441
|
+
}
|
|
3442
|
+
const parseUnquotedResult = this.tryParseUnquoted(nestingLevel, parentArgType);
|
|
3443
|
+
if (parseUnquotedResult) {
|
|
3444
|
+
value += parseUnquotedResult;
|
|
3445
|
+
continue;
|
|
3446
|
+
}
|
|
3447
|
+
const parseLeftAngleResult = this.tryParseLeftAngleBracket();
|
|
3448
|
+
if (parseLeftAngleResult) {
|
|
3449
|
+
value += parseLeftAngleResult;
|
|
3450
|
+
continue;
|
|
3451
|
+
}
|
|
3452
|
+
break;
|
|
3453
|
+
}
|
|
3454
|
+
const location = createLocation(start, this.clonePosition());
|
|
3455
|
+
return {
|
|
3456
|
+
val: {
|
|
3457
|
+
type: 0,
|
|
3458
|
+
value,
|
|
3459
|
+
location
|
|
3460
|
+
},
|
|
3461
|
+
err: null
|
|
3462
|
+
};
|
|
3463
|
+
}
|
|
3464
|
+
tryParseLeftAngleBracket() {
|
|
3465
|
+
if (!this.isEOF() && this.char() === 60 && (this.ignoreTag || !_isAlphaOrSlash(this.peek() || 0))) {
|
|
3466
|
+
this.bump();
|
|
3467
|
+
return "<";
|
|
3468
|
+
}
|
|
3469
|
+
return null;
|
|
3470
|
+
}
|
|
3471
|
+
/**
|
|
3472
|
+
* Starting with ICU 4.8, an ASCII apostrophe only starts quoted text if it immediately precedes
|
|
3473
|
+
* a character that requires quoting (that is, "only where needed"), and works the same in
|
|
3474
|
+
* nested messages as on the top level of the pattern. The new behavior is otherwise compatible.
|
|
3475
|
+
*/
|
|
3476
|
+
tryParseQuote(parentArgType) {
|
|
3477
|
+
if (this.isEOF() || this.char() !== 39) return null;
|
|
3478
|
+
switch (this.peek()) {
|
|
3479
|
+
case 39:
|
|
3480
|
+
this.bump();
|
|
3481
|
+
this.bump();
|
|
3482
|
+
return "'";
|
|
3483
|
+
case 123:
|
|
3484
|
+
case 60:
|
|
3485
|
+
case 62:
|
|
3486
|
+
case 125: break;
|
|
3487
|
+
case 35:
|
|
3488
|
+
if (parentArgType === "plural" || parentArgType === "selectordinal") break;
|
|
3489
|
+
return null;
|
|
3490
|
+
default: return null;
|
|
3491
|
+
}
|
|
3492
|
+
this.bump();
|
|
3493
|
+
const codePoints = [this.char()];
|
|
3494
|
+
this.bump();
|
|
3495
|
+
while (!this.isEOF()) {
|
|
3496
|
+
const ch = this.char();
|
|
3497
|
+
if (ch === 39) {
|
|
3498
|
+
if (this.peek() === 39) {
|
|
3499
|
+
codePoints.push(39);
|
|
3500
|
+
this.bump();
|
|
3501
|
+
} else {
|
|
3502
|
+
this.bump();
|
|
3503
|
+
break;
|
|
3504
|
+
}
|
|
3505
|
+
} else codePoints.push(ch);
|
|
3506
|
+
this.bump();
|
|
3507
|
+
}
|
|
3508
|
+
return String.fromCodePoint(...codePoints);
|
|
3509
|
+
}
|
|
3510
|
+
tryParseUnquoted(nestingLevel, parentArgType) {
|
|
3511
|
+
if (this.isEOF()) return null;
|
|
3512
|
+
const ch = this.char();
|
|
3513
|
+
if (ch === 60 || ch === 123 || ch === 35 && (parentArgType === "plural" || parentArgType === "selectordinal") || ch === 125 && nestingLevel > 0) return null;
|
|
3514
|
+
else {
|
|
3515
|
+
this.bump();
|
|
3516
|
+
return String.fromCodePoint(ch);
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
parseArgument(nestingLevel, expectingCloseTag) {
|
|
3520
|
+
const openingBracePosition = this.clonePosition();
|
|
3521
|
+
this.bump();
|
|
3522
|
+
this.bumpSpace();
|
|
3523
|
+
if (this.isEOF()) return this.error(1, createLocation(openingBracePosition, this.clonePosition()));
|
|
3524
|
+
if (this.char() === 125) {
|
|
3525
|
+
this.bump();
|
|
3526
|
+
return this.error(2, createLocation(openingBracePosition, this.clonePosition()));
|
|
3527
|
+
}
|
|
3528
|
+
let value = this.parseIdentifierIfPossible().value;
|
|
3529
|
+
if (!value) return this.error(3, createLocation(openingBracePosition, this.clonePosition()));
|
|
3530
|
+
this.bumpSpace();
|
|
3531
|
+
if (this.isEOF()) return this.error(1, createLocation(openingBracePosition, this.clonePosition()));
|
|
3532
|
+
switch (this.char()) {
|
|
3533
|
+
case 125:
|
|
3534
|
+
this.bump();
|
|
3535
|
+
return {
|
|
3536
|
+
val: {
|
|
3537
|
+
type: 1,
|
|
3538
|
+
value,
|
|
3539
|
+
location: createLocation(openingBracePosition, this.clonePosition())
|
|
3540
|
+
},
|
|
3541
|
+
err: null
|
|
3542
|
+
};
|
|
3543
|
+
case 44:
|
|
3544
|
+
this.bump();
|
|
3545
|
+
this.bumpSpace();
|
|
3546
|
+
if (this.isEOF()) return this.error(1, createLocation(openingBracePosition, this.clonePosition()));
|
|
3547
|
+
return this.parseArgumentOptions(nestingLevel, expectingCloseTag, value, openingBracePosition);
|
|
3548
|
+
default: return this.error(3, createLocation(openingBracePosition, this.clonePosition()));
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
/**
|
|
3552
|
+
* Advance the parser until the end of the identifier, if it is currently on
|
|
3553
|
+
* an identifier character. Return an empty string otherwise.
|
|
3554
|
+
*/
|
|
3555
|
+
parseIdentifierIfPossible() {
|
|
3556
|
+
const startingPosition = this.clonePosition();
|
|
3557
|
+
const startOffset = this.offset();
|
|
3558
|
+
const value = matchIdentifierAtIndex(this.message, startOffset);
|
|
3559
|
+
const endOffset = startOffset + value.length;
|
|
3560
|
+
this.bumpTo(endOffset);
|
|
3561
|
+
return {
|
|
3562
|
+
value,
|
|
3563
|
+
location: createLocation(startingPosition, this.clonePosition())
|
|
3564
|
+
};
|
|
3565
|
+
}
|
|
3566
|
+
parseArgumentOptions(nestingLevel, expectingCloseTag, value, openingBracePosition) {
|
|
3567
|
+
let typeStartPosition = this.clonePosition();
|
|
3568
|
+
let argType = this.parseIdentifierIfPossible().value;
|
|
3569
|
+
let typeEndPosition = this.clonePosition();
|
|
3570
|
+
switch (argType) {
|
|
3571
|
+
case "": return this.error(4, createLocation(typeStartPosition, typeEndPosition));
|
|
3572
|
+
case "number":
|
|
3573
|
+
case "date":
|
|
3574
|
+
case "time": {
|
|
3575
|
+
this.bumpSpace();
|
|
3576
|
+
let styleAndLocation = null;
|
|
3577
|
+
if (this.bumpIf(",")) {
|
|
3578
|
+
this.bumpSpace();
|
|
3579
|
+
const styleStartPosition = this.clonePosition();
|
|
3580
|
+
const result = this.parseSimpleArgStyleIfPossible();
|
|
3581
|
+
if (result.err) return result;
|
|
3582
|
+
const style = trimEnd(result.val);
|
|
3583
|
+
if (style.length === 0) return this.error(6, createLocation(this.clonePosition(), this.clonePosition()));
|
|
3584
|
+
styleAndLocation = {
|
|
3585
|
+
style,
|
|
3586
|
+
styleLocation: createLocation(styleStartPosition, this.clonePosition())
|
|
3587
|
+
};
|
|
3588
|
+
}
|
|
3589
|
+
const argCloseResult = this.tryParseArgumentClose(openingBracePosition);
|
|
3590
|
+
if (argCloseResult.err) return argCloseResult;
|
|
3591
|
+
const location = createLocation(openingBracePosition, this.clonePosition());
|
|
3592
|
+
if (styleAndLocation && styleAndLocation.style.startsWith("::")) {
|
|
3593
|
+
let skeleton = trimStart(styleAndLocation.style.slice(2));
|
|
3594
|
+
if (argType === "number") {
|
|
3595
|
+
const result = this.parseNumberSkeletonFromString(skeleton, styleAndLocation.styleLocation);
|
|
3596
|
+
if (result.err) return result;
|
|
3597
|
+
return {
|
|
3598
|
+
val: {
|
|
3599
|
+
type: 2,
|
|
3600
|
+
value,
|
|
3601
|
+
location,
|
|
3602
|
+
style: result.val
|
|
3603
|
+
},
|
|
3604
|
+
err: null
|
|
3605
|
+
};
|
|
3606
|
+
} else {
|
|
3607
|
+
if (skeleton.length === 0) return this.error(10, location);
|
|
3608
|
+
let dateTimePattern = skeleton;
|
|
3609
|
+
if (this.locale) dateTimePattern = getBestPattern(skeleton, this.locale);
|
|
3610
|
+
const style = {
|
|
3611
|
+
type: 1,
|
|
3612
|
+
pattern: dateTimePattern,
|
|
3613
|
+
location: styleAndLocation.styleLocation,
|
|
3614
|
+
parsedOptions: this.shouldParseSkeletons ? parseDateTimeSkeleton(dateTimePattern) : {}
|
|
3615
|
+
};
|
|
3616
|
+
return {
|
|
3617
|
+
val: {
|
|
3618
|
+
type: argType === "date" ? 3 : 4,
|
|
3619
|
+
value,
|
|
3620
|
+
location,
|
|
3621
|
+
style
|
|
3622
|
+
},
|
|
3623
|
+
err: null
|
|
3624
|
+
};
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
return {
|
|
3628
|
+
val: {
|
|
3629
|
+
type: argType === "number" ? 2 : argType === "date" ? 3 : 4,
|
|
3630
|
+
value,
|
|
3631
|
+
location,
|
|
3632
|
+
style: styleAndLocation?.style ?? null
|
|
3633
|
+
},
|
|
3634
|
+
err: null
|
|
3635
|
+
};
|
|
3636
|
+
}
|
|
3637
|
+
case "plural":
|
|
3638
|
+
case "selectordinal":
|
|
3639
|
+
case "select": {
|
|
3640
|
+
const typeEndPosition = this.clonePosition();
|
|
3641
|
+
this.bumpSpace();
|
|
3642
|
+
if (!this.bumpIf(",")) return this.error(12, createLocation(typeEndPosition, { ...typeEndPosition }));
|
|
3643
|
+
this.bumpSpace();
|
|
3644
|
+
let identifierAndLocation = this.parseIdentifierIfPossible();
|
|
3645
|
+
let pluralOffset = 0;
|
|
3646
|
+
if (argType !== "select" && identifierAndLocation.value === "offset") {
|
|
3647
|
+
if (!this.bumpIf(":")) return this.error(13, createLocation(this.clonePosition(), this.clonePosition()));
|
|
3648
|
+
this.bumpSpace();
|
|
3649
|
+
const result = this.tryParseDecimalInteger(13, 14);
|
|
3650
|
+
if (result.err) return result;
|
|
3651
|
+
this.bumpSpace();
|
|
3652
|
+
identifierAndLocation = this.parseIdentifierIfPossible();
|
|
3653
|
+
pluralOffset = result.val;
|
|
3654
|
+
}
|
|
3655
|
+
const optionsResult = this.tryParsePluralOrSelectOptions(nestingLevel, argType, expectingCloseTag, identifierAndLocation);
|
|
3656
|
+
if (optionsResult.err) return optionsResult;
|
|
3657
|
+
const argCloseResult = this.tryParseArgumentClose(openingBracePosition);
|
|
3658
|
+
if (argCloseResult.err) return argCloseResult;
|
|
3659
|
+
const location = createLocation(openingBracePosition, this.clonePosition());
|
|
3660
|
+
if (argType === "select") return {
|
|
3661
|
+
val: {
|
|
3662
|
+
type: 5,
|
|
3663
|
+
value,
|
|
3664
|
+
options: fromEntries(optionsResult.val),
|
|
3665
|
+
location
|
|
3666
|
+
},
|
|
3667
|
+
err: null
|
|
3668
|
+
};
|
|
3669
|
+
else return {
|
|
3670
|
+
val: {
|
|
3671
|
+
type: 6,
|
|
3672
|
+
value,
|
|
3673
|
+
options: fromEntries(optionsResult.val),
|
|
3674
|
+
offset: pluralOffset,
|
|
3675
|
+
pluralType: argType === "plural" ? "cardinal" : "ordinal",
|
|
3676
|
+
location
|
|
3677
|
+
},
|
|
3678
|
+
err: null
|
|
3679
|
+
};
|
|
3680
|
+
}
|
|
3681
|
+
default: return this.error(5, createLocation(typeStartPosition, typeEndPosition));
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
tryParseArgumentClose(openingBracePosition) {
|
|
3685
|
+
if (this.isEOF() || this.char() !== 125) return this.error(1, createLocation(openingBracePosition, this.clonePosition()));
|
|
3686
|
+
this.bump();
|
|
3687
|
+
return {
|
|
3688
|
+
val: true,
|
|
3689
|
+
err: null
|
|
3690
|
+
};
|
|
3691
|
+
}
|
|
3692
|
+
/**
|
|
3693
|
+
* See: https://github.com/unicode-org/icu/blob/af7ed1f6d2298013dc303628438ec4abe1f16479/icu4c/source/common/messagepattern.cpp#L659
|
|
3694
|
+
*/
|
|
3695
|
+
parseSimpleArgStyleIfPossible() {
|
|
3696
|
+
let nestedBraces = 0;
|
|
3697
|
+
const startPosition = this.clonePosition();
|
|
3698
|
+
while (!this.isEOF()) switch (this.char()) {
|
|
3699
|
+
case 39: {
|
|
3700
|
+
this.bump();
|
|
3701
|
+
let apostrophePosition = this.clonePosition();
|
|
3702
|
+
if (!this.bumpUntil("'")) return this.error(11, createLocation(apostrophePosition, this.clonePosition()));
|
|
3703
|
+
this.bump();
|
|
3704
|
+
break;
|
|
3705
|
+
}
|
|
3706
|
+
case 123:
|
|
3707
|
+
nestedBraces += 1;
|
|
3708
|
+
this.bump();
|
|
3709
|
+
break;
|
|
3710
|
+
case 125:
|
|
3711
|
+
if (nestedBraces > 0) nestedBraces -= 1;
|
|
3712
|
+
else return {
|
|
3713
|
+
val: this.message.slice(startPosition.offset, this.offset()),
|
|
3714
|
+
err: null
|
|
3715
|
+
};
|
|
3716
|
+
break;
|
|
3717
|
+
default: this.bump();
|
|
3718
|
+
}
|
|
3719
|
+
return {
|
|
3720
|
+
val: this.message.slice(startPosition.offset, this.offset()),
|
|
3721
|
+
err: null
|
|
3722
|
+
};
|
|
3723
|
+
}
|
|
3724
|
+
parseNumberSkeletonFromString(skeleton, location) {
|
|
3725
|
+
let tokens = [];
|
|
3726
|
+
try {
|
|
3727
|
+
tokens = parseNumberSkeletonFromString(skeleton);
|
|
3728
|
+
} catch {
|
|
3729
|
+
return this.error(7, location);
|
|
3730
|
+
}
|
|
3731
|
+
return {
|
|
3732
|
+
val: {
|
|
3733
|
+
type: 0,
|
|
3734
|
+
tokens,
|
|
3735
|
+
location,
|
|
3736
|
+
parsedOptions: this.shouldParseSkeletons ? parseNumberSkeleton(tokens) : {}
|
|
3737
|
+
},
|
|
3738
|
+
err: null
|
|
3739
|
+
};
|
|
3740
|
+
}
|
|
3741
|
+
/**
|
|
3742
|
+
* @param nesting_level The current nesting level of messages.
|
|
3743
|
+
* This can be positive when parsing message fragment in select or plural argument options.
|
|
3744
|
+
* @param parent_arg_type The parent argument's type.
|
|
3745
|
+
* @param parsed_first_identifier If provided, this is the first identifier-like selector of
|
|
3746
|
+
* the argument. It is a by-product of a previous parsing attempt.
|
|
3747
|
+
* @param expecting_close_tag If true, this message is directly or indirectly nested inside
|
|
3748
|
+
* between a pair of opening and closing tags. The nested message will not parse beyond
|
|
3749
|
+
* the closing tag boundary.
|
|
3750
|
+
*/
|
|
3751
|
+
tryParsePluralOrSelectOptions(nestingLevel, parentArgType, expectCloseTag, parsedFirstIdentifier) {
|
|
3752
|
+
let hasOtherClause = false;
|
|
3753
|
+
const options = [];
|
|
3754
|
+
const parsedSelectors = /* @__PURE__ */ new Set();
|
|
3755
|
+
let { value: selector, location: selectorLocation } = parsedFirstIdentifier;
|
|
3756
|
+
while (true) {
|
|
3757
|
+
if (selector.length === 0) {
|
|
3758
|
+
const startPosition = this.clonePosition();
|
|
3759
|
+
if (parentArgType !== "select" && this.bumpIf("=")) {
|
|
3760
|
+
const result = this.tryParseDecimalInteger(16, 19);
|
|
3761
|
+
if (result.err) return result;
|
|
3762
|
+
selectorLocation = createLocation(startPosition, this.clonePosition());
|
|
3763
|
+
selector = this.message.slice(startPosition.offset, this.offset());
|
|
3764
|
+
} else break;
|
|
3765
|
+
}
|
|
3766
|
+
if (parsedSelectors.has(selector)) return this.error(parentArgType === "select" ? 21 : 20, selectorLocation);
|
|
3767
|
+
if (selector === "other") hasOtherClause = true;
|
|
3768
|
+
this.bumpSpace();
|
|
3769
|
+
const openingBracePosition = this.clonePosition();
|
|
3770
|
+
if (!this.bumpIf("{")) return this.error(parentArgType === "select" ? 17 : 18, createLocation(this.clonePosition(), this.clonePosition()));
|
|
3771
|
+
const fragmentResult = this.parseMessage(nestingLevel + 1, parentArgType, expectCloseTag);
|
|
3772
|
+
if (fragmentResult.err) return fragmentResult;
|
|
3773
|
+
const argCloseResult = this.tryParseArgumentClose(openingBracePosition);
|
|
3774
|
+
if (argCloseResult.err) return argCloseResult;
|
|
3775
|
+
options.push([selector, {
|
|
3776
|
+
value: fragmentResult.val,
|
|
3777
|
+
location: createLocation(openingBracePosition, this.clonePosition())
|
|
3778
|
+
}]);
|
|
3779
|
+
parsedSelectors.add(selector);
|
|
3780
|
+
this.bumpSpace();
|
|
3781
|
+
({value: selector, location: selectorLocation} = this.parseIdentifierIfPossible());
|
|
3782
|
+
}
|
|
3783
|
+
if (options.length === 0) return this.error(parentArgType === "select" ? 15 : 16, createLocation(this.clonePosition(), this.clonePosition()));
|
|
3784
|
+
if (this.requiresOtherClause && !hasOtherClause) return this.error(22, createLocation(this.clonePosition(), this.clonePosition()));
|
|
3785
|
+
return {
|
|
3786
|
+
val: options,
|
|
3787
|
+
err: null
|
|
3788
|
+
};
|
|
3789
|
+
}
|
|
3790
|
+
tryParseDecimalInteger(expectNumberError, invalidNumberError) {
|
|
3791
|
+
let sign = 1;
|
|
3792
|
+
const startingPosition = this.clonePosition();
|
|
3793
|
+
if (this.bumpIf("+")) {} else if (this.bumpIf("-")) sign = -1;
|
|
3794
|
+
let hasDigits = false;
|
|
3795
|
+
let decimal = 0;
|
|
3796
|
+
while (!this.isEOF()) {
|
|
3797
|
+
const ch = this.char();
|
|
3798
|
+
if (ch >= 48 && ch <= 57) {
|
|
3799
|
+
hasDigits = true;
|
|
3800
|
+
decimal = decimal * 10 + (ch - 48);
|
|
3801
|
+
this.bump();
|
|
3802
|
+
} else break;
|
|
3803
|
+
}
|
|
3804
|
+
const location = createLocation(startingPosition, this.clonePosition());
|
|
3805
|
+
if (!hasDigits) return this.error(expectNumberError, location);
|
|
3806
|
+
decimal *= sign;
|
|
3807
|
+
if (!Number.isSafeInteger(decimal)) return this.error(invalidNumberError, location);
|
|
3808
|
+
return {
|
|
3809
|
+
val: decimal,
|
|
3810
|
+
err: null
|
|
3811
|
+
};
|
|
3812
|
+
}
|
|
3813
|
+
offset() {
|
|
3814
|
+
return this.position.offset;
|
|
3815
|
+
}
|
|
3816
|
+
isEOF() {
|
|
3817
|
+
return this.offset() === this.message.length;
|
|
3818
|
+
}
|
|
3819
|
+
clonePosition() {
|
|
3820
|
+
return {
|
|
3821
|
+
offset: this.position.offset,
|
|
3822
|
+
line: this.position.line,
|
|
3823
|
+
column: this.position.column
|
|
3824
|
+
};
|
|
3825
|
+
}
|
|
3826
|
+
/**
|
|
3827
|
+
* Return the code point at the current position of the parser.
|
|
3828
|
+
* Throws if the index is out of bound.
|
|
3829
|
+
*/
|
|
3830
|
+
char() {
|
|
3831
|
+
const offset = this.position.offset;
|
|
3832
|
+
if (offset >= this.message.length) throw Error("out of bound");
|
|
3833
|
+
const code = this.message.codePointAt(offset);
|
|
3834
|
+
if (code === void 0) throw Error(`Offset ${offset} is at invalid UTF-16 code unit boundary`);
|
|
3835
|
+
return code;
|
|
3836
|
+
}
|
|
3837
|
+
error(kind, location) {
|
|
3838
|
+
return {
|
|
3839
|
+
val: null,
|
|
3840
|
+
err: {
|
|
3841
|
+
kind,
|
|
3842
|
+
message: this.message,
|
|
3843
|
+
location
|
|
3844
|
+
}
|
|
3845
|
+
};
|
|
3846
|
+
}
|
|
3847
|
+
/** Bump the parser to the next UTF-16 code unit. */
|
|
3848
|
+
bump() {
|
|
3849
|
+
if (this.isEOF()) return;
|
|
3850
|
+
const code = this.char();
|
|
3851
|
+
if (code === 10) {
|
|
3852
|
+
this.position.line += 1;
|
|
3853
|
+
this.position.column = 1;
|
|
3854
|
+
this.position.offset += 1;
|
|
3855
|
+
} else {
|
|
3856
|
+
this.position.column += 1;
|
|
3857
|
+
this.position.offset += code < 65536 ? 1 : 2;
|
|
3858
|
+
}
|
|
3859
|
+
}
|
|
3860
|
+
/**
|
|
3861
|
+
* If the substring starting at the current position of the parser has
|
|
3862
|
+
* the given prefix, then bump the parser to the character immediately
|
|
3863
|
+
* following the prefix and return true. Otherwise, don't bump the parser
|
|
3864
|
+
* and return false.
|
|
3865
|
+
*/
|
|
3866
|
+
bumpIf(prefix) {
|
|
3867
|
+
if (this.message.startsWith(prefix, this.offset())) {
|
|
3868
|
+
for (let i = 0; i < prefix.length; i++) this.bump();
|
|
3869
|
+
return true;
|
|
3870
|
+
}
|
|
3871
|
+
return false;
|
|
3872
|
+
}
|
|
3873
|
+
/**
|
|
3874
|
+
* Bump the parser until the pattern character is found and return `true`.
|
|
3875
|
+
* Otherwise bump to the end of the file and return `false`.
|
|
3876
|
+
*/
|
|
3877
|
+
bumpUntil(pattern) {
|
|
3878
|
+
const currentOffset = this.offset();
|
|
3879
|
+
const index = this.message.indexOf(pattern, currentOffset);
|
|
3880
|
+
if (index >= 0) {
|
|
3881
|
+
this.bumpTo(index);
|
|
3882
|
+
return true;
|
|
3883
|
+
} else {
|
|
3884
|
+
this.bumpTo(this.message.length);
|
|
3885
|
+
return false;
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
/**
|
|
3889
|
+
* Bump the parser to the target offset.
|
|
3890
|
+
* If target offset is beyond the end of the input, bump the parser to the end of the input.
|
|
3891
|
+
*/
|
|
3892
|
+
bumpTo(targetOffset) {
|
|
3893
|
+
if (this.offset() > targetOffset) throw Error(`targetOffset ${targetOffset} must be greater than or equal to the current offset ${this.offset()}`);
|
|
3894
|
+
targetOffset = Math.min(targetOffset, this.message.length);
|
|
3895
|
+
while (true) {
|
|
3896
|
+
const offset = this.offset();
|
|
3897
|
+
if (offset === targetOffset) break;
|
|
3898
|
+
if (offset > targetOffset) throw Error(`targetOffset ${targetOffset} is at invalid UTF-16 code unit boundary`);
|
|
3899
|
+
this.bump();
|
|
3900
|
+
if (this.isEOF()) break;
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
/** advance the parser through all whitespace to the next non-whitespace code unit. */
|
|
3904
|
+
bumpSpace() {
|
|
3905
|
+
while (!this.isEOF() && _isWhiteSpace(this.char())) this.bump();
|
|
3906
|
+
}
|
|
3907
|
+
/**
|
|
3908
|
+
* Peek at the *next* Unicode codepoint in the input without advancing the parser.
|
|
3909
|
+
* If the input has been exhausted, then this returns null.
|
|
3910
|
+
*/
|
|
3911
|
+
peek() {
|
|
3912
|
+
if (this.isEOF()) return null;
|
|
3913
|
+
const code = this.char();
|
|
3914
|
+
const offset = this.offset();
|
|
3915
|
+
return this.message.charCodeAt(offset + (code >= 65536 ? 2 : 1)) ?? null;
|
|
3916
|
+
}
|
|
3917
|
+
};
|
|
3918
|
+
/**
|
|
3919
|
+
* This check if codepoint is alphabet (lower & uppercase)
|
|
3920
|
+
* @param codepoint
|
|
3921
|
+
* @returns
|
|
3922
|
+
*/
|
|
3923
|
+
function _isAlpha(codepoint) {
|
|
3924
|
+
return codepoint >= 97 && codepoint <= 122 || codepoint >= 65 && codepoint <= 90;
|
|
3925
|
+
}
|
|
3926
|
+
function _isAlphaOrSlash(codepoint) {
|
|
3927
|
+
return _isAlpha(codepoint) || codepoint === 47;
|
|
3928
|
+
}
|
|
3929
|
+
/** See `parseTag` function docs. */
|
|
3930
|
+
function _isPotentialElementNameChar(c) {
|
|
3931
|
+
return c === 45 || c === 46 || c >= 48 && c <= 57 || c === 95 || c >= 97 && c <= 122 || c >= 65 && c <= 90 || c == 183 || c >= 192 && c <= 214 || c >= 216 && c <= 246 || c >= 248 && c <= 893 || c >= 895 && c <= 8191 || c >= 8204 && c <= 8205 || c >= 8255 && c <= 8256 || c >= 8304 && c <= 8591 || c >= 11264 && c <= 12271 || c >= 12289 && c <= 55295 || c >= 63744 && c <= 64975 || c >= 65008 && c <= 65533 || c >= 65536 && c <= 983039;
|
|
3932
|
+
}
|
|
3933
|
+
/**
|
|
3934
|
+
* Code point equivalent of regex `\p{White_Space}`.
|
|
3935
|
+
* From: https://www.unicode.org/Public/UCD/latest/ucd/PropList.txt
|
|
3936
|
+
*/
|
|
3937
|
+
function _isWhiteSpace(c) {
|
|
3938
|
+
return c >= 9 && c <= 13 || c === 32 || c === 133 || c >= 8206 && c <= 8207 || c === 8232 || c === 8233;
|
|
3939
|
+
}
|
|
3940
|
+
function pruneLocation(els) {
|
|
3941
|
+
els.forEach((el) => {
|
|
3942
|
+
delete el.location;
|
|
3943
|
+
if (isSelectElement(el) || isPluralElement(el)) for (const k in el.options) {
|
|
3944
|
+
delete el.options[k].location;
|
|
3945
|
+
pruneLocation(el.options[k].value);
|
|
3946
|
+
}
|
|
3947
|
+
else if (isNumberElement(el) && isNumberSkeleton(el.style)) delete el.style.location;
|
|
3948
|
+
else if ((isDateElement(el) || isTimeElement(el)) && isDateTimeSkeleton(el.style)) delete el.style.location;
|
|
3949
|
+
else if (isTagElement(el)) pruneLocation(el.children);
|
|
3950
|
+
});
|
|
3951
|
+
}
|
|
3952
|
+
function parse(message, opts = {}) {
|
|
3953
|
+
opts = {
|
|
3954
|
+
shouldParseSkeletons: true,
|
|
3955
|
+
requiresOtherClause: true,
|
|
3956
|
+
...opts
|
|
3957
|
+
};
|
|
3958
|
+
const result = new Parser(message, opts).parse();
|
|
3959
|
+
if (result.err) {
|
|
3960
|
+
const error = SyntaxError(ErrorKind[result.err.kind]);
|
|
3961
|
+
error.location = result.err.location;
|
|
3962
|
+
error.originalMessage = result.err.message;
|
|
3963
|
+
throw error;
|
|
3964
|
+
}
|
|
3965
|
+
if (!opts?.captureLocation) pruneLocation(result.val);
|
|
3966
|
+
return result.val;
|
|
3967
|
+
}
|
|
3968
|
+
//#endregion
|
|
3969
|
+
//#region src/plugins/i18n/compile/message.ts
|
|
3970
|
+
/**
|
|
3971
|
+
* @file i18n plugin, build time — one ICU MessageFormat 1 message into one function source and
|
|
3972
|
+
* the types of its parameters. The parser runs here and nowhere else: what ships to the browser
|
|
3973
|
+
* is the function this file writes. Node and Bun only.
|
|
3974
|
+
*/
|
|
3975
|
+
/** The style a `{d, date}` with no style means. */
|
|
3976
|
+
const DEFAULT_DATE_STYLE = "medium";
|
|
3977
|
+
/** A parameter name that can be read with a dot. */
|
|
3978
|
+
const PLAIN_NAME = /^[A-Za-z_$][\w$]*$/;
|
|
3979
|
+
/** The TypeScript cast keyword, kept apart so this file writes one and holds none. */
|
|
3980
|
+
const CAST = "as";
|
|
3981
|
+
/**
|
|
3982
|
+
* Writes a cast into the generated source.
|
|
3983
|
+
*
|
|
3984
|
+
* @param expression - What is being cast.
|
|
3985
|
+
* @param type - The type it is cast to.
|
|
3986
|
+
* @returns The expression with its cast.
|
|
3987
|
+
*/
|
|
3988
|
+
function cast(expression, type) {
|
|
3989
|
+
return `${expression} ${CAST} ${type}`;
|
|
3990
|
+
}
|
|
3991
|
+
/**
|
|
3992
|
+
* Wraps a compile problem. The caller adds the key and the file.
|
|
3993
|
+
*
|
|
3994
|
+
* @param reason - One sentence naming what the message asked for.
|
|
3995
|
+
* @returns The error to throw.
|
|
3996
|
+
*/
|
|
3997
|
+
function problem(reason) {
|
|
3998
|
+
return new Error(reason);
|
|
3999
|
+
}
|
|
4000
|
+
/**
|
|
4001
|
+
* Reads the message of anything the parser threw.
|
|
4002
|
+
*
|
|
4003
|
+
* @param error - What the `catch` caught.
|
|
4004
|
+
* @returns The message.
|
|
4005
|
+
*/
|
|
4006
|
+
function detailOf$1(error) {
|
|
4007
|
+
return error instanceof Error ? error.message : String(error);
|
|
4008
|
+
}
|
|
4009
|
+
/**
|
|
4010
|
+
* Parses one message, turning a parser failure into a compile problem.
|
|
4011
|
+
*
|
|
4012
|
+
* @param text - The message as the game wrote it.
|
|
4013
|
+
* @returns The elements of the message.
|
|
4014
|
+
* @throws {Error} When the message does not parse.
|
|
4015
|
+
*/
|
|
4016
|
+
function parseMessage(text) {
|
|
4017
|
+
try {
|
|
4018
|
+
return parse(text, { ignoreTag: true });
|
|
4019
|
+
} catch (error) {
|
|
4020
|
+
throw problem(detailOf$1(error));
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
4023
|
+
/**
|
|
4024
|
+
* Tells whether a style is one of the four ICU date and time names.
|
|
4025
|
+
*
|
|
4026
|
+
* @param style - The style the message asked for.
|
|
4027
|
+
* @returns True for "short", "medium", "long" and "full".
|
|
4028
|
+
*/
|
|
4029
|
+
function isDateStyle(style) {
|
|
4030
|
+
return style === "short" || style === "medium" || style === "long" || style === "full";
|
|
4031
|
+
}
|
|
4032
|
+
/**
|
|
4033
|
+
* Names one style for a problem message, whether it arrived as text or as a parsed skeleton.
|
|
4034
|
+
*
|
|
4035
|
+
* @param style - What the parser read after the argument type.
|
|
4036
|
+
* @returns The style as the game wrote it.
|
|
4037
|
+
*/
|
|
4038
|
+
function styleName(style) {
|
|
4039
|
+
if (typeof style === "string") return style;
|
|
4040
|
+
if (style === null || style === void 0) return "";
|
|
4041
|
+
if ("pattern" in style) return style.pattern;
|
|
4042
|
+
return style.tokens.map((token) => token.stem).join(" ");
|
|
4043
|
+
}
|
|
4044
|
+
/**
|
|
4045
|
+
* Reads one parameter of `p`.
|
|
4046
|
+
*
|
|
4047
|
+
* @param name - The parameter name.
|
|
4048
|
+
* @returns The access expression.
|
|
4049
|
+
*/
|
|
4050
|
+
function access(name) {
|
|
4051
|
+
return PLAIN_NAME.test(name) ? `p.${name}` : `p[${JSON.stringify(name)}]`;
|
|
4052
|
+
}
|
|
4053
|
+
/**
|
|
4054
|
+
* Records the type of one parameter, refusing a name used with two argument kinds.
|
|
4055
|
+
*
|
|
4056
|
+
* @param scan - What the walk collects.
|
|
4057
|
+
* @param name - The parameter name.
|
|
4058
|
+
* @param type - What this use of the parameter needs.
|
|
4059
|
+
* @throws {Error} When the same name is used with two kinds in one message.
|
|
4060
|
+
*/
|
|
4061
|
+
function addParameter(scan, name, type) {
|
|
4062
|
+
const known = scan.params[name];
|
|
4063
|
+
scan.usesParams = true;
|
|
4064
|
+
if (known === void 0) {
|
|
4065
|
+
scan.params[name] = type;
|
|
4066
|
+
return;
|
|
4067
|
+
}
|
|
4068
|
+
if (known.kind !== type.kind) throw problem(`the parameter "${name}" is used as ${known.kind} and as ${type.kind}`);
|
|
4069
|
+
if (known.kind === "select" && type.kind === "select") scan.params[name] = {
|
|
4070
|
+
kind: "select",
|
|
4071
|
+
options: unique$1([...known.options, ...type.options])
|
|
4072
|
+
};
|
|
4073
|
+
}
|
|
4074
|
+
/**
|
|
4075
|
+
* Sorts option names and drops the repeats. Its own function because lint rule L5 refuses a
|
|
4076
|
+
* collection built inside an exported declaration.
|
|
4077
|
+
*
|
|
4078
|
+
* @param options - The option names of one select, from any number of uses.
|
|
4079
|
+
* @returns The names, unique and sorted.
|
|
4080
|
+
* @example
|
|
4081
|
+
* ```ts
|
|
4082
|
+
* unique(["language", "audio", "audio"]); // ["audio", "language"]
|
|
4083
|
+
* ```
|
|
4084
|
+
*/
|
|
4085
|
+
function unique$1(options) {
|
|
4086
|
+
return [...new Set(options)].toSorted();
|
|
4087
|
+
}
|
|
4088
|
+
/**
|
|
4089
|
+
* Escapes a literal so it is safe between backticks.
|
|
4090
|
+
*
|
|
4091
|
+
* @param text - The literal.
|
|
4092
|
+
* @returns The literal, escaped.
|
|
4093
|
+
*/
|
|
4094
|
+
function escapeTemplate(text) {
|
|
4095
|
+
return text.replaceAll("\\", String.raw`\\`).replaceAll("`", String.raw`\``).replaceAll("${", "\\${");
|
|
4096
|
+
}
|
|
4097
|
+
/**
|
|
4098
|
+
* Reads the code of one piece.
|
|
4099
|
+
*
|
|
4100
|
+
* @param piece - The piece.
|
|
4101
|
+
* @returns Its expression.
|
|
4102
|
+
*/
|
|
4103
|
+
function codeOf(piece) {
|
|
4104
|
+
return piece.kind === "literal" ? JSON.stringify(piece.text) : piece.code;
|
|
4105
|
+
}
|
|
4106
|
+
/**
|
|
4107
|
+
* Tells whether a piece is text rather than an element.
|
|
4108
|
+
*
|
|
4109
|
+
* @param piece - The piece.
|
|
4110
|
+
* @returns True for a literal or a string expression.
|
|
4111
|
+
*/
|
|
4112
|
+
function isText(piece) {
|
|
4113
|
+
return piece.kind === "literal" || piece.kind === "expr";
|
|
4114
|
+
}
|
|
4115
|
+
/**
|
|
4116
|
+
* Joins text pieces into one string expression: a quoted literal when nothing varies, else a
|
|
4117
|
+
* template literal.
|
|
4118
|
+
*
|
|
4119
|
+
* @param pieces - The pieces, all of them text.
|
|
4120
|
+
* @returns The string expression.
|
|
4121
|
+
*/
|
|
4122
|
+
function joinText(pieces) {
|
|
4123
|
+
const only = pieces.length === 1 ? pieces[0] : void 0;
|
|
4124
|
+
if (pieces.length === 0) return "\"\"";
|
|
4125
|
+
if (only !== void 0) return codeOf(only);
|
|
4126
|
+
return `\`${pieces.map((piece) => piece.kind === "literal" ? escapeTemplate(piece.text) : `\${${codeOf(piece)}}`).join("")}\``;
|
|
4127
|
+
}
|
|
4128
|
+
/**
|
|
4129
|
+
* Turns the pieces of a message into a `Part[]` expression. Runs of text become one text part;
|
|
4130
|
+
* an element keeps its place.
|
|
4131
|
+
*
|
|
4132
|
+
* @param pieces - The pieces of the message.
|
|
4133
|
+
* @returns The array expression.
|
|
4134
|
+
*/
|
|
4135
|
+
function toParts(pieces) {
|
|
4136
|
+
if (pieces.every((piece) => isText(piece))) return `[{ kind: "text", text: ${joinText(pieces)} }]`;
|
|
4137
|
+
const items = [];
|
|
4138
|
+
const run = [];
|
|
4139
|
+
const flush = () => {
|
|
4140
|
+
if (run.length > 0) items.push(`{ kind: "text", text: ${joinText(run)} }`);
|
|
4141
|
+
run.length = 0;
|
|
4142
|
+
};
|
|
4143
|
+
for (const piece of pieces) {
|
|
4144
|
+
if (isText(piece)) {
|
|
4145
|
+
run.push(piece);
|
|
4146
|
+
continue;
|
|
4147
|
+
}
|
|
4148
|
+
flush();
|
|
4149
|
+
items.push(piece.kind === "parts" ? `...${piece.code}` : piece.code);
|
|
4150
|
+
}
|
|
4151
|
+
flush();
|
|
4152
|
+
return `[${items.join(", ")}]`;
|
|
4153
|
+
}
|
|
4154
|
+
/**
|
|
4155
|
+
* Writes a chain of conditions, parenthesised so it nests safely.
|
|
4156
|
+
*
|
|
4157
|
+
* @param tests - The condition and the value of each branch, in order.
|
|
4158
|
+
* @param otherwise - The value of the last branch.
|
|
4159
|
+
* @returns The chain expression.
|
|
4160
|
+
*/
|
|
4161
|
+
function chain(tests, otherwise) {
|
|
4162
|
+
if (tests.length === 0) return otherwise;
|
|
4163
|
+
return `(${tests.map((entry) => `${entry.test} ? ${entry.value} : `).join("")}${otherwise})`;
|
|
4164
|
+
}
|
|
4165
|
+
/**
|
|
4166
|
+
* Compiles the branches of a plural or a select. When every branch is text the whole argument is
|
|
4167
|
+
* one string expression; as soon as one branch holds an element it becomes a `Part[]` expression.
|
|
4168
|
+
*
|
|
4169
|
+
* @param options - The branches the parser read.
|
|
4170
|
+
* @param scan - What the walk collects.
|
|
4171
|
+
* @param pound - What `#` stands for inside these branches.
|
|
4172
|
+
* @returns The code of each branch and whether they are all text.
|
|
4173
|
+
*/
|
|
4174
|
+
function branchesOf(options, scan, pound) {
|
|
4175
|
+
const pieces = {};
|
|
4176
|
+
for (const [name, option] of Object.entries(options)) pieces[name] = piecesOf(option.value, scan, pound);
|
|
4177
|
+
const allText = Object.values(pieces).every((list) => list.every((piece) => isText(piece)));
|
|
4178
|
+
const code = {};
|
|
4179
|
+
for (const [name, list] of Object.entries(pieces)) code[name] = allText ? joinText(list) : toParts(list);
|
|
4180
|
+
return {
|
|
4181
|
+
code,
|
|
4182
|
+
allText
|
|
4183
|
+
};
|
|
4184
|
+
}
|
|
4185
|
+
/**
|
|
4186
|
+
* Builds the piece one plural or select argument becomes.
|
|
4187
|
+
*
|
|
4188
|
+
* @param tests - The condition and the value of each branch.
|
|
4189
|
+
* @param otherwise - The value of the `other` branch, when the argument declared one.
|
|
4190
|
+
* @param allText - Whether every branch is text.
|
|
4191
|
+
* @returns The piece.
|
|
4192
|
+
* @throws {Error} When the argument has no `other` branch.
|
|
4193
|
+
*/
|
|
4194
|
+
function branchPiece(tests, otherwise, allText) {
|
|
4195
|
+
if (otherwise === void 0) throw problem("MISSING_OTHER_CLAUSE");
|
|
4196
|
+
const code = chain(tests, otherwise);
|
|
4197
|
+
if (allText) return {
|
|
4198
|
+
kind: "expr",
|
|
4199
|
+
code
|
|
4200
|
+
};
|
|
4201
|
+
return {
|
|
4202
|
+
kind: "parts",
|
|
4203
|
+
code: `(${code} satisfies I18n.Part[])`
|
|
4204
|
+
};
|
|
4205
|
+
}
|
|
4206
|
+
/**
|
|
4207
|
+
* Compiles a `plural` or `selectordinal` argument. The exact matches are tested first, then the
|
|
4208
|
+
* plural category of the value minus the offset.
|
|
4209
|
+
*
|
|
4210
|
+
* @param element - The argument the parser read.
|
|
4211
|
+
* @param scan - What the walk collects.
|
|
4212
|
+
* @returns The piece it becomes.
|
|
4213
|
+
* @throws {Error} When the argument has no `other` branch.
|
|
4214
|
+
*/
|
|
4215
|
+
function pluralPiece(element, scan) {
|
|
4216
|
+
addParameter(scan, element.value, { kind: "number" });
|
|
4217
|
+
scan.usesIntl = true;
|
|
4218
|
+
const number = `(${cast(access(element.value), "number")})`;
|
|
4219
|
+
const value = element.offset === 0 ? number : `(${number} - ${element.offset})`;
|
|
4220
|
+
const rules = element.pluralType === "ordinal" ? "intl.plural({ type: \"ordinal\" })" : "intl.plural()";
|
|
4221
|
+
const { code, allText } = branchesOf(element.options, scan, `intl.number().format(${value})`);
|
|
4222
|
+
const exact = [];
|
|
4223
|
+
const categories = [];
|
|
4224
|
+
for (const [name, branch] of Object.entries(code)) {
|
|
4225
|
+
if (name === "other") continue;
|
|
4226
|
+
if (name.startsWith("=")) exact.push({
|
|
4227
|
+
test: `${value} === ${name.slice(1)}`,
|
|
4228
|
+
value: branch
|
|
4229
|
+
});
|
|
4230
|
+
else categories.push({
|
|
4231
|
+
test: `${rules}.select(${value}) === ${JSON.stringify(name)}`,
|
|
4232
|
+
value: branch
|
|
4233
|
+
});
|
|
4234
|
+
}
|
|
4235
|
+
return branchPiece([...exact, ...categories], code.other, allText);
|
|
4236
|
+
}
|
|
4237
|
+
/**
|
|
4238
|
+
* Compiles a `select` argument.
|
|
4239
|
+
*
|
|
4240
|
+
* @param element - The argument the parser read.
|
|
4241
|
+
* @param scan - What the walk collects.
|
|
4242
|
+
* @param pound - What `#` stands for inside these branches.
|
|
4243
|
+
* @returns The piece it becomes.
|
|
4244
|
+
* @throws {Error} When the argument has no `other` branch.
|
|
4245
|
+
*/
|
|
4246
|
+
function selectPiece(element, scan, pound) {
|
|
4247
|
+
addParameter(scan, element.value, {
|
|
4248
|
+
kind: "select",
|
|
4249
|
+
options: Object.keys(element.options).filter((name) => name !== "other").toSorted()
|
|
4250
|
+
});
|
|
4251
|
+
const subject = `String(${access(element.value)})`;
|
|
4252
|
+
const { code, allText } = branchesOf(element.options, scan, pound);
|
|
4253
|
+
const tests = [];
|
|
4254
|
+
for (const [name, branch] of Object.entries(code)) {
|
|
4255
|
+
if (name === "other") continue;
|
|
4256
|
+
tests.push({
|
|
4257
|
+
test: `${subject} === ${JSON.stringify(name)}`,
|
|
4258
|
+
value: branch
|
|
4259
|
+
});
|
|
4260
|
+
}
|
|
4261
|
+
return branchPiece(tests, code.other, allText);
|
|
4262
|
+
}
|
|
4263
|
+
/**
|
|
4264
|
+
* Writes the `Intl.NumberFormat` options one ICU number style asks for.
|
|
4265
|
+
*
|
|
4266
|
+
* @param style - The style the message named, empty for none.
|
|
4267
|
+
* @returns The options as source, empty for the plain format.
|
|
4268
|
+
* @example
|
|
4269
|
+
* ```ts
|
|
4270
|
+
* numberOptions("percent"); // '{ style: "percent" }'
|
|
4271
|
+
* ```
|
|
4272
|
+
*/
|
|
4273
|
+
function numberOptions(style) {
|
|
4274
|
+
if (style === "integer") return "{ maximumFractionDigits: 0 }";
|
|
4275
|
+
if (style === "percent") return "{ style: \"percent\" }";
|
|
4276
|
+
return "";
|
|
4277
|
+
}
|
|
4278
|
+
/**
|
|
4279
|
+
* Compiles a `number` argument.
|
|
4280
|
+
*
|
|
4281
|
+
* @param element - The argument the parser read.
|
|
4282
|
+
* @param scan - What the walk collects.
|
|
4283
|
+
* @returns The piece it becomes.
|
|
4284
|
+
* @throws {Error} When the style is not one this engine supports.
|
|
4285
|
+
*/
|
|
4286
|
+
function numberPiece(element, scan) {
|
|
4287
|
+
const style = styleName(element.style);
|
|
4288
|
+
if (style !== "" && style !== "integer" && style !== "percent") throw problem(`the number style "${style}" is not supported`);
|
|
4289
|
+
addParameter(scan, element.value, { kind: "number" });
|
|
4290
|
+
scan.usesIntl = true;
|
|
4291
|
+
return {
|
|
4292
|
+
kind: "expr",
|
|
4293
|
+
code: `intl.number(${numberOptions(style)}).format(${cast(access(element.value), "number")})`
|
|
4294
|
+
};
|
|
4295
|
+
}
|
|
4296
|
+
/**
|
|
4297
|
+
* Compiles a `date` or a `time` argument.
|
|
4298
|
+
*
|
|
4299
|
+
* @param element - The argument the parser read.
|
|
4300
|
+
* @param scan - What the walk collects.
|
|
4301
|
+
* @param field - Which half of `Intl.DateTimeFormat` the argument names.
|
|
4302
|
+
* @returns The piece it becomes.
|
|
4303
|
+
* @throws {Error} When the style is not one of the four ICU names.
|
|
4304
|
+
*/
|
|
4305
|
+
function datePiece(element, scan, field) {
|
|
4306
|
+
const named = styleName(element.style);
|
|
4307
|
+
const style = named === "" ? DEFAULT_DATE_STYLE : named;
|
|
4308
|
+
if (!isDateStyle(style)) throw problem(`the ${field} "${named}" is not supported`);
|
|
4309
|
+
addParameter(scan, element.value, { kind: "date" });
|
|
4310
|
+
scan.usesIntl = true;
|
|
4311
|
+
return {
|
|
4312
|
+
kind: "expr",
|
|
4313
|
+
code: `intl.date({ ${field}: "${style}" }).format(${cast(access(element.value), "Date | number")})`
|
|
4314
|
+
};
|
|
4315
|
+
}
|
|
4316
|
+
/**
|
|
4317
|
+
* Compiles one run of message elements into pieces.
|
|
4318
|
+
*
|
|
4319
|
+
* @param elements - What the parser read.
|
|
4320
|
+
* @param scan - What the walk collects.
|
|
4321
|
+
* @param pound - What `#` stands for here, when a plural is open.
|
|
4322
|
+
* @returns The pieces, in order.
|
|
4323
|
+
* @throws {Error} When the run holds a feature this engine does not support.
|
|
4324
|
+
*/
|
|
4325
|
+
function piecesOf(elements, scan, pound) {
|
|
4326
|
+
const pieces = [];
|
|
4327
|
+
for (const element of elements) switch (element.type) {
|
|
4328
|
+
case TYPE.literal:
|
|
4329
|
+
pieces.push({
|
|
4330
|
+
kind: "literal",
|
|
4331
|
+
text: element.value
|
|
4332
|
+
});
|
|
4333
|
+
break;
|
|
4334
|
+
case TYPE.argument:
|
|
4335
|
+
addParameter(scan, element.value, { kind: "argument" });
|
|
4336
|
+
scan.usesArgument = true;
|
|
4337
|
+
scan.usesIntl = true;
|
|
4338
|
+
pieces.push({
|
|
4339
|
+
kind: "part",
|
|
4340
|
+
code: `argument(${access(element.value)}, intl)`
|
|
4341
|
+
});
|
|
4342
|
+
break;
|
|
4343
|
+
case TYPE.number:
|
|
4344
|
+
pieces.push(numberPiece(element, scan));
|
|
4345
|
+
break;
|
|
4346
|
+
case TYPE.date:
|
|
4347
|
+
pieces.push(datePiece(element, scan, "dateStyle"));
|
|
4348
|
+
break;
|
|
4349
|
+
case TYPE.time:
|
|
4350
|
+
pieces.push(datePiece(element, scan, "timeStyle"));
|
|
4351
|
+
break;
|
|
4352
|
+
case TYPE.select:
|
|
4353
|
+
pieces.push(selectPiece(element, scan, pound));
|
|
4354
|
+
break;
|
|
4355
|
+
case TYPE.plural:
|
|
4356
|
+
pieces.push(pluralPiece(element, scan));
|
|
4357
|
+
break;
|
|
4358
|
+
case TYPE.pound:
|
|
4359
|
+
if (pound === void 0) throw problem("\"#\" is only allowed inside a plural");
|
|
4360
|
+
pieces.push({
|
|
4361
|
+
kind: "expr",
|
|
4362
|
+
code: pound
|
|
4363
|
+
});
|
|
4364
|
+
break;
|
|
4365
|
+
default: throw problem("the message holds an element this engine cannot read");
|
|
4366
|
+
}
|
|
4367
|
+
return pieces;
|
|
4368
|
+
}
|
|
4369
|
+
/**
|
|
4370
|
+
* Writes the head of the arrow, naming only the arguments the body reads.
|
|
4371
|
+
*
|
|
4372
|
+
* @param scan - What the walk collected.
|
|
4373
|
+
* @returns The parameter list.
|
|
4374
|
+
*/
|
|
4375
|
+
function headOf(scan) {
|
|
4376
|
+
if (scan.usesIntl) return "(p, intl)";
|
|
4377
|
+
return scan.usesParams ? "p" : "()";
|
|
4378
|
+
}
|
|
4379
|
+
/**
|
|
4380
|
+
* Compiles one ICU message into the arrow the build writes into `generated/strings.<locale>.ts`.
|
|
4381
|
+
*
|
|
4382
|
+
* @param text - The message as the game wrote it.
|
|
4383
|
+
* @returns The arrow source, the parameter types and whether the module needs the helper.
|
|
4384
|
+
* @throws {Error} When the message does not parse or uses an unsupported ICU feature.
|
|
4385
|
+
* @example
|
|
4386
|
+
* ```ts
|
|
4387
|
+
* compileMessage("Заказы").source; // '() => [{ kind: "text", text: "Заказы" }]'
|
|
4388
|
+
* ```
|
|
4389
|
+
*/
|
|
4390
|
+
function compileMessage(text) {
|
|
4391
|
+
const scan = {
|
|
4392
|
+
params: {},
|
|
4393
|
+
usesArgument: false,
|
|
4394
|
+
usesParams: false,
|
|
4395
|
+
usesIntl: false
|
|
4396
|
+
};
|
|
4397
|
+
const pieces = piecesOf(parseMessage(text), scan);
|
|
4398
|
+
return {
|
|
4399
|
+
source: `${headOf(scan)} => ${toParts(pieces)}`,
|
|
4400
|
+
params: scan.params,
|
|
4401
|
+
usesArgument: scan.usesArgument
|
|
4402
|
+
};
|
|
4403
|
+
}
|
|
4404
|
+
//#endregion
|
|
4405
|
+
//#region src/plugins/i18n/compile/compile.ts
|
|
4406
|
+
/**
|
|
4407
|
+
* @file i18n plugin, build time — the walk over `features/*/strings/<locale>.json` and the two
|
|
4408
|
+
* generated modules it writes. Every problem of a run is collected and reported in one error, so
|
|
4409
|
+
* a game fixes its messages in one round. Node and Bun only: nothing under `src/` outside this
|
|
4410
|
+
* folder and `src/assets.ts` imports it, so no game bundles the ICU parser.
|
|
4411
|
+
*/
|
|
4412
|
+
/** How every problem of this compiler is prefixed. */
|
|
4413
|
+
const PREFIX = "[game] i18n: ";
|
|
4414
|
+
/**
|
|
4415
|
+
* Creates the table of compiled messages per locale. Its own function because lint rule L5
|
|
4416
|
+
* refuses a collection built inside an exported declaration.
|
|
4417
|
+
*
|
|
4418
|
+
* @returns An empty table.
|
|
4419
|
+
*/
|
|
4420
|
+
function emptyLocales() {
|
|
4421
|
+
return /* @__PURE__ */ new Map();
|
|
4422
|
+
}
|
|
4423
|
+
/**
|
|
4424
|
+
* Creates the table of which feature owns which key.
|
|
4425
|
+
*
|
|
4426
|
+
* @returns An empty table.
|
|
4427
|
+
*/
|
|
4428
|
+
function emptyOwners() {
|
|
4429
|
+
return /* @__PURE__ */ new Map();
|
|
4430
|
+
}
|
|
4431
|
+
/**
|
|
4432
|
+
* Creates the table of merged parameters per key.
|
|
4433
|
+
*
|
|
4434
|
+
* @returns An empty table.
|
|
4435
|
+
*/
|
|
4436
|
+
function emptyParameters() {
|
|
4437
|
+
return /* @__PURE__ */ new Map();
|
|
4438
|
+
}
|
|
4439
|
+
/**
|
|
4440
|
+
* Reads the message of anything that was thrown.
|
|
4441
|
+
*
|
|
4442
|
+
* @param error - What the `catch` caught.
|
|
4443
|
+
* @returns The message.
|
|
4444
|
+
*/
|
|
4445
|
+
function detailOf(error) {
|
|
4446
|
+
return error instanceof Error ? error.message : String(error);
|
|
4447
|
+
}
|
|
4448
|
+
/**
|
|
4449
|
+
* Collects every problem of a compile into one error.
|
|
4450
|
+
*
|
|
4451
|
+
* @param problems - The problems, in walk order.
|
|
4452
|
+
* @returns The error to reject with.
|
|
4453
|
+
*/
|
|
4454
|
+
function collected(problems) {
|
|
4455
|
+
const only = problems[0];
|
|
4456
|
+
if (problems.length === 1 && only !== void 0) return /* @__PURE__ */ new Error(`${PREFIX}${only}\n Fix the message or use a supported ICU feature.`);
|
|
4457
|
+
const lines = problems.map((text) => ` ${text}`).join("\n");
|
|
4458
|
+
return /* @__PURE__ */ new Error(`${PREFIX}the compile found ${problems.length} problems.\n${lines}\n Fix them and run "bun run assets:keys" again.`);
|
|
4459
|
+
}
|
|
4460
|
+
/**
|
|
4461
|
+
* Lists the sub-folders of a folder that may not exist.
|
|
4462
|
+
*
|
|
4463
|
+
* @param folder - Path of the folder.
|
|
4464
|
+
* @returns The names, sorted.
|
|
4465
|
+
*/
|
|
4466
|
+
async function listFolders(folder) {
|
|
4467
|
+
try {
|
|
4468
|
+
return (await readdir(folder, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).toSorted();
|
|
4469
|
+
} catch {
|
|
4470
|
+
return [];
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
/**
|
|
4474
|
+
* Lists the locales one feature brought, by the names of its string files.
|
|
4475
|
+
*
|
|
4476
|
+
* @param folder - Path of the `strings` folder.
|
|
4477
|
+
* @returns The locale names, sorted.
|
|
4478
|
+
*/
|
|
4479
|
+
async function listLocales(folder) {
|
|
4480
|
+
try {
|
|
4481
|
+
return (await readdir(folder, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name.slice(0, -5)).toSorted();
|
|
4482
|
+
} catch {
|
|
4483
|
+
return [];
|
|
4484
|
+
}
|
|
4485
|
+
}
|
|
4486
|
+
/**
|
|
4487
|
+
* Reads one string file as a flat table of key to message.
|
|
4488
|
+
*
|
|
4489
|
+
* @param file - Path of the file.
|
|
4490
|
+
* @param relative - The path a problem names it by.
|
|
4491
|
+
* @param walk - What the compile collects.
|
|
4492
|
+
* @returns The table, or `undefined` when the file is a problem.
|
|
4493
|
+
*/
|
|
4494
|
+
async function readTable(file, relative, walk) {
|
|
4495
|
+
try {
|
|
4496
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
4497
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
4498
|
+
walk.problems.push(`${relative}: a string file is an object of key to message.`);
|
|
4499
|
+
return;
|
|
4500
|
+
}
|
|
4501
|
+
return parsed;
|
|
4502
|
+
} catch (error) {
|
|
4503
|
+
walk.problems.push(`${relative} could not be read: ${detailOf(error)}.`);
|
|
4504
|
+
return;
|
|
4505
|
+
}
|
|
4506
|
+
}
|
|
4507
|
+
/**
|
|
4508
|
+
* Records which feature owns a key, refusing the same key in two features.
|
|
4509
|
+
*
|
|
4510
|
+
* @param walk - What the compile collects.
|
|
4511
|
+
* @param key - The message key.
|
|
4512
|
+
* @param source - Where this message came from.
|
|
4513
|
+
* @returns True when the key belongs to this feature.
|
|
4514
|
+
*/
|
|
4515
|
+
function claim(walk, key, source) {
|
|
4516
|
+
const known = walk.owner.get(key);
|
|
4517
|
+
if (known === void 0) {
|
|
4518
|
+
walk.owner.set(key, source);
|
|
4519
|
+
return true;
|
|
4520
|
+
}
|
|
4521
|
+
if (known.feature === source.feature) return true;
|
|
4522
|
+
walk.problems.push(`the key "${key}" is in ${known.relative} and ${source.relative}.`);
|
|
4523
|
+
return false;
|
|
4524
|
+
}
|
|
4525
|
+
/**
|
|
4526
|
+
* Sorts option names and drops the repeats.
|
|
4527
|
+
*
|
|
4528
|
+
* @param options - The option names of one select, from any number of locales.
|
|
4529
|
+
* @returns The names, unique and sorted.
|
|
4530
|
+
* @example
|
|
4531
|
+
* ```ts
|
|
4532
|
+
* unique(["language", "audio", "audio"]); // ["audio", "language"]
|
|
4533
|
+
* ```
|
|
4534
|
+
*/
|
|
4535
|
+
function unique(options) {
|
|
4536
|
+
return [...new Set(options)].toSorted();
|
|
4537
|
+
}
|
|
4538
|
+
/**
|
|
4539
|
+
* Creates the parameter table of one key.
|
|
4540
|
+
*
|
|
4541
|
+
* @returns An empty table.
|
|
4542
|
+
*/
|
|
4543
|
+
function emptyOrigins() {
|
|
4544
|
+
return /* @__PURE__ */ new Map();
|
|
4545
|
+
}
|
|
4546
|
+
/**
|
|
4547
|
+
* Merges the parameters one locale read for a key into what the other locales read.
|
|
4548
|
+
*
|
|
4549
|
+
* @param walk - What the compile collects.
|
|
4550
|
+
* @param key - The message key.
|
|
4551
|
+
* @param params - What this locale read.
|
|
4552
|
+
* @param relative - The file this locale came from.
|
|
4553
|
+
*/
|
|
4554
|
+
function mergeParameters(walk, key, params, relative) {
|
|
4555
|
+
const merged = walk.params.get(key) ?? emptyOrigins();
|
|
4556
|
+
walk.params.set(key, merged);
|
|
4557
|
+
for (const [name, type] of Object.entries(params)) {
|
|
4558
|
+
const known = merged.get(name);
|
|
4559
|
+
if (known === void 0) {
|
|
4560
|
+
merged.set(name, {
|
|
4561
|
+
type,
|
|
4562
|
+
relative
|
|
4563
|
+
});
|
|
4564
|
+
continue;
|
|
4565
|
+
}
|
|
4566
|
+
if (known.type.kind !== type.kind) {
|
|
4567
|
+
walk.problems.push(`"${key}": the parameter "${name}" is a ${known.type.kind} in ${known.relative} and a ${type.kind} in ${relative}.`);
|
|
4568
|
+
continue;
|
|
4569
|
+
}
|
|
4570
|
+
if (known.type.kind === "select" && type.kind === "select") merged.set(name, {
|
|
4571
|
+
type: {
|
|
4572
|
+
kind: "select",
|
|
4573
|
+
options: unique([...known.type.options, ...type.options])
|
|
4574
|
+
},
|
|
4575
|
+
relative: known.relative
|
|
4576
|
+
});
|
|
4577
|
+
}
|
|
4578
|
+
}
|
|
4579
|
+
/**
|
|
4580
|
+
* Compiles one message into the locale it belongs to.
|
|
4581
|
+
*
|
|
4582
|
+
* @param walk - What the compile collects.
|
|
4583
|
+
* @param key - The message key.
|
|
4584
|
+
* @param text - The message.
|
|
4585
|
+
* @param source - Where this message came from.
|
|
4586
|
+
*/
|
|
4587
|
+
function compileOne(walk, key, text, source) {
|
|
4588
|
+
if (typeof text !== "string") {
|
|
4589
|
+
walk.problems.push(`"${key}" in ${source.relative}: a message must be a string.`);
|
|
4590
|
+
return;
|
|
4591
|
+
}
|
|
4592
|
+
if (!claim(walk, key, source)) return;
|
|
4593
|
+
try {
|
|
4594
|
+
const compiled = compileMessage(text);
|
|
4595
|
+
const entries = walk.byLocale.get(source.locale) ?? [];
|
|
4596
|
+
entries.push({
|
|
4597
|
+
key,
|
|
4598
|
+
compiled
|
|
4599
|
+
});
|
|
4600
|
+
walk.byLocale.set(source.locale, entries);
|
|
4601
|
+
mergeParameters(walk, key, compiled.params, source.relative);
|
|
4602
|
+
} catch (error) {
|
|
4603
|
+
walk.problems.push(`"${key}" in ${source.relative}: ${detailOf(error)}.`);
|
|
4604
|
+
}
|
|
4605
|
+
}
|
|
4606
|
+
/**
|
|
4607
|
+
* Lists the keys a locale is missing, so a translator sees the gap without the build stopping.
|
|
4608
|
+
*
|
|
4609
|
+
* @param walk - What the compile collects.
|
|
4610
|
+
* @param locales - Every locale of the game.
|
|
4611
|
+
* @returns One note per missing key, sorted.
|
|
4612
|
+
*/
|
|
4613
|
+
function missingNotes(walk, locales) {
|
|
4614
|
+
const notes = [];
|
|
4615
|
+
for (const [key, source] of [...walk.owner].toSorted((left, right) => left[0] < right[0] ? -1 : 1)) for (const locale of locales) {
|
|
4616
|
+
if ((walk.byLocale.get(locale) ?? []).some((entry) => entry.key === key)) continue;
|
|
4617
|
+
notes.push(`the key "${key}" is missing from features/${source.feature}/strings/${locale}.json.`);
|
|
4618
|
+
}
|
|
4619
|
+
return notes;
|
|
4620
|
+
}
|
|
4621
|
+
/**
|
|
4622
|
+
* Builds the types module out of the merged parameters.
|
|
4623
|
+
*
|
|
4624
|
+
* @param walk - What the compile collects.
|
|
4625
|
+
* @returns One entry per key.
|
|
4626
|
+
*/
|
|
4627
|
+
function typeEntries(walk) {
|
|
4628
|
+
const entries = [];
|
|
4629
|
+
for (const [key, params] of walk.params) {
|
|
4630
|
+
const merged = {};
|
|
4631
|
+
for (const [name, origin] of params) merged[name] = origin.type;
|
|
4632
|
+
entries.push({
|
|
4633
|
+
key,
|
|
4634
|
+
params: merged
|
|
4635
|
+
});
|
|
4636
|
+
}
|
|
4637
|
+
return entries;
|
|
4638
|
+
}
|
|
4639
|
+
/**
|
|
4640
|
+
* Walks `features/*/strings/<locale>.json` of a game, compiles every message and writes the
|
|
4641
|
+
* generated modules: the types module `strings.ts` and one `strings.<locale>.ts` per locale. A
|
|
4642
|
+
* check run writes nothing and only reports whether something would change.
|
|
4643
|
+
*
|
|
4644
|
+
* @param root - Game source root, the folder that holds the features.
|
|
4645
|
+
* @param out - The generated directory the modules are written into.
|
|
4646
|
+
* @param options - Whether this is a check run, and what the features folder is called.
|
|
4647
|
+
* @returns Whether an output differed, the locales, the keys and the notes.
|
|
4648
|
+
* @throws {Error} One error that lists every problem the compile found.
|
|
4649
|
+
* @example
|
|
4650
|
+
* ```ts
|
|
4651
|
+
* const report = await compileStrings("src", "src/generated");
|
|
4652
|
+
* // report.locales: ["en", "ru"], report.changed: true on the first run
|
|
4653
|
+
* ```
|
|
4654
|
+
*/
|
|
4655
|
+
async function compileStrings(root, out, options = {}) {
|
|
4656
|
+
const walk = {
|
|
4657
|
+
byLocale: emptyLocales(),
|
|
4658
|
+
owner: emptyOwners(),
|
|
4659
|
+
params: emptyParameters(),
|
|
4660
|
+
problems: []
|
|
4661
|
+
};
|
|
4662
|
+
const featuresFolder = path.join(root, options.features ?? "features");
|
|
4663
|
+
for (const feature of await listFolders(featuresFolder)) {
|
|
4664
|
+
const stringsFolder = path.join(featuresFolder, feature, "strings");
|
|
4665
|
+
for (const locale of await listLocales(stringsFolder)) {
|
|
4666
|
+
const relative = `features/${feature}/strings/${locale}.json`;
|
|
4667
|
+
const table = await readTable(path.join(stringsFolder, `${locale}.json`), relative, walk);
|
|
4668
|
+
if (table === void 0) continue;
|
|
4669
|
+
for (const [key, text] of Object.entries(table)) compileOne(walk, key, text, {
|
|
4670
|
+
feature,
|
|
4671
|
+
locale,
|
|
4672
|
+
relative
|
|
4673
|
+
});
|
|
4674
|
+
}
|
|
4675
|
+
}
|
|
4676
|
+
if (walk.problems.length > 0) throw collected(walk.problems);
|
|
4677
|
+
const locales = [...walk.byLocale.keys()].toSorted();
|
|
4678
|
+
const keys = [...walk.owner.keys()].toSorted();
|
|
4679
|
+
if (locales.length === 0) return {
|
|
4680
|
+
changed: false,
|
|
4681
|
+
locales,
|
|
4682
|
+
keys,
|
|
4683
|
+
notes: []
|
|
4684
|
+
};
|
|
4685
|
+
const write = options.check !== true;
|
|
4686
|
+
const outputs = [{
|
|
4687
|
+
file: path.join(out, "strings.ts"),
|
|
4688
|
+
text: emitTypes(typeEntries(walk))
|
|
4689
|
+
}, ...locales.map((locale) => ({
|
|
4690
|
+
file: path.join(out, `strings.${locale}.ts`),
|
|
4691
|
+
text: emitLocale(walk.byLocale.get(locale) ?? [])
|
|
4692
|
+
}))];
|
|
4693
|
+
let changed = false;
|
|
4694
|
+
for (const output of outputs) changed = await writeIfChanged(output.file, output.text, write) || changed;
|
|
4695
|
+
return {
|
|
4696
|
+
changed,
|
|
4697
|
+
locales,
|
|
4698
|
+
keys,
|
|
4699
|
+
notes: missingNotes(walk, locales)
|
|
4700
|
+
};
|
|
4701
|
+
}
|
|
4702
|
+
/**
|
|
4703
|
+
* Tells whether the generated modules of a game are current, without writing anything. The
|
|
4704
|
+
* `--check` run of `bun run assets:keys` covers the strings with it.
|
|
4705
|
+
*
|
|
4706
|
+
* @param root - Game source root, the folder that holds the features.
|
|
4707
|
+
* @param out - The generated directory the modules live in.
|
|
4708
|
+
* @returns True when nothing would change.
|
|
4709
|
+
* @throws {Error} One error that lists every problem the compile found.
|
|
4710
|
+
* @example
|
|
4711
|
+
* ```ts
|
|
4712
|
+
* await checkStrings("src", "src/generated"); // false when a message changed since the last run
|
|
4713
|
+
* ```
|
|
4714
|
+
*/
|
|
4715
|
+
async function checkStrings(root, out) {
|
|
4716
|
+
return !(await compileStrings(root, out, { check: true })).changed;
|
|
4717
|
+
}
|
|
4718
|
+
//#endregion
|
|
4719
|
+
//#region src/assets.ts
|
|
4720
|
+
/**
|
|
4721
|
+
* @file The node-side door of the engine (subpath `./assets`, node and bun only): asset and file
|
|
4722
|
+
* work that has no place in a browser bundle. It holds the asset key scanner and the string
|
|
4723
|
+
* compiler; a game runs both as `bun run assets:keys`, the editor imports the same functions later.
|
|
4724
|
+
* Re-exports only: the code lives in `plugins/assets/scan/` and `plugins/i18n/compile/`, and this is
|
|
4725
|
+
* the one door to them in `src/`.
|
|
4726
|
+
*/
|
|
4727
|
+
if (import.meta.main) process.exitCode = await runCli(process.argv.slice(2), compileStrings);
|
|
4728
|
+
//#endregion
|
|
4729
|
+
export { checkStrings, compileStrings, emitKeys, emitManifest, runCli, scanAssets };
|