@moku-labs/game 0.8.0 → 0.9.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 (42) hide show
  1. package/README.md +8 -5
  2. package/dist/assets.d.mts +97 -33
  3. package/dist/assets.mjs +350 -110
  4. package/dist/{capture-BX5Gfoty.mjs → capture-fpd83Mi5.mjs} +384 -1
  5. package/dist/{commands-C1kIfOG5.d.mts → commands-DuwCxK5t.d.mts} +5 -6
  6. package/dist/control.d.mts +4 -4
  7. package/dist/control.mjs +1 -1
  8. package/dist/{define-BPpZcpxW.d.mts → define-B6pHpdBi.d.mts} +2 -2
  9. package/dist/{headless-CvamCUcR.mjs → headless-BsNdJs9W.mjs} +1 -1
  10. package/dist/{headless-q98SwADR.d.mts → headless-SCQXJxQf.d.mts} +1 -1
  11. package/dist/hot.d.mts +4 -2
  12. package/dist/hot.mjs +8 -6
  13. package/dist/index-BO9kZzY3.d.mts +1611 -0
  14. package/dist/index.d.mts +6 -1597
  15. package/dist/index.mjs +4 -34343
  16. package/dist/inspect.d.mts +7 -8
  17. package/dist/inspect.mjs +1 -1
  18. package/dist/jsx-dev-runtime.d.mts +1 -1
  19. package/dist/jsx-runtime.d.mts +1 -1
  20. package/dist/lint.d.mts +55 -4
  21. package/dist/lint.mjs +993 -40
  22. package/dist/project.d.mts +29 -13
  23. package/dist/project.mjs +986 -497
  24. package/dist/{run-CUPSFM85.mjs → run-CofJEdYx.mjs} +2 -2
  25. package/dist/{runtime-CvDZZg1S.d.mts → runtime-CdNJ0UBm.d.mts} +1 -1
  26. package/dist/{sources-BBUd15vQ.mjs → sources-DNnzyCYf.mjs} +1 -1
  27. package/dist/src-536arXjV.mjs +34644 -0
  28. package/dist/testing.d.mts +130 -3
  29. package/dist/testing.mjs +192 -3
  30. package/dist/{types-BWzCSRM2.d.mts → types-B0v5VVa3.d.mts} +2982 -19
  31. package/dist/{types-DF5kkW-C.d.mts → types-CQueODzo.d.mts} +4 -5
  32. package/dist/{types-CDSKa0vl.d.mts → types-DUMoYPYe.d.mts} +2 -2
  33. package/dist/{types-DlgsfOmW.d.mts → types-VZVWwqCg.d.mts} +49 -12
  34. package/dist/{types-DgdMLZnV.d.mts → types-xXfyk56Q.d.mts} +7 -8
  35. package/dist/{visual-DUvvPPCt.mjs → visual-dMW8iqAE.mjs} +8 -4
  36. package/dist/visual.d.mts +10 -11
  37. package/dist/visual.mjs +2 -2
  38. package/llms.txt +55 -18
  39. package/package.json +1 -1
  40. package/dist/memory-CvgdnsQO.mjs +0 -259
  41. package/dist/registry-DlpRCibU.mjs +0 -384
  42. package/dist/types-ERb2Y9U5.d.mts +0 -2967
