create-ailk 0.13.0 → 0.15.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 (188) hide show
  1. package/README.md +12 -4
  2. package/component-catalog.json +250 -36
  3. package/component-catalog.md +10 -6
  4. package/dist/add-module.d.ts +7 -0
  5. package/dist/add-module.js +34 -2
  6. package/dist/add-route.js +18 -2
  7. package/dist/apply-design.d.ts +32 -2
  8. package/dist/apply-design.js +155 -22
  9. package/dist/cli.js +24 -3
  10. package/dist/derive-architecture.d.ts +15 -2
  11. package/dist/derive-architecture.js +34 -6
  12. package/dist/design-compile.d.ts +70 -2
  13. package/dist/design-compile.js +97 -1
  14. package/dist/design-home.d.ts +74 -0
  15. package/dist/design-home.js +256 -0
  16. package/dist/design-package.d.ts +39 -2
  17. package/dist/design-package.js +224 -9
  18. package/dist/design-presets.d.ts +17 -1
  19. package/dist/design-presets.js +17 -5
  20. package/dist/design-scaffold.d.ts +6 -25
  21. package/dist/design-scaffold.js +26 -74
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.js +1 -1
  24. package/dist/lib/chrome-readers.d.ts +21 -0
  25. package/dist/lib/chrome-readers.js +100 -0
  26. package/dist/lib/design-checks.d.ts +12 -1
  27. package/dist/lib/design-checks.js +38 -2
  28. package/dist/lib/extract-module-bundle.d.ts +11 -0
  29. package/dist/lib/extract-module-bundle.js +36 -6
  30. package/dist/lib/license-key-files.d.ts +6 -1
  31. package/dist/lib/license-key-files.js +9 -4
  32. package/dist/lib/module-license-gate.d.ts +9 -0
  33. package/dist/lib/module-license-gate.js +18 -0
  34. package/dist/module-architecture.d.ts +9 -0
  35. package/dist/module-architecture.js +6 -0
  36. package/dist/parse-args.d.ts +22 -0
  37. package/dist/parse-args.js +73 -0
  38. package/dist/programmatic.d.ts +20 -0
  39. package/dist/programmatic.js +180 -35
  40. package/dist/site-chrome.d.ts +42 -0
  41. package/dist/site-chrome.js +140 -2
  42. package/dist/site-fonts.d.ts +21 -0
  43. package/dist/site-fonts.js +54 -0
  44. package/dist/site-manifest.d.ts +12 -5
  45. package/dist/site-manifest.js +25 -11
  46. package/dist/site-roster.d.ts +16 -0
  47. package/dist/site-roster.js +81 -0
  48. package/dist/starter-grid.js +15 -0
  49. package/dist/surfaces.js +12 -0
  50. package/dist/sync-routes.js +11 -2
  51. package/dist/team-profiles.d.ts +29 -0
  52. package/dist/team-profiles.js +43 -0
  53. package/dist/topology.d.ts +131 -0
  54. package/dist/topology.js +533 -0
  55. package/package.json +3 -3
  56. package/release-scope.json +1 -1
  57. package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
  58. package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
  59. package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
  60. package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
  61. package/site-templates/designs/minimalism/DESIGN.md +166 -36
  62. package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
  63. package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
  64. package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
  65. package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
  66. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
  67. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
  68. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
  69. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
  70. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  71. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
  72. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
  73. package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
  74. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
  75. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
  76. package/site-templates/shapes/organization-service/ailk-template.json +1 -1
  77. package/templates/README.md +2 -0
  78. package/templates/apps/api/CLAUDE.md +6 -1
  79. package/templates/apps/api/package.json +1 -0
  80. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  81. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  82. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  83. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  84. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  85. package/templates/apps/api/src/lib/site.ts +37 -0
  86. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  87. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  88. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  89. package/templates/apps/api/src/middleware/auth.ts +3 -0
  90. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  91. package/templates/apps/api/src/openapi/spec.ts +38 -2
  92. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  93. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  94. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  95. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  96. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  97. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  98. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  99. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  100. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  101. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  102. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  103. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  104. package/templates/apps/mcp/package.json +1 -1
  105. package/templates/apps/mcp/src/index.ts +6 -4
  106. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  107. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  108. package/templates/apps/web/CLAUDE.md +12 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
  112. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  113. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  115. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  116. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
  117. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  118. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +163 -0
  119. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +68 -0
  120. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
  121. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
  122. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
  123. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
  124. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
  125. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
  126. package/templates/apps/web/app/[locale]/layout.tsx +212 -57
  127. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  128. package/templates/apps/web/app/[locale]/page.tsx +37 -0
  129. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
  131. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  132. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
  133. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
  134. package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
  135. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
  136. package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
  137. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
  138. package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
  139. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
  140. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
  141. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  142. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
  143. package/templates/apps/web/app/not-found.tsx +47 -0
  144. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  145. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  146. package/templates/apps/web/instrumentation.ts +7 -4
  147. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  148. package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
  149. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  150. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  151. package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
  152. package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
  153. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  154. package/templates/apps/web/lib/profile-back-link.ts +51 -0
  155. package/templates/apps/web/lib/relay.ts +206 -2
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +15 -0
  158. package/templates/apps/web/lib/site-fonts.ts +15 -0
  159. package/templates/apps/web/lib/site-logo.tsx +39 -1
  160. package/templates/apps/web/lib/site-theme-head.ts +33 -0
  161. package/templates/apps/web/lib/site-theme.ts +13 -1
  162. package/templates/apps/web/lib/topology.generated.ts +23 -0
  163. package/templates/apps/web/messages/en.json +7 -1
  164. package/templates/apps/web/package.json +1 -1
  165. package/templates/database/CHANGELOG.md +32 -0
  166. package/templates/database/__tests__/agents.db.test.ts +65 -0
  167. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  168. package/templates/database/auth/schema.prisma +23 -0
  169. package/templates/database/inbox/schema.prisma +13 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/package.json +1 -1
  175. package/versions.json +35 -34
  176. package/site-templates/designs/minimalism/sample/README.md +0 -48
  177. package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
  178. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
  179. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
  180. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
  181. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
  182. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
  183. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
  184. package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
  185. package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
  186. package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
  187. package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
  188. package/site-templates/designs/minimalism/sample/public/wordmark-placeholder.svg +0 -28
