@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.
Files changed (35) hide show
  1. package/README.md +2 -0
  2. package/dist/assets.d.mts +181 -22
  3. package/dist/assets.mjs +1186 -160
  4. package/dist/{commands-C0BljsM5.d.mts → commands-DYO22lzi.d.mts} +22 -5
  5. package/dist/control.d.mts +3 -3
  6. package/dist/control.mjs +3 -3
  7. package/dist/{define-CjP_4H0G.d.mts → define-BKWjWgyQ.d.mts} +1 -1
  8. package/dist/dev-CQYJPmcD.mjs +1183 -0
  9. package/dist/flatten-BkXuscAV.mjs +100 -0
  10. package/dist/index.d.mts +85 -31
  11. package/dist/index.mjs +3208 -490
  12. package/dist/{session-DmAxY6Ll.mjs → input-CK4nxBO1.mjs} +41 -12
  13. package/dist/inspect.d.mts +32 -9
  14. package/dist/inspect.mjs +2 -2
  15. package/dist/jsx-dev-runtime.d.mts +1 -1
  16. package/dist/jsx-dev-runtime.mjs +1 -1
  17. package/dist/jsx-runtime.d.mts +1 -1
  18. package/dist/jsx-runtime.mjs +1 -1
  19. package/dist/{run-tlcn1w7y.mjs → run-BfuMLahc.mjs} +489 -213
  20. package/dist/{runtime-Bn5TXNxP.d.mts → runtime-Bhu442L8.d.mts} +1 -1
  21. package/dist/{runtime-DRlwxkIv.mjs → runtime-CBZgWgzx.mjs} +1 -57
  22. package/dist/{sources-BooL2MsX.mjs → sources-C1uGjz4l.mjs} +198 -21
  23. package/dist/testing.d.mts +5 -5
  24. package/dist/testing.mjs +5 -4
  25. package/dist/{types-CxZy7Pi4.d.mts → types-BnWH-75b.d.mts} +95 -21
  26. package/dist/{types-CqkrEY06.d.mts → types-CVUHPvMB.d.mts} +5 -434
  27. package/dist/{types-DmhTHRVW.d.mts → types-DtmFVCHf.d.mts} +1 -1
  28. package/dist/{types-BZ1OLf24.d.mts → types-Dy1xB68L.d.mts} +2318 -1169
  29. package/dist/{types-X9jXjka3.d.mts → types-VErbiEXy.d.mts} +867 -198
  30. package/dist/{visual-NPrDkvUk.mjs → visual-DUvvPPCt.mjs} +51 -1
  31. package/llms.txt +5 -5
  32. package/package.json +5 -5
  33. package/dist/component-DGg5DqKK.mjs +0 -44
  34. package/dist/dev-CcwFrwok.mjs +0 -413
  35. 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.click", "9c4e2b7a10"); // "ui/ui.click-9c4e2b7a10.mp3"
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}.mp3`;
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$2(problems) {
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$2(run.problems);
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 out with a note.
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` or an MP3.
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
- /** The formats a game brings instead of an MP3. One format decodes everywhere, so MP3 it is. */
1907
- const OTHER_AUDIO = /\.(?:ogg|wav|m4a|aac|opus|flac)$/i;
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 .mp3 only.`;
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$1(problems) {
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$1(scan.problems);
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 and the packer. It is the
2494
- * only file here that touches the terminal: the scan, the emitters, the key rule and the packer
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
- * compiler of i18n comes in as a parameter: i18n sits above assets, so the door `src/assets.ts`
2498
- * hands it over instead of this file importing it.
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 path.
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 `--root`, `--manifest`, `--keys` and `--pack`.
2535
+ * @returns True for the path flags and `--source`.
2525
2536
  */
2526
- function isPathFlag(flag) {
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
- * Reads the flags. `--keys` defaults to `generated/assets.ts` inside the root; `--manifest` to
2531
- * `manifest.json` inside the root, or inside the pack folder with `--pack`.
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 resolved paths, the pack folder and the two switches.
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 parseArgv(argv) {
2538
- const given = {
2539
- "--root": void 0,
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 === "--check" || flag === "--no-cache") {
2550
- if (flag === "--check") check = true;
2551
- else cache = false;
2562
+ if (isSwitch(flag)) {
2563
+ switches.add(flag);
2552
2564
  index += 1;
2553
2565
  continue;
2554
2566
  }
2555
- if (!isPathFlag(flag)) throw problem$1(`unknown option "${String(flag)}".`);
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
- given[flag] = value;
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
- const root = path.resolve(given["--root"] ?? ".");
2562
- const pack = given["--pack"] === void 0 ? void 0 : path.resolve(given["--pack"]);
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(given["--manifest"] ?? path.join(pack ?? root, MANIFEST_FILE)),
2566
- keys: path.resolve(given["--keys"] ?? path.join(root, KEYS_FILE)),
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} With `--check`, and when the scan, the compile or the pack fails.
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), { check: false });
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
- * Nothing here calls `process.exit`; the caller does.
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 compile - The strings compiler, `compileStrings` of i18n.
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 or the pack failed or `--check` found a
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
- * await runCli(["--root", "src", "--manifest", "public/assets/manifest.json", "--check"], compileStrings);
2706
- * // 1 when public/assets/manifest.json is older than the files in src/features
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, compile, ui = createBrandConsole()) {
2878
+ async function runCli(argv, strings, ui = createBrandConsole()) {
2713
2879
  try {
2714
2880
  const options = parseArgv(argv);
2715
- if (options.pack !== void 0) return await runPack(options, options.pack, compile, ui);
2716
- const { manifest, changed, notes } = await scanAssets({
2717
- root: options.root,
2718
- manifest: options.manifest,
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, the helper only when a
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
- * Parses one message, turning a parser failure into a compile problem.
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 `number` argument.
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 ICU message into the arrow the build writes into `generated/strings.<locale>.ts`.
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 text - The message as the game wrote it.
5778
- * @returns The arrow source, the parameter types and whether the module needs the helper.
5779
- * @throws {Error} When the message does not parse or uses an unsupported ICU feature.
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
- * compileMessage("Заказы").source; // '() => [{ kind: "text", text: "Заказы" }]'
6093
+ * compileElements([{ type: TYPE.literal, value: "Play" }]).source; // '() => [{ kind: "text", text: "Play" }]'
5783
6094
  * ```
