create-ailk 0.22.0 → 0.24.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 (287) hide show
  1. package/README.md +27 -14
  2. package/component-catalog.json +149 -5
  3. package/component-catalog.md +7 -6
  4. package/dist/add-module.js +17 -10
  5. package/dist/add-route.d.ts +55 -1
  6. package/dist/add-route.js +301 -13
  7. package/dist/cli.js +27 -10
  8. package/dist/derive-architecture.d.ts +7 -5
  9. package/dist/derive-architecture.js +26 -32
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +0 -4
  12. package/dist/lib/npm-auth.d.ts +9 -0
  13. package/dist/lib/npm-auth.js +48 -0
  14. package/dist/lib/scaffold-render-sweep.d.ts +36 -2
  15. package/dist/lib/scaffold-render-sweep.js +50 -8
  16. package/dist/module-architecture.js +115 -10
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/seed-flows.d.ts +4 -1
  20. package/dist/seed-flows.js +50 -4
  21. package/dist/spoke-checkout.d.ts +38 -35
  22. package/dist/spoke-checkout.js +66 -229
  23. package/dist/starter-grid.d.ts +0 -3
  24. package/dist/starter-grid.js +24 -14
  25. package/dist/surfaces.js +33 -12
  26. package/dist/sync-routes.d.ts +68 -5
  27. package/dist/sync-routes.js +455 -29
  28. package/dist/template-record.d.ts +1 -1
  29. package/dist/template-record.js +3 -2
  30. package/dist/update.js +10 -0
  31. package/package.json +3 -3
  32. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  33. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  35. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  38. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  40. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  41. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  43. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  44. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  46. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  47. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  48. package/templates/.env.example +13 -7
  49. package/templates/.github/workflows/ci.yml +13 -0
  50. package/templates/CLAUDE.md +4 -2
  51. package/templates/README.md +1 -0
  52. package/templates/_gitignore +4 -0
  53. package/templates/apps/api/.env.example +34 -0
  54. package/templates/apps/api/CLAUDE.md +94 -19
  55. package/templates/apps/api/package.json +1 -0
  56. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  57. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  58. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  59. package/templates/apps/api/src/config/index.ts +109 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
  61. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  62. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  63. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  64. package/templates/apps/api/src/lib/site.ts +4 -1
  65. package/templates/apps/api/src/lib/stripe.ts +77 -4
  66. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  67. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  68. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  69. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  70. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  71. package/templates/apps/api/src/openapi/spec.ts +343 -7
  72. package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
  73. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  74. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
  75. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  76. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  77. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  78. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  79. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  80. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  81. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  82. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  83. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  84. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  85. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  87. package/templates/apps/api/src/routes/content/index.ts +16 -2
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  89. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  90. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  91. package/templates/apps/api/src/routes/content/search.ts +16 -45
  92. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  93. package/templates/apps/api/src/routes/leads/index.ts +377 -308
  94. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  95. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  96. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  97. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  98. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  99. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  100. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  101. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
  102. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  103. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  104. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  105. package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
  106. package/templates/apps/api/src/server.ts +165 -51
  107. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  108. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  109. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
  110. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  111. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  112. package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
  113. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
  114. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  115. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  116. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  117. package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
  118. package/templates/apps/api/src/services/entitlements.ts +157 -1
  119. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  120. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
  121. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  122. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
  123. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  124. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
  125. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
  126. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  127. package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
  128. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
  129. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  130. package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
  131. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  132. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  133. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  134. package/templates/apps/api/src/services/person.ts +147 -9
  135. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  136. package/templates/apps/api/src/services/project-listings.ts +43 -31
  137. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  138. package/templates/apps/mcp/CLAUDE.md +32 -18
  139. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  140. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  141. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  142. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  143. package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
  144. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  145. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  146. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  147. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  148. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  149. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  150. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  151. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  152. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  153. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  154. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  155. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  156. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  157. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  158. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  159. package/templates/apps/mcp/package.json +1 -0
  160. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  161. package/templates/apps/mcp/src/http-transport.ts +47 -9
  162. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  163. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  164. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  165. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  166. package/templates/apps/mcp/src/server.ts +5 -0
  167. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  168. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  169. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  170. package/templates/apps/mcp/src/tools/index.ts +22 -21
  171. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  172. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  173. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  174. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  175. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  176. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  177. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  178. package/templates/apps/web/.env.example +10 -31
  179. package/templates/apps/web/CLAUDE.md +324 -42
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  183. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  184. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  185. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  186. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  187. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  188. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  189. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  190. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  191. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  192. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  193. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  194. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  195. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  196. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  197. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
  198. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  199. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  200. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  201. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  202. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  203. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  204. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  205. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  206. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
  207. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  208. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  209. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  210. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  211. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  212. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  213. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  214. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  215. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  216. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  217. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  218. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  219. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  220. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  221. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  222. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  223. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  224. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  225. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  226. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  227. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
  228. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  229. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  230. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  231. package/templates/apps/web/lib/auth-routes.ts +36 -2
  232. package/templates/apps/web/lib/checkout-config.ts +9 -20
  233. package/templates/apps/web/lib/deck-page.ts +1 -0
  234. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  235. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  236. package/templates/apps/web/lib/magnet-routes.ts +44 -0
  237. package/templates/apps/web/lib/modules.generated.ts +9 -0
  238. package/templates/apps/web/lib/newsletter.actions.ts +205 -0
  239. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  240. package/templates/apps/web/lib/posting-title.ts +17 -0
  241. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  242. package/templates/apps/web/messages/en.json +49 -15
  243. package/templates/apps/web/middleware.ts +12 -2
  244. package/templates/apps/web/security-headers.config.ts +17 -0
  245. package/templates/content/en/BlogList/blog.mdx +4 -71
  246. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  247. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  248. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  249. package/templates/content/en/BookCall/start-project.mdx +1 -17
  250. package/templates/content/en/ContactPage/contact.mdx +6 -63
  251. package/templates/content/en/HomePage/home.mdx +3 -21
  252. package/templates/database/CHANGELOG.md +51 -0
  253. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  254. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  255. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  256. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  257. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  258. package/templates/database/__tests__/seed.test.ts +63 -1
  259. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  260. package/templates/database/auth/schema.prisma +4 -0
  261. package/templates/database/config/schema.prisma +91 -0
  262. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  263. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  264. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  265. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  266. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  267. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  268. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  269. package/templates/database/package.json +1 -1
  270. package/templates/database/seed.ts +57 -2
  271. package/templates/tools/lint-architecture.ts +205 -1
  272. package/versions.json +35 -35
  273. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  274. package/templates/apps/api/src/routes/content/slug.ts +0 -9
  275. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  276. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  277. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  278. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  279. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  280. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  281. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  282. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  283. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  284. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  285. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  286. package/templates/apps/web/lib/checkout.ts +0 -93
  287. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -33,9 +33,10 @@ jest.mock("next-mdx-remote/rsc", () => ({
33
33
  React.createElement("div", { "data-testid": "mdx-rendered" }, source),
34
34
  }));
