create-ailk 0.8.0 → 0.9.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 (211) hide show
  1. package/README.md +68 -0
  2. package/component-catalog.json +279 -45
  3. package/dist/add-module.d.ts +20 -0
  4. package/dist/add-module.js +43 -7
  5. package/dist/add-route.d.ts +56 -0
  6. package/dist/add-route.js +286 -0
  7. package/dist/apply-design-md.d.ts +7 -0
  8. package/dist/apply-design-md.js +50 -0
  9. package/dist/apply-design.d.ts +64 -0
  10. package/dist/apply-design.js +273 -0
  11. package/dist/claude-md.d.ts +111 -0
  12. package/dist/claude-md.js +334 -0
  13. package/dist/cli.js +92 -4
  14. package/dist/derive-architecture.js +41 -36
  15. package/dist/design-compile.d.ts +71 -0
  16. package/dist/design-compile.js +98 -0
  17. package/dist/design-package.d.ts +83 -0
  18. package/dist/design-package.js +468 -0
  19. package/dist/design-presets.d.ts +86 -0
  20. package/dist/design-presets.js +642 -0
  21. package/dist/design-scaffold.d.ts +76 -0
  22. package/dist/design-scaffold.js +220 -0
  23. package/dist/index.d.ts +2 -1
  24. package/dist/index.js +4 -1
  25. package/dist/legacy-nav-renderers.d.ts +45 -0
  26. package/dist/legacy-nav-renderers.js +545 -0
  27. package/dist/lib/auth-secret-file.d.ts +19 -0
  28. package/dist/lib/auth-secret-file.js +112 -0
  29. package/dist/lib/design-checks.d.ts +78 -0
  30. package/dist/lib/design-checks.js +174 -0
  31. package/dist/lib/license-key-files.d.ts +9 -0
  32. package/dist/lib/license-key-files.js +2 -2
  33. package/dist/lib/module-license-gate.d.ts +19 -9
  34. package/dist/lib/module-license-gate.js +19 -8
  35. package/dist/lib/render-verdict.d.ts +52 -0
  36. package/dist/lib/render-verdict.js +77 -0
  37. package/dist/lib/scaffold-render-sweep.d.ts +105 -0
  38. package/dist/lib/scaffold-render-sweep.js +236 -0
  39. package/dist/module-architecture.d.ts +19 -16
  40. package/dist/module-architecture.js +64 -21
  41. package/dist/parse-args.d.ts +12 -2
  42. package/dist/parse-args.js +74 -13
  43. package/dist/programmatic.d.ts +30 -6
  44. package/dist/programmatic.js +206 -17
  45. package/dist/site-chrome.d.ts +19 -9
  46. package/dist/site-chrome.js +23 -13
  47. package/dist/site-manifest.d.ts +25 -0
  48. package/dist/site-manifest.js +254 -0
  49. package/dist/site-roster.d.ts +41 -0
  50. package/dist/site-roster.js +141 -8
  51. package/dist/starter-grid.d.ts +32 -16
  52. package/dist/starter-grid.js +129 -33
  53. package/dist/surfaces.d.ts +41 -9
  54. package/dist/surfaces.js +200 -83
  55. package/dist/template-manifest.d.ts +30 -13
  56. package/dist/template-manifest.js +49 -24
  57. package/package.json +5 -4
  58. package/site-templates/_base/content/en/UseCase/persona-1.mdx +7 -6
  59. package/site-templates/_base/content/en/UseCase/persona-2.mdx +7 -6
  60. package/site-templates/_base/content/en/UseCase/persona-3.mdx +7 -6
  61. package/site-templates/{minimalism-site/TEMPLATE-MANIFEST.json → aliases/minimalism-site.TEMPLATE-MANIFEST.json} +72 -40
  62. package/site-templates/designs/minimalism/DESIGN.md +98 -6
  63. package/site-templates/designs/minimalism/sample/README.md +48 -0
  64. package/site-templates/designs/standard/DESIGN.md +98 -6
  65. package/site-templates/marketing-site/DESIGN.md +98 -6
  66. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +60 -27
  67. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +50 -30
  68. package/site-templates/marketing-site-free/DESIGN.md +98 -6
  69. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +60 -24
  70. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +44 -27
  71. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  72. package/site-templates/saas-product/DESIGN.md +98 -6
  73. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +127 -18
  74. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +74 -20
  75. package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
  76. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +127 -18
  77. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +74 -20
  78. package/site-templates/shapes/organization-service/ailk-template.json +1 -1
  79. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +127 -18
  80. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +75 -21
  81. package/site-templates/shapes/person-service/ailk-template.json +1 -1
  82. package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +1 -1
  83. package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +2 -2
  84. package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +4 -2
  85. package/site-templates/shapes/person-service/content/en/ServicesList/services.mdx +1 -1
  86. package/templates/.claude/rules/architecture.md +7 -0
  87. package/templates/.claude/skills/README.md +3 -2
  88. package/templates/.claude/skills/add-auth/references/env-vars.md +22 -15
  89. package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +3 -2
  90. package/templates/.claude/skills/add-page/SKILL.md +241 -238
  91. package/templates/.claude/skills/add-page/references/page-types.md +85 -76
  92. package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
  93. package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
  94. package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
  95. package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
  96. package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
  97. package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
  98. package/templates/.claude/skills/check-site/SKILL.md +23 -0
  99. package/templates/.claude/skills/fill-page/SKILL.md +26 -7
  100. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
  101. package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +28 -11
  102. package/templates/.claude/skills/update-page/SKILL.md +117 -0
  103. package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
  104. package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
  105. package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
  106. package/templates/.github/workflows/ci.yml +4 -0
  107. package/templates/.prettierignore +1 -0
  108. package/templates/CLAUDE.md +146 -103
  109. package/templates/DESIGN.md +98 -6
  110. package/templates/README.md +50 -38
  111. package/templates/apps/api/.env.example +2 -1
  112. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
  113. package/templates/apps/api/src/__tests__/server.test.ts +116 -0
  114. package/templates/apps/api/src/lib/stripe.ts +9 -0
  115. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
  116. package/templates/apps/api/src/middleware/auth.ts +54 -0
  117. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
  118. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
  119. package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
  120. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
  121. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
  122. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
  123. package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
  124. package/templates/apps/api/src/server.ts +28 -0
  125. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
  126. package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
  127. package/templates/apps/api/src/services/revalidation.ts +60 -15
  128. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
  129. package/templates/apps/mcp/src/tools/purchase.ts +4 -2
  130. package/templates/apps/web/.env.example +13 -0
  131. package/templates/apps/web/CLAUDE.md +22 -28
  132. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
  133. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
  134. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
  135. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
  136. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
  137. package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
  139. package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
  140. package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +4 -3
  141. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
  142. package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
  143. package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
  144. package/templates/apps/web/app/[locale]/layout.tsx +35 -19
  145. package/templates/apps/web/app/[locale]/newsletter/page.tsx +1 -1
  146. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/portfolio/page.tsx +1 -1
  148. package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
  149. package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
  150. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
  151. package/templates/apps/web/app/api/revalidate/route.ts +78 -36
  152. package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
  153. package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
  154. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
  155. package/templates/apps/web/lib/locale-guard.ts +18 -0
  156. package/templates/apps/web/lib/meet-placeholder.ts +5 -0
  157. package/templates/apps/web/lib/nav-config.ts +14 -13
  158. package/templates/apps/web/lib/site-brand.tsx +13 -9
  159. package/templates/apps/web/lib/site-logo.tsx +36 -0
  160. package/templates/apps/web/lib/site-theme.ts +46 -26
  161. package/templates/apps/web/messages/en.json +23 -9
  162. package/templates/apps/web/middleware.ts +28 -4
  163. package/templates/content/en/BookCall/book-a-call.mdx +80 -0
  164. package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
  165. package/templates/content/en/BookCall/onsite-service.mdx +56 -0
  166. package/templates/content/en/BookCall/start-project.mdx +56 -0
  167. package/templates/content/en/ContactPage/contact.mdx +40 -1
  168. package/templates/content/en/HomePage/home.mdx +2 -6
  169. package/templates/content/en/LandingPage/meet.mdx +59 -0
  170. package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
  171. package/templates/database/CHANGELOG.md +7 -0
  172. package/templates/database/package.json +1 -1
  173. package/templates/package.json +2 -1
  174. package/templates/tools/check-claude-md.ts +128 -0
  175. package/versions.json +33 -33
  176. package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -52
  177. package/site-templates/minimalism-site/DESIGN.md +0 -162
  178. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -458
  179. package/site-templates/minimalism-site/ailk-template.json +0 -23
  180. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
  181. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -52
  182. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
  183. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
  184. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
  185. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
  186. package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -123
  187. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
  188. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
  189. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
  190. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
  191. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +0 -38
  192. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +0 -38
  193. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +0 -38
  194. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +0 -73
  195. package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
  196. package/site-templates/minimalism-site/provenance.json +0 -4
  197. package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
  198. package/templates/apps/web/app/[locale]/book-a-call/page.tsx +0 -50
  199. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
  200. /package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +0 -0
  201. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
  202. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
  203. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
  204. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
  205. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
  206. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
  207. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
  208. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
  209. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
  210. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
  211. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
