create-ailk 0.3.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 (155) hide show
  1. package/component-catalog.json +11939 -0
  2. package/component-catalog.md +176 -64
  3. package/dist/cli.js +48 -10
  4. package/dist/derive-architecture.js +5 -2
  5. package/dist/lib/extract-module-bundle.d.ts +88 -1
  6. package/dist/lib/extract-module-bundle.js +375 -80
  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.d.ts +20 -0
  11. package/dist/lib/fetch-template.js +133 -0
  12. package/dist/lib/module-license-gate.d.ts +8 -0
  13. package/dist/lib/module-license-gate.js +12 -4
  14. package/dist/module-architecture.d.ts +116 -7
  15. package/dist/module-architecture.js +465 -94
  16. package/dist/parse-args.d.ts +8 -1
  17. package/dist/parse-args.js +49 -2
  18. package/dist/programmatic.d.ts +36 -1
  19. package/dist/programmatic.js +216 -5
  20. package/dist/site-chrome.d.ts +80 -0
  21. package/dist/site-chrome.js +135 -0
  22. package/dist/surfaces.d.ts +21 -6
  23. package/dist/surfaces.js +140 -23
  24. package/dist/template-manifest.d.ts +144 -0
  25. package/dist/template-manifest.js +337 -0
  26. package/dist/template-names.d.ts +41 -0
  27. package/dist/template-names.js +45 -0
  28. package/package.json +10 -2
  29. package/site-templates/_base/ailk-template.json +6 -0
  30. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  31. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +79 -0
  32. package/site-templates/_base/content/en/TermsPage/terms.mdx +80 -0
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +331 -0
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +393 -0
  35. package/site-templates/marketing-site/ailk-template.json +11 -0
  36. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  37. package/site-templates/marketing-site/provenance.json +4 -0
  38. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  39. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +285 -0
  40. package/site-templates/marketing-site-free/ailk-template.json +9 -0
  41. package/site-templates/marketing-site-free/provenance.json +4 -0
  42. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +271 -0
  43. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +324 -0
  44. package/site-templates/minimalism-site/ailk-template.json +21 -0
  45. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  46. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  47. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  48. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  49. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  50. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  51. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  52. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  53. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  54. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  55. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  56. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  57. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  58. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +214 -0
  59. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  60. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  61. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  62. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  63. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  64. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  65. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  66. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  67. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  68. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  69. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  70. package/site-templates/minimalism-site/provenance.json +4 -0
  71. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  72. package/site-templates/saas-product/ailk-template.json +16 -0
  73. package/site-templates/saas-product/provenance.json +10 -0
  74. package/templates/.claude/skills/README.md +16 -25
  75. package/templates/.env.example +32 -7
  76. package/templates/.github/workflows/ci.yml +48 -0
  77. package/templates/README.md +3 -1
  78. package/templates/apps/api/CLAUDE.md +4 -1
  79. package/templates/apps/api/eslint.config.mjs +69 -0
  80. package/templates/apps/api/package.json +6 -2
  81. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +6 -2
  82. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  83. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +173 -0
  84. package/templates/apps/api/src/config/modules.ts +1 -1
  85. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  86. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +339 -0
  87. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +108 -0
  88. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  89. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  90. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  91. package/templates/apps/api/src/server.ts +122 -3
  92. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  93. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  94. package/templates/apps/api/src/services/default-tenant.ts +261 -0
  95. package/templates/apps/api/src/services/lead-promotion.ts +161 -0
  96. package/templates/apps/api/src/services/leads-inbox.ts +263 -0
  97. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  98. package/templates/apps/mcp/CLAUDE.md +1 -1
  99. package/templates/apps/mcp/__tests__/parity.test.ts +2 -2
  100. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  101. package/templates/apps/mcp/eslint.config.mjs +69 -0
  102. package/templates/apps/mcp/package.json +6 -2
  103. package/templates/apps/mcp/src/server.ts +2 -2
  104. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  105. package/templates/apps/mcp/src/tools/index.ts +2 -2
  106. package/templates/apps/web/.env.example +3 -3
  107. package/templates/apps/web/CLAUDE.md +2 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +4 -6
  109. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  111. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  112. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  113. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  115. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  116. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  117. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  118. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  119. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  120. package/templates/apps/web/app/[locale]/layout.tsx +214 -17
  121. package/templates/apps/web/app/[locale]/pricing/page.tsx +40 -0
  122. package/templates/apps/web/app/[locale]/privacy/page.tsx +40 -0
  123. package/templates/apps/web/app/[locale]/terms/page.tsx +40 -0
  124. package/templates/apps/web/components/google-analytics.tsx +18 -1
  125. package/templates/apps/web/eslint.config.mjs +69 -0
  126. package/templates/apps/web/jest.config.cjs +12 -15
  127. package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
  128. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +32 -5
  129. package/templates/apps/web/lib/nav-config.ts +27 -7
  130. package/templates/apps/web/lib/server-api.ts +29 -13
  131. package/templates/apps/web/lib/site-chrome.ts +29 -0
  132. package/templates/apps/web/lib/telemetry-scrub.ts +46 -0
  133. package/templates/apps/web/messages/en.json +86 -1
  134. package/templates/apps/web/next.config.mjs +20 -0
  135. package/templates/apps/web/package.json +6 -2
  136. package/templates/apps/web/sentry.client.config.ts +131 -0
  137. package/templates/apps/web/sentry.edge.config.ts +72 -0
  138. package/templates/apps/web/sentry.server.config.ts +72 -0
  139. package/templates/apps/web/src/lib/routes.ts +13 -18
  140. package/templates/brand_assets/README.md +24 -0
  141. package/templates/content/en/AboutPage/about.mdx +160 -15
  142. package/templates/content/en/ContactPage/contact.mdx +90 -13
  143. package/templates/content/en/FAQPage/faq.mdx +62 -22
  144. package/templates/content/en/HomePage/home.mdx +244 -20
  145. package/templates/content/en/LandingPage/blog.mdx +83 -0
  146. package/templates/content/en/LandingPage/features.mdx +103 -0
  147. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  148. package/templates/database/CHANGELOG.md +21 -0
  149. package/templates/database/__tests__/seed.test.ts +1 -1
  150. package/templates/database/eslint.config.mjs +69 -0
  151. package/templates/database/package.json +7 -3
  152. package/templates/database/seed.ts +1 -1
  153. package/templates/package.json +1 -14
  154. package/versions.json +52 -0
  155. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -7,12 +7,19 @@