package/dist/assets.mjs CHANGED
@@ -1894,9 +1894,10 @@ function keyOf(feature, relative, file) {
1894
1894
  //#endregion
1895
1895
  //#region src/plugins/assets/scan/scan.ts
1896
1896
  /**
1897
- * @file assets plugin, build time — the walk. `features/*` with an `assets/` folder becomes the
1898
- * manifest: one bundle per feature, split by the optional `assets.ts` that feature exports.
1899
- * Node and Bun only. Nothing under `src/` outside `scan/` imports it, so no game bundles it.
1897
+ * @file assets plugin, build time — the walk. `features/*` with an `assets/` folder, and every
1898
+ * layer the options map (a folder under the root read like one more feature), becomes the manifest:
1899
+ * one bundle per feature or layer, split by the optional `assets.ts` it exports. Node and Bun only.
1900
+ * Nothing under `src/` outside `scan/` imports it, so no game bundles it.
1900
1901
  */
1901
1902
  const TIERS = [
1902
1903
  "boot",
@@ -1982,6 +1983,75 @@ async function listFeatures(dir) {
1982
1983
  return (await readdir(dir, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).toSorted(byName$1);
1983
1984
  }
1984
1985
  /**
1986
+ * Tells whether a path is a folder.
1987
+ *
1988
+ * @param dir - The path to look at.
1989
+ * @returns True for a folder; false for a file or nothing.
1990
+ */
1991
+ async function isFolder(dir) {
1992
+ return await stat(dir).then((entry) => entry.isDirectory(), () => false);
1993
+ }
1994
+ /**
1995
+ * Says why one layer cannot be read, by its own folder and name alone.
1996
+ *
1997
+ * @param folder - The layer folder under the root.
1998
+ * @param name - The name its keys and bundles take.
1999
+ * @param features - Name of the features folder.
2000
+ * @param featureNames - The feature folders found under it.
2001
+ * @returns The problem, or `undefined` when the layer is fine on its own.
2002
+ * @example
2003
+ * ```ts
2004
+ * layerRefusal("shared", "u.i", "features", []);
2005
+ * // 'the layer "shared" is mapped to "u.i", which is empty or has a "." that would fake a folder.'
2006
+ * ```
2007
+ */
2008
+ function layerRefusal(folder, name, features, featureNames) {
2009
+ if (folder === "" || /[/\\.]/.test(folder)) return `the layer "${folder}" is not a folder name: ` + String.raw`use a name without "/", "\" or ".".`;
2010
+ if (folder === features) return `the layer "${folder}" is the features folder "${features}"; the features are scanned already.`;
2011
+ if (name === "" || name.includes(".")) return `the layer "${folder}" is mapped to "${name}", which is empty or has a "." that would fake a folder.`;
2012
+ if (featureNames.includes(name)) return `the layer "${folder}" is mapped to "${name}", which is also a feature folder: their keys and bundles would merge.`;
2013
+ }
2014
+ /**
2015
+ * Says that a layer is mapped to a name an earlier layer took.
2016
+ *
2017
+ * @param first - The folder of the earlier layer with that name, if one took it.
2018
+ * @param folder - The layer folder.
2019
+ * @param name - The name it is mapped to.
2020
+ * @returns The refusal, or `undefined` when no earlier layer took the name.
2021
+ * @example
2022
+ * ```ts
2023
+ * nameTakenRefusal("common", "shared", "ui"); // 'the layers "common" and "shared" are both mapped to "ui".'
2024
+ * ```
2025
+ */
2026
+ function nameTakenRefusal(first, folder, name) {
2027
+ if (first === void 0) return void 0;
2028
+ return `the layers "${first}" and "${folder}" are both mapped to "${name}".`;
2029
+ }
2030
+ /**
2031
+ * Checks every layer of a map, sorted by folder: one refusal at most per layer, and a name that an
2032
+ * earlier layer took is a refusal of the later one.
2033
+ *
2034
+ * @param layers - Folder to name.
2035
+ * @param features - Name of the features folder.
2036
+ * @param featureNames - The feature folders found under it.
2037
+ * @returns Every layer with its refusal, in the order of the sorted folders.
2038
+ */
2039
+ function checkLayers(layers, features, featureNames) {
2040
+ const taken = /* @__PURE__ */ new Map();
2041
+ const checks = [];
2042
+ const sorted = Object.entries(layers).toSorted((left, right) => byName$1(left[0], right[0]));
2043
+ for (const [folder, name] of sorted) {
2044
+ const refusal = layerRefusal(folder, name, features, featureNames) ?? nameTakenRefusal(taken.get(name), folder, name);
2045
+ if (refusal === void 0) taken.set(name, folder);
2046
+ checks.push({
2047
+ folder,
2048
+ name,
2049
+ refusal
2050
+ });
2051
+ }
2052
+ return checks;
2053
+ }
2054
+ /**
1985
2055
  * Walks one `assets/` folder.
1986
2056
  *
1987
2057
  * @param dir - The folder to read.
@@ -2064,20 +2134,21 @@ function readSpec(scan, feature, name, raw, specs) {
2064
2134
  });
2065
2135
  }
2066
2136
  /**
2067
- * Reads the bundles a feature declares. Every export that is a `defineBundles` result counts.
2137
+ * Reads the bundles a feature or a layer declares. Every export that is a `defineBundles` result
2138
+ * counts.
2068
2139
  *
2069
2140
  * @param scan - State of the scan.
2070
- * @param feature - Name of the feature.
2071
- * @returns The declared bundles, empty when the feature brought no description.
2141
+ * @param owner - The feature or layer: its folder and the name its bundles take.
2142
+ * @returns The declared bundles, empty when the owner brought no description.
2072
2143
  */
2073
- async function readBundleSpecs(scan, feature) {
2144
+ async function readBundleSpecs(scan, owner) {
2074
2145
  const specs = /* @__PURE__ */ new Map();
2075
- const file = path.join(scan.featuresFolder, feature, "assets.ts");
2146
+ const file = path.join(owner.dir, "assets.ts");
2076
2147
  try {
2077
2148
  const loaded = await importDescription(file);
2078
2149
  for (const value of Object.values(loaded ?? {})) {
2079
2150
  if (!isRecord(value) || value.kind !== "bundles" || !isRecord(value.map)) continue;
2080
- for (const [name, raw] of Object.entries(value.map)) readSpec(scan, feature, name, raw, specs);
2151
+ for (const [name, raw] of Object.entries(value.map)) readSpec(scan, owner.name, name, raw, specs);
2081
2152
  }
2082
2153
  } catch (error) {
2083
2154
  const relative = toPosix(path.relative(scan.root, file));
@@ -2351,14 +2422,16 @@ async function addFile(scan, pass, relative) {
2351
2422
  }
2352
2423
  }
2353
2424
  /**
2354
- * Reads one feature: its description first, then its fonts, then every file of its `assets/`.
2425
+ * Reads one feature or layer: its description first, then its fonts, then every file of its
2426
+ * `assets/`.
2355
2427
  *
2356
2428
  * @param scan - State of the scan.
2357
- * @param feature - Name of the feature folder.
2429
+ * @param owner - The feature or layer: its folder and the name its keys and bundles take.
2358
2430
  */
2359
- async function scanFeature(scan, feature) {
2360
- const specs = await readBundleSpecs(scan, feature);
2361
- const assetsFolder = path.join(scan.featuresFolder, feature, "assets");
2431
+ async function scanOwner(scan, owner) {
2432
+ const feature = owner.name;
2433
+ const specs = await readBundleSpecs(scan, owner);
2434
+ const assetsFolder = path.join(owner.dir, "assets");
2362
2435
  for (const [name, spec] of specs) draftOf(scan, name, feature, spec.tier);
2363
2436
  const files = await walkAssets(assetsFolder, "");
2364
2437
  const fonts = await readFonts(scan, assetsFolder, files);
@@ -2457,26 +2530,57 @@ async function applyOutputs(outputs, write) {
2457
2530
  return changed;
2458
2531
  }
2459
2532
  /**
2460
- * Walks the features of a game and writes its two generated files: one bundle per feature, split
2461
- * by the optional `assets.ts` of that feature, every file keyed by the key rule. A check run
2533
+ * Reads the layers that passed their checks, sorted by folder. A layer without its folder is a
2534
+ * note, not a problem: a typo in `--layer` would otherwise drop its keys without a word.
2535
+ *
2536
+ * @param scan - State of the scan.
2537
+ * @param checks - Every layer with its refusal; a refused layer is not read.
2538
+ */
2539
+ async function scanLayers(scan, checks) {
2540
+ for (const { folder, name, refusal } of checks) {
2541
+ if (refusal !== void 0) continue;
2542
+ const dir = path.join(scan.root, folder);
2543
+ if (!await isFolder(dir)) {
2544
+ scan.notes.push(`the layer "${folder}" has no folder "${folder}/" under the root; nothing was read for it.`);
2545
+ continue;
2546
+ }
2547
+ await scanOwner(scan, {
2548
+ name,
2549
+ dir
2550
+ });
2551
+ }
2552
+ }
2553
+ /**
2554
+ * Walks the features and the layers of a game and writes its two generated files: one bundle per
2555
+ * feature or layer, split by its optional `assets.ts`, every file keyed by the key rule. A layer is
2556
+ * read like one more feature under its mapped name, after the features. A check run
2462
2557
  * (`write: false`) writes nothing and only reports whether something would change.
2463
2558
  *
2464
- * @param options - The game source root, the two output paths and whether to write.
2559
+ * @param options - The game source root, the layers, the two output paths and whether to write.
2465
2560
  * @returns The manifest, the text of the key module and whether an output differed.
2466
- * @throws {Error} One error that lists every problem the scan found.
2561
+ * @throws {Error} One error that lists every problem the scan found, the layer problems first.
2467
2562
  * @example
2468
2563
  * ```ts
2469
2564
  * const { manifest, changed } = await scanAssets({
2470
2565
  * root: "src",
2471
2566
  * manifest: "public/assets/manifest.json",
2472
- * keys: "src/generated/assets.ts"
2567
+ * keys: "src/generated/assets.ts",
2568
+ * layers: { shared: "ui" } // shared/assets/button/primary.png keeps the key "ui.button.primary"
2473
2569
  * });
2474
2570
  * // manifest.bundles.ui.files[0].key: "ui.button.primary", changed: true on the first run
2475
2571
  * ```
2476
2572
  */
2477
2573
  async function scanAssets(options) {
2478
- const scan = createState(options.root, options.features ?? "features");
2479
- for (const feature of await listFeatures(scan.featuresFolder)) await scanFeature(scan, feature);
2574
+ const features = options.features ?? "features";
2575
+ const scan = createState(options.root, features);
2576
+ const featureNames = await listFeatures(scan.featuresFolder);
2577
+ const checks = checkLayers(options.layers ?? {}, features, featureNames);
2578
+ for (const check of checks) if (check.refusal !== void 0) scan.problems.push(check.refusal);
2579
+ for (const feature of featureNames) await scanOwner(scan, {
2580
+ name: feature,
2581
+ dir: path.join(scan.featuresFolder, feature)
2582
+ });
2583
+ await scanLayers(scan, checks);
2480
2584
  if (scan.problems.length > 0) throw collected(scan.problems);
2481
2585
  const manifest = toManifest(scan);
2482
2586
  const manifestSource = emitManifest(manifest);
@@ -2510,6 +2614,8 @@ const KEYS_FILE = path.join("generated", "assets.ts");
2510
2614
  const DEFAULT_SOURCE$1 = "en";
2511
2615
  /** The extension of an exchange file: the export writes `<dir>/<locale>.json`. */
2512
2616
  const EXCHANGE_EXTENSION = ".json";
2617
+ /** The flag that maps a folder under the root to the name its keys take. It repeats. */
2618
+ const LAYER_FLAG = "--layer";
2513
2619
  /**
2514
2620
  * Wraps a command line problem in the message shape of the framework.
2515
2621
  *
@@ -2547,15 +2653,35 @@ function isSwitch(flag) {
2547
2653
  return flag === "--check" || flag === "--no-cache" || flag === "--pseudo";
2548
2654
  }
2549
2655
  /**
2656
+ * Reads the value after a flag that takes one: the next argument, unless it is missing or is
2657
+ * another flag.
2658
+ *
2659
+ * @param argv - The arguments after the script name.
2660
+ * @param at - Where the flag is.
2661
+ * @param what - What the value is, as the refusal names it: `a path`, `a locale`.
2662
+ * @returns The value.
2663
+ * @throws {Error} When the value is missing or is another flag.
2664
+ * @example
2665
+ * ```ts
2666
+ * valueAt(["--root", "src"], 0, "a path"); // "src"
2667
+ * ```
2668
+ */
2669
+ function valueAt(argv, at, what) {
2670
+ const value = argv[at + 1];
2671
+ if (value === void 0 || value.startsWith("--")) throw problem$1(`"${String(argv[at])}" needs ${what}.`);
2672
+ return value;
2673
+ }
2674
+ /**
2550
2675
  * Reads the flags as they were given.
2551
2676
  *
2552
2677
  * @param argv - The arguments after the script name.
2553
- * @returns The value of each flag that takes one, and the switches.
2678
+ * @returns The value of each flag that takes one, the switches and the `--layer` values.
2554
2679
  * @throws {Error} When an option is unknown or misses its value.
2555
2680
  */
2556
2681
  function readFlags(argv) {
2557
2682
  const values = {};
2558
2683
  const switches = /* @__PURE__ */ new Set();
2684
+ const layers = [];
2559
2685
  let index = 0;
2560
2686
  while (index < argv.length) {
2561
2687
  const flag = argv[index];
@@ -2564,18 +2690,62 @@ function readFlags(argv) {
2564
2690
  index += 1;
2565
2691
  continue;
2566
2692
  }
2693
+ if (flag === LAYER_FLAG) {
2694
+ layers.push(valueAt(argv, index, "\"<folder>[=<name>]\""));
2695
+ index += 2;
2696
+ continue;
2697
+ }
2567
2698
  if (!isValueFlag(flag)) throw problem$1(`unknown option "${String(flag)}".`);
2568
- const value = argv[index + 1];
2569
- if (value === void 0 || value.startsWith("--")) throw problem$1(`"${flag}" needs ${flag === "--source" ? "a locale" : "a path"}.`);
2570
- values[flag] = value;
2699
+ values[flag] = valueAt(argv, index, flag === "--source" ? "a locale" : "a path");
2571
2700
  index += 2;
2572
2701
  }
2573
2702
  return {
2574
2703
  values,
2575
- switches
2704
+ switches,
2705
+ layers
2576
2706
  };
2577
2707
  }
2578
2708
  /**
2709
+ * Reads the `--layer` values into the layer map. `shared=ui` maps the folder to the name,
2710
+ * `shared` alone maps it to itself. Only the syntax is checked here; the scan refuses a folder or
2711
+ * a name that cannot be a layer.
2712
+ *
2713
+ * @param raw - The values as given, in order.
2714
+ * @returns Folder to name.
2715
+ * @throws {Error} When a value has an empty side or a folder repeats.
2716
+ * @example
2717
+ * ```ts
2718
+ * parseLayers(["shared=ui", "common"]); // { shared: "ui", common: "common" }
2719
+ * ```
2720
+ */
2721
+ function parseLayers(raw) {
2722
+ const layers = /* @__PURE__ */ new Map();
2723
+ for (const value of raw) {
2724
+ const cut = value.indexOf("=");
2725
+ const folder = cut === -1 ? value : value.slice(0, cut);
2726
+ const name = cut === -1 ? value : value.slice(cut + 1);
2727
+ if (folder === "" || name === "" || name.includes("=")) throw problem$1(`"--layer ${value}" needs a folder and a name: "<folder>[=<name>]".`);
2728
+ if (layers.has(folder)) throw problem$1(`"--layer" names the folder "${folder}" twice.`);
2729
+ layers.set(folder, name);
2730
+ }
2731
+ return Object.fromEntries(layers);
2732
+ }
2733
+ /**
2734
+ * The layer member of the options a tool is handed: none for a run without `--layer`, so such a
2735
+ * run calls every tool exactly as before layers existed.
2736
+ *
2737
+ * @param layers - The layers of the run.
2738
+ * @returns `{ layers }`, or an empty object when there is no layer.
2739
+ * @example
2740
+ * ```ts
2741
+ * withLayers({}); // {}
2742
+ * withLayers({ shared: "ui" }); // { layers: { shared: "ui" } }
2743
+ * ```
2744
+ */
2745
+ function withLayers(layers) {
2746
+ return Object.keys(layers).length === 0 ? {} : { layers };
2747
+ }
2748
+ /**
2579
2749
  * Refuses the flags that do not go together, before anything is read or written. An exchange
2580
2750
  * with the translators runs alone; only `--pseudo` may join an import, because the compile after
2581
2751
  * it writes the pseudo-locale too.
@@ -2610,8 +2780,9 @@ function resolveGiven(given) {
2610
2780
  * `manifest.json` inside the root, or inside the pack folder with `--pack`; `--source` to `"en"`.
2611
2781
  *
2612
2782
  * @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.
2783
+ * @returns The resolved paths, the folders of the three modes, the switches and the layers.
2784
+ * @throws {Error} When an option is unknown, misses its value, does not go with another, or a
2785
+ * `--layer` value is not `<folder>[=<name>]`.
2615
2786
  */
2616
2787
  function parseArgv(argv) {
2617
2788
  const flags = readFlags(argv);
@@ -2629,7 +2800,8 @@ function parseArgv(argv) {
2629
2800
  pseudo: switches.has("--pseudo"),
2630
2801
  source: values["--source"] ?? DEFAULT_SOURCE$1,
2631
2802
  exportDir: resolveGiven(values["--export"]),
2632
- importDir: resolveGiven(values["--import"])
2803
+ importDir: resolveGiven(values["--import"]),
2804
+ layers: parseLayers(flags.layers)
2633
2805
  };
2634
2806
  }
2635
2807
  /**
@@ -2747,9 +2919,9 @@ function packSummary(file, result, cache) {
2747
2919
  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}.`;
2748
2920
  }
2749
2921
  /**
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.
2922
+ * The dev run of `bun run assets:keys`: walk the features and the layers, write the manifest and
2923
+ * the key module, then compile the strings next to the key module (the pseudo-locale too with
2924
+ * `--pseudo`). With `--check` nothing is written and a difference fails the run.
2753
2925
  *
2754
2926
  * @param options - The parsed flags.
2755
2927
  * @param compile - The strings compiler.
@@ -2762,11 +2934,13 @@ async function runKeys(options, compile, ui) {
2762
2934
  root: options.root,
2763
2935
  manifest: options.manifest,
2764
2936
  keys: options.keys,
2765
- write: !options.check
2937
+ write: !options.check,
2938
+ ...withLayers(options.layers)
2766
2939
  });
2767
2940
  const strings = await compile(options.root, path.dirname(options.keys), {
2768
2941
  check: options.check,
2769
- pseudo: options.pseudo
2942
+ pseudo: options.pseudo,
2943
+ ...withLayers(options.layers)
2770
2944
  });
2771
2945
  for (const note of [...notes, ...strings.notes]) ui.warn(note);
2772
2946
  if ((changed || strings.changed) && options.check) {
@@ -2795,7 +2969,8 @@ async function runPack(options, pack, compile, ui) {
2795
2969
  root: options.root,
2796
2970
  manifest: options.manifest,
2797
2971
  keys: options.keys,
2798
- write: false
2972
+ write: false,
2973
+ ...withLayers(options.layers)
2799
2974
  });
2800
2975
  await applyOutputs([{
2801
2976
  file: options.keys,
@@ -2803,7 +2978,8 @@ async function runPack(options, pack, compile, ui) {
2803
2978
  }], true);
2804
2979
  const strings = await compile(options.root, path.dirname(options.keys), {
2805
2980
  check: false,
2806
- pseudo: false
2981
+ pseudo: false,
2982
+ ...withLayers(options.layers)
2807
2983
  });
2808
2984
  for (const note of [...scan.notes, ...strings.notes]) ui.warn(note);
2809
2985
  const result = await packAssets({
@@ -2829,7 +3005,10 @@ async function runPack(options, pack, compile, ui) {
2829
3005
  * @throws {Error} When the export fails.
2830
3006
  */
2831
3007
  async function runExport(options, dir, exportStrings, ui) {
2832
- const report = await exportStrings(options.root, dir, { source: options.source });
3008
+ const report = await exportStrings(options.root, dir, {
3009
+ source: options.source,
3010
+ ...withLayers(options.layers)
3011
+ });
2833
3012
  for (const locale of report.locales) ui.info(exportLine(dir, locale, report.missing[locale] ?? 0));
2834
3013
  return 0;
2835
3014
  }
@@ -2848,7 +3027,8 @@ async function runImport(options, dir, importStrings, ui) {
2848
3027
  const report = await importStrings(options.root, dir, {
2849
3028
  source: options.source,
2850
3029
  pseudo: options.pseudo,
2851
- out: path.dirname(options.keys)
3030
+ out: path.dirname(options.keys),
3031
+ ...withLayers(options.layers)
2852
3032
  });
2853
3033
  ui.info(importLine(dir, report));
2854
3034
  return 0;
@@ -2858,8 +3038,10 @@ async function runImport(options, dir, importStrings, ui) {
2858
3038
  * compile the feature strings next to the key module, or check that all of it is current. With
2859
3039
  * `--pack <dir>` it packs the art for production on the same scan (`--no-cache` for a cold run);
2860
3040
  * `--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.
3041
+ * the strings with translators and run alone, from the locale of `--source`.
3042
+ * `--layer <folder>[=<name>]` (repeatable) scans a folder under the root like one more feature,
3043
+ * its keys and bundles under `<name>` (`<folder>` by default); it goes with every mode. Nothing
3044
+ * here calls `process.exit`; the caller does.
2863
3045
  *
2864
3046
  * @param argv - The arguments after the script name.
2865
3047
  * @param strings - The string tools of i18n: compile, export and import.
@@ -2873,6 +3055,8 @@ async function runImport(options, dir, importStrings, ui) {
2873
3055
  * // 0, and dist/assets holds the pages, the loose files and the packed manifest.json
2874
3056
  * await runCli(["--root", "src", "--export", "translations"], strings);
2875
3057
  * // 0, and translations/ holds one <locale>.json per locale for the translators
3058
+ * await runCli(["--root", "src", "--layer", "shared=ui"], strings);
3059
+ * // 0, shared/assets/* keyed ui.*, shared/strings/* compiled
2876
3060
  * ```
2877
3061
  */
2878
3062
  async function runCli(argv, strings, ui = createBrandConsole()) {
@@ -6309,13 +6493,16 @@ function pseudoMessage(elements) {
6309
6493
  //#endregion
6310
6494
  //#region src/plugins/i18n/compile/walk.ts
6311
6495
  /**
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.
6496
+ * @file i18n plugin, build time — the walk over `features/*​/strings/<locale>.json` and the
6497
+ * `strings/` of every layer: every file read, every key claimed by one feature or layer, every
6498
+ * message compiled once and every problem collected, with the text and the translator note of each
6499
+ * key kept aside. `compileStrings`, `exportStrings` and `importStrings` all start from it. Node and
6500
+ * Bun only.
6316
6501
  */
6317
6502
  /** How every problem of the string compiler is prefixed. */
6318
6503
  const PREFIX = "[game] i18n: ";
6504
+ /** What a layer folder may not hold: a separator or a ".", which would lead out of the root. */
6505
+ const NOT_ONE_FOLDER = /[/\\.]/;
6319
6506
  /**
6320
6507
  * Creates the table of compiled messages per locale. Its own function because lint rule L5
6321
6508
  * refuses a collection built inside an exported declaration.
@@ -6459,24 +6646,53 @@ async function listLocales(folder) {
6459
6646
  }
6460
6647
  }
6461
6648
  /**
6462
- * Lists every string file of a game: features sorted, then locales sorted.
6649
+ * Lists the string files of one feature or layer, locales sorted. A folder without `strings/`
6650
+ * yields nothing.
6651
+ *
6652
+ * @param root - Game source root.
6653
+ * @param feature - The label: the feature folder name, or the mapped name of a layer.
6654
+ * @param folder - Root-relative POSIX path of the owner folder.
6655
+ * @returns The files of that folder.
6656
+ */
6657
+ async function listOwnerFiles(root, feature, folder) {
6658
+ const stringsFolder = path.join(root, ...folder.split("/"), "strings");
6659
+ return (await listLocales(stringsFolder)).map((locale) => ({
6660
+ feature,
6661
+ folder,
6662
+ locale,
6663
+ file: path.join(stringsFolder, `${locale}.json`),
6664
+ relative: `${folder}/strings/${locale}.json`
6665
+ }));
6666
+ }
6667
+ /**
6668
+ * The error for a layer folder that is not one folder name under the root: empty, or with a
6669
+ * separator or a "." that would read and write outside it.
6670
+ *
6671
+ * @param folder - The layer folder as given.
6672
+ * @returns The error to throw.
6673
+ */
6674
+ function notAFolderName(folder) {
6675
+ return /* @__PURE__ */ new Error(`${PREFIX}the layer "${folder}" is not a folder name.\n` + String.raw` Use a name without "/", "\" or ".".`);
6676
+ }
6677
+ /**
6678
+ * Lists every string file of a game: features sorted, then the layers sorted by folder; inside
6679
+ * each, locales sorted. Every layer folder is checked before anything is read.
6463
6680
  *
6464
6681
  * @param root - Game source root, the folder that holds the features.
6465
6682
  * @param features - Name of the folder that holds the features.
6683
+ * @param layers - Folder under the root to the name its keys take. Default `{}`: features only.
6466
6684
  * @returns The files, in walk order.
6685
+ * @throws {Error} When a layer folder is empty or holds "/", "\" or ".".
6467
6686
  */
6468
- async function listStringFiles(root, features) {
6687
+ async function listStringFiles(root, features, layers = {}) {
6688
+ const sortedLayers = Object.entries(layers).toSorted((left, right) => left[0] < right[0] ? -1 : 1);
6689
+ const outside = sortedLayers.find(([folder]) => folder === "" || NOT_ONE_FOLDER.test(folder));
6690
+ if (outside !== void 0) throw notAFolderName(outside[0]);
6469
6691
  const files = [];
6470
6692
  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
- }
6693
+ const featuresPath = features.split(path.sep).join("/");
6694
+ for (const feature of await listFolders(featuresFolder)) files.push(...await listOwnerFiles(root, feature, `${featuresPath}/${feature}`));
6695
+ for (const [folder, name] of sortedLayers) files.push(...await listOwnerFiles(root, name, folder));
6480
6696
  return files;
6481
6697
  }
6482
6698
  /**
@@ -6501,12 +6717,13 @@ async function readTable(file, relative, walk) {
6501
6717
  }
6502
6718
  }
6503
6719
  /**
6504
- * Records which feature owns a key, refusing the same key in two features.
6720
+ * Records which feature or layer owns a key, refusing the same key in two folders. Folders are
6721
+ * compared, not labels: a layer mapped to `ui` and the feature folder `features/ui` are two owners.
6505
6722
  *
6506
6723
  * @param walk - What the walk collects.
6507
6724
  * @param key - The message key.
6508
6725
  * @param source - Where this message came from.
6509
- * @returns True when the key belongs to this feature.
6726
+ * @returns True when the key belongs to this folder.
6510
6727
  */
6511
6728
  function claim(walk, key, source) {
6512
6729
  const known = walk.owner.get(key);
@@ -6514,7 +6731,7 @@ function claim(walk, key, source) {
6514
6731
  walk.owner.set(key, source);
6515
6732
  return true;
6516
6733
  }
6517
- if (known.feature === source.feature) return true;
6734
+ if (known.folder === source.folder) return true;
6518
6735
  walk.problems.push(`the key "${key}" is in ${known.relative} and ${source.relative}.`);
6519
6736
  return false;
6520
6737
  }
@@ -6608,15 +6825,17 @@ function compileOne(walk, key, value, source) {
6608
6825
  }
6609
6826
  }
6610
6827
  /**
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.
6828
+ * Walks every string file of a game, features first and then the layers, and compiles every
6829
+ * message once. Problems are collected, not thrown, so the caller reports them all in one error.
6613
6830
  *
6614
6831
  * @param root - Game source root, the folder that holds the features.
6615
6832
  * @param features - Name of the folder that holds the features.
6833
+ * @param layers - Folder under the root to the name its keys take. Default `{}`: features only.
6616
6834
  * @returns The compiled messages per locale, the owner and parameters of each key, the texts and
6617
6835
  * notes of each key per locale, and every problem.
6836
+ * @throws {Error} When a layer folder is empty or holds "/", "\" or ".": nothing is read.
6618
6837
  */
6619
- async function walkStrings(root, features) {
6838
+ async function walkStrings(root, features, layers = {}) {
6620
6839
  const walk = {
6621
6840
  byLocale: emptyLocales(),
6622
6841
  owner: emptyOwners(),
@@ -6625,11 +6844,12 @@ async function walkStrings(root, features) {
6625
6844
  texts: emptyTexts(),
6626
6845
  notes: emptyTexts()
6627
6846
  };
6628
- for (const file of await listStringFiles(root, features)) {
6847
+ for (const file of await listStringFiles(root, features, layers)) {
6629
6848
  const table = await readTable(file.file, file.relative, walk);
6630
6849
  if (table === void 0) continue;
6631
6850
  const source = {
6632
6851
  feature: file.feature,
6852
+ folder: file.folder,
6633
6853
  locale: file.locale,
6634
6854
  relative: file.relative
6635
6855
  };
@@ -6641,10 +6861,10 @@ async function walkStrings(root, features) {
6641
6861
  //#region src/plugins/i18n/compile/compile.ts
6642
6862
  /**
6643
6863
  * @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.
6864
+ * and the `strings/` of every layer turned into the generated modules, and on request the
6865
+ * pseudo-locale `en-XA` derived from English. Every problem of a run is collected and reported in
6866
+ * one error, so a game fixes its messages in one round. Node and Bun only: nothing under `src/`
6867
+ * outside this folder and `src/assets.ts` imports it, so no game bundles the ICU parser.
6648
6868
  */
6649
6869
  /** The locale the pseudo-locale is derived from. */
6650
6870
  const PSEUDO_SOURCE = "en";
@@ -6659,15 +6879,16 @@ function pseudoNeedsEnglish() {
6659
6879
  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
6880
  }
6661
6881
  /**
6662
- * Tells whether at least one feature brings a string file for a locale.
6882
+ * Tells whether at least one feature or layer brings a string file for a locale.
6663
6883
  *
6664
6884
  * @param root - Game source root, the folder that holds the features.
6665
6885
  * @param features - Name of the folder that holds the features.
6886
+ * @param layers - Folder under the root to the name its keys take.
6666
6887
  * @param locale - The locale to look for.
6667
- * @returns True when a feature has `strings/<locale>.json`.
6888
+ * @returns True when a feature or a layer has `strings/<locale>.json`.
6668
6889
  */
6669
- async function bringsLocale(root, features, locale) {
6670
- return (await listStringFiles(root, features)).some((file) => file.locale === locale);
6890
+ async function bringsLocale(root, features, layers, locale) {
6891
+ return (await listStringFiles(root, features, layers)).some((file) => file.locale === locale);
6671
6892
  }
6672
6893
  /**
6673
6894
  * Derives the pseudo message of every English message. Each one compiled already in the walk, so
@@ -6712,7 +6933,7 @@ function missingNotes(walk, locales) {
6712
6933
  const notes = [];
6713
6934
  for (const [key, source] of [...walk.owner].toSorted((left, right) => left[0] < right[0] ? -1 : 1)) for (const locale of locales) {
6714
6935
  if ((walk.byLocale.get(locale) ?? []).some((entry) => entry.key === key)) continue;
6715
- notes.push(`the key "${key}" is missing from features/${source.feature}/strings/${locale}.json.`);
6936
+ notes.push(`the key "${key}" is missing from ${source.folder}/strings/${locale}.json.`);
6716
6937
  }
6717
6938
  return notes;
6718
6939
  }
@@ -6735,30 +6956,37 @@ function typeEntries(walk) {
6735
6956
  return entries;
6736
6957
  }
6737
6958
  /**
6738
- * Walks `features/*​/strings/<locale>.json` of a game, compiles every message and writes the
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`.
6959
+ * Walks `features/*​/strings/<locale>.json` of a game and the `strings/` of every layer, compiles
6960
+ * every message and writes the generated modules: the types module `strings.ts` and one
6961
+ * `strings.<locale>.ts` per locale, plus `strings.en-XA.ts` with `pseudo`. A check run writes
6962
+ * nothing and only reports whether something would change; it checks `strings.en-XA.ts` only with
6963
+ * `pseudo`.
6742
6964
  *
6743
6965
  * @param root - Game source root, the folder that holds the features.
6744
6966
  * @param out - The generated directory the modules are written into.
6745
- * @param options - Whether this is a check run, what the features folder is called, and whether
6746
- * the pseudo-locale is written.
6967
+ * @param options - Whether this is a check run, what the features folder is called, whether the
6968
+ * pseudo-locale is written, and the layers.
6747
6969
  * @returns Whether an output differed, the locales, the keys and the notes.
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.
6970
+ * @throws {Error} One error that lists every problem the compile found, the pseudo error when
6971
+ * no feature or layer brings an `en` string file, or the layer error when a layer folder is not
6972
+ * one folder name: nothing is read then.
6750
6973
  * @example
6751
6974
  * ```ts
6752
6975
  * // A dev build also writes the pseudo-locale, derived from the English messages.
6753
6976
  * const report = await compileStrings("src", "src/generated", { pseudo: true });
6754
6977
  * // report.locales: ["en", "en-XA", "ru"], report.changed: true on the first run
6978
+ * // A v15 game: features/board/strings/en.json { "board.full": "Full" } and the layer file
6979
+ * // shared/strings/en.json { "ui.ok": "OK" } compile together.
6980
+ * const layered = await compileStrings("src", "src/generated", { layers: { shared: "ui" } });
6981
+ * // layered.keys: ["board.full", "ui.ok"]
6755
6982
  * ```
6756
6983
  */
6757
6984
  async function compileStrings(root, out, options = {}) {
6758
6985
  const features = options.features ?? "features";
6986
+ const layers = options.layers ?? {};
6759
6987
  const pseudo = options.pseudo === true;
6760
- if (pseudo && !await bringsLocale(root, features, PSEUDO_SOURCE)) throw pseudoNeedsEnglish();
6761
- const walk = await walkStrings(root, features);
6988
+ if (pseudo && !await bringsLocale(root, features, layers, PSEUDO_SOURCE)) throw pseudoNeedsEnglish();
6989
+ const walk = await walkStrings(root, features, layers);
6762
6990
  if (walk.problems.length > 0) throw compileFailure(walk.problems);
6763
6991
  const brought = [...walk.byLocale.keys()].toSorted();
6764
6992
  const keys = [...walk.owner.keys()].toSorted();
@@ -6789,7 +7017,8 @@ async function compileStrings(root, out, options = {}) {
6789
7017
  }
6790
7018
  /**
6791
7019
  * Tells whether the generated modules of a game are current, without writing anything. The
6792
- * `--check` run of `bun run assets:keys` covers the strings with it.
7020
+ * `--check` run of `bun run assets:keys` covers the strings with it. It walks the features only: a
7021
+ * game with layers checks through `runCli` with `--check --layer …`.
6793
7022
  *
6794
7023
  * @param root - Game source root, the folder that holds the features.
6795
7024
  * @param out - The generated directory the modules live in.
@@ -6808,8 +7037,8 @@ async function checkStrings(root, out) {
6808
7037
  /**
6809
7038
  * @file i18n plugin, build time — the exchange with translators. `exportStrings` writes one JSON
6810
7039
  * 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`.
7040
+ * the translated files against the game, writes them into the string files of the features and
7041
+ * layers that own the keys, and compiles. Node and Bun only, reached through `src/assets.ts`.
6813
7042
  */
6814
7043
  /** The locale translators read from when none is named. */
6815
7044
  const DEFAULT_SOURCE = "en";
@@ -6858,11 +7087,13 @@ function importFailure(problems) {
6858
7087
  *
6859
7088
  * @param root - Game source root, the folder that holds the features.
6860
7089
  * @param features - Name of the folder that holds the features.
7090
+ * @param layers - Folder under the root to the name its keys take.
6861
7091
  * @returns What the walk collected.
6862
- * @throws {Error} One error that lists every problem the compile found.
7092
+ * @throws {Error} One error that lists every problem the compile found, or the layer error when a
7093
+ * layer folder is not one folder name.
6863
7094
  */
6864
- async function readGame(root, features) {
6865
- const walk = await walkStrings(root, features);
7095
+ async function readGame(root, features, layers) {
7096
+ const walk = await walkStrings(root, features, layers);
6866
7097
  if (walk.problems.length > 0) throw compileFailure(walk.problems);
6867
7098
  return walk;
6868
7099
  }
@@ -6890,16 +7121,17 @@ function exchangeEntry(walk, key, locale, source) {
6890
7121
  };
6891
7122
  }
6892
7123
  /**
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.
7124
+ * Writes the files translators work in: one `<dir>/<locale>.json` per locale the features and
7125
+ * layers bring, the source locale included, so a new language starts as a copy of `en.json`. Every
7126
+ * key of the game is in every file, sorted: `{ text, source, note? }`, with `text: ""` where the
7127
+ * locale has no message yet. The game tree is not touched, and a second run writes the same bytes.
6897
7128
  *
6898
7129
  * @param root - Game source root, the folder that holds the features.
6899
7130
  * @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.
7131
+ * @param options - The source locale, the name of the features folder and the layers.
6901
7132
  * @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.
7133
+ * @throws {Error} When the game's strings do not compile, a layer folder is not one folder name,
7134
+ * or no feature brings the source locale.
6903
7135
  * @example
6904
7136
  * ```ts
6905
7137
  * // Before a release, the game hands its strings to the translators.
@@ -6909,7 +7141,7 @@ function exchangeEntry(walk, key, locale, source) {
6909
7141
  */
6910
7142
  async function exportStrings(root, dir, options = {}) {
6911
7143
  const source = options.source ?? DEFAULT_SOURCE;
6912
- const walk = await readGame(root, options.features ?? DEFAULT_FEATURES);
7144
+ const walk = await readGame(root, options.features ?? DEFAULT_FEATURES, options.layers ?? {});
6913
7145
  const locales = [...walk.byLocale.keys()].toSorted();
6914
7146
  if (!locales.includes(source)) throw sourceMissing(source);
6915
7147
  const keys = [...walk.owner.keys()].toSorted();
@@ -7007,7 +7239,7 @@ function reviewEntry(walk, review, exchange, key) {
7007
7239
  try {
7008
7240
  compileMessage(text);
7009
7241
  review.changes.push({
7010
- feature: owner.feature,
7242
+ folder: owner.folder,
7011
7243
  locale: exchange.locale,
7012
7244
  key,
7013
7245
  text
@@ -7076,19 +7308,20 @@ async function rewriteStrings(file, texts) {
7076
7308
  await writeFile(file, jsonText(table));
7077
7309
  }
7078
7310
  /**
7079
- * Groups the changes of an import by the string file they land in.
7311
+ * Groups the changes of an import by the string file they land in: the `strings/` of the feature
7312
+ * or layer folder that owns each key.
7080
7313
  *
7081
7314
  * @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.
7315
+ * @param root - Game source root.
7316
+ * @returns The path and the new texts of each file, by its POSIX path relative to the root.
7084
7317
  */
7085
- function groupByFile(changes, folder) {
7318
+ function groupByFile(changes, root) {
7086
7319
  const files = /* @__PURE__ */ new Map();
7087
7320
  for (const change of changes) {
7088
7321
  const name = `${change.locale}${JSON_EXTENSION}`;
7089
- const relative = `features/${change.feature}/strings/${name}`;
7322
+ const relative = `${change.folder}/strings/${name}`;
7090
7323
  const group = files.get(relative) ?? {
7091
- file: path.join(folder, change.feature, "strings", name),
7324
+ file: path.join(root, ...change.folder.split("/"), "strings", name),
7092
7325
  texts: /* @__PURE__ */ new Map()
7093
7326
  };
7094
7327
  group.texts.set(change.key, change.text);
@@ -7098,32 +7331,39 @@ function groupByFile(changes, folder) {
7098
7331
  }
7099
7332
  /**
7100
7333
  * 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.
7334
+ * against the game first, then each new text is written into the string file of the feature or
7335
+ * layer that owns its key, and the strings are compiled into `out` as a dev run does. An empty text
7336
+ * and a text equal to the current message are skipped. With any problem nothing is written.
7104
7337
  *
7105
7338
  * @param root - Game source root, the folder that holds the features.
7106
7339
  * @param dir - The folder the translators handed back.
7107
- * @param options - The features folder, where the compile writes, and whether it writes `en-XA`.
7340
+ * @param options - The features folder, the layers, where the compile writes, and whether it
7341
+ * writes `en-XA`.
7108
7342
  * @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).
7343
+ * @throws {Error} One error that lists every problem of the exchange files, the layer error
7344
+ * before anything is read when a layer folder is not one folder name, or the compile's own error
7345
+ * after the files are written (a parameter kind that differs across locales).
7111
7346
  * @example
7112
7347
  * ```ts
7113
7348
  * // The translators handed back translations/ru.json with two hud texts filled in.
7114
7349
  * await importStrings("src", "translations", { out: "src/generated" });
7115
7350
  * // { locales: ["ru"], keys: 2, files: ["features/hud/strings/ru.json"] }
7351
+ * // A v15 game: the translated ui.ok lands in the layer's own file.
7352
+ * await importStrings("src", "translations", { out: "src/generated", layers: { shared: "ui" } });
7353
+ * // { locales: ["ru"], keys: 1, files: ["shared/strings/ru.json"] }
7116
7354
  * ```
7117
7355
  */
7118
7356
  async function importStrings(root, dir, options = {}) {
7119
7357
  const features = options.features ?? DEFAULT_FEATURES;
7120
- const review = await reviewImport(await readGame(root, features), dir);
7358
+ const layers = options.layers ?? {};
7359
+ const review = await reviewImport(await readGame(root, features, layers), dir);
7121
7360
  if (review.problems.length > 0) throw importFailure(review.problems);
7122
- const byFile = groupByFile(review.changes, path.join(root, features));
7361
+ const byFile = groupByFile(review.changes, root);
7123
7362
  for (const group of byFile.values()) await rewriteStrings(group.file, group.texts);
7124
7363
  await compileStrings(root, options.out ?? path.join(root, "generated"), {
7125
7364
  features,
7126
- pseudo: options.pseudo === true
7365
+ pseudo: options.pseudo === true,
7366
+ layers
7127
7367
  });
7128
7368
  return {
7129
7369
  locales: review.locales.toSorted(),