@@ -20,16 +20,21 @@
20
20
  * true of any declared palette. Deleting the `theme:` block still fails, on the
21
21
  * `toBeDefined()` below.
22
22
  */
23
- import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
24
24
  import { tmpdir } from "node:os";
25
25
  import { join } from "node:path";
26
26
 
27
- import { parse as parseYaml } from "yaml";
27
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
28
28
 
29
- import { PartialColorPaletteSchema } from "@working-theory/site-config/schema";
30
- import { siteColorTokenMap, themeToCssVarsBothModes } from "@working-theory/theme";
29
+ import { PartialColorPaletteSchema, SiteThemeSchema } from "@working-theory/site-config/schema";
30
+ import {
31
+ siteColorTokenMap,
32
+ siteThemeShapeCss,
33
+ siteThemeToBrandTokens,
34
+ themeToCssVarsBothModes,
35
+ } from "@working-theory/theme";
31
36
 
32
- import { loadSiteBrandTokens } from "../site-theme";
37
+ import { loadSiteBrandTokens, loadSiteShapeCss } from "../site-theme";
33
38
 
34
39
  const REPO_CONTENT_ROOT = join(__dirname, "..", "..", "..", "..", "content");
35
40
 
@@ -46,19 +51,28 @@ function darkBlock(css: string): string {
46
51
  /** The two modes, as the palette and the emitted CSS both name them. */
47
52
  const MODES = ["light", "dark"] as const;
48
53
 
54
+ type ModeColors = Partial<Record<(typeof MODES)[number], Record<string, string>>>;
55
+
49
56
  /**
50
- * The `theme.colors` block exactly as the `_site.mdx` under test declares it —
51
- * raw SLOT names (`text`, `primary`), not token keys. Read with the same
57
+ * The colours the `_site.mdx` under test declares — `theme.colors` with any
58
+ * `brand.colors` (the brand triad, #6820) layered over it per mode — as raw
59
+ * SLOT names (`text`, `primary`), not token keys. Read with the same
52
60
  * frontmatter parse `site-theme.ts` uses, so the test cannot pass against a
53
- * file the implementation reads differently.
61
+ * file the implementation reads differently, and derived from the layered
62
+ * result so a scaffold whose owner sets a triad stays green.
54
63
  */
55
- function declaredColors(): Partial<Record<(typeof MODES)[number], Record<string, string>>> {
64
+ function declaredColors(): ModeColors {
56
65
  const raw = readFileSync(join(REPO_CONTENT_ROOT, "_site.mdx"), "utf8");
57
66
  const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
58
67
  const parsed = parseYaml(frontmatter?.[1] ?? "") as
59
- | { theme?: { colors?: Record<string, Record<string, string>> } }
68
+ | { theme?: { colors?: ModeColors }; brand?: { colors?: ModeColors } }
60
69
  | null;
61
- return parsed?.theme?.colors ?? {};
70
+ const out: ModeColors = {};
71
+ for (const mode of MODES) {
72
+ const layered = { ...parsed?.theme?.colors?.[mode], ...parsed?.brand?.colors?.[mode] };
73
+ if (Object.keys(layered).length > 0) out[mode] = layered;
74
+ }
75
+ return out;
62
76
  }
63
77
 
64
78
  /**
@@ -210,6 +224,85 @@ describe("loadSiteBrandTokens — the real content/_site.mdx", () => {
210
224
  });
211
225
  });
212
226
 
227
+ /**
228
+ * The brand triad (#6820) — `brand.colors` / `brand.fonts` layered over the
229
+ * theme. The golden: a site with a theme and no triad emits exactly the CSS
230
+ * the pre-triad formula emitted; a site with a triad carries its fonts and
231
+ * primary into both mode blocks.
232
+ */
233
+ describe("loadSiteBrandTokens — the brand triad", () => {
234
+ const originalRoot = process.env.AILK_CONTENT_ROOT;
235
+ const tempRoots: string[] = [];
236
+
237
+ afterEach(() => {
238
+ if (originalRoot === undefined) delete process.env.AILK_CONTENT_ROOT;
239
+ else process.env.AILK_CONTENT_ROOT = originalRoot;
240
+ for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true });
241
+ });
242
+
243
+ /** The repo's own `theme:` block, as YAML text and as parsed. */
244
+ function repoTheme(): { yaml: string; parsed: unknown } {
245
+ const raw = readFileSync(join(REPO_CONTENT_ROOT, "_site.mdx"), "utf8");
246
+ const parsed = (
247
+ parseYaml(/^---\r?\n([\s\S]*?)\r?\n---/.exec(raw)?.[1] ?? "") as { theme?: unknown }
248
+ ).theme;
249
+ return { yaml: stringifyYaml({ theme: parsed }), parsed };
250
+ }
251
+
252
+ function useSiteMdx(frontmatter: string): void {
253
+ const root = mkdtempSync(join(tmpdir(), "ailk-site-triad-"));
254
+ tempRoots.push(root);
255
+ writeFileSync(join(root, "_site.mdx"), `---\nsiteName: Test\n${frontmatter}---\n`, "utf8");
256
+ process.env.AILK_CONTENT_ROOT = root;
257
+ }
258
+
259
+ /** The layout's emission, verbatim (`app/[locale]/layout.tsx`). */
260
+ function layoutCss(): string {
261
+ const brand = loadSiteBrandTokens();
262
+ return themeToCssVarsBothModes(brand ? { brand } : {}) + loadSiteShapeCss();
263
+ }
264
+
265
+ it("golden: the repo's theme with no triad emits the pre-triad CSS byte for byte", () => {
266
+ const { yaml, parsed } = repoTheme();
267
+ useSiteMdx(`${yaml}brand:\n logomark: { light: /logomark.svg, dark: /logomark.svg, alt: Test }\n`);
268
+
269
+ const theme = SiteThemeSchema.parse(parsed);
270
+ const before = siteThemeToBrandTokens(theme);
271
+ expect(layoutCss()).toBe(
272
+ themeToCssVarsBothModes(before ? { brand: before } : {}) + siteThemeShapeCss(theme),
273
+ );
274
+ });
275
+
276
+ it("golden: no _site.mdx emits the default CSS", () => {
277
+ process.env.AILK_CONTENT_ROOT = join(__dirname, "no-such-content-root");
278
+ expect(layoutCss()).toBe(themeToCssVarsBothModes({}));
279
+ });
280
+
281
+ it("a declared triad reaches --font-display / --font-body and --color-brand in both blocks", () => {
282
+ const { yaml } = repoTheme();
283
+ useSiteMdx(
284
+ `${yaml}brand:\n` +
285
+ ` colors:\n light: { primary: "#0a3d62" }\n dark: { primary: "#82ccdd" }\n` +
286
+ ` fonts:\n display: "'Fraunces', Georgia, serif"\n body: "Inter, sans-serif"\n`,
287
+ );
288
+
289
+ const css = layoutCss();
290
+ for (const [mode, block] of [
291
+ ["light", lightBlock(css)],
292
+ ["dark", darkBlock(css)],
293
+ ] as const) {
294
+ expect(block).toContain("--font-display: 'Fraunces', Georgia, serif;");
295
+ expect(block).toContain("--font-body: Inter, sans-serif;");
296
+ expect(block).toContain(`--color-brand: ${mode === "light" ? "#0a3d62" : "#82ccdd"};`);
297
+ }
298
+ });
299
+
300
+ it("refuses an unknown brand key by name", () => {
301
+ useSiteMdx("brand:\n corners: square\n");
302
+ expect(() => loadSiteBrandTokens()).toThrow(/corners/);
303
+ });
304
+ });
305
+
213
306
  /**
214
307
  * The twelve customer-facing colour slots live in `@working-theory/site-config`
215
308
  * and the map from them to token names lives in `@working-theory/theme`;
@@ -53,6 +53,8 @@ import {
53
53
  type SchemaOrgType,
54
54
  type ValidationError,
55
55
  } from "@working-theory/schema-validator";
56
+
57
+ import { MEET_PLACEHOLDER_HUB } from "../meet-placeholder";
56
58
  import type { ReactElement } from "react";
57
59
  import { parse as parseYaml } from "yaml";
58
60
 
@@ -447,19 +449,31 @@ function routeIdentity(
447
449
 
448
450
  type PageModule = {
449
451
  default: (props: {
450
- params: Promise<{ locale: string; slug?: string }>;
452
+ params: Promise<{ locale: string; slug?: string; detail?: string }>;
451
453
  }) => Promise<ReactElement>;
452
454
  };
453
455
 
454
456
  async function renderRoute(
455
457
  file: string,
458
+ route: string,
456
459
  slug: string | undefined,
457
460
  ): Promise<HTMLElement> {
458
461
  // Resolved at test time from the walk above — a static import would dangle
459
462
  // in a scaffold that gated the surface off.
460
463
  const mod = require(file) as PageModule;
461
- const params: { locale: string; slug?: string } = { locale: DEFAULT_LOCALE };
462
- if (slug !== undefined) params.slug = slug;
464
+ const params: { locale: string; slug?: string; detail?: string } = {
465
+ locale: DEFAULT_LOCALE,
466
+ };
467
+ // #6897 D2 — params are built from the route's OWN dynamic segments: the
468
+ // `[slug]/[detail]` placeholder route's `slug` param is fixed to the meet
469
+ // hub, and its content slug rides `detail` instead — never a copied
470
+ // literal for the hub name.
471
+ if (route === "[slug]/[detail]") {
472
+ params.slug = MEET_PLACEHOLDER_HUB;
473
+ if (slug !== undefined) params.detail = slug;
474
+ } else if (slug !== undefined) {
475
+ params.slug = slug;
476
+ }
463
477
  const element = await mod.default({ params: Promise.resolve(params) });
464
478
  return render(element).container;
465
479
  }
@@ -531,12 +545,15 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
531
545
  for (const slug of slugs) {
532
546
  const container = await renderRoute(
533
547
  file,
548
+ route,
534
549
  identity.slug === null ? slug : undefined,
535
550
  );
536
551
  const blocks = extractJsonLdBlocks(container);
537
552
  const pageLabel =
538
553
  identity.slug === null
539
- ? `${label.replace(/\[slug\]$/, "")}${slug}`
554
+ ? route === "[slug]/[detail]"
555
+ ? `/${DEFAULT_LOCALE}/${MEET_PLACEHOLDER_HUB}/${slug}`
556
+ : `${label.replace(/\[slug\]$/, "")}${slug}`
540
557
  : label;
541
558
  if (blocks.length === 0) {
542
559
  failures.push(
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The `[locale]` layout's first check (#6107 F5).
3
+ *
4
+ * A dotted path that is not a file — the `/.well-known` directory, `/foo.bar`
5
+ * — skips the middleware matcher and reaches `app/[locale]` with that segment
6
+ * as `locale`. Anything outside the enabled-locale list (the same list the
7
+ * footer switcher offers) is a 404, not the whole site rendered under a bogus
8
+ * locale.
9
+ */
10
+ import { readLocales } from "@working-theory/web-i18n";
11
+ import { notFound } from "next/navigation";
12
+
13
+ export function assertEnabledLocale(
14
+ locale: string,
15
+ locales: readonly string[] = readLocales(),
16
+ ): void {
17
+ if (!locales.includes(locale)) notFound();
18
+ }
@@ -0,0 +1,5 @@
1
+ // The Free `/meet/<slug>` placeholder hub (#6897 D2). `MEET_PLACEHOLDER_HUB`
2
+ // is the one fixed top-level slug the `[slug]/[detail]` route answers under —
3
+ // never a copied literal, so the route and the structured-data walk
4
+ // (`apps/web/lib/__tests__/structured-data.test.tsx`) can never drift.
5
+ export const MEET_PLACEHOLDER_HUB = "meet";
@@ -19,9 +19,9 @@ export const navItems: NavItem[] = [
19
19
  { labelKey: "feature1", path: "/features/feature-1" },
20
20
  { labelKey: "feature2", path: "/features/feature-2" },
21
21
  { labelKey: "feature3", path: "/features/feature-3" },
22
- { labelKey: "useCase1", path: "/use-cases/persona-1" },
23
- { labelKey: "useCase2", path: "/use-cases/persona-2" },
24
- { labelKey: "useCase3", path: "/use-cases/persona-3" },
22
+ { labelKey: "customer1", path: "/for/persona-1" },
23
+ { labelKey: "customer2", path: "/for/persona-2" },
24
+ { labelKey: "customer3", path: "/for/persona-3" },
25
25
  ],