7
7
  * composition is downstream work, so the default (`marketing`) reproduces
8
8
  * today's fixed-tree scaffold exactly.
9
9
  */
10
- export declare const RECOGNIZED_MODULES: readonly ["marketing", "docs-blog", "multi-tenant", "scheduling", "signal-collectors", "commerce"];
10
+ export declare const RECOGNIZED_MODULES: readonly ["marketing", "docs-blog", "multi-tenant", "workspace", "scheduling", "signal-collectors", "commerce"];
11
11
  export type ModuleName = (typeof RECOGNIZED_MODULES)[number];
12
12
  export interface CliOptions {
13
13
  /** Positional target directory (the dir to scaffold into). Optional only
14
14
  * when `help` or `version` is set. */
15
15
  target: string | undefined;
16
+ /**
17
+ * The site template to install (`--template`), validated against
18
+ * `RECOGNIZED_TEMPLATES` at parse time. Undefined when the flag is absent —
19
+ * and a run naming no template scaffolds exactly what it did before the
20
+ * flag existed (spec 5481 § 3 DD3).
21
+ */
22
+ template: string | undefined;
16
23
  install: boolean;
17
24
  help: boolean;
18
25
  version: boolean;
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { parseArgs } from "node:util";
11
11
  import { RECOGNIZED_SURFACES, parseSurfaces } from "./surfaces.js";
12
+ import { RECOGNIZED_TEMPLATES } from "./template-names.js";
12
13
  /**
13
14
  * The recognized module vocabulary for the `--modules` selector.
14
15
  *
@@ -22,6 +23,7 @@ export const RECOGNIZED_MODULES = [
22
23
  "marketing",
23
24
  "docs-blog",
24
25
  "multi-tenant",
26
+ "workspace",
25
27
  "scheduling",
26
28
  "signal-collectors",
27
29
  "commerce",
@@ -51,9 +53,23 @@ Options:
51
53
  Defaults to \`marketing\`, which reproduces today's
52
54
  scaffold. A module is a composable unit across the
53
55
  three tiers (data · API · presentation).
56
+ --template <name> The site template to install (recognized:
57
+ ${RECOGNIZED_TEMPLATES.join(", ")}).
58
+ A template is a page roster plus seed content and
59
+ config — never code. Its required modules are ADDED
60
+ to whatever \`--modules\` names, so the two compose.
61
+ A template needing only free modules installs from
62
+ the tree this package ships, with no key and no
63
+ network call; one needing a paid module needs
64
+ AILK_LICENSE_KEY to cover every module it requires,
65
+ and refuses naming whichever it does not.
54
66
  --surfaces <csv> Comma-separated route surfaces to scaffold IN
55
- ADDITION to the core set (/, /about, /contact, /faq,
56
- /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(", ")}.
57
73
  Surfaces the declared architecture does not carry are
58
74
  NOT scaffolded — their routes are absent, so a fresh
59
75
  site never serves a stock page with placeholder
@@ -102,6 +118,7 @@ export function parseCliArgs(argv) {
102
118
  allowPositionals: true,
103
119
  options: {
104
120
  modules: { type: "string" },
121
+ template: { type: "string" },
105
122
  surfaces: { type: "string" },
106
123
  "sync-architecture": { type: "string" },
107
124
  "sync-catalog": { type: "boolean", default: false },
@@ -119,6 +136,7 @@ export function parseCliArgs(argv) {
119
136
  const version = parsed.values.version === true;
120
137
  const install = parsed.values.install === true;
121
138
  const modules = parseModules(parsed.values.modules);
139
+ const template = parseTemplate(parsed.values.template);
122
140
  const surfaces = parseSurfaceList(parsed.values.surfaces);
123
141
  const syncArchitecture = parsed.values["sync-architecture"];
124
142
  const syncCatalog = parsed.values["sync-catalog"] === true;
@@ -138,6 +156,7 @@ export function parseCliArgs(argv) {
138
156
  if (help || version) {
139
157
  return {
140
158
  target: undefined,
159
+ template: undefined,
141
160
  install,
142
161
  help,
143
162
  version,
@@ -155,6 +174,7 @@ export function parseCliArgs(argv) {
155
174
  }
156
175
  return {
157
176
  target: parsed.positionals[0],
177
+ template,
158
178
  install,
159
179
  help,
160
180
  version,
@@ -173,6 +193,7 @@ export function parseCliArgs(argv) {
173
193
  }
174
194
  return {
175
195
  target: parsed.positionals[0],
196
+ template,
176
197
  install,
177
198
  help,
178
199
  version,
@@ -183,6 +204,32 @@ export function parseCliArgs(argv) {
183
204
  syncArchitecture: undefined,
184
205
  };
185
206
  }
207
+ /**
208
+ * Validate the `--template` value against the declared template set
209
+ * (spec 5481 § 3 DD1).
210
+ *
211
+ * Validated HERE, in the parser, so an unrecognized template fails at parse
212
+ * time as a `ParseError` exactly like an unrecognized module or surface does.
213
+ * Splitting it out to `programmatic.ts` would make `--template` the one flag
214
+ * in this CLI whose bad value surfaces somewhere else.
215
+ *
216
+ * The names come from `template-names.ts` rather than from
217
+ * `TEMPLATE_MANIFESTS` itself, and that file's doc comment records the
218
+ * measured reason: importing the registry here closes a runtime cycle
219
+ * through `lib/module-tier.ts`. `TEMPLATE_MANIFESTS` remains the registry;
220
+ * the two sets are asserted equal by the suite.
221
+ *
222
+ * @throws ParseError on an undeclared template name, naming the declared set.
223
+ */
224
+ function parseTemplate(raw) {
225
+ if (raw === undefined)
226
+ return undefined;
227
+ const name = raw.trim();
228
+ if (!RECOGNIZED_TEMPLATES.includes(name)) {
229
+ throw new ParseError(`unknown template "${raw}". Recognized: ${RECOGNIZED_TEMPLATES.join(", ")}`);
230
+ }
231
+ return name;
232
+ }
186
233
  /**
187
234
  * Parse + validate the `--surfaces` CSV, re-throwing an unrecognized-surface
188
235
  * failure as a ParseError so every CLI parse failure reaches the orchestrator's
@@ -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. */
@@ -28,6 +29,21 @@ export interface ScaffoldOptions {
28
29
  * Ignored at runtime until named presets are implemented.
29
30
  */
30
31
  preset?: string;
32
+ /**
33
+ * The site template to install (`--template`), a `TEMPLATE_MANIFESTS` key.
34
+ *
35
+ * Consumed ONLY by the async `scaffold()` below. `scaffoldSync` stays
36
+ * template-oblivious for exactly the reason it stays licensing-oblivious
37
+ * (spec 5481 § 3 DD3): a call naming no template takes a code path with no
38
+ * new statement in it, which is a stronger byte-identity guarantee than a
39
+ * test asserting the output happens to match.
40
+ *
41
+ * What a template DOES change is `modules` — `scaffold()` unions the
42
+ * template's `requires` with whatever `--modules` named and passes THAT
43
+ * list to `scaffoldSync` (DD2), so the resolved surfaces, the emitted
44
+ * `architecture.yaml` and the generated nav are all functions of the union.
45
+ */
46
+ template?: string;
31
47
  /**
32
48
  * Selected modules (recognized: marketing, docs-blog, multi-tenant,
33
49
  * scheduling). Defaults to
@@ -70,8 +86,19 @@ export interface ScaffoldOptions {
70
86
  licenseKey?: string;
71
87
  /** Overrides `AILK_MODULE_REGISTRY_URL` / the built-in module-distribution default. */
72
88
  moduleRegistryUrl?: string;
89
+ /** Overrides `AILK_TEMPLATE_REGISTRY_URL` / the built-in template-distribution default. */
90
+ templateRegistryUrl?: string;
73
91
  /** Injected fetch for tests. Never a live network call in CI or in this repo's evidence runs. */
74
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;
75
102
  }
76
103
  export interface ScaffoldResult {
77
104
  /** Resolved absolute path to the scaffolded directory. */
@@ -98,9 +125,17 @@ export declare function scaffoldSync(opts: ScaffoldOptions): ScaffoldResult;
98
125
  * read and no network call (the regression floor). A paid module with
99
126
  * no key, or a licence the module distribution rejects, throws
100
127
  * `LicenseRequiredError` HERE — before anything is written to disk.
101
- * 3. `scaffoldSync` — unchanged, today's local `templates/` copy.
128
+ * 3. `scaffoldSync` — unchanged, today's local `templates/` copy, over the
129
+ * EFFECTIVE module set (see 0 below).
102
130
  * 4. Merge each resolved paid module's fetched bundle into the freshly
103
131
  * scaffolded tree (`extractModuleBundle`).
132
+ * 5. Install the named site template LAST (#5481; parent record D9), from
133
+ * its fetched bundle or — when it requires only free modules — from the
134
+ * `site-templates/` tree this package ships.
135
+ *
136
+ * And, ahead of all five when `opts.template` is set (0): resolve the
137
+ * template and union its `requires` with `opts.modules`, so every step above
138
+ * runs over the EFFECTIVE module set rather than the requested one.
104
139
  *
105
140
  * `scaffoldSync` itself stays licensing-oblivious on purpose: every existing
106
141
  * synchronous caller (this package's own test suite, overwhelmingly) is
@@ -21,15 +21,27 @@ import { fileURLToPath } from "node:url";
21
21
  import { CATALOG_TARGET_PATH, writeComponentCatalog, } from "./component-catalog.js";
22
22
  import { copyTemplate } from "./copy.js";
23
23
  import { deriveArchitecture } from "./derive-architecture.js";
24
- import { extractModuleBundle } from "./lib/extract-module-bundle.js";
24
+ import { extractModuleBundle, installBundleTree, } from "./lib/extract-module-bundle.js";
25
+ import { fetchTemplate } from "./lib/fetch-template.js";
25
26
  import { resolvePaidModules } from "./lib/module-license-gate.js";
26
27
  import { classifyModules } from "./lib/module-tier.js";
27
28
  import { normalizeModules } from "./module-architecture.js";
28
- 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";
29
31
  import { syncRoutes } from "./sync-routes.js";
32
+ import { BASE_LAYER_DIR, mergeRoster, resolveTemplate, resolveTemplateLineage, templateDelivery, } from "./template-manifest.js";
30
33
  import { validateTarget } from "./validate.js";
31
34
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
32
35
  const TEMPLATES_DIR = resolve(PKG_ROOT, "templates");
36
+ /**
37
+ * Where a Free site template's files are read from on the keyless path — and
38
+ * why it is BESIDE `templates/` rather than inside it (parent record D14):
39
+ * `copyTemplate` copies the whole `templates/` tree into EVERY scaffold, so a
40
+ * per-template subtree there would land in scaffolds that asked for a
41
+ * different template, or for none. It reaches the published tarball through
42
+ * `package.json` `files` instead.
43
+ */
44
+ const SITE_TEMPLATES_DIR = resolve(PKG_ROOT, "site-templates");
33
45
  const VERSIONS_FILE = resolve(PKG_ROOT, "versions.json");
34
46
  function readVersionMap() {
35
47
  if (!existsSync(VERSIONS_FILE)) {
@@ -84,6 +96,10 @@ export function scaffoldSync(opts) {
84
96
  PROJECT_NAME: projectName,
85
97
  COMPANY_NAME: "Your Company",
86
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",
87
103
  },
88
104
  });
89
105
  // Derive + write the module-set-driven architecture.yaml (spec #3787). This
@@ -97,6 +113,12 @@ export function scaffoldSync(opts) {
97
113
  // the template's copy in place would link /docs and /blog sitewide in a
98
114
  // scaffold that carries neither route.
99
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)));
100
122
  // Opt-in only (#4636). Every write above this line is unconditional, so a
101
123
  // scaffold with `withCatalog` unset touches exactly the files it did before.
102
124
  if (opts.withCatalog === true)
@@ -114,9 +136,17 @@ export function scaffoldSync(opts) {
114
136
  * read and no network call (the regression floor). A paid module with
115
137
  * no key, or a licence the module distribution rejects, throws
116
138
  * `LicenseRequiredError` HERE — before anything is written to disk.
117
- * 3. `scaffoldSync` — unchanged, today's local `templates/` copy.
139
+ * 3. `scaffoldSync` — unchanged, today's local `templates/` copy, over the
140
+ * EFFECTIVE module set (see 0 below).
118
141
  * 4. Merge each resolved paid module's fetched bundle into the freshly
119
142
  * scaffolded tree (`extractModuleBundle`).
143
+ * 5. Install the named site template LAST (#5481; parent record D9), from
144
+ * its fetched bundle or — when it requires only free modules — from the
145
+ * `site-templates/` tree this package ships.
146
+ *
147
+ * And, ahead of all five when `opts.template` is set (0): resolve the
148
+ * template and union its `requires` with `opts.modules`, so every step above
149
+ * runs over the EFFECTIVE module set rather than the requested one.
120
150
  *
121
151
  * `scaffoldSync` itself stays licensing-oblivious on purpose: every existing
122
152
  * synchronous caller (this package's own test suite, overwhelmingly) is
@@ -124,31 +154,212 @@ export function scaffoldSync(opts) {
124
154
  * with a paid module in `opts.modules` exercises the gate at all.
125
155
  */
126
156
  export async function scaffold(opts) {
127
- const { paid } = classifyModules(normalizeModules(opts.modules ?? ["marketing"]));
157
+ // Resolved first, and by the registry rather than re-validated here: the
158
+ // name already passed `parse-args.ts`, and `resolveTemplate` throws a
159
+ // `TemplateError` for a programmatic caller that skipped the parser.
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);
166
+ // The effective module set: union(template.requires, parsed --modules),
167
+ // normalized through `normalizeModules` — the single ordering authority
168
+ // (parent record D7). Order-independence falls out rather than being
169
+ // engineered: it returns `RECOGNIZED_MODULES.filter(...)`, declaration-
170
+ // ordered and deduped, so the same SET in any argument order is the same
171
+ // list.
172
+ const modules = normalizeModules([
173
+ ...(template?.requires ?? []),
174
+ ...(opts.modules ?? ["marketing"]),
175
+ ]);
176
+ const { paid } = classifyModules(modules);
128
177
  const bundles = await resolvePaidModules({
129
178
  paidModules: paid,
130
179
  licenseKey: opts.licenseKey,
180
+ ...(template !== undefined ? { templateName: template.name } : {}),
131
181
  ...(opts.moduleRegistryUrl !== undefined
132
182
  ? { registryUrl: opts.moduleRegistryUrl }
133
183
  : {}),
134
184
  ...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
135
185
  });
136
- const result = scaffoldSync(opts);
186
+ // The template's OWN bundle, fetched here — before `scaffoldSync` — so the
187
+ // fourth refusal (the template's bundle unpublished, parent record D17 /
188
+ // spec 5481 § 3 DD5 row 4) throws with the target still non-existent, like
189
+ // the three module refusals above it (DD6). It is EXTRACTED last, below.
190
+ //
191
+ // The keyless path never reaches this at all: a template requiring only
192
+ // free modules classifies to an empty paid set, so `resolvePaidModules`
193
+ // returned at its first branch without reading `process.env` or the
194
+ // network, and `templateDelivery` sends us past this too (DD7). That is
195
+ // what makes record D11's no-network guarantee structural rather than
196
+ // merely likely.
197
+ const templateBundle = template !== undefined && templateDelivery(template) === "paid"
198
+ ? await fetchTemplate({
199
+ templateName: template.name,
200
+ token: opts.licenseKey ?? process.env["AILK_LICENSE_KEY"] ?? "",
201
+ ...(opts.templateRegistryUrl !== undefined
202
+ ? { registryUrl: opts.templateRegistryUrl }
203
+ : {}),
204
+ ...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
205
+ })
206
+ : undefined;
207
+ // `modules` — the union — is what `scaffoldSync` receives, not `opts`
208
+ // (spec 5481 § 3 DD2). `resolveSurfaces`, `deriveArchitecture` and
209
+ // `renderNavConfig` are all computed INSIDE `scaffoldSync` from
210
+ // `opts.modules`, so a union computed for the licence classification alone
211
+ // would gate correctly and emit the wrong surfaces, the wrong
212
+ // `architecture.yaml` and the wrong nav — a silent wrong result, not a
213
+ // failure.
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
+ });
137
239
  // Threaded to extractModuleBundle for `package.json` patch targets'
138
240
  // `workspace:*` substitution (spec 5418 § 3 D2) — the same version map
139
241
  // `scaffoldSync` already reads for `copyTemplate`, re-read here since
140
242
  // `scaffold()` doesn't share `scaffoldSync`'s call frame.
141
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
+ }
142
273
  for (const [moduleName, bytes] of bundles) {
143
274
  await extractModuleBundle({
144
275
  bytes,
145
276
  targetDir: result.targetDir,
146
277
  moduleName,
147
278
  versions,
279
+ ownership,
148
280
  });
149
281
  }
282
+ // The template lands LAST — after `scaffoldSync` and after every module
283
+ // bundle (parent record D9) — so its content and config files sit on top of
284
+ // everything derived. It shapes the derived artifacts by DECLARING (its
285
+ // `requires` are in the union above; its roster rows are what the nav is
286
+ // generated from), never by landing a code file: the nav is already written
287
+ // by `scaffoldSync` from `renderNavConfig(enabledSurfaces)`, and record
288
+ // §5.2 forbids a `.ts` in any `site-templates/` `installMap` destination.
289
+ //
290
+ // One install step, two source roots (DD7): an extracted staging dir on the
291
+ // paid path, `site-templates/<name>/` on disk on the Free one.
292
+ if (template !== undefined) {
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({
302
+ bytes: templateBundle,
303
+ targetDir: result.targetDir,
304
+ moduleName: template.name,
305
+ templateRequires: template.requires,
306
+ templateExtends: template.extends,
307
+ versions,
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)));
327
+ }
328
+ }
150
329
  return result;
151
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
+ }
152
363
  // Re-exported so a consumer of the async `scaffold()` above can type its own
153
364
  // wrapper (e.g. the MCP scaffold-site tool) without reaching into `lib/`.
154
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;