create-ailk 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/component-catalog.json +11939 -0
  2. package/component-catalog.md +176 -64
  3. package/dist/cli.js +29 -1
  4. package/dist/derive-architecture.js +5 -2
  5. package/dist/lib/extract-module-bundle.d.ts +50 -2
  6. package/dist/lib/extract-module-bundle.js +223 -15
  7. package/dist/lib/fetch-guard.d.ts +28 -0
  8. package/dist/lib/fetch-guard.js +101 -0
  9. package/dist/lib/fetch-module.js +4 -0
  10. package/dist/lib/fetch-template.js +4 -0
  11. package/dist/module-architecture.d.ts +59 -8
  12. package/dist/module-architecture.js +334 -115
  13. package/dist/parse-args.js +6 -2
  14. package/dist/programmatic.d.ts +10 -0
  15. package/dist/programmatic.js +134 -19
  16. package/dist/site-chrome.d.ts +80 -0
  17. package/dist/site-chrome.js +135 -0
  18. package/dist/surfaces.d.ts +21 -6
  19. package/dist/surfaces.js +107 -32
  20. package/dist/template-manifest.d.ts +55 -3
  21. package/dist/template-manifest.js +181 -12
  22. package/dist/template-names.d.ts +1 -1
  23. package/dist/template-names.js +1 -0
  24. package/package.json +9 -2
  25. package/site-templates/_base/ailk-template.json +6 -0
  26. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  27. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +331 -0
  28. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +393 -0
  29. package/site-templates/marketing-site/ailk-template.json +11 -0
  30. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  31. package/site-templates/marketing-site/provenance.json +4 -0
  32. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  33. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +285 -0
  34. package/site-templates/marketing-site-free/ailk-template.json +2 -1
  35. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +271 -0
  36. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +324 -0
  37. package/site-templates/minimalism-site/ailk-template.json +21 -0
  38. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  39. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  40. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  41. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  42. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  43. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  44. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  45. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  46. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  47. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  48. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  49. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  50. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  51. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +214 -0
  52. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  53. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  54. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  55. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  56. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  57. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  58. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  59. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  60. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  61. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  62. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  63. package/site-templates/minimalism-site/provenance.json +4 -0
  64. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  65. package/site-templates/saas-product/ailk-template.json +16 -0
  66. package/site-templates/saas-product/provenance.json +10 -0
  67. package/templates/.claude/skills/README.md +16 -25
  68. package/templates/.env.example +32 -7
  69. package/templates/.github/workflows/ci.yml +48 -0
  70. package/templates/README.md +3 -1
  71. package/templates/apps/api/CLAUDE.md +4 -1
  72. package/templates/apps/api/eslint.config.mjs +69 -0
  73. package/templates/apps/api/package.json +6 -2
  74. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +6 -2
  75. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  76. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +8 -1
  77. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  78. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +339 -0
  79. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +108 -0
  80. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  82. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  83. package/templates/apps/api/src/server.ts +82 -6
  84. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  85. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  86. package/templates/apps/api/src/services/default-tenant.ts +261 -0
  87. package/templates/apps/api/src/services/lead-promotion.ts +161 -0
  88. package/templates/apps/api/src/services/leads-inbox.ts +263 -0
  89. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  90. package/templates/apps/mcp/CLAUDE.md +1 -1
  91. package/templates/apps/mcp/__tests__/parity.test.ts +2 -2
  92. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  93. package/templates/apps/mcp/eslint.config.mjs +69 -0
  94. package/templates/apps/mcp/package.json +6 -2
  95. package/templates/apps/mcp/src/server.ts +2 -2
  96. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  97. package/templates/apps/mcp/src/tools/index.ts +2 -2
  98. package/templates/apps/web/.env.example +3 -3
  99. package/templates/apps/web/CLAUDE.md +2 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +4 -6
  101. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +1 -1
  102. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  103. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  104. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  105. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  106. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  107. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  108. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  109. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  111. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  112. package/templates/apps/web/app/[locale]/layout.tsx +214 -17
  113. package/templates/apps/web/components/google-analytics.tsx +18 -1
  114. package/templates/apps/web/eslint.config.mjs +69 -0
  115. package/templates/apps/web/jest.config.cjs +7 -15
  116. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +32 -5
  117. package/templates/apps/web/lib/nav-config.ts +27 -7
  118. package/templates/apps/web/lib/server-api.ts +1 -1
  119. package/templates/apps/web/lib/site-chrome.ts +29 -0
  120. package/templates/apps/web/lib/telemetry-scrub.ts +46 -0
  121. package/templates/apps/web/messages/en.json +29 -2
  122. package/templates/apps/web/next.config.mjs +20 -0
  123. package/templates/apps/web/package.json +6 -2
  124. package/templates/apps/web/sentry.client.config.ts +131 -0
  125. package/templates/apps/web/sentry.edge.config.ts +72 -0
  126. package/templates/apps/web/sentry.server.config.ts +72 -0
  127. package/templates/apps/web/src/lib/routes.ts +13 -18
  128. package/templates/brand_assets/README.md +24 -0
  129. package/templates/content/en/AboutPage/about.mdx +160 -15
  130. package/templates/content/en/ContactPage/contact.mdx +90 -13
  131. package/templates/content/en/FAQPage/faq.mdx +62 -22
  132. package/templates/content/en/HomePage/home.mdx +244 -20
  133. package/templates/content/en/LandingPage/blog.mdx +83 -0
  134. package/templates/content/en/LandingPage/features.mdx +103 -0
  135. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  136. package/templates/database/CHANGELOG.md +7 -0
  137. package/templates/database/__tests__/seed.test.ts +1 -1
  138. package/templates/database/eslint.config.mjs +69 -0
  139. package/templates/database/package.json +7 -3
  140. package/templates/database/seed.ts +1 -1
  141. package/templates/package.json +1 -14
  142. package/versions.json +52 -0
  143. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
  144. /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
  145. /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
  146. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -64,8 +64,12 @@ Options:
64
64
  AILK_LICENSE_KEY to cover every module it requires,
65
65
  and refuses naming whichever it does not.
66
66
  --surfaces <csv> Comma-separated route surfaces to scaffold IN
67
- ADDITION to the core set (/, /about, /contact, /faq,
68
- /thanks). Recognized: ${RECOGNIZED_SURFACES.join(", ")}.
67
+ ADDITION to the core set (/, /about, /contact, /faq).
68
+ /thanks/{slug} ships with every scaffold too, but it
69
+ is not a surface you select here — it is a per-form
70
+ confirmation page, authored when the form that needs
71
+ it exists (there is no /thanks hub). Recognized:
72
+ ${RECOGNIZED_SURFACES.join(", ")}.
69
73
  Surfaces the declared architecture does not carry are
70
74
  NOT scaffolded — their routes are absent, so a fresh
71
75
  site never serves a stock page with placeholder
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { CATALOG_TARGET_PATH } from "./component-catalog.js";
19
19
  import { type ModuleName } from "./parse-args.js";
20
+ import { type TemplateLayout } from "./site-chrome.js";
20
21
  import { type SyncRoutesResult } from "./sync-routes.js";
21
22
  export interface ScaffoldOptions {
22
23
  /** Absolute or relative path to the target directory. Must not exist or be empty. */
@@ -89,6 +90,15 @@ export interface ScaffoldOptions {
89
90
  templateRegistryUrl?: string;
90
91
  /** Injected fetch for tests. Never a live network call in CI or in this repo's evidence runs. */
91
92
  fetchImpl?: typeof fetch;
93
+ /**
94
+ * Resolved template layout knobs (#5612 D1/D2) — INTERNAL: populated by
95
+ * `scaffold()` from the resolved template's `layout` block (the code
96
+ * registry's `TemplateManifest.layout`) before calling `scaffoldSync`. Never
97
+ * set by a CLI/MCP caller directly. `scaffoldSync` fills every unset field
98
+ * with `DEFAULT_SITE_CHROME`, so a call naming no `layout` (every existing
99
+ * caller) writes the same defaults `site-chrome.ts` always has.
100
+ */
101
+ layout?: TemplateLayout;
92
102
  }
93
103
  export interface ScaffoldResult {
94
104
  /** Resolved absolute path to the scaffolded directory. */
@@ -26,9 +26,10 @@ import { fetchTemplate } from "./lib/fetch-template.js";
26
26
  import { resolvePaidModules } from "./lib/module-license-gate.js";
27
27
  import { classifyModules } from "./lib/module-tier.js";
28
28
  import { normalizeModules } from "./module-architecture.js";
29
- import { NAV_CONFIG_PATH, excludedRoutePaths, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
29
+ import { SITE_CHROME_PATH, renderSiteChrome, resolveSiteChrome, } from "./site-chrome.js";
30
+ import { GATEABLE_SURFACES, MODULE_SURFACES, NAV_CONFIG_PATH, excludedRoutePaths, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
30
31
  import { syncRoutes } from "./sync-routes.js";
31
- import { resolveTemplate, templateDelivery } from "./template-manifest.js";
32
+ import { BASE_LAYER_DIR, mergeRoster, resolveTemplate, resolveTemplateLineage, templateDelivery, } from "./template-manifest.js";
32
33
  import { validateTarget } from "./validate.js";
33
34
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
34
35
  const TEMPLATES_DIR = resolve(PKG_ROOT, "templates");
@@ -95,6 +96,10 @@ export function scaffoldSync(opts) {
95
96
  PROJECT_NAME: projectName,
96
97
  COMPANY_NAME: "Your Company",
97
98
  COMPANY_DOMAIN: "example.com",
99
+ // The social-profile handle the Free chrome's footer links carry
100
+ // (`https://x.com/{{COMPANY_HANDLE}}`, #5469 D5) — a placeholder the
101
+ // site owner replaces, never the kit vendor's own handle.
102
+ COMPANY_HANDLE: "yourcompany",
98
103
  },
99
104
  });
100
105
  // Derive + write the module-set-driven architecture.yaml (spec #3787). This
@@ -108,6 +113,12 @@ export function scaffoldSync(opts) {
108
113
  // the template's copy in place would link /docs and /blog sitewide in a
109
114
  // scaffold that carries neither route.
110
115
  writeFileSync(join(targetAbs, NAV_CONFIG_PATH), renderNavConfig(enabledSurfaces));
116
+ // Site-level chrome knobs (#5612 D2) — beside nav-config.ts, same GENERATED
117
+ // idiom. `opts.layout` is set only by `scaffold()`, from the resolved
118
+ // template's `layout` block; a bare `scaffoldSync` call (every existing
119
+ // synchronous caller) writes DEFAULT_SITE_CHROME, byte-identical to the
120
+ // monorepo's own committed apps/web/lib/site-chrome.ts.
121
+ writeFileSync(join(targetAbs, SITE_CHROME_PATH), renderSiteChrome(resolveSiteChrome(opts.layout)));
111
122
  // Opt-in only (#4636). Every write above this line is unconditional, so a
112
123
  // scaffold with `withCatalog` unset touches exactly the files it did before.
113
124
  if (opts.withCatalog === true)
@@ -147,6 +158,11 @@ export async function scaffold(opts) {
147
158
  // name already passed `parse-args.ts`, and `resolveTemplate` throws a
148
159
  // `TemplateError` for a programmatic caller that skipped the parser.
149
160
  const template = opts.template !== undefined ? resolveTemplate(opts.template) : undefined;
161
+ // #5618 D2 — the `extends` chain is resolved in the client registry before
162
+ // any fetch: an unknown parent or a cycle is refused here, with the target
163
+ // still non-existent, like the four refusals below.
164
+ if (template !== undefined)
165
+ resolveTemplateLineage(template.name);
150
166
  // The effective module set: union(template.requires, parsed --modules),
151
167
  // normalized through `normalizeModules` — the single ordering authority
152
168
  // (parent record D7). Order-independence falls out rather than being
@@ -195,18 +211,72 @@ export async function scaffold(opts) {
195
211
  // would gate correctly and emit the wrong surfaces, the wrong
196
212
  // `architecture.yaml` and the wrong nav — a silent wrong result, not a
197
213
  // failure.
198
- const result = scaffoldSync({ ...opts, modules });
214
+ //
215
+ // The template's roster ENABLES its surfaces (#5469 D10, the #5554 finding).
216
+ // A template's module-less rows (`/pricing`, `/privacy`, `/terms`) name
217
+ // `GATEABLE_SURFACES` entries no module contributes, and until this line
218
+ // `mergeRoster` was dead on this path: the content landed, the route
219
+ // directories were skipped, and the three pages 404ed. The merged roster's
220
+ // routes select their surface entries — matched on each surface's own
221
+ // `routes[]`, never on a new field — and that set rides `opts.surfaces`
222
+ // into `scaffoldSync`'s existing `resolveSurfaces` call, so the copied
223
+ // tree, the emitted `architecture.yaml` and the generated nav all follow.
224
+ // A scaffold naming no template takes no new statement (parent § D16): the
225
+ // roster-derived set is only ever computed when `template` is set.
226
+ const surfaces = template !== undefined
227
+ ? [...new Set([...(opts.surfaces ?? []), ...rosterSurfaces(template)])].sort()
228
+ : opts.surfaces;
229
+ const result = scaffoldSync({
230
+ ...opts,
231
+ modules,
232
+ ...(surfaces !== undefined ? { surfaces } : {}),
233
+ // #5612 D1/D2 — the code registry's own `layout` block, known
234
+ // synchronously (unlike a bundle's `ailk-template.json`, only readable
235
+ // after install below). A template declaring none resolves to the same
236
+ // defaults `scaffoldSync` already writes for a templateless scaffold.
237
+ ...(template?.layout !== undefined ? { layout: template.layout } : {}),
238
+ });
199
239
  // Threaded to extractModuleBundle for `package.json` patch targets'
200
240
  // `workspace:*` substitution (spec 5418 § 3 D2) — the same version map
201
241
  // `scaffoldSync` already reads for `copyTemplate`, re-read here since
202
242
  // `scaffold()` doesn't share `scaffoldSync`'s call frame.
203
243
  const versions = readVersionMap();
244
+ // #5618 D4 — the file-grain ownership registry for THIS scaffold: which
245
+ // layer wrote which destination. Call-scoped (a fresh Map per scaffold(),
246
+ // never module-level), threaded through every layer install below in the
247
+ // composition order base → modules → template. A module writing a path the
248
+ // base or an earlier module owns is refused before its first write unless
249
+ // it is a declared patch; the template, the customer's copy, may overwrite
250
+ // anything. Starts empty at the base-layer boundary: the `templates/`
251
+ // snapshot (layer 0) is deliberately outside it.
252
+ const ownership = new Map();
253
+ // The BASE LAYER lands first (#5618 D1) — the seed files every template
254
+ // reaches by `extends` (today the three module-less base pages), read off
255
+ // the `site-templates/_base/` tree this package ships, through the same
256
+ // `installBundleTree` the keyless Free path uses below. Inside the
257
+ // `template !== undefined` guard, so a scaffold naming no template
258
+ // executes no new statement — the keyless regression floor holds
259
+ // structurally, exactly as spec 5462 D16 argues for `rosterSurfaces` above.
260
+ // Never fetched: it ships in the npm tarball, so the Free path keeps making
261
+ // zero requests. Every module bundle and then the template land ON TOP of
262
+ // it, later layers winning file by file.
263
+ if (template !== undefined) {
264
+ installBundleTree({
265
+ sourceRoot: join(SITE_TEMPLATES_DIR, BASE_LAYER_DIR),
266
+ targetDir: result.targetDir,
267
+ name: BASE_LAYER_DIR,
268
+ templateRequires: [],
269
+ versions,
270
+ ownership,
271
+ });
272
+ }
204
273
  for (const [moduleName, bytes] of bundles) {
205
274
  await extractModuleBundle({
206
275
  bytes,
207
276
  targetDir: result.targetDir,
208
277
  moduleName,
209
278
  versions,
279
+ ownership,
210
280
  });
211
281
  }
212
282
  // The template lands LAST — after `scaffoldSync` and after every module
@@ -220,31 +290,76 @@ export async function scaffold(opts) {
220
290
  // One install step, two source roots (DD7): an extracted staging dir on the
221
291
  // paid path, `site-templates/<name>/` on disk on the Free one.
222
292
  if (template !== undefined) {
223
- if (templateBundle !== undefined) {
224
- await extractModuleBundle({
293
+ // #5612 D1/D2 — the INSTALLED template's own `ailk-template.json` layout
294
+ // (validated by `readAilkTemplateManifest`), if it declares one. Read
295
+ // only now: it lives in the bundle/tree, unreachable before install. When
296
+ // declared, it is authoritative over the code registry's `template.layout`
297
+ // already baked into `result`'s site-chrome.ts above — overwritten below.
298
+ // Absent (every shipped template today), the registry-resolved write from
299
+ // `scaffoldSync` stands untouched.
300
+ const installed = templateBundle !== undefined
301
+ ? await extractModuleBundle({
225
302
  bytes: templateBundle,
226
303
  targetDir: result.targetDir,
227
304
  moduleName: template.name,
228
305
  templateRequires: template.requires,
306
+ templateExtends: template.extends,
229
307
  versions,
230
- });
231
- }
232
- else {
233
- const sourceRoot = join(SITE_TEMPLATES_DIR, template.name);
234
- if (!existsSync(sourceRoot)) {
235
- throw new Error(`template "${template.name}": ${sourceRoot} is missing — the published package must carry site-templates/ in its "files" list`);
236
- }
237
- installBundleTree({
238
- sourceRoot,
239
- targetDir: result.targetDir,
240
- name: template.name,
241
- templateRequires: template.requires,
242
- versions,
243
- });
308
+ ownership,
309
+ })
310
+ : (() => {
311
+ const sourceRoot = join(SITE_TEMPLATES_DIR, template.name);
312
+ if (!existsSync(sourceRoot)) {
313
+ throw new Error(`template "${template.name}": ${sourceRoot} is missing — the published package must carry site-templates/ in its "files" list`);
314
+ }
315
+ return installBundleTree({
316
+ sourceRoot,
317
+ targetDir: result.targetDir,
318
+ name: template.name,
319
+ templateRequires: template.requires,
320
+ templateExtends: template.extends,
321
+ versions,
322
+ ownership,
323
+ });
324
+ })();
325
+ if (installed.layout !== undefined) {
326
+ writeFileSync(join(result.targetDir, SITE_CHROME_PATH), renderSiteChrome(resolveSiteChrome(installed.layout)));
244
327
  }
245
328
  }
246
329
  return result;
247
330
  }
331
+ /**
332
+ * The MODULE-LESS gateable-surface ids a template's merged roster names
333
+ * (#5469 D10): every `GATEABLE_SURFACES` entry no module contributes
334
+ * (`MODULE_SURFACES`) one of whose `routes[]` directories is the route
335
+ * directory of a merged roster row. Module-owned surfaces are left to the
336
+ * module set — a module's own rows (docs-blog's `/blog`) already enable
337
+ * theirs through `resolveSurfaces`, and the Free `/blog` teaser row must NOT
338
+ * enable the docs-blog `blog` surface: keyless there is no `blog/` route
339
+ * directory to copy, and the teaser renders through `[slug]` (D3).
340
+ *
341
+ * Merged over the template's OWN `requires`, not the scaffold's full module
342
+ * set: a module-less surface can only ever come from the template's base
343
+ * rows, and merging the Free template with an ADDED docs-blog would make
344
+ * `mergeRoster` refuse `/blog` (the teaser and the module's listing both
345
+ * claim it — parent § D8). That composition is legal at the route level
346
+ * precisely because the module's `blog/` directory takes precedence over
347
+ * the `[slug]` teaser, so it is resolved there, never in the registry.
348
+ *
349
+ * Route → directory: a roster route is site-relative (`/pricing`,
350
+ * `/flows/[slug]/start`); a surface owns template-relative route directories
351
+ * (`apps/web/app/[locale]/pricing`). A row matches a surface when its first
352
+ * path segment is the surface's top-level route directory.
353
+ */
354
+ function rosterSurfaces(template) {
355
+ const routeRoot = "apps/web/app/[locale]";
356
+ const moduleOwned = new Set(Object.values(MODULE_SURFACES).flat());
357
+ const topSegments = new Set(mergeRoster(template, template.requires)
358
+ .map((row) => row.route.split("/").filter(Boolean)[0])
359
+ .filter((segment) => segment !== undefined));
360
+ return GATEABLE_SURFACES.filter((surface) => !moduleOwned.has(surface.id) &&
361
+ surface.routes.some((dir) => topSegments.has(dir.slice(routeRoot.length + 1)))).map((surface) => surface.id);
362
+ }
248
363
  // Re-exported so a consumer of the async `scaffold()` above can type its own
249
364
  // wrapper (e.g. the MCP scaffold-site tool) without reaching into `lib/`.
250
365
  export { LicenseRequiredError, } from "./lib/module-license-gate.js";
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Template layout knobs (#5612) — the resolved chrome facts a scaffold's
3
+ * `apps/web/app/[locale]/layout.tsx` reads to mount Nav/Footer/Banner/
4
+ * MarketingShell: `nav` (full nav links vs. brand-mark-only), `footer`
5
+ * (columns vs. the existing centered variant), `announcementBanner` (mount
6
+ * the home-only announcement Banner or not) and `home` (the default
7
+ * document-scroll home vs. the scroll-snap deck, #5270/#5426).
8
+ *
9
+ * A site template is content + an `installMap`, never a route/layout/`.ts`
10
+ * file (spec 5462 D9/D16) — these are SITE-LEVEL facts a template DECLARES
11
+ * (on `TemplateManifest.layout` in the code registry, or on a bundle's
12
+ * `ailk-template.json` `layout` field), never a file it ships. This module is
13
+ * the single place that declares the vocabulary, fills defaults, validates an
14
+ * untrusted (bundle-sourced) value, and renders the generated
15
+ * `apps/web/lib/site-chrome.ts` every scaffold gets — beside `nav-config.ts`,
16
+ * the same GENERATED-header idiom (`surfaces.ts` `renderNavConfig`).
17
+ *
18
+ * Every field is optional and DEFAULTS to today's render (`full` / `columns`
19
+ * / `true` / `default`) — a scaffold naming no template, or a template naming
20
+ * no `layout` block, gets these defaults written, byte-identical to the
21
+ * monorepo's own committed `apps/web/lib/site-chrome.ts`.
22
+ */
23
+ /** The `nav` knob's vocabulary. `mark-only` → the layout passes Nav an empty
24
+ * `navItems` (brand mark + the existing actions/theme/locale controls only). */
25
+ export type SiteChromeNav = "full" | "mark-only";
26
+ /** The `footer` knob's vocabulary. `centered` → `Footer layout="centered"`
27
+ * (the existing third variant: nav row + social row + FooterBottomBar). */
28
+ export type SiteChromeFooter = "columns" | "centered";
29
+ /** The `home` knob's vocabulary. `deck` → the locale-root route mounts
30
+ * MarketingShell with `snap="mandatory"` + `deckHero` (spec 5426 D7). */
31
+ export type SiteChromeHome = "default" | "deck";
32
+ /**
33
+ * A template's declared layout block — every field optional. Lives on
34
+ * `TemplateManifest.layout` (the code registry, `template-manifest.ts`) and
35
+ * on a template bundle's `ailk-template.json` `layout` field, same shape.
36
+ */
37
+ export interface TemplateLayout {
38
+ nav?: SiteChromeNav;
39
+ footer?: SiteChromeFooter;
40
+ announcementBanner?: boolean;
41
+ home?: SiteChromeHome;
42
+ }
43
+ /** `TemplateLayout` with every field resolved — what `site-chrome.ts` exports. */
44
+ export interface ResolvedSiteChrome {
45
+ nav: SiteChromeNav;
46
+ footer: SiteChromeFooter;
47
+ announcementBanner: boolean;
48
+ home: SiteChromeHome;
49
+ }
50
+ /** Byte-identical-to-today defaults (#5612 D1). */
51
+ export declare const DEFAULT_SITE_CHROME: ResolvedSiteChrome;
52
+ /** Template-relative path of the generated site-chrome registry — beside `NAV_CONFIG_PATH`. */
53
+ export declare const SITE_CHROME_PATH = "apps/web/lib/site-chrome.ts";
54
+ /** Thrown by `validateTemplateLayout` on an unrecognized value, naming the field. */
55
+ export declare class TemplateLayoutError extends Error {
56
+ constructor(message: string);
57
+ }
58
+ /**
59
+ * Validate an UNTRUSTED `layout` value (a bundle's `ailk-template.json`
60
+ * `layout` field — arbitrary JSON, never compile-time checked the way the
61
+ * code registry's `TemplateManifest.layout` is) against the vocabulary above.
62
+ * Refuses on the first unrecognized field value, naming the field and the
63
+ * value (#5612 D1). `undefined`/`null` is valid (no block declared); a
64
+ * non-object value, or a recognized field holding the wrong TYPE, is refused
65
+ * the same way as an unrecognized enum value — both are "not a value this
66
+ * field accepts".
67
+ *
68
+ * @param subject Caller-supplied context for the error message (e.g. `template "foo"`).
69
+ */
70
+ export declare function validateTemplateLayout(value: unknown, subject: string): TemplateLayout;
71
+ /** Fill every field a `TemplateLayout` leaves unset with `DEFAULT_SITE_CHROME`'s value. */
72
+ export declare function resolveSiteChrome(layout?: TemplateLayout): ResolvedSiteChrome;
73
+ /**
74
+ * Render `apps/web/lib/site-chrome.ts` for a resolved layout — the same
75
+ * GENERATED-header idiom `renderNavConfig` uses. `layout.tsx` imports
76
+ * `siteChrome` to decide Nav's `navItems`, Footer's `layout` prop, whether
77
+ * the announcement Banner mounts, and whether the locale-root route passes
78
+ * `snap="mandatory"` + `deckHero` to MarketingShell.
79
+ */
80
+ export declare function renderSiteChrome(resolved: ResolvedSiteChrome): string;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Template layout knobs (#5612) — the resolved chrome facts a scaffold's
3
+ * `apps/web/app/[locale]/layout.tsx` reads to mount Nav/Footer/Banner/
4
+ * MarketingShell: `nav` (full nav links vs. brand-mark-only), `footer`
5
+ * (columns vs. the existing centered variant), `announcementBanner` (mount
6
+ * the home-only announcement Banner or not) and `home` (the default
7
+ * document-scroll home vs. the scroll-snap deck, #5270/#5426).
8
+ *
9
+ * A site template is content + an `installMap`, never a route/layout/`.ts`
10
+ * file (spec 5462 D9/D16) — these are SITE-LEVEL facts a template DECLARES
11
+ * (on `TemplateManifest.layout` in the code registry, or on a bundle's
12
+ * `ailk-template.json` `layout` field), never a file it ships. This module is
13
+ * the single place that declares the vocabulary, fills defaults, validates an
14
+ * untrusted (bundle-sourced) value, and renders the generated
15
+ * `apps/web/lib/site-chrome.ts` every scaffold gets — beside `nav-config.ts`,
16
+ * the same GENERATED-header idiom (`surfaces.ts` `renderNavConfig`).
17
+ *
18
+ * Every field is optional and DEFAULTS to today's render (`full` / `columns`
19
+ * / `true` / `default`) — a scaffold naming no template, or a template naming
20
+ * no `layout` block, gets these defaults written, byte-identical to the
21
+ * monorepo's own committed `apps/web/lib/site-chrome.ts`.
22
+ */
23
+ /** Byte-identical-to-today defaults (#5612 D1). */
24
+ export const DEFAULT_SITE_CHROME = {
25
+ nav: "full",
26
+ footer: "columns",
27
+ announcementBanner: true,
28
+ home: "default",
29
+ };
30
+ /** Template-relative path of the generated site-chrome registry — beside `NAV_CONFIG_PATH`. */
31
+ export const SITE_CHROME_PATH = "apps/web/lib/site-chrome.ts";
32
+ /** Thrown by `validateTemplateLayout` on an unrecognized value, naming the field. */
33
+ export class TemplateLayoutError extends Error {
34
+ constructor(message) {
35
+ super(message);
36
+ this.name = "TemplateLayoutError";
37
+ }
38
+ }
39
+ const NAV_VALUES = ["full", "mark-only"];
40
+ const FOOTER_VALUES = ["columns", "centered"];
41
+ const HOME_VALUES = ["default", "deck"];
42
+ /**
43
+ * Validate an UNTRUSTED `layout` value (a bundle's `ailk-template.json`
44
+ * `layout` field — arbitrary JSON, never compile-time checked the way the
45
+ * code registry's `TemplateManifest.layout` is) against the vocabulary above.
46
+ * Refuses on the first unrecognized field value, naming the field and the
47
+ * value (#5612 D1). `undefined`/`null` is valid (no block declared); a
48
+ * non-object value, or a recognized field holding the wrong TYPE, is refused
49
+ * the same way as an unrecognized enum value — both are "not a value this
50
+ * field accepts".
51
+ *
52
+ * @param subject Caller-supplied context for the error message (e.g. `template "foo"`).
53
+ */
54
+ export function validateTemplateLayout(value, subject) {
55
+ if (value === undefined || value === null)
56
+ return {};
57
+ if (typeof value !== "object" || Array.isArray(value)) {
58
+ throw new TemplateLayoutError(`${subject}: "layout" must be an object, got ${JSON.stringify(value)}`);
59
+ }
60
+ const raw = value;
61
+ const result = {};
62
+ if ("nav" in raw && raw.nav !== undefined) {
63
+ if (!NAV_VALUES.includes(raw.nav)) {
64
+ throw new TemplateLayoutError(`${subject}: layout.nav must be one of ${NAV_VALUES.join(", ")} — got ${JSON.stringify(raw.nav)}`);
65
+ }
66
+ result.nav = raw.nav;
67
+ }
68
+ if ("footer" in raw && raw.footer !== undefined) {
69
+ if (!FOOTER_VALUES.includes(raw.footer)) {
70
+ throw new TemplateLayoutError(`${subject}: layout.footer must be one of ${FOOTER_VALUES.join(", ")} — got ${JSON.stringify(raw.footer)}`);
71
+ }
72
+ result.footer = raw.footer;
73
+ }
74
+ if ("announcementBanner" in raw && raw.announcementBanner !== undefined) {
75
+ if (typeof raw.announcementBanner !== "boolean") {
76
+ throw new TemplateLayoutError(`${subject}: layout.announcementBanner must be a boolean — got ${JSON.stringify(raw.announcementBanner)}`);
77
+ }
78
+ result.announcementBanner = raw.announcementBanner;
79
+ }
80
+ if ("home" in raw && raw.home !== undefined) {
81
+ if (!HOME_VALUES.includes(raw.home)) {
82
+ throw new TemplateLayoutError(`${subject}: layout.home must be one of ${HOME_VALUES.join(", ")} — got ${JSON.stringify(raw.home)}`);
83
+ }
84
+ result.home = raw.home;
85
+ }
86
+ return result;
87
+ }
88
+ /** Fill every field a `TemplateLayout` leaves unset with `DEFAULT_SITE_CHROME`'s value. */
89
+ export function resolveSiteChrome(layout) {
90
+ return {
91
+ nav: layout?.nav ?? DEFAULT_SITE_CHROME.nav,
92
+ footer: layout?.footer ?? DEFAULT_SITE_CHROME.footer,
93
+ announcementBanner: layout?.announcementBanner ?? DEFAULT_SITE_CHROME.announcementBanner,
94
+ home: layout?.home ?? DEFAULT_SITE_CHROME.home,
95
+ };
96
+ }
97
+ /**
98
+ * Render `apps/web/lib/site-chrome.ts` for a resolved layout — the same
99
+ * GENERATED-header idiom `renderNavConfig` uses. `layout.tsx` imports
100
+ * `siteChrome` to decide Nav's `navItems`, Footer's `layout` prop, whether
101
+ * the announcement Banner mounts, and whether the locale-root route passes
102
+ * `snap="mandatory"` + `deckHero` to MarketingShell.
103
+ */
104
+ export function renderSiteChrome(resolved) {
105
+ return `// site-chrome.ts — resolved template layout knobs.
106
+ //
107
+ // GENERATED by create-ailk from the declared template's \`layout\` block
108
+ // (#5612). A scaffold with no template, or a template with no \`layout\`
109
+ // block, gets these defaults — byte-identical to every scaffold before this
110
+ // file existed. \`apps/web/app/[locale]/layout.tsx\` reads this to decide:
111
+ // - nav: "mark-only" → Nav renders the brand mark + right-hand controls
112
+ // only (no nav links).
113
+ // - footer: "centered" → Footer renders its existing centered variant.
114
+ // - announcementBanner: false → the home-only announcement Banner is not
115
+ // mounted (the cookie-consent Banner is unaffected — it is not an
116
+ // announcement).
117
+ // - home: "deck" → the locale-root route mounts MarketingShell with
118
+ // snap="mandatory" + deckHero (spec 5426 D7); every other route is
119
+ // unaffected.
120
+
121
+ export interface SiteChrome {
122
+ nav: "full" | "mark-only";
123
+ footer: "columns" | "centered";
124
+ announcementBanner: boolean;
125
+ home: "default" | "deck";
126
+ }
127
+
128
+ export const siteChrome: SiteChrome = {
129
+ nav: ${JSON.stringify(resolved.nav)},
130
+ footer: ${JSON.stringify(resolved.footer)},
131
+ announcementBanner: ${JSON.stringify(resolved.announcementBanner)},
132
+ home: ${JSON.stringify(resolved.home)},
133
+ };
134
+ `;
135
+ }
@@ -51,6 +51,12 @@ export interface Surface {
51
51
  export declare const GATEABLE_SURFACES: readonly Surface[];
52
52
  /** The recognized `--surfaces` vocabulary, sorted. */
53
53
  export declare const RECOGNIZED_SURFACES: readonly string[];
54
+ /**
55
+ * Retired surface ids, kept for ONE release (#5563). Removal rides the same
56
+ * trigger as the `/schedule` legacyRoutes entries in routes-map.ts — see the
57
+ * comment on that row. Resolving through an alias prints a deprecation notice.
58
+ */
59
+ export declare const SURFACE_ALIASES: Readonly<Record<string, string>>;
54
60
  /**
55
61
  * Surfaces each recognized module contributes.
56
62
  *
@@ -176,12 +182,21 @@ export declare const NAV_CONFIG_PATH = "apps/web/lib/nav-config.ts";
176
182
  * exists: two dead links, sitewide, by default. So the nav follows the declared
177
183
  * surface set for the same reason the route tree does.
178
184
  *
179
- * Only surfaces carrying a `navLabelKey` are linked, because a nav entry needs
180
- * a translated label and the template's `nav` namespace ships keys for exactly
181
- * `home`, `docs`, and `blog`. Emitting an entry whose `labelKey` has no
182
- * translation would trade a dead link for a missing-message error.
185
+ * The base items are the Free marketing-site chrome (#5469 D5, the approved
186
+ * chrome diff recorded on that card):
187
+ * a Product flyout (How it works · Features · FAQ), then How it works, Pricing
188
+ * and About. Every one of those pages is in EVERY scaffold — the four core
189
+ * routes plus the `[slug]` route over the overlay's LandingPage MDX — except
190
+ * Pricing, which is a gateable surface: its item is emitted only when the
191
+ * `pricing` surface is enabled (a template roster enables it, #5469 D10), so
192
+ * the nav still never links a page the scaffold does not carry.
193
+ *
194
+ * Only gateable surfaces carrying a `navLabelKey` are linked after the base
195
+ * items, because a nav entry needs a translated label. Emitting an entry whose
196
+ * `labelKey` has no translation would trade a dead link for a missing-message
197
+ * error.
183
198
  *
184
- * Deterministic: entries follow GATEABLE_SURFACES order (alphabetical by id),
185
- * after the always-present home entry.
199
+ * Deterministic: gateable entries follow GATEABLE_SURFACES order (alphabetical
200
+ * by id), after the fixed base items.
186
201
  */
187
202
  export declare function renderNavConfig(enabled: string[]): string;