26
26
  groups: [
27
27
  {
@@ -33,11 +33,11 @@ export const navItems: NavItem[] = [
33
33
  ],
34
34
  },
35
35
  {
36
- headingKey: "flyoutUseCasesHeading",
36
+ headingKey: "flyoutCustomersHeading",
37
37
  items: [
38
- { labelKey: "useCase1", path: "/use-cases/persona-1" },
39
- { labelKey: "useCase2", path: "/use-cases/persona-2" },
40
- { labelKey: "useCase3", path: "/use-cases/persona-3" },
38
+ { labelKey: "customer1", path: "/for/persona-1" },
39
+ { labelKey: "customer2", path: "/for/persona-2" },
40
+ { labelKey: "customer3", path: "/for/persona-3" },
41
41
  ],
42
42
  },
43
43
  ],
@@ -62,9 +62,10 @@ export const footerColumns: FooterColumnConfig[] = [
62
62
  items: [
63
63
  { labelKey: "features", path: "/features" },
64
64
  { labelKey: "pricing", path: "/pricing" },
65
+ { labelKey: "portfolio", path: "/portfolio" },
65
66
  { labelKey: "about", path: "/about" },
66
67
  { labelKey: "contact", path: "/contact" },
67
- { labelKey: "bookACall", path: "/book-a-call" },
68
+ { labelKey: "bookACall", path: "/meet/book-a-call" },
68
69
  ],
69
70
  },
