@stardeck-customer-apps/compose 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -81,6 +81,14 @@ a silent fallback. It holds deployment facts: gitignore it (`apps/web/.gitignore
81
81
  missing or wrong makes compose refuse rather than clobber your edit.
82
82
  - Change `module.json` (or the Module's own source under `routes/`,
83
83
  `contributions.ts`, `i18n/`, …) and re-run compose.
84
+ - To reword a Module string, don't edit the Module: put the same key in
85
+ `apps/web/src/lib/i18n/overrides/<locale>.json` (app-owned, composed last).
86
+ It may only replace keys a Module or `lib/i18n/substrate/` defines; an
87
+ override of a missing key fails compose and names the key. An override file
88
+ for a locale the Modules don't ship (e.g. `ja`) translates their keys for that
89
+ locale; the locale also needs a `lib/i18n/substrate/<locale>.json`. New keys
90
+ go in `lib/i18n/substrate/<locale>.json`, never in an override. Needs compose
91
+ 0.6.0 or later.
84
92
  - `@stardeck-customer-apps/compose/runtime` exports `isModuleEnabledByList`,
85
93
  the single enablement rule, and imports nothing — it is safe in client
86
94
  components.
package/dist/cli.js CHANGED
@@ -4267,6 +4267,40 @@ function cloneTree(value) {
4267
4267
  }
4268
4268
  return clone;
4269
4269
  }
4270
+ function collectLeafPaths(value, path3, out) {
4271
+ if (typeof value === "string") {
4272
+ out.push(path3);
4273
+ return;
4274
+ }
4275
+ assertMessageTree(value, path3);
4276
+ for (const [key, child] of Object.entries(value)) collectLeafPaths(child, `${path3}.${key}`, out);
4277
+ }
4278
+ function overrideInto(target, source, owner, path3, missing) {
4279
+ for (const [key, value] of Object.entries(source)) {
4280
+ const nextPath = path3 ? `${path3}.${key}` : key;
4281
+ if (!Object.prototype.hasOwnProperty.call(target, key)) {
4282
+ collectLeafPaths(value, nextPath, missing);
4283
+ continue;
4284
+ }
4285
+ const existing = target[key];
4286
+ if (typeof value === "string") {
4287
+ if (typeof existing !== "string") {
4288
+ throw new Error(
4289
+ `[ModuleRail] composition artifacts: i18n merge conflict at ${nextPath}: ${owner} has string leaf but another owner has an object`
4290
+ );
4291
+ }
4292
+ target[key] = value;
4293
+ continue;
4294
+ }
4295
+ assertMessageTree(value, nextPath);
4296
+ if (typeof existing === "string") {
4297
+ throw new Error(
4298
+ `[ModuleRail] composition artifacts: i18n merge conflict at ${nextPath}: ${owner} has object but another owner has a string leaf`
4299
+ );
4300
+ }
4301
+ overrideInto(existing, value, owner, nextPath, missing);
4302
+ }
4303
+ }
4270
4304
  function composeLocaleMessagesForPlan(layers) {
4271
4305
  const result = createMessageTree();
4272
4306
  const leafOwners = /* @__PURE__ */ new Map();
@@ -4318,7 +4352,17 @@ function composeLocaleMessagesForPlan(layers) {
4318
4352
  }
4319
4353
  for (const layer of layers) {
4320
4354
  assertMessageTree(layer.messages, layer.owner);
4321
- mergeInto(result, cloneTree(layer.messages), layer.owner, "");
4355
+ if (!layer.override) {
4356
+ mergeInto(result, cloneTree(layer.messages), layer.owner, "");
4357
+ continue;
4358
+ }
4359
+ const missing = [];
4360
+ overrideInto(result, cloneTree(layer.messages), layer.owner, "", missing);
4361
+ if (missing.length > 0) {
4362
+ throw new Error(
4363
+ `[ModuleRail] composition artifacts: i18n override in ${layer.owner} replaces keys that no Module or substrate defines: ${missing.join(", ")}`
4364
+ );
4365
+ }
4322
4366
  }
4323
4367
  return result;
4324
4368
  }
@@ -4497,6 +4541,7 @@ function emitModuleI18nGenTs(input) {
4497
4541
  );
4498
4542
  }
4499
4543
  const targetLocales = substrate.map((l) => l.locale);
4544
+ const overrideByLocale = indexOverrideLocales(input.overrideLocales ?? [], targetLocales);
4500
4545
  const installed = new Set(input.registry.modules.map((m) => m.name));
4501
4546
  const allLayers = input.moduleLayers ? [...input.moduleLayers] : deriveModuleLocaleLayers(input.registry, input.facts);
4502
4547
  const layers = allLayers.filter((layer) => installed.has(layer.owner.split("/")[0]));
@@ -4517,19 +4562,38 @@ function emitModuleI18nGenTs(input) {
4517
4562
  const messages = layer.localeMessages[locale] ?? (locale === "en" ? layer.enMessages : layer.enMessages);
4518
4563
  composeLayers.push({ owner: layer.sourceLabel, messages });
4519
4564
  }
4565
+ const override = overrideByLocale.get(locale);
4566
+ if (override) {
4567
+ composeLayers.push({
4568
+ owner: overrideOwner(locale),
4569
+ messages: override.messages,
4570
+ override: true
4571
+ });
4572
+ }
4520
4573
  composeLocaleMessagesForPlan(composeLayers);
4521
4574
  }
4522
4575
  const composeImport = 'import { composeLocaleMessages } from "@/lib/i18n/compose-messages";';
4523
4576
  const moduleJsonImports = [];
4524
4577
  const relativeSubstrateImports = [];
4525
4578
  const substrateIdentByLocale = {};
4579
+ const overrideIdentByLocale = {};
4526
4580
  targetLocales.forEach((locale, localeIdx) => {
4527
4581
  const ident = `i18nSubstrateLoc${localeIdx}`;
4528
4582
  relativeSubstrateImports.push(
4529
4583
  `import ${ident} from ${JSON.stringify(`./lib/i18n/substrate/${locale}.json`)};`
4530
4584
  );
4531
4585
  substrateIdentByLocale[locale] = ident;
4586
+ if (overrideByLocale.has(locale)) {
4587
+ const overrideIdent = `i18nOverrideLoc${localeIdx}`;
4588
+ relativeSubstrateImports.push(
4589
+ `import ${overrideIdent} from ${JSON.stringify(`./${overrideOwner(locale)}`)};`
4590
+ );
4591
+ overrideIdentByLocale[locale] = overrideIdent;
4592
+ }
4532
4593
  });
4594
+ relativeSubstrateImports.sort(
4595
+ (a, b) => compareImportSpecifiers(a.match(/ from (.+);$/)[1], b.match(/ from (.+);$/)[1])
4596
+ );
4533
4597
  const layerIdents = [];
4534
4598
  layers.forEach((layer, layerIdx) => {
4535
4599
  const localeIdents = {};
@@ -4563,15 +4627,29 @@ function emitModuleI18nGenTs(input) {
4563
4627
  (layer) => ` { owner: ${JSON.stringify(layer.owner)}, messages: ${layer.localeIdents[locale]} },`
4564
4628
  )
4565
4629
  ];
4630
+ const overrideIdent = overrideIdentByLocale[locale];
4631
+ const messageLines = overrideIdent ? [
4632
+ " messages: composeLocaleMessages([",
4633
+ " ...[",
4634
+ ...layerLines,
4635
+ " ].map((layer) => ({",
4636
+ " ...layer,",
4637
+ ' disabled: layer.owner !== "substrate" && !layer.owner.split("/").every(enabled),',
4638
+ " })),",
4639
+ ` { owner: ${JSON.stringify(overrideOwner(locale))}, messages: ${overrideIdent}, override: true },`,
4640
+ " ]),"
4641
+ ] : [
4642
+ " messages: composeLocaleMessages([",
4643
+ ...layerLines,
4644
+ " ].filter((layer) =>",
4645
+ ' layer.owner === "substrate" || layer.owner.split("/").every(enabled)',
4646
+ " )),"
4647
+ ];
4566
4648
  bundleBlocks.push(
4567
4649
  [
4568
4650
  ` ${JSON.stringify(locale)}: {`,
4569
4651
  ` locale: ${JSON.stringify(locale)},`,
4570
- " messages: composeLocaleMessages([",
4571
- ...layerLines,
4572
- " ].filter((layer) =>",
4573
- ' layer.owner === "substrate" || layer.owner.split("/").every(enabled)',
4574
- " )),",
4652
+ ...messageLines,
4575
4653
  " }"
4576
4654
  ].join("\n")
4577
4655
  );
@@ -4616,6 +4694,26 @@ function emitModuleI18nGenTs(input) {
4616
4694
  ""
4617
4695
  ].join("\n");
4618
4696
  }
4697
+ function indexOverrideLocales(overrideLocales, targetLocales) {
4698
+ const byLocale = /* @__PURE__ */ new Map();
4699
+ for (const layer of overrideLocales) {
4700
+ if (!isValidLocaleBasename(layer.locale)) {
4701
+ throw new Error(
4702
+ `[ModuleRail] composition artifacts: unsafe override locale basename "${layer.locale}"`
4703
+ );
4704
+ }
4705
+ if (!targetLocales.includes(layer.locale)) {
4706
+ throw new Error(
4707
+ `[ModuleRail] composition artifacts: ${overrideOwner(layer.locale)} has no matching lib/i18n/substrate/${layer.locale}.json; add that substrate file to make ${layer.locale} an app locale`
4708
+ );
4709
+ }
4710
+ byLocale.set(layer.locale, layer);
4711
+ }
4712
+ return byLocale;
4713
+ }
4714
+ function overrideOwner(locale) {
4715
+ return `lib/i18n/overrides/${locale}.json`;
4716
+ }
4619
4717
  function deriveModuleLocaleLayers(registry, facts) {
4620
4718
  const layers = [];
4621
4719
  const installed = new Set(registry.modules.map((m) => m.name));
@@ -5494,7 +5592,8 @@ function planCompositionArtifacts(input) {
5494
5592
  registry: input.registry,
5495
5593
  facts: compositionEntryFacts,
5496
5594
  substrateLocales,
5497
- moduleLayers: input.moduleLocaleLayers
5595
+ moduleLayers: input.moduleLocaleLayers,
5596
+ overrideLocales: input.overrideLocales
5498
5597
  });
5499
5598
  initContent = emitModuleInitServerGenTs({
5500
5599
  registry: input.registry,
@@ -6839,7 +6938,8 @@ async function collectCompositionEntryFacts(context, registry) {
6839
6938
  `[ModuleRail] runId=${context.runId} collecting composition entry facts modules=${registry.modules.length}`
6840
6939
  );
6841
6940
  const substrateDir = `${APP_PACKAGE_DIR}/src/lib/i18n/substrate`;
6842
- const localeDirs = [substrateDir];
6941
+ const overridesDir = `${APP_PACKAGE_DIR}/src/lib/i18n/overrides`;
6942
+ const localeDirs = [substrateDir, overridesDir];
6843
6943
  const conventionalPaths = [];
6844
6944
  for (const mod of registry.modules) {
6845
6945
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
@@ -6890,6 +6990,12 @@ async function collectCompositionEntryFacts(context, registry) {
6890
6990
  messages: await readJsonObjectFile(localePath, files)
6891
6991
  });
6892
6992
  }
6993
+ const overrideLocales = [];
6994
+ for (const locale of localesByDir.get(overridesDir) ?? []) {
6995
+ const localePath = `${overridesDir}/${locale}.json`;
6996
+ assertRealLocaleJsonFilePresence(localePath, localePresences.get(localePath) ?? "unreadable");
6997
+ overrideLocales.push({ locale, messages: await readJsonObjectFile(localePath, files) });
6998
+ }
6893
6999
  const facts = [];
6894
7000
  const moduleLocaleLayers = [];
6895
7001
  for (const mod of registry.modules) {
@@ -7016,9 +7122,9 @@ async function collectCompositionEntryFacts(context, registry) {
7016
7122
  facts.sort((a, b) => a.moduleName.localeCompare(b.moduleName));
7017
7123
  moduleLocaleLayers.sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel));
7018
7124
  console.log(
7019
- `[ModuleRail] runId=${context.runId} composition entry facts modules=${facts.length} substrateLocales=${substrateLocales.length} moduleLocaleLayers=${moduleLocaleLayers.length}`
7125
+ `[ModuleRail] runId=${context.runId} composition entry facts modules=${facts.length} substrateLocales=${substrateLocales.length} overrideLocales=${overrideLocales.map((layer) => layer.locale).join(",") || "none"} moduleLocaleLayers=${moduleLocaleLayers.length}`
7020
7126
  );
7021
- return { facts, substrateLocales, moduleLocaleLayers };
7127
+ return { facts, substrateLocales, overrideLocales, moduleLocaleLayers };
7022
7128
  }
7023
7129
  function selectComposedModules(presentNames, manifestsByName, enabledRaw) {
7024
7130
  if (enabledRaw) {
@@ -7158,6 +7264,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7158
7264
  const {
7159
7265
  facts: compositionEntryFacts,
7160
7266
  substrateLocales,
7267
+ overrideLocales,
7161
7268
  moduleLocaleLayers
7162
7269
  } = await collectCompositionEntryFacts(context, registry);
7163
7270
  const candidatePaths = /* @__PURE__ */ new Set([
@@ -7180,6 +7287,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7180
7287
  existingAppRoutes,
7181
7288
  compositionEntryFacts,
7182
7289
  substrateLocales,
7290
+ overrideLocales,
7183
7291
  moduleLocaleLayers,
7184
7292
  moduleDataStoreBindings
7185
7293
  });
@@ -7248,6 +7356,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7248
7356
  existingAppRoutes,
7249
7357
  compositionEntryFacts,
7250
7358
  substrateLocales,
7359
+ overrideLocales,
7251
7360
  moduleLocaleLayers,
7252
7361
  moduleDataStoreBindings
7253
7362
  });
package/dist/index.js CHANGED
@@ -4276,6 +4276,40 @@ function cloneTree(value) {
4276
4276
  }
4277
4277
  return clone;
4278
4278
  }
4279
+ function collectLeafPaths(value, path3, out) {
4280
+ if (typeof value === "string") {
4281
+ out.push(path3);
4282
+ return;
4283
+ }
4284
+ assertMessageTree(value, path3);
4285
+ for (const [key, child] of Object.entries(value)) collectLeafPaths(child, `${path3}.${key}`, out);
4286
+ }
4287
+ function overrideInto(target, source, owner, path3, missing) {
4288
+ for (const [key, value] of Object.entries(source)) {
4289
+ const nextPath = path3 ? `${path3}.${key}` : key;
4290
+ if (!Object.prototype.hasOwnProperty.call(target, key)) {
4291
+ collectLeafPaths(value, nextPath, missing);
4292
+ continue;
4293
+ }
4294
+ const existing = target[key];
4295
+ if (typeof value === "string") {
4296
+ if (typeof existing !== "string") {
4297
+ throw new Error(
4298
+ `[ModuleRail] composition artifacts: i18n merge conflict at ${nextPath}: ${owner} has string leaf but another owner has an object`
4299
+ );
4300
+ }
4301
+ target[key] = value;
4302
+ continue;
4303
+ }
4304
+ assertMessageTree(value, nextPath);
4305
+ if (typeof existing === "string") {
4306
+ throw new Error(
4307
+ `[ModuleRail] composition artifacts: i18n merge conflict at ${nextPath}: ${owner} has object but another owner has a string leaf`
4308
+ );
4309
+ }
4310
+ overrideInto(existing, value, owner, nextPath, missing);
4311
+ }
4312
+ }
4279
4313
  function composeLocaleMessagesForPlan(layers) {
4280
4314
  const result = createMessageTree();
4281
4315
  const leafOwners = /* @__PURE__ */ new Map();
@@ -4327,7 +4361,17 @@ function composeLocaleMessagesForPlan(layers) {
4327
4361
  }
4328
4362
  for (const layer of layers) {
4329
4363
  assertMessageTree(layer.messages, layer.owner);
4330
- mergeInto(result, cloneTree(layer.messages), layer.owner, "");
4364
+ if (!layer.override) {
4365
+ mergeInto(result, cloneTree(layer.messages), layer.owner, "");
4366
+ continue;
4367
+ }
4368
+ const missing = [];
4369
+ overrideInto(result, cloneTree(layer.messages), layer.owner, "", missing);
4370
+ if (missing.length > 0) {
4371
+ throw new Error(
4372
+ `[ModuleRail] composition artifacts: i18n override in ${layer.owner} replaces keys that no Module or substrate defines: ${missing.join(", ")}`
4373
+ );
4374
+ }
4331
4375
  }
4332
4376
  return result;
4333
4377
  }
@@ -4506,6 +4550,7 @@ function emitModuleI18nGenTs(input) {
4506
4550
  );
4507
4551
  }
4508
4552
  const targetLocales = substrate.map((l) => l.locale);
4553
+ const overrideByLocale = indexOverrideLocales(input.overrideLocales ?? [], targetLocales);
4509
4554
  const installed = new Set(input.registry.modules.map((m) => m.name));
4510
4555
  const allLayers = input.moduleLayers ? [...input.moduleLayers] : deriveModuleLocaleLayers(input.registry, input.facts);
4511
4556
  const layers = allLayers.filter((layer) => installed.has(layer.owner.split("/")[0]));
@@ -4526,19 +4571,38 @@ function emitModuleI18nGenTs(input) {
4526
4571
  const messages = layer.localeMessages[locale] ?? (locale === "en" ? layer.enMessages : layer.enMessages);
4527
4572
  composeLayers.push({ owner: layer.sourceLabel, messages });
4528
4573
  }
4574
+ const override = overrideByLocale.get(locale);
4575
+ if (override) {
4576
+ composeLayers.push({
4577
+ owner: overrideOwner(locale),
4578
+ messages: override.messages,
4579
+ override: true
4580
+ });
4581
+ }
4529
4582
  composeLocaleMessagesForPlan(composeLayers);
4530
4583
  }
4531
4584
  const composeImport = 'import { composeLocaleMessages } from "@/lib/i18n/compose-messages";';
4532
4585
  const moduleJsonImports = [];
4533
4586
  const relativeSubstrateImports = [];
4534
4587
  const substrateIdentByLocale = {};
4588
+ const overrideIdentByLocale = {};
4535
4589
  targetLocales.forEach((locale, localeIdx) => {
4536
4590
  const ident = `i18nSubstrateLoc${localeIdx}`;
4537
4591
  relativeSubstrateImports.push(
4538
4592
  `import ${ident} from ${JSON.stringify(`./lib/i18n/substrate/${locale}.json`)};`
4539
4593
  );
4540
4594
  substrateIdentByLocale[locale] = ident;
4595
+ if (overrideByLocale.has(locale)) {
4596
+ const overrideIdent = `i18nOverrideLoc${localeIdx}`;
4597
+ relativeSubstrateImports.push(
4598
+ `import ${overrideIdent} from ${JSON.stringify(`./${overrideOwner(locale)}`)};`
4599
+ );
4600
+ overrideIdentByLocale[locale] = overrideIdent;
4601
+ }
4541
4602
  });
4603
+ relativeSubstrateImports.sort(
4604
+ (a, b) => compareImportSpecifiers(a.match(/ from (.+);$/)[1], b.match(/ from (.+);$/)[1])
4605
+ );
4542
4606
  const layerIdents = [];
4543
4607
  layers.forEach((layer, layerIdx) => {
4544
4608
  const localeIdents = {};
@@ -4572,15 +4636,29 @@ function emitModuleI18nGenTs(input) {
4572
4636
  (layer) => ` { owner: ${JSON.stringify(layer.owner)}, messages: ${layer.localeIdents[locale]} },`
4573
4637
  )
4574
4638
  ];
4639
+ const overrideIdent = overrideIdentByLocale[locale];
4640
+ const messageLines = overrideIdent ? [
4641
+ " messages: composeLocaleMessages([",
4642
+ " ...[",
4643
+ ...layerLines,
4644
+ " ].map((layer) => ({",
4645
+ " ...layer,",
4646
+ ' disabled: layer.owner !== "substrate" && !layer.owner.split("/").every(enabled),',
4647
+ " })),",
4648
+ ` { owner: ${JSON.stringify(overrideOwner(locale))}, messages: ${overrideIdent}, override: true },`,
4649
+ " ]),"
4650
+ ] : [
4651
+ " messages: composeLocaleMessages([",
4652
+ ...layerLines,
4653
+ " ].filter((layer) =>",
4654
+ ' layer.owner === "substrate" || layer.owner.split("/").every(enabled)',
4655
+ " )),"
4656
+ ];
4575
4657
  bundleBlocks.push(
4576
4658
  [
4577
4659
  ` ${JSON.stringify(locale)}: {`,
4578
4660
  ` locale: ${JSON.stringify(locale)},`,
4579
- " messages: composeLocaleMessages([",
4580
- ...layerLines,
4581
- " ].filter((layer) =>",
4582
- ' layer.owner === "substrate" || layer.owner.split("/").every(enabled)',
4583
- " )),",
4661
+ ...messageLines,
4584
4662
  " }"
4585
4663
  ].join("\n")
4586
4664
  );
@@ -4625,6 +4703,26 @@ function emitModuleI18nGenTs(input) {
4625
4703
  ""
4626
4704
  ].join("\n");
4627
4705
  }
4706
+ function indexOverrideLocales(overrideLocales, targetLocales) {
4707
+ const byLocale = /* @__PURE__ */ new Map();
4708
+ for (const layer of overrideLocales) {
4709
+ if (!isValidLocaleBasename(layer.locale)) {
4710
+ throw new Error(
4711
+ `[ModuleRail] composition artifacts: unsafe override locale basename "${layer.locale}"`
4712
+ );
4713
+ }
4714
+ if (!targetLocales.includes(layer.locale)) {
4715
+ throw new Error(
4716
+ `[ModuleRail] composition artifacts: ${overrideOwner(layer.locale)} has no matching lib/i18n/substrate/${layer.locale}.json; add that substrate file to make ${layer.locale} an app locale`
4717
+ );
4718
+ }
4719
+ byLocale.set(layer.locale, layer);
4720
+ }
4721
+ return byLocale;
4722
+ }
4723
+ function overrideOwner(locale) {
4724
+ return `lib/i18n/overrides/${locale}.json`;
4725
+ }
4628
4726
  function deriveModuleLocaleLayers(registry, facts) {
4629
4727
  const layers = [];
4630
4728
  const installed = new Set(registry.modules.map((m) => m.name));
@@ -5503,7 +5601,8 @@ function planCompositionArtifacts(input) {
5503
5601
  registry: input.registry,
5504
5602
  facts: compositionEntryFacts,
5505
5603
  substrateLocales,
5506
- moduleLayers: input.moduleLocaleLayers
5604
+ moduleLayers: input.moduleLocaleLayers,
5605
+ overrideLocales: input.overrideLocales
5507
5606
  });
5508
5607
  initContent = emitModuleInitServerGenTs({
5509
5608
  registry: input.registry,
@@ -6848,7 +6947,8 @@ async function collectCompositionEntryFacts(context, registry) {
6848
6947
  `[ModuleRail] runId=${context.runId} collecting composition entry facts modules=${registry.modules.length}`
6849
6948
  );
6850
6949
  const substrateDir = `${APP_PACKAGE_DIR}/src/lib/i18n/substrate`;
6851
- const localeDirs = [substrateDir];
6950
+ const overridesDir = `${APP_PACKAGE_DIR}/src/lib/i18n/overrides`;
6951
+ const localeDirs = [substrateDir, overridesDir];
6852
6952
  const conventionalPaths = [];
6853
6953
  for (const mod of registry.modules) {
6854
6954
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
@@ -6899,6 +6999,12 @@ async function collectCompositionEntryFacts(context, registry) {
6899
6999
  messages: await readJsonObjectFile(localePath, files)
6900
7000
  });
6901
7001
  }
7002
+ const overrideLocales = [];
7003
+ for (const locale of localesByDir.get(overridesDir) ?? []) {
7004
+ const localePath = `${overridesDir}/${locale}.json`;
7005
+ assertRealLocaleJsonFilePresence(localePath, localePresences.get(localePath) ?? "unreadable");
7006
+ overrideLocales.push({ locale, messages: await readJsonObjectFile(localePath, files) });
7007
+ }
6902
7008
  const facts = [];
6903
7009
  const moduleLocaleLayers = [];
6904
7010
  for (const mod of registry.modules) {
@@ -7025,9 +7131,9 @@ async function collectCompositionEntryFacts(context, registry) {
7025
7131
  facts.sort((a, b) => a.moduleName.localeCompare(b.moduleName));
7026
7132
  moduleLocaleLayers.sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel));
7027
7133
  console.log(
7028
- `[ModuleRail] runId=${context.runId} composition entry facts modules=${facts.length} substrateLocales=${substrateLocales.length} moduleLocaleLayers=${moduleLocaleLayers.length}`
7134
+ `[ModuleRail] runId=${context.runId} composition entry facts modules=${facts.length} substrateLocales=${substrateLocales.length} overrideLocales=${overrideLocales.map((layer) => layer.locale).join(",") || "none"} moduleLocaleLayers=${moduleLocaleLayers.length}`
7029
7135
  );
7030
- return { facts, substrateLocales, moduleLocaleLayers };
7136
+ return { facts, substrateLocales, overrideLocales, moduleLocaleLayers };
7031
7137
  }
7032
7138
  function selectComposedModules(presentNames, manifestsByName, enabledRaw) {
7033
7139
  if (enabledRaw) {
@@ -7167,6 +7273,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7167
7273
  const {
7168
7274
  facts: compositionEntryFacts,
7169
7275
  substrateLocales,
7276
+ overrideLocales,
7170
7277
  moduleLocaleLayers
7171
7278
  } = await collectCompositionEntryFacts(context, registry);
7172
7279
  const candidatePaths = /* @__PURE__ */ new Set([
@@ -7189,6 +7296,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7189
7296
  existingAppRoutes,
7190
7297
  compositionEntryFacts,
7191
7298
  substrateLocales,
7299
+ overrideLocales,
7192
7300
  moduleLocaleLayers,
7193
7301
  moduleDataStoreBindings
7194
7302
  });
@@ -7257,6 +7365,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7257
7365
  existingAppRoutes,
7258
7366
  compositionEntryFacts,
7259
7367
  substrateLocales,
7368
+ overrideLocales,
7260
7369
  moduleLocaleLayers,
7261
7370
  moduleDataStoreBindings
7262
7371
  });
package/dist/index.mjs CHANGED
@@ -4261,6 +4261,40 @@ function cloneTree(value) {
4261
4261
  }
4262
4262
  return clone;
4263
4263
  }
4264
+ function collectLeafPaths(value, path3, out) {
4265
+ if (typeof value === "string") {
4266
+ out.push(path3);
4267
+ return;
4268
+ }
4269
+ assertMessageTree(value, path3);
4270
+ for (const [key, child] of Object.entries(value)) collectLeafPaths(child, `${path3}.${key}`, out);
4271
+ }
4272
+ function overrideInto(target, source, owner, path3, missing) {
4273
+ for (const [key, value] of Object.entries(source)) {
4274
+ const nextPath = path3 ? `${path3}.${key}` : key;
4275
+ if (!Object.prototype.hasOwnProperty.call(target, key)) {
4276
+ collectLeafPaths(value, nextPath, missing);
4277
+ continue;
4278
+ }
4279
+ const existing = target[key];
4280
+ if (typeof value === "string") {
4281
+ if (typeof existing !== "string") {
4282
+ throw new Error(
4283
+ `[ModuleRail] composition artifacts: i18n merge conflict at ${nextPath}: ${owner} has string leaf but another owner has an object`
4284
+ );
4285
+ }
4286
+ target[key] = value;
4287
+ continue;
4288
+ }
4289
+ assertMessageTree(value, nextPath);
4290
+ if (typeof existing === "string") {
4291
+ throw new Error(
4292
+ `[ModuleRail] composition artifacts: i18n merge conflict at ${nextPath}: ${owner} has object but another owner has a string leaf`
4293
+ );
4294
+ }
4295
+ overrideInto(existing, value, owner, nextPath, missing);
4296
+ }
4297
+ }
4264
4298
  function composeLocaleMessagesForPlan(layers) {
4265
4299
  const result = createMessageTree();
4266
4300
  const leafOwners = /* @__PURE__ */ new Map();
@@ -4312,7 +4346,17 @@ function composeLocaleMessagesForPlan(layers) {
4312
4346
  }
4313
4347
  for (const layer of layers) {
4314
4348
  assertMessageTree(layer.messages, layer.owner);
4315
- mergeInto(result, cloneTree(layer.messages), layer.owner, "");
4349
+ if (!layer.override) {
4350
+ mergeInto(result, cloneTree(layer.messages), layer.owner, "");
4351
+ continue;
4352
+ }
4353
+ const missing = [];
4354
+ overrideInto(result, cloneTree(layer.messages), layer.owner, "", missing);
4355
+ if (missing.length > 0) {
4356
+ throw new Error(
4357
+ `[ModuleRail] composition artifacts: i18n override in ${layer.owner} replaces keys that no Module or substrate defines: ${missing.join(", ")}`
4358
+ );
4359
+ }
4316
4360
  }
4317
4361
  return result;
4318
4362
  }
@@ -4491,6 +4535,7 @@ function emitModuleI18nGenTs(input) {
4491
4535
  );
4492
4536
  }
4493
4537
  const targetLocales = substrate.map((l) => l.locale);
4538
+ const overrideByLocale = indexOverrideLocales(input.overrideLocales ?? [], targetLocales);
4494
4539
  const installed = new Set(input.registry.modules.map((m) => m.name));
4495
4540
  const allLayers = input.moduleLayers ? [...input.moduleLayers] : deriveModuleLocaleLayers(input.registry, input.facts);
4496
4541
  const layers = allLayers.filter((layer) => installed.has(layer.owner.split("/")[0]));
@@ -4511,19 +4556,38 @@ function emitModuleI18nGenTs(input) {
4511
4556
  const messages = layer.localeMessages[locale] ?? (locale === "en" ? layer.enMessages : layer.enMessages);
4512
4557
  composeLayers.push({ owner: layer.sourceLabel, messages });
4513
4558
  }
4559
+ const override = overrideByLocale.get(locale);
4560
+ if (override) {
4561
+ composeLayers.push({
4562
+ owner: overrideOwner(locale),
4563
+ messages: override.messages,
4564
+ override: true
4565
+ });
4566
+ }
4514
4567
  composeLocaleMessagesForPlan(composeLayers);
4515
4568
  }
4516
4569
  const composeImport = 'import { composeLocaleMessages } from "@/lib/i18n/compose-messages";';
4517
4570
  const moduleJsonImports = [];
4518
4571
  const relativeSubstrateImports = [];
4519
4572
  const substrateIdentByLocale = {};
4573
+ const overrideIdentByLocale = {};
4520
4574
  targetLocales.forEach((locale, localeIdx) => {
4521
4575
  const ident = `i18nSubstrateLoc${localeIdx}`;
4522
4576
  relativeSubstrateImports.push(
4523
4577
  `import ${ident} from ${JSON.stringify(`./lib/i18n/substrate/${locale}.json`)};`
4524
4578
  );
4525
4579
  substrateIdentByLocale[locale] = ident;
4580
+ if (overrideByLocale.has(locale)) {
4581
+ const overrideIdent = `i18nOverrideLoc${localeIdx}`;
4582
+ relativeSubstrateImports.push(
4583
+ `import ${overrideIdent} from ${JSON.stringify(`./${overrideOwner(locale)}`)};`
4584
+ );
4585
+ overrideIdentByLocale[locale] = overrideIdent;
4586
+ }
4526
4587
  });
4588
+ relativeSubstrateImports.sort(
4589
+ (a, b) => compareImportSpecifiers(a.match(/ from (.+);$/)[1], b.match(/ from (.+);$/)[1])
4590
+ );
4527
4591
  const layerIdents = [];
4528
4592
  layers.forEach((layer, layerIdx) => {
4529
4593
  const localeIdents = {};
@@ -4557,15 +4621,29 @@ function emitModuleI18nGenTs(input) {
4557
4621
  (layer) => ` { owner: ${JSON.stringify(layer.owner)}, messages: ${layer.localeIdents[locale]} },`
4558
4622
  )
4559
4623
  ];
4624
+ const overrideIdent = overrideIdentByLocale[locale];
4625
+ const messageLines = overrideIdent ? [
4626
+ " messages: composeLocaleMessages([",
4627
+ " ...[",
4628
+ ...layerLines,
4629
+ " ].map((layer) => ({",
4630
+ " ...layer,",
4631
+ ' disabled: layer.owner !== "substrate" && !layer.owner.split("/").every(enabled),',
4632
+ " })),",
4633
+ ` { owner: ${JSON.stringify(overrideOwner(locale))}, messages: ${overrideIdent}, override: true },`,
4634
+ " ]),"
4635
+ ] : [
4636
+ " messages: composeLocaleMessages([",
4637
+ ...layerLines,
4638
+ " ].filter((layer) =>",
4639
+ ' layer.owner === "substrate" || layer.owner.split("/").every(enabled)',
4640
+ " )),"
4641
+ ];
4560
4642
  bundleBlocks.push(
4561
4643
  [
4562
4644
  ` ${JSON.stringify(locale)}: {`,
4563
4645
  ` locale: ${JSON.stringify(locale)},`,
4564
- " messages: composeLocaleMessages([",
4565
- ...layerLines,
4566
- " ].filter((layer) =>",
4567
- ' layer.owner === "substrate" || layer.owner.split("/").every(enabled)',
4568
- " )),",
4646
+ ...messageLines,
4569
4647
  " }"
4570
4648
  ].join("\n")
4571
4649
  );
@@ -4610,6 +4688,26 @@ function emitModuleI18nGenTs(input) {
4610
4688
  ""
4611
4689
  ].join("\n");
4612
4690
  }
4691
+ function indexOverrideLocales(overrideLocales, targetLocales) {
4692
+ const byLocale = /* @__PURE__ */ new Map();
4693
+ for (const layer of overrideLocales) {
4694
+ if (!isValidLocaleBasename(layer.locale)) {
4695
+ throw new Error(
4696
+ `[ModuleRail] composition artifacts: unsafe override locale basename "${layer.locale}"`
4697
+ );
4698
+ }
4699
+ if (!targetLocales.includes(layer.locale)) {
4700
+ throw new Error(
4701
+ `[ModuleRail] composition artifacts: ${overrideOwner(layer.locale)} has no matching lib/i18n/substrate/${layer.locale}.json; add that substrate file to make ${layer.locale} an app locale`
4702
+ );
4703
+ }
4704
+ byLocale.set(layer.locale, layer);
4705
+ }
4706
+ return byLocale;
4707
+ }
4708
+ function overrideOwner(locale) {
4709
+ return `lib/i18n/overrides/${locale}.json`;
4710
+ }
4613
4711
  function deriveModuleLocaleLayers(registry, facts) {
4614
4712
  const layers = [];
4615
4713
  const installed = new Set(registry.modules.map((m) => m.name));
@@ -5488,7 +5586,8 @@ function planCompositionArtifacts(input) {
5488
5586
  registry: input.registry,
5489
5587
  facts: compositionEntryFacts,
5490
5588
  substrateLocales,
5491
- moduleLayers: input.moduleLocaleLayers
5589
+ moduleLayers: input.moduleLocaleLayers,
5590
+ overrideLocales: input.overrideLocales
5492
5591
  });
5493
5592
  initContent = emitModuleInitServerGenTs({
5494
5593
  registry: input.registry,
@@ -6833,7 +6932,8 @@ async function collectCompositionEntryFacts(context, registry) {
6833
6932
  `[ModuleRail] runId=${context.runId} collecting composition entry facts modules=${registry.modules.length}`
6834
6933
  );
6835
6934
  const substrateDir = `${APP_PACKAGE_DIR}/src/lib/i18n/substrate`;
6836
- const localeDirs = [substrateDir];
6935
+ const overridesDir = `${APP_PACKAGE_DIR}/src/lib/i18n/overrides`;
6936
+ const localeDirs = [substrateDir, overridesDir];
6837
6937
  const conventionalPaths = [];
6838
6938
  for (const mod of registry.modules) {
6839
6939
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
@@ -6884,6 +6984,12 @@ async function collectCompositionEntryFacts(context, registry) {
6884
6984
  messages: await readJsonObjectFile(localePath, files)
6885
6985
  });
6886
6986
  }
6987
+ const overrideLocales = [];
6988
+ for (const locale of localesByDir.get(overridesDir) ?? []) {
6989
+ const localePath = `${overridesDir}/${locale}.json`;
6990
+ assertRealLocaleJsonFilePresence(localePath, localePresences.get(localePath) ?? "unreadable");
6991
+ overrideLocales.push({ locale, messages: await readJsonObjectFile(localePath, files) });
6992
+ }
6887
6993
  const facts = [];
6888
6994
  const moduleLocaleLayers = [];
6889
6995
  for (const mod of registry.modules) {
@@ -7010,9 +7116,9 @@ async function collectCompositionEntryFacts(context, registry) {
7010
7116
  facts.sort((a, b) => a.moduleName.localeCompare(b.moduleName));
7011
7117
  moduleLocaleLayers.sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel));
7012
7118
  console.log(
7013
- `[ModuleRail] runId=${context.runId} composition entry facts modules=${facts.length} substrateLocales=${substrateLocales.length} moduleLocaleLayers=${moduleLocaleLayers.length}`
7119
+ `[ModuleRail] runId=${context.runId} composition entry facts modules=${facts.length} substrateLocales=${substrateLocales.length} overrideLocales=${overrideLocales.map((layer) => layer.locale).join(",") || "none"} moduleLocaleLayers=${moduleLocaleLayers.length}`
7014
7120
  );
7015
- return { facts, substrateLocales, moduleLocaleLayers };
7121
+ return { facts, substrateLocales, overrideLocales, moduleLocaleLayers };
7016
7122
  }
7017
7123
  function selectComposedModules(presentNames, manifestsByName, enabledRaw) {
7018
7124
  if (enabledRaw) {
@@ -7152,6 +7258,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7152
7258
  const {
7153
7259
  facts: compositionEntryFacts,
7154
7260
  substrateLocales,
7261
+ overrideLocales,
7155
7262
  moduleLocaleLayers
7156
7263
  } = await collectCompositionEntryFacts(context, registry);
7157
7264
  const candidatePaths = /* @__PURE__ */ new Set([
@@ -7174,6 +7281,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7174
7281
  existingAppRoutes,
7175
7282
  compositionEntryFacts,
7176
7283
  substrateLocales,
7284
+ overrideLocales,
7177
7285
  moduleLocaleLayers,
7178
7286
  moduleDataStoreBindings
7179
7287
  });
@@ -7242,6 +7350,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7242
7350
  existingAppRoutes,
7243
7351
  compositionEntryFacts,
7244
7352
  substrateLocales,
7353
+ overrideLocales,
7245
7354
  moduleLocaleLayers,
7246
7355
  moduleDataStoreBindings
7247
7356
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/compose",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Regenerates a Stardeck app's Module-rail artifacts — the five src/*.gen.ts registries and every route/endpoint stub — from src/modules/*/module.json",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",