5784
6095
  */
5785
- function compileMessage(text) {
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(parseMessage(text), scan);
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/compile.ts
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
- * @file i18n plugin, build time — the walk over `features/*​/strings/<locale>.json` and the two
5803
- * generated modules it writes. Every problem of a run is collected and reported in one error, so
5804
- * a game fixes its messages in one round. Node and Bun only: nothing under `src/` outside this
5805
- * folder and `src/assets.ts` imports it, so no game bundles the ICU parser.
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
- /** How every problem of this compiler is prefixed. */
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 collected(problems) {
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 compile collects.
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 (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
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 compile collects.
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 compile collects.
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
- * Compiles one message into the locale it belongs to.
6568
+ * Keeps one text of one key in one locale: the message, or its note.
5976
6569
  *
5977
- * @param walk - What the compile collects.
6570
+ * @param table - The messages or the notes of the walk.
5978
6571
  * @param key - The message key.
5979
- * @param text - The message.
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, text, source) {
5983
- if (typeof text !== "string") {
5984
- walk.problems.push(`"${key}" in ${source.relative}: a message must be a string.`);
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. A
6037
- * check run writes nothing and only reports whether something would change.
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, and what the features folder is called.
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
- * const report = await compileStrings("src", "src/generated");
6047
- * // report.locales: ["en", "ru"], report.changed: true on the first run
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 walk = {
6052
- byLocale: emptyLocales(),
6053
- owner: emptyOwners(),
6054
- params: emptyParameters(),
6055
- problems: []
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 (locales.length === 0) return {
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(walk.byLocale.get(locale) ?? [])
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, locales)
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 string compiler; a game runs them as `bun run assets:keys` and
6119
- * `bun run assets:pack` (`--pack <dir>`), the editor imports the same functions later.
6120
- * Re-exports only: the code lives in `plugins/assets/scan/` and `plugins/i18n/compile/`, and this is
6121
- * the one door to them in `src/`.
6122
- */
6123
- if (import.meta.main) process.exitCode = await runCli(process.argv.slice(2), compileStrings);
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 };