35
35
 
36
- // ── #8746 — the job page's #apply section reads the API and _site.mdx ─────────
37
- // Stubbed so the migration snapshot stays offline and deterministic; the
38
- // section itself is covered by job-apply-page.test.tsx.
36
+ // ── #8746 — the careers routes once read the API and _site.mdx ────────────────
37
+ // Stubbed so the migration snapshot stays offline and deterministic. Since
38
+ // #8849 neither page reads them (the form is on the apply page, covered by
39
+ // job-apply-page.test.tsx).
39
40
  jest.mock("@working-theory/api-client", () => ({
40
41
  flows: {
41
42
  get: jest.fn().mockResolvedValue({ ok: false, status: 404, body: null }),
@@ -75,23 +76,62 @@ describe("careers/page — snapshot regression", () => {
75
76
  });
76
77
 
77
78
  it("populated state renders deterministic HTML", async () => {
78
- const getMdxSourcePopulated = jest.fn().mockResolvedValue({
79
- frontmatter: {
80
- type: "CareersList",
81
- slug: "careers",
82
- title: "Careers at AILK",
83
- description: "Join the team building the AX-first platform.",
84
- },
85
- body: "mdx-body-content",
86
- });
79
+ // #8849 D10 — sections mode: one generated items section per posting,
80
+ // first, with the search box; the ItemList carries ListItems.
81
+ const getMdxSourcePopulated = jest.fn<
82
+ ReturnType<typeof pageRenderer.getMdxSource>,
83
+ Parameters<typeof pageRenderer.getMdxSource>
84
+ >(async (type) =>
85
+ type === "CareersList"
86
+ ? {
87
+ frontmatter: {
88
+ type: "CareersList" as const,
89
+ slug: "careers",
90
+ title: "Careers at AILK",
91
+ description: "Join the team building the AX-first platform.",
92
+ },
93
+ body: "mdx-body-content",
94
+ }
95
+ : {
96
+ frontmatter: {
97
+ type: "JobPosting" as const,
98
+ slug: "senior-engineer",
99
+ title: "Senior Engineer",
100
+ description: "Build the AILK platform.",
101
+ employmentType: "FULL_TIME" as const,
102
+ location: "Remote (US)",
103
+ },
104
+ body: "",
105
+ },
106
+ );
87
107
 
88
108
  const element = await CareersPage({
89
109
  params: Promise.resolve({ locale: "en" }),
90
110
  getMdxSource: getMdxSourcePopulated,
111
+ getMdxSlugs: jest.fn().mockResolvedValue(["senior-engineer"]),
91
112
  });
92
113
  const { container } = render(element);
114
+ const jsonLd = JSON.parse(
115
+ container.querySelector("[data-testid='json-ld']")!.textContent!,
116
+ ) as { mainEntity: { itemListElement: Record<string, unknown>[] } };
117
+ expect(jsonLd.mainEntity.itemListElement).toEqual([
118
+ {
119
+ "@type": "ListItem",
120
+ position: 1,
121
+ url: "http://localhost:3000/en/careers/senior-engineer",
122
+ item: {
123
+ "@type": "JobPosting",
124
+ name: "Senior Engineer",
125
+ description: "Build the AILK platform.",
126
+ },
127
+ },
128
+ ]);
129
+ expect(
130
+ container.querySelector("[data-testid='items-filter-search']"),
131
+ ).not.toBeNull();
132
+ expect(container.querySelector("[data-testid='mdx-body']")).toBeNull();
93
133
  expect(container.innerHTML).toMatchInlineSnapshot(
94
- `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Careers at AILK","description":"Join the team building the AX-first platform.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"JobPosting"}]}}</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">Careers at AILK</h1><p data-testid="careers-description" class="mt-block-tight text-lead">Join the team building the AX-first platform.</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 motion-safe:transition motion-safe:duration-(--button-hover-duration) 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) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) 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 motion-safe:transition motion-safe:duration-(--button-hover-duration) 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) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) 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-text-muted) 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">mdx-body-content</div></div></article></div></div></div></main>"`,
134
+ `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Careers at AILK","description":"Join the team building the AX-first platform.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"ListItem","position":1,"url":"http://localhost:3000/en/careers/senior-engineer","item":{"@type":"JobPosting","name":"Senior Engineer","description":"Build the AILK platform."}}]}}</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">Careers at AILK</h1><p data-testid="careers-description" class="mt-block-tight text-lead">Join the team building the AX-first platform.</p></div><div class="ailk-section-renderer" data-testid="section-renderer"><span id="section-open-positions" class="ailk-section-anchor" aria-hidden="true" data-testid="section-anchor"></span><section class="py-section bg-(--color-bg)" data-testid="items" data-section="items"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8"><div class="mb-block-loose" data-testid="section-frame-header"><div><h2 class="text-heading-1 lg:text-display-sm font-display text-fg whitespace-pre-line max-w-3xl">Open positions</h2></div></div><div data-testid="items-filter-island" class="mt-10"><div class="flex flex-col gap-4 md:flex-row md:items-end" data-testid="items-filter"><div class="flex flex-col gap-1.5 md:flex-1"><div class="flex items-center justify-between gap-2"><label class="text-body-sm font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70" for="_r_0_-search">Search roles</label></div><input data-testid="items-filter-search" class="flex rounded-control border-hairline text-body-sm text-(--color-fg) file:border-0 file:bg-transparent file:text-body-sm file:font-medium file:text-(--color-fg) placeholder:text-(--color-text-muted) focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 motion-safe:transition-colors bg-(--color-surface-raised) border-(--color-border-strong) hover:enabled:border-transparent hover:enabled:ring-1 hover:enabled:ring-(--color-border-strong) focus-visible:border-transparent focus-visible:ring-(--color-brand) h-(--control-height-default) px-3 py-1.5 w-full" id="_r_0_-search" type="search" value=""></div><div class="flex flex-col gap-1.5 md:w-56"><div class="flex items-center justify-between gap-2"><label class="text-body-sm font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70" for="_r_0_-type">Type</label></div><button type="button" role="combobox" aria-expanded="false" aria-autocomplete="none" dir="ltr" data-state="closed" data-testid="items-filter-facet-type" class="flex w-full items-center justify-between rounded-control border-hairline border-(--color-border) bg-(--color-surface-secondary) text-body-sm text-(--color-fg) placeholder:text-(--color-text-muted) focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-1 focus-visible:ring-(--color-accent) hover:enabled:border-transparent hover:enabled:ring-1 hover:enabled:ring-(--color-border) disabled:cursor-not-allowed disabled:opacity-50 h-(--control-height-default) px-3 py-1.5 w-full" id="_r_0_-type"><span style="pointer-events: none;">All types</span><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 opacity-50" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg></button></div><div class="flex flex-col gap-1.5 md:w-56"><div class="flex items-center justify-between gap-2"><label class="text-body-sm font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70" for="_r_0_-location">Location</label></div><button type="button" role="combobox" aria-expanded="false" aria-autocomplete="none" dir="ltr" data-state="closed" data-testid="items-filter-facet-location" class="flex w-full items-center justify-between rounded-control border-hairline border-(--color-border) bg-(--color-surface-secondary) text-body-sm text-(--color-fg) placeholder:text-(--color-text-muted) focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-1 focus-visible:ring-(--color-accent) hover:enabled:border-transparent hover:enabled:ring-1 hover:enabled:ring-(--color-border) disabled:cursor-not-allowed disabled:opacity-50 h-(--control-height-default) px-3 py-1.5 w-full" id="_r_0_-location"><span style="pointer-events: none;">All locations</span><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 opacity-50" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg></button></div></div><div role="status" aria-live="polite" class="mt-4 text-body-sm text-muted" data-testid="items-filter-status">1 open position</div><div class="grid gap-grid max-w-2xl justify-items-center mx-auto mt-6" data-testid="items-grid" data-separation="gapped"><div class="contents" data-testid="items-filter-slot"><div class="h-full rounded-card p-card flex flex-col border-hairline border-(--color-border) bg-(--color-surface-raised)" data-testid="card"><span class="text-body font-body leading-normal text-fg mb-1 block text-eyebrow eyebrow-case" style="color: var(--items-item-eyebrow-color, var(--color-brand));">Full-time · Remote (US)</span><h3 class="text-heading-3 font-display text-fg">Senior Engineer</h3><p class="text-body font-body leading-normal text-(--color-text-muted) mt-2 text-sm">Build the AILK platform.</p><div class="mt-auto pt-4" data-testid="card-footer"><a class="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-brand) rounded-sm mt-4 inline-block text-sm font-medium text-brand underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand" href="/careers/senior-engineer" data-testid="items-item-link-0">View role</a></div></div></div></div></div></div></section></div></main>"`,
95
135
  );
96
136
  });
97
137
  });
@@ -115,15 +155,10 @@ describe("careers/[slug]/page — snapshot regression", () => {
115
155
  getMdxSource: getMdxSourcePopulated,
116
156
  });
117
157
  const { container } = render(element);
118
- // #8746 AC-1 — the JobPosting's ApplyAction (the breadcrumb's own page
119
- // URL + #apply) is the one deliberate JSON-LD change in this pin.
120
- // #8746 — the page appends an #apply section after renderPage's output;
121
- // it is covered by job-apply-page.test.tsx. Left out of a copy so this
122
- // snapshot keeps pinning renderPage's output byte for byte.
123
- const pinned = container.cloneNode(true) as HTMLElement;
124
- pinned.querySelector("#apply")?.remove();
125
- expect(pinned.innerHTML).toMatchInlineSnapshot(
126
- `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Senior Engineer","description":"Build the AILK platform.","mainEntity":{"@context":"https://schema.org","@type":"JobPosting","title":"Senior Engineer","description":"Build the AILK platform.","potentialAction":{"@type":"ApplyAction","target":"http://localhost:3000/en/careers/senior-engineer#apply"}}},{"@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":"Careers","item":"http://localhost:3000/en/careers"},{"@type":"ListItem","position":3,"name":"Senior Engineer","item":"http://localhost:3000/en/careers/senior-engineer"}]}]}</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">Senior Engineer</h1><p data-testid="senior-engineer-description" class="mt-block-tight text-lead">Build the AILK platform.</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 motion-safe:transition motion-safe:duration-(--button-hover-duration) 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) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) 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 motion-safe:transition motion-safe:duration-(--button-hover-duration) 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) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) 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-text-muted) 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">mdx-body-content</div></div></article></div></div></div></main>"`,
158
+ // #8849 D3, D8 — the JobPosting's ApplyAction targets the apply page, and
159
+ // the page passes renderPage an Apply button inside <main>.
160
+ expect(container.innerHTML).toMatchInlineSnapshot(
161
+ `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Senior Engineer","description":"Build the AILK platform.","mainEntity":{"@context":"https://schema.org","@type":"JobPosting","title":"Senior Engineer","description":"Build the AILK platform.","potentialAction":{"@type":"ApplyAction","target":"http://localhost:3000/en/careers/senior-engineer/apply"}}},{"@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":"Careers","item":"http://localhost:3000/en/careers"},{"@type":"ListItem","position":3,"name":"Senior Engineer","item":"http://localhost:3000/en/careers/senior-engineer"}]}]}</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">Senior Engineer</h1><p data-testid="senior-engineer-description" class="mt-block-tight text-lead">Build the AILK platform.</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 motion-safe:transition motion-safe:duration-(--button-hover-duration) 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) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) 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 motion-safe:transition motion-safe:duration-(--button-hover-duration) 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) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) 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-text-muted) 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">mdx-body-content</div></div></article></div></div></div><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 pb-section"><a class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) 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 bg-(--color-brand) text-(--color-fg-on-brand) not-disabled:hover:bg-(--button-hover-bg) not-disabled:hover:text-(--button-hover-fg) not-disabled:hover:outline not-disabled:hover:outline-1 not-disabled:hover:-outline-offset-1 not-disabled:hover:outline-(--button-hover-border) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:opacity-80 rounded-control px-control-x min-h-(--control-height-big)" href="/careers/senior-engineer/apply" data-testid="job-apply-cta">Apply for this role</a></div></main>"`,
127
162
  );
128
163
  });
129
164
 
@@ -1,13 +1,59 @@
1
- // Careers list page. Reads CareersList MDX via the content adapter and emits
2
- // a JSON-LD CollectionPage. Links to JobPosting detail pages at /careers/<slug>.
3
- import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
4
- import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
1
+ // Careers list page (#8849 D10) — the /alternatives pattern. Reads CareersList
2
+ // MDX via the content adapter and puts one generated `items` section first,
3
+ // one card per JobPosting, so the visible card grid and the CollectionPage's
4
+ // ItemList JSON-LD (built from the same sections) always agree. Each card
5
+ // links to /careers/<slug>. The section carries a search box and employment-
6
+ // type and location filters (the Items filter, #8849 D6) whose facets read the
7
+ // postings' first-class `employmentType` and `location` frontmatter, never
8
+ // `schema.mainEntity`. Every posting stays in the server HTML.
9
+ //
10
+ // A missing seed renders the `careers` empty state; a seed with no postings
11
+ // renders its declared sections alone.
12
+ import type {
13
+ getMdxSource as defaultGetMdxSource,
14
+ getMdxSlugs as defaultGetMdxSlugs,
15
+ getPageSections as defaultGetPageSections,
16
+ } from "@working-theory/page-renderer";
17
+ import {
18
+ getMdxSlugs as loadMdxSlugs,
19
+ getMdxSource as loadMdxSource,
20
+ getPageSections as loadPageSections,
21
+ renderPage,
22
+ renderPageMetadata,
23
+ } from "@working-theory/page-renderer";
5
24
  import type { Metadata } from "next";
25
+ import { getTranslations } from "next-intl/server";
6
26
  import type { ReactElement } from "react";
7
27
 
8
28
  interface Props {
9
29
  params: Promise<{ locale: string }>;
10
30
  getMdxSource?: typeof defaultGetMdxSource;
31
+ getMdxSlugs?: typeof defaultGetMdxSlugs;
32
+ getPageSections?: typeof defaultGetPageSections;
33
+ }
34
+
35
+ type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
36
+
37
+ /** Each `employmentType` value's `careers` message key. */
38
+ const EMPLOYMENT_TYPE_KEYS = {
39
+ FULL_TIME: "employmentTypeFullTime",
40
+ PART_TIME: "employmentTypePartTime",
41
+ CONTRACTOR: "employmentTypeContractor",
42
+ TEMPORARY: "employmentTypeTemporary",
43
+ INTERN: "employmentTypeIntern",
44
+ VOLUNTEER: "employmentTypeVolunteer",
45
+ PER_DIEM: "employmentTypePerDiem",
46
+ OTHER: "employmentTypeOther",
47
+ } as const;
48
+
49
+ type EmploymentType = keyof typeof EMPLOYMENT_TYPE_KEYS;
50
+
51
+ interface PostingFields {
52
+ title?: string;
53
+ description?: string;
54
+ publishedAt?: string;
55
+ employmentType?: EmploymentType;
56
+ location?: string;
11
57
  }
12
58
 
13
59
  export async function generateMetadata({
@@ -20,14 +66,92 @@ export async function generateMetadata({
20
66
  export default async function Page({
21
67
  params,
22
68
  getMdxSource,
69
+ getMdxSlugs,
70
+ getPageSections,
23
71
  }: Props): Promise<ReactElement> {
24
72
  const { locale } = await params;
73
+ const load = getMdxSource ?? loadMdxSource;
74
+ const listSlugs = getMdxSlugs ?? loadMdxSlugs;
75
+ const loadSections = getPageSections ?? loadPageSections;
76
+ const t = await getTranslations({ locale, namespace: "careers" });
77
+
78
+ /**
79
+ * Put one generated `items` section first — the postings are the page's
80
+ * main content — followed by the declared sections. Absent when there are
81
+ * no postings.
82
+ */
83
+ async function withPostings(frontmatter: unknown): Promise<Section[]> {
84
+ const declared = await loadSections(frontmatter);
85
+ const slugs = await listSlugs("JobPosting", locale);
86
+ if (slugs.length === 0) return declared;
87
+
88
+ const postings = await Promise.all(
89
+ slugs.map(async (slug) => {
90
+ const source = await load("JobPosting", slug, locale);
91
+ return { slug, fm: (source?.frontmatter ?? {}) as PostingFields };
92
+ }),
93
+ );
94
+ // Newest `publishedAt` first, then by slug; no date sorts last.
95
+ postings.sort(
96
+ (a, b) =>
97
+ (b.fm.publishedAt ?? "").localeCompare(a.fm.publishedAt ?? "") ||
98
+ a.slug.localeCompare(b.slug),
99
+ );
100
+
101
+ const items = postings.map(({ slug, fm }) => {
102
+ const type =
103
+ fm.employmentType !== undefined
104
+ ? t(EMPLOYMENT_TYPE_KEYS[fm.employmentType])
105
+ : undefined;
106
+ const eyebrow = [type, fm.location].filter(Boolean).join(" · ");
107
+ const facets = {
108
+ ...(type !== undefined && { type }),
109
+ ...(fm.location !== undefined && { location: fm.location }),
110
+ };
111
+ return {
112
+ title: fm.title ?? slug,
113
+ description: fm.description ?? "",
114
+ ...(eyebrow !== "" && { eyebrow }),
115
+ link: { label: t("viewRole"), href: `/careers/${slug}` },
116
+ ...(Object.keys(facets).length > 0 && { facets }),
117
+ };
118
+ });
119
+
120
+ const filter = {
121
+ searchLabel: t("filterSearch"),
122
+ facets: [
123
+ { key: "type", label: t("filterType"), allLabel: t("filterAllTypes") },
124
+ {
125
+ key: "location",
126
+ label: t("filterLocation"),
127
+ allLabel: t("filterAllLocations"),
128
+ },
129
+ ],
130
+ emptyText: t("filterEmpty"),
131
+ // The island fills `{count}` in the browser, so the template passes
132
+ // through with its placeholder intact.
133
+ countText: {
134
+ one: t("filterCountOne"),
135
+ other: t("filterCountOther", { count: "{count}" }),
136
+ },
137
+ };
138
+
139
+ return [
140
+ {
141
+ type: "items" as const,
142
+ id: "open-positions",
143
+ data: { headline: t("openPositions"), items, filter },
144
+ },
145
+ ...declared,
146
+ ];
147
+ }
148
+
25
149
  return renderPage({
26
150
  type: "CareersList",
27
151
  slug: "careers",
28
152
  locale,
29
153
  translationsNamespace: "careers",
30
- bodyMode: "mdx-body",
154
+ getPageSections: withPostings,
31
155
  ...(getMdxSource !== undefined && { getMdxSource }),
32
156
  });
33
157
  }
@@ -7,7 +7,7 @@
7
7
  * boundary is mocked (`@working-theory/api-client`'s `leads.submit`) —
8
8
  * the REAL `ConversationalIntake` block (#4543) and its engine (#4532),
9
9
  * the REAL `captureLeadAction` (the SAME action the shared
10
- * `LeadCaptureForm` on `/contact` uses), and the REAL `submitLeadInputSchema` validation all run, proving
10
+ * `LeadCaptureForm` on `/contact` uses), and the REAL `contactFormSchema` validation all run, proving
11
11
  * the whole chain end to end. No file under apps/api is imported, mocked,
12
12
  * or touched by this test or by the wiring it exercises.
13
13
  * AC2 — Skip renders the existing form, carrying over answers already
@@ -219,7 +219,7 @@ describe("contact/page — rendering (AC-3)", () => {
219
219
  expect(mockCaptureStatus).toHaveBeenCalledTimes(1);
220
220
  });
221
221
 
222
- it("links Terms, Privacy Policy and Working Agreement in the statement", async () => {
222
+ it("links Terms and Privacy Policy in the two-link statement (#8847 D2)", async () => {
223
223
  mockFlowsGet.mockResolvedValue(served(contactFormFlowConfig));
224
224
 
225
225
  await renderPage();
@@ -232,10 +232,7 @@ describe("contact/page — rendering (AC-3)", () => {
232
232
  "href",
233
233
  localeHref("en", "/privacy"),
234
234
  );
235
- expect(screen.getByTestId("consent-policy-link-working_agreement")).toHaveAttribute(
236
- "href",
237
- localeHref("en", "/working-agreement"),
238
- );
235
+ expect(screen.queryByTestId("consent-policy-link-working_agreement")).toBeNull();
239
236
  });
240
237
 
241
238
  it("a magnet-driven post reaches leads.submit as flow:contact with its subject", async () => {
@@ -315,7 +312,7 @@ describe("contact/page — fallback (AC-5)", () => {
315
312
  expect(screen.getByTestId("contact-submit")).toHaveTextContent("Request a demo");
316
313
  });
317
314
 
318
- it("the built-in form takes its labels from the contact catalog and the section's copy", async () => {
315
+ it("the built-in form takes its labels from the contact catalog and ends on the built-in ending (#8847 D7)", async () => {
319
316
  mockFlowsGet.mockResolvedValue({ ok: false, status: 404, body: {} });
320
317
  const messages = jest.requireActual("../../../../messages/en.json") as {
321
318
  contact: Record<string, string>;
@@ -332,13 +329,16 @@ describe("contact/page — fallback (AC-5)", () => {
332
329
  const form = screen.getByTestId("lead-capture-form");
333
330
  expect(form).toHaveTextContent(messages.contact.nameLabel!);
334
331
  expect(form).toHaveTextContent(messages.contact.messageLabel!);
335
- expect(screen.getByTestId("contact-submit")).toHaveTextContent("Say hello");
332
+ expect(screen.getByTestId("contact-submit")).toHaveTextContent(messages.contact.sendMessage!);
336
333
 
337
334
  await tickAndSubmit({ name: "Ada", email: "ada@example.com" });
338
- await waitFor(() => expect(document.body).toHaveTextContent("Got it, thanks."));
335
+ await waitFor(() =>
336
+ expect(screen.getByTestId("contact-success-headline")).toHaveTextContent("Message sent."),
337
+ );
338
+ expect(document.body).not.toHaveTextContent("Got it, thanks.");
339
339
  });
340
340
 
341
- it("a magnet's submit label wins, and the section's success message still applies", async () => {
341
+ it("a magnet's submit label and ending win over the section's copy (#8847 D7)", async () => {
342
342
  mockFlowsGet.mockResolvedValue(served(contactFormFlowConfig));
343
343
 
344
344
  await renderPage([
@@ -349,13 +349,121 @@ describe("contact/page — fallback (AC-5)", () => {
349
349
  },
350
350
  ]);
351
351
 
352
- const form = screen.getByTestId("lead-capture-form");
353
352
  expect(screen.getByTestId("contact-submit")).toHaveTextContent(
354
353
  contactFormFromConfig(contactFormFlowConfig, "en")!.submitLabel!,
355
354
  );
356
355
 
357
356
  await tickAndSubmit({ name: "Ada", email: "ada@example.com" });
358
- await waitFor(() => expect(document.body).toHaveTextContent("Got it, thanks."));
357
+ await waitFor(() =>
358
+ expect(screen.getByTestId("contact-success-headline")).toHaveTextContent("Message sent."),
359
+ );
360
+ expect(document.body).not.toHaveTextContent("Got it, thanks.");
361
+ });
362
+ });
363
+
364
+ // ── #8847 AC-2 — /contact is the magnet ─────────────────────────────────────
365
+
366
+ /** A served Contact form whose every visible fact differs from the default. */
367
+ function sayHelloConfig(): FlowConfig {
368
+ const config = JSON.parse(JSON.stringify(contactFormFlowConfig)) as FlowConfig;
369
+ const step = config.steps[0] as FieldStep;
370
+ step.copy = { title: { en: "Say hello" }, body: { en: "Two days, tops." }, cta: { en: "Send" } };
371
+ step.fields.find((f) => f.name === "terms")!.copy.label = { en: "I agree to the [Terms](terms)." };
372
+ config.ending = { default: { kind: "screen", headline: { en: "Got it, {name}." } } };
373
+ return config;
374
+ }
375
+
376
+ const COLUMN_SECTION = {
377
+ id: "contact-form",
378
+ type: "contact-form",
379
+ data: { variant: "column", headline: "Old heading", description: "Old line", fields: [] },
380
+ };
381
+
382
+ async function renderColumnPage() {
383
+ const element = await ContactPage({
384
+ params: Promise.resolve({ locale: "en" }),
385
+ getMdxSource: jest.fn().mockResolvedValue({
386
+ ...source,
387
+ frontmatter: { ...source.frontmatter, title: "Old title" },
388
+ }),
389
+ getPageSections: jest.fn().mockResolvedValue([COLUMN_SECTION]),
390
+ });
391
+ return render(element);
392
+ }
393
+
394
+ describe("contact/page — every visible fact comes from the magnet (#8847 AC-2)", () => {
395
+ it("the H1, the line, the consent and the ending are the served magnet's", async () => {
396
+ mockFlowsGet.mockResolvedValue(served(sayHelloConfig()));
397
+
398
+ await renderColumnPage();
399
+
400
+ const headings = screen.getAllByRole("heading", { level: 1 });
401
+ expect(headings).toHaveLength(1);
402
+ expect(headings[0]).toHaveTextContent("Say hello");
403
+ expect(screen.getByTestId("contact-form-column")).toHaveTextContent("Two days, tops.");
404
+ expect(
405
+ document.getElementById("lead-capture-consent-statement")!.textContent,
406
+ ).toBe("I agree to the Terms.");
407
+ expect(screen.getByTestId("consent-policy-link-terms")).toBeInTheDocument();
408
+ for (const old of ["Old heading", "Old line", "Old title"]) {
409
+ expect(document.body).not.toHaveTextContent(old);
410
+ }
411
+
412
+ await tickAndSubmit({ name: "Ada", email: "ada@example.com" });
413
+ await waitFor(() =>
414
+ expect(screen.getByTestId("contact-success-headline")).toHaveTextContent("Got it, Ada."),
415
+ );
416
+ const messages = jest.requireActual("../../../../messages/en.json") as {
417
+ contact: Record<string, string>;
418
+ };
419
+ expect(document.body).not.toHaveTextContent(messages.contact.successMessage!);
420
+ });
421
+
422
+ it("generateMetadata's title is the magnet's step title", async () => {
423
+ mockFlowsGet.mockResolvedValue(served(sayHelloConfig()));
424
+
425
+ const metadata = await pageModule.generateMetadata({
426
+ params: Promise.resolve({ locale: "en" }),
427
+ getMdxSource: jest.fn().mockResolvedValue({
428
+ ...source,
429
+ frontmatter: { ...source.frontmatter, title: "Old title" },
430
+ }),
431
+ getPageSections: jest.fn().mockResolvedValue([COLUMN_SECTION]),
432
+ });
433
+
434
+ expect(String((metadata.title as { absolute?: string })?.absolute ?? metadata.title)).toContain(
435
+ "Say hello",
436
+ );
437
+ });
438
+
439
+ it("with the hub unreadable, the built-in config's title and two-link statement render", async () => {
440
+ mockFlowsGet.mockRejectedValue(new Error("hub down"));
441
+
442
+ await renderColumnPage();
443
+
444
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Get in touch");
445
+ expect(
446
+ document.getElementById("lead-capture-consent-statement")!.textContent,
447
+ ).toBe("I accept the Terms and Privacy Policy.");
448
+ });
449
+
450
+ it("under the form, 'Prefer email?' links the _site.mdx contact email; absent with none", async () => {
451
+ const { readSiteContactEmail } = jest.requireMock("../site-contact-email") as {
452
+ readSiteContactEmail: jest.Mock;
453
+ };
454
+ mockFlowsGet.mockResolvedValue(served(contactFormFlowConfig));
455
+ readSiteContactEmail.mockReturnValue("hi@x.co");
456
+
457
+ const view = await renderColumnPage();
458
+
459
+ const line = screen.getByTestId("contact-prefer-email");
460
+ expect(line).toHaveTextContent("Prefer email? hi@x.co");
461
+ expect(line.querySelector("a")).toHaveAttribute("href", "mailto:hi@x.co");
462
+ view.unmount();
463
+
464
+ readSiteContactEmail.mockReturnValue(undefined);
465
+ await renderColumnPage();
466
+ expect(screen.queryByTestId("contact-prefer-email")).toBeNull();
359
467
  });
360
468
  });
361
469