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
@@ -0,0 +1,104 @@
1
+ // ComparisonsList hub (#6878 D9) — the /alternatives page. Reads
2
+ // ComparisonsList MDX via the content adapter, injects a generated `items`
3
+ // section listing every ComparisonPage spoke (so the visible card grid and
4
+ // the CollectionPage's ItemList JSON-LD — built from the same sections —
5
+ // always agree, D9), and emits via the dispatcher.
6
+ //
7
+ // notFound() when the hub seed is absent, so no empty hub is ever served
8
+ // (unlike the /for hub's empty-state branch): an add-page run always writes
9
+ // the seed alongside the first spoke (D11), so a fresh scaffold — which
10
+ // excludes this gated surface entirely — never reaches this branch in
11
+ // production, only a mis-authored site would.
12
+ //
13
+ // Gated off by default (surfaces.ts `alternatives` surface): no roster names
14
+ // this route, so a fresh scaffold excludes it.
15
+ import type {
16
+ getMdxSource as defaultGetMdxSource,
17
+ getPageSections as defaultGetPageSections,
18
+ } from "@working-theory/page-renderer";
19
+ import {
20
+ getMdxSlugs,
21
+ getMdxSource as loadMdxSource,
22
+ getPageSections as loadPageSections,
23
+ renderPage,
24
+ renderPageMetadata,
25
+ } from "@working-theory/page-renderer";
26
+ import type { Metadata } from "next";
27
+ import { notFound } from "next/navigation";
28
+ import type { ReactElement } from "react";
29
+
30
+ interface Props {
31
+ params: Promise<{ locale: string }>;
32
+ getMdxSource?: typeof defaultGetMdxSource;
33
+ getPageSections?: typeof defaultGetPageSections;
34
+ }
35
+
36
+ type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
37
+
38
+ export async function generateMetadata({
39
+ params,
40
+ }: Pick<Props, "params">): Promise<Metadata> {
41
+ const { locale } = await params;
42
+ return renderPageMetadata({
43
+ type: "ComparisonsList",
44
+ slug: "alternatives",
45
+ locale,
46
+ });
47
+ }
48
+
49
+ export default async function Page({
50
+ params,
51
+ getMdxSource,
52
+ getPageSections,
53
+ }: Props): Promise<ReactElement> {
54
+ const { locale } = await params;
55
+ const load = getMdxSource ?? loadMdxSource;
56
+ const loadSections = getPageSections ?? loadPageSections;
57
+
58
+ const source = await load("ComparisonsList", "alternatives", locale);
59
+ if (source === null) {
60
+ notFound();
61
+ }
62
+
63
+ /**
64
+ * Append one generated `items` section listing every ComparisonPage spoke
65
+ * — D9's "the visible list and the CollectionPage's ItemList always
66
+ * agree" property, since `renderPage` builds JSON-LD from these same
67
+ * sections. Absent when there are zero spokes: the hub still renders its
68
+ * own authored sections and answers 200 on its seed alone.
69
+ */
70
+ async function withSpokes(frontmatter: unknown): Promise<Section[]> {
71
+ const declared = await loadSections(frontmatter);
72
+ const slugs = await getMdxSlugs("ComparisonPage", locale);
73
+ if (slugs.length === 0) return declared;
74
+
75
+ const items = await Promise.all(
76
+ slugs.map(async (slug) => {
77
+ const spoke = await load("ComparisonPage", slug, locale);
78
+ const fm = (spoke?.frontmatter ?? {}) as {
79
+ title?: string;
80
+ description?: string;
81
+ };
82
+ return {
83
+ title: fm.title ?? slug,
84
+ description: fm.description ?? "",
85
+ link: { label: "Read the comparison", href: `/vs/${slug}` },
86
+ };
87
+ }),
88
+ );
89
+
90
+ return [
91
+ ...declared,
92
+ { type: "items" as const, id: "comparison-spokes", data: { items } },
93
+ ];
94
+ }
95
+
96
+ return renderPage({
97
+ type: "ComparisonsList",
98
+ slug: "alternatives",
99
+ locale,
100
+ translationsNamespace: "alternatives",
101
+ getPageSections: withSpokes,
102
+ ...(getMdxSource !== undefined && { getMdxSource }),
103
+ });
104
+ }
@@ -1,5 +1,6 @@
1
- // UseCase detail page (#6658 D4) — one use case at /use-cases/<slug>, listed by
2
- // the /use-cases page. Reads the UseCase MDX for the given slug, renders via
1
+ // UseCase detail page (#6658 D4; route renamed to /for by #6878 D2) — one
2
+ // customer page at /for/<slug>, listed by the /for hub. Reads the UseCase MDX
3
+ // for the given slug, renders via
3
4
  // @working-theory/page-renderer, and emits a JSON-LD WebPage whose mainEntity