70
71
  {
@@ -77,12 +78,12 @@ export const footerColumns: FooterColumnConfig[] = [
77
78
  ],
78
79
  },
79
80
  {
80
- headingKey: "footerUseCasesHeading",
81
+ headingKey: "footerCustomersHeading",
81
82
  items: [
82
- { labelKey: "useCase1", path: "/use-cases/persona-1" },
83
- { labelKey: "useCase2", path: "/use-cases/persona-2" },
84
- { labelKey: "useCase3", path: "/use-cases/persona-3" },
85
- { labelKey: "allUseCases", path: "/use-cases" },
83
+ { labelKey: "customer1", path: "/for/persona-1" },
84
+ { labelKey: "customer2", path: "/for/persona-2" },
85
+ { labelKey: "customer3", path: "/for/persona-3" },
86
+ { labelKey: "allCustomers", path: "/for" },
86
87
  ],
87
88
  },
88
89
  {
@@ -20,7 +20,7 @@
20
20
  * rather than a dependency on `@working-theory/site-config`'s `loadSiteConfig`
21
21
  * (`packages/site-config/src/server/load.ts`): that package is async (this
22
22
  * module's exports are plain constants, read at many synchronous call
23
- * sites — Nav/Footer props, `siteLogo()`, edge-safe route defaults) and
23
+ * sites — Nav/Footer props, the raw logomark `siteLogo()` in `site-logo.tsx` renders, edge-safe route defaults) and
24
24
  * brings gray-matter + Zod validation + optimistic-locking writes built for
