@moku-labs/game 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/assets.d.mts +181 -22
- package/dist/assets.mjs +1186 -160
- package/dist/{commands-C0BljsM5.d.mts → commands-DYO22lzi.d.mts} +22 -5
- package/dist/control.d.mts +3 -3
- package/dist/control.mjs +3 -3
- package/dist/{define-CjP_4H0G.d.mts → define-BKWjWgyQ.d.mts} +1 -1
- package/dist/dev-CQYJPmcD.mjs +1183 -0
- package/dist/flatten-BkXuscAV.mjs +100 -0
- package/dist/index.d.mts +85 -31
- package/dist/index.mjs +3208 -490
- package/dist/{session-DmAxY6Ll.mjs → input-CK4nxBO1.mjs} +41 -12
- package/dist/inspect.d.mts +32 -9
- package/dist/inspect.mjs +2 -2
- package/dist/jsx-dev-runtime.d.mts +1 -1
- package/dist/jsx-dev-runtime.mjs +1 -1
- package/dist/jsx-runtime.d.mts +1 -1
- package/dist/jsx-runtime.mjs +1 -1
- package/dist/{run-tlcn1w7y.mjs → run-BfuMLahc.mjs} +489 -213
- package/dist/{runtime-Bn5TXNxP.d.mts → runtime-Bhu442L8.d.mts} +1 -1
- package/dist/{runtime-DRlwxkIv.mjs → runtime-CBZgWgzx.mjs} +1 -57
- package/dist/{sources-BooL2MsX.mjs → sources-C1uGjz4l.mjs} +198 -21
- package/dist/testing.d.mts +5 -5
- package/dist/testing.mjs +5 -4
- package/dist/{types-CxZy7Pi4.d.mts → types-BnWH-75b.d.mts} +95 -21
- package/dist/{types-CqkrEY06.d.mts → types-CVUHPvMB.d.mts} +5 -434
- package/dist/{types-DmhTHRVW.d.mts → types-DtmFVCHf.d.mts} +1 -1
- package/dist/{types-BZ1OLf24.d.mts → types-Dy1xB68L.d.mts} +2318 -1169
- package/dist/{types-X9jXjka3.d.mts → types-VErbiEXy.d.mts} +867 -198
- package/dist/{visual-NPrDkvUk.mjs → visual-DUvvPPCt.mjs} +51 -1
- package/llms.txt +5 -5
- package/package.json +5 -5
- package/dist/component-DGg5DqKK.mjs +0 -44
- package/dist/dev-CcwFrwok.mjs +0 -413
- package/dist/types-C47fKNcn.d.mts +0 -695
package/dist/assets.mjs
CHANGED
|
@@ -922,19 +922,21 @@ function fontPageName(bundle, key, index, hash, extension) {
|
|
|
922
922
|
return `${bundle}/${key}-${index}-${hash}.${extension}`;
|
|
923
923
|
}
|
|
924
924
|
/**
|
|
925
|
-
* Names the file of a sound.
|
|
925
|
+
* Names the file of a sound. The sound keeps its format, and so its extension: the loader reads
|
|
926
|
+
* the MIME type of a sound from it.
|
|
926
927
|
*
|
|
927
928
|
* @param bundle - Name of the bundle.
|
|
928
929
|
* @param key - Asset key of the sound.
|
|
929
930
|
* @param hash - The content hash of the bytes.
|
|
931
|
+
* @param extension - The extension of the source, without the dot: `mp3` or `m4a`.
|
|
930
932
|
* @returns The path inside the pack folder.
|
|
931
933
|
* @example
|
|
932
934
|
* ```ts
|
|
933
|
-
* audioName("ui", "ui.
|
|
935
|
+
* audioName("ui", "ui.theme", "9c4e2b7a10", "m4a"); // "ui/ui.theme-9c4e2b7a10.m4a"
|
|
934
936
|
* ```
|
|
935
937
|
*/
|
|
936
|
-
function audioName(bundle, key, hash) {
|
|
937
|
-
return `${bundle}/${key}-${hash}
|
|
938
|
+
function audioName(bundle, key, hash, extension) {
|
|
939
|
+
return `${bundle}/${key}-${hash}.${extension}`;
|
|
938
940
|
}
|
|
939
941
|
//#endregion
|
|
940
942
|
//#region src/plugins/assets/scan/pack/copy.ts
|
|
@@ -1008,17 +1010,17 @@ async function copyFont(root, bundle, file, fontPath) {
|
|
|
1008
1010
|
};
|
|
1009
1011
|
}
|
|
1010
1012
|
/**
|
|
1011
|
-
* Copies one sound byte for byte under its hashed name.
|
|
1013
|
+
* Copies one sound byte for byte under its hashed name, with the extension of its source.
|
|
1012
1014
|
*
|
|
1013
1015
|
* @param root - The game source root.
|
|
1014
1016
|
* @param bundle - Name of the bundle.
|
|
1015
1017
|
* @param file - The sound of the scanned manifest.
|
|
1016
|
-
* @param soundPath - The path of its `.mp3` file.
|
|
1018
|
+
* @param soundPath - The path of its `.mp3` or `.m4a` file.
|
|
1017
1019
|
* @returns The packed entry and the file to write.
|
|
1018
1020
|
*/
|
|
1019
1021
|
async function copyAudio(root, bundle, file, soundPath) {
|
|
1020
1022
|
const bytes = await readSource(root, soundPath);
|
|
1021
|
-
const name = audioName(bundle, file.key, contentHash(bytes));
|
|
1023
|
+
const name = audioName(bundle, file.key, contentHash(bytes), extensionOf(soundPath));
|
|
1022
1024
|
return {
|
|
1023
1025
|
file: {
|
|
1024
1026
|
...file,
|
|
@@ -1165,7 +1167,7 @@ function messageOf$1(failure) {
|
|
|
1165
1167
|
* @param problems - The problems, in pack order.
|
|
1166
1168
|
* @returns The error to reject with.
|
|
1167
1169
|
*/
|
|
1168
|
-
function collected$
|
|
1170
|
+
function collected$1(problems) {
|
|
1169
1171
|
const count = problems.length;
|
|
1170
1172
|
const lines = problems.map((text) => ` ${text}`).join("\n");
|
|
1171
1173
|
return /* @__PURE__ */ new Error(`[game] assets: the pack found ${count} problem${count === 1 ? "" : "s"}.\n${lines}\n Fix them and run "bun run assets:pack" again.`);
|
|
@@ -1683,7 +1685,7 @@ async function packAssets(options) {
|
|
|
1683
1685
|
bundles
|
|
1684
1686
|
};
|
|
1685
1687
|
if (run.problems.length === 0) run.problems.push(...checkPacked(options.manifest, manifest));
|
|
1686
|
-
if (run.problems.length > 0) throw collected$
|
|
1688
|
+
if (run.problems.length > 0) throw collected$1(run.problems);
|
|
1687
1689
|
const bytes = await writePack(options, run.outputs, emitManifest(manifest));
|
|
1688
1690
|
const all = Object.values(bundles);
|
|
1689
1691
|
return {
|
|
@@ -1698,7 +1700,7 @@ async function packAssets(options) {
|
|
|
1698
1700
|
//#region src/plugins/assets/scan/keys.ts
|
|
1699
1701
|
const TEXTURE = /\.(?:png|webp)$/i;
|
|
1700
1702
|
const FONT = /\.fnt$/i;
|
|
1701
|
-
const AUDIO = /\.mp3$/i;
|
|
1703
|
+
const AUDIO = /\.(?:mp3|m4a)$/i;
|
|
1702
1704
|
const TAG_GROUPS = /^(?:\{[^{}]*\})*$/;
|
|
1703
1705
|
const WHOLE_NUMBER = /^\d+$/;
|
|
1704
1706
|
/**
|
|
@@ -1736,13 +1738,15 @@ function problem$3(message) {
|
|
|
1736
1738
|
}
|
|
1737
1739
|
/**
|
|
1738
1740
|
* Tells what the scanner makes of a file: one texture per PNG or WebP, one font per `.fnt` with
|
|
1739
|
-
* the pages it names, one sound per MP3. Everything else is left
|
|
1741
|
+
* the pages it names, one sound per MP3 or M4A (AAC in an MP4 container). Everything else is left
|
|
1742
|
+
* out with a note.
|
|
1740
1743
|
*
|
|
1741
1744
|
* @param fileName - Name of the file, with its extension.
|
|
1742
1745
|
* @returns The kind of the asset, or `undefined` when the scanner reads no such file.
|
|
1743
1746
|
* @example
|
|
1744
1747
|
* ```ts
|
|
1745
1748
|
* assetKindOf("body.fnt"); // "font"
|
|
1749
|
+
* assetKindOf("theme.m4a"); // "audio"
|
|
1746
1750
|
* assetKindOf("theme.ogg"); // undefined
|
|
1747
1751
|
* ```
|
|
1748
1752
|
*/
|
|
@@ -1755,7 +1759,7 @@ function assetKindOf(fileName) {
|
|
|
1755
1759
|
* Tells whether the scanner reads this file.
|
|
1756
1760
|
*
|
|
1757
1761
|
* @param fileName - Name of the file, with its extension.
|
|
1758
|
-
* @returns True for a PNG, a WebP, a `.fnt
|
|
1762
|
+
* @returns True for a PNG, a WebP, a `.fnt`, an MP3 or an M4A.
|
|
1759
1763
|
* @example
|
|
1760
1764
|
* ```ts
|
|
1761
1765
|
* isAssetFile("star-on.webp"); // true
|
|
@@ -1903,8 +1907,11 @@ const TIERS = [
|
|
|
1903
1907
|
];
|
|
1904
1908
|
const PREFIX$1 = "[game] assets: ";
|
|
1905
1909
|
const ESCAPE = /[.+^${}()|[\]\\]/g;
|
|
1906
|
-
/**
|
|
1907
|
-
|
|
1910
|
+
/**
|
|
1911
|
+
* The formats a game brings instead of an MP3 or an M4A. Only those two decode in every browser
|
|
1912
|
+
* and WebView; a raw ADTS `.aac` is AAC without its MP4 container, so it stays out too.
|
|
1913
|
+
*/
|
|
1914
|
+
const OTHER_AUDIO = /\.(?:ogg|wav|aac|opus|flac)$/i;
|
|
1908
1915
|
/** A vector font. The engine draws bitmap text, so a font arrives as a `.fnt` with its pages. */
|
|
1909
1916
|
const OTHER_FONT = /\.(?:ttf|otf|woff2?)$/i;
|
|
1910
1917
|
/** A page name that says "this folder". */
|
|
@@ -2186,13 +2193,13 @@ function draftOf(scan, name, feature, tier) {
|
|
|
2186
2193
|
* @example
|
|
2187
2194
|
* ```ts
|
|
2188
2195
|
* ignoredNote("features/ui/assets/click.wav");
|
|
2189
|
-
* // 'ignored "features/ui/assets/click.wav": audio is .mp3 only.'
|
|
2196
|
+
* // 'ignored "features/ui/assets/click.wav": audio is .mp3 or .m4a only.'
|
|
2190
2197
|
* ```
|
|
2191
2198
|
*/
|
|
2192
2199
|
function ignoredNote(file) {
|
|
2193
|
-
if (OTHER_AUDIO.test(file)) return `ignored "${file}": audio is .mp3 only.`;
|
|
2200
|
+
if (OTHER_AUDIO.test(file)) return `ignored "${file}": audio is .mp3 or .m4a only.`;
|
|
2194
2201
|
if (OTHER_FONT.test(file)) return `ignored "${file}": a font is a .fnt file with its .png pages.`;
|
|
2195
|
-
return `ignored "${file}": the scanner reads .png, .webp, .fnt and .
|
|
2202
|
+
return `ignored "${file}": the scanner reads .png, .webp, .fnt, .mp3 and .m4a only.`;
|
|
2196
2203
|
}
|
|
2197
2204
|
/**
|
|
2198
2205
|
* Works out where a page of a font lives: page names are relative to the `.fnt` file.
|
|
@@ -2407,7 +2414,7 @@ function toManifest(scan) {
|
|
|
2407
2414
|
* @param problems - The problems, in scan order.
|
|
2408
2415
|
* @returns The error to reject with.
|
|
2409
2416
|
*/
|
|
2410
|
-
function collected
|
|
2417
|
+
function collected(problems) {
|
|
2411
2418
|
const count = problems.length;
|
|
2412
2419
|
const lines = problems.map((text) => ` ${text}`).join("\n");
|
|
2413
2420
|
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.`);
|
|
@@ -2470,7 +2477,7 @@ async function applyOutputs(outputs, write) {
|
|
|
2470
2477
|
async function scanAssets(options) {
|
|
2471
2478
|
const scan = createState(options.root, options.features ?? "features");
|
|
2472
2479
|
for (const feature of await listFeatures(scan.featuresFolder)) await scanFeature(scan, feature);
|
|
2473
|
-
if (scan.problems.length > 0) throw collected
|
|
2480
|
+
if (scan.problems.length > 0) throw collected(scan.problems);
|
|
2474
2481
|
const manifest = toManifest(scan);
|
|
2475
2482
|
const manifestSource = emitManifest(manifest);
|
|
2476
2483
|
const keysSource = emitKeys(manifest);
|
|
@@ -2490,15 +2497,19 @@ async function scanAssets(options) {
|
|
|
2490
2497
|
//#endregion
|
|
2491
2498
|
//#region src/plugins/assets/scan/cli.ts
|
|
2492
2499
|
/**
|
|
2493
|
-
* @file assets plugin, build time — the command line of the scanner
|
|
2494
|
-
* only file here that touches the terminal: the scan, the emitters, the key rule
|
|
2495
|
-
* report back instead of printing. Output goes through the branded console of
|
|
2496
|
-
* `@moku-labs/common`, and `runCli` returns the exit code instead of taking it. The string
|
|
2497
|
-
*
|
|
2498
|
-
* hands
|
|
2500
|
+
* @file assets plugin, build time — the command line of the scanner, the packer and the string
|
|
2501
|
+
* tools. It is the only file here that touches the terminal: the scan, the emitters, the key rule
|
|
2502
|
+
* and the packer report back instead of printing. Output goes through the branded console of
|
|
2503
|
+
* `@moku-labs/common`, and `runCli` returns the exit code instead of taking it. The string tools
|
|
2504
|
+
* of i18n (compile, export, import) come in as a parameter: i18n sits above assets, so the door
|
|
2505
|
+
* `src/assets.ts` hands them over instead of this file importing them.
|
|
2499
2506
|
*/
|
|
2500
2507
|
const MANIFEST_FILE = "manifest.json";
|
|
2501
2508
|
const KEYS_FILE = path.join("generated", "assets.ts");
|
|
2509
|
+
/** The locale translators read from when `--source` names none. */
|
|
2510
|
+
const DEFAULT_SOURCE$1 = "en";
|
|
2511
|
+
/** The extension of an exchange file: the export writes `<dir>/<locale>.json`. */
|
|
2512
|
+
const EXCHANGE_EXTENSION = ".json";
|
|
2502
2513
|
/**
|
|
2503
2514
|
* Wraps a command line problem in the message shape of the framework.
|
|
2504
2515
|
*
|
|
@@ -2518,55 +2529,107 @@ function messageOf(failure) {
|
|
|
2518
2529
|
return failure instanceof Error ? failure.message : String(failure);
|
|
2519
2530
|
}
|
|
2520
2531
|
/**
|
|
2521
|
-
* Tells whether an argument is one of the flags that take a
|
|
2532
|
+
* Tells whether an argument is one of the flags that take a value.
|
|
2522
2533
|
*
|
|
2523
2534
|
* @param flag - The argument.
|
|
2524
|
-
* @returns True for
|
|
2535
|
+
* @returns True for the path flags and `--source`.
|
|
2525
2536
|
*/
|
|
2526
|
-
function
|
|
2527
|
-
return flag === "--root" || flag === "--manifest" || flag === "--keys" || flag === "--pack";
|
|
2537
|
+
function isValueFlag(flag) {
|
|
2538
|
+
return flag === "--root" || flag === "--manifest" || flag === "--keys" || flag === "--pack" || flag === "--export" || flag === "--import" || flag === "--source";
|
|
2528
2539
|
}
|
|
2529
2540
|
/**
|
|
2530
|
-
*
|
|
2531
|
-
*
|
|
2541
|
+
* Tells whether an argument is one of the flags that stand alone.
|
|
2542
|
+
*
|
|
2543
|
+
* @param flag - The argument.
|
|
2544
|
+
* @returns True for `--check`, `--no-cache` and `--pseudo`.
|
|
2545
|
+
*/
|
|
2546
|
+
function isSwitch(flag) {
|
|
2547
|
+
return flag === "--check" || flag === "--no-cache" || flag === "--pseudo";
|
|
2548
|
+
}
|
|
2549
|
+
/**
|
|
2550
|
+
* Reads the flags as they were given.
|
|
2532
2551
|
*
|
|
2533
2552
|
* @param argv - The arguments after the script name.
|
|
2534
|
-
* @returns The
|
|
2553
|
+
* @returns The value of each flag that takes one, and the switches.
|
|
2535
2554
|
* @throws {Error} When an option is unknown or misses its value.
|
|
2536
2555
|
*/
|
|
2537
|
-
function
|
|
2538
|
-
const
|
|
2539
|
-
|
|
2540
|
-
"--manifest": void 0,
|
|
2541
|
-
"--keys": void 0,
|
|
2542
|
-
"--pack": void 0
|
|
2543
|
-
};
|
|
2544
|
-
let check = false;
|
|
2545
|
-
let cache = true;
|
|
2556
|
+
function readFlags(argv) {
|
|
2557
|
+
const values = {};
|
|
2558
|
+
const switches = /* @__PURE__ */ new Set();
|
|
2546
2559
|
let index = 0;
|
|
2547
2560
|
while (index < argv.length) {
|
|
2548
2561
|
const flag = argv[index];
|
|
2549
|
-
if (flag
|
|
2550
|
-
|
|
2551
|
-
else cache = false;
|
|
2562
|
+
if (isSwitch(flag)) {
|
|
2563
|
+
switches.add(flag);
|
|
2552
2564
|
index += 1;
|
|
2553
2565
|
continue;
|
|
2554
2566
|
}
|
|
2555
|
-
if (!
|
|
2567
|
+
if (!isValueFlag(flag)) throw problem$1(`unknown option "${String(flag)}".`);
|
|
2556
2568
|
const value = argv[index + 1];
|
|
2557
|
-
if (value === void 0 || value.startsWith("--")) throw problem$1(`"${flag}" needs a path.`);
|
|
2558
|
-
|
|
2569
|
+
if (value === void 0 || value.startsWith("--")) throw problem$1(`"${flag}" needs ${flag === "--source" ? "a locale" : "a path"}.`);
|
|
2570
|
+
values[flag] = value;
|
|
2559
2571
|
index += 2;
|
|
2560
2572
|
}
|
|
2561
|
-
|
|
2562
|
-
|
|
2573
|
+
return {
|
|
2574
|
+
values,
|
|
2575
|
+
switches
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
/**
|
|
2579
|
+
* Refuses the flags that do not go together, before anything is read or written. An exchange
|
|
2580
|
+
* with the translators runs alone; only `--pseudo` may join an import, because the compile after
|
|
2581
|
+
* it writes the pseudo-locale too.
|
|
2582
|
+
*
|
|
2583
|
+
* @param flags - The flags as they were given.
|
|
2584
|
+
* @throws {Error} When two flags do not go together.
|
|
2585
|
+
*/
|
|
2586
|
+
function refuseConflicts(flags) {
|
|
2587
|
+
const { values, switches } = flags;
|
|
2588
|
+
const exporting = values["--export"] !== void 0;
|
|
2589
|
+
const importing = values["--import"] !== void 0;
|
|
2590
|
+
const packing = values["--pack"] !== void 0;
|
|
2591
|
+
if (exporting || importing) {
|
|
2592
|
+
if (exporting && importing || packing || switches.has("--check") || exporting && switches.has("--pseudo")) throw problem$1("\"--export\" and \"--import\" run alone; drop the other flags.");
|
|
2593
|
+
return;
|
|
2594
|
+
}
|
|
2595
|
+
if (values["--source"] !== void 0) throw problem$1("\"--source\" goes with \"--export\" or \"--import\".");
|
|
2596
|
+
if (packing && switches.has("--pseudo")) throw problem$1("\"--pseudo\" is for a dev run; drop it from \"--pack\".");
|
|
2597
|
+
if (packing && switches.has("--check")) throw problem$1("\"--pack\" writes files; drop \"--check\".");
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* Resolves a folder flag against the working directory.
|
|
2601
|
+
*
|
|
2602
|
+
* @param given - The value of the flag, or `undefined` when it was not given.
|
|
2603
|
+
* @returns The absolute path, or `undefined`.
|
|
2604
|
+
*/
|
|
2605
|
+
function resolveGiven(given) {
|
|
2606
|
+
return given === void 0 ? void 0 : path.resolve(given);
|
|
2607
|
+
}
|
|
2608
|
+
/**
|
|
2609
|
+
* Reads the flags. `--keys` defaults to `generated/assets.ts` inside the root; `--manifest` to
|
|
2610
|
+
* `manifest.json` inside the root, or inside the pack folder with `--pack`; `--source` to `"en"`.
|
|
2611
|
+
*
|
|
2612
|
+
* @param argv - The arguments after the script name.
|
|
2613
|
+
* @returns The resolved paths, the folders of the three modes and the switches.
|
|
2614
|
+
* @throws {Error} When an option is unknown, misses its value or does not go with another.
|
|
2615
|
+
*/
|
|
2616
|
+
function parseArgv(argv) {
|
|
2617
|
+
const flags = readFlags(argv);
|
|
2618
|
+
refuseConflicts(flags);
|
|
2619
|
+
const { values, switches } = flags;
|
|
2620
|
+
const root = path.resolve(values["--root"] ?? ".");
|
|
2621
|
+
const pack = resolveGiven(values["--pack"]);
|
|
2563
2622
|
return {
|
|
2564
2623
|
root,
|
|
2565
|
-
manifest: path.resolve(
|
|
2566
|
-
keys: path.resolve(
|
|
2567
|
-
check,
|
|
2624
|
+
manifest: path.resolve(values["--manifest"] ?? path.join(pack ?? root, MANIFEST_FILE)),
|
|
2625
|
+
keys: path.resolve(values["--keys"] ?? path.join(root, KEYS_FILE)),
|
|
2626
|
+
check: switches.has("--check"),
|
|
2568
2627
|
pack,
|
|
2569
|
-
cache
|
|
2628
|
+
cache: !switches.has("--no-cache"),
|
|
2629
|
+
pseudo: switches.has("--pseudo"),
|
|
2630
|
+
source: values["--source"] ?? DEFAULT_SOURCE$1,
|
|
2631
|
+
exportDir: resolveGiven(values["--export"]),
|
|
2632
|
+
importDir: resolveGiven(values["--import"])
|
|
2570
2633
|
};
|
|
2571
2634
|
}
|
|
2572
2635
|
/**
|
|
@@ -2618,6 +2681,37 @@ function stringsSummary(report) {
|
|
|
2618
2681
|
return `${report.keys.length} strings in ${report.locales.length} locales (${report.locales.join(", ")}).`;
|
|
2619
2682
|
}
|
|
2620
2683
|
/**
|
|
2684
|
+
* One line about one exported locale: its file and how many keys it lacks.
|
|
2685
|
+
*
|
|
2686
|
+
* @param dir - The export folder, resolved.
|
|
2687
|
+
* @param locale - The locale of the file.
|
|
2688
|
+
* @param missing - How many keys have no text in that locale.
|
|
2689
|
+
* @returns The line for the console.
|
|
2690
|
+
* @example
|
|
2691
|
+
* ```ts
|
|
2692
|
+
* exportLine("/game/translations", "ru", 3); // 'exported "/game/translations/ru.json": 3 missing.'
|
|
2693
|
+
* ```
|
|
2694
|
+
*/
|
|
2695
|
+
function exportLine(dir, locale, missing) {
|
|
2696
|
+
return `exported "${path.join(dir, locale + EXCHANGE_EXTENSION)}": ${missing} missing.`;
|
|
2697
|
+
}
|
|
2698
|
+
/**
|
|
2699
|
+
* One line about an import. The importer reports the totals of the run, so one line sums up
|
|
2700
|
+
* every locale: the folder, the locales read, the keys written and the feature files rewritten.
|
|
2701
|
+
*
|
|
2702
|
+
* @param dir - The import folder, resolved.
|
|
2703
|
+
* @param report - What the import wrote.
|
|
2704
|
+
* @returns The line for the console.
|
|
2705
|
+
* @example
|
|
2706
|
+
* ```ts
|
|
2707
|
+
* importLine("/game/translations", { locales: ["ru"], keys: 3, files: ["a.json", "b.json"] });
|
|
2708
|
+
* // 'imported "/game/translations" (ru): 3 keys into 2 files.'
|
|
2709
|
+
* ```
|
|
2710
|
+
*/
|
|
2711
|
+
function importLine(dir, report) {
|
|
2712
|
+
return `imported "${dir}"${report.locales.length === 0 ? "" : ` (${report.locales.join(", ")})`}: ${plural(report.keys, "key")} into ${plural(report.files.length, "file")}.`;
|
|
2713
|
+
}
|
|
2714
|
+
/**
|
|
2621
2715
|
* One line about a packed bundle.
|
|
2622
2716
|
*
|
|
2623
2717
|
* @param name - Name of the bundle.
|
|
@@ -2653,6 +2747,38 @@ function packSummary(file, result, cache) {
|
|
|
2653
2747
|
return `wrote "${file}": ${plural(result.pages, "page")}, ${plural(result.loose, "loose file")}, ${plural(fonts, "font")}, ${plural(sounds, "sound")}, ${Math.round(result.bytes / 1024)} KB. cache: ${hits}.`;
|
|
2654
2748
|
}
|
|
2655
2749
|
/**
|
|
2750
|
+
* The dev run of `bun run assets:keys`: walk the features, write the manifest and the key module, then compile the
|
|
2751
|
+
* feature strings next to the key module (the pseudo-locale too with `--pseudo`). With `--check`
|
|
2752
|
+
* nothing is written and a difference fails the run.
|
|
2753
|
+
*
|
|
2754
|
+
* @param options - The parsed flags.
|
|
2755
|
+
* @param compile - The strings compiler.
|
|
2756
|
+
* @param ui - Where the lines go.
|
|
2757
|
+
* @returns The exit code: `1` when `--check` found a difference, else `0`.
|
|
2758
|
+
* @throws {Error} When the scan or the compile fails.
|
|
2759
|
+
*/
|
|
2760
|
+
async function runKeys(options, compile, ui) {
|
|
2761
|
+
const { manifest, changed, notes } = await scanAssets({
|
|
2762
|
+
root: options.root,
|
|
2763
|
+
manifest: options.manifest,
|
|
2764
|
+
keys: options.keys,
|
|
2765
|
+
write: !options.check
|
|
2766
|
+
});
|
|
2767
|
+
const strings = await compile(options.root, path.dirname(options.keys), {
|
|
2768
|
+
check: options.check,
|
|
2769
|
+
pseudo: options.pseudo
|
|
2770
|
+
});
|
|
2771
|
+
for (const note of [...notes, ...strings.notes]) ui.warn(note);
|
|
2772
|
+
if ((changed || strings.changed) && options.check) {
|
|
2773
|
+
ui.error(`${changed ? outputNames(options) : "the generated strings"} are out of date.\n Run "bun run assets:keys" and commit the result.`);
|
|
2774
|
+
return 1;
|
|
2775
|
+
}
|
|
2776
|
+
ui.info(changed ? `wrote ${outputNames(options)}.` : `${outputNames(options)} are up to date.`);
|
|
2777
|
+
ui.info(summary(manifest));
|
|
2778
|
+
if (strings.keys.length > 0) ui.info(stringsSummary(strings));
|
|
2779
|
+
return 0;
|
|
2780
|
+
}
|
|
2781
|
+
/**
|
|
2656
2782
|
* The `--pack` run: the same scan and key list as a dev run, the strings compile, then the packer.
|
|
2657
2783
|
* The keys are written as a dev run writes them; the dev manifest is not touched, the packed one
|
|
2658
2784
|
* goes to `--manifest`.
|
|
@@ -2662,10 +2788,9 @@ function packSummary(file, result, cache) {
|
|
|
2662
2788
|
* @param compile - The strings compiler.
|
|
2663
2789
|
* @param ui - Where the lines go.
|
|
2664
2790
|
* @returns The exit code.
|
|
2665
|
-
* @throws {Error}
|
|
2791
|
+
* @throws {Error} When the scan, the compile or the pack fails.
|
|
2666
2792
|
*/
|
|
2667
2793
|
async function runPack(options, pack, compile, ui) {
|
|
2668
|
-
if (options.check) throw problem$1("\"--pack\" writes files; drop \"--check\".");
|
|
2669
2794
|
const scan = await scanAssets({
|
|
2670
2795
|
root: options.root,
|
|
2671
2796
|
manifest: options.manifest,
|
|
@@ -2676,7 +2801,10 @@ async function runPack(options, pack, compile, ui) {
|
|
|
2676
2801
|
file: options.keys,
|
|
2677
2802
|
text: scan.keysSource
|
|
2678
2803
|
}], true);
|
|
2679
|
-
const strings = await compile(options.root, path.dirname(options.keys), {
|
|
2804
|
+
const strings = await compile(options.root, path.dirname(options.keys), {
|
|
2805
|
+
check: false,
|
|
2806
|
+
pseudo: false
|
|
2807
|
+
});
|
|
2680
2808
|
for (const note of [...scan.notes, ...strings.notes]) ui.warn(note);
|
|
2681
2809
|
const result = await packAssets({
|
|
2682
2810
|
root: options.root,
|
|
@@ -2690,45 +2818,70 @@ async function runPack(options, pack, compile, ui) {
|
|
|
2690
2818
|
return 0;
|
|
2691
2819
|
}
|
|
2692
2820
|
/**
|
|
2821
|
+
* The `--export <dir>` run: the exchange files for the translators and nothing else. No asset
|
|
2822
|
+
* scan, no generated module.
|
|
2823
|
+
*
|
|
2824
|
+
* @param options - The parsed flags.
|
|
2825
|
+
* @param dir - The export folder, resolved.
|
|
2826
|
+
* @param exportStrings - The strings exporter.
|
|
2827
|
+
* @param ui - Where the lines go.
|
|
2828
|
+
* @returns The exit code.
|
|
2829
|
+
* @throws {Error} When the export fails.
|
|
2830
|
+
*/
|
|
2831
|
+
async function runExport(options, dir, exportStrings, ui) {
|
|
2832
|
+
const report = await exportStrings(options.root, dir, { source: options.source });
|
|
2833
|
+
for (const locale of report.locales) ui.info(exportLine(dir, locale, report.missing[locale] ?? 0));
|
|
2834
|
+
return 0;
|
|
2835
|
+
}
|
|
2836
|
+
/**
|
|
2837
|
+
* The `--import <dir>` run: the translated files go into the feature string files, and the
|
|
2838
|
+
* importer compiles into the folder of the key module. No asset scan.
|
|
2839
|
+
*
|
|
2840
|
+
* @param options - The parsed flags.
|
|
2841
|
+
* @param dir - The import folder, resolved.
|
|
2842
|
+
* @param importStrings - The strings importer.
|
|
2843
|
+
* @param ui - Where the lines go.
|
|
2844
|
+
* @returns The exit code.
|
|
2845
|
+
* @throws {Error} When the import or its compile fails.
|
|
2846
|
+
*/
|
|
2847
|
+
async function runImport(options, dir, importStrings, ui) {
|
|
2848
|
+
const report = await importStrings(options.root, dir, {
|
|
2849
|
+
source: options.source,
|
|
2850
|
+
pseudo: options.pseudo,
|
|
2851
|
+
out: path.dirname(options.keys)
|
|
2852
|
+
});
|
|
2853
|
+
ui.info(importLine(dir, report));
|
|
2854
|
+
return 0;
|
|
2855
|
+
}
|
|
2856
|
+
/**
|
|
2693
2857
|
* Runs the asset key scanner: walk the features, write the manifest and the key module, then
|
|
2694
2858
|
* compile the feature strings next to the key module, or check that all of it is current. With
|
|
2695
|
-
* `--pack <dir>` it packs the art for production on the same scan (`--no-cache` for a cold run)
|
|
2696
|
-
*
|
|
2859
|
+
* `--pack <dir>` it packs the art for production on the same scan (`--no-cache` for a cold run);
|
|
2860
|
+
* `--pseudo` adds the pseudo-locale to a dev run. `--export <dir>` and `--import <dir>` exchange
|
|
2861
|
+
* the strings with translators and run alone, from the locale of `--source`. Nothing here calls
|
|
2862
|
+
* `process.exit`; the caller does.
|
|
2697
2863
|
*
|
|
2698
2864
|
* @param argv - The arguments after the script name.
|
|
2699
|
-
* @param
|
|
2865
|
+
* @param strings - The string tools of i18n: compile, export and import.
|
|
2700
2866
|
* @param ui - Where the lines go. The branded console by default.
|
|
2701
|
-
* @returns The exit code: `1` when the scan, the compile
|
|
2702
|
-
* difference, else `0`.
|
|
2867
|
+
* @returns The exit code: `1` when the flags, the scan, the compile, the pack, the export or the
|
|
2868
|
+
* import failed or `--check` found a difference, else `0`.
|
|
2703
2869
|
* @example
|
|
2704
2870
|
* ```ts
|
|
2705
|
-
*
|
|
2706
|
-
*
|
|
2707
|
-
*
|
|
2708
|
-
* await runCli(["--root", "src", "--keys", "src/generated/assets.ts", "--pack", "dist/assets"], compileStrings);
|
|
2871
|
+
* const strings = { compile: compileStrings, exportStrings, importStrings };
|
|
2872
|
+
* await runCli(["--root", "src", "--keys", "src/generated/assets.ts", "--pack", "dist/assets"], strings);
|
|
2709
2873
|
* // 0, and dist/assets holds the pages, the loose files and the packed manifest.json
|
|
2874
|
+
* await runCli(["--root", "src", "--export", "translations"], strings);
|
|
2875
|
+
* // 0, and translations/ holds one <locale>.json per locale for the translators
|
|
2710
2876
|
* ```
|
|
2711
2877
|
*/
|
|
2712
|
-
async function runCli(argv,
|
|
2878
|
+
async function runCli(argv, strings, ui = createBrandConsole()) {
|
|
2713
2879
|
try {
|
|
2714
2880
|
const options = parseArgv(argv);
|
|
2715
|
-
if (options.
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
keys: options.keys,
|
|
2720
|
-
write: !options.check
|
|
2721
|
-
});
|
|
2722
|
-
const strings = await compile(options.root, path.dirname(options.keys), { check: options.check });
|
|
2723
|
-
for (const note of [...notes, ...strings.notes]) ui.warn(note);
|
|
2724
|
-
if ((changed || strings.changed) && options.check) {
|
|
2725
|
-
ui.error(`${changed ? outputNames(options) : "the generated strings"} are out of date.\n Run "bun run assets:keys" and commit the result.`);
|
|
2726
|
-
return 1;
|
|
2727
|
-
}
|
|
2728
|
-
ui.info(changed ? `wrote ${outputNames(options)}.` : `${outputNames(options)} are up to date.`);
|
|
2729
|
-
ui.info(summary(manifest));
|
|
2730
|
-
if (strings.keys.length > 0) ui.info(stringsSummary(strings));
|
|
2731
|
-
return 0;
|
|
2881
|
+
if (options.exportDir !== void 0) return await runExport(options, options.exportDir, strings.exportStrings, ui);
|
|
2882
|
+
if (options.importDir !== void 0) return await runImport(options, options.importDir, strings.importStrings, ui);
|
|
2883
|
+
if (options.pack !== void 0) return await runPack(options, options.pack, strings.compile, ui);
|
|
2884
|
+
return await runKeys(options, strings.compile, ui);
|
|
2732
2885
|
} catch (error) {
|
|
2733
2886
|
ui.error(messageOf(error));
|
|
2734
2887
|
return 1;
|
|
@@ -2780,6 +2933,30 @@ function argumentHelper() {
|
|
|
2780
2933
|
].join("\n");
|
|
2781
2934
|
}
|
|
2782
2935
|
/**
|
|
2936
|
+
* The helper a compiled `duration` calls: milliseconds into the record `Intl.DurationFormat`
|
|
2937
|
+
* reads. The same split as `durationInput` of the plugin: whole seconds rounded up, a negative or
|
|
2938
|
+
* non-finite value as zero, hours and minutes when above zero, seconds always.
|
|
2939
|
+
*
|
|
2940
|
+
* @returns The helper as source.
|
|
2941
|
+
*/
|
|
2942
|
+
function durationHelper() {
|
|
2943
|
+
return [
|
|
2944
|
+
"/** Milliseconds as Intl.DurationFormat reads them: whole seconds rounded up, seconds always. */",
|
|
2945
|
+
"function duration(ms: number): Partial<Record<Intl.DurationFormatUnit, number>> {",
|
|
2946
|
+
" const total = Number.isFinite(ms) ? Math.max(0, Math.ceil(ms / 1000)) : 0;",
|
|
2947
|
+
" const hours = Math.floor(total / 3600);",
|
|
2948
|
+
" const minutes = Math.floor((total % 3600) / 60);",
|
|
2949
|
+
" const input: Partial<Record<Intl.DurationFormatUnit, number>> = {};",
|
|
2950
|
+
"",
|
|
2951
|
+
" if (hours > 0) input.hours = hours;",
|
|
2952
|
+
" if (minutes > 0) input.minutes = minutes;",
|
|
2953
|
+
" input.seconds = total % 60;",
|
|
2954
|
+
"",
|
|
2955
|
+
" return input;",
|
|
2956
|
+
"}"
|
|
2957
|
+
].join("\n");
|
|
2958
|
+
}
|
|
2959
|
+
/**
|
|
2783
2960
|
* Compares two keys, so every list the compiler writes has one order.
|
|
2784
2961
|
*
|
|
2785
2962
|
* @param left - First key.
|
|
@@ -2791,7 +2968,7 @@ function byName(left, right) {
|
|
|
2791
2968
|
return left < right ? -1 : 1;
|
|
2792
2969
|
}
|
|
2793
2970
|
/**
|
|
2794
|
-
* Writes `generated/strings.<locale>.ts`: one arrow per key, keys sorted,
|
|
2971
|
+
* Writes `generated/strings.<locale>.ts`: one arrow per key, keys sorted, each helper only when a
|
|
2795
2972
|
* message needs it.
|
|
2796
2973
|
*
|
|
2797
2974
|
* @param entries - The keys of the locale, in any order.
|
|
@@ -2807,6 +2984,7 @@ function emitLocale(entries) {
|
|
|
2807
2984
|
const rows = sorted.map((entry) => ` ${JSON.stringify(entry.key)}: ${entry.compiled.source}`);
|
|
2808
2985
|
const blocks = [`${HEADER}\nimport type { I18n } from "@moku-labs/game";`];
|
|
2809
2986
|
if (sorted.some((entry) => entry.compiled.usesArgument)) blocks.push(argumentHelper());
|
|
2987
|
+
if (sorted.some((entry) => entry.compiled.usesDuration)) blocks.push(durationHelper());
|
|
2810
2988
|
blocks.push(`export default {\n${rows.join(",\n")}\n} satisfies I18n.CompiledMessages;`);
|
|
2811
2989
|
return `${blocks.join("\n\n")}\n`;
|
|
2812
2990
|
}
|
|
@@ -2822,7 +3000,7 @@ function emitLocale(entries) {
|
|
|
2822
3000
|
*/
|
|
2823
3001
|
function typeOf(type) {
|
|
2824
3002
|
if (type.kind === "argument") return ARGUMENT;
|
|
2825
|
-
if (type.kind === "number") return "number";
|
|
3003
|
+
if (type.kind === "number" || type.kind === "duration") return "number";
|
|
2826
3004
|
if (type.kind === "date") return "Date | number";
|
|
2827
3005
|
return type.options.map((option) => JSON.stringify(option)).join(" | ");
|
|
2828
3006
|
}
|
|
@@ -5369,6 +5547,20 @@ function parse(message, opts = {}) {
|
|
|
5369
5547
|
*/
|
|
5370
5548
|
/** The style a `{d, date}` with no style means. */
|
|
5371
5549
|
const DEFAULT_DATE_STYLE = "medium";
|
|
5550
|
+
/** The style a `{left, duration}` with no style means, the `Intl.DurationFormat` default. */
|
|
5551
|
+
const DEFAULT_DURATION_STYLE = "short";
|
|
5552
|
+
/**
|
|
5553
|
+
* The number style a `{left, duration, short}` is rewritten to before the parser reads it: the
|
|
5554
|
+
* parser knows no `duration` type, and refuses it.
|
|
5555
|
+
*/
|
|
5556
|
+
const DURATION_PREFIX = "duration-";
|
|
5557
|
+
/**
|
|
5558
|
+
* One `duration` argument at the start of a string: `{name, duration}` or
|
|
5559
|
+
* `{name, duration, style}`. It has no sub-messages, so it never crosses a brace.
|
|
5560
|
+
*/
|
|
5561
|
+
const DURATION_ARGUMENT = /^\{\s*([^\s{},]+)\s*,\s*duration\s*(?:,\s*([^\s{},]+)\s*)?\}/;
|
|
5562
|
+
/** The characters an apostrophe quotes when it stands right before one of them. */
|
|
5563
|
+
const QUOTABLE = "{}<>#";
|
|
5372
5564
|
/** A parameter name that can be read with a dot. */
|
|
5373
5565
|
const PLAIN_NAME = /^[A-Za-z_$][\w$]*$/;
|
|
5374
5566
|
/** The TypeScript cast keyword, kept apart so this file writes one and holds none. */
|
|
@@ -5402,15 +5594,97 @@ function detailOf$1(error) {
|
|
|
5402
5594
|
return error instanceof Error ? error.message : String(error);
|
|
5403
5595
|
}
|
|
5404
5596
|
/**
|
|
5405
|
-
*
|
|
5597
|
+
* Measures the run at one position that the pre-pass copies untouched: `''`, one literal
|
|
5598
|
+
* apostrophe, or a run an apostrophe quotes because a syntax character follows it, up to the
|
|
5599
|
+
* closing apostrophe or the end of the message.
|
|
5600
|
+
*
|
|
5601
|
+
* @param text - The message as the game wrote it.
|
|
5602
|
+
* @param index - Where the run may start.
|
|
5603
|
+
* @returns Where the run ends, or the same index when none starts there.
|
|
5604
|
+
* @example
|
|
5605
|
+
* ```ts
|
|
5606
|
+
* quotedRunEnd("'{x}' left", 0); // 5
|
|
5607
|
+
* ```
|
|
5608
|
+
*/
|
|
5609
|
+
function quotedRunEnd(text, index) {
|
|
5610
|
+
const next = text[index + 1];
|
|
5611
|
+
if (text[index] !== "'" || next === void 0) return index;
|
|
5612
|
+
if (next === "'") return index + 2;
|
|
5613
|
+
if (!QUOTABLE.includes(next)) return index;
|
|
5614
|
+
let cursor = index + 2;
|
|
5615
|
+
while (cursor < text.length) if (text[cursor] !== "'") cursor += 1;
|
|
5616
|
+
else if (text[cursor + 1] === "'") cursor += 2;
|
|
5617
|
+
else return cursor + 1;
|
|
5618
|
+
return text.length;
|
|
5619
|
+
}
|
|
5620
|
+
/**
|
|
5621
|
+
* Reads a `duration` argument at one position and writes, in its place, the number style the
|
|
5622
|
+
* parser accepts.
|
|
5623
|
+
*
|
|
5624
|
+
* @param text - The message as the game wrote it.
|
|
5625
|
+
* @param index - Where the argument may start.
|
|
5626
|
+
* @returns The rewritten argument and the length it replaces, or `undefined` when none starts there.
|
|
5627
|
+
* @example
|
|
5628
|
+
* ```ts
|
|
5629
|
+
* durationAt("{left, duration}", 0); // { text: "{left, number, duration-short}", length: 16 }
|
|
5630
|
+
* ```
|
|
5631
|
+
*/
|
|
5632
|
+
function durationAt(text, index) {
|
|
5633
|
+
const match = DURATION_ARGUMENT.exec(text.slice(index));
|
|
5634
|
+
if (match === null) return void 0;
|
|
5635
|
+
const [whole, name, style = DEFAULT_DURATION_STYLE] = match;
|
|
5636
|
+
return {
|
|
5637
|
+
text: `{${name}, number, ${DURATION_PREFIX}${style}}`,
|
|
5638
|
+
length: whole.length
|
|
5639
|
+
};
|
|
5640
|
+
}
|
|
5641
|
+
/**
|
|
5642
|
+
* The pre-pass the parser needs: every `duration` argument outside a quoted run becomes a number
|
|
5643
|
+
* with a `duration-<style>` style, which `numberPiece` turns back into a duration.
|
|
5644
|
+
*
|
|
5645
|
+
* @param text - The message as the game wrote it.
|
|
5646
|
+
* @returns The message the parser reads.
|
|
5647
|
+
* @example
|
|
5648
|
+
* ```ts
|
|
5649
|
+
* rewriteDurations("Opens in {left, duration, long}"); // "Opens in {left, number, duration-long}"
|
|
5650
|
+
* ```
|
|
5651
|
+
*/
|
|
5652
|
+
function rewriteDurations(text) {
|
|
5653
|
+
let rewritten = "";
|
|
5654
|
+
let index = 0;
|
|
5655
|
+
while (index < text.length) {
|
|
5656
|
+
const quoted = quotedRunEnd(text, index);
|
|
5657
|
+
if (quoted > index) {
|
|
5658
|
+
rewritten += text.slice(index, quoted);
|
|
5659
|
+
index = quoted;
|
|
5660
|
+
continue;
|
|
5661
|
+
}
|
|
5662
|
+
const argument = text[index] === "{" ? durationAt(text, index) : void 0;
|
|
5663
|
+
if (argument !== void 0) {
|
|
5664
|
+
rewritten += argument.text;
|
|
5665
|
+
index += argument.length;
|
|
5666
|
+
continue;
|
|
5667
|
+
}
|
|
5668
|
+
rewritten += text.charAt(index);
|
|
5669
|
+
index += 1;
|
|
5670
|
+
}
|
|
5671
|
+
return rewritten;
|
|
5672
|
+
}
|
|
5673
|
+
/**
|
|
5674
|
+
* Parses one message, turning a parser failure into a compile problem. The `duration` pre-pass
|
|
5675
|
+
* runs first, so the elements carry a duration as a number with a `duration-<style>` style.
|
|
5406
5676
|
*
|
|
5407
5677
|
* @param text - The message as the game wrote it.
|
|
5408
5678
|
* @returns The elements of the message.
|
|
5409
5679
|
* @throws {Error} When the message does not parse.
|
|
5680
|
+
* @example
|
|
5681
|
+
* ```ts
|
|
5682
|
+
* parseMessage("{left, duration}"); // [{ type: TYPE.number, value: "left", style: "duration-short" }]
|
|
5683
|
+
* ```
|
|
5410
5684
|
*/
|
|
5411
5685
|
function parseMessage(text) {
|
|
5412
5686
|
try {
|
|
5413
|
-
return parse(text, { ignoreTag: true });
|
|
5687
|
+
return parse(rewriteDurations(text), { ignoreTag: true });
|
|
5414
5688
|
} catch (error) {
|
|
5415
5689
|
throw problem(detailOf$1(error));
|
|
5416
5690
|
}
|
|
@@ -5425,6 +5699,19 @@ function isDateStyle(style) {
|
|
|
5425
5699
|
return style === "short" || style === "medium" || style === "long" || style === "full";
|
|
5426
5700
|
}
|
|
5427
5701
|
/**
|
|
5702
|
+
* Tells whether a style is one of the four `Intl.DurationFormat` names.
|
|
5703
|
+
*
|
|
5704
|
+
* @param style - The style the message asked for, without the `duration-` prefix.
|
|
5705
|
+
* @returns True for "long", "short", "narrow" and "digital".
|
|
5706
|
+
* @example
|
|
5707
|
+
* ```ts
|
|
5708
|
+
* isDurationStyle("tiny"); // false
|
|
5709
|
+
* ```
|
|
5710
|
+
*/
|
|
5711
|
+
function isDurationStyle(style) {
|
|
5712
|
+
return style === "long" || style === "short" || style === "narrow" || style === "digital";
|
|
5713
|
+
}
|
|
5714
|
+
/**
|
|
5428
5715
|
* Names one style for a problem message, whether it arrived as text or as a parsed skeleton.
|
|
5429
5716
|
*
|
|
5430
5717
|
* @param style - What the parser read after the argument type.
|
|
@@ -5671,7 +5958,29 @@ function numberOptions(style) {
|
|
|
5671
5958
|
return "";
|
|
5672
5959
|
}
|
|
5673
5960
|
/**
|
|
5674
|
-
* Compiles a `
|
|
5961
|
+
* Compiles a `duration` argument, which the pre-pass handed over as a number with a `duration-`
|
|
5962
|
+
* style. The kit's formatter reads the record of the module's `duration` helper, with the seconds
|
|
5963
|
+
* always shown: `format({ seconds: 0 })` is empty by default, and a countdown at zero must read
|
|
5964
|
+
* "0 sec".
|
|
5965
|
+
*
|
|
5966
|
+
* @param element - The argument the parser read.
|
|
5967
|
+
* @param scan - What the walk collects.
|
|
5968
|
+
* @param style - The style the message named, without the `duration-` prefix.
|
|
5969
|
+
* @returns The piece it becomes.
|
|
5970
|
+
* @throws {Error} When the style is not one of the four `Intl.DurationFormat` styles.
|
|
5971
|
+
*/
|
|
5972
|
+
function durationPiece(element, scan, style) {
|
|
5973
|
+
if (!isDurationStyle(style)) throw problem(`the duration style "${style}" is not supported`);
|
|
5974
|
+
addParameter(scan, element.value, { kind: "duration" });
|
|
5975
|
+
scan.usesIntl = true;
|
|
5976
|
+
scan.usesDuration = true;
|
|
5977
|
+
return {
|
|
5978
|
+
kind: "expr",
|
|
5979
|
+
code: `intl.duration(${`{ style: ${JSON.stringify(style)}, secondsDisplay: "always" }`}).format(duration(${cast(access(element.value), "number")}))`
|
|
5980
|
+
};
|
|
5981
|
+
}
|
|
5982
|
+
/**
|
|
5983
|
+
* Compiles a `number` argument, or a `duration` the pre-pass rewrote into one.
|
|
5675
5984
|
*
|
|
5676
5985
|
* @param element - The argument the parser read.
|
|
5677
5986
|
* @param scan - What the walk collects.
|
|
@@ -5680,6 +5989,7 @@ function numberOptions(style) {
|
|
|
5680
5989
|
*/
|
|
5681
5990
|
function numberPiece(element, scan) {
|
|
5682
5991
|
const style = styleName(element.style);
|
|
5992
|
+
if (style.startsWith(DURATION_PREFIX)) return durationPiece(element, scan, style.slice(9));
|
|
5683
5993
|
if (style !== "" && style !== "integer" && style !== "percent") throw problem(`the number style "${style}" is not supported`);
|
|
5684
5994
|
addParameter(scan, element.value, { kind: "number" });
|
|
5685
5995
|
scan.usesIntl = true;
|
|
@@ -5772,39 +6082,239 @@ function headOf(scan) {
|
|
|
5772
6082
|
return scan.usesParams ? "p" : "()";
|
|
5773
6083
|
}
|
|
5774
6084
|
/**
|
|
5775
|
-
* Compiles one
|
|
6085
|
+
* Compiles the parsed elements of one message into the arrow the build writes. The pseudo-locale
|
|
6086
|
+
* hands its rewritten elements here directly.
|
|
5776
6087
|
*
|
|
5777
|
-
* @param
|
|
5778
|
-
* @returns The arrow source, the parameter types and
|
|
5779
|
-
* @throws {Error} When the
|
|
6088
|
+
* @param elements - What `parseMessage` read, or a rewrite of it.
|
|
6089
|
+
* @returns The arrow source, the parameter types and the helpers the module needs.
|
|
6090
|
+
* @throws {Error} When the elements use an unsupported ICU feature.
|
|
5780
6091
|
* @example
|
|
5781
6092
|
* ```ts
|
|
5782
|
-
*
|
|
6093
|
+
* compileElements([{ type: TYPE.literal, value: "Play" }]).source; // '() => [{ kind: "text", text: "Play" }]'
|
|
5783
6094
|
* ```
|
|
5784
6095
|
*/
|
|
5785
|
-
function
|
|
6096
|
+
function compileElements(elements) {
|
|
5786
6097
|
const scan = {
|
|
5787
6098
|
params: {},
|
|
5788
6099
|
usesArgument: false,
|
|
6100
|
+
usesDuration: false,
|
|
5789
6101
|
usesParams: false,
|
|
5790
6102
|
usesIntl: false
|
|
5791
6103
|
};
|
|
5792
|
-
const pieces = piecesOf(
|
|
6104
|
+
const pieces = piecesOf(elements, scan);
|
|
5793
6105
|
return {
|
|
5794
6106
|
source: `${headOf(scan)} => ${toParts(pieces)}`,
|
|
5795
6107
|
params: scan.params,
|
|
5796
|
-
usesArgument: scan.usesArgument
|
|
6108
|
+
usesArgument: scan.usesArgument,
|
|
6109
|
+
usesDuration: scan.usesDuration
|
|
5797
6110
|
};
|
|
5798
6111
|
}
|
|
6112
|
+
/**
|
|
6113
|
+
* Compiles one ICU message into the arrow the build writes into `generated/strings.<locale>.ts`.
|
|
6114
|
+
*
|
|
6115
|
+
* @param text - The message as the game wrote it.
|
|
6116
|
+
* @returns The arrow source, the parameter types and the helpers the module needs.
|
|
6117
|
+
* @throws {Error} When the message does not parse or uses an unsupported ICU feature.
|
|
6118
|
+
* @example
|
|
6119
|
+
* ```ts
|
|
6120
|
+
* compileMessage("Заказы").source; // '() => [{ kind: "text", text: "Заказы" }]'
|
|
6121
|
+
* ```
|
|
6122
|
+
*/
|
|
6123
|
+
function compileMessage(text) {
|
|
6124
|
+
return compileElements(parseMessage(text));
|
|
6125
|
+
}
|
|
5799
6126
|
//#endregion
|
|
5800
|
-
//#region src/plugins/i18n/compile/
|
|
6127
|
+
//#region src/plugins/i18n/compile/pseudo.ts
|
|
6128
|
+
/**
|
|
6129
|
+
* @file i18n plugin, build time — the pseudo-locale `en-XA`: every English message with accented
|
|
6130
|
+
* letters, 40 % more length in whole words, and brackets around it. On screen it shows a label
|
|
6131
|
+
* that is not translated, a box too small for a longer language and a sentence cut short. It works
|
|
6132
|
+
* on the parsed message: only literal text changes; arguments, plural keywords, exact matches, `#`
|
|
6133
|
+
* and tags stay as they are. Node and Bun only, like the rest of `compile/`.
|
|
6134
|
+
*/
|
|
6135
|
+
/** The ASCII letters, in the order of `ACCENTED`. */
|
|
6136
|
+
const PLAIN = "abcdefghijklmnopqrstuvwxyz";
|
|
6137
|
+
/** The accent table: the accented form of each letter of `PLAIN`; a capital takes its upper case. */
|
|
6138
|
+
const ACCENTED = "áɓçðéƒĝĥíĵķļɱñóþǫŕšţúṿŵẋýž";
|
|
6139
|
+
/** One ASCII letter of either case. */
|
|
6140
|
+
const LETTER = /[a-z]/gi;
|
|
6141
|
+
/** One tag run inside a literal, captured so a split keeps it: `<b>`, `</b>`, `<icon=hud.coin>`. */
|
|
6142
|
+
const TAG_RUN = /(<\/?[a-z][^<>]*>)/;
|
|
6143
|
+
/** The words the padding is made of, cycled. */
|
|
6144
|
+
const PADDING_WORDS = [
|
|
6145
|
+
"one",
|
|
6146
|
+
"two",
|
|
6147
|
+
"three",
|
|
6148
|
+
"four",
|
|
6149
|
+
"five",
|
|
6150
|
+
"six",
|
|
6151
|
+
"seven",
|
|
6152
|
+
"eight",
|
|
6153
|
+
"nine",
|
|
6154
|
+
"ten"
|
|
6155
|
+
];
|
|
6156
|
+
/** The literal length of a pseudo message against its original, in percent. */
|
|
6157
|
+
const GROWTH_PERCENT = 140;
|
|
5801
6158
|
/**
|
|
5802
|
-
*
|
|
5803
|
-
*
|
|
5804
|
-
*
|
|
5805
|
-
*
|
|
6159
|
+
* Accents one ASCII letter, keeping its case.
|
|
6160
|
+
*
|
|
6161
|
+
* @param letter - One letter from `a` to `z` or `A` to `Z`.
|
|
6162
|
+
* @returns The accented letter.
|
|
6163
|
+
* @example
|
|
6164
|
+
* ```ts
|
|
6165
|
+
* accentLetter("P"); // "Þ"
|
|
6166
|
+
* ```
|
|
5806
6167
|
*/
|
|
5807
|
-
|
|
6168
|
+
function accentLetter(letter) {
|
|
6169
|
+
const lower = letter.toLowerCase();
|
|
6170
|
+
const accented = ACCENTED.charAt(PLAIN.indexOf(lower));
|
|
6171
|
+
return letter === lower ? accented : accented.toUpperCase();
|
|
6172
|
+
}
|
|
6173
|
+
/**
|
|
6174
|
+
* Splits a literal into its runs: text at even positions, tag runs at odd ones.
|
|
6175
|
+
*
|
|
6176
|
+
* @param text - One literal of a message.
|
|
6177
|
+
* @returns The runs, in order.
|
|
6178
|
+
* @example
|
|
6179
|
+
* ```ts
|
|
6180
|
+
* runsOf("<b>Gold</b>"); // ["", "<b>", "Gold", "</b>", ""]
|
|
6181
|
+
* ```
|
|
6182
|
+
*/
|
|
6183
|
+
function runsOf(text) {
|
|
6184
|
+
return text.split(TAG_RUN);
|
|
6185
|
+
}
|
|
6186
|
+
/**
|
|
6187
|
+
* Tells whether a run of `runsOf` is a tag run.
|
|
6188
|
+
*
|
|
6189
|
+
* @param index - The position of the run.
|
|
6190
|
+
* @returns True for a tag run.
|
|
6191
|
+
*/
|
|
6192
|
+
function isTagRun(index) {
|
|
6193
|
+
return index % 2 === 1;
|
|
6194
|
+
}
|
|
6195
|
+
/**
|
|
6196
|
+
* Accents every ASCII letter of a literal and copies its tag runs as they are, so the tag pass
|
|
6197
|
+
* of `text` still finds them. Every other character stays, and the length does not change.
|
|
6198
|
+
*
|
|
6199
|
+
* @param text - One literal of a message.
|
|
6200
|
+
* @returns The accented literal.
|
|
6201
|
+
* @example
|
|
6202
|
+
* ```ts
|
|
6203
|
+
* accent("<b>Gold</b> 25"); // "<b>Ĝóļð</b> 25"
|
|
6204
|
+
* ```
|
|
6205
|
+
*/
|
|
6206
|
+
function accent(text) {
|
|
6207
|
+
return runsOf(text).map((run, index) => isTagRun(index) ? run : run.replaceAll(LETTER, accentLetter)).join("");
|
|
6208
|
+
}
|
|
6209
|
+
/**
|
|
6210
|
+
* The padding a message of one literal length gets: whole words, each after a space, until the
|
|
6211
|
+
* length reaches 140 % of the original. A message with no literal text gets none.
|
|
6212
|
+
*
|
|
6213
|
+
* @param original - The literal characters of the message outside tags, every branch counted.
|
|
6214
|
+
* @returns The padding text.
|
|
6215
|
+
* @example
|
|
6216
|
+
* ```ts
|
|
6217
|
+
* pad(13); // " one two"
|
|
6218
|
+
* ```
|
|
6219
|
+
*/
|
|
6220
|
+
function pad(original) {
|
|
6221
|
+
const target = Math.ceil(original * GROWTH_PERCENT / 100);
|
|
6222
|
+
const words = [];
|
|
6223
|
+
let length = original;
|
|
6224
|
+
while (length < target) {
|
|
6225
|
+
const word = PADDING_WORDS[words.length % PADDING_WORDS.length] ?? "";
|
|
6226
|
+
words.push(word);
|
|
6227
|
+
length += word.length + 1;
|
|
6228
|
+
}
|
|
6229
|
+
return words.map((word) => ` ${word}`).join("");
|
|
6230
|
+
}
|
|
6231
|
+
/**
|
|
6232
|
+
* Counts the literal characters of a message outside tags, every branch of every plural and
|
|
6233
|
+
* select counted.
|
|
6234
|
+
*
|
|
6235
|
+
* @param elements - The parsed message.
|
|
6236
|
+
* @returns The count.
|
|
6237
|
+
*/
|
|
6238
|
+
function literalLength(elements) {
|
|
6239
|
+
let length = 0;
|
|
6240
|
+
for (const element of elements) {
|
|
6241
|
+
if (element.type === TYPE.literal) length += runsOf(element.value).filter((_run, index) => !isTagRun(index)).join("").length;
|
|
6242
|
+
if (element.type === TYPE.plural || element.type === TYPE.select) for (const option of Object.values(element.options)) length += literalLength(option.value);
|
|
6243
|
+
}
|
|
6244
|
+
return length;
|
|
6245
|
+
}
|
|
6246
|
+
/**
|
|
6247
|
+
* Accents the branches of a plural or a select, keeping their names.
|
|
6248
|
+
*
|
|
6249
|
+
* @param options - The branches the parser read.
|
|
6250
|
+
* @returns The same branches with accented literals.
|
|
6251
|
+
*/
|
|
6252
|
+
function accentOptions(options) {
|
|
6253
|
+
const accented = {};
|
|
6254
|
+
for (const [name, option] of Object.entries(options)) accented[name] = {
|
|
6255
|
+
...option,
|
|
6256
|
+
value: option.value.map((element) => accentElement(element))
|
|
6257
|
+
};
|
|
6258
|
+
return accented;
|
|
6259
|
+
}
|
|
6260
|
+
/**
|
|
6261
|
+
* Accents the literals of one element, at any depth. Arguments, `#` and every name stay.
|
|
6262
|
+
*
|
|
6263
|
+
* @param element - One parsed element.
|
|
6264
|
+
* @returns The element with accented literals.
|
|
6265
|
+
*/
|
|
6266
|
+
function accentElement(element) {
|
|
6267
|
+
if (element.type === TYPE.literal) return {
|
|
6268
|
+
...element,
|
|
6269
|
+
value: accent(element.value)
|
|
6270
|
+
};
|
|
6271
|
+
if (element.type === TYPE.plural) return {
|
|
6272
|
+
...element,
|
|
6273
|
+
options: accentOptions(element.options)
|
|
6274
|
+
};
|
|
6275
|
+
if (element.type === TYPE.select) return {
|
|
6276
|
+
...element,
|
|
6277
|
+
options: accentOptions(element.options)
|
|
6278
|
+
};
|
|
6279
|
+
return element;
|
|
6280
|
+
}
|
|
6281
|
+
/**
|
|
6282
|
+
* Builds one literal element.
|
|
6283
|
+
*
|
|
6284
|
+
* @param value - The text.
|
|
6285
|
+
* @returns The element.
|
|
6286
|
+
*/
|
|
6287
|
+
function literal(value) {
|
|
6288
|
+
return {
|
|
6289
|
+
type: TYPE.literal,
|
|
6290
|
+
value
|
|
6291
|
+
};
|
|
6292
|
+
}
|
|
6293
|
+
/**
|
|
6294
|
+
* Derives the `en-XA` message of one English message: accented literals, the padding after the
|
|
6295
|
+
* last top-level piece, and brackets around everything. `"Opens in {left, duration, short}"`
|
|
6296
|
+
* reads `"[Óþéñš íñ {left, duration, short} one]"`.
|
|
6297
|
+
*
|
|
6298
|
+
* @param elements - The parsed English message.
|
|
6299
|
+
* @returns The parsed pseudo message, ready for `compileElements`.
|
|
6300
|
+
*/
|
|
6301
|
+
function pseudoMessage(elements) {
|
|
6302
|
+
const padding = pad(literalLength(elements));
|
|
6303
|
+
return [
|
|
6304
|
+
literal("["),
|
|
6305
|
+
...elements.map((element) => accentElement(element)),
|
|
6306
|
+
literal(`${padding}]`)
|
|
6307
|
+
];
|
|
6308
|
+
}
|
|
6309
|
+
//#endregion
|
|
6310
|
+
//#region src/plugins/i18n/compile/walk.ts
|
|
6311
|
+
/**
|
|
6312
|
+
* @file i18n plugin, build time — the walk over `features/*/strings/<locale>.json`: every file
|
|
6313
|
+
* read, every key claimed by one feature, every message compiled once and every problem collected,
|
|
6314
|
+
* with the text and the translator note of each key kept aside. `compileStrings`, `exportStrings`
|
|
6315
|
+
* and `importStrings` all start from it. Node and Bun only.
|
|
6316
|
+
*/
|
|
6317
|
+
/** How every problem of the string compiler is prefixed. */
|
|
5808
6318
|
const PREFIX = "[game] i18n: ";
|
|
5809
6319
|
/**
|
|
5810
6320
|
* Creates the table of compiled messages per locale. Its own function because lint rule L5
|
|
@@ -5832,6 +6342,30 @@ function emptyParameters() {
|
|
|
5832
6342
|
return /* @__PURE__ */ new Map();
|
|
5833
6343
|
}
|
|
5834
6344
|
/**
|
|
6345
|
+
* Creates a table of key to locale to text, for the messages or the notes.
|
|
6346
|
+
*
|
|
6347
|
+
* @returns An empty table.
|
|
6348
|
+
*/
|
|
6349
|
+
function emptyTexts() {
|
|
6350
|
+
return /* @__PURE__ */ new Map();
|
|
6351
|
+
}
|
|
6352
|
+
/**
|
|
6353
|
+
* Creates the parameter table of one key.
|
|
6354
|
+
*
|
|
6355
|
+
* @returns An empty table.
|
|
6356
|
+
*/
|
|
6357
|
+
function emptyOrigins() {
|
|
6358
|
+
return /* @__PURE__ */ new Map();
|
|
6359
|
+
}
|
|
6360
|
+
/**
|
|
6361
|
+
* Creates the per-locale table of one key.
|
|
6362
|
+
*
|
|
6363
|
+
* @returns An empty table.
|
|
6364
|
+
*/
|
|
6365
|
+
function emptyByLocale() {
|
|
6366
|
+
return /* @__PURE__ */ new Map();
|
|
6367
|
+
}
|
|
6368
|
+
/**
|
|
5835
6369
|
* Reads the message of anything that was thrown.
|
|
5836
6370
|
*
|
|
5837
6371
|
* @param error - What the `catch` caught.
|
|
@@ -5846,13 +6380,59 @@ function detailOf(error) {
|
|
|
5846
6380
|
* @param problems - The problems, in walk order.
|
|
5847
6381
|
* @returns The error to reject with.
|
|
5848
6382
|
*/
|
|
5849
|
-
function
|
|
6383
|
+
function compileFailure(problems) {
|
|
5850
6384
|
const only = problems[0];
|
|
5851
6385
|
if (problems.length === 1 && only !== void 0) return /* @__PURE__ */ new Error(`${PREFIX}${only}\n Fix the message or use a supported ICU feature.`);
|
|
5852
6386
|
const lines = problems.map((text) => ` ${text}`).join("\n");
|
|
5853
6387
|
return /* @__PURE__ */ new Error(`${PREFIX}the compile found ${problems.length} problems.\n${lines}\n Fix them and run "bun run assets:keys" again.`);
|
|
5854
6388
|
}
|
|
5855
6389
|
/**
|
|
6390
|
+
* Tells whether an object is the `{ text, note }` form of a message: a string `text`, an optional
|
|
6391
|
+
* string `note`, and nothing else.
|
|
6392
|
+
*
|
|
6393
|
+
* @param value - One value of a string file that is an object.
|
|
6394
|
+
* @returns True for the noted form.
|
|
6395
|
+
* @example
|
|
6396
|
+
* ```ts
|
|
6397
|
+
* isNoted({ text: "Deliver", note: "Button, max 10 chars" }); // true
|
|
6398
|
+
* ```
|
|
6399
|
+
*/
|
|
6400
|
+
function isNoted(value) {
|
|
6401
|
+
const hasText = "text" in value && typeof value.text === "string";
|
|
6402
|
+
const noteIsText = !("note" in value) || typeof value.note === "string";
|
|
6403
|
+
const onlyKnown = Object.keys(value).every((name) => name === "text" || name === "note");
|
|
6404
|
+
return hasText && noteIsText && onlyKnown;
|
|
6405
|
+
}
|
|
6406
|
+
/**
|
|
6407
|
+
* Tells whether a parsed JSON value is a table of key to value: an object, not `null`, not an
|
|
6408
|
+
* array.
|
|
6409
|
+
*
|
|
6410
|
+
* @param value - A parsed JSON value.
|
|
6411
|
+
* @returns True when the value is a table.
|
|
6412
|
+
* @example
|
|
6413
|
+
* ```ts
|
|
6414
|
+
* isTable(["hud.title"]); // false
|
|
6415
|
+
* ```
|
|
6416
|
+
*/
|
|
6417
|
+
function isTable(value) {
|
|
6418
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6419
|
+
}
|
|
6420
|
+
/**
|
|
6421
|
+
* Tells whether one value of a string file is a message: a string, or `{ text, note }`.
|
|
6422
|
+
*
|
|
6423
|
+
* @param value - One value of a string file.
|
|
6424
|
+
* @returns True when the value is a message.
|
|
6425
|
+
* @example
|
|
6426
|
+
* ```ts
|
|
6427
|
+
* isMessageEntry({ text: "Deliver", max: 10 }); // false: an extra member
|
|
6428
|
+
* ```
|
|
6429
|
+
*/
|
|
6430
|
+
function isMessageEntry(value) {
|
|
6431
|
+
if (typeof value === "string") return true;
|
|
6432
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
6433
|
+
return isNoted(value);
|
|
6434
|
+
}
|
|
6435
|
+
/**
|
|
5856
6436
|
* Lists the sub-folders of a folder that may not exist.
|
|
5857
6437
|
*
|
|
5858
6438
|
* @param folder - Path of the folder.
|
|
@@ -5879,17 +6459,38 @@ async function listLocales(folder) {
|
|
|
5879
6459
|
}
|
|
5880
6460
|
}
|
|
5881
6461
|
/**
|
|
6462
|
+
* Lists every string file of a game: features sorted, then locales sorted.
|
|
6463
|
+
*
|
|
6464
|
+
* @param root - Game source root, the folder that holds the features.
|
|
6465
|
+
* @param features - Name of the folder that holds the features.
|
|
6466
|
+
* @returns The files, in walk order.
|
|
6467
|
+
*/
|
|
6468
|
+
async function listStringFiles(root, features) {
|
|
6469
|
+
const files = [];
|
|
6470
|
+
const featuresFolder = path.join(root, features);
|
|
6471
|
+
for (const feature of await listFolders(featuresFolder)) {
|
|
6472
|
+
const stringsFolder = path.join(featuresFolder, feature, "strings");
|
|
6473
|
+
for (const locale of await listLocales(stringsFolder)) files.push({
|
|
6474
|
+
feature,
|
|
6475
|
+
locale,
|
|
6476
|
+
file: path.join(stringsFolder, `${locale}.json`),
|
|
6477
|
+
relative: `features/${feature}/strings/${locale}.json`
|
|
6478
|
+
});
|
|
6479
|
+
}
|
|
6480
|
+
return files;
|
|
6481
|
+
}
|
|
6482
|
+
/**
|
|
5882
6483
|
* Reads one string file as a flat table of key to message.
|
|
5883
6484
|
*
|
|
5884
6485
|
* @param file - Path of the file.
|
|
5885
6486
|
* @param relative - The path a problem names it by.
|
|
5886
|
-
* @param walk - What the
|
|
6487
|
+
* @param walk - What the walk collects.
|
|
5887
6488
|
* @returns The table, or `undefined` when the file is a problem.
|
|
5888
6489
|
*/
|
|
5889
6490
|
async function readTable(file, relative, walk) {
|
|
5890
6491
|
try {
|
|
5891
6492
|
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
5892
|
-
if (
|
|
6493
|
+
if (!isTable(parsed)) {
|
|
5893
6494
|
walk.problems.push(`${relative}: a string file is an object of key to message.`);
|
|
5894
6495
|
return;
|
|
5895
6496
|
}
|
|
@@ -5902,7 +6503,7 @@ async function readTable(file, relative, walk) {
|
|
|
5902
6503
|
/**
|
|
5903
6504
|
* Records which feature owns a key, refusing the same key in two features.
|
|
5904
6505
|
*
|
|
5905
|
-
* @param walk - What the
|
|
6506
|
+
* @param walk - What the walk collects.
|
|
5906
6507
|
* @param key - The message key.
|
|
5907
6508
|
* @param source - Where this message came from.
|
|
5908
6509
|
* @returns True when the key belongs to this feature.
|
|
@@ -5931,17 +6532,9 @@ function unique(options) {
|
|
|
5931
6532
|
return [...new Set(options)].toSorted();
|
|
5932
6533
|
}
|
|
5933
6534
|
/**
|
|
5934
|
-
* Creates the parameter table of one key.
|
|
5935
|
-
*
|
|
5936
|
-
* @returns An empty table.
|
|
5937
|
-
*/
|
|
5938
|
-
function emptyOrigins() {
|
|
5939
|
-
return /* @__PURE__ */ new Map();
|
|
5940
|
-
}
|
|
5941
|
-
/**
|
|
5942
6535
|
* Merges the parameters one locale read for a key into what the other locales read.
|
|
5943
6536
|
*
|
|
5944
|
-
* @param walk - What the
|
|
6537
|
+
* @param walk - What the walk collects.
|
|
5945
6538
|
* @param key - The message key.
|
|
5946
6539
|
* @param params - What this locale read.
|
|
5947
6540
|
* @param relative - The file this locale came from.
|
|
@@ -5972,19 +6565,35 @@ function mergeParameters(walk, key, params, relative) {
|
|
|
5972
6565
|
}
|
|
5973
6566
|
}
|
|
5974
6567
|
/**
|
|
5975
|
-
*
|
|
6568
|
+
* Keeps one text of one key in one locale: the message, or its note.
|
|
5976
6569
|
*
|
|
5977
|
-
* @param
|
|
6570
|
+
* @param table - The messages or the notes of the walk.
|
|
5978
6571
|
* @param key - The message key.
|
|
5979
|
-
* @param
|
|
6572
|
+
* @param locale - The locale of the file.
|
|
6573
|
+
* @param text - What the file wrote.
|
|
6574
|
+
*/
|
|
6575
|
+
function remember(table, key, locale, text) {
|
|
6576
|
+
const byLocale = table.get(key) ?? emptyByLocale();
|
|
6577
|
+
byLocale.set(locale, text);
|
|
6578
|
+
table.set(key, byLocale);
|
|
6579
|
+
}
|
|
6580
|
+
/**
|
|
6581
|
+
* Reads one value of a string file and compiles its message into the locale it belongs to.
|
|
6582
|
+
*
|
|
6583
|
+
* @param walk - What the walk collects.
|
|
6584
|
+
* @param key - The message key.
|
|
6585
|
+
* @param value - The value the file wrote for the key.
|
|
5980
6586
|
* @param source - Where this message came from.
|
|
5981
6587
|
*/
|
|
5982
|
-
function compileOne(walk, key,
|
|
5983
|
-
if (
|
|
5984
|
-
walk.problems.push(`"${key}" in ${source.relative}: a message
|
|
6588
|
+
function compileOne(walk, key, value, source) {
|
|
6589
|
+
if (!isMessageEntry(value)) {
|
|
6590
|
+
walk.problems.push(`"${key}" in ${source.relative}: a message is a string or { "text": string, "note": string }.`);
|
|
5985
6591
|
return;
|
|
5986
6592
|
}
|
|
5987
6593
|
if (!claim(walk, key, source)) return;
|
|
6594
|
+
const text = typeof value === "string" ? value : value.text;
|
|
6595
|
+
remember(walk.texts, key, source.locale, text);
|
|
6596
|
+
if (typeof value !== "string" && value.note !== void 0) remember(walk.notes, key, source.locale, value.note);
|
|
5988
6597
|
try {
|
|
5989
6598
|
const compiled = compileMessage(text);
|
|
5990
6599
|
const entries = walk.byLocale.get(source.locale) ?? [];
|
|
@@ -5999,6 +6608,100 @@ function compileOne(walk, key, text, source) {
|
|
|
5999
6608
|
}
|
|
6000
6609
|
}
|
|
6001
6610
|
/**
|
|
6611
|
+
* Walks every string file of a game and compiles every message once. Problems are collected, not
|
|
6612
|
+
* thrown, so the caller reports them all in one error.
|
|
6613
|
+
*
|
|
6614
|
+
* @param root - Game source root, the folder that holds the features.
|
|
6615
|
+
* @param features - Name of the folder that holds the features.
|
|
6616
|
+
* @returns The compiled messages per locale, the owner and parameters of each key, the texts and
|
|
6617
|
+
* notes of each key per locale, and every problem.
|
|
6618
|
+
*/
|
|
6619
|
+
async function walkStrings(root, features) {
|
|
6620
|
+
const walk = {
|
|
6621
|
+
byLocale: emptyLocales(),
|
|
6622
|
+
owner: emptyOwners(),
|
|
6623
|
+
params: emptyParameters(),
|
|
6624
|
+
problems: [],
|
|
6625
|
+
texts: emptyTexts(),
|
|
6626
|
+
notes: emptyTexts()
|
|
6627
|
+
};
|
|
6628
|
+
for (const file of await listStringFiles(root, features)) {
|
|
6629
|
+
const table = await readTable(file.file, file.relative, walk);
|
|
6630
|
+
if (table === void 0) continue;
|
|
6631
|
+
const source = {
|
|
6632
|
+
feature: file.feature,
|
|
6633
|
+
locale: file.locale,
|
|
6634
|
+
relative: file.relative
|
|
6635
|
+
};
|
|
6636
|
+
for (const [key, value] of Object.entries(table)) compileOne(walk, key, value, source);
|
|
6637
|
+
}
|
|
6638
|
+
return walk;
|
|
6639
|
+
}
|
|
6640
|
+
//#endregion
|
|
6641
|
+
//#region src/plugins/i18n/compile/compile.ts
|
|
6642
|
+
/**
|
|
6643
|
+
* @file i18n plugin, build time — `compileStrings`: the walk over `features/*/strings/<locale>.json`
|
|
6644
|
+
* turned into the generated modules, and on request the pseudo-locale `en-XA` derived from English.
|
|
6645
|
+
* Every problem of a run is collected and reported in one error, so a game fixes its messages in
|
|
6646
|
+
* one round. Node and Bun only: nothing under `src/` outside this folder and `src/assets.ts`
|
|
6647
|
+
* imports it, so no game bundles the ICU parser.
|
|
6648
|
+
*/
|
|
6649
|
+
/** The locale the pseudo-locale is derived from. */
|
|
6650
|
+
const PSEUDO_SOURCE = "en";
|
|
6651
|
+
/** The pseudo-locale `--pseudo` writes. */
|
|
6652
|
+
const PSEUDO_LOCALE = "en-XA";
|
|
6653
|
+
/**
|
|
6654
|
+
* Wraps a pseudo run without English in the message shape of the framework.
|
|
6655
|
+
*
|
|
6656
|
+
* @returns The error to reject with.
|
|
6657
|
+
*/
|
|
6658
|
+
function pseudoNeedsEnglish() {
|
|
6659
|
+
return /* @__PURE__ */ new Error(`${PREFIX}"--pseudo" needs an "en" string file in at least one feature.\n Add features/<feature>/strings/en.json or drop "--pseudo".`);
|
|
6660
|
+
}
|
|
6661
|
+
/**
|
|
6662
|
+
* Tells whether at least one feature brings a string file for a locale.
|
|
6663
|
+
*
|
|
6664
|
+
* @param root - Game source root, the folder that holds the features.
|
|
6665
|
+
* @param features - Name of the folder that holds the features.
|
|
6666
|
+
* @param locale - The locale to look for.
|
|
6667
|
+
* @returns True when a feature has `strings/<locale>.json`.
|
|
6668
|
+
*/
|
|
6669
|
+
async function bringsLocale(root, features, locale) {
|
|
6670
|
+
return (await listStringFiles(root, features)).some((file) => file.locale === locale);
|
|
6671
|
+
}
|
|
6672
|
+
/**
|
|
6673
|
+
* Derives the pseudo message of every English message. Each one compiled already in the walk, so
|
|
6674
|
+
* nothing here can fail.
|
|
6675
|
+
*
|
|
6676
|
+
* @param walk - What the walk collected.
|
|
6677
|
+
* @returns One entry per English key.
|
|
6678
|
+
*/
|
|
6679
|
+
function pseudoEntries(walk) {
|
|
6680
|
+
const entries = [];
|
|
6681
|
+
for (const [key, texts] of walk.texts) {
|
|
6682
|
+
const english = texts.get(PSEUDO_SOURCE);
|
|
6683
|
+
if (english === void 0) continue;
|
|
6684
|
+
entries.push({
|
|
6685
|
+
key,
|
|
6686
|
+
compiled: compileElements(pseudoMessage(parseMessage(english)))
|
|
6687
|
+
});
|
|
6688
|
+
}
|
|
6689
|
+
return entries;
|
|
6690
|
+
}
|
|
6691
|
+
/**
|
|
6692
|
+
* The locale modules one compile writes: what the features brought, and the pseudo-locale derived
|
|
6693
|
+
* from English on request.
|
|
6694
|
+
*
|
|
6695
|
+
* @param walk - What the walk collected.
|
|
6696
|
+
* @param pseudo - Whether the pseudo-locale is written too.
|
|
6697
|
+
* @returns The compiled messages per locale.
|
|
6698
|
+
*/
|
|
6699
|
+
function localeModules(walk, pseudo) {
|
|
6700
|
+
const modules = new Map(walk.byLocale);
|
|
6701
|
+
if (pseudo) modules.set(PSEUDO_LOCALE, pseudoEntries(walk));
|
|
6702
|
+
return modules;
|
|
6703
|
+
}
|
|
6704
|
+
/**
|
|
6002
6705
|
* Lists the keys a locale is missing, so a translator sees the gap without the build stopping.
|
|
6003
6706
|
*
|
|
6004
6707
|
* @param walk - What the compile collects.
|
|
@@ -6033,57 +6736,47 @@ function typeEntries(walk) {
|
|
|
6033
6736
|
}
|
|
6034
6737
|
/**
|
|
6035
6738
|
* Walks `features/*/strings/<locale>.json` of a game, compiles every message and writes the
|
|
6036
|
-
* generated modules: the types module `strings.ts` and one `strings.<locale>.ts` per locale
|
|
6037
|
-
* check run writes nothing and only reports whether something
|
|
6739
|
+
* generated modules: the types module `strings.ts` and one `strings.<locale>.ts` per locale, plus
|
|
6740
|
+
* `strings.en-XA.ts` with `pseudo`. A check run writes nothing and only reports whether something
|
|
6741
|
+
* would change; it checks `strings.en-XA.ts` only with `pseudo`.
|
|
6038
6742
|
*
|
|
6039
6743
|
* @param root - Game source root, the folder that holds the features.
|
|
6040
6744
|
* @param out - The generated directory the modules are written into.
|
|
6041
|
-
* @param options - Whether this is a check run,
|
|
6745
|
+
* @param options - Whether this is a check run, what the features folder is called, and whether
|
|
6746
|
+
* the pseudo-locale is written.
|
|
6042
6747
|
* @returns Whether an output differed, the locales, the keys and the notes.
|
|
6043
|
-
* @throws {Error} One error that lists every problem the compile found
|
|
6748
|
+
* @throws {Error} One error that lists every problem the compile found, or the pseudo error when
|
|
6749
|
+
* no feature brings an `en` string file.
|
|
6044
6750
|
* @example
|
|
6045
6751
|
* ```ts
|
|
6046
|
-
*
|
|
6047
|
-
*
|
|
6752
|
+
* // A dev build also writes the pseudo-locale, derived from the English messages.
|
|
6753
|
+
* const report = await compileStrings("src", "src/generated", { pseudo: true });
|
|
6754
|
+
* // report.locales: ["en", "en-XA", "ru"], report.changed: true on the first run
|
|
6048
6755
|
* ```
|
|
6049
6756
|
*/
|
|
6050
6757
|
async function compileStrings(root, out, options = {}) {
|
|
6051
|
-
const
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
const featuresFolder = path.join(root, options.features ?? "features");
|
|
6058
|
-
for (const feature of await listFolders(featuresFolder)) {
|
|
6059
|
-
const stringsFolder = path.join(featuresFolder, feature, "strings");
|
|
6060
|
-
for (const locale of await listLocales(stringsFolder)) {
|
|
6061
|
-
const relative = `features/${feature}/strings/${locale}.json`;
|
|
6062
|
-
const table = await readTable(path.join(stringsFolder, `${locale}.json`), relative, walk);
|
|
6063
|
-
if (table === void 0) continue;
|
|
6064
|
-
for (const [key, text] of Object.entries(table)) compileOne(walk, key, text, {
|
|
6065
|
-
feature,
|
|
6066
|
-
locale,
|
|
6067
|
-
relative
|
|
6068
|
-
});
|
|
6069
|
-
}
|
|
6070
|
-
}
|
|
6071
|
-
if (walk.problems.length > 0) throw collected(walk.problems);
|
|
6072
|
-
const locales = [...walk.byLocale.keys()].toSorted();
|
|
6758
|
+
const features = options.features ?? "features";
|
|
6759
|
+
const pseudo = options.pseudo === true;
|
|
6760
|
+
if (pseudo && !await bringsLocale(root, features, PSEUDO_SOURCE)) throw pseudoNeedsEnglish();
|
|
6761
|
+
const walk = await walkStrings(root, features);
|
|
6762
|
+
if (walk.problems.length > 0) throw compileFailure(walk.problems);
|
|
6763
|
+
const brought = [...walk.byLocale.keys()].toSorted();
|
|
6073
6764
|
const keys = [...walk.owner.keys()].toSorted();
|
|
6074
|
-
if (
|
|
6765
|
+
if (brought.length === 0) return {
|
|
6075
6766
|
changed: false,
|
|
6076
|
-
locales,
|
|
6767
|
+
locales: brought,
|
|
6077
6768
|
keys,
|
|
6078
6769
|
notes: []
|
|
6079
6770
|
};
|
|
6771
|
+
const modules = localeModules(walk, pseudo);
|
|
6772
|
+
const locales = [...modules.keys()].toSorted();
|
|
6080
6773
|
const write = options.check !== true;
|
|
6081
6774
|
const outputs = [{
|
|
6082
6775
|
file: path.join(out, "strings.ts"),
|
|
6083
6776
|
text: emitTypes(typeEntries(walk))
|
|
6084
6777
|
}, ...locales.map((locale) => ({
|
|
6085
6778
|
file: path.join(out, `strings.${locale}.ts`),
|
|
6086
|
-
text: emitLocale(
|
|
6779
|
+
text: emitLocale(modules.get(locale) ?? [])
|
|
6087
6780
|
}))];
|
|
6088
6781
|
let changed = false;
|
|
6089
6782
|
for (const output of outputs) changed = await writeIfChanged(output.file, output.text, write) || changed;
|
|
@@ -6091,7 +6784,7 @@ async function compileStrings(root, out, options = {}) {
|
|
|
6091
6784
|
changed,
|
|
6092
6785
|
locales,
|
|
6093
6786
|
keys,
|
|
6094
|
-
notes: missingNotes(walk,
|
|
6787
|
+
notes: missingNotes(walk, brought)
|
|
6095
6788
|
};
|
|
6096
6789
|
}
|
|
6097
6790
|
/**
|
|
@@ -6111,15 +6804,348 @@ async function checkStrings(root, out) {
|
|
|
6111
6804
|
return !(await compileStrings(root, out, { check: true })).changed;
|
|
6112
6805
|
}
|
|
6113
6806
|
//#endregion
|
|
6807
|
+
//#region src/plugins/i18n/compile/exchange.ts
|
|
6808
|
+
/**
|
|
6809
|
+
* @file i18n plugin, build time — the exchange with translators. `exportStrings` writes one JSON
|
|
6810
|
+
* file per locale with every key, the source text and the note beside it; `importStrings` checks
|
|
6811
|
+
* the translated files against the game, writes them into the string files of the features that
|
|
6812
|
+
* own the keys, and compiles. Node and Bun only, reached through `src/assets.ts`.
|
|
6813
|
+
*/
|
|
6814
|
+
/** The locale translators read from when none is named. */
|
|
6815
|
+
const DEFAULT_SOURCE = "en";
|
|
6816
|
+
/** The folder that holds the features when none is named. */
|
|
6817
|
+
const DEFAULT_FEATURES = "features";
|
|
6818
|
+
/** The extension of every string file and every exchange file. */
|
|
6819
|
+
const JSON_EXTENSION = ".json";
|
|
6820
|
+
/**
|
|
6821
|
+
* Writes a table the way every exchange and string file is written: 2-space indent, trailing
|
|
6822
|
+
* newline.
|
|
6823
|
+
*
|
|
6824
|
+
* @param table - The content.
|
|
6825
|
+
* @returns The text of the file.
|
|
6826
|
+
* @example
|
|
6827
|
+
* ```ts
|
|
6828
|
+
* jsonText({ "hud.title": "Orders" }); // '{\n "hud.title": "Orders"\n}\n'
|
|
6829
|
+
* ```
|
|
6830
|
+
*/
|
|
6831
|
+
function jsonText(table) {
|
|
6832
|
+
return `${JSON.stringify(table, void 0, 2)}\n`;
|
|
6833
|
+
}
|
|
6834
|
+
/**
|
|
6835
|
+
* Builds the error of a source locale that no feature has a string file for.
|
|
6836
|
+
*
|
|
6837
|
+
* @param source - The locale the export was told to read from.
|
|
6838
|
+
* @returns The error to reject with.
|
|
6839
|
+
*/
|
|
6840
|
+
function sourceMissing(source) {
|
|
6841
|
+
return /* @__PURE__ */ new Error(`${PREFIX}the source locale "${source}" has no string file.\n Pass "--source <locale>".`);
|
|
6842
|
+
}
|
|
6843
|
+
/**
|
|
6844
|
+
* Collects every problem of an import into one error.
|
|
6845
|
+
*
|
|
6846
|
+
* @param problems - The problems, in file order.
|
|
6847
|
+
* @returns The error to reject with.
|
|
6848
|
+
*/
|
|
6849
|
+
function importFailure(problems) {
|
|
6850
|
+
const only = problems[0];
|
|
6851
|
+
if (problems.length === 1 && only !== void 0) return /* @__PURE__ */ new Error(`${PREFIX}${only}\n Fix the file and import again.`);
|
|
6852
|
+
const lines = problems.map((text) => ` ${text}`).join("\n");
|
|
6853
|
+
return /* @__PURE__ */ new Error(`${PREFIX}the import found ${problems.length} problems.\n${lines}\n Fix the files and import again.`);
|
|
6854
|
+
}
|
|
6855
|
+
/**
|
|
6856
|
+
* Reads the strings of a game, refusing one that does not compile: an export of it would hand the
|
|
6857
|
+
* translators broken text, and an import could not tell where a key belongs.
|
|
6858
|
+
*
|
|
6859
|
+
* @param root - Game source root, the folder that holds the features.
|
|
6860
|
+
* @param features - Name of the folder that holds the features.
|
|
6861
|
+
* @returns What the walk collected.
|
|
6862
|
+
* @throws {Error} One error that lists every problem the compile found.
|
|
6863
|
+
*/
|
|
6864
|
+
async function readGame(root, features) {
|
|
6865
|
+
const walk = await walkStrings(root, features);
|
|
6866
|
+
if (walk.problems.length > 0) throw compileFailure(walk.problems);
|
|
6867
|
+
return walk;
|
|
6868
|
+
}
|
|
6869
|
+
/**
|
|
6870
|
+
* Builds the exchange entry of one key in one locale. The note of the source wins over the
|
|
6871
|
+
* locale's own, because a translator reads the source.
|
|
6872
|
+
*
|
|
6873
|
+
* @param walk - What the walk collected.
|
|
6874
|
+
* @param key - The message key.
|
|
6875
|
+
* @param locale - The locale of the exchange file.
|
|
6876
|
+
* @param source - The locale translators read from.
|
|
6877
|
+
* @returns The entry: the locale's text or `""`, the source text or `""`, and a note if any.
|
|
6878
|
+
*/
|
|
6879
|
+
function exchangeEntry(walk, key, locale, source) {
|
|
6880
|
+
const texts = walk.texts.get(key);
|
|
6881
|
+
const notes = walk.notes.get(key);
|
|
6882
|
+
const note = notes?.get(source) ?? notes?.get(locale);
|
|
6883
|
+
const entry = {
|
|
6884
|
+
text: texts?.get(locale) ?? "",
|
|
6885
|
+
source: texts?.get(source) ?? ""
|
|
6886
|
+
};
|
|
6887
|
+
return note === void 0 ? entry : {
|
|
6888
|
+
...entry,
|
|
6889
|
+
note
|
|
6890
|
+
};
|
|
6891
|
+
}
|
|
6892
|
+
/**
|
|
6893
|
+
* Writes the files translators work in: one `<dir>/<locale>.json` per locale the features bring,
|
|
6894
|
+
* the source locale included, so a new language starts as a copy of `en.json`. Every key of the
|
|
6895
|
+
* game is in every file, sorted: `{ text, source, note? }`, with `text: ""` where the locale has no
|
|
6896
|
+
* message yet. The game tree is not touched, and a second run writes the same bytes.
|
|
6897
|
+
*
|
|
6898
|
+
* @param root - Game source root, the folder that holds the features.
|
|
6899
|
+
* @param dir - The folder the exchange files are written into; created when missing.
|
|
6900
|
+
* @param options - The source locale and the name of the features folder.
|
|
6901
|
+
* @returns The exported locales and how many keys each one lacks.
|
|
6902
|
+
* @throws {Error} When the game's strings do not compile, or no feature brings the source locale.
|
|
6903
|
+
* @example
|
|
6904
|
+
* ```ts
|
|
6905
|
+
* // Before a release, the game hands its strings to the translators.
|
|
6906
|
+
* await exportStrings("src", "translations"); // { locales: ["en", "ru"], missing: { en: 0, ru: 3 } }
|
|
6907
|
+
* // translations/ru.json: { "hud.bonus": { "text": "", "source": "Bonus" }, … }
|
|
6908
|
+
* ```
|
|
6909
|
+
*/
|
|
6910
|
+
async function exportStrings(root, dir, options = {}) {
|
|
6911
|
+
const source = options.source ?? DEFAULT_SOURCE;
|
|
6912
|
+
const walk = await readGame(root, options.features ?? DEFAULT_FEATURES);
|
|
6913
|
+
const locales = [...walk.byLocale.keys()].toSorted();
|
|
6914
|
+
if (!locales.includes(source)) throw sourceMissing(source);
|
|
6915
|
+
const keys = [...walk.owner.keys()].toSorted();
|
|
6916
|
+
const missing = {};
|
|
6917
|
+
for (const locale of locales) {
|
|
6918
|
+
const table = {};
|
|
6919
|
+
for (const key of keys) table[key] = exchangeEntry(walk, key, locale, source);
|
|
6920
|
+
missing[locale] = keys.filter((key) => walk.texts.get(key)?.has(locale) !== true).length;
|
|
6921
|
+
await writeIfChanged(path.join(dir, `${locale}${JSON_EXTENSION}`), jsonText(table), true);
|
|
6922
|
+
}
|
|
6923
|
+
return {
|
|
6924
|
+
locales,
|
|
6925
|
+
missing
|
|
6926
|
+
};
|
|
6927
|
+
}
|
|
6928
|
+
/**
|
|
6929
|
+
* Lists the exchange files of a folder.
|
|
6930
|
+
*
|
|
6931
|
+
* @param dir - The folder the translators handed back.
|
|
6932
|
+
* @param review - Where a folder that cannot be read is reported.
|
|
6933
|
+
* @returns The file names, sorted.
|
|
6934
|
+
*/
|
|
6935
|
+
async function listExchangeFiles(dir, review) {
|
|
6936
|
+
try {
|
|
6937
|
+
return (await readdir(dir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(JSON_EXTENSION)).map((entry) => entry.name).toSorted();
|
|
6938
|
+
} catch (error) {
|
|
6939
|
+
review.problems.push(`the folder ${dir} could not be read: ${detailOf(error)}.`);
|
|
6940
|
+
return [];
|
|
6941
|
+
}
|
|
6942
|
+
}
|
|
6943
|
+
/**
|
|
6944
|
+
* Reads one exchange file as a table of key to entry.
|
|
6945
|
+
*
|
|
6946
|
+
* @param dir - The folder the translators handed back.
|
|
6947
|
+
* @param name - The file name; minus `.json` it is the locale.
|
|
6948
|
+
* @param review - Where a file that is a problem is reported.
|
|
6949
|
+
* @returns The file, or `undefined` when it is a problem.
|
|
6950
|
+
*/
|
|
6951
|
+
async function readExchangeFile(dir, name, review) {
|
|
6952
|
+
const file = path.join(dir, name);
|
|
6953
|
+
const locale = name.slice(0, -5);
|
|
6954
|
+
review.locales.push(locale);
|
|
6955
|
+
try {
|
|
6956
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
6957
|
+
if (!isTable(parsed)) {
|
|
6958
|
+
review.problems.push(`${file}: an import file is an object of key to entry.`);
|
|
6959
|
+
return;
|
|
6960
|
+
}
|
|
6961
|
+
return {
|
|
6962
|
+
locale,
|
|
6963
|
+
file,
|
|
6964
|
+
table: parsed
|
|
6965
|
+
};
|
|
6966
|
+
} catch (error) {
|
|
6967
|
+
review.problems.push(`${file} could not be read: ${detailOf(error)}.`);
|
|
6968
|
+
return;
|
|
6969
|
+
}
|
|
6970
|
+
}
|
|
6971
|
+
/**
|
|
6972
|
+
* Reads the text of one exchange entry. `source` and `note` are ignored: notes are written in the
|
|
6973
|
+
* game tree.
|
|
6974
|
+
*
|
|
6975
|
+
* @param entry - One value of an exchange file.
|
|
6976
|
+
* @returns The text, or `undefined` when the entry has no string `text`.
|
|
6977
|
+
* @example
|
|
6978
|
+
* ```ts
|
|
6979
|
+
* textOf({ text: "Заказы", source: "Orders" }); // "Заказы"
|
|
6980
|
+
* ```
|
|
6981
|
+
*/
|
|
6982
|
+
function textOf(entry) {
|
|
6983
|
+
if (typeof entry !== "object" || entry === null || !("text" in entry)) return void 0;
|
|
6984
|
+
return typeof entry.text === "string" ? entry.text : void 0;
|
|
6985
|
+
}
|
|
6986
|
+
/**
|
|
6987
|
+
* Checks one entry of an exchange file against the game, and keeps it as a change when it brings
|
|
6988
|
+
* a new text that compiles.
|
|
6989
|
+
*
|
|
6990
|
+
* @param walk - What the walk of the game collected.
|
|
6991
|
+
* @param review - What the review found so far.
|
|
6992
|
+
* @param exchange - The exchange file the entry belongs to.
|
|
6993
|
+
* @param key - The key of the entry.
|
|
6994
|
+
*/
|
|
6995
|
+
function reviewEntry(walk, review, exchange, key) {
|
|
6996
|
+
const text = textOf(exchange.table[key]);
|
|
6997
|
+
const owner = walk.owner.get(key);
|
|
6998
|
+
if (text === void 0) {
|
|
6999
|
+
review.problems.push(`"${key}" in ${exchange.file}: an entry is { "text": string }.`);
|
|
7000
|
+
return;
|
|
7001
|
+
}
|
|
7002
|
+
if (owner === void 0) {
|
|
7003
|
+
review.problems.push(`"${key}" in ${exchange.file} is not a key of this game.`);
|
|
7004
|
+
return;
|
|
7005
|
+
}
|
|
7006
|
+
if (text === "" || text === walk.texts.get(key)?.get(exchange.locale)) return;
|
|
7007
|
+
try {
|
|
7008
|
+
compileMessage(text);
|
|
7009
|
+
review.changes.push({
|
|
7010
|
+
feature: owner.feature,
|
|
7011
|
+
locale: exchange.locale,
|
|
7012
|
+
key,
|
|
7013
|
+
text
|
|
7014
|
+
});
|
|
7015
|
+
} catch (error) {
|
|
7016
|
+
review.problems.push(`"${key}" in ${exchange.file}: ${detailOf(error)}.`);
|
|
7017
|
+
}
|
|
7018
|
+
}
|
|
7019
|
+
/**
|
|
7020
|
+
* Reads every exchange file of a folder and checks every entry against the game. Nothing is
|
|
7021
|
+
* written here.
|
|
7022
|
+
*
|
|
7023
|
+
* @param walk - What the walk of the game collected.
|
|
7024
|
+
* @param dir - The folder the translators handed back.
|
|
7025
|
+
* @returns The locales read, the texts to write and every problem.
|
|
7026
|
+
*/
|
|
7027
|
+
async function reviewImport(walk, dir) {
|
|
7028
|
+
const review = {
|
|
7029
|
+
locales: [],
|
|
7030
|
+
changes: [],
|
|
7031
|
+
problems: []
|
|
7032
|
+
};
|
|
7033
|
+
for (const name of await listExchangeFiles(dir, review)) {
|
|
7034
|
+
const exchange = await readExchangeFile(dir, name, review);
|
|
7035
|
+
if (exchange === void 0) continue;
|
|
7036
|
+
for (const key of Object.keys(exchange.table)) reviewEntry(walk, review, exchange, key);
|
|
7037
|
+
}
|
|
7038
|
+
return review;
|
|
7039
|
+
}
|
|
7040
|
+
/**
|
|
7041
|
+
* Reads a string file of a feature, or an empty table when the feature has none for the locale
|
|
7042
|
+
* yet. The walk checked every file that exists, so each value is a message.
|
|
7043
|
+
*
|
|
7044
|
+
* @param file - Path of the string file.
|
|
7045
|
+
* @returns The messages by key, in file order.
|
|
7046
|
+
*/
|
|
7047
|
+
async function readStrings(file) {
|
|
7048
|
+
const text = await readFile(file, "utf8").catch(() => "{}");
|
|
7049
|
+
const parsed = JSON.parse(text);
|
|
7050
|
+
const table = {};
|
|
7051
|
+
if (typeof parsed !== "object" || parsed === null) return table;
|
|
7052
|
+
for (const [key, value] of Object.entries(parsed)) if (isMessageEntry(value)) table[key] = value;
|
|
7053
|
+
return table;
|
|
7054
|
+
}
|
|
7055
|
+
/**
|
|
7056
|
+
* Rewrites one string file of a feature with its new texts. A key the file has keeps its place
|
|
7057
|
+
* and its form (`{ text, note }` keeps the note); a key it lacks is appended, the new ones sorted
|
|
7058
|
+
* among themselves. No other key is touched.
|
|
7059
|
+
*
|
|
7060
|
+
* @param file - Path of the string file; created when missing.
|
|
7061
|
+
* @param texts - The new text of each key.
|
|
7062
|
+
*/
|
|
7063
|
+
async function rewriteStrings(file, texts) {
|
|
7064
|
+
const table = await readStrings(file);
|
|
7065
|
+
const added = [];
|
|
7066
|
+
for (const [key, text] of texts) {
|
|
7067
|
+
const current = table[key];
|
|
7068
|
+
if (current === void 0) added.push(key);
|
|
7069
|
+
else table[key] = typeof current === "string" ? text : {
|
|
7070
|
+
...current,
|
|
7071
|
+
text
|
|
7072
|
+
};
|
|
7073
|
+
}
|
|
7074
|
+
for (const key of added.toSorted()) table[key] = texts.get(key) ?? "";
|
|
7075
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
7076
|
+
await writeFile(file, jsonText(table));
|
|
7077
|
+
}
|
|
7078
|
+
/**
|
|
7079
|
+
* Groups the changes of an import by the string file they land in.
|
|
7080
|
+
*
|
|
7081
|
+
* @param changes - Every text the import writes.
|
|
7082
|
+
* @param folder - Path of the folder that holds the features.
|
|
7083
|
+
* @returns The path and the new texts of each file, by its path relative to the root.
|
|
7084
|
+
*/
|
|
7085
|
+
function groupByFile(changes, folder) {
|
|
7086
|
+
const files = /* @__PURE__ */ new Map();
|
|
7087
|
+
for (const change of changes) {
|
|
7088
|
+
const name = `${change.locale}${JSON_EXTENSION}`;
|
|
7089
|
+
const relative = `features/${change.feature}/strings/${name}`;
|
|
7090
|
+
const group = files.get(relative) ?? {
|
|
7091
|
+
file: path.join(folder, change.feature, "strings", name),
|
|
7092
|
+
texts: /* @__PURE__ */ new Map()
|
|
7093
|
+
};
|
|
7094
|
+
group.texts.set(change.key, change.text);
|
|
7095
|
+
files.set(relative, group);
|
|
7096
|
+
}
|
|
7097
|
+
return files;
|
|
7098
|
+
}
|
|
7099
|
+
/**
|
|
7100
|
+
* Takes translated exchange files back into the game: every `<dir>/<locale>.json` is checked
|
|
7101
|
+
* against the game first, then each new text is written into the string file of the feature that
|
|
7102
|
+
* owns its key, and the strings are compiled into `out` as a dev run does. An empty text and a
|
|
7103
|
+
* text equal to the current message are skipped. With any problem nothing is written.
|
|
7104
|
+
*
|
|
7105
|
+
* @param root - Game source root, the folder that holds the features.
|
|
7106
|
+
* @param dir - The folder the translators handed back.
|
|
7107
|
+
* @param options - The features folder, where the compile writes, and whether it writes `en-XA`.
|
|
7108
|
+
* @returns The locales read, how many keys were written and the files rewritten.
|
|
7109
|
+
* @throws {Error} One error that lists every problem of the exchange files, or the compile's own
|
|
7110
|
+
* error after the files are written (a parameter kind that differs across locales).
|
|
7111
|
+
* @example
|
|
7112
|
+
* ```ts
|
|
7113
|
+
* // The translators handed back translations/ru.json with two hud texts filled in.
|
|
7114
|
+
* await importStrings("src", "translations", { out: "src/generated" });
|
|
7115
|
+
* // { locales: ["ru"], keys: 2, files: ["features/hud/strings/ru.json"] }
|
|
7116
|
+
* ```
|
|
7117
|
+
*/
|
|
7118
|
+
async function importStrings(root, dir, options = {}) {
|
|
7119
|
+
const features = options.features ?? DEFAULT_FEATURES;
|
|
7120
|
+
const review = await reviewImport(await readGame(root, features), dir);
|
|
7121
|
+
if (review.problems.length > 0) throw importFailure(review.problems);
|
|
7122
|
+
const byFile = groupByFile(review.changes, path.join(root, features));
|
|
7123
|
+
for (const group of byFile.values()) await rewriteStrings(group.file, group.texts);
|
|
7124
|
+
await compileStrings(root, options.out ?? path.join(root, "generated"), {
|
|
7125
|
+
features,
|
|
7126
|
+
pseudo: options.pseudo === true
|
|
7127
|
+
});
|
|
7128
|
+
return {
|
|
7129
|
+
locales: review.locales.toSorted(),
|
|
7130
|
+
keys: review.changes.length,
|
|
7131
|
+
files: [...byFile.keys()].toSorted()
|
|
7132
|
+
};
|
|
7133
|
+
}
|
|
7134
|
+
//#endregion
|
|
6114
7135
|
//#region src/assets.ts
|
|
6115
7136
|
/**
|
|
6116
7137
|
* @file The node-side door of the engine (subpath `./assets`, node and bun only): asset and file
|
|
6117
7138
|
* work that has no place in a browser bundle. It holds the asset key scanner, the production
|
|
6118
|
-
* packer and the
|
|
6119
|
-
* `bun run assets:
|
|
6120
|
-
*
|
|
6121
|
-
* the
|
|
6122
|
-
|
|
6123
|
-
|
|
7139
|
+
* packer, the string compiler and the exchange with translators; a game runs them as
|
|
7140
|
+
* `bun run assets:keys` (`--pseudo` for the pseudo-locale), `bun run assets:pack` (`--pack <dir>`),
|
|
7141
|
+
* `bun run assets:export` (`--export <dir>`) and `bun run assets:import` (`--import <dir>`), the
|
|
7142
|
+
* editor imports the same functions later. Re-exports only: the code lives in
|
|
7143
|
+
* `plugins/assets/scan/` and `plugins/i18n/compile/`, and this is the one door to them in `src/`.
|
|
7144
|
+
*/
|
|
7145
|
+
if (import.meta.main) process.exitCode = await runCli(process.argv.slice(2), {
|
|
7146
|
+
compile: compileStrings,
|
|
7147
|
+
exportStrings,
|
|
7148
|
+
importStrings
|
|
7149
|
+
});
|
|
6124
7150
|
//#endregion
|
|
6125
|
-
export { checkStrings, compileStrings, emitKeys, emitManifest, packAssets, runCli, scanAssets };
|
|
7151
|
+
export { checkStrings, compileStrings, emitKeys, emitManifest, exportStrings, importStrings, packAssets, runCli, scanAssets };
|