@stardeck-customer-apps/compose 0.5.0 → 0.7.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
@@ -9,7 +9,7 @@ Every Module in `apps/web/src/modules/*` declares its mounts, endpoints,
9
9
  enhancements, contributions, i18n layers and data stores in `module.json`.
10
10
  Compose turns that declaration into the code Next.js actually builds:
11
11
 
12
- - `apps/web/src/modules.gen.ts` — the Module registry and `isModuleEnabled`
12
+ - `apps/web/src/modules.gen.ts` — the Module registry, `isModuleEnabled` and `isMountHeadless`
13
13
  - `apps/web/src/module-contributions.gen.ts`
14
14
  - `apps/web/src/module-i18n.gen.ts`
15
15
  - `apps/web/src/module-init.server.gen.ts`
@@ -81,6 +81,26 @@ 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.
92
+ - **Headless mounts.** `NEXT_PUBLIC_STARDECK_HEADLESS_MOUNTS` (set by the
93
+ platform from the Modules tab) lists mount keys `<module>:<group>/<segment>`
94
+ from `module.json` `mounts`, comma-joined, e.g. `pos:(register)/register`.
95
+ The generated page for a listed mount returns not-found, and `admin.nav`
96
+ entries whose `href` is that mount's path (or below it) are dropped. The rest
97
+ of the Module keeps running: its other mounts, every endpoint, `server/`,
98
+ `client/`, initializers and other contributions. Build your own screen in
99
+ app code on the Module's `server/` entry and `client/` hooks, at a different
100
+ URL: the generated page still exists, so an app page at the same URL fails
101
+ compose as a route collision.
102
+ `isMountHeadless(key)` and `isHrefHeadless(href)` are exported from
103
+ `@/modules.gen`. Needs compose 0.7.0 or later.
84
104
  - `@stardeck-customer-apps/compose/runtime` exports `isModuleEnabledByList`,
85
105
  the single enablement rule, and imports nothing — it is safe in client
86
106
  components.
package/dist/cli.js CHANGED
@@ -3891,6 +3891,7 @@ function analyzeClientSafeEntryImports(sourceText, fileName) {
3891
3891
  var APP_WEB_PREFIX = "apps/web/";
3892
3892
  var MODULE_STUB_MARKER = "@stardeck-module-rail-generated";
3893
3893
  var ENABLED_MODULES_ENV = "NEXT_PUBLIC_STARDECK_ENABLED_MODULES";
3894
+ var HEADLESS_MOUNTS_ENV = "NEXT_PUBLIC_STARDECK_HEADLESS_MOUNTS";
3894
3895
  var MODULE_CONTRIBUTIONS_GEN_PATH = `${APP_WEB_PREFIX}src/module-contributions.gen.ts`;
3895
3896
  var MODULE_DATASTORES_GEN_PATH = `${APP_WEB_PREFIX}src/module-datastores.gen.ts`;
3896
3897
  var MODULE_I18N_GEN_PATH = `${APP_WEB_PREFIX}src/module-i18n.gen.ts`;
@@ -4127,6 +4128,11 @@ function isModuleEnabledByList(raw, name, kindOf) {
4127
4128
  return raw.split(",").includes(name);
4128
4129
  }
4129
4130
 
4131
+ // ../../packages/lib/src/shared/module-mount-key.ts
4132
+ function mountKey(moduleName, mount) {
4133
+ return `${moduleName}:${mount.group}/${mount.segment}`;
4134
+ }
4135
+
4130
4136
  // ../../packages/lib/src/server/module-rail/composition-artifacts.ts
4131
4137
  var import_typescript4 = __toESM(require("typescript"), 1);
4132
4138
 
@@ -4267,6 +4273,40 @@ function cloneTree(value) {
4267
4273
  }
4268
4274
  return clone;
4269
4275
  }
4276
+ function collectLeafPaths(value, path3, out) {
4277
+ if (typeof value === "string") {
4278
+ out.push(path3);
4279
+ return;
4280
+ }
4281
+ assertMessageTree(value, path3);
4282
+ for (const [key, child] of Object.entries(value)) collectLeafPaths(child, `${path3}.${key}`, out);
4283
+ }
4284
+ function overrideInto(target, source, owner, path3, missing) {
4285
+ for (const [key, value] of Object.entries(source)) {
4286
+ const nextPath = path3 ? `${path3}.${key}` : key;
4287
+ if (!Object.prototype.hasOwnProperty.call(target, key)) {
4288
+ collectLeafPaths(value, nextPath, missing);
4289
+ continue;
4290
+ }
4291
+ const existing = target[key];
4292
+ if (typeof value === "string") {
4293
+ if (typeof existing !== "string") {
4294
+ throw new Error(
4295
+ `[ModuleRail] composition artifacts: i18n merge conflict at ${nextPath}: ${owner} has string leaf but another owner has an object`
4296
+ );
4297
+ }
4298
+ target[key] = value;
4299
+ continue;
4300
+ }
4301
+ assertMessageTree(value, nextPath);
4302
+ if (typeof existing === "string") {
4303
+ throw new Error(
4304
+ `[ModuleRail] composition artifacts: i18n merge conflict at ${nextPath}: ${owner} has object but another owner has a string leaf`
4305
+ );
4306
+ }
4307
+ overrideInto(existing, value, owner, nextPath, missing);
4308
+ }
4309
+ }
4270
4310
  function composeLocaleMessagesForPlan(layers) {
4271
4311
  const result = createMessageTree();
4272
4312
  const leafOwners = /* @__PURE__ */ new Map();
@@ -4318,7 +4358,17 @@ function composeLocaleMessagesForPlan(layers) {
4318
4358
  }
4319
4359
  for (const layer of layers) {
4320
4360
  assertMessageTree(layer.messages, layer.owner);
4321
- mergeInto(result, cloneTree(layer.messages), layer.owner, "");
4361
+ if (!layer.override) {
4362
+ mergeInto(result, cloneTree(layer.messages), layer.owner, "");
4363
+ continue;
4364
+ }
4365
+ const missing = [];
4366
+ overrideInto(result, cloneTree(layer.messages), layer.owner, "", missing);
4367
+ if (missing.length > 0) {
4368
+ throw new Error(
4369
+ `[ModuleRail] composition artifacts: i18n override in ${layer.owner} replaces keys that no Module or substrate defines: ${missing.join(", ")}`
4370
+ );
4371
+ }
4322
4372
  }
4323
4373
  return result;
4324
4374
  }
