create-ailk 0.26.0 → 0.28.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 (224) hide show
  1. package/README.md +86 -9
  2. package/dist/add-module-core.js +2 -1
  3. package/dist/add-module.d.ts +2 -1
  4. package/dist/add-module.js +9 -4
  5. package/dist/add-route.js +46 -19
  6. package/dist/body-heading.d.ts +56 -0
  7. package/dist/body-heading.js +394 -0
  8. package/dist/cli.js +28 -1
  9. package/dist/content-payloads.js +2 -2
  10. package/dist/derive-architecture.js +8 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +4 -1
  13. package/dist/lib/auth-secret-file.d.ts +6 -2
  14. package/dist/lib/auth-secret-file.js +46 -20
  15. package/dist/lib/env-catalog.js +20 -0
  16. package/dist/lib/site-modules.d.ts +8 -0
  17. package/dist/lib/site-modules.js +34 -0
  18. package/dist/module-architecture.js +25 -14
  19. package/dist/paid-sections.d.ts +17 -0
  20. package/dist/paid-sections.js +39 -0
  21. package/dist/programmatic.d.ts +16 -0
  22. package/dist/programmatic.js +73 -4
  23. package/dist/remove-module.d.ts +98 -0
  24. package/dist/remove-module.js +620 -0
  25. package/dist/seed-flows.d.ts +2 -2
  26. package/dist/seed-flows.js +16 -9
  27. package/dist/site-roster.js +4 -1
  28. package/dist/starter-grid.d.ts +26 -0
  29. package/dist/starter-grid.js +34 -0
  30. package/dist/surfaces.d.ts +26 -0
  31. package/dist/surfaces.js +112 -11
  32. package/dist/sync-routes.d.ts +7 -0
  33. package/dist/sync-routes.js +53 -6
  34. package/dist/template-pages.d.ts +36 -0
  35. package/dist/template-pages.js +129 -0
  36. package/dist/topology.d.ts +2 -1
  37. package/dist/topology.js +4 -2
  38. package/dist/update.d.ts +3 -1
  39. package/dist/update.js +90 -6
  40. package/package.json +3 -3
  41. package/release-scope.json +2 -1
  42. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  43. package/templates/.env.example +12 -2
  44. package/templates/CLAUDE.md +20 -20
  45. package/templates/README.md +19 -19
  46. package/templates/apps/api/.env.example +16 -5
  47. package/templates/apps/api/CLAUDE.md +32 -11
  48. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  49. package/templates/apps/api/src/vercel-handler.ts +8 -1
  50. package/templates/apps/api/vercel.json +7 -1
  51. package/templates/apps/mcp/CLAUDE.md +18 -10
  52. package/templates/apps/web/.env.example +27 -0
  53. package/templates/apps/web/CLAUDE.md +56 -8
  54. package/templates/apps/web/__tests__/setup.ts +7 -0
  55. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
  58. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  59. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
  60. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
  61. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  63. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  66. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  67. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  68. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  69. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  70. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  72. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  73. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  74. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  76. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  77. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  78. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  79. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  80. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  81. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  82. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  83. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  84. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  85. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  86. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  87. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  88. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  89. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  90. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  91. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  92. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  93. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  105. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  107. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  108. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  109. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  110. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  111. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  114. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  115. package/templates/apps/web/app/[locale]/layout.tsx +161 -128
  116. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  117. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  118. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  119. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  120. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  121. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  122. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  124. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  126. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  127. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  128. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  129. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  131. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  132. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  133. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  134. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  135. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  136. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  137. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  138. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  139. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  140. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  141. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  142. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  144. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  145. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  146. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  148. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  149. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  150. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  151. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  152. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  153. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  154. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  155. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  156. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  157. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
  160. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  161. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  162. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  163. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  164. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  165. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  166. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  167. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  168. package/templates/apps/web/auth.ts +102 -50
  169. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  170. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  171. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  172. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  173. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  174. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  175. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  176. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  177. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  178. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  179. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  180. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  181. package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
  182. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  183. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  184. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  185. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  187. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  188. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  189. package/templates/apps/web/lib/auth-secret.ts +57 -0
  190. package/templates/apps/web/lib/bot-check.ts +138 -0
  191. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  192. package/templates/apps/web/lib/deck-page.ts +6 -3
  193. package/templates/apps/web/lib/docs-index.ts +100 -0
  194. package/templates/apps/web/lib/docs-path.ts +94 -0
  195. package/templates/apps/web/lib/hub-children.tsx +318 -0
  196. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
  197. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
  198. package/templates/apps/web/lib/modules.generated.ts +9 -0
  199. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  200. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  201. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  202. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  203. package/templates/apps/web/lib/page-chrome.ts +367 -0
  204. package/templates/apps/web/lib/posting-title.ts +26 -4
  205. package/templates/apps/web/lib/pricing-page.ts +430 -0
  206. package/templates/apps/web/lib/relay.ts +44 -4
  207. package/templates/apps/web/lib/route-family.ts +69 -0
  208. package/templates/apps/web/lib/service-listing.ts +89 -0
  209. package/templates/apps/web/lib/services-path.ts +97 -0
  210. package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
  211. package/templates/apps/web/lib/site-header.ts +94 -0
  212. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  213. package/templates/apps/web/lib/template-listing.ts +169 -0
  214. package/templates/apps/web/messages/en.json +144 -3
  215. package/templates/apps/web/middleware.ts +179 -12
  216. package/templates/apps/web/next.config.mjs +220 -3
  217. package/templates/apps/web/package.json +3 -2
  218. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  219. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  220. package/templates/apps/web/sentry.server.config.ts +30 -2
  221. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  222. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  223. package/templates/apps/web/src/lib/routes.ts +15 -1
  224. package/versions.json +48 -48
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Shared cases for the hub child-card tests (#10121).
3
+ *
4
+ * Each hub's test lives beside its route
5
+ * (`app/[locale]/<hub>/__tests__/hub-children.test.tsx`) so a scaffold that
6
+ * drops a gated hub drops its test with it (#10157). This module imports no
7
+ * route, so it ships to every scaffold and type-checks without any hub.
8
+ *
9
+ * `renderPage`/`renderPageMetadata` are mocked at the
10
+ * `@working-theory/page-renderer` boundary in each test file, the /templates
11
+ * test shape: each assertion inspects the sections a hub hands `renderPage`.
12
+ * The JSON-LD is built from those same sections with the real `buildJsonLd`,
13
+ * as `renderPage` does, so the cards and the ItemList are compared on one
14
+ * input per hub.
15
+ */
16
+ import { buildJsonLd } from "@working-theory/schema";
17
+ import type { ReactElement } from "react";
18
+
19
+ export type Section = {
20
+ type: string;
21
+ id?: string;
22
+ data?: Record<string, unknown>;
23
+ };
24
+ export type Loaders = {
25
+ getMdxSource: jest.Mock;
26
+ getPageSections: jest.Mock;
27
+ getMdxSlugs: jest.Mock;
28
+ };
29
+ export type HubPage = (
30
+ props: {
31
+ params: Promise<{ locale: string }>;
32
+ } & Loaders,
33
+ ) => Promise<ReactElement>;
34
+ export type RenderPageMock = jest.Mock<Promise<string>, unknown[]>;
35
+
36
+ /** [route, page, list type, child type, body rule] — one row per hub. */
37
+ export type HubRow = [
38
+ string,
39
+ HubPage,
40
+ string,
41
+ string,
42
+ "sections" | "mdx-body" | "composed",
43
+ ];
44
+
45
+ export const HERO: Section = {
46
+ type: "hero",
47
+ id: "hero",
48
+ data: { headline: "Hub" },
49
+ };
50
+ export const CLOSING: Section = {
51
+ type: "cta",
52
+ id: "closing",
53
+ data: { headline: "Go" },
54
+ };
55
+
56
+ /** Loaders for one hub: its seed, its declared sections, and its children. */
57
+ export function loaders(
58
+ listType: string,
59
+ childType: string,
60
+ children: Record<string, { title?: string; description?: string } | null>,
61
+ declared: Section[],
62
+ hubSections = true,
63
+ ): Loaders {
64
+ return {
65
+ getMdxSource: jest.fn(async (type: string, slug: string) => {
66
+ if (type === listType) {
67
+ return {
68
+ frontmatter: {
69
+ type,
70
+ slug,
71
+ title: "Hub",
72
+ description: "The hub.",
73
+ ...(hubSections && { sections: declared }),
74
+ },
75
+ body: "",
76
+ };
77
+ }
78
+ if (type !== childType) return null;
79
+ const child = children[slug];
80
+ return child === null || child === undefined
81
+ ? null
82
+ : { frontmatter: { type, slug, ...child }, body: "" };
83
+ }),
84
+ getPageSections: jest.fn(async () => declared),
85
+ getMdxSlugs: jest.fn(async (type: string) =>
86
+ type === childType ? Object.keys(children) : [],
87
+ ),
88
+ };
89
+ }
90
+
91
+ /** Renders a hub and returns the one input it handed `renderPage`. */
92
+ export async function renderInput(
93
+ mockRenderPage: RenderPageMock,
94
+ page: HubPage,
95
+ l: Loaders,
96
+ ) {
97
+ mockRenderPage.mockClear();
98
+ await page({ params: Promise.resolve({ locale: "en" }), ...l });
99
+ expect(mockRenderPage).toHaveBeenCalledTimes(1);
100
+ return mockRenderPage.mock.calls[0]?.[0] as {
101
+ type: string;
102
+ slug: string;
103
+ bodyMode?: string;
104
+ trailing?: { props: { sections: Section[] } };
105
+ getPageSections: (fm: unknown) => Promise<Section[]>;
106
+ };
107
+ }
108
+
109
+ export const titlesOf = (section: unknown) =>
110
+ (section as { data: { items: { title: string }[] } } | null)?.data.items.map(
111
+ (i) => i.title,
112
+ );
113
+
114
+ /** The five cases every hub meets, registered as one `describe` per hub. */
115
+ export function describeHub(
116
+ [route, page, listType, childType, body]: HubRow,
117
+ mockRenderPage: RenderPageMock,
118
+ ): void {
119
+ describe(`/${route} hub`, () => {
120
+ // Out of slug order on purpose, plus one slug that does not load.
121
+ const children = {
122
+ zeta: { title: "Zeta", description: "Zeta page." },
123
+ alpha: { title: "Alpha", description: "Alpha page." },
124
+ gone: null,
125
+ beta: { title: "Beta" },
126
+ };
127
+
128
+ it("generates one card per child page, sorted by slug, before the closing CTA", async () => {
129
+ const input = await renderInput(
130
+ mockRenderPage,
131
+ page,
132
+ loaders(listType, childType, children, [HERO, CLOSING]),
133
+ );
134
+ expect(input.type).toBe(listType);
135
+ expect(input.slug).toBe(route);
136
+
137
+ const sections = await input.getPageSections({});
138
+ expect(sections.map((s) => s.type)).toEqual(["hero", "items", "cta"]);
139
+ const items = (sections[1]?.data as { items: unknown[] }).items;
140
+ expect(items).toEqual([
141
+ expect.objectContaining({
142
+ title: "Alpha",
143
+ description: "Alpha page.",
144
+ link: expect.objectContaining({ href: `/${route}/alpha` }),
145
+ }),
146
+ expect.objectContaining({
147
+ title: "Beta",
148
+ description: "",
149
+ link: expect.objectContaining({ href: `/${route}/beta` }),
150
+ }),
151
+ expect.objectContaining({
152
+ title: "Zeta",
153
+ link: expect.objectContaining({ href: `/${route}/zeta` }),
154
+ }),
155
+ ]);
156
+ });
157
+
158
+ it("lists the same pages in the CollectionPage ItemList as in the cards", async () => {
159
+ const input = await renderInput(
160
+ mockRenderPage,
161
+ page,
162
+ loaders(listType, childType, children, [HERO, CLOSING]),
163
+ );
164
+ const sections = await input.getPageSections({});
165
+ const jsonLd = buildJsonLd({
166
+ type: listType,
167
+ slug: route,
168
+ title: "Hub",
169
+ description: "The hub.",
170
+ locale: "en",
171
+ sections,
172
+ } as never) as unknown as {
173
+ mainEntity: { itemListElement: { name?: string }[] };
174
+ };
175
+ expect(jsonLd.mainEntity.itemListElement.map((e) => e.name)).toEqual([
176
+ "Alpha",
177
+ "Beta",
178
+ "Zeta",
179
+ ]);
180
+ });
181
+
182
+ it("gives an already-linked child no second card", async () => {
183
+ const authored: Section = {
184
+ type: "items",
185
+ id: "authored",
186
+ data: {
187
+ items: [
188
+ {
189
+ title: "Alpha (hand-written)",
190
+ link: { label: "Read", href: `/en/${route}/alpha/` },
191
+ },
192
+ {
193
+ title: "Zeta (hand-written)",
194
+ link: {
195
+ label: "Read",
196
+ href: `https://example.com/${route}/zeta?x=1`,
197
+ },
198
+ },
199
+ ],
200
+ },
201
+ };
202
+ const input = await renderInput(
203
+ mockRenderPage,
204
+ page,
205
+ loaders(listType, childType, children, [HERO, authored, CLOSING]),
206
+ );
207
+ const sections = await input.getPageSections({});
208
+ const titles = (s: Section | undefined) =>
209
+ (s?.data as { items: { title: string }[] }).items.map((i) => i.title);
210
+ if (body === "mdx-body") {
211
+ // A prose hub never renders its declared sections, so links there do
212
+ // not count: every child gets a visible card after the body.
213
+ expect(sections.map((s) => s.type)).toEqual([
214
+ "hero",
215
+ "items",
216
+ "items",
217
+ "cta",
218
+ ]);
219
+ expect(titles(sections[2])).toEqual(["Alpha", "Beta", "Zeta"]);
220
+ } else {
221
+ // The missing child continues the authored grid: one list, no duplicate.
222
+ expect(sections.map((s) => s.id)).toEqual([
223
+ "hero",
224
+ "authored",
225
+ "closing",
226
+ ]);
227
+ expect(titles(sections[1])).toEqual([
228
+ "Alpha (hand-written)",
229
+ "Zeta (hand-written)",
230
+ "Beta",
231
+ ]);
232
+ }
233
+ });
234
+
235
+ it("renders its authored sections unchanged with zero child pages", async () => {
236
+ const input = await renderInput(
237
+ mockRenderPage,
238
+ page,
239
+ loaders(listType, childType, {}, [HERO, CLOSING]),
240
+ );
241
+ expect(await input.getPageSections({})).toEqual([HERO, CLOSING]);
242
+ expect(input.trailing).toBeUndefined();
243
+ });
244
+
245
+ it("keeps the hub's body rule, rendering the cards after a prose body", async () => {
246
+ const prose = await renderInput(
247
+ mockRenderPage,
248
+ page,
249
+ loaders(listType, childType, children, [], false),
250
+ );
251
+ if (body === "sections") {
252
+ expect(prose.bodyMode).toBeUndefined();
253
+ expect(prose.trailing).toBeUndefined();
254
+ } else {
255
+ // A prose seed renders its MDX body, with the cards after it.
256
+ expect(prose.bodyMode).toBe("mdx-body");
257
+ expect(prose.trailing?.props.sections.map((s) => s.type)).toEqual([
258
+ "items",
259
+ ]);
260
+ expect(await prose.getPageSections({})).toEqual(
261
+ prose.trailing?.props.sections,
262
+ );
263
+ }
264
+
265
+ const composed = await renderInput(
266
+ mockRenderPage,
267
+ page,
268
+ loaders(listType, childType, children, [HERO, CLOSING]),
269
+ );
270
+ if (body === "mdx-body") {
271
+ expect(composed.bodyMode).toBe("mdx-body");
272
+ } else {
273
+ expect(composed.bodyMode).toBeUndefined();
274
+ expect(composed.trailing).toBeUndefined();
275
+ }
276
+ });
277
+ });
278
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ // #10045 — every nav/footer key resolves from the `nav` messages namespace by
5
+ // key (D1), and a key missing from any locale's messages file fails the build
6
+ // naming the key and the file (D2).
7
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ import {
12
+ findMissingNavLabels,
13
+ formatMissingNavLabels,
14
+ } from "../nav-label-check";
15
+ import {
16
+ collectNavLabelKeys,
17
+ navMessageKeys,
18
+ usedNavLabelKeys,
19
+ } from "../nav-label-keys";
20
+
21
+ describe("collectNavLabelKeys", () => {
22
+ it("collects labelKey, headingKey and descKey at any depth", () => {
23
+ const keys = collectNavLabelKeys(
24
+ [
25
+ {
26
+ labelKey: "product",
27
+ path: "/p",
28
+ flyout: {
29
+ features: [
30
+ { labelKey: "featureA", descKey: "featureADesc", path: "/a" },
31
+ ],
32
+ groups: [
33
+ {
34
+ headingKey: "groupHeading",
35
+ items: [{ labelKey: "groupItem", path: "/g" }],
36
+ },
37
+ ],
38
+ },
39
+ },
40
+ ],
41
+ [
42
+ {
43
+ headingKey: "footerHeading",
44
+ items: [{ labelKey: "footerItem", path: "/f" }],
45
+ },
46
+ ],
47
+ );
48
+ expect([...keys].sort()).toEqual(
49
+ [
50
+ "featureA",
51
+ "featureADesc",
52
+ "footerHeading",
53
+ "footerItem",
54
+ "groupHeading",
55
+ "groupItem",
56
+ "product",
57
+ ].sort(),
58
+ );
59
+ });
60
+
61
+ it("ignores non-string key fields and other string properties", () => {
62
+ const keys = collectNavLabelKeys([
63
+ { labelKey: 3, path: "/x", title: "nope" },
64
+ ]);
65
+ expect([...keys]).toEqual([]);
66
+ });
67
+ });
68
+
69
+ describe("navMessageKeys", () => {
70
+ it("returns every string key of the nav namespace", () => {
71
+ expect(
72
+ navMessageKeys({ nav: { a: "A", b: "B", nested: { c: "C" } } }),
73
+ ).toEqual(["a", "b"]);
74
+ });
75
+
76
+ it("is empty when there is no nav namespace", () => {
77
+ expect(navMessageKeys({})).toEqual([]);
78
+ expect(navMessageKeys(undefined)).toEqual([]);
79
+ });
80
+ });
81
+
82
+ describe("findMissingNavLabels", () => {
83
+ function fixture(files: Record<string, unknown>): string {
84
+ const dir = mkdtempSync(join(tmpdir(), "nav-labels-"));
85
+ mkdirSync(join(dir, "site"));
86
+ for (const [name, body] of Object.entries(files)) {
87
+ writeFileSync(join(dir, name), JSON.stringify(body));
88
+ }
89
+ return dir;
90
+ }
91
+
92
+ it("reports nothing when every key is present", () => {
93
+ const dir = fixture({
94
+ "en.json": { nav: { home: "Home" } },
95
+ "site/en.json": {},
96
+ });
97
+ expect(findMissingNavLabels(dir, ["home"])).toEqual([]);
98
+ });
99
+
100
+ it("names the missing key and the messages file", () => {
101
+ const dir = fixture({
102
+ "en.json": { nav: { home: "Home", websiteRebuild: "Website rebuild" } },
103
+ "fr.json": { nav: { home: "Accueil" } },
104
+ "site/en.json": {},
105
+ });
106
+ const missing = findMissingNavLabels(dir, ["home", "websiteRebuild"]);
107
+ expect(missing).toEqual([
108
+ { key: "websiteRebuild", file: join(dir, "fr.json") },
109
+ ]);
110
+ const message = formatMissingNavLabels(missing);
111
+ expect(message).toContain('"websiteRebuild"');
112
+ expect(message).toContain(join(dir, "fr.json"));
113
+ });
114
+
115
+ it("accepts a key the site overlay adds (messages/site/<locale>.json)", () => {
116
+ const dir = fixture({
117
+ "en.json": { nav: { home: "Home" } },
118
+ "site/en.json": { nav: { aiGrowthPlatform: "AI growth platform" } },
119
+ });
120
+ expect(findMissingNavLabels(dir, ["aiGrowthPlatform"])).toEqual([]);
121
+ });
122
+
123
+ it("falls back to the English overlay for a locale with no overlay of its own", () => {
124
+ const dir = fixture({
125
+ "en.json": { nav: {} },
126
+ "fr.json": { nav: {} },
127
+ "site/en.json": { nav: { aiGrowthPlatform: "AI growth platform" } },
128
+ });
129
+ expect(findMissingNavLabels(dir, ["aiGrowthPlatform"])).toEqual([]);
130
+ });
131
+
132
+ it("every key nav-config.ts and the layout use exists in every shipped locale", () => {
133
+ const keys = usedNavLabelKeys();
134
+ expect(
135
+ findMissingNavLabels(join(__dirname, "..", "..", "messages"), keys),
136
+ ).toEqual([]);
137
+ });
138
+ });
@@ -10,6 +10,13 @@ import { subscribeNewsletterAction } from "../newsletter.actions.js";
10
10
  // `flows.get` answers 404 by default — a Free build with no flow route — so
11
11
  // the #6730 cases below take `leads.subscribe`; the #8847 cases serve the
12
12
  // newsletter magnet.
13
+ // #9255 — the write bot check's verdict. `checkBotId` reads Vercel's
14
+ // request context, which jest has none of, so every case decides it here.
15
+ const checkBotIdMock = jest.fn();
16
+ jest.mock("botid/server", () => ({
17
+ checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
18
+ }));
19
+
13
20
  jest.mock("@working-theory/api-client", () => ({
14
21
  flows: {
15
22
  get: jest.fn().mockResolvedValue({ ok: false, status: 404, body: {} }),
@@ -258,10 +265,25 @@ describe("subscribeNewsletterAction — through the newsletter magnet (#8847 D11
258
265
  expect(mockSubscribe).not.toHaveBeenCalled();
259
266
  });
260
267
 
268
+ // On Vercel, where the edge-set address is read (#9967 D1); the bot check
269
+ // has its own suite below.
270
+ const LIMIT_ENV_KEYS = ["VERCEL", "AILK_BOT_CHECK"] as const;
271
+ const priorLimitEnv = new Map(LIMIT_ENV_KEYS.map((name) => [name, process.env[name]]));
272
+ beforeEach(() => {
273
+ process.env.VERCEL = "1";
274
+ process.env.AILK_BOT_CHECK = "off";
275
+ });
276
+ afterEach(() => {
277
+ for (const [name, value] of priorLimitEnv) {
278
+ if (value === undefined) delete process.env[name];
279
+ else process.env[name] = value;
280
+ }
281
+ });
282
+
261
283
  it("forwards the visitor's address to the step route, and refuses a visitor's 31st post in ten minutes (security review)", async () => {
262
284
  servedMagnet();
263
285
  mockHeaders.mockResolvedValue({
264
- get: (name: string) => (name === "x-forwarded-for" ? "203.0.113.9" : null),
286
+ get: (name: string) => (name === "x-vercel-forwarded-for" ? "198.51.100.7" : null),
265
287
  });
266
288
  mockSubmitStep.mockResolvedValue({
267
289
  ok: true,
@@ -273,7 +295,7 @@ describe("subscribeNewsletterAction — through the newsletter magnet (#8847 D11
273
295
  expect(await subscribeNewsletterAction(null, formWith(`a${i}@b.co`))).toEqual({ kind: "success" });
274
296
  }
275
297
  expect(mockSubmitStep.mock.calls[0]![2]).toEqual({
276
- headers: expect.objectContaining({ "x-forwarded-for": "203.0.113.9" }),
298
+ headers: expect.objectContaining({ "x-forwarded-for": "198.51.100.7" }),
277
299
  });
278
300
 
279
301
  expect(await subscribeNewsletterAction(null, formWith("late@b.co"))).toMatchObject({
@@ -283,6 +305,46 @@ describe("subscribeNewsletterAction — through the newsletter magnet (#8847 D11
283
305
  expect(mockSubmitStep).toHaveBeenCalledTimes(30);
284
306
  });
285
307
 
308
+ it("off Vercel, never forwards a client-sent x-forwarded-for to the step route (#9967 D1)", async () => {
309
+ delete process.env.VERCEL;
310
+ servedMagnet();
311
+ mockHeaders.mockResolvedValue({
312
+ get: (name: string) => (name === "x-forwarded-for" ? "203.0.113.9" : null),
313
+ });
314
+ mockSubmitStep.mockResolvedValue({
315
+ ok: true,
316
+ status: 200,
317
+ body: { sessionId: "s1", nextStepKey: null, completed: true },
318
+ });
319
+
320
+ expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toEqual({ kind: "success" });
321
+ const stepInit = mockSubmitStep.mock.calls[0]![2] as { headers?: Record<string, string> } | undefined;
322
+ expect(stepInit?.headers?.["x-forwarded-for"]).toBeUndefined();
323
+ });
324
+
325
+ it("with no readable address, every post shares one bucket at ten times the limit, never skipped (#9967 D1)", async () => {
326
+ servedMagnet();
327
+ let n = 0;
328
+ mockHeaders.mockImplementation(async () => {
329
+ n += 1;
330
+ return { get: (name: string) => (name === "x-forwarded-for" ? `10.0.${n >> 8}.${n & 255}` : null) };
331
+ });
332
+ mockSubmitStep.mockResolvedValue({
333
+ ok: true,
334
+ status: 200,
335
+ body: { sessionId: "s1", nextStepKey: null, completed: true },
336
+ });
337
+
338
+ for (let i = 0; i < 300; i += 1) {
339
+ expect(await subscribeNewsletterAction(null, formWith(`a${i}@b.co`))).toEqual({ kind: "success" });
340
+ }
341
+ expect(await subscribeNewsletterAction(null, formWith("late@b.co"))).toMatchObject({
342
+ kind: "error",
343
+ message: expect.any(String),
344
+ });
345
+ expect(mockSubmitStep).toHaveBeenCalledTimes(300);
346
+ });
347
+
286
348
  it("with flows.get 404 (a Free build) the same post calls leads.subscribe and not submitStep", async () => {
287
349
  mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
288
350
 
@@ -291,3 +353,117 @@ describe("subscribeNewsletterAction — through the newsletter magnet (#8847 D11
291
353
  expect(mockSubmitStep).not.toHaveBeenCalled();
292
354
  });
293
355
  });
356
+
357
+ // #9255 D4 — the write bot check runs once per signup, on whichever write
358
+ // path the signup takes: the magnet's `flows.submitStep` or the Free path's
359
+ // `leads.subscribe` (spec docs/specs/9255-an-edge-bot-challenge-in-spec.md
360
+ // §5.1 AC-2).
361
+ describe("subscribeNewsletterAction — the write bot check (#9255)", () => {
362
+ const BOT = { isHuman: false, isBot: true, isVerifiedBot: false, bypassed: false };
363
+ const HUMAN = { ...BOT, isHuman: true, isBot: false };
364
+ const BOT_ENV_KEYS = ["VERCEL", "AILK_BOT_CHECK", "AILK_BOOKING_BOT_CHECK"] as const;
365
+ const priorBotEnv = new Map(BOT_ENV_KEYS.map((name) => [name, process.env[name]]));
366
+
367
+ let botLogSpies: jest.SpyInstance[] = [];
368
+
369
+ beforeEach(() => {
370
+ checkBotIdMock.mockReset();
371
+ for (const name of BOT_ENV_KEYS) delete process.env[name];
372
+ process.env.VERCEL = "1";
373
+ botLogSpies = [
374
+ jest.spyOn(console, "warn").mockImplementation(() => undefined),
375
+ jest.spyOn(console, "error").mockImplementation(() => undefined),
376
+ ];
377
+ });
378
+
379
+ afterEach(() => {
380
+ for (const [name, value] of priorBotEnv) {
381
+ if (value === undefined) delete process.env[name];
382
+ else process.env[name] = value;
383
+ }
384
+ for (const spy of botLogSpies) spy.mockRestore();
385
+ });
386
+
387
+ function servedMagnet(): void {
388
+ const { completionEmail: _omitted, ...body } = newsletterFlowConfig;
389
+ mockFlowsGet.mockResolvedValue({ ok: true, status: 200, body });
390
+ }
391
+ afterEach(() => {
392
+ mockFlowsGet.mockResolvedValue({ ok: false, status: 404, body: {} });
393
+ });
394
+
395
+ const PATHS = [
396
+ {
397
+ path: "the magnet's flows.submitStep",
398
+ serve: servedMagnet,
399
+ write: () => mockSubmitStep,
400
+ answer: { ok: true, status: 200, body: { sessionId: "s1", nextStepKey: null, completed: true } },
401
+ },
402
+ {
403
+ path: "the Free path's leads.subscribe",
404
+ serve: () => undefined,
405
+ write: () => mockSubscribe,
406
+ answer: { ok: true, body: { id: "subscr_1", persisted: "db" } },
407
+ },
408
+ ];
409
+
410
+ it.each(PATHS)("$path: a bot gets the generic error, and nothing is sent", async ({ serve }) => {
411
+ serve();
412
+ checkBotIdMock.mockResolvedValue(BOT);
413
+ expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toStrictEqual({
414
+ kind: "error",
415
+ message: "Something went wrong, please try again.",
416
+ });
417
+ expect(checkBotIdMock).toHaveBeenCalledTimes(1);
418
+ expect(mockSubmitStep).not.toHaveBeenCalled();
419
+ expect(mockSubscribe).not.toHaveBeenCalled();
420
+ });
421
+
422
+ it.each(PATHS)("$path: a check that fails is unavailable, and nothing is sent", async ({ serve }) => {
423
+ serve();
424
+ checkBotIdMock.mockRejectedValue(new Error("boom"));
425
+ expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toStrictEqual({
426
+ kind: "unavailable",
427
+ });
428
+ expect(checkBotIdMock).toHaveBeenCalledTimes(1);
429
+ expect(mockSubmitStep).not.toHaveBeenCalled();
430
+ expect(mockSubscribe).not.toHaveBeenCalled();
431
+ });
432
+
433
+ it.each(PATHS)(
434
+ "$path: a human is checked once and sends exactly what the check off sends",
435
+ async ({ serve, write, answer }) => {
436
+ serve();
437
+ checkBotIdMock.mockResolvedValue(HUMAN);
438
+ write().mockResolvedValue(answer);
439
+ delete process.env.VERCEL;
440
+ await subscribeNewsletterAction(null, formWith("a@b.co"));
441
+ process.env.VERCEL = "1";
442
+ expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toEqual({ kind: "success" });
443
+ expect(checkBotIdMock).toHaveBeenCalledTimes(1);
444
+ expect(write()).toHaveBeenCalledTimes(2);
445
+ expect(write().mock.calls[1]).toEqual(write().mock.calls[0]);
446
+ },
447
+ );
448
+
449
+ it.each(PATHS)("$path: a filled honeypot answers success with no check", async ({ serve }) => {
450
+ serve();
451
+ checkBotIdMock.mockResolvedValue(BOT);
452
+ const fd = formWith("a@b.co");
453
+ fd.append("ailk_hp", "spam");
454
+ expect(await subscribeNewsletterAction(null, fd)).toEqual({ kind: "success" });
455
+ expect(checkBotIdMock).not.toHaveBeenCalled();
456
+ });
457
+
458
+ it.each(PATHS)("$path: a post without consent answers the consent error with no check", async ({ serve }) => {
459
+ serve();
460
+ checkBotIdMock.mockResolvedValue(BOT);
461
+ const fd = new FormData();
462
+ fd.append("email", "a@b.co");
463
+ expect(await subscribeNewsletterAction(null, fd)).toEqual({
464
+ kind: "error",
465
+ fieldErrors: { consent: "Please accept the terms to subscribe." },
466
+ });
467
+ expect(checkBotIdMock).not.toHaveBeenCalled();
468
+ });
469
+ });