25
25
  * the agent-operator editing workflow — disproportionate for reading two
26
26
  * fields. See docs/specs/4157-scaffold-brand-defaults-spec.md § Design
@@ -30,7 +30,6 @@
30
30
  import { existsSync, readFileSync } from "node:fs";
31
31
  import { join } from "node:path";
32
32
 
33
- import type { ReactElement } from "react";
34
33
  import { parse as parseYaml } from "yaml";
35
34
 
36
35
  /** Neutral, non-vendor fallback — see module doc comment point 3. */
@@ -46,6 +45,8 @@ interface SiteFrontmatter {
46
45
  // `undefined` (a plain `?: string` would forbid that explicit assignment).
47
46
  siteName?: string | undefined;
48
47
  baseUrl?: string | undefined;
48
+ /** `brand.logomark`, raw — validated by `siteLogo()` when it renders. */
49
+ logomark?: unknown;
49
50
  }
50
51
 
51
52
  /**
@@ -83,10 +84,14 @@ function readSiteFrontmatter(): SiteFrontmatter | null {
83
84
  const parsed: unknown = parseYaml(match[1] ?? "");
84
85
  if (!parsed || typeof parsed !== "object") return null;
85
86
 
86
- const { siteName, baseUrl } = parsed as Record<string, unknown>;
87
+ const { siteName, baseUrl, brand } = parsed as Record<string, unknown>;
87
88
  return {
88
89
  siteName: typeof siteName === "string" ? siteName : undefined,
89
90
  baseUrl: typeof baseUrl === "string" ? baseUrl : undefined,
91
+ logomark:
92
+ brand && typeof brand === "object"
93
+ ? ((brand as Record<string, unknown>).logomark ?? undefined)
94
+ : undefined,
90
95
  };
91
96
  } catch {
92
97
  return null;
@@ -108,10 +113,9 @@ export const SITE_BASE_URL =
108
113
  process.env.NEXT_PUBLIC_BASE_URL ?? siteFrontmatter?.baseUrl ?? FALLBACK_BASE_URL;
109
114
 
110
115
  /**
111
- * Shared brand mark — the square logomark (the central brand library's
112
- * `logomark/loop/tile-black.svg`). The Nav pairs it with `/wordmark.svg`.
113
- * RSC-safe (no hooks, no client state).
116
+ * The raw `brand.logomark` from `_site.mdx`, or `undefined` when none is
117
+ * declared. Unvalidated: `siteLogo()` (`site-logo.tsx`) parses it on the
118
+ * render that uses it, so a malformed mark never breaks `SITE_NAME` /
119
+ * `SITE_BASE_URL` for the routes that read only those.
114
120
  */
