@stardeck-customer-apps/compose 0.6.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`
@@ -89,6 +89,18 @@ a silent fallback. It holds deployment facts: gitignore it (`apps/web/.gitignore
89
89
  locale; the locale also needs a `lib/i18n/substrate/<locale>.json`. New keys
90
90
  go in `lib/i18n/substrate/<locale>.json`, never in an override. Needs compose
91
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.
92
104
  - `@stardeck-customer-apps/compose/runtime` exports `isModuleEnabledByList`,
93
105
  the single enablement rule, and imports nothing — it is safe in client
94
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
 
@@ -4455,7 +4461,7 @@ function emitModuleContributionsGenTs(input) {
4455
4461
  " type SurfaceSlotCatalog,",
4456
4462
  '} from "@/lib/modules/contributions";',
4457
4463
  'import { substrateSlotCatalogs } from "@/lib/modules/substrate-slots";',
4458
- 'import { generatedModuleRegistry, isModuleEnabled } from "@/modules.gen";',
4464
+ 'import { generatedModuleRegistry, isHrefHeadless, isModuleEnabled } from "@/modules.gen";',
4459
4465
  ...[...moduleImportLines].sort((a, b) => {
4460
4466
  const pathA = a.match(/from ("[^"]+")/)?.[1] ?? a;
4461
4467
  const pathB = b.match(/from ("[^"]+")/)?.[1] ?? b;
@@ -4505,11 +4511,19 @@ function emitModuleContributionsGenTs(input) {
4505
4511
  "export function resolveModuleContributions(",
4506
4512
  " registry: typeof generatedModuleRegistry = generatedModuleRegistry",
4507
4513
  "): ResolvedModuleContributions {",
4508
- " return resolveContributions({",
4514
+ " const resolved = resolveContributions({",
4509
4515
  " registry,",
4510
4516
  " catalogs: moduleSlotCatalogs,",
4511
4517
  " sources: moduleContributionSources,",
4512
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;",
4513
4527
  "}",
4514
4528
  "",
4515
4529
  "export const resolvedModuleContributions: ResolvedModuleContributions =",
@@ -5095,6 +5109,25 @@ function emitModulesGenTs(registry) {
5095
5109
  ' if (entry && entry.kind !== "feature") return true;',
5096
5110
  ' return raw.split(",").includes(name);',
5097
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
+ "}",
5098
5131
  ""
5099
5132
  ].join("\n");
5100
5133
  }
@@ -5102,6 +5135,22 @@ var STUB_PRINT_WIDTH = 100;
5102
5135
  function wrapAtPrintWidth(flat, broken) {
5103
5136
  return flat.length > STUB_PRINT_WIDTH ? broken : [flat];
5104
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
+ }
5105
5154
  function buildEndpointStubContent(input) {
5106
5155
  const sorted = [...input.methods].sort(
5107
5156
  (a, b) => METHOD_ORDER[a.method] - METHOD_ORDER[b.method] || a.pairKey.localeCompare(b.pairKey)
@@ -5109,24 +5158,15 @@ function buildEndpointStubContent(input) {
5109
5158
  const exports2 = sorted.map((m) => {
5110
5159
  const pair = emitTsString(m.pairKey);
5111
5160
  const guards = [
5112
- `!isModuleEnabled(${emitTsString(input.owner)})`,
5113
- ...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 }] : []
5114
5163
  ];
5115
- const condition = ` if (${guards.join(" || ")})`;
5116
- const notFoundBody = "return new Response(null, { status: 404 });";
5117
5164
  const message = emitTsString(`Missing endpoint handler for ${m.pairKey}`);
5118
5165
  const importPath = emitTsString(input.importModule);
5119
5166
  return [
5120
5167
  `export async function ${m.method}(request: Request, context?: unknown): Promise<Response> {`,
5121
5168
  " await initializeActiveEnhancements();",
5122
- ...wrapAtPrintWidth(`${condition} ${notFoundBody}`, [
5123
- ...wrapAtPrintWidth(condition, [
5124
- " if (",
5125
- ...guards.map((guard, i) => ` ${guard}${i < guards.length - 1 ? " ||" : ""}`),
5126
- " )"
5127
- ]),
5128
- ` ${notFoundBody}`
5129
- ]),
5169
+ ...emitGuardLines(guards, "return new Response(null, { status: 404 });"),
5130
5170
  ...wrapAtPrintWidth(` const { endpointHandlers } = await import(${importPath});`, [
5131
5171
  " const { endpointHandlers } = await import(",
5132
5172
  ` ${importPath}`,
@@ -5156,12 +5196,16 @@ function buildEndpointStubContent(input) {
5156
5196
  ].join("\n");
5157
5197
  }
5158
5198
  function buildRouteStubContent(input) {
5199
+ const guards = [
5200
+ { call: "!isModuleEnabled", arg: input.owner },
5201
+ { call: "isMountHeadless", arg: input.mountKey }
5202
+ ];
5159
5203
  const lines = [
5160
5204
  formatRouteMarker(input.owner, input.nextRelativePath),
5161
5205
  'import { notFound } from "next/navigation";',
5162
5206
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5163
5207
  "",
5164
- 'import { isModuleEnabled } from "@/modules.gen";',
5208
+ 'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5165
5209
  `import Page from ${emitTsString(input.importModule)};`
5166
5210
  ];
5167
5211
  if (input.hasGenerateMetadata) {
@@ -5169,7 +5213,7 @@ function buildRouteStubContent(input) {
5169
5213
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5170
5214
  "",
5171
5215
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5172
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) return {};`,
5216
+ ...emitGuardLines(guards, "return {};"),
5173
5217
  " return sourceGenerateMetadata(...args);",
5174
5218
  "}"
5175
5219
  );
@@ -5180,7 +5224,7 @@ function buildRouteStubContent(input) {
5180
5224
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5181
5225
  "",
5182
5226
  "export default function ModulePage(props: PageProps) {",
5183
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) notFound();`,
5227
+ ...emitGuardLines(guards, "notFound();"),
5184
5228
  " return <Page {...props} />;",
5185
5229
  "}",
5186
5230
  ""
@@ -5382,7 +5426,6 @@ function planRouteStubs(input) {
5382
5426
  }
5383
5427
  for (const entry of entries) {
5384
5428
  const nextRelativePath = entry.nextRelativePath;
5385
- analyzeRouteEntryPath(nextRelativePath);
5386
5429
  const destination = normalizeAppRouterDestinationPattern(nextRelativePath);
5387
5430
  const path3 = routeEntryToAppPageFile(nextRelativePath);
5388
5431
  const existing = routeDestinationOwners.get(destination);
@@ -5397,6 +5440,8 @@ function planRouteStubs(input) {
5397
5440
  path: path3
5398
5441
  });
5399
5442
  const importModule = routeEntryImport(regMod.name, nextRelativePath);
5443
+ const analyzed = analyzeRouteEntryPath(nextRelativePath);
5444
+ const mount = mounts.find((claim) => routeEntryCoveredByMountClaim(analyzed, claim));
5400
5445
  routeStubs.push({
5401
5446
  path: path3,
5402
5447
  owner: regMod.name,
@@ -5406,6 +5451,7 @@ function planRouteStubs(input) {
5406
5451
  owner: regMod.name,
5407
5452
  nextRelativePath,
5408
5453
  importModule,
5454
+ mountKey: mountKey(regMod.name, mount),
5409
5455
  hasGenerateMetadata: entry.hasGenerateMetadata
5410
5456
  })
5411
5457
  });
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
 
@@ -4464,7 +4470,7 @@ function emitModuleContributionsGenTs(input) {
4464
4470
  " type SurfaceSlotCatalog,",
4465
4471
  '} from "@/lib/modules/contributions";',
4466
4472
  'import { substrateSlotCatalogs } from "@/lib/modules/substrate-slots";',
4467
- 'import { generatedModuleRegistry, isModuleEnabled } from "@/modules.gen";',
4473
+ 'import { generatedModuleRegistry, isHrefHeadless, isModuleEnabled } from "@/modules.gen";',
4468
4474
  ...[...moduleImportLines].sort((a, b) => {
4469
4475
  const pathA = a.match(/from ("[^"]+")/)?.[1] ?? a;
4470
4476
  const pathB = b.match(/from ("[^"]+")/)?.[1] ?? b;
@@ -4514,11 +4520,19 @@ function emitModuleContributionsGenTs(input) {
4514
4520
  "export function resolveModuleContributions(",
4515
4521
  " registry: typeof generatedModuleRegistry = generatedModuleRegistry",
4516
4522
  "): ResolvedModuleContributions {",
4517
- " return resolveContributions({",
4523
+ " const resolved = resolveContributions({",
4518
4524
  " registry,",
4519
4525
  " catalogs: moduleSlotCatalogs,",
4520
4526
  " sources: moduleContributionSources,",
4521
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;",
4522
4536
  "}",
4523
4537
  "",
4524
4538
  "export const resolvedModuleContributions: ResolvedModuleContributions =",
@@ -5104,6 +5118,25 @@ function emitModulesGenTs(registry) {
5104
5118
  ' if (entry && entry.kind !== "feature") return true;',
5105
5119
  ' return raw.split(",").includes(name);',
5106
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
+ "}",
5107
5140
  ""
5108
5141
  ].join("\n");
5109
5142
  }
@@ -5111,6 +5144,22 @@ var STUB_PRINT_WIDTH = 100;
5111
5144
  function wrapAtPrintWidth(flat, broken) {
5112
5145
  return flat.length > STUB_PRINT_WIDTH ? broken : [flat];
5113
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
+ }
5114
5163
  function buildEndpointStubContent(input) {
5115
5164
  const sorted = [...input.methods].sort(
5116
5165
  (a, b) => METHOD_ORDER[a.method] - METHOD_ORDER[b.method] || a.pairKey.localeCompare(b.pairKey)
@@ -5118,24 +5167,15 @@ function buildEndpointStubContent(input) {
5118
5167
  const exports2 = sorted.map((m) => {
5119
5168
  const pair = emitTsString(m.pairKey);
5120
5169
  const guards = [
5121
- `!isModuleEnabled(${emitTsString(input.owner)})`,
5122
- ...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 }] : []
5123
5172
  ];
5124
- const condition = ` if (${guards.join(" || ")})`;
5125
- const notFoundBody = "return new Response(null, { status: 404 });";
5126
5173
  const message = emitTsString(`Missing endpoint handler for ${m.pairKey}`);
5127
5174
  const importPath = emitTsString(input.importModule);
5128
5175
  return [
5129
5176
  `export async function ${m.method}(request: Request, context?: unknown): Promise<Response> {`,
5130
5177
  " await initializeActiveEnhancements();",
5131
- ...wrapAtPrintWidth(`${condition} ${notFoundBody}`, [
5132
- ...wrapAtPrintWidth(condition, [
5133
- " if (",
5134
- ...guards.map((guard, i) => ` ${guard}${i < guards.length - 1 ? " ||" : ""}`),
5135
- " )"
5136
- ]),
5137
- ` ${notFoundBody}`
5138
- ]),
5178
+ ...emitGuardLines(guards, "return new Response(null, { status: 404 });"),
5139
5179
  ...wrapAtPrintWidth(` const { endpointHandlers } = await import(${importPath});`, [
5140
5180
  " const { endpointHandlers } = await import(",
5141
5181
  ` ${importPath}`,
@@ -5165,12 +5205,16 @@ function buildEndpointStubContent(input) {
5165
5205
  ].join("\n");
5166
5206
  }
5167
5207
  function buildRouteStubContent(input) {
5208
+ const guards = [
5209
+ { call: "!isModuleEnabled", arg: input.owner },
5210
+ { call: "isMountHeadless", arg: input.mountKey }
5211
+ ];
5168
5212
  const lines = [
5169
5213
  formatRouteMarker(input.owner, input.nextRelativePath),
5170
5214
  'import { notFound } from "next/navigation";',
5171
5215
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5172
5216
  "",
5173
- 'import { isModuleEnabled } from "@/modules.gen";',
5217
+ 'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5174
5218
  `import Page from ${emitTsString(input.importModule)};`
5175
5219
  ];
5176
5220
  if (input.hasGenerateMetadata) {
@@ -5178,7 +5222,7 @@ function buildRouteStubContent(input) {
5178
5222
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5179
5223
  "",
5180
5224
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5181
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) return {};`,
5225
+ ...emitGuardLines(guards, "return {};"),
5182
5226
  " return sourceGenerateMetadata(...args);",
5183
5227
  "}"
5184
5228
  );
@@ -5189,7 +5233,7 @@ function buildRouteStubContent(input) {
5189
5233
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5190
5234
  "",
5191
5235
  "export default function ModulePage(props: PageProps) {",
5192
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) notFound();`,
5236
+ ...emitGuardLines(guards, "notFound();"),
5193
5237
  " return <Page {...props} />;",
5194
5238
  "}",
5195
5239
  ""
@@ -5391,7 +5435,6 @@ function planRouteStubs(input) {
5391
5435
  }
5392
5436
  for (const entry of entries) {
5393
5437
  const nextRelativePath = entry.nextRelativePath;
5394
- analyzeRouteEntryPath(nextRelativePath);
5395
5438
  const destination = normalizeAppRouterDestinationPattern(nextRelativePath);
5396
5439
  const path3 = routeEntryToAppPageFile(nextRelativePath);
5397
5440
  const existing = routeDestinationOwners.get(destination);
@@ -5406,6 +5449,8 @@ function planRouteStubs(input) {
5406
5449
  path: path3
5407
5450
  });
5408
5451
  const importModule = routeEntryImport(regMod.name, nextRelativePath);
5452
+ const analyzed = analyzeRouteEntryPath(nextRelativePath);
5453
+ const mount = mounts.find((claim) => routeEntryCoveredByMountClaim(analyzed, claim));
5409
5454
  routeStubs.push({
5410
5455
  path: path3,
5411
5456
  owner: regMod.name,
@@ -5415,6 +5460,7 @@ function planRouteStubs(input) {
5415
5460
  owner: regMod.name,
5416
5461
  nextRelativePath,
5417
5462
  importModule,
5463
+ mountKey: mountKey(regMod.name, mount),
5418
5464
  hasGenerateMetadata: entry.hasGenerateMetadata
5419
5465
  })
5420
5466
  });
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
 
@@ -4449,7 +4455,7 @@ function emitModuleContributionsGenTs(input) {
4449
4455
  " type SurfaceSlotCatalog,",
4450
4456
  '} from "@/lib/modules/contributions";',
4451
4457
  'import { substrateSlotCatalogs } from "@/lib/modules/substrate-slots";',
4452
- 'import { generatedModuleRegistry, isModuleEnabled } from "@/modules.gen";',
4458
+ 'import { generatedModuleRegistry, isHrefHeadless, isModuleEnabled } from "@/modules.gen";',
4453
4459
  ...[...moduleImportLines].sort((a, b) => {
4454
4460
  const pathA = a.match(/from ("[^"]+")/)?.[1] ?? a;
4455
4461
  const pathB = b.match(/from ("[^"]+")/)?.[1] ?? b;
@@ -4499,11 +4505,19 @@ function emitModuleContributionsGenTs(input) {
4499
4505
  "export function resolveModuleContributions(",
4500
4506
  " registry: typeof generatedModuleRegistry = generatedModuleRegistry",
4501
4507
  "): ResolvedModuleContributions {",
4502
- " return resolveContributions({",
4508
+ " const resolved = resolveContributions({",
4503
4509
  " registry,",
4504
4510
  " catalogs: moduleSlotCatalogs,",
4505
4511
  " sources: moduleContributionSources,",
4506
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;",
4507
4521
  "}",
4508
4522
  "",
4509
4523
  "export const resolvedModuleContributions: ResolvedModuleContributions =",
@@ -5089,6 +5103,25 @@ function emitModulesGenTs(registry) {
5089
5103
  ' if (entry && entry.kind !== "feature") return true;',
5090
5104
  ' return raw.split(",").includes(name);',
5091
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
+ "}",
5092
5125
  ""
5093
5126
  ].join("\n");
5094
5127
  }
@@ -5096,6 +5129,22 @@ var STUB_PRINT_WIDTH = 100;
5096
5129
  function wrapAtPrintWidth(flat, broken) {
5097
5130
  return flat.length > STUB_PRINT_WIDTH ? broken : [flat];
5098
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
+ }
5099
5148
  function buildEndpointStubContent(input) {
5100
5149
  const sorted = [...input.methods].sort(
5101
5150
  (a, b) => METHOD_ORDER[a.method] - METHOD_ORDER[b.method] || a.pairKey.localeCompare(b.pairKey)
@@ -5103,24 +5152,15 @@ function buildEndpointStubContent(input) {
5103
5152
  const exports = sorted.map((m) => {
5104
5153
  const pair = emitTsString(m.pairKey);
5105
5154
  const guards = [
5106
- `!isModuleEnabled(${emitTsString(input.owner)})`,
5107
- ...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 }] : []
5108
5157
  ];
5109
- const condition = ` if (${guards.join(" || ")})`;
5110
- const notFoundBody = "return new Response(null, { status: 404 });";
5111
5158
  const message = emitTsString(`Missing endpoint handler for ${m.pairKey}`);
5112
5159
  const importPath = emitTsString(input.importModule);
5113
5160
  return [
5114
5161
  `export async function ${m.method}(request: Request, context?: unknown): Promise<Response> {`,
5115
5162
  " await initializeActiveEnhancements();",
5116
- ...wrapAtPrintWidth(`${condition} ${notFoundBody}`, [
5117
- ...wrapAtPrintWidth(condition, [
5118
- " if (",
5119
- ...guards.map((guard, i) => ` ${guard}${i < guards.length - 1 ? " ||" : ""}`),
5120
- " )"
5121
- ]),
5122
- ` ${notFoundBody}`
5123
- ]),
5163
+ ...emitGuardLines(guards, "return new Response(null, { status: 404 });"),
5124
5164
  ...wrapAtPrintWidth(` const { endpointHandlers } = await import(${importPath});`, [
5125
5165
  " const { endpointHandlers } = await import(",
5126
5166
  ` ${importPath}`,
@@ -5150,12 +5190,16 @@ function buildEndpointStubContent(input) {
5150
5190
  ].join("\n");
5151
5191
  }
5152
5192
  function buildRouteStubContent(input) {
5193
+ const guards = [
5194
+ { call: "!isModuleEnabled", arg: input.owner },
5195
+ { call: "isMountHeadless", arg: input.mountKey }
5196
+ ];
5153
5197
  const lines = [
5154
5198
  formatRouteMarker(input.owner, input.nextRelativePath),
5155
5199
  'import { notFound } from "next/navigation";',
5156
5200
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5157
5201
  "",
5158
- 'import { isModuleEnabled } from "@/modules.gen";',
5202
+ 'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5159
5203
  `import Page from ${emitTsString(input.importModule)};`
5160
5204
  ];
5161
5205
  if (input.hasGenerateMetadata) {
@@ -5163,7 +5207,7 @@ function buildRouteStubContent(input) {
5163
5207
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5164
5208
  "",
5165
5209
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5166
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) return {};`,
5210
+ ...emitGuardLines(guards, "return {};"),
5167
5211
  " return sourceGenerateMetadata(...args);",
5168
5212
  "}"
5169
5213
  );
@@ -5174,7 +5218,7 @@ function buildRouteStubContent(input) {
5174
5218
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5175
5219
  "",
5176
5220
  "export default function ModulePage(props: PageProps) {",
5177
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) notFound();`,
5221
+ ...emitGuardLines(guards, "notFound();"),
5178
5222
  " return <Page {...props} />;",
5179
5223
  "}",
5180
5224
  ""
@@ -5376,7 +5420,6 @@ function planRouteStubs(input) {
5376
5420
  }
5377
5421
  for (const entry of entries) {
5378
5422
  const nextRelativePath = entry.nextRelativePath;
5379
- analyzeRouteEntryPath(nextRelativePath);
5380
5423
  const destination = normalizeAppRouterDestinationPattern(nextRelativePath);
5381
5424
  const path3 = routeEntryToAppPageFile(nextRelativePath);
5382
5425
  const existing = routeDestinationOwners.get(destination);
@@ -5391,6 +5434,8 @@ function planRouteStubs(input) {
5391
5434
  path: path3
5392
5435
  });
5393
5436
  const importModule = routeEntryImport(regMod.name, nextRelativePath);
5437
+ const analyzed = analyzeRouteEntryPath(nextRelativePath);
5438
+ const mount = mounts.find((claim) => routeEntryCoveredByMountClaim(analyzed, claim));
5394
5439
  routeStubs.push({
5395
5440
  path: path3,
5396
5441
  owner: regMod.name,
@@ -5400,6 +5445,7 @@ function planRouteStubs(input) {
5400
5445
  owner: regMod.name,
5401
5446
  nextRelativePath,
5402
5447
  importModule,
5448
+ mountKey: mountKey(regMod.name, mount),
5403
5449
  hasGenerateMetadata: entry.hasGenerateMetadata
5404
5450
  })
5405
5451
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/compose",
3
- "version": "0.6.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",