@@ -62,9 +62,9 @@ describe("whitepapers/[slug] — snapshot regression", () => {
62
62
  });
63
63
  const { container } = render(element);
64
64
  expect(container.innerHTML).toMatchInlineSnapshot(`
65
- "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products.","mainEntity":{"@context":"https://schema.org","@type":"Article","headline":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Whitepapers","item":"http://localhost:3000/en/whitepapers"},{"@type":"ListItem","position":3,"name":"AILK AEO Strategy 2026","item":"http://localhost:3000/en/whitepapers/ailk-aeo-strategy-2026"}]}]}</script><main id="main"><h1>AILK AEO Strategy 2026</h1><p data-testid="whitepaper-description">In-depth research on answer engine optimization for AILK products.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div data-testid="mdx-rendered">## Executive Summary
65
+ "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products.","mainEntity":{"@context":"https://schema.org","@type":"Article","headline":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Whitepapers","item":"http://localhost:3000/en/whitepapers"},{"@type":"ListItem","position":3,"name":"AILK AEO Strategy 2026","item":"http://localhost:3000/en/whitepapers/ailk-aeo-strategy-2026"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">AILK AEO Strategy 2026</h1><p data-testid="whitepaper-description" class="mt-block-tight text-lead">In-depth research on answer engine optimization for AILK products.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div class="ailk-prose max-w-none text-(--color-fg) text-body-sm leading-relaxed [&amp;>*:first-child]:mt-0 [&amp;_h2]:mt-block-loose [&amp;_h2]:scroll-mt-24 [&amp;_h2]:text-heading-3 [&amp;_h2]:font-display [&amp;_h2]:text-(--color-fg) [&amp;_h3]:mt-block-loose [&amp;_h3]:scroll-mt-24 [&amp;_h3]:text-heading-4 [&amp;_h3]:font-display [&amp;_h3]:text-(--color-fg) [&amp;_h4]:mt-block-tight [&amp;_h4]:scroll-mt-24 [&amp;_h4]:text-heading-4 [&amp;_h4]:font-display [&amp;_h4]:text-(--color-fg) [&amp;_h5]:mt-block-tight [&amp;_h5]:scroll-mt-24 [&amp;_h5]:text-heading-4 [&amp;_h5]:font-display [&amp;_h5]:text-(--color-fg) [&amp;_h6]:mt-block-tight [&amp;_h6]:scroll-mt-24 [&amp;_h6]:text-heading-4 [&amp;_h6]:font-display [&amp;_h6]:text-(--color-fg) [&amp;_p]:mt-paragraph [&amp;_strong]:font-semibold [&amp;_strong]:text-(--color-fg) [&amp;_em]:italic [&amp;_ul]:mt-paragraph [&amp;_ul]:list-disc [&amp;_ul]:ps-6 [&amp;_ol]:mt-paragraph [&amp;_ol]:list-decimal [&amp;_ol]:ps-6 [&amp;_li]:mt-1 [&amp;_li]:ps-1 [&amp;_li]:marker:text-(--color-text-muted) [&amp;_li>ul]:mt-1 [&amp;_li>ol]:mt-1 [&amp;_blockquote]:mt-paragraph [&amp;_blockquote]:border-s-4 [&amp;_blockquote]:border-(--color-border-strong) [&amp;_blockquote]:ps-4 [&amp;_blockquote]:italic [&amp;_blockquote]:text-(--color-text-muted) [&amp;_hr]:my-block-loose [&amp;_hr]:border-(--color-border) [&amp;_a]:text-(--color-accent) [&amp;_a]:underline [&amp;_a]:underline-offset-4 [&amp;_:not(pre)>code]:rounded [&amp;_:not(pre)>code]:bg-(--color-surface-secondary) [&amp;_:not(pre)>code]:px-1.5 [&amp;_:not(pre)>code]:py-0.5 [&amp;_:not(pre)>code]:text-code [&amp;_:not(pre)>code]:font-mono [&amp;_:not(pre)>code]:text-(--color-fg) [&amp;_pre]:mt-paragraph [&amp;_pre]:overflow-auto [&amp;_pre]:rounded-md [&amp;_pre]:border-hairline [&amp;_pre]:border-(--color-border) [&amp;_pre]:bg-(--color-bg) [&amp;_pre]:p-4 [&amp;_pre]:font-mono [&amp;_pre]:text-body-sm [&amp;_img]:mt-paragraph [&amp;_img]:h-auto [&amp;_img]:max-w-full [&amp;_img]:rounded-lg [&amp;_table]:mt-paragraph [&amp;_table]:w-full [&amp;_table]:border-collapse [&amp;_table]:text-body-sm [&amp;_th]:border-b [&amp;_th]:border-(--color-border) [&amp;_th]:bg-(--color-surface-secondary) [&amp;_th]:px-3 [&amp;_th]:py-2 [&amp;_th]:text-start [&amp;_th]:font-semibold [&amp;_th]:text-(--color-fg) [&amp;_td]:border-b [&amp;_td]:border-(--color-border) [&amp;_td]:px-3 [&amp;_td]:py-2 [&amp;_kbd]:rounded [&amp;_kbd]:border-hairline [&amp;_kbd]:border-(--color-border) [&amp;_kbd]:bg-(--color-surface-secondary) [&amp;_kbd]:px-1.5 [&amp;_kbd]:py-0.5 [&amp;_kbd]:text-caption [&amp;_kbd]:font-mono" data-testid="mdx-content"><div data-testid="mdx-rendered">## Executive Summary
66
66
 
67
- Body content.</div></article></div></div></main>"
67
+ Body content.</div></div></article></div></div></div></main>"
68
68
  `);