@@ -4411,7 +4461,7 @@ function emitModuleContributionsGenTs(input) {
4411
4461
  " type SurfaceSlotCatalog,",
4412
4462
  '} from "@/lib/modules/contributions";',
4413
4463
  'import { substrateSlotCatalogs } from "@/lib/modules/substrate-slots";',
4414
- 'import { generatedModuleRegistry, isModuleEnabled } from "@/modules.gen";',
4464
+ 'import { generatedModuleRegistry, isHrefHeadless, isModuleEnabled } from "@/modules.gen";',
4415
4465
  ...[...moduleImportLines].sort((a, b) => {
4416
4466
  const pathA = a.match(/from ("[^"]+")/)?.[1] ?? a;
4417
4467
  const pathB = b.match(/from ("[^"]+")/)?.[1] ?? b;
@@ -4461,11 +4511,19 @@ function emitModuleContributionsGenTs(input) {
4461
4511
  "export function resolveModuleContributions(",
4462
4512
  " registry: typeof generatedModuleRegistry = generatedModuleRegistry",
4463
4513
  "): ResolvedModuleContributions {",
4464
- " return resolveContributions({",
4514
+ " const resolved = resolveContributions({",
4465
4515
  " registry,",
4466
4516
  " catalogs: moduleSlotCatalogs,",
4467
4517
  " sources: moduleContributionSources,",
4468
4518
  " });",
4519
+ " // A nav entry into a headless mount would link to a page that is switched off.",
4520
+ ' const nav = resolved["admin.nav"];',
4521
+ " if (nav) {",
4522
+ ' resolved["admin.nav"] = nav.filter(',
4523
+ " (entry) => !isHrefHeadless((entry as { href?: unknown }).href)",
4524
+ " );",
4525
+ " }",
4526
+ " return resolved;",
4469
4527
  "}",
4470
4528
  "",
4471
4529
  "export const resolvedModuleContributions: ResolvedModuleContributions =",
@@ -4497,6 +4555,7 @@ function emitModuleI18nGenTs(input) {
4497
4555
  );
4498
4556
  }
4499
4557
  const targetLocales = substrate.map((l) => l.locale);
4558
+ const overrideByLocale = indexOverrideLocales(input.overrideLocales ?? [], targetLocales);
4500
4559
  const installed = new Set(input.registry.modules.map((m) => m.name));
4501
4560
  const allLayers = input.moduleLayers ? [...input.moduleLayers] : deriveModuleLocaleLayers(input.registry, input.facts);
4502
4561
  const layers = allLayers.filter((layer) => installed.has(layer.owner.split("/")[0]));
@@ -4517,19 +4576,38 @@ function emitModuleI18nGenTs(input) {
4517
4576
  const messages = layer.localeMessages[locale] ?? (locale === "en" ? layer.enMessages : layer.enMessages);
4518
4577
  composeLayers.push({ owner: layer.sourceLabel, messages });
4519
4578
  }
4579
+ const override = overrideByLocale.get(locale);
4580
+ if (override) {
4581
+ composeLayers.push({
4582
+ owner: overrideOwner(locale),
4583
+ messages: override.messages,
4584
+ override: true
4585
+ });
4586
+ }
4520
4587
  composeLocaleMessagesForPlan(composeLayers);
4521
4588
  }
4522
4589
  const composeImport = 'import { composeLocaleMessages } from "@/lib/i18n/compose-messages";';
4523
4590
  const moduleJsonImports = [];
4524
4591
  const relativeSubstrateImports = [];
4525
4592
  const substrateIdentByLocale = {};
4593
+ const overrideIdentByLocale = {};
4526
4594
  targetLocales.forEach((locale, localeIdx) => {
4527
4595
  const ident = `i18nSubstrateLoc${localeIdx}`;
4528
4596
  relativeSubstrateImports.push(
4529
4597
  `import ${ident} from ${JSON.stringify(`./lib/i18n/substrate/${locale}.json`)};`
4530
4598
  );
4531
4599
  substrateIdentByLocale[locale] = ident;
4600
+ if (overrideByLocale.has(locale)) {
4601
+ const overrideIdent = `i18nOverrideLoc${localeIdx}`;
4602
+ relativeSubstrateImports.push(
4603
+ `import ${overrideIdent} from ${JSON.stringify(`./${overrideOwner(locale)}`)};`
4604
+ );
4605
+ overrideIdentByLocale[locale] = overrideIdent;
4606
+ }
4532
4607
  });
4608
+ relativeSubstrateImports.sort(
4609
+ (a, b) => compareImportSpecifiers(a.match(/ from (.+);$/)[1], b.match(/ from (.+);$/)[1])
4610
+ );
4533
4611
  const layerIdents = [];
4534
4612
  layers.forEach((layer, layerIdx) => {
4535
4613
  const localeIdents = {};
@@ -4563,15 +4641,29 @@ function emitModuleI18nGenTs(input) {
4563
4641
  (layer) => ` { owner: ${JSON.stringify(layer.owner)}, messages: ${layer.localeIdents[locale]} },`
4564
4642
  )
4565
4643
  ];
4644
+ const overrideIdent = overrideIdentByLocale[locale];
4645
+ const messageLines = overrideIdent ? [
4646
+ " messages: composeLocaleMessages([",
4647
+ " ...[",
4648
+ ...layerLines,
4649
+ " ].map((layer) => ({",
4650
+ " ...layer,",
4651
+ ' disabled: layer.owner !== "substrate" && !layer.owner.split("/").every(enabled),',
4652
+ " })),",
4653
+ ` { owner: ${JSON.stringify(overrideOwner(locale))}, messages: ${overrideIdent}, override: true },`,
4654
+ " ]),"
4655
+ ] : [
4656
+ " messages: composeLocaleMessages([",
4657
+ ...layerLines,
4658
+ " ].filter((layer) =>",
4659
+ ' layer.owner === "substrate" || layer.owner.split("/").every(enabled)',
4660
+ " )),"
4661
+ ];
4566
4662
  bundleBlocks.push(
4567
4663
  [
4568
4664
  ` ${JSON.stringify(locale)}: {`,
4569
4665
  ` locale: ${JSON.stringify(locale)},`,
4570
- " messages: composeLocaleMessages([",
4571
- ...layerLines,
4572
- " ].filter((layer) =>",
4573
- ' layer.owner === "substrate" || layer.owner.split("/").every(enabled)',
4574
- " )),",
4666
+ ...messageLines,
4575
4667
  " }"
4576
4668
  ].join("\n")
4577
4669
  );
@@ -4616,6 +4708,26 @@ function emitModuleI18nGenTs(input) {
4616
4708
  ""
4617
4709
  ].join("\n");
4618
4710
  }
4711
+ function indexOverrideLocales(overrideLocales, targetLocales) {
4712
+ const byLocale = /* @__PURE__ */ new Map();
4713
+ for (const layer of overrideLocales) {
4714
+ if (!isValidLocaleBasename(layer.locale)) {
4715
+ throw new Error(
4716
+ `[ModuleRail] composition artifacts: unsafe override locale basename "${layer.locale}"`
4717
+ );
4718
+ }
4719
+ if (!targetLocales.includes(layer.locale)) {
4720
+ throw new Error(
4721
+ `[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`
4722
+ );
4723
+ }
4724
+ byLocale.set(layer.locale, layer);
4725
+ }
4726
+ return byLocale;
4727
+ }
4728
+ function overrideOwner(locale) {
4729
+ return `lib/i18n/overrides/${locale}.json`;
4730
+ }
4619
4731
  function deriveModuleLocaleLayers(registry, facts) {
4620
4732
  const layers = [];
4621
4733
  const installed = new Set(registry.modules.map((m) => m.name));
@@ -4997,6 +5109,25 @@ function emitModulesGenTs(registry) {
4997
5109
  ' if (entry && entry.kind !== "feature") return true;',
4998
5110
  ' return raw.split(",").includes(name);',
4999
5111
  "}",
5112
+ "",
5113
+ "/** Headless mount keys come from the platform at build time: that page is off, its Module stays on. */",
5114
+ "export function isMountHeadless(key: string): boolean {",
5115
+ ` const raw = process.env.${HEADLESS_MOUNTS_ENV};`,
5116
+ ' return raw ? raw.split(",").includes(key) : false;',
5117
+ "}",
5118
+ "",
5119
+ "/** True when `href` is a headless mount's URL or below it. */",
5120
+ "export function isHrefHeadless(href: unknown): boolean {",
5121
+ ' if (typeof href !== "string") return false;',
5122
+ " const target = href.split(/[?#]/)[0];",
5123
+ " return generatedModuleRegistry.modules.some((mod) =>",
5124
+ " mod.mounts.some((mount) => {",
5125
+ " if (!isMountHeadless(`${mod.name}:${mount.group}/${mount.segment}`)) return false;",
5126
+ " const path = `/${mount.segment}`;",
5127
+ " return target === path || target.startsWith(`${path}/`);",
5128
+ " })",
5129
+ " );",
5130
+ "}",
5000
5131
  ""
5001
5132
  ].join("\n");
5002
5133
  }
@@ -5004,6 +5135,22 @@ var STUB_PRINT_WIDTH = 100;
5004
5135
  function wrapAtPrintWidth(flat, broken) {
5005
5136
  return flat.length > STUB_PRINT_WIDTH ? broken : [flat];
5006
5137
  }
5138
+ function emitGuardLines(guards, body) {
5139
+ const operands = guards.map((guard) => `${guard.call}(${emitTsString(guard.arg)})`);
5140
+ const condition = ` if (${operands.join(" || ")})`;
5141
+ const brokenOperands = guards.flatMap((guard, i) => {
5142
+ const suffix = i < guards.length - 1 ? " ||" : "";
5143
+ return wrapAtPrintWidth(` ${operands[i]}${suffix}`, [
5144
+ ` ${guard.call}(`,
5145
+ ` ${emitTsString(guard.arg)}`,
5146
+ ` )${suffix}`
5147
+ ]);
5148
+ });
5149
+ return wrapAtPrintWidth(`${condition} ${body}`, [
5150
+ ...wrapAtPrintWidth(condition, [" if (", ...brokenOperands, " )"]),
5151
+ ` ${body}`
5152
+ ]);
5153
+ }
5007
5154
  function buildEndpointStubContent(input) {
5008
5155
  const sorted = [...input.methods].sort(
5009
5156
  (a, b) => METHOD_ORDER[a.method] - METHOD_ORDER[b.method] || a.pairKey.localeCompare(b.pairKey)
@@ -5011,24 +5158,15 @@ function buildEndpointStubContent(input) {
5011
5158
  const exports2 = sorted.map((m) => {
5012
5159
  const pair = emitTsString(m.pairKey);
5013
5160
  const guards = [
5014
- `!isModuleEnabled(${emitTsString(input.owner)})`,
5015
- ...input.scope.kind === "enhancement" ? [`!isModuleEnabled(${emitTsString(input.scope.peer)})`] : []
5161
+ { call: "!isModuleEnabled", arg: input.owner },
5162
+ ...input.scope.kind === "enhancement" ? [{ call: "!isModuleEnabled", arg: input.scope.peer }] : []
5016
5163
  ];
5017
- const condition = ` if (${guards.join(" || ")})`;
5018
- const notFoundBody = "return new Response(null, { status: 404 });";
5019
5164
  const message = emitTsString(`Missing endpoint handler for ${m.pairKey}`);
5020
5165
  const importPath = emitTsString(input.importModule);
5021
5166
  return [
5022
5167
  `export async function ${m.method}(request: Request, context?: unknown): Promise<Response> {`,
5023
5168
  " await initializeActiveEnhancements();",
5024
- ...wrapAtPrintWidth(`${condition} ${notFoundBody}`, [
5025
- ...wrapAtPrintWidth(condition, [
5026
- " if (",
5027
- ...guards.map((guard, i) => ` ${guard}${i < guards.length - 1 ? " ||" : ""}`),
5028
- " )"
5029
- ]),
5030
- ` ${notFoundBody}`
5031
- ]),
5169
+ ...emitGuardLines(guards, "return new Response(null, { status: 404 });"),
5032
5170
  ...wrapAtPrintWidth(` const { endpointHandlers } = await import(${importPath});`, [
5033
5171
  " const { endpointHandlers } = await import(",
5034
5172
  ` ${importPath}`,
@@ -5058,12 +5196,16 @@ function buildEndpointStubContent(input) {
5058
5196
  ].join("\n");
5059
5197
  }
5060
5198
  function buildRouteStubContent(input) {
5199
+ const guards = [
5200
+ { call: "!isModuleEnabled", arg: input.owner },
5201
+ { call: "isMountHeadless", arg: input.mountKey }
5202
+ ];
5061
5203
  const lines = [
5062
5204
  formatRouteMarker(input.owner, input.nextRelativePath),
5063
5205
  'import { notFound } from "next/navigation";',
5064
5206
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5065
5207
  "",
5066
- 'import { isModuleEnabled } from "@/modules.gen";',
5208
+ 'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5067
5209
  `import Page from ${emitTsString(input.importModule)};`
5068
5210
  ];
5069
5211
  if (input.hasGenerateMetadata) {
@@ -5071,7 +5213,7 @@ function buildRouteStubContent(input) {
5071
5213
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5072
5214
  "",
5073
5215
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5074
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) return {};`,
5216
+ ...emitGuardLines(guards, "return {};"),
5075
5217
  " return sourceGenerateMetadata(...args);",
5076
5218
  "}"
5077
5219
  );
@@ -5082,7 +5224,7 @@ function buildRouteStubContent(input) {
5082
5224
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5083
5225
  "",
5084
5226
  "export default function ModulePage(props: PageProps) {",
5085
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) notFound();`,
5227
+ ...emitGuardLines(guards, "notFound();"),
5086
5228
  " return <Page {...props} />;",
5087
5229
  "}",
5088
5230
  ""
@@ -5284,7 +5426,6 @@ function planRouteStubs(input) {
5284
5426
  }
5285
5427
  for (const entry of entries) {
5286
5428
  const nextRelativePath = entry.nextRelativePath;
5287
- analyzeRouteEntryPath(nextRelativePath);
5288
5429
  const destination = normalizeAppRouterDestinationPattern(nextRelativePath);
5289
5430
  const path3 = routeEntryToAppPageFile(nextRelativePath);
5290
5431
  const existing = routeDestinationOwners.get(destination);
@@ -5299,6 +5440,8 @@ function planRouteStubs(input) {
5299
5440
  path: path3
5300
5441
  });
5301
5442
  const importModule = routeEntryImport(regMod.name, nextRelativePath);
5443
+ const analyzed = analyzeRouteEntryPath(nextRelativePath);
5444
+ const mount = mounts.find((claim) => routeEntryCoveredByMountClaim(analyzed, claim));
5302
5445
  routeStubs.push({
5303
5446
  path: path3,
5304
5447
  owner: regMod.name,
@@ -5308,6 +5451,7 @@ function planRouteStubs(input) {
5308
5451
  owner: regMod.name,
5309
5452
  nextRelativePath,
5310
5453
  importModule,
5454
+ mountKey: mountKey(regMod.name, mount),
5311
5455
  hasGenerateMetadata: entry.hasGenerateMetadata
5312
5456
  })
5313
5457
  });
@@ -5494,7 +5638,8 @@ function planCompositionArtifacts(input) {
5494
5638
  registry: input.registry,
5495
5639
  facts: compositionEntryFacts,
5496
5640
  substrateLocales,
5497
- moduleLayers: input.moduleLocaleLayers
5641
+ moduleLayers: input.moduleLocaleLayers,
5642
+ overrideLocales: input.overrideLocales
5498
5643
  });
5499
5644
  initContent = emitModuleInitServerGenTs({
5500
5645
  registry: input.registry,
@@ -6839,7 +6984,8 @@ async function collectCompositionEntryFacts(context, registry) {
6839
6984
  `[ModuleRail] runId=${context.runId} collecting composition entry facts modules=${registry.modules.length}`
6840
6985
  );
6841
6986
  const substrateDir = `${APP_PACKAGE_DIR}/src/lib/i18n/substrate`;
6842
- const localeDirs = [substrateDir];
6987
+ const overridesDir = `${APP_PACKAGE_DIR}/src/lib/i18n/overrides`;
6988
+ const localeDirs = [substrateDir, overridesDir];
6843
6989
  const conventionalPaths = [];
6844
6990
  for (const mod of registry.modules) {
6845
6991
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
@@ -6890,6 +7036,12 @@ async function collectCompositionEntryFacts(context, registry) {
6890
7036
  messages: await readJsonObjectFile(localePath, files)
6891
7037
  });
6892
7038
  }
7039
+ const overrideLocales = [];
7040
+ for (const locale of localesByDir.get(overridesDir) ?? []) {
7041
+ const localePath = `${overridesDir}/${locale}.json`;
7042
+ assertRealLocaleJsonFilePresence(localePath, localePresences.get(localePath) ?? "unreadable");
7043
+ overrideLocales.push({ locale, messages: await readJsonObjectFile(localePath, files) });
7044
+ }
6893
7045
  const facts = [];
6894
7046
  const moduleLocaleLayers = [];
6895
7047
  for (const mod of registry.modules) {
@@ -7016,9 +7168,9 @@ async function collectCompositionEntryFacts(context, registry) {
7016
7168
  facts.sort((a, b) => a.moduleName.localeCompare(b.moduleName));
7017
7169
  moduleLocaleLayers.sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel));
7018
7170
  console.log(
7019
- `[ModuleRail] runId=${context.runId} composition entry facts modules=${facts.length} substrateLocales=${substrateLocales.length} moduleLocaleLayers=${moduleLocaleLayers.length}`
7171
+ `[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
7172
  );
7021
- return { facts, substrateLocales, moduleLocaleLayers };
7173
+ return { facts, substrateLocales, overrideLocales, moduleLocaleLayers };
7022
7174
  }
7023
7175
  function selectComposedModules(presentNames, manifestsByName, enabledRaw) {
7024
7176
  if (enabledRaw) {
@@ -7158,6 +7310,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7158
7310
  const {
7159
7311
  facts: compositionEntryFacts,
7160
7312
  substrateLocales,
7313
+ overrideLocales,
7161
7314
  moduleLocaleLayers
7162
7315
  } = await collectCompositionEntryFacts(context, registry);
7163
7316
  const candidatePaths = /* @__PURE__ */ new Set([
@@ -7180,6 +7333,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7180
7333
  existingAppRoutes,
7181
7334
  compositionEntryFacts,
7182
7335
  substrateLocales,
7336
+ overrideLocales,
7183
7337
  moduleLocaleLayers,
7184
7338
  moduleDataStoreBindings
7185
7339
  });
@@ -7248,6 +7402,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7248
7402
  existingAppRoutes,
7249
7403
  compositionEntryFacts,
7250
7404
  substrateLocales,
7405
+ overrideLocales,
7251
7406
  moduleLocaleLayers,
7252
7407
  moduleDataStoreBindings
7253
7408
  });
package/dist/index.js CHANGED
@@ -4129,6 +4129,7 @@ function isModuleEnabledByList(raw, name, kindOf) {
4129
4129
  var APP_WEB_PREFIX = "apps/web/";
4130
4130
  var MODULE_STUB_MARKER = "@stardeck-module-rail-generated";
4131
4131
  var ENABLED_MODULES_ENV = "NEXT_PUBLIC_STARDECK_ENABLED_MODULES";
4132
+ var HEADLESS_MOUNTS_ENV = "NEXT_PUBLIC_STARDECK_HEADLESS_MOUNTS";
4132
4133
  var MODULE_CONTRIBUTIONS_GEN_PATH = `${APP_WEB_PREFIX}src/module-contributions.gen.ts`;
4133
4134
  var MODULE_DATASTORES_GEN_PATH = `${APP_WEB_PREFIX}src/module-datastores.gen.ts`;
4134
4135
  var MODULE_I18N_GEN_PATH = `${APP_WEB_PREFIX}src/module-i18n.gen.ts`;
@@ -4136,6 +4137,11 @@ var MODULE_INIT_SERVER_GEN_PATH = `${APP_WEB_PREFIX}src/module-init.server.gen.t
4136
4137
  var MODULES_GEN_PATH = `${APP_WEB_PREFIX}src/modules.gen.ts`;
4137
4138
  var APP_EXTENSIONS_PATH = `${APP_WEB_PREFIX}src/lib/modules/app-extensions.ts`;
4138
4139
 
4140
+ // ../../packages/lib/src/shared/module-mount-key.ts
4141
+ function mountKey(moduleName, mount) {
4142
+ return `${moduleName}:${mount.group}/${mount.segment}`;
4143
+ }
4144
+
4139
4145
  // ../../packages/lib/src/server/module-rail/composition-artifacts.ts
4140
4146
  var import_typescript4 = __toESM(require("typescript"), 1);
4141
4147
 
@@ -4276,6 +4282,40 @@ function cloneTree(value) {
4276
4282
  }
4277
4283
  return clone;
4278
4284
  }
4285
+ function collectLeafPaths(value, path3, out) {
4286
+ if (typeof value === "string") {
4287
+ out.push(path3);
4288
+ return;
4289
+ }
4290
+ assertMessageTree(value, path3);
4291
+ for (const [key, child] of Object.entries(value)) collectLeafPaths(child, `${path3}.${key}`, out);
4292
+ }
4293
+ function overrideInto(target, source, owner, path3, missing) {
4294
+ for (const [key, value] of Object.entries(source)) {
4295
+ const nextPath = path3 ? `${path3}.${key}` : key;
4296
+ if (!Object.prototype.hasOwnProperty.call(target, key)) {
4297
+ collectLeafPaths(value, nextPath, missing);
4298
+ continue;
4299
+ }
4300
+ const existing = target[key];
4301
+ if (typeof value === "string") {
4302
+ if (typeof existing !== "string") {
4303
+ throw new Error(
4304
+ `[ModuleRail] composition artifacts: i18n merge conflict at ${nextPath}: ${owner} has string leaf but another owner has an object`
4305
+ );
4306
+ }
4307
+ target[key] = value;
4308
+ continue;
4309
+ }
4310
+ assertMessageTree(value, nextPath);
4311
+ if (typeof existing === "string") {
4312
+ throw new Error(
4313
+ `[ModuleRail] composition artifacts: i18n merge conflict at ${nextPath}: ${owner} has object but another owner has a string leaf`
4314
+ );
4315
+ }
4316
+ overrideInto(existing, value, owner, nextPath, missing);
4317
+ }
4318
+ }
4279
4319
  function composeLocaleMessagesForPlan(layers) {
4280
4320
  const result = createMessageTree();
4281
4321
  const leafOwners = /* @__PURE__ */ new Map();
@@ -4327,7 +4367,17 @@ function composeLocaleMessagesForPlan(layers) {
4327
4367
  }
4328
4368
  for (const layer of layers) {
4329
4369
  assertMessageTree(layer.messages, layer.owner);
4330
- mergeInto(result, cloneTree(layer.messages), layer.owner, "");
4370
+ if (!layer.override) {
4371
+ mergeInto(result, cloneTree(layer.messages), layer.owner, "");
4372
+ continue;
4373
+ }
4374
+ const missing = [];
4375
+ overrideInto(result, cloneTree(layer.messages), layer.owner, "", missing);
4376
+ if (missing.length > 0) {
4377
+ throw new Error(
4378
+ `[ModuleRail] composition artifacts: i18n override in ${layer.owner} replaces keys that no Module or substrate defines: ${missing.join(", ")}`
4379
+ );
4380
+ }
4331
4381
  }
4332
4382
  return result;
4333
4383
  }
@@ -4420,7 +4470,7 @@ function emitModuleContributionsGenTs(input) {
4420
4470
  " type SurfaceSlotCatalog,",
4421
4471
  '} from "@/lib/modules/contributions";',
4422
4472
  'import { substrateSlotCatalogs } from "@/lib/modules/substrate-slots";',
4423
- 'import { generatedModuleRegistry, isModuleEnabled } from "@/modules.gen";',
4473
+ 'import { generatedModuleRegistry, isHrefHeadless, isModuleEnabled } from "@/modules.gen";',
4424
4474
  ...[...moduleImportLines].sort((a, b) => {
4425
4475
  const pathA = a.match(/from ("[^"]+")/)?.[1] ?? a;
4426
4476
  const pathB = b.match(/from ("[^"]+")/)?.[1] ?? b;
@@ -4470,11 +4520,19 @@ function emitModuleContributionsGenTs(input) {
4470
4520
  "export function resolveModuleContributions(",
4471
4521
  " registry: typeof generatedModuleRegistry = generatedModuleRegistry",
4472
4522
  "): ResolvedModuleContributions {",
4473
- " return resolveContributions({",
4523
+ " const resolved = resolveContributions({",
4474
4524
  " registry,",
4475
4525
  " catalogs: moduleSlotCatalogs,",
4476
4526
  " sources: moduleContributionSources,",
4477
4527
  " });",
4528
+ " // A nav entry into a headless mount would link to a page that is switched off.",
4529
+ ' const nav = resolved["admin.nav"];',
4530
+ " if (nav) {",
4531
+ ' resolved["admin.nav"] = nav.filter(',
4532
+ " (entry) => !isHrefHeadless((entry as { href?: unknown }).href)",
4533
+ " );",
4534
+ " }",
4535
+ " return resolved;",
4478
4536
  "}",
4479
4537
  "",
4480
4538
  "export const resolvedModuleContributions: ResolvedModuleContributions =",
@@ -4506,6 +4564,7 @@ function emitModuleI18nGenTs(input) {
4506
4564
  );
4507
4565
  }
4508
4566
  const targetLocales = substrate.map((l) => l.locale);
4567
+ const overrideByLocale = indexOverrideLocales(input.overrideLocales ?? [], targetLocales);
4509
4568
  const installed = new Set(input.registry.modules.map((m) => m.name));
4510
4569
  const allLayers = input.moduleLayers ? [...input.moduleLayers] : deriveModuleLocaleLayers(input.registry, input.facts);
4511
4570
  const layers = allLayers.filter((layer) => installed.has(layer.owner.split("/")[0]));
@@ -4526,19 +4585,38 @@ function emitModuleI18nGenTs(input) {
4526
4585
  const messages = layer.localeMessages[locale] ?? (locale === "en" ? layer.enMessages : layer.enMessages);
4527
4586
  composeLayers.push({ owner: layer.sourceLabel, messages });
4528
4587
  }
4588
+ const override = overrideByLocale.get(locale);
4589
+ if (override) {
4590
+ composeLayers.push({
4591
+ owner: overrideOwner(locale),
4592
+ messages: override.messages,
4593
+ override: true
4594
+ });
4595
+ }
4529
4596
  composeLocaleMessagesForPlan(composeLayers);
4530
4597
  }
4531
4598
  const composeImport = 'import { composeLocaleMessages } from "@/lib/i18n/compose-messages";';
4532
4599
  const moduleJsonImports = [];
4533
4600
  const relativeSubstrateImports = [];
4534
4601
  const substrateIdentByLocale = {};
4602
+ const overrideIdentByLocale = {};
4535
4603
  targetLocales.forEach((locale, localeIdx) => {
4536
4604
  const ident = `i18nSubstrateLoc${localeIdx}`;
4537
4605
  relativeSubstrateImports.push(
4538
4606
  `import ${ident} from ${JSON.stringify(`./lib/i18n/substrate/${locale}.json`)};`
4539
4607
  );
4540
4608
  substrateIdentByLocale[locale] = ident;
4609
+ if (overrideByLocale.has(locale)) {
4610
+ const overrideIdent = `i18nOverrideLoc${localeIdx}`;
4611
+ relativeSubstrateImports.push(
4612
+ `import ${overrideIdent} from ${JSON.stringify(`./${overrideOwner(locale)}`)};`
4613
+ );
4614
+ overrideIdentByLocale[locale] = overrideIdent;
4615
+ }
4541
4616
  });
4617
+ relativeSubstrateImports.sort(
4618
+ (a, b) => compareImportSpecifiers(a.match(/ from (.+);$/)[1], b.match(/ from (.+);$/)[1])
4619
+ );
4542
4620
  const layerIdents = [];
4543
4621
  layers.forEach((layer, layerIdx) => {
4544
4622
  const localeIdents = {};
@@ -4572,15 +4650,29 @@ function emitModuleI18nGenTs(input) {
4572
4650
  (layer) => ` { owner: ${JSON.stringify(layer.owner)}, messages: ${layer.localeIdents[locale]} },`
4573
4651
  )
4574
4652
  ];
4653
+ const overrideIdent = overrideIdentByLocale[locale];
4654
+ const messageLines = overrideIdent ? [
4655
+ " messages: composeLocaleMessages([",
4656
+ " ...[",
4657
+ ...layerLines,
4658
+ " ].map((layer) => ({",
4659
+ " ...layer,",
4660
+ ' disabled: layer.owner !== "substrate" && !layer.owner.split("/").every(enabled),',
4661
+ " })),",
4662
+ ` { owner: ${JSON.stringify(overrideOwner(locale))}, messages: ${overrideIdent}, override: true },`,
4663
+ " ]),"
4664
+ ] : [
4665
+ " messages: composeLocaleMessages([",
4666
+ ...layerLines,
4667
+ " ].filter((layer) =>",
4668
+ ' layer.owner === "substrate" || layer.owner.split("/").every(enabled)',
4669
+ " )),"
4670
+ ];
4575
4671
  bundleBlocks.push(
4576
4672
  [
4577
4673
  ` ${JSON.stringify(locale)}: {`,
4578
4674
  ` locale: ${JSON.stringify(locale)},`,
4579
- " messages: composeLocaleMessages([",
4580
- ...layerLines,
4581
- " ].filter((layer) =>",
4582
- ' layer.owner === "substrate" || layer.owner.split("/").every(enabled)',
4583
- " )),",
4675
+ ...messageLines,
4584
4676
  " }"
4585
4677
  ].join("\n")
4586
4678
  );
@@ -4625,6 +4717,26 @@ function emitModuleI18nGenTs(input) {
4625
4717
  ""
4626
4718
  ].join("\n");
4627
4719
  }
4720
+ function indexOverrideLocales(overrideLocales, targetLocales) {
4721
+ const byLocale = /* @__PURE__ */ new Map();
4722
+ for (const layer of overrideLocales) {
4723
+ if (!isValidLocaleBasename(layer.locale)) {
4724
+ throw new Error(
4725
+ `[ModuleRail] composition artifacts: unsafe override locale basename "${layer.locale}"`
4726
+ );
4727
+ }
4728
+ if (!targetLocales.includes(layer.locale)) {
4729
+ throw new Error(
4730
+ `[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`
4731
+ );
4732
+ }
4733
+ byLocale.set(layer.locale, layer);
4734
+ }
4735
+ return byLocale;
4736
+ }
4737
+ function overrideOwner(locale) {
4738
+ return `lib/i18n/overrides/${locale}.json`;
4739
+ }
4628
4740
  function deriveModuleLocaleLayers(registry, facts) {
4629
4741
  const layers = [];
4630
4742
  const installed = new Set(registry.modules.map((m) => m.name));
@@ -5006,6 +5118,25 @@ function emitModulesGenTs(registry) {
5006
5118
  ' if (entry && entry.kind !== "feature") return true;',
5007
5119
  ' return raw.split(",").includes(name);',
5008
5120
  "}",
5121
+ "",
5122
+ "/** Headless mount keys come from the platform at build time: that page is off, its Module stays on. */",
5123
+ "export function isMountHeadless(key: string): boolean {",
5124
+ ` const raw = process.env.${HEADLESS_MOUNTS_ENV};`,
5125
+ ' return raw ? raw.split(",").includes(key) : false;',
5126
+ "}",
5127
+ "",
5128
+ "/** True when `href` is a headless mount's URL or below it. */",
5129
+ "export function isHrefHeadless(href: unknown): boolean {",
5130
+ ' if (typeof href !== "string") return false;',
5131
+ " const target = href.split(/[?#]/)[0];",
5132
+ " return generatedModuleRegistry.modules.some((mod) =>",
5133
+ " mod.mounts.some((mount) => {",
5134
+ " if (!isMountHeadless(`${mod.name}:${mount.group}/${mount.segment}`)) return false;",
5135
+ " const path = `/${mount.segment}`;",
5136
+ " return target === path || target.startsWith(`${path}/`);",
5137
+ " })",
5138
+ " );",
5139
+ "}",
5009
5140
  ""
5010
5141
  ].join("\n");
5011
5142
  }
@@ -5013,6 +5144,22 @@ var STUB_PRINT_WIDTH = 100;
5013
5144
  function wrapAtPrintWidth(flat, broken) {
5014
5145
  return flat.length > STUB_PRINT_WIDTH ? broken : [flat];
5015
5146
  }
5147
+ function emitGuardLines(guards, body) {
5148
+ const operands = guards.map((guard) => `${guard.call}(${emitTsString(guard.arg)})`);
5149
+ const condition = ` if (${operands.join(" || ")})`;
5150
+ const brokenOperands = guards.flatMap((guard, i) => {
5151
+ const suffix = i < guards.length - 1 ? " ||" : "";
5152
+ return wrapAtPrintWidth(` ${operands[i]}${suffix}`, [
5153
+ ` ${guard.call}(`,
5154
+ ` ${emitTsString(guard.arg)}`,
5155
+ ` )${suffix}`
5156
+ ]);
5157
+ });
5158
+ return wrapAtPrintWidth(`${condition} ${body}`, [
5159
+ ...wrapAtPrintWidth(condition, [" if (", ...brokenOperands, " )"]),
5160
+ ` ${body}`
5161
+ ]);
5162
+ }
5016
5163
  function buildEndpointStubContent(input) {
5017
5164
  const sorted = [...input.methods].sort(
5018
5165
  (a, b) => METHOD_ORDER[a.method] - METHOD_ORDER[b.method] || a.pairKey.localeCompare(b.pairKey)
@@ -5020,24 +5167,15 @@ function buildEndpointStubContent(input) {
5020
5167
  const exports2 = sorted.map((m) => {
5021
5168
  const pair = emitTsString(m.pairKey);
5022
5169
  const guards = [
5023
- `!isModuleEnabled(${emitTsString(input.owner)})`,
5024
- ...input.scope.kind === "enhancement" ? [`!isModuleEnabled(${emitTsString(input.scope.peer)})`] : []
5170
+ { call: "!isModuleEnabled", arg: input.owner },
5171
+ ...input.scope.kind === "enhancement" ? [{ call: "!isModuleEnabled", arg: input.scope.peer }] : []
5025
5172
  ];
5026
- const condition = ` if (${guards.join(" || ")})`;
5027
- const notFoundBody = "return new Response(null, { status: 404 });";
5028
5173
  const message = emitTsString(`Missing endpoint handler for ${m.pairKey}`);
5029
5174
  const importPath = emitTsString(input.importModule);
5030
5175
  return [
5031
5176
  `export async function ${m.method}(request: Request, context?: unknown): Promise<Response> {`,
5032
5177
  " await initializeActiveEnhancements();",
5033
- ...wrapAtPrintWidth(`${condition} ${notFoundBody}`, [
5034
- ...wrapAtPrintWidth(condition, [
5035
- " if (",
5036
- ...guards.map((guard, i) => ` ${guard}${i < guards.length - 1 ? " ||" : ""}`),
5037
- " )"
5038
- ]),
5039
- ` ${notFoundBody}`
5040
- ]),
5178
+ ...emitGuardLines(guards, "return new Response(null, { status: 404 });"),
5041
5179
  ...wrapAtPrintWidth(` const { endpointHandlers } = await import(${importPath});`, [
5042
5180
  " const { endpointHandlers } = await import(",
5043
5181
  ` ${importPath}`,
@@ -5067,12 +5205,16 @@ function buildEndpointStubContent(input) {
5067
5205
  ].join("\n");
5068
5206
  }
5069
5207
  function buildRouteStubContent(input) {
5208
+ const guards = [
5209
+ { call: "!isModuleEnabled", arg: input.owner },
5210
+ { call: "isMountHeadless", arg: input.mountKey }
5211
+ ];
5070
5212
  const lines = [
5071
5213
  formatRouteMarker(input.owner, input.nextRelativePath),
5072
5214
  'import { notFound } from "next/navigation";',
5073
5215
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5074
5216
  "",
5075
- 'import { isModuleEnabled } from "@/modules.gen";',
5217
+ 'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5076
5218
  `import Page from ${emitTsString(input.importModule)};`
5077
5219
  ];
5078
5220
  if (input.hasGenerateMetadata) {
@@ -5080,7 +5222,7 @@ function buildRouteStubContent(input) {
5080
5222
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5081
5223
  "",
5082
5224
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5083
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) return {};`,
5225
+ ...emitGuardLines(guards, "return {};"),
5084
5226
  " return sourceGenerateMetadata(...args);",
5085
5227
  "}"
5086
5228
  );
@@ -5091,7 +5233,7 @@ function buildRouteStubContent(input) {
5091
5233
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5092
5234
  "",
5093
5235
  "export default function ModulePage(props: PageProps) {",
5094
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) notFound();`,
5236
+ ...emitGuardLines(guards, "notFound();"),
5095
5237
  " return <Page {...props} />;",
5096
5238
  "}",
5097
5239
  ""
@@ -5293,7 +5435,6 @@ function planRouteStubs(input) {
5293
5435
  }
5294
5436
  for (const entry of entries) {
5295
5437
  const nextRelativePath = entry.nextRelativePath;
5296
- analyzeRouteEntryPath(nextRelativePath);
5297
5438
  const destination = normalizeAppRouterDestinationPattern(nextRelativePath);
5298
5439
  const path3 = routeEntryToAppPageFile(nextRelativePath);
5299
5440
  const existing = routeDestinationOwners.get(destination);
@@ -5308,6 +5449,8 @@ function planRouteStubs(input) {
5308
5449
  path: path3
5309
5450
  });
5310
5451
  const importModule = routeEntryImport(regMod.name, nextRelativePath);
5452
+ const analyzed = analyzeRouteEntryPath(nextRelativePath);
5453
+ const mount = mounts.find((claim) => routeEntryCoveredByMountClaim(analyzed, claim));
5311
5454
  routeStubs.push({
5312
5455
  path: path3,
5313
5456
  owner: regMod.name,
@@ -5317,6 +5460,7 @@ function planRouteStubs(input) {
5317
5460
  owner: regMod.name,
5318
5461
  nextRelativePath,
5319
5462
  importModule,
5463
+ mountKey: mountKey(regMod.name, mount),
5320
5464
  hasGenerateMetadata: entry.hasGenerateMetadata
5321
5465
  })
5322
5466
  });
@@ -5503,7 +5647,8 @@ function planCompositionArtifacts(input) {
5503
5647
  registry: input.registry,
5504
5648
  facts: compositionEntryFacts,
5505
5649
  substrateLocales,
5506
- moduleLayers: input.moduleLocaleLayers
5650
+ moduleLayers: input.moduleLocaleLayers,
5651
+ overrideLocales: input.overrideLocales
5507
5652
  });
5508
5653
  initContent = emitModuleInitServerGenTs({
5509
5654
  registry: input.registry,
@@ -6848,7 +6993,8 @@ async function collectCompositionEntryFacts(context, registry) {
6848
6993
  `[ModuleRail] runId=${context.runId} collecting composition entry facts modules=${registry.modules.length}`
6849
6994
  );
6850
6995
  const substrateDir = `${APP_PACKAGE_DIR}/src/lib/i18n/substrate`;
6851
- const localeDirs = [substrateDir];
6996
+ const overridesDir = `${APP_PACKAGE_DIR}/src/lib/i18n/overrides`;
6997
+ const localeDirs = [substrateDir, overridesDir];
6852
6998
  const conventionalPaths = [];
6853
6999
  for (const mod of registry.modules) {
6854
7000
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
@@ -6899,6 +7045,12 @@ async function collectCompositionEntryFacts(context, registry) {
6899
7045
  messages: await readJsonObjectFile(localePath, files)
6900
7046
  });
6901
7047
  }
7048
+ const overrideLocales = [];
7049
+ for (const locale of localesByDir.get(overridesDir) ?? []) {
7050
+ const localePath = `${overridesDir}/${locale}.json`;
7051
+ assertRealLocaleJsonFilePresence(localePath, localePresences.get(localePath) ?? "unreadable");
7052
+ overrideLocales.push({ locale, messages: await readJsonObjectFile(localePath, files) });
7053
+ }
6902
7054
  const facts = [];
6903
7055
  const moduleLocaleLayers = [];
6904
7056
  for (const mod of registry.modules) {
@@ -7025,9 +7177,9 @@ async function collectCompositionEntryFacts(context, registry) {
7025
7177
  facts.sort((a, b) => a.moduleName.localeCompare(b.moduleName));
7026
7178
  moduleLocaleLayers.sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel));
7027
7179
  console.log(
7028
- `[ModuleRail] runId=${context.runId} composition entry facts modules=${facts.length} substrateLocales=${substrateLocales.length} moduleLocaleLayers=${moduleLocaleLayers.length}`
7180
+ `[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
7181
  );
7030
- return { facts, substrateLocales, moduleLocaleLayers };
7182
+ return { facts, substrateLocales, overrideLocales, moduleLocaleLayers };
7031
7183
  }
7032
7184
  function selectComposedModules(presentNames, manifestsByName, enabledRaw) {
7033
7185
  if (enabledRaw) {
@@ -7167,6 +7319,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7167
7319
  const {
7168
7320
  facts: compositionEntryFacts,
7169
7321
  substrateLocales,
7322
+ overrideLocales,
7170
7323
  moduleLocaleLayers
7171
7324
  } = await collectCompositionEntryFacts(context, registry);
7172
7325
  const candidatePaths = /* @__PURE__ */ new Set([
@@ -7189,6 +7342,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7189
7342
  existingAppRoutes,
7190
7343
  compositionEntryFacts,
7191
7344
  substrateLocales,
7345
+ overrideLocales,
7192
7346
  moduleLocaleLayers,
7193
7347
  moduleDataStoreBindings
7194
7348
  });
@@ -7257,6 +7411,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7257
7411
  existingAppRoutes,
7258
7412
  compositionEntryFacts,
7259
7413
  substrateLocales,
7414
+ overrideLocales,
7260
7415
  moduleLocaleLayers,
7261
7416
  moduleDataStoreBindings
7262
7417
  });
package/dist/index.mjs CHANGED
@@ -4114,6 +4114,7 @@ function isModuleEnabledByList(raw, name, kindOf) {
4114
4114
  var APP_WEB_PREFIX = "apps/web/";
4115
4115
  var MODULE_STUB_MARKER = "@stardeck-module-rail-generated";
4116
4116
  var ENABLED_MODULES_ENV = "NEXT_PUBLIC_STARDECK_ENABLED_MODULES";
4117
+ var HEADLESS_MOUNTS_ENV = "NEXT_PUBLIC_STARDECK_HEADLESS_MOUNTS";
4117
4118
  var MODULE_CONTRIBUTIONS_GEN_PATH = `${APP_WEB_PREFIX}src/module-contributions.gen.ts`;
4118
4119
  var MODULE_DATASTORES_GEN_PATH = `${APP_WEB_PREFIX}src/module-datastores.gen.ts`;
4119
4120
  var MODULE_I18N_GEN_PATH = `${APP_WEB_PREFIX}src/module-i18n.gen.ts`;
@@ -4121,6 +4122,11 @@ var MODULE_INIT_SERVER_GEN_PATH = `${APP_WEB_PREFIX}src/module-init.server.gen.t
4121
4122
  var MODULES_GEN_PATH = `${APP_WEB_PREFIX}src/modules.gen.ts`;
4122
4123
  var APP_EXTENSIONS_PATH = `${APP_WEB_PREFIX}src/lib/modules/app-extensions.ts`;
4123
4124
 
4125
+ // ../../packages/lib/src/shared/module-mount-key.ts
4126
+ function mountKey(moduleName, mount) {
4127
+ return `${moduleName}:${mount.group}/${mount.segment}`;
4128
+ }
4129
+
4124
4130
  // ../../packages/lib/src/server/module-rail/composition-artifacts.ts
4125
4131
  import ts4 from "typescript";
4126
4132
 
@@ -4261,6 +4267,40 @@ function cloneTree(value) {
4261
4267
  }
4262
4268
  return clone;
4263
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
+ }
4264
4304
  function composeLocaleMessagesForPlan(layers) {
4265
4305
  const result = createMessageTree();
4266
4306
  const leafOwners = /* @__PURE__ */ new Map();
@@ -4312,7 +4352,17 @@ function composeLocaleMessagesForPlan(layers) {
4312
4352
  }
4313
4353
  for (const layer of layers) {
4314
4354
  assertMessageTree(layer.messages, layer.owner);
4315
- 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
+ }
4316
4366
  }
4317
4367
  return result;
4318
4368
  }
@@ -4405,7 +4455,7 @@ function emitModuleContributionsGenTs(input) {
4405
4455
  " type SurfaceSlotCatalog,",
4406
4456
  '} from "@/lib/modules/contributions";',
4407
4457
  'import { substrateSlotCatalogs } from "@/lib/modules/substrate-slots";',
4408
- 'import { generatedModuleRegistry, isModuleEnabled } from "@/modules.gen";',
4458
+ 'import { generatedModuleRegistry, isHrefHeadless, isModuleEnabled } from "@/modules.gen";',
4409
4459
  ...[...moduleImportLines].sort((a, b) => {
4410
4460
  const pathA = a.match(/from ("[^"]+")/)?.[1] ?? a;
4411
4461
  const pathB = b.match(/from ("[^"]+")/)?.[1] ?? b;
@@ -4455,11 +4505,19 @@ function emitModuleContributionsGenTs(input) {
4455
4505
  "export function resolveModuleContributions(",
4456
4506
  " registry: typeof generatedModuleRegistry = generatedModuleRegistry",
4457
4507
  "): ResolvedModuleContributions {",
4458
- " return resolveContributions({",
4508
+ " const resolved = resolveContributions({",
4459
4509
  " registry,",
4460
4510
  " catalogs: moduleSlotCatalogs,",
4461
4511
  " sources: moduleContributionSources,",
4462
4512
  " });",
4513
+ " // A nav entry into a headless mount would link to a page that is switched off.",
4514
+ ' const nav = resolved["admin.nav"];',
4515
+ " if (nav) {",
4516
+ ' resolved["admin.nav"] = nav.filter(',
4517
+ " (entry) => !isHrefHeadless((entry as { href?: unknown }).href)",
4518
+ " );",
4519
+ " }",
4520
+ " return resolved;",
4463
4521
  "}",
4464
4522
  "",
4465
4523
  "export const resolvedModuleContributions: ResolvedModuleContributions =",
@@ -4491,6 +4549,7 @@ function emitModuleI18nGenTs(input) {
4491
4549
  );
4492
4550
  }
4493
4551
  const targetLocales = substrate.map((l) => l.locale);
4552
+ const overrideByLocale = indexOverrideLocales(input.overrideLocales ?? [], targetLocales);
4494
4553
  const installed = new Set(input.registry.modules.map((m) => m.name));
4495
4554
  const allLayers = input.moduleLayers ? [...input.moduleLayers] : deriveModuleLocaleLayers(input.registry, input.facts);
4496
4555
  const layers = allLayers.filter((layer) => installed.has(layer.owner.split("/")[0]));
@@ -4511,19 +4570,38 @@ function emitModuleI18nGenTs(input) {
4511
4570
  const messages = layer.localeMessages[locale] ?? (locale === "en" ? layer.enMessages : layer.enMessages);
4512
4571
  composeLayers.push({ owner: layer.sourceLabel, messages });
4513
4572
  }
4573
+ const override = overrideByLocale.get(locale);
4574
+ if (override) {
4575
+ composeLayers.push({
4576
+ owner: overrideOwner(locale),
4577
+ messages: override.messages,
4578
+ override: true
4579
+ });
4580
+ }
4514
4581
  composeLocaleMessagesForPlan(composeLayers);
4515
4582
  }
4516
4583
  const composeImport = 'import { composeLocaleMessages } from "@/lib/i18n/compose-messages";';
4517
4584
  const moduleJsonImports = [];
4518
4585
  const relativeSubstrateImports = [];
4519
4586
  const substrateIdentByLocale = {};
4587
+ const overrideIdentByLocale = {};
4520
4588
  targetLocales.forEach((locale, localeIdx) => {
4521
4589
  const ident = `i18nSubstrateLoc${localeIdx}`;
4522
4590
  relativeSubstrateImports.push(
4523
4591
  `import ${ident} from ${JSON.stringify(`./lib/i18n/substrate/${locale}.json`)};`
4524
4592
  );
4525
4593
  substrateIdentByLocale[locale] = ident;
4594
+ if (overrideByLocale.has(locale)) {
4595
+ const overrideIdent = `i18nOverrideLoc${localeIdx}`;
4596
+ relativeSubstrateImports.push(
4597
+ `import ${overrideIdent} from ${JSON.stringify(`./${overrideOwner(locale)}`)};`
4598
+ );
4599
+ overrideIdentByLocale[locale] = overrideIdent;
4600
+ }
4526
4601
  });
4602
+ relativeSubstrateImports.sort(
4603
+ (a, b) => compareImportSpecifiers(a.match(/ from (.+);$/)[1], b.match(/ from (.+);$/)[1])
4604
+ );
4527
4605
  const layerIdents = [];
4528
4606
  layers.forEach((layer, layerIdx) => {
4529
4607
  const localeIdents = {};
@@ -4557,15 +4635,29 @@ function emitModuleI18nGenTs(input) {
4557
4635
  (layer) => ` { owner: ${JSON.stringify(layer.owner)}, messages: ${layer.localeIdents[locale]} },`
4558
4636
  )
4559
4637
  ];
4638
+ const overrideIdent = overrideIdentByLocale[locale];
4639
+ const messageLines = overrideIdent ? [
4640
+ " messages: composeLocaleMessages([",
4641
+ " ...[",
4642
+ ...layerLines,
4643
+ " ].map((layer) => ({",
4644
+ " ...layer,",
4645
+ ' disabled: layer.owner !== "substrate" && !layer.owner.split("/").every(enabled),',
4646
+ " })),",
4647
+ ` { owner: ${JSON.stringify(overrideOwner(locale))}, messages: ${overrideIdent}, override: true },`,
4648
+ " ]),"
4649
+ ] : [
4650
+ " messages: composeLocaleMessages([",
4651
+ ...layerLines,
4652
+ " ].filter((layer) =>",
4653
+ ' layer.owner === "substrate" || layer.owner.split("/").every(enabled)',
4654
+ " )),"
4655
+ ];
4560
4656
  bundleBlocks.push(
4561
4657
  [
4562
4658
  ` ${JSON.stringify(locale)}: {`,
4563
4659
  ` locale: ${JSON.stringify(locale)},`,
4564
- " messages: composeLocaleMessages([",
4565
- ...layerLines,
4566
- " ].filter((layer) =>",
4567
- ' layer.owner === "substrate" || layer.owner.split("/").every(enabled)',
4568
- " )),",
4660
+ ...messageLines,
4569
4661
  " }"
4570
4662
  ].join("\n")
4571
4663
  );
@@ -4610,6 +4702,26 @@ function emitModuleI18nGenTs(input) {
4610
4702
  ""
4611
4703
  ].join("\n");
4612
4704
  }
4705
+ function indexOverrideLocales(overrideLocales, targetLocales) {
4706
+ const byLocale = /* @__PURE__ */ new Map();
4707
+ for (const layer of overrideLocales) {
4708
+ if (!isValidLocaleBasename(layer.locale)) {
4709
+ throw new Error(
4710
+ `[ModuleRail] composition artifacts: unsafe override locale basename "${layer.locale}"`
4711
+ );
4712
+ }
4713
+ if (!targetLocales.includes(layer.locale)) {
4714
+ throw new Error(
4715
+ `[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`
4716
+ );
4717
+ }
4718
+ byLocale.set(layer.locale, layer);
4719
+ }
4720
+ return byLocale;
4721
+ }
4722
+ function overrideOwner(locale) {
4723
+ return `lib/i18n/overrides/${locale}.json`;
4724
+ }
4613
4725
  function deriveModuleLocaleLayers(registry, facts) {
4614
4726
  const layers = [];
4615
4727
  const installed = new Set(registry.modules.map((m) => m.name));
@@ -4991,6 +5103,25 @@ function emitModulesGenTs(registry) {
4991
5103
  ' if (entry && entry.kind !== "feature") return true;',
4992
5104
  ' return raw.split(",").includes(name);',
4993
5105
  "}",
5106
+ "",
5107
+ "/** Headless mount keys come from the platform at build time: that page is off, its Module stays on. */",
5108
+ "export function isMountHeadless(key: string): boolean {",
5109
+ ` const raw = process.env.${HEADLESS_MOUNTS_ENV};`,
5110
+ ' return raw ? raw.split(",").includes(key) : false;',
5111
+ "}",
5112
+ "",
5113
+ "/** True when `href` is a headless mount's URL or below it. */",
5114
+ "export function isHrefHeadless(href: unknown): boolean {",
5115
+ ' if (typeof href !== "string") return false;',
5116
+ " const target = href.split(/[?#]/)[0];",
5117
+ " return generatedModuleRegistry.modules.some((mod) =>",
5118
+ " mod.mounts.some((mount) => {",
5119
+ " if (!isMountHeadless(`${mod.name}:${mount.group}/${mount.segment}`)) return false;",
5120
+ " const path = `/${mount.segment}`;",
5121
+ " return target === path || target.startsWith(`${path}/`);",
5122
+ " })",
5123
+ " );",
5124
+ "}",
4994
5125
  ""
4995
5126
  ].join("\n");
4996
5127
  }
@@ -4998,6 +5129,22 @@ var STUB_PRINT_WIDTH = 100;
4998
5129
  function wrapAtPrintWidth(flat, broken) {
4999
5130
  return flat.length > STUB_PRINT_WIDTH ? broken : [flat];
5000
5131
  }
5132
+ function emitGuardLines(guards, body) {
5133
+ const operands = guards.map((guard) => `${guard.call}(${emitTsString(guard.arg)})`);
5134
+ const condition = ` if (${operands.join(" || ")})`;
5135
+ const brokenOperands = guards.flatMap((guard, i) => {
5136
+ const suffix = i < guards.length - 1 ? " ||" : "";
5137
+ return wrapAtPrintWidth(` ${operands[i]}${suffix}`, [
5138
+ ` ${guard.call}(`,
5139
+ ` ${emitTsString(guard.arg)}`,
5140
+ ` )${suffix}`
5141
+ ]);
5142
+ });
5143
+ return wrapAtPrintWidth(`${condition} ${body}`, [
5144
+ ...wrapAtPrintWidth(condition, [" if (", ...brokenOperands, " )"]),
5145
+ ` ${body}`
5146
+ ]);
5147
+ }
5001
5148
  function buildEndpointStubContent(input) {
5002
5149
  const sorted = [...input.methods].sort(
5003
5150
  (a, b) => METHOD_ORDER[a.method] - METHOD_ORDER[b.method] || a.pairKey.localeCompare(b.pairKey)
@@ -5005,24 +5152,15 @@ function buildEndpointStubContent(input) {
5005
5152
  const exports = sorted.map((m) => {
5006
5153
  const pair = emitTsString(m.pairKey);
5007
5154
  const guards = [
5008
- `!isModuleEnabled(${emitTsString(input.owner)})`,
5009
- ...input.scope.kind === "enhancement" ? [`!isModuleEnabled(${emitTsString(input.scope.peer)})`] : []
5155
+ { call: "!isModuleEnabled", arg: input.owner },
5156
+ ...input.scope.kind === "enhancement" ? [{ call: "!isModuleEnabled", arg: input.scope.peer }] : []
5010
5157
  ];
5011
- const condition = ` if (${guards.join(" || ")})`;
5012
- const notFoundBody = "return new Response(null, { status: 404 });";
5013
5158
  const message = emitTsString(`Missing endpoint handler for ${m.pairKey}`);
5014
5159
  const importPath = emitTsString(input.importModule);
5015
5160
  return [
5016
5161
  `export async function ${m.method}(request: Request, context?: unknown): Promise<Response> {`,
5017
5162
  " await initializeActiveEnhancements();",
5018
- ...wrapAtPrintWidth(`${condition} ${notFoundBody}`, [
5019
- ...wrapAtPrintWidth(condition, [
5020
- " if (",
5021
- ...guards.map((guard, i) => ` ${guard}${i < guards.length - 1 ? " ||" : ""}`),
5022
- " )"
5023
- ]),
5024
- ` ${notFoundBody}`
5025
- ]),
5163
+ ...emitGuardLines(guards, "return new Response(null, { status: 404 });"),
5026
5164
  ...wrapAtPrintWidth(` const { endpointHandlers } = await import(${importPath});`, [
5027
5165
  " const { endpointHandlers } = await import(",
5028
5166
  ` ${importPath}`,
@@ -5052,12 +5190,16 @@ function buildEndpointStubContent(input) {
5052
5190
  ].join("\n");
5053
5191
  }
5054
5192
  function buildRouteStubContent(input) {
5193
+ const guards = [
5194
+ { call: "!isModuleEnabled", arg: input.owner },
5195
+ { call: "isMountHeadless", arg: input.mountKey }
5196
+ ];
5055
5197
  const lines = [
5056
5198
  formatRouteMarker(input.owner, input.nextRelativePath),
5057
5199
  'import { notFound } from "next/navigation";',
5058
5200
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5059
5201
  "",
5060
- 'import { isModuleEnabled } from "@/modules.gen";',
5202
+ 'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5061
5203
  `import Page from ${emitTsString(input.importModule)};`
5062
5204
  ];
5063
5205
  if (input.hasGenerateMetadata) {
@@ -5065,7 +5207,7 @@ function buildRouteStubContent(input) {
5065
5207
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5066
5208
  "",
5067
5209
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5068
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) return {};`,
5210
+ ...emitGuardLines(guards, "return {};"),
5069
5211
  " return sourceGenerateMetadata(...args);",
5070
5212
  "}"
5071
5213
  );
@@ -5076,7 +5218,7 @@ function buildRouteStubContent(input) {
5076
5218
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5077
5219
  "",
5078
5220
  "export default function ModulePage(props: PageProps) {",
5079
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) notFound();`,
5221
+ ...emitGuardLines(guards, "notFound();"),
5080
5222
  " return <Page {...props} />;",
5081
5223
  "}",
5082
5224
  ""
@@ -5278,7 +5420,6 @@ function planRouteStubs(input) {
5278
5420
  }
5279
5421
  for (const entry of entries) {
5280
5422
  const nextRelativePath = entry.nextRelativePath;
5281
- analyzeRouteEntryPath(nextRelativePath);
5282
5423
  const destination = normalizeAppRouterDestinationPattern(nextRelativePath);
5283
5424
  const path3 = routeEntryToAppPageFile(nextRelativePath);
5284
5425
  const existing = routeDestinationOwners.get(destination);
@@ -5293,6 +5434,8 @@ function planRouteStubs(input) {
5293
5434
  path: path3
5294
5435
  });
5295
5436
  const importModule = routeEntryImport(regMod.name, nextRelativePath);
5437
+ const analyzed = analyzeRouteEntryPath(nextRelativePath);
5438
+ const mount = mounts.find((claim) => routeEntryCoveredByMountClaim(analyzed, claim));
5296
5439
  routeStubs.push({
5297
5440
  path: path3,
5298
5441
  owner: regMod.name,
@@ -5302,6 +5445,7 @@ function planRouteStubs(input) {
5302
5445
  owner: regMod.name,
5303
5446
  nextRelativePath,
5304
5447
  importModule,
5448
+ mountKey: mountKey(regMod.name, mount),
5305
5449
  hasGenerateMetadata: entry.hasGenerateMetadata
5306
5450
  })
5307
5451
  });
@@ -5488,7 +5632,8 @@ function planCompositionArtifacts(input) {
5488
5632
  registry: input.registry,
5489
5633
  facts: compositionEntryFacts,
5490
5634
  substrateLocales,
5491
- moduleLayers: input.moduleLocaleLayers
5635
+ moduleLayers: input.moduleLocaleLayers,
5636
+ overrideLocales: input.overrideLocales
5492
5637
  });
5493
5638
  initContent = emitModuleInitServerGenTs({
5494
5639
  registry: input.registry,
@@ -6833,7 +6978,8 @@ async function collectCompositionEntryFacts(context, registry) {
6833
6978
  `[ModuleRail] runId=${context.runId} collecting composition entry facts modules=${registry.modules.length}`
6834
6979
  );
6835
6980
  const substrateDir = `${APP_PACKAGE_DIR}/src/lib/i18n/substrate`;
6836
- const localeDirs = [substrateDir];
6981
+ const overridesDir = `${APP_PACKAGE_DIR}/src/lib/i18n/overrides`;
6982
+ const localeDirs = [substrateDir, overridesDir];
6837
6983
  const conventionalPaths = [];
6838
6984
  for (const mod of registry.modules) {
6839
6985
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
@@ -6884,6 +7030,12 @@ async function collectCompositionEntryFacts(context, registry) {
6884
7030
  messages: await readJsonObjectFile(localePath, files)
6885
7031
  });
6886
7032
  }
7033
+ const overrideLocales = [];
7034
+ for (const locale of localesByDir.get(overridesDir) ?? []) {
7035
+ const localePath = `${overridesDir}/${locale}.json`;
7036
+ assertRealLocaleJsonFilePresence(localePath, localePresences.get(localePath) ?? "unreadable");
7037
+ overrideLocales.push({ locale, messages: await readJsonObjectFile(localePath, files) });
7038
+ }
6887
7039
  const facts = [];
6888
7040
  const moduleLocaleLayers = [];
6889
7041
  for (const mod of registry.modules) {
@@ -7010,9 +7162,9 @@ async function collectCompositionEntryFacts(context, registry) {
7010
7162
  facts.sort((a, b) => a.moduleName.localeCompare(b.moduleName));
7011
7163
  moduleLocaleLayers.sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel));
7012
7164
  console.log(
7013
- `[ModuleRail] runId=${context.runId} composition entry facts modules=${facts.length} substrateLocales=${substrateLocales.length} moduleLocaleLayers=${moduleLocaleLayers.length}`
7165
+ `[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
7166
  );
7015
- return { facts, substrateLocales, moduleLocaleLayers };
7167
+ return { facts, substrateLocales, overrideLocales, moduleLocaleLayers };
7016
7168
  }
7017
7169
  function selectComposedModules(presentNames, manifestsByName, enabledRaw) {
7018
7170
  if (enabledRaw) {
@@ -7152,6 +7304,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7152
7304
  const {
7153
7305
  facts: compositionEntryFacts,
7154
7306
  substrateLocales,
7307
+ overrideLocales,
7155
7308
  moduleLocaleLayers
7156
7309
  } = await collectCompositionEntryFacts(context, registry);
7157
7310
  const candidatePaths = /* @__PURE__ */ new Set([
@@ -7174,6 +7327,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7174
7327
  existingAppRoutes,
7175
7328
  compositionEntryFacts,
7176
7329
  substrateLocales,
7330
+ overrideLocales,
7177
7331
  moduleLocaleLayers,
7178
7332
  moduleDataStoreBindings
7179
7333
  });
@@ -7242,6 +7396,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7242
7396
  existingAppRoutes,
7243
7397
  compositionEntryFacts,
7244
7398
  substrateLocales,
7399
+ overrideLocales,
7245
7400
  moduleLocaleLayers,
7246
7401
  moduleDataStoreBindings
7247
7402
  });
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.7.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",