4
5
  // is an Article. Unknown slugs 404. Composed `sections[]` render as sections
5
6
  // (#6721); a prose page renders its MDX body.
@@ -40,7 +41,7 @@ export default async function Page({
40
41
  }: Props): Promise<ReactElement> {
41
42
  const { locale, slug } = await params;
42
43
  // A page that composes `sections[]` (the starter seeds, #6721) renders
43
- // them; a prose page renders its MDX body — the same rule the /use-cases hub
44
+ // them; a prose page renders its MDX body — the same rule the /for hub
44
45
  // applies. JSON-LD is built from the frontmatter either way.
45
46
  const source = await (getMdxSource ?? loadMdxSource)("UseCase", slug, locale);
46
47
  const composed =
@@ -0,0 +1,73 @@
1
+ /**
2
+ * HTML snapshot regression tests for for/page.tsx → @working-theory/page-renderer migration
3
+ * (route renamed from use-cases/ by #6878 D2).
4
+ *
5
+ * Two-commit strategy:
6
+ * Commit 1: this file added; snapshots captured against pre-migration route (RED-passing).
7
+ * Commit 2: route migrated; same snapshots verify diff=0 (GREEN).
8
+ *
9
+ * Two states tested:
10
+ * - empty: getMdxSource returns null → empty-state branch
11
+ * - populated: getMdxSource returns a minimal MdxPageSource → full render
12
+ *
13
+ * Failure = snapshot diff > 0 bytes. DO NOT regenerate snapshots to pass.
14
+ */
15
+ import React from "react";
16
+ import { render } from "@testing-library/react";
17
+
18
+ // ── @working-theory/ui mock ─────────────────────────────────────────────────────────────
19
+ jest.mock("@working-theory/ui", () => ({
20
+ // #4446/#4447: renderPage's own fallback/empty-state title now composes
21
+ // Heading (mobile type-budget classes, #4417) instead of a bare <h1>.
22
+ Heading: ({
23
+ children,
24
+ level,
25
+ }: {
26
+ children: React.ReactNode;
27
+ level: number;
28
+ }): React.ReactElement => React.createElement(`h${level}`, null, children),
29
+ SectionRenderer: ({ sections }: { sections: unknown[] }) =>
30
+ React.createElement("div", {
31
+ "data-testid": "section-renderer",
32
+ "data-section-count": String(sections.length),
33
+ }),
34
+ }));
35
+
36
+ import ForPage from "../page";
37
+
38
+ const getMdxSourceEmpty = jest.fn().mockResolvedValue(null);
39
+
40
+ describe("ForPage — snapshot regression", () => {
41
+ it("empty state renders deterministic HTML", async () => {
42
+ const element = await ForPage({
43
+ params: Promise.resolve({ locale: "en" }),
44
+ getMdxSource: getMdxSourceEmpty,
45
+ });
46
+ const { container } = render(element);
47
+ expect(container.innerHTML).toMatchInlineSnapshot(
48
+ `"<main id="main"><h1>Customers</h1><p data-testid="section-empty-state">Customer pages coming soon.</p></main>"`,
49
+ );
50
+ });
51
+
52
+ it("populated state renders deterministic HTML", async () => {
53
+ const source = {
54
+ frontmatter: {
55
+ type: "UseCasesList" as const,
56
+ slug: "for",
57
+ title: "Use Cases",
58
+ description: "How teams use our platform.",
59
+ },
60
+ body: "",
61
+ };
62
+ const getMdxSourcePopulated = jest.fn().mockResolvedValue(source);
63
+
64
+ const element = await ForPage({
65
+ params: Promise.resolve({ locale: "en" }),
66
+ getMdxSource: getMdxSourcePopulated,
67
+ });
68
+ const { container } = render(element);
69
+ expect(container.innerHTML).toMatchInlineSnapshot(
70
+ `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Use Cases","description":"How teams use our platform.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Article"}]}}</script><main id="main"><h1>Use Cases</h1><p data-testid="for-description">How teams use our platform.</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-remote" data-source=""></div></article></div></div></main>"`,
71
+ );
72
+ });
73
+ });
@@ -1,5 +1,6 @@
1
- // Use Cases list page. Reads UseCasesList MDX via the content adapter and emits
2
- // a JSON-LD CollectionPage via the dispatcher. List-only — no detail route.
1
+ // Customers (/for) hub page (#6878 D2 — renamed from /use-cases). Reads
2
+ // UseCasesList MDX via the content adapter and emits a JSON-LD CollectionPage
3
+ // via the dispatcher. List-only — no detail route.
3
4
  import type {
4
5
  getMdxSource as defaultGetMdxSource,
5
6
  } from "@working-theory/page-renderer";
@@ -22,7 +23,7 @@ export async function generateMetadata({
22
23
  const { locale } = await params;
23
24
  return renderPageMetadata({
24
25
  type: "UseCasesList",
25
- slug: "use-cases",
26
+ slug: "for",
26
27
  locale,
27
28
  });
28
29
  }
@@ -33,19 +34,19 @@ export default async function Page({
33
34
  }: Props): Promise<ReactElement> {
34
35
  const { locale } = await params;
35
36
  // A UseCasesList page that composes `sections[]` (the Free template's
36
- // /use-cases, operator ruling 2026-09-23) renders them; a prose-only page
37
+ // /for hub, operator ruling 2026-09-23) renders them; a prose-only page
37
38
  // keeps the MDX-body render. The CollectionPage JSON-LD is built from the
38
39
  // sections either way (buildUseCasesList).
39
- const source = await (getMdxSource ?? loadMdxSource)("UseCasesList", "use-cases", locale);
40
+ const source = await (getMdxSource ?? loadMdxSource)("UseCasesList", "for", locale);
40
41
  const composed =
41
42
  source !== null &&
42
43
  Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
43
44
  ((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) > 0;
44
45
  return renderPage({
45
46
  type: "UseCasesList",
46
- slug: "use-cases",
47
+ slug: "for",
47
48
  locale,
48
- translationsNamespace: "use-cases",
49
+ translationsNamespace: "for",
49
50
  ...(composed ? {} : { bodyMode: "mdx-body" as const }),
50
51
  ...(getMdxSource !== undefined && { getMdxSource }),
51
52
  });
@@ -2,7 +2,7 @@
2
2
  // `GlossaryList` MDX with slug `glossary` and emits a JSON-LD CollectionPage
3
3
  // whose mainEntity is a DefinedTermSet built from its `glossary-index`
4
4
  // sections. A module-less surface a template enables by naming it in its
5
- // roster; with no content the route 404s. Mirrors use-cases/page.tsx: a
5
+ // roster; with no content the route 404s. Mirrors for/page.tsx: a
6
6
  // sections-composed page renders its sections, a prose-only page its MDX body.
7
7
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
8
8
  import {
@@ -50,10 +50,12 @@ import { auth } from "../../auth";
50
50
  import { GoogleAnalytics } from "../../components/google-analytics";
51
51
  import { SignOutTrigger } from "../../components/sign-out-trigger";
52
52
  import { isAuthPath } from "../../lib/auth-routes";
53
+ import { assertEnabledLocale } from "../../lib/locale-guard";
53
54
  import { footerColumns, navItems } from "../../lib/nav-config";
54
55
  import { getOgImageUrl } from "../../lib/og";
55
- import { SITE_NAME, siteLogo } from "../../lib/site-brand";
56
+ import { SITE_NAME } from "../../lib/site-brand";
56
57
  import { siteChrome } from "../../lib/site-chrome";
58
+ import { siteLogo } from "../../lib/site-logo";
57
59
  // Registers the `_site.mdx` identity with page-renderer at module load, so
58
60
  // every page rendered under this layout carries the sitewide JSON-LD (#6597).
59
61
  import "../../lib/site-identity";
@@ -211,6 +213,9 @@ export default async function LocaleLayout({
211
213
  params,
212
214
  }: Props): Promise<ReactElement> {
213
215
  const { locale } = await params;
216
+ // #6107 — a segment that is not an enabled locale is a 404, before any
217
+ // message load or session read.
218
+ assertEnabledLocale(locale);
214
219
  const messages = await getMessages();
215
220
  const themeMode = await resolveThemeMode();
216
221
  const consentDecided = await hasConsentChoice();
@@ -227,6 +232,7 @@ export default async function LocaleLayout({
227
232
  // The Free marketing-site chrome (#5469 D5): the Product flyout's three
228
233
  // entries + descriptions, the top-level items, and the Get started CTA.
229
234
  product: tNav("product"),
235
+ services: tNav("services"),
230
236
  useCases: tNav("useCases"),
231
237
  useCasesDesc: tNav("useCasesDesc"),
232
238
  footerSitePagesHeading: tNav("footerSitePagesHeading"),
@@ -238,14 +244,20 @@ export default async function LocaleLayout({
238
244
  feature4: tNav("feature4"),
239
245
  feature5: tNav("feature5"),
240
246
  allFeatures: tNav("allFeatures"),
241
- // #6721 — the two-column Product flyout + the Use Cases footer column.
247
+ // #6878 D6 — one flyout rule for every shape: the offering column
248
+ // (Features or Services) beside the Customers column (the /for
249
+ // persona pages), plus the Customers footer column.
242
250
  flyoutFeaturesHeading: tNav("flyoutFeaturesHeading"),
243
- flyoutUseCasesHeading: tNav("flyoutUseCasesHeading"),
244
- footerUseCasesHeading: tNav("footerUseCasesHeading"),
245
- useCase1: tNav("useCase1"),
246
- useCase2: tNav("useCase2"),
247
- useCase3: tNav("useCase3"),
248
- allUseCases: tNav("allUseCases"),
251
+ flyoutServicesHeading: tNav("flyoutServicesHeading"),
252
+ flyoutCustomersHeading: tNav("flyoutCustomersHeading"),
253
+ footerCustomersHeading: tNav("footerCustomersHeading"),
254
+ allCustomers: tNav("allCustomers"),
255
+ customer1: tNav("customer1"),
256
+ customer2: tNav("customer2"),
257
+ customer3: tNav("customer3"),
258
+ service1: tNav("service1"),
259
+ service2: tNav("service2"),
260
+ service3: tNav("service3"),
249
261
  howItWorks: tNav("howItWorks"),
250
262
  howItWorksDesc: tNav("howItWorksDesc"),
251
263
  features: tNav("features"),
@@ -253,6 +265,7 @@ export default async function LocaleLayout({
253
265
  faq: tNav("faq"),
254
266
  faqDesc: tNav("faqDesc"),
255
267
  pricing: tNav("pricing"),
268
+ portfolio: tNav("portfolio"),
256
269
  about: tNav("about"),
257
270
  // The footer's column groups (#6468): their two headings + Contact.
258
271
  contact: tNav("contact"),
@@ -292,10 +305,13 @@ export default async function LocaleLayout({
292
305
  // enforcement, the callbacks are the hook for @working-theory/consent.
293
306
  const tConsent = await getTranslations({ locale, namespace: "consent" });
294
307
 
295
- // Right-side action cluster (#4984). Signed out → the Free template's pair
296
- // (#5469 D5): a primary "Get started" → /contact beside the outline-strong
297
- // "Sign in" link (the operator's auth-pair idiom, #4370, MINUS Sign up —
298
- // this site offers no sign-up flow). Signed in → the user's name (a
308
+ // Right-side action cluster (#4984). Signed out → the auth pair: an
309
+ // outline-strong "Sign in" → /sign-in beside a primary "Sign up" → /sign-up
310
+ // (the operator's auth-pair idiom, #4370). Every shape ships the (auth)
311
+ // route group, so both routes exist in every scaffold; whether the header
312
+ // shows them is the design's nav setting (#6797 — an earlier revision of
313
+ // this comment said the Free site had no sign-up flow; it renders both and
314
+ // the service shapes now roster both). Signed in → the user's name (a
299
315
  // non-navigating label) + an outline-strong "Sign out"; both authored as
300
316
  // `kind: "flow"` so Nav hands their triggers back through
301
317
  // `renderFlowTrigger` (see NAV_CLIENT_ACTION_FLOW above).
@@ -327,7 +343,7 @@ export default async function LocaleLayout({
327
343
  const hasFlowAction = navActions.some((action) => action.kind === "flow");
328
344
 
329
345
  // Shared brand mark injected into both Nav and Footer chrome slots. The
330
- // mark comes from site-brand.tsx (the mark the /sign-in card also renders);
346
+ // mark comes from site-logo.tsx (the mark the /sign-in card also renders);
331
347
  // both add the shipped wordmark beside it — the Nav composes mark + wordmark
332
348
  // on desktop and mark-only below `md` (#5469 D5), the columns Footer shows
333
349
  // the full lockup at every width (#6507).
@@ -372,11 +388,10 @@ export default async function LocaleLayout({
372
388
  // `(marketing)` group — a ~35-directory move for a five-page delta (spec D1).
373
389
  const isAuthRoute = isAuthPath(pathname, enabledLocales);
374
390
 
375
- // Placeholder social handles + copyright (#5469 D5): `{{COMPANY_HANDLE}}` /
376
- // `{{COMPANY_NAME}}` are create-ailk markers substituted at scaffold time
391
+ // Placeholder social handles (#5469 D5): `{{COMPANY_HANDLE}}` is a
392
+ // create-ailk marker substituted at scaffold time
377
393
  // (packages/create-ailk/src/programmatic.ts), so a customer's site never
378
- // carries the kit vendor's own identity here. The year is computed, never
379
- // typed, so the placeholder does not go stale.
394
+ // carries the kit vendor's own identity here.
380
395
  const socialLinks = [
381
396
  { platform: "facebook", href: "https://facebook.com/{{COMPANY_HANDLE}}" },
382
397
  { platform: "instagram", href: "https://instagram.com/{{COMPANY_HANDLE}}" },
@@ -400,8 +415,9 @@ export default async function LocaleLayout({
400
415
  items: col.items.map((item) => ({ ...item, label: labelFor(item.labelKey) })),
401
416
  }));
402
417
 
403
- // NO_HAND_IMPLEMENT_OK: operator-directed live Free-template pass (2026-09-23).
404
- const copyrightText = `© ${new Date().getFullYear()} SalesSmyth LLC. All rights reserved.`;
418
+ // The site's own name (#6942) — `SITE_NAME` resolves from `_site.mdx`'s
419
+ // `siteName:`, so a customer's footer never carries the kit vendor's name.
420
+ const copyrightText = `© ${new Date().getFullYear()} ${SITE_NAME}. All rights reserved.`;
405
421
 
406
422
  // Footer legal links (#5612 D3). The centered variant folds in FAQ — the
407
423
  // roster's third always-scaffolded core route (`/faq`, alongside `/privacy`
@@ -1,6 +1,6 @@
1
1
  // Newsletter page (#6658 D5) — a NewsletterSignup at /newsletter. A
2
2
  // module-less surface a template enables by naming it in its roster; with no
3
- // content the route 404s. Mirrors use-cases/page.tsx: a sections-composed
3
+ // content the route 404s. Mirrors for/page.tsx: a sections-composed
4
4
  // page renders its sections, a prose-only page its MDX body.
5
5
  //
6
6
  // A signup only when there is somewhere to keep it (#6730 D9). The API says
@@ -1,6 +1,6 @@
1
1
  // Portfolio item page (#6687). Reads the PortfolioItem MDX for the given slug and emits a
2
2
  // JSON-LD <script>. Unknown slugs trigger 404. Renders `sections[]` when the
3
- // frontmatter carries them (the use-cases composed pattern), MDX prose otherwise.
3
+ // frontmatter carries them (the /for composed pattern), MDX prose otherwise.
4
4
  // NO_HAND_IMPLEMENT_OK: /build-starter operator-directed live pass (#6687).
5
5
  import {
6
6
  getMdxSource as defaultGetMdxSource,
@@ -1,7 +1,7 @@
1
1
  // Portfolio list page (#6687). Reads PortfolioList MDX via the content adapter and emits
2
2
  // a JSON-LD CollectionPage via the dispatcher.
3
3
  // NO_HAND_IMPLEMENT_OK: /build-starter operator-directed live pass (#6687) — the
4
- // use-cases composed pattern mirrored onto the portfolio hub: sections when the
4
+ // /for composed pattern mirrored onto the portfolio hub: sections when the
5
5
  // frontmatter carries `sections[]`, MDX prose otherwise (existing prose pages unchanged).
6
6
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
7
7
  import {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Tests for /vs/[slug] (#6878 D9) — the ComparisonPage detail route.
3
+ * `renderPage`/`renderPageMetadata`/`getMdxSlugs` are mocked at the
4
+ * `@working-theory/page-renderer` boundary; `getMdxSource` is supplied
5
+ * per-call through the route's DI prop.
6
+ */
7
+ // The mock return value is never rendered — every assertion below inspects
8
+ // `mockRenderPage.mock.calls` directly — so it carries no `ReactElement`
9
+ // cast; `renderPage`'s real return type is `Promise<ReactElement>` in
10
+ // production, unaffected by this mock's loose type here.
11
+ const mockRenderPage = jest.fn(async (..._args: unknown[]) => "<rendered/>");
12
+ const mockRenderPageMetadata = jest.fn((..._args: unknown[]) => undefined);
13
+ const mockGetMdxSlugs = jest.fn((..._args: unknown[]) => Promise.resolve<string[]>([]));
14
+
15
+ jest.mock("@working-theory/page-renderer", () => ({
16
+ __esModule: true,
17
+ getMdxSlugs: (...args: unknown[]) => mockGetMdxSlugs(...args),
18
+ renderPage: (...args: unknown[]) => mockRenderPage(...args),
19
+ renderPageMetadata: (...args: unknown[]) => mockRenderPageMetadata(...args),
20
+ getMdxSource: jest.fn(),
21
+ }));
22
+
23
+ import ComparisonPage, {
24
+ generateMetadata,
25
+ generateStaticParams,
26
+ } from "../page";
27
+
28
+ beforeEach(() => jest.clearAllMocks());
29
+
30
+ describe("ComparisonPage detail — metadata + static params", () => {
31
+ it("resolves metadata for the given slug", async () => {
32
+ await generateMetadata({
33
+ params: Promise.resolve({ locale: "en", slug: "acme" }),
34
+ });
35
+ expect(mockRenderPageMetadata).toHaveBeenCalledWith({
36
+ type: "ComparisonPage",
37
+ slug: "acme",
38
+ locale: "en",
39
+ });
40
+ });
41
+
42
+ it("enumerates every ComparisonPage slug for static generation", async () => {
43
+ mockGetMdxSlugs.mockResolvedValue(["acme", "globex"]);
44
+ const params = await generateStaticParams({ params: { locale: "en" } });
45
+ expect(mockGetMdxSlugs).toHaveBeenCalledWith("ComparisonPage", "en");
46
+ expect(params).toEqual([{ slug: "acme" }, { slug: "globex" }]);
47
+ });
48
+ });
49
+
50
+ describe("ComparisonPage detail — sections vs mdx-body", () => {
51
+ it("renders in sections mode (D10: hero, decision-split, comparison-table, cta) when frontmatter carries sections", async () => {
52
+ const getMdxSource = jest.fn().mockResolvedValue({
53
+ frontmatter: {
54
+ type: "ComparisonPage",
55
+ slug: "acme",
56
+ title: "vs. Acme",
57
+ description: "How we compare to Acme.",
58
+ sections: [
59
+ { id: "hero", type: "hero", data: {} },
60
+ { id: "compare", type: "decision-split", data: {} },
61
+ { id: "table", type: "comparison-table", data: {} },
62
+ {
63
+ id: "cta",
64
+ type: "cta",
65
+ data: { primaryCta: { label: "See all alternatives", href: "/alternatives" } },
66
+ },
67
+ ],
68
+ },
69
+ body: "",
70
+ });
71
+
72
+ await ComparisonPage({
73
+ params: Promise.resolve({ locale: "en", slug: "acme" }),
74
+ getMdxSource,
75
+ });
76
+
77
+ const passedInput = mockRenderPage.mock.calls[0]?.[0] as {
78
+ type: string;
79
+ slug: string;
80
+ bodyMode?: string;
81
+ };
82
+ expect(passedInput.type).toBe("ComparisonPage");
83
+ expect(passedInput.slug).toBe("acme");
84
+ expect(passedInput.bodyMode).toBeUndefined();
85
+ });
86
+
87
+ it("falls back to mdx-body mode for a prose-only comparison page", async () => {
88
+ const getMdxSource = jest.fn().mockResolvedValue({
89
+ frontmatter: {
90
+ type: "ComparisonPage",
91
+ slug: "acme",
92
+ title: "vs. Acme",
93
+ description: "How we compare to Acme.",
94
+ },
95
+ body: "Prose comparison.",
96
+ });
97
+
98
+ await ComparisonPage({
99
+ params: Promise.resolve({ locale: "en", slug: "acme" }),
100
+ getMdxSource,
101
+ });
102
+
103
+ const passedInput = mockRenderPage.mock.calls[0]?.[0] as {
104
+ bodyMode?: string;
105
+ populatedDescriptionTestId?: string;
106
+ };
107
+ expect(passedInput.bodyMode).toBe("mdx-body");
108
+ expect(passedInput.populatedDescriptionTestId).toBe(
109
+ "comparison-description",
110
+ );
111
+ });
112
+ });
@@ -0,0 +1,70 @@
1
+ // ComparisonPage detail (#6878 D9) — one head-to-head comparison at
2
+ // /vs/<slug>, listed by the /alternatives hub. Reads the ComparisonPage MDX
3
+ // for the given slug, renders via @working-theory/page-renderer, and emits a
4
+ // JSON-LD WebPage. Unknown slugs 404. Composed `sections[]` (D10 — the
5
+ // generic detail chassis, no dedicated comparison section: hero,
6
+ // decision-split, comparison-table, cta) render as sections; a prose page
7
+ // renders its MDX body — the same rule the /for detail page applies.
8
+ //
9
+ // Gated off by default (surfaces.ts `alternatives` surface): no roster names
10
+ // this route, so a fresh scaffold excludes it. `add-page` installs it (D11)
11
+ // the first time a site adds a comparison.
12
+ import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
13
+ import {
14
+ getMdxSource as loadMdxSource,
15
+ getMdxSlugs,
16
+ renderPage,
17
+ renderPageMetadata,
18
+ } from "@working-theory/page-renderer";
19
+ import type { Metadata } from "next";
20
+ import type { ReactElement } from "react";
21
+
22
+ interface Props {
23
+ params: Promise<{ locale: string; slug: string }>;
24
+ getMdxSource?: typeof defaultGetMdxSource;
25
+ }
26
+
27
+ export async function generateMetadata({
28
+ params,
29
+ }: Pick<Props, "params">): Promise<Metadata> {
30
+ const { locale, slug } = await params;
31
+ return renderPageMetadata({ type: "ComparisonPage", slug, locale });
32
+ }
33
+
34
+ export async function generateStaticParams({
35
+ params,
36
+ }: {
37
+ params: { locale: string };
38
+ }): Promise<{ slug: string }[]> {
39
+ const slugs = await getMdxSlugs("ComparisonPage", params.locale);
40
+ return slugs.map((slug) => ({ slug }));
41
+ }
42
+
43
+ export default async function Page({
44
+ params,
45
+ getMdxSource,
46
+ }: Props): Promise<ReactElement> {
47
+ const { locale, slug } = await params;
48
+ const source = await (getMdxSource ?? loadMdxSource)(
49
+ "ComparisonPage",
50
+ slug,
51
+ locale,
52
+ );
53
+ const composed =
54
+ source !== null &&
55
+ Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
56
+ ((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) >
57
+ 0;
58
+ return renderPage({
59
+ type: "ComparisonPage",
60
+ slug,
61
+ locale,
62
+ ...(composed
63
+ ? {}
64
+ : {
65
+ bodyMode: "mdx-body" as const,
66
+ populatedDescriptionTestId: "comparison-description",
67
+ }),
68
+ ...(getMdxSource !== undefined && { getMdxSource }),
69
+ });
70
+ }