69
69
  });
70
70
 
@@ -6,12 +6,59 @@
6
6
  // app/[locale]/layout.tsx — the only other importer of globals.css — so it
7
7
  // imports the stylesheet itself; without it the primitives' token classes
8
8
  // would render unstyled here (#6514).
9
+ //
10
+ // Under `chromeStyle: minimal` (#7742 D6) every 404 renders in the site's
11
+ // theme instead: its own <html> and <body> (the documented pattern for a root
12
+ // not-found under a pass-through root layout), the theme variables and font
13
+ // class the locale layout emits (`siteThemeHead`), the site's mark, one
14
+ // statement and a back link. `data-theme` is fixed at light — the one mode a
15
+ // `colorMode: light` site has. Without the knob it renders today's markup.
9
16
  import "./globals.css";
10
17
 
18
+ import { BrandLogo } from "@working-theory/ui/blocks";
11
19
  import { Heading, Link, Text } from "@working-theory/ui/primitives";
12
20
  import type { ReactElement } from "react";
13
21
 
22
+ import { SITE_NAME } from "../lib/site-brand";
23
+ import { siteChrome, type SiteChrome } from "../lib/site-chrome";
24
+ import { siteLogo } from "../lib/site-logo";
25
+ import { siteThemeHead } from "../lib/site-theme-head";
26
+
27
+ /** Widened as the locale layout reads it: an older generated file has no `chromeStyle`. */
28
+ const chrome: SiteChrome & { chromeStyle?: "minimal" } = siteChrome;
29
+
30
+ function ThemedNotFound(): ReactElement {
31
+ const head = siteThemeHead();
32
+ return (
33
+ <html lang="en" data-theme="light" className={head.fontClassName}>
34
+ <head>
35
+ <style data-ailk="theme-vars" dangerouslySetInnerHTML={{ __html: head.css }} />
36
+ </head>
37
+ <body>
38
+ <main
39
+ data-testid="not-found"
40
+ className="flex min-h-screen flex-col items-center justify-center gap-block-loose bg-(--color-bg) p-card text-(--color-fg)"
41
+ >
42
+ <BrandLogo
43
+ locale="en"
44
+ siteName={SITE_NAME}
45
+ name={SITE_NAME}
46
+ href="/"
47
+ composition="logomark"
48
+ mark={siteLogo()}
49
+ />
50
+ <Heading level={1}>Page not found</Heading>
51
+ <Link href="/" data-testid="not-found-back">
52
+ Back to home
53
+ </Link>
54
+ </main>
55
+ </body>
56
+ </html>
57
+ );
58
+ }
59
+
14
60
  export default function NotFound(): ReactElement {
61
+ if (chrome.chromeStyle === "minimal") return <ThemedNotFound />;
15
62
  return (
16
63
  <main className="flex min-h-screen flex-col items-center justify-center gap-4 bg-(--color-bg) p-card text-(--color-fg)">
17
64
  <Heading level={1} visual={3}>
@@ -15,13 +15,29 @@
15
15
 
16
16
  import { GET } from "../route";
17
17
 
18
+ // The generated topology seam (#7426 D5), mocked so each case picks its
19
+ // topology: the hub cases below run against `"hub"`, exactly as before; the
20
+ // spoke cases (D12) flip it. Mocked rather than imported because the hub
21
+ // render imports `../auth`, which this route never touches.
22
+ const mockTopology: { value: "hub" | "spoke" } = { value: "hub" };
23
+ // eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426 D5), not a runtime input; the seam IS the boundary between a hub and a spoke build, and there is no env or HTTP surface that selects it.
24
+ jest.mock("../../../lib/topology.generated", () => ({
25
+ get TOPOLOGY() {
26
+ return mockTopology.value;
27
+ },
28
+ }));
29
+
18
30
  const SPEC = {
19
31
  openapi: "3.1.0",
20
32
  info: { title: "Example API", version: "1.0.0" },
21
33
  paths: { "/v1/content/search": {} },
22
34
  };
23
35
 
24
- const API_ENV = ["AILK_API_URL", "NEXT_PUBLIC_AILK_API_URL"] as const;
36
+ const API_ENV = [
37
+ "AILK_API_URL",
38
+ "NEXT_PUBLIC_AILK_API_URL",
39
+ "NEXT_PUBLIC_BASE_URL",
40
+ ] as const;
25
41
  const saved: Record<string, string | undefined> = {};
26
42
  let fetchSpy: jest.SpyInstance;
27
43
 
@@ -34,6 +50,7 @@ beforeEach(() => {
34
50
  });
35
51
 
36
52
  afterEach(() => {
53
+ mockTopology.value = "hub";
37
54
  for (const name of API_ENV) {
38
55
  if (saved[name] === undefined) delete process.env[name];
39
56
  else process.env[name] = saved[name];
@@ -174,3 +191,74 @@ describe("GET /openapi.json", () => {
174
191
  expect(await response.text()).not.toContain("api.internal");
175
192
  });
176
193
  });
194
+
195
+ describe("GET /openapi.json on a spoke (#7426 D12)", () => {
196
+ const HUB_SPEC = {
197
+ openapi: "3.1.0",
198
+ info: { title: "Hub API", version: "1.0.0" },
199
+ servers: [{ url: "https://hub.example.com", description: "The hub" }],
200
+ paths: {
201
+ "/v1/leads/submit": {
202
+ post: { summary: "Submit a lead" },
203
+ get: { summary: "Not relayed" },
204
+ },
205
+ "/v1/schedule/availability": {
206
+ get: {
207
+ summary: "Availability",
208
+ servers: [{ url: "https://hub.example.com/schedule" }],
209
+ },
210
+ },
211
+ "/v1/content/search": { get: { summary: "Hub-only search" } },
212
+ "/v1/admin/sites": { post: { summary: "Hub-only admin" } },
213
+ },
214
+ };
215
+
216
+ beforeEach(() => {
217
+ mockTopology.value = "spoke";
218
+ process.env["AILK_API_URL"] = "https://hub.example.com";
219
+ process.env["NEXT_PUBLIC_BASE_URL"] = "https://spoke.example.com";
220
+ });
221
+
222
+ it("names only this spoke's own origin in servers[]", async () => {
223
+ apiAnswers(200, JSON.stringify(HUB_SPEC));
224
+
225
+ const response = await GET();
226
+ const body = (await response.json()) as { servers: unknown };
227
+
228
+ expect(response.status).toBe(200);
229
+ expect(body.servers).toEqual([{ url: "https://spoke.example.com" }]);
230
+ expect(JSON.stringify(body)).not.toContain("hub.example.com");
231
+ });
232
+
233
+ it("keeps only the paths and methods the relay forwards", async () => {
234
+ apiAnswers(200, JSON.stringify(HUB_SPEC));
235
+
236
+ const body = (await (await GET()).json()) as {
237
+ paths: Record<string, Record<string, unknown>>;
238
+ };
239
+
240
+ expect(body.paths).toEqual({
241
+ "/v1/leads/submit": { post: { summary: "Submit a lead" } },
242
+ "/v1/schedule/availability": { get: { summary: "Availability" } },
243
+ });
244
+ });
245
+
246
+ it("still reads the hub's document at AILK_API_URL", async () => {
247
+ apiAnswers(200, JSON.stringify(HUB_SPEC));
248
+
249
+ await GET();
250
+
251
+ expect(requestedUrl()).toBe("https://hub.example.com/v1/openapi.json");
252
+ });
253
+
254
+ it("on a hub, leaves servers[] and paths as the API declared them", async () => {
255
+ mockTopology.value = "hub";
256
+ apiAnswers(200, JSON.stringify(HUB_SPEC));
257
+
258
+ const body = (await (await GET()).json()) as typeof HUB_SPEC;
259
+
260
+ expect(body.servers).toEqual(HUB_SPEC.servers);
261
+ expect(body.paths).toEqual(HUB_SPEC.paths);
262
+ expect(JSON.stringify(body)).toContain("hub.example.com");
263
+ });
264
+ });
@@ -15,8 +15,18 @@
15
15
  * When the API cannot be reached, or answers anything but an OpenAPI
16
16
  * document with a 200, this route answers 502 with a JSON error that names no
17
17
  * upstream origin, and is never cached.
18
+ *
19
+ * On a spoke (#7426 D12) the API is the hub's, so the document is rewritten
20
+ * to describe THIS host only: `servers[]` becomes this site's own origin, and
21
+ * `paths` keeps only the (path, method) pairs the same-origin relay forwards
22
+ * (`RELAY_ROUTES`, lib/relay.ts) — an agent reading it never learns the hub's
23
+ * host, and never calls a path this site would answer 404.
18
24
  */
19
25
 
26
+ import { RELAY_ROUTES } from "../../lib/relay";
27
+ import { TOPOLOGY } from "../../lib/topology.generated";
28
+ import { resolveBaseUrl } from "../../src/lib/routes";
29
+
20
30
  // Read per request: the document is the running API's, and `next build` has
21
31
  // no API to read it from.
22
32
  export const dynamic = "force-dynamic";
@@ -58,6 +68,7 @@ function stripSlash(url: string): string {
58
68
  interface OpenApiDocument {
59
69
  openapi: string;
60
70
  servers?: Array<{ url?: unknown; description?: unknown }>;
71
+ paths?: Record<string, unknown>;
61
72
  }
62
73
 
63
74
  function isOpenApiDocument(value: unknown): value is OpenApiDocument {
@@ -86,6 +97,61 @@ function withAbsoluteServers(spec: OpenApiDocument): OpenApiDocument {
86
97
  };
87
98
  }
88
99
 
100
+ /** The operation keys an OpenAPI path item may carry (OpenAPI 3.1 § 4.8.9). */
101
+ const OPERATION_KEYS = new Set([
102
+ "get",
103
+ "put",
104
+ "post",
105
+ "delete",
106
+ "options",
107
+ "head",
108
+ "patch",
109
+ "trace",
110
+ ]);
111
+
112
+ /**
113
+ * The document as a spoke serves it (#7426 D12): this host's own origin as
114
+ * the only server, and only the relayed (path, method) pairs. A path item's
115
+ * non-operation keys (`summary`, `parameters`, ...) ride along with a kept
116
+ * operation; a `servers` list — on the path item or on a kept operation —
117
+ * never does: it would name the hub.
118
+ */
119
+ function withoutServers(operation: unknown): unknown {
120
+ if (typeof operation !== "object" || operation === null) return operation;
121
+ const { servers: _servers, ...rest } = operation as Record<string, unknown>;
122
+ return rest;
123
+ }
124
+
125
+ function asSpokeDocument(spec: OpenApiDocument): OpenApiDocument {
126
+ const relayed = new Map<string, Set<string>>();
127
+ for (const { method, path } of RELAY_ROUTES) {
128
+ const methods = relayed.get(path) ?? new Set<string>();
129
+ methods.add(method.toLowerCase());
130
+ relayed.set(path, methods);
131
+ }
132
+ const paths: Record<string, unknown> = {};
133
+ for (const [path, item] of Object.entries(spec.paths ?? {})) {
134
+ const methods = relayed.get(path);
135
+ if (methods === undefined || typeof item !== "object" || item === null) {
136
+ continue;
137
+ }
138
+ const kept = Object.fromEntries(
139
+ Object.entries(item)
140
+ .filter(([key]) =>
141
+ OPERATION_KEYS.has(key) ? methods.has(key) : key !== "servers",
142
+ )
143
+ .map(([key, value]) => [
144
+ key,
145
+ OPERATION_KEYS.has(key) ? withoutServers(value) : value,
146
+ ]),
147
+ );
148
+ if (Object.keys(kept).some((key) => OPERATION_KEYS.has(key))) {
149
+ paths[path] = kept;
150
+ }
151
+ }
152
+ return { ...spec, servers: [{ url: stripSlash(resolveBaseUrl()) }], paths };
153
+ }
154
+
89
155
  function badGateway(message: string): Response {
90
156
  return Response.json(
91
157
  { error: { code: "upstream_unavailable", message } },
@@ -120,7 +186,9 @@ export async function GET(): Promise<Response> {
120
186
  return badGateway("The API answered JSON that is not an OpenAPI document.");
121
187
  }
122
188
 
123
- return Response.json(withAbsoluteServers(spec), {
189
+ const served =
190
+ TOPOLOGY === "spoke" ? asSpokeDocument(spec) : withAbsoluteServers(spec);
191
+ return Response.json(served, {
124
192
  status: 200,
125
193
  headers: { "cache-control": "public, max-age=3600" },
126
194
  });
@@ -2,13 +2,16 @@
2
2
  * Next.js instrumentation hook — runs once per server instance at boot.
3
3
  *
4
4
  * Registers the durable home of a renewed licence key (#6677 DD-6) before any
5
- * request resolves the licence. Node runtime only: the store reaches the
6
- * database through Prisma, which the Edge runtime cannot load.
5
+ * request resolves the licence, through the generated topology seam (#7426
6
+ * D5): a hub registers the database-backed store; a spoke has no database,
7
+ * so its render registers nothing. Node runtime only: the store reaches the
8
+ * database through Prisma, which the Edge runtime cannot load — the import
9
+ * stays inside the runtime check so the Edge bundle never carries it.
7
10
  */
8
11
 
9
12
  export async function register(): Promise<void> {
10
13
  if (process.env.NEXT_RUNTIME === "nodejs") {
11
- const { registerLicenseKeyStore } = await import("./lib/license-key-store");
12
- await registerLicenseKeyStore();
14
+ const { registerSiteStores } = await import("./lib/topology.generated");
15
+ await registerSiteStores();
13
16
  }
14
17
  }
@@ -0,0 +1,96 @@
1
+ import {
2
+ isClosingFooterPath,
3
+ normalizeRoutePath,
4
+ } from "../closing-footer-path";
5
+
6
+ const LOCALES = ["en", "es"];
7
+
8
+ describe("normalizeRoutePath (#7797)", () => {
9
+ it.each([
10
+ ["/about", "/about"],
11
+ ["/about/", "/about"],
12
+ ["/about//", "/about"],
13
+ ["/es/about", "/about"],
14
+ ["/es/about/", "/about"],
15
+ ["/about?x=1", "/about"],
16
+ ["/about#team", "/about"],
17
+ ["/services/first-engagement/", "/services/first-engagement"],
18
+ ["/", "/"],
19
+ ["", "/"],
20
+ ["/es", "/"],
21
+ ["/es/", "/"],
22
+ ])("%s → %s", (input, expected) => {
23
+ expect(normalizeRoutePath(input, LOCALES)).toBe(expected);
24
+ });
25
+
26
+ it("strips only a whole leading locale segment", () => {
27
+ expect(normalizeRoutePath("/espresso", LOCALES)).toBe("/espresso");
28
+ expect(normalizeRoutePath("/about/es", LOCALES)).toBe("/about/es");
29
+ });
30
+
31
+ it("strips no prefix when no locale is enabled", () => {
32
+ expect(normalizeRoutePath("/es/about", [])).toBe("/es/about");
33
+ });
34
+ });
35
+
36
+ describe("isClosingFooterPath (#7797)", () => {
37
+ const paths = ["/about", "/services/first-engagement/"];
38
+
39
+ it.each([
40
+ "/about",
41
+ "/about/",
42
+ "/es/about",
43
+ "/es/about/",
44
+ "/services/first-engagement",
45
+ ])("%s is a listed page", (pathname) => {
46
+ expect(isClosingFooterPath({ pathname, paths, locales: LOCALES })).toBe(
47
+ true,
48
+ );
49
+ });
50
+
51
+ it.each(["/services", "/about/team", "/aboutus", "/privacy", "/es/contact"])(
52
+ "%s is not a listed page",
53
+ (pathname) => {
54
+ expect(isClosingFooterPath({ pathname, paths, locales: LOCALES })).toBe(
55
+ false,
56
+ );
57
+ },
58
+ );
59
+
60
+ it("matches a listed path spelled with a locale prefix or a trailing slash", () => {
61
+ expect(
62
+ isClosingFooterPath({
63
+ pathname: "/about",
64
+ paths: ["/es/about/"],
65
+ locales: LOCALES,
66
+ }),
67
+ ).toBe(true);
68
+ });
69
+
70
+ it("is false with no list, an empty list, or no pathname", () => {
71
+ expect(
72
+ isClosingFooterPath({
73
+ pathname: "/about",
74
+ paths: undefined,
75
+ locales: LOCALES,
76
+ }),
77
+ ).toBe(false);
78
+ expect(
79
+ isClosingFooterPath({ pathname: "/about", paths: [], locales: LOCALES }),
80
+ ).toBe(false);
81
+ expect(
82
+ isClosingFooterPath({ pathname: null, paths, locales: LOCALES }),
83
+ ).toBe(false);
84
+ expect(isClosingFooterPath({ pathname: "", paths, locales: LOCALES })).toBe(
85
+ false,
86
+ );
87
+ });
88
+
89
+ it("never selects the home, even when it is listed", () => {
90
+ for (const pathname of ["/", "/es", "/es/"]) {
91
+ expect(
92
+ isClosingFooterPath({ pathname, paths: ["/"], locales: LOCALES }),
93
+ ).toBe(false);
94
+ }
95
+ });
96
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Tests for the profile back-link rule (#7744 D7).
3
+ *
4
+ * On a deck home, a team profile's back arrow returns to the home's people
5
+ * slide (`#slide-people`, #7740's anchor for the section id `people`);
6
+ * everywhere else it goes home. Covers bare and locale-prefixed profiles, the
7
+ * `/team` index, near misses, a non-deck home and the absent-header fail-safe.
8
+ */
9
+
10
+ import {
11
+ backLinkHref,
12
+ isTeamProfilePath,
13
+ PEOPLE_SLIDE_ANCHOR,
14
+ } from "../profile-back-link";
15
+
16
+ const LOCALES = ["en", "fr"];
17
+
18
+ describe("isTeamProfilePath", () => {
19
+ it.each(["/team/first-member", "/fr/team/first-member", "/en/team/a"])(
20
+ "is true for the profile path %s",
21
+ (pathname) => {
22
+ expect(isTeamProfilePath(pathname, LOCALES)).toBe(true);
23
+ },
24
+ );
25
+
26
+ it.each([
27
+ "/team",
28
+ "/team/",
29
+ "/fr/team",
30
+ "/team/a/b",
31
+ "/teams/x",
32
+ "/about",
33
+ "/",
34
+ "",
35
+ ])("is false for %s", (pathname) => {
36
+ expect(isTeamProfilePath(pathname, LOCALES)).toBe(false);
37
+ });
38
+
39
+ it("treats a prefix that is not an enabled locale as the first segment", () => {
40
+ expect(isTeamProfilePath("/xx/team/a", ["en"])).toBe(false);
41
+ });
42
+
43
+ it("answers false for an absent pathname", () => {
44
+ expect(isTeamProfilePath(null, LOCALES)).toBe(false);
45
+ expect(isTeamProfilePath(undefined, LOCALES)).toBe(false);
46
+ });
47
+ });
48
+
49
+ describe("backLinkHref", () => {
50
+ it("names the people-slide anchor", () => {
51
+ expect(PEOPLE_SLIDE_ANCHOR).toBe("slide-people");
52
+ });
53
+
54
+ it("returns /#slide-people for a profile on a deck home", () => {
55
+ expect(
56
+ backLinkHref({
57
+ pathname: "/team/first-member",
58
+ homePath: "/",
59
+ home: "deck",
60
+ locales: LOCALES,
61
+ }),
62
+ ).toBe("/#slide-people");
63
+ });
64
+
65
+ it("keeps the /fr prefix on a non-default locale", () => {
66
+ expect(
67
+ backLinkHref({
68
+ pathname: "/fr/team/first-member",
69
+ homePath: "/fr",
70
+ home: "deck",
71
+ locales: LOCALES,
72
+ }),
73
+ ).toBe("/fr#slide-people");
74
+ });
75
+
76
+ it.each(["/team", "/team/a/b", "/about", "/teams/x", null, undefined])(
77
+ "returns the home for %s on a deck home",
78
+ (pathname) => {
79
+ expect(
80
+ backLinkHref({ pathname, homePath: "/", home: "deck", locales: LOCALES }),
81
+ ).toBe("/");
82
+ },
83
+ );
84
+
85
+ it("returns the home for a profile when the home is not a deck", () => {
86
+ expect(
87
+ backLinkHref({
88
+ pathname: "/team/first-member",
89
+ homePath: "/",
90
+ home: "default",
91
+ locales: LOCALES,
92
+ }),
93
+ ).toBe("/");
94
+ });
95
+ });