create-ailk 0.27.0 → 0.28.1

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 (219) hide show
  1. package/README.md +84 -7
  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/chrome-readers.d.ts +21 -5
  16. package/dist/lib/chrome-readers.js +128 -20
  17. package/dist/lib/declared-env.d.ts +6 -0
  18. package/dist/lib/declared-env.js +2 -0
  19. package/dist/lib/env-catalog.js +20 -0
  20. package/dist/lib/import-graph.d.ts +16 -0
  21. package/dist/lib/import-graph.js +53 -0
  22. package/dist/lib/site-modules.d.ts +8 -0
  23. package/dist/lib/site-modules.js +34 -0
  24. package/dist/module-architecture.js +23 -14
  25. package/dist/paid-sections.d.ts +17 -0
  26. package/dist/paid-sections.js +39 -0
  27. package/dist/programmatic.d.ts +16 -0
  28. package/dist/programmatic.js +73 -4
  29. package/dist/remove-module.d.ts +98 -0
  30. package/dist/remove-module.js +620 -0
  31. package/dist/seed-flows.d.ts +2 -2
  32. package/dist/seed-flows.js +16 -9
  33. package/dist/site-roster.js +4 -1
  34. package/dist/starter-grid.d.ts +26 -0
  35. package/dist/starter-grid.js +34 -0
  36. package/dist/surfaces.d.ts +26 -0
  37. package/dist/surfaces.js +105 -9
  38. package/dist/sync-routes.d.ts +7 -0
  39. package/dist/sync-routes.js +53 -6
  40. package/dist/template-pages.d.ts +36 -0
  41. package/dist/template-pages.js +129 -0
  42. package/dist/template-record.d.ts +13 -4
  43. package/dist/template-record.js +8 -4
  44. package/dist/topology.d.ts +2 -1
  45. package/dist/topology.js +4 -2
  46. package/dist/update.d.ts +34 -2
  47. package/dist/update.js +395 -48
  48. package/package.json +3 -3
  49. package/release-scope.json +2 -1
  50. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  51. package/templates/.env.example +5 -2
  52. package/templates/apps/api/.env.example +9 -5
  53. package/templates/apps/api/CLAUDE.md +14 -9
  54. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  55. package/templates/apps/api/src/vercel-handler.ts +8 -1
  56. package/templates/apps/mcp/CLAUDE.md +18 -10
  57. package/templates/apps/web/.env.example +25 -11
  58. package/templates/apps/web/CLAUDE.md +45 -4
  59. package/templates/apps/web/__tests__/setup.ts +7 -0
  60. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  61. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  63. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  65. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  66. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  67. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  68. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  69. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  70. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  72. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  73. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  74. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  76. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  77. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  78. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  79. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  80. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  81. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  82. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  83. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  84. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  85. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  86. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  87. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  88. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  89. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  90. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  91. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  92. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  93. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  105. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  107. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  108. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  109. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  111. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  113. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  114. package/templates/apps/web/app/[locale]/layout.tsx +157 -139
  115. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  116. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  117. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  118. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  119. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  120. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  121. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  122. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  124. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  126. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  127. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  128. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  129. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  130. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  131. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  132. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  133. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  134. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  135. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  136. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  137. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  138. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  139. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  140. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  141. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  142. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +224 -0
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  144. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  145. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  146. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  147. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  148. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  149. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  150. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  151. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  152. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +237 -0
  153. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  154. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  155. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  156. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  157. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
  158. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
  159. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  160. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  161. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  162. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  163. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  164. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  165. package/templates/apps/web/auth.ts +102 -50
  166. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  167. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  168. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  169. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  170. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  171. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  172. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +279 -0
  173. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  174. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  175. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  176. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  177. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  178. package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
  179. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  180. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  181. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  182. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  183. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  184. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  185. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  186. package/templates/apps/web/lib/auth-secret.ts +57 -0
  187. package/templates/apps/web/lib/bot-check.ts +138 -0
  188. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  189. package/templates/apps/web/lib/deck-page.ts +6 -3
  190. package/templates/apps/web/lib/docs-index.ts +100 -0
  191. package/templates/apps/web/lib/docs-path.ts +94 -0
  192. package/templates/apps/web/lib/hub-children.tsx +318 -0
  193. package/templates/apps/web/lib/modules.generated.ts +9 -0
  194. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  195. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  196. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  197. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  198. package/templates/apps/web/lib/page-chrome.ts +367 -0
  199. package/templates/apps/web/lib/posting-title.ts +26 -4
  200. package/templates/apps/web/lib/pricing-page.ts +430 -0
  201. package/templates/apps/web/lib/relay.ts +31 -106
  202. package/templates/apps/web/lib/route-family.ts +69 -0
  203. package/templates/apps/web/lib/service-listing.ts +89 -0
  204. package/templates/apps/web/lib/services-path.ts +97 -0
  205. package/templates/apps/web/lib/site-header.ts +94 -0
  206. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  207. package/templates/apps/web/lib/template-listing.ts +169 -0
  208. package/templates/apps/web/messages/en.json +86 -30
  209. package/templates/apps/web/middleware.ts +171 -11
  210. package/templates/apps/web/next.config.mjs +211 -2
  211. package/templates/apps/web/package.json +1 -1
  212. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  213. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  214. package/templates/apps/web/sentry.server.config.ts +30 -2
  215. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  216. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  217. package/templates/apps/web/src/lib/routes.ts +15 -1
  218. package/templates/tools/declared-env.ts +7 -0
  219. package/versions.json +48 -48
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Hub child cards (#10121) for /courses. The shared cases live in
3
+ * `lib/__tests__/hub-children.fixtures.ts`; this file sits beside the route so
4
+ * a scaffold without /courses drops it with the route (#10157).
5
+ */
6
+ const mockRenderPage = jest.fn(async (..._args: unknown[]) => "<rendered/>");
7
+
8
+ jest.mock("@working-theory/page-renderer", () => ({
9
+ __esModule: true,
10
+ renderPage: (...args: unknown[]) => mockRenderPage(...args),
11
+ renderPageMetadata: jest.fn(),
12
+ // Never reached — every call in this suite passes its loaders via DI.
13
+ getMdxSlugs: jest.fn(),
14
+ getMdxSource: jest.fn(),
15
+ getPageSections: jest.fn(),
16
+ }));
17
+
18
+ jest.mock("@working-theory/ui/sections", () => ({
19
+ __esModule: true,
20
+ SectionRenderer: () => null,
21
+ }));
22
+
23
+ import CoursesPage from "../page";
24
+ import {
25
+ describeHub,
26
+ type HubPage,
27
+ } from "../../../../lib/__tests__/hub-children.fixtures";
28
+
29
+ describeHub(
30
+ ["courses", CoursesPage as HubPage, "CoursesList", "Course", "mdx-body"],
31
+ mockRenderPage,
32
+ );
@@ -45,6 +45,8 @@ describe("courses/page — snapshot regression", () => {
45
45
 
46
46
  const element = await CoursesPage({
47
47
  params: Promise.resolve({ locale: "en" }),
48
+ // No child pages: this snapshot pins the hub's own render (#10121).
49
+ getMdxSlugs: async () => [],
48
50
  getMdxSource,
49
51
  });
50
52
  const { container } = render(element);
@@ -66,11 +68,13 @@ describe("courses/page — snapshot regression", () => {
66
68
 
67
69
  const element = await CoursesPage({
68
70
  params: Promise.resolve({ locale: "en" }),
71
+ // No child pages: this snapshot pins the hub's own render (#10121).
72
+ getMdxSlugs: async () => [],
69
73
  getMdxSource,
70
74
  });
71
75
  const { container } = render(element);
72
76
  expect(container.innerHTML).toMatchInlineSnapshot(`
73
- "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Courses","description":"Structured learning paths for teams adopting AILK.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Course"}]}}</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">Courses</h1><p data-testid="courses-description" class="mt-block-tight text-lead">Structured learning paths for teams adopting AILK.</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">## Courses
77
+ "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Courses","description":"Structured learning paths for teams adopting AILK.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Course"}]}}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium 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">## Courses
74
78
 
75
79
  Body content.</div></div></article></div></div></div></main>"
76
80
  `);
@@ -99,7 +103,7 @@ describe("courses/[slug] — snapshot regression", () => {
99
103
  });
100
104
  const { container } = render(element);
101
105
  expect(container.innerHTML).toMatchInlineSnapshot(`
102
- "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AEO Fundamentals","description":"A practical course covering Layer 1 AEO rules.","mainEntity":{"@context":"https://schema.org","@type":"Course","name":"AEO Fundamentals","description":"A practical course covering Layer 1 AEO rules."}},{"@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":"Courses","item":"http://localhost:3000/en/courses"},{"@type":"ListItem","position":3,"name":"AEO Fundamentals","item":"http://localhost:3000/en/courses/aeo-fundamentals"}]}]}</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">AEO Fundamentals</h1><p data-testid="course-description" class="mt-block-tight text-lead">A practical course covering Layer 1 AEO rules.</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">## AEO Fundamentals
106
+ "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AEO Fundamentals","description":"A practical course covering Layer 1 AEO rules.","mainEntity":{"@context":"https://schema.org","@type":"Course","name":"AEO Fundamentals","description":"A practical course covering Layer 1 AEO rules."}},{"@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":"Courses","item":"http://localhost:3000/en/courses"},{"@type":"ListItem","position":3,"name":"AEO Fundamentals","item":"http://localhost:3000/en/courses/aeo-fundamentals"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium 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">## AEO Fundamentals
103
107
 
104
108
  Body content.</div></div></article></div></div></div></main>"
105
109
  `);
@@ -1,15 +1,34 @@
1
1
  // Courses list page. Reads CoursesList MDX via the content adapter and emits
2
2
  // a JSON-LD CollectionPage via the dispatcher.
3
- import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
3
+ // It lists every Course page through one generated card section (#10121,
4
+ // apps/web/lib/hub-children.tsx — the /alternatives and /templates shape).
5
+ import type {
6
+ getMdxSlugs as defaultGetMdxSlugs,
7
+ getMdxSource as defaultGetMdxSource,
8
+ getPageSections as defaultGetPageSections,
9
+ } from "@working-theory/page-renderer";
4
10
  import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
5
11
  import type { Metadata } from "next";
6
12
  import type { ReactElement } from "react";
7
13
 
14
+ import { type HubConfig, hubPageInput } from "../../../lib/hub-children";
15
+
8
16
  interface Props {
9
17
  params: Promise<{ locale: string }>;
10
18
  getMdxSource?: typeof defaultGetMdxSource;
19
+ getPageSections?: typeof defaultGetPageSections;
20
+ getMdxSlugs?: typeof defaultGetMdxSlugs;
11
21
  }
12
22
 
23
+ const HUB: HubConfig = {
24
+ listType: "CoursesList",
25
+ slug: "courses",
26
+ childType: "Course",
27
+ linkLabel: "View the course",
28
+ sectionId: "course-cards",
29
+ body: "mdx-body",
30
+ };
31
+
13
32
  export async function generateMetadata({
14
33
  params,
15
34
  }: Pick<Props, "params">): Promise<Metadata> {
@@ -19,15 +38,12 @@ export async function generateMetadata({
19
38
 
20
39
  export default async function Page({
21
40
  params,
22
- getMdxSource,
41
+ ...loaders
23
42
  }: Props): Promise<ReactElement> {
24
43
  const { locale } = await params;
25
44
  return renderPage({
26
45
  type: "CoursesList",
27
46
  slug: "courses",
28
- locale,
29
- translationsNamespace: "courses",
30
- bodyMode: "mdx-body",
31
- ...(getMdxSource !== undefined && { getMdxSource }),
47
+ ...(await hubPageInput(HUB, locale, loaders)),
32
48
  });
33
49
  }
@@ -1,7 +1,11 @@
1
1
  // Event detail page. Reads the Event MDX for the given slug, renders via
2
2
  // renderPage, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
3
3
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
4
- import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
4
+ import {
5
+ getMdxSource as loadMdxSource,
6
+ renderPage,
7
+ renderPageMetadata,
8
+ } from "@working-theory/page-renderer";
5
9
  import type { Metadata } from "next";
6
10
  import type { ReactElement } from "react";
7
11
 
@@ -22,11 +26,20 @@ export default async function Page({
22
26
  getMdxSource,
23
27
  }: Props): Promise<ReactElement> {
24
28
  const { locale, slug } = await params;
29
+ // A page that composes `sections[]` renders them (JSON-LD is built from
30
+ // the same sections); a prose page renders its MDX body — the rule
31
+ // /vs/[slug] applies (#10122).
32
+ const source = await (getMdxSource ?? loadMdxSource)("Event", slug, locale);
33
+ const composed =
34
+ source !== null &&
35
+ Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
36
+ ((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) >
37
+ 0;
25
38
  return renderPage({
26
39
  type: "Event",
27
40
  slug,
28
41
  locale,
29
- bodyMode: "mdx-body",
42
+ ...(composed ? {} : { bodyMode: "mdx-body" as const }),
30
43
  ...(getMdxSource !== undefined && { getMdxSource }),
31
44
  });
32
45
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Hub child cards (#10121) for /events. The shared cases live in
3
+ * `lib/__tests__/hub-children.fixtures.ts`; this file sits beside the route so
4
+ * a scaffold without /events drops it with the route (#10157).
5
+ */
6
+ const mockRenderPage = jest.fn(async (..._args: unknown[]) => "<rendered/>");
7
+
8
+ jest.mock("@working-theory/page-renderer", () => ({
9
+ __esModule: true,
10
+ renderPage: (...args: unknown[]) => mockRenderPage(...args),
11
+ renderPageMetadata: jest.fn(),
12
+ // Never reached — every call in this suite passes its loaders via DI.
13
+ getMdxSlugs: jest.fn(),
14
+ getMdxSource: jest.fn(),
15
+ getPageSections: jest.fn(),
16
+ }));
17
+
18
+ jest.mock("@working-theory/ui/sections", () => ({
19
+ __esModule: true,
20
+ SectionRenderer: () => null,
21
+ }));
22
+
23
+ import EventsPage from "../page";
24
+ import {
25
+ describeHub,
26
+ type HubPage,
27
+ } from "../../../../lib/__tests__/hub-children.fixtures";
28
+
29
+ describeHub(
30
+ ["events", EventsPage as HubPage, "EventsList", "Event", "mdx-body"],
31
+ mockRenderPage,
32
+ );
@@ -75,6 +75,8 @@ describe("EventsPage — snapshot regression", () => {
75
75
  it("empty state renders deterministic HTML", async () => {
76
76
  const element = await EventsPage({
77
77
  params: Promise.resolve({ locale: "en" }),
78
+ // No child pages: this snapshot pins the hub's own render (#10121).
79
+ getMdxSlugs: async () => [],
78
80
  getMdxSource: getMdxSourceEmpty,
79
81
  });
80
82
  const { container } = render(element);
@@ -96,11 +98,14 @@ describe("EventsPage — snapshot regression", () => {
96
98
 
97
99
  const element = await EventsPage({
98
100
  params: Promise.resolve({ locale: "en" }),
101
+ // No child pages: this snapshot pins the hub's own render (#10121).
102
+ getMdxSlugs: async () => [],
99
103
  getMdxSource: getMdxSourcePopulated,
100
104
  });
101
105
  const { container } = render(element);
102
106
  expect(container.innerHTML).toMatchInlineSnapshot(`
103
- "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Events","description":"Upcoming and past events.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Event"}]}}</script><main id="main"><div><h1>Events</h1><p data-testid="events-description" class="mt-block-tight text-lead">Upcoming and past events.</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 data-testid="mdx-rendered">
107
+ "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Events","description":"Upcoming and past events.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Event"}]}}</script><main id="main"><div><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium 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 data-testid="mdx-rendered"># Events
108
+
104
109
  Body.</div></article></div></div></div></main>"
105
110
  `);
106
111
  });
@@ -134,7 +139,8 @@ describe("EventSlugPage — snapshot regression", () => {
134
139
  // This replaces the pre-migration hardcoded "event-description" testid
135
140
  // with the slug-derived "annual-summit-description". Intentional per spec.
136
141
  expect(container.innerHTML).toMatchInlineSnapshot(`
137
- "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Annual Summit","description":"Our annual developer summit.","mainEntity":{"@context":"https://schema.org","@type":"Event","name":"Annual Summit","description":"Our annual developer summit."}},{"@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":"Events","item":"http://localhost:3000/en/events"},{"@type":"ListItem","position":3,"name":"Annual Summit","item":"http://localhost:3000/en/events/annual-summit"}]}]}</script><main id="main"><div><h1>Annual Summit</h1><p data-testid="annual-summit-description" class="mt-block-tight text-lead">Our annual developer summit.</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 data-testid="mdx-rendered">
142
+ "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Annual Summit","description":"Our annual developer summit.","mainEntity":{"@context":"https://schema.org","@type":"Event","name":"Annual Summit","description":"Our annual developer summit."}},{"@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":"Events","item":"http://localhost:3000/en/events"},{"@type":"ListItem","position":3,"name":"Annual Summit","item":"http://localhost:3000/en/events/annual-summit"}]}]}</script><main id="main"><div><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium 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 data-testid="mdx-rendered"># Annual Summit
143
+
138
144
  Body.</div></article></div></div></div></main>"
139
145
  `);
140
146
  });
@@ -1,15 +1,34 @@
1
- // Events list page. Reads EventsList MDX via renderPage and emits
1
+ // Events list page. Reads EventsList MDX via the content adapter and emits
2
2
  // a JSON-LD CollectionPage via the dispatcher.
3
- import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
3
+ // It lists every Event page through one generated card section (#10121,
4
+ // apps/web/lib/hub-children.tsx — the /alternatives and /templates shape).
5
+ import type {
6
+ getMdxSlugs as defaultGetMdxSlugs,
7
+ getMdxSource as defaultGetMdxSource,
8
+ getPageSections as defaultGetPageSections,
9
+ } from "@working-theory/page-renderer";
4
10
  import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
5
11
  import type { Metadata } from "next";
6
12
  import type { ReactElement } from "react";
7
13
 
14
+ import { type HubConfig, hubPageInput } from "../../../lib/hub-children";
15
+
8
16
  interface Props {
9
17
  params: Promise<{ locale: string }>;
10
18
  getMdxSource?: typeof defaultGetMdxSource;
19
+ getPageSections?: typeof defaultGetPageSections;
20
+ getMdxSlugs?: typeof defaultGetMdxSlugs;
11
21
  }
12
22
 
23
+ const HUB: HubConfig = {
24
+ listType: "EventsList",
25
+ slug: "events",
26
+ childType: "Event",
27
+ linkLabel: "View the event",
28
+ sectionId: "event-cards",
29
+ body: "mdx-body",
30
+ };
31
+
13
32
  export async function generateMetadata({
14
33
  params,
15
34
  }: Pick<Props, "params">): Promise<Metadata> {
@@ -19,15 +38,12 @@ export async function generateMetadata({
19
38
 
20
39
  export default async function Page({
21
40
  params,
22
- getMdxSource,
41
+ ...loaders
23
42
  }: Props): Promise<ReactElement> {
24
43
  const { locale } = await params;
25
44
  return renderPage({
26
45
  type: "EventsList",
27
46
  slug: "events",
28
- locale,
29
- translationsNamespace: "events",
30
- bodyMode: "mdx-body",
31
- ...(getMdxSource !== undefined && { getMdxSource }),
47
+ ...(await hubPageInput(HUB, locale, loaders)),
32
48
  });
33
49
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Hub child cards (#10121) for /features. The shared cases live in
3
+ * `lib/__tests__/hub-children.fixtures.ts`; this file sits beside the route so
4
+ * a scaffold without /features drops it with the route (#10157).
5
+ */
6
+ const mockRenderPage = jest.fn(async (..._args: unknown[]) => "<rendered/>");
7
+
8
+ jest.mock("@working-theory/page-renderer", () => ({
9
+ __esModule: true,
10
+ renderPage: (...args: unknown[]) => mockRenderPage(...args),
11
+ renderPageMetadata: jest.fn(),
12
+ // Never reached — every call in this suite passes its loaders via DI.
13
+ getMdxSlugs: jest.fn(),
14
+ getMdxSource: jest.fn(),
15
+ getPageSections: jest.fn(),
16
+ }));
17
+
18
+ jest.mock("@working-theory/ui/sections", () => ({
19
+ __esModule: true,
20
+ SectionRenderer: () => null,
21
+ }));
22
+
23
+ import FeaturesPage from "../page";
24
+ import {
25
+ describeHub,
26
+ type HubPage,
27
+ } from "../../../../lib/__tests__/hub-children.fixtures";
28
+
29
+ describeHub(
30
+ ["features", FeaturesPage as HubPage, "FeaturesList", "Feature", "composed"],
31
+ mockRenderPage,
32
+ );
@@ -1,52 +1,50 @@
1
1
  // Features list page. Reads FeaturesList MDX via the content adapter and emits
2
- // a JSON-LD CollectionPage via the dispatcher. List-only — no detail route.
2
+ // a JSON-LD CollectionPage via the dispatcher. The /for composed pattern:
3
+ // sections when the frontmatter carries `sections[]`, MDX prose otherwise.
4
+ // It lists every Feature page through one generated card section (#10121,
5
+ // apps/web/lib/hub-children.tsx — the /alternatives and /templates shape).
3
6
  import type {
7
+ getMdxSlugs as defaultGetMdxSlugs,
4
8
  getMdxSource as defaultGetMdxSource,
9
+ getPageSections as defaultGetPageSections,
5
10
  } from "@working-theory/page-renderer";
6
- import {
7
- getMdxSource as loadMdxSource,
8
- renderPage,
9
- renderPageMetadata,
10
- } from "@working-theory/page-renderer";
11
+ import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
11
12
  import type { Metadata } from "next";
12
13
  import type { ReactElement } from "react";
13
14
 
15
+ import { type HubConfig, hubPageInput } from "../../../lib/hub-children";
16
+
14
17
  interface Props {
15
18
  params: Promise<{ locale: string }>;
16
19
  getMdxSource?: typeof defaultGetMdxSource;
20
+ getPageSections?: typeof defaultGetPageSections;
21
+ getMdxSlugs?: typeof defaultGetMdxSlugs;
17
22
  }
18
23
 
24
+ const HUB: HubConfig = {
25
+ listType: "FeaturesList",
26
+ slug: "features",
27
+ childType: "Feature",
28
+ linkLabel: "Learn more",
29
+ sectionId: "feature-cards",
30
+ body: "composed",
31
+ };
32
+
19
33
  export async function generateMetadata({
20
34
  params,
21
35
  }: Pick<Props, "params">): Promise<Metadata> {
22
36
  const { locale } = await params;
23
- return renderPageMetadata({
24
- type: "FeaturesList",
25
- slug: "features",
26
- locale,
27
- });
37
+ return renderPageMetadata({ type: "FeaturesList", slug: "features", locale });
28
38
  }
29
39
 
30
40
  export default async function Page({
31
41
  params,
32
- getMdxSource,
42
+ ...loaders
33
43
  }: Props): Promise<ReactElement> {
34
44
  const { locale } = await params;
35
- // A FeaturesList page that composes `sections[]` (the Free template's
36
- // /features, operator ruling 2026-09-23) renders them; a prose-only page
37
- // keeps the MDX-body render. The CollectionPage JSON-LD is built from the
38
- // sections either way (buildFeaturesList).
39
- const source = await (getMdxSource ?? loadMdxSource)("FeaturesList", "features", locale);
40
- const composed =
41
- source !== null &&
42
- Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
43
- ((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) > 0;
44
45
  return renderPage({
45
46
  type: "FeaturesList",
46
47
  slug: "features",
47
- locale,
48
- translationsNamespace: "features",
49
- ...(composed ? {} : { bodyMode: "mdx-body" as const }),
50
- ...(getMdxSource !== undefined && { getMdxSource }),
48
+ ...(await hubPageInput(HUB, locale, loaders)),
51
49
  });
52
50
  }
@@ -18,6 +18,13 @@
18
18
 
19
19
  import { submitFlowStepAction } from "../flow.actions.js";
20
20
 
21
+ // #9255 — the write bot check's verdict. `checkBotId` reads Vercel's
22
+ // request context, which jest has none of, so every case decides it here.
23
+ const checkBotIdMock = jest.fn();
24
+ jest.mock("botid/server", () => ({
25
+ checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
26
+ }));
27
+
21
28
  jest.mock("@working-theory/api-client", () => ({
22
29
  flows: {
23
30
  submitStep: jest.fn(),
@@ -301,3 +308,91 @@ describe("submitFlowStepAction — visitor IP forwarding (#4730)", () => {
301
308
  expect(mockSubmitStep.mock.calls[0]).toHaveLength(2);
302
309
  });
303
310
  });
311
+
312
+ // #9255 D4 — the write bot check runs after the slug and payload checks,
313
+ // immediately before the write (spec
314
+ // docs/specs/9255-an-edge-bot-challenge-in-spec.md §5.1 AC-2).
315
+ describe("submitFlowStepAction — the write bot check (#9255)", () => {
316
+ const BOT = {
317
+ isHuman: false,
318
+ isBot: true,
319
+ isVerifiedBot: false,
320
+ bypassed: false,
321
+ };
322
+ const HUMAN = { ...BOT, isHuman: true, isBot: false };
323
+ const BOT_ENV_KEYS = [
324
+ "VERCEL",
325
+ "AILK_BOT_CHECK",
326
+ "AILK_BOOKING_BOT_CHECK",
327
+ ] as const;
328
+ const priorBotEnv = new Map(
329
+ BOT_ENV_KEYS.map((name) => [name, process.env[name]]),
330
+ );
331
+
332
+ let botLogSpies: jest.SpyInstance[] = [];
333
+
334
+ beforeEach(() => {
335
+ checkBotIdMock.mockReset();
336
+ for (const name of BOT_ENV_KEYS) delete process.env[name];
337
+ process.env.VERCEL = "1";
338
+ botLogSpies = [
339
+ jest.spyOn(console, "warn").mockImplementation(() => undefined),
340
+ jest.spyOn(console, "error").mockImplementation(() => undefined),
341
+ ];
342
+ });
343
+
344
+ afterEach(() => {
345
+ for (const [name, value] of priorBotEnv) {
346
+ if (value === undefined) delete process.env[name];
347
+ else process.env[name] = value;
348
+ }
349
+ for (const spy of botLogSpies) spy.mockRestore();
350
+ });
351
+
352
+ it.each([
353
+ ["a bot", () => checkBotIdMock.mockResolvedValue(BOT)],
354
+ [
355
+ "a check that fails",
356
+ () => checkBotIdMock.mockRejectedValue(new Error("boom")),
357
+ ],
358
+ ])("%s gets kind network, and nothing is sent", async (_label, decide) => {
359
+ decide();
360
+ expect(
361
+ await submitFlowStepAction("lead-magnet", VALID_PAYLOAD),
362
+ ).toStrictEqual({
363
+ ok: false,
364
+ kind: "network",
365
+ });
366
+ expect(checkBotIdMock).toHaveBeenCalledTimes(1);
367
+ expect(mockSubmitStep).not.toHaveBeenCalled();
368
+ });
369
+
370
+ it("a human sends exactly what the check off sends", async () => {
371
+ checkBotIdMock.mockResolvedValue(HUMAN);
372
+ mockSubmitStep.mockResolvedValue({
373
+ ok: true,
374
+ status: 200,
375
+ body: { sessionId: "s1", nextStepKey: null, completed: true },
376
+ });
377
+ delete process.env.VERCEL;
378
+ await submitFlowStepAction("lead-magnet", VALID_PAYLOAD);
379
+ process.env.VERCEL = "1";
380
+ expect(
381
+ await submitFlowStepAction("lead-magnet", VALID_PAYLOAD),
382
+ ).toMatchObject({ ok: true });
383
+ expect(checkBotIdMock).toHaveBeenCalledTimes(1);
384
+ expect(mockSubmitStep).toHaveBeenCalledTimes(2);
385
+ expect(mockSubmitStep.mock.calls[1]).toEqual(mockSubmitStep.mock.calls[0]);
386
+ });
387
+
388
+ it("an invalid payload answers invalid_input with no check", async () => {
389
+ checkBotIdMock.mockResolvedValue(BOT);
390
+ expect(
391
+ await submitFlowStepAction("lead-magnet", { stepKey: "", values: {} }),
392
+ ).toMatchObject({
393
+ ok: false,
394
+ kind: "invalid_input",
395
+ });
396
+ expect(checkBotIdMock).not.toHaveBeenCalled();
397
+ });
398
+ });