115
- export function siteLogo(): ReactElement {
116
- return <img src="/logomark.svg" alt={SITE_NAME} width={28} height={28} className="block" />;
117
- }
121
+ export const SITE_LOGOMARK: unknown = siteFrontmatter?.logomark;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Site logo — the site's declared `brand.logomark` (#6820), else the square
3
+ * logomark (the central brand library's `logomark/loop/tile-black.svg`).
4
+ *
5
+ * Split from `site-brand.tsx`, which ten route and lib modules import for
6
+ * `SITE_NAME` / `SITE_BASE_URL`: the mark needs `@working-theory/ui`'s
7
+ * `Image` and site-config's schema, and those stay out of that wider graph
8
+ * (spec #6820 § 5.4). The two layouts are the only callers.
9
+ */
10
+
11
+ import { BrandLogomarkSchema } from "@working-theory/site-config/schema";
12
+ import { Image } from "@working-theory/ui/primitives";
13
+ import type { ReactElement } from "react";
14
+
15
+ import { SITE_LOGOMARK, SITE_NAME } from "./site-brand";
16
+
17
+ /**
18
+ * Shared brand mark. The Nav pairs it with `/wordmark.svg`. RSC-safe (no
19
+ * hooks, no client state).
20
+ *
21
+ * The logomark is validated here, on the render that uses it, not at module
22
+ * init: a malformed mark throws naming the field while `SITE_NAME` /
23
+ * `SITE_BASE_URL` stay readable by the routes that need only them. A mark
24
+ * whose dark source differs renders through the `Image` primitive's
25
+ * `darkSrc` swap, so the dark mark is not dropped.
26
+ */
27
+ export function siteLogo(): ReactElement {
28
+ if (SITE_LOGOMARK === undefined) {
29
+ return <img src="/logomark.svg" alt={SITE_NAME} width={28} height={28} className="block" />;
30
+ }
31
+ const { light, dark, alt } = BrandLogomarkSchema.parse(SITE_LOGOMARK);
32
+ if (light === dark) {
33
+ return <img src={light} alt={alt} width={28} height={28} className="block" />;
34
+ }
35
+ return <Image src={light} darkSrc={dark} alt={alt} width={28} height={28} />;
36
+ }
@@ -15,21 +15,26 @@
15
15
  * The translation itself lives in `@working-theory/theme`
16
16
  * (`siteThemeToBrandTokens`) — the token vocabulary is that package's own.
17
17
  *
18
- * Returns `undefined` when the site declares no theme, so the layout passes
19
- * no `brand` at all and the resolver emits byte-for-byte the CSS it emitted
20
- * before this wiring existed.
18
+ * The brand triad (#6820) — `brand.colors` and `brand.fonts`, a sibling of
19
+ * `theme:` that applying a design never rewrites — is read in the same parse
20
+ * and layered over the theme by `siteBrandToBrandTokens`.
21
+ *
22
+ * Returns `undefined` when the site declares no theme and no triad, so the
23
+ * layout passes no `brand` at all and the resolver emits byte-for-byte the
24
+ * CSS it emitted before this wiring existed.
21
25
  */
22
26
 
23
27
  import { readFileSync } from "node:fs";
24
28
  import { join } from "node:path";
25
29
 
26
- import { SiteThemeSchema } from "@working-theory/site-config/schema";
30
+ import { BrandSchema, SiteThemeSchema } from "@working-theory/site-config/schema";
27
31
  import {
32
+ siteBrandToBrandTokens,
28
33
  siteThemeShapeCss,
29
- siteThemeToBrandTokens,
30
34
  type BrandTokenOverrides,
31
35
  } from "@working-theory/theme";
32
36
  import { parse as parseYaml } from "yaml";
37
+ import { z } from "zod";
33
38
 
34
39
  import { resolveContentRoot } from "./site-brand";
35
40
 
@@ -38,40 +43,55 @@ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
38
43
  const SOURCE = "content/_site.mdx theme";
39
44
 
40
45
  /**
41
- * The `theme:` value as it appears in the frontmatter, or `undefined` when
42
- * `_site.mdx` is absent, unreadable, or declares no theme. A site with no
43
- * readable config has no theme to apply, and the default palette is the
44
- * correct result — the same fallback `site-brand.tsx` takes.
46
+ * `brand:` as this loader validates it: every key checked by name, but the
47
+ * `logomark` left to `siteLogo()` (`site-brand.tsx`), which parses it on the
48
+ * render that uses it — one throw site per field.
49
+ */
50
+ const BrandTriadSchema = BrandSchema.extend({
51
+ logomark: z.unknown().optional(),
52
+ }).strict();
53
+
54
+ /**
55
+ * The `theme:` and `brand:` values as they appear in the frontmatter, each
56
+ * `undefined` when `_site.mdx` is absent, unreadable, or does not declare it.
57
+ * A site with no readable config has no theme to apply, and the default
58
+ * palette is the correct result — the same fallback `site-brand.tsx` takes.
45
59
  *
46
- * "Declares no theme" covers a bare `theme:` key with nothing under it, which
47
- * YAML parses to `null`. That is the shape the most natural edit produces —
48
- * commenting out the block's children leaves the key dangling — and it means
49
- * exactly what an absent key means, so it takes the same path rather than
50
- * reaching the schema and throwing out of a Server Component.
60
+ * "Does not declare it" covers a bare `theme:` key with nothing under it,
61
+ * which YAML parses to `null`. That is the shape the most natural edit
62
+ * produces — commenting out the block's children leaves the key dangling —
63
+ * and it means exactly what an absent key means, so it takes the same path
64
+ * rather than reaching the schema and throwing out of a Server Component.
51
65
  */
52
- function readThemeBlock(): unknown {
66
+ function readSiteBlocks(): { theme: unknown; brand: unknown } {
53
67
  try {
54
68
  const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
55
69
  const match = FRONTMATTER_RE.exec(raw);
56
- if (!match) return undefined;
70
+ if (!match) return { theme: undefined, brand: undefined };
57
71
 
58
72
  const parsed: unknown = parseYaml(match[1] ?? "");
59
- if (!parsed || typeof parsed !== "object") return undefined;
73
+ if (!parsed || typeof parsed !== "object") {
74
+ return { theme: undefined, brand: undefined };
75
+ }
60
76
 
61
- return (parsed as Record<string, unknown>).theme ?? undefined;
77
+ const { theme, brand } = parsed as Record<string, unknown>;
78
+ return { theme: theme ?? undefined, brand: brand ?? undefined };
62
79
  } catch {
63
- return undefined;
80
+ return { theme: undefined, brand: undefined };
64
81
  }
65
82
  }
66
83
 
67
84
  export function loadSiteBrandTokens(): BrandTokenOverrides | undefined {
68
- const block = readThemeBlock();
69
- if (block === undefined) return undefined;
85
+ const blocks = readSiteBlocks();
86
+ if (blocks.theme === undefined && blocks.brand === undefined) return undefined;
70
87
 
71
88
  // Validation failures and unsupported colours BOTH throw, deliberately: a
72
89
  // customer who set a colour and saw no change would have been lied to.
73
- const theme = SiteThemeSchema.parse(block);
74
- return siteThemeToBrandTokens(theme, SOURCE);
90
+ const theme =
91
+ blocks.theme === undefined ? undefined : SiteThemeSchema.parse(blocks.theme);
92
+ const brand =
93
+ blocks.brand === undefined ? undefined : BrandTriadSchema.parse(blocks.brand);
94
+ return siteBrandToBrandTokens(theme, brand, SOURCE);
75
95
  }
76
96
 
77
97
  /**
@@ -79,7 +99,7 @@ export function loadSiteBrandTokens(): BrandTokenOverrides | undefined {
79
99
  * override block for `:root`. "" when the site sets neither.
80
100
  */
81
101
  export function loadSiteShapeCss(): string {
82
- const block = readThemeBlock();
83
- if (block === undefined) return "";
84
- return siteThemeShapeCss(SiteThemeSchema.parse(block));
102
+ const { theme } = readSiteBlocks();
103
+ if (theme === undefined) return "";
104
+ return siteThemeShapeCss(SiteThemeSchema.parse(theme));
85
105
  }
@@ -11,6 +11,7 @@
11
11
  "signOut": "Sign out",
12
12
  "account": "Account",
13
13
  "product": "Product",
14
+ "services": "Services",
14
15
  "howItWorks": "How it works",
15
16
  "howItWorksDesc": "'{One line on how your product works}'",
16
17
  "features": "Features",
@@ -18,6 +19,7 @@
18
19
  "faq": "FAQ",
19
20
  "faqDesc": "'{One line on the questions you answer}'",
20
21
  "pricing": "Pricing",
22
+ "portfolio": "Portfolio",
21
23
  "about": "About",
22
24
  "contact": "Contact",
23
25
  "bookACall": "Book a call",
@@ -39,13 +41,17 @@
39
41
  "signUp": "Sign up",
40
42
  "skipToContent": "Skip to content",
41
43
  "useCasesDesc": "'{One line on how your product works}'",
42
- "useCase1": "'{Use Case 1}'",
43
- "useCase2": "'{Use Case 2}'",
44
- "useCase3": "'{Use Case 3}'",
45
- "footerUseCasesHeading": "Use Cases",
46
- "allUseCases": "All use cases",
47
44
  "flyoutFeaturesHeading": "Features",
48
- "flyoutUseCasesHeading": "Use Cases"
45
+ "flyoutServicesHeading": "Services",
46
+ "flyoutCustomersHeading": "Customers",
47
+ "footerCustomersHeading": "Customers",
48
+ "allCustomers": "All customers",
49
+ "customer1": "'{Customer 1}'",
50
+ "customer2": "'{Customer 2}'",
51
+ "customer3": "'{Customer 3}'",
52
+ "service1": "'{Service 1}'",
53
+ "service2": "'{Service 2}'",
54
+ "service3": "'{Service 3}'"
49
55
  },
50
56
  "consent": {
51
57
  "title": "Cookie Preferences",
@@ -325,9 +331,17 @@
325
331
  "heading": "Testimonials",
326
332
  "emptyState": "Testimonials coming soon."
327
333
  },
328
- "use-cases": {
329
- "heading": "Use Cases",
330
- "emptyState": "Use cases listing coming soon."
334
+ "for": {
335
+ "heading": "Customers",
336
+ "emptyState": "Customer pages coming soon."
337
+ },
338
+ "alternatives": {
339
+ "heading": "Alternatives",
340
+ "emptyState": "Comparisons coming soon."
341
+ },
342
+ "reports": {
343
+ "heading": "Reports",
344
+ "emptyState": "Reports coming soon."
331
345
  },
332
346
  "waitlistDashboard": {
333
347
  "gate": {
@@ -12,11 +12,17 @@
12
12
  // Locale list: read once at module load from project.yaml via readLocales() (S1).
13
13
  // Static assets and internal Next.js paths are excluded via the matcher below.
14
14
  //
15
+ // /api/* and /og are route handlers outside app/[locale] (#6864). They skip
16
+ // next-intl — whose as-needed rewrite would send them to /en/api/… and /en/og,
17
+ // which do not exist — but still get the security headers. They are exempted
18
+ // here rather than in the matcher, because this middleware is the only place
19
+ // the security headers are stamped.
20
+ //
15
21
  // S16 spec: docs/internal/specs/ailk-pre-oss-security-phase-3-s16-security-headers-spec.md
16
22
  // S2 spec: docs/internal/specs/i18n-phase1-s2-next-intl-foundation-spec.md
17
23
  // P2 S1 spec: docs/internal/specs/i18n-phase2-s1-hreflang-metadata-spec.md §3
18
24
 
19
- import { NextRequest, type NextResponse } from "next/server";
25
+ import { NextRequest, NextResponse } from "next/server";
20
26
  import createMiddleware from "next-intl/middleware";
21
27
 
22
28
  import {
@@ -30,9 +36,9 @@ import {
30
36
  // importing lib/locales — which uses node:fs and process.cwd() — into the
31
37
  // Edge runtime that runs middleware.
32
38
  // Falls back to ["en"] in test environments where the env var is unset.
33
- const enabledLocales = (
34
- process.env.NEXT_PUBLIC_ENABLED_LOCALES ?? "en"
35
- ).split(",");
39
+ const enabledLocales = (process.env.NEXT_PUBLIC_ENABLED_LOCALES ?? "en").split(
40
+ ",",
41
+ );
36
42
  const DEFAULT_LOCALE = "en";
37
43
 
38
44
  // next-intl middleware instance — locale detection priority chain:
@@ -49,6 +55,17 @@ const intlMiddleware = createMiddleware({
49
55
  localePrefix: "as-needed",
50
56
  });
51
57
 
58
+ // Top-level app/ route directories that live outside [locale]. Kept in step
59
+ // with app/ by the drift test in __tests__/i18n/middleware.test.ts.
60
+ export const NON_LOCALE_ROUTE_PREFIXES = ["/api", "/og"] as const;
61
+
62
+ // Segment-bound: /og and /og/x are exempt; /ogden and /og-image are not.
63
+ export function isNonLocaleRoute(pathname: string): boolean {
64
+ return NON_LOCALE_ROUTE_PREFIXES.some(
65
+ (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
66
+ );
67
+ }
68
+
52
69
  export function middleware(request: NextRequest): NextResponse {
53
70
  // Step 1: Forward x-pathname to server components so generateMetadata can
54
71
  // build path-aware hreflang alternates (P2 S1). Clone the request headers
@@ -57,6 +74,13 @@ export function middleware(request: NextRequest): NextResponse {
57
74
  const requestHeaders = new Headers(request.headers);
58
75
  requestHeaders.set("x-pathname", request.nextUrl.pathname);
59
76
 
77
+ // Non-locale routes are served as requested — no rewrite, no redirect.
78
+ if (isNonLocaleRoute(request.nextUrl.pathname)) {
79
+ const res = NextResponse.next({ request: { headers: requestHeaders } });
80
+ applyHeaders(res.headers, DEFAULT_SECURITY_HEADERS);
81
+ return res;
82
+ }
83
+
60
84
  const requestWithPathname = new NextRequest(request.url, {
61
85
  headers: requestHeaders,
62
86
  method: request.method,