create-ailk 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/README.md +86 -9
  2. package/dist/add-module-core.js +2 -1
  3. package/dist/add-module.d.ts +2 -1
  4. package/dist/add-module.js +9 -4
  5. package/dist/add-route.js +46 -19
  6. package/dist/body-heading.d.ts +56 -0
  7. package/dist/body-heading.js +394 -0
  8. package/dist/cli.js +28 -1
  9. package/dist/content-payloads.js +2 -2
  10. package/dist/derive-architecture.js +8 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +4 -1
  13. package/dist/lib/auth-secret-file.d.ts +6 -2
  14. package/dist/lib/auth-secret-file.js +46 -20
  15. package/dist/lib/env-catalog.js +20 -0
  16. package/dist/lib/site-modules.d.ts +8 -0
  17. package/dist/lib/site-modules.js +34 -0
  18. package/dist/module-architecture.js +25 -14
  19. package/dist/paid-sections.d.ts +17 -0
  20. package/dist/paid-sections.js +39 -0
  21. package/dist/programmatic.d.ts +16 -0
  22. package/dist/programmatic.js +73 -4
  23. package/dist/remove-module.d.ts +98 -0
  24. package/dist/remove-module.js +620 -0
  25. package/dist/seed-flows.d.ts +2 -2
  26. package/dist/seed-flows.js +16 -9
  27. package/dist/site-roster.js +4 -1
  28. package/dist/starter-grid.d.ts +26 -0
  29. package/dist/starter-grid.js +34 -0
  30. package/dist/surfaces.d.ts +26 -0
  31. package/dist/surfaces.js +112 -11
  32. package/dist/sync-routes.d.ts +7 -0
  33. package/dist/sync-routes.js +53 -6
  34. package/dist/template-pages.d.ts +36 -0
  35. package/dist/template-pages.js +129 -0
  36. package/dist/topology.d.ts +2 -1
  37. package/dist/topology.js +4 -2
  38. package/dist/update.d.ts +3 -1
  39. package/dist/update.js +90 -6
  40. package/package.json +3 -3
  41. package/release-scope.json +2 -1
  42. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  43. package/templates/.env.example +12 -2
  44. package/templates/CLAUDE.md +20 -20
  45. package/templates/README.md +19 -19
  46. package/templates/apps/api/.env.example +16 -5
  47. package/templates/apps/api/CLAUDE.md +32 -11
  48. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  49. package/templates/apps/api/src/vercel-handler.ts +8 -1
  50. package/templates/apps/api/vercel.json +7 -1
  51. package/templates/apps/mcp/CLAUDE.md +18 -10
  52. package/templates/apps/web/.env.example +27 -0
  53. package/templates/apps/web/CLAUDE.md +56 -8
  54. package/templates/apps/web/__tests__/setup.ts +7 -0
  55. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
  58. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  59. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
  60. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
  61. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  63. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  66. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  67. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  68. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  69. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  70. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  72. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  73. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  74. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  76. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  77. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  78. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  79. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  80. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  81. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  82. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  83. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  84. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  85. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  86. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  87. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  88. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  89. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  90. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  91. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  92. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  93. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  105. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  107. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  108. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  109. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  110. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  111. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  114. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  115. package/templates/apps/web/app/[locale]/layout.tsx +161 -128
  116. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  117. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  118. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  119. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  120. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  121. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  122. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  124. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  126. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  127. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  128. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  129. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  131. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  132. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  133. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  134. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  135. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  136. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  137. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  138. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  139. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  140. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  141. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  142. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  144. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  145. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  146. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  148. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  149. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  150. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  151. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  152. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  153. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  154. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  155. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  156. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  157. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
  160. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  161. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  162. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  163. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  164. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  165. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  166. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  167. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  168. package/templates/apps/web/auth.ts +102 -50
  169. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  170. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  171. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  172. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  173. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  174. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  175. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  176. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  177. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  178. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  179. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  180. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  181. package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
  182. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  183. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  184. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  185. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  187. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  188. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  189. package/templates/apps/web/lib/auth-secret.ts +57 -0
  190. package/templates/apps/web/lib/bot-check.ts +138 -0
  191. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  192. package/templates/apps/web/lib/deck-page.ts +6 -3
  193. package/templates/apps/web/lib/docs-index.ts +100 -0
  194. package/templates/apps/web/lib/docs-path.ts +94 -0
  195. package/templates/apps/web/lib/hub-children.tsx +318 -0
  196. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
  197. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
  198. package/templates/apps/web/lib/modules.generated.ts +9 -0
  199. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  200. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  201. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  202. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  203. package/templates/apps/web/lib/page-chrome.ts +367 -0
  204. package/templates/apps/web/lib/posting-title.ts +26 -4
  205. package/templates/apps/web/lib/pricing-page.ts +430 -0
  206. package/templates/apps/web/lib/relay.ts +44 -4
  207. package/templates/apps/web/lib/route-family.ts +69 -0
  208. package/templates/apps/web/lib/service-listing.ts +89 -0
  209. package/templates/apps/web/lib/services-path.ts +97 -0
  210. package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
  211. package/templates/apps/web/lib/site-header.ts +94 -0
  212. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  213. package/templates/apps/web/lib/template-listing.ts +169 -0
  214. package/templates/apps/web/messages/en.json +144 -3
  215. package/templates/apps/web/middleware.ts +179 -12
  216. package/templates/apps/web/next.config.mjs +220 -3
  217. package/templates/apps/web/package.json +3 -2
  218. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  219. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  220. package/templates/apps/web/sentry.server.config.ts +30 -2
  221. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  222. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  223. package/templates/apps/web/src/lib/routes.ts +15 -1
  224. package/versions.json +48 -48
@@ -62,7 +62,7 @@ describe("tech-articles/[slug] — snapshot regression", () => {
62
62
  });
63
63
  const { container } = render(element);
64
64
  expect(container.innerHTML).toMatchInlineSnapshot(`
65
- "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AEO Layer 1 Scoring","description":"Deep dive into AEO Layer 1 scoring and AILK audit internals.","mainEntity":{"@context":"https://schema.org","@type":"TechArticle","headline":"AEO Layer 1 Scoring","description":"Deep dive into AEO Layer 1 scoring and AILK audit internals."}},{"@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":"Tech Articles","item":"http://localhost:3000/en/tech-articles"},{"@type":"ListItem","position":3,"name":"AEO Layer 1 Scoring","item":"http://localhost:3000/en/tech-articles/aeo-layer1-scoring"}]}]}</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 Layer 1 Scoring</h1><p data-testid="tech-article-description" class="mt-block-tight text-lead">Deep dive into AEO Layer 1 scoring and AILK audit internals.</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 Layer 1
65
+ "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AEO Layer 1 Scoring","description":"Deep dive into AEO Layer 1 scoring and AILK audit internals.","mainEntity":{"@context":"https://schema.org","@type":"TechArticle","headline":"AEO Layer 1 Scoring","description":"Deep dive into AEO Layer 1 scoring and AILK audit internals."}},{"@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":"Tech Articles","item":"http://localhost:3000/en/tech-articles"},{"@type":"ListItem","position":3,"name":"AEO Layer 1 Scoring","item":"http://localhost:3000/en/tech-articles/aeo-layer1-scoring"}]}]}</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 Layer 1
66
66
 
67
67
  Body content.</div></div></article></div></div></div></main>"
68
68
  `);
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Tests for /templates/[slug] (#10051 D7, AC-5) — the TemplatePage detail
3
+ * route. `renderPage`/`renderPageMetadata` are mocked at the
4
+ * `@working-theory/page-renderer` boundary (the /vs/[slug] shape) and each
5
+ * assertion inspects the sections the route hands `renderPage`; the unknown-
6
+ * slug case runs the real `renderPage` so its notFound() is observed. The
7
+ * JSON-LD is the real builder over the real `toContentPageInput`, the path
8
+ * `renderPage` takes.
9
+ */
10
+
11
+ const mockRenderPage = jest.fn(
12
+ async (..._args: unknown[]): Promise<unknown> => "<rendered/>",
13
+ );
14
+ const mockRenderPageMetadata = jest.fn((..._args: unknown[]) => undefined);
15
+ const mockNotFound = jest.fn(() => {
16
+ throw new Error("NEXT_NOT_FOUND");
17
+ });
18
+
19
+ jest.mock("next/navigation", () => ({
20
+ __esModule: true,
21
+ notFound: () => mockNotFound(),
22
+ redirect: jest.fn(),
23
+ }));
24
+
25
+ jest.mock("@working-theory/page-renderer", () => ({
26
+ ...jest.requireActual("@working-theory/page-renderer"),
27
+ renderPage: (...args: unknown[]) => mockRenderPage(...args),
28
+ renderPageMetadata: (...args: unknown[]) => mockRenderPageMetadata(...args),
29
+ }));
30
+
31
+ const MESSAGES: Record<string, string> = {
32
+ getIt: "Get it",
33
+ startFree: "Start free",
34
+ viewDemo: "View demo",
35
+ viewTemplate: "View template",
36
+ included: "What's included",
37
+ demoBadge: "Live demo",
38
+ };
39
+
40
+ jest.mock("next-intl/server", () => ({
41
+ __esModule: true,
42
+ getTranslations: () => Promise.resolve((key: string) => MESSAGES[key] ?? key),
43
+ }));
44
+
45
+ import { pageToJsonLd } from "@working-theory/page-renderer";
46
+
47
+ import TemplateDetailPage, { generateMetadata } from "../page";
48
+
49
+ const A = {
50
+ type: "TemplatePage",
51
+ slug: "a",
52
+ title: "Template A",
53
+ description: "Description A.",
54
+ summary: "Summary A.",
55
+ price: 0,
56
+ priceCurrency: "USD",
57
+ demoUrl: "https://a.salessmyth.com",
58
+ categories: ["accounting"],
59
+ thumbnail: "/img/a.png",
60
+ getItUrl: "/contact",
61
+ includes: [
62
+ { title: "Base site", description: "Pages and structured data." },
63
+ { title: "Marketing", description: "Landing pages." },
64
+ ],
65
+ };
66
+
67
+ const B = {
68
+ type: "TemplatePage",
69
+ slug: "b",
70
+ title: "Template B",
71
+ description: "Description B.",
72
+ getItUrl: "/contact",
73
+ includes: [{ title: "Base site", description: "Pages and structured data." }],
74
+ };
75
+
76
+ type Section = { type: string; id: string; data: Record<string, unknown> };
77
+
78
+ async function sectionsFor(
79
+ frontmatter: Record<string, unknown>,
80
+ declared: Section[] = [],
81
+ ) {
82
+ await TemplateDetailPage({
83
+ params: Promise.resolve({ locale: "en", slug: String(frontmatter.slug) }),
84
+ getMdxSource: jest
85
+ .fn()
86
+ .mockResolvedValue({ frontmatter, body: "" }) as never,
87
+ getPageSections: jest.fn(async () => declared) as never,
88
+ });
89
+ const input = mockRenderPage.mock.calls[0]![0] as {
90
+ type: string;
91
+ translationsNamespace?: string;
92
+ getPageSections: (fm: unknown) => Promise<Section[]>;
93
+ };
94
+ return { input, sections: await input.getPageSections(frontmatter) };
95
+ }
96
+
97
+ beforeEach(() => jest.clearAllMocks());
98
+
99
+ describe("TemplatePage detail — metadata", () => {
100
+ it("resolves metadata for the given slug", async () => {
101
+ await generateMetadata({
102
+ params: Promise.resolve({ locale: "en", slug: "a" }),
103
+ });
104
+ expect(mockRenderPageMetadata).toHaveBeenCalledWith({
105
+ type: "TemplatePage",
106
+ slug: "a",
107
+ locale: "en",
108
+ });
109
+ });
110
+ });
111
+
112
+ describe("TemplatePage detail — the generated sections (#10051 AC-5)", () => {
113
+ it("renders TemplatePage with no translations namespace", async () => {
114
+ const { input } = await sectionsFor(A);
115
+ expect(input.type).toBe("TemplatePage");
116
+ expect(input.translationsNamespace).toBeUndefined();
117
+ });
118
+
119
+ it("leads with a hero carrying the title, summary, thumbnail, Start free and View demo", async () => {
120
+ const { sections } = await sectionsFor(A);
121
+ expect(sections[0]).toMatchObject({
122
+ type: "hero",
123
+ data: {
124
+ headline: "Template A",
125
+ subheadline: "Summary A.",
126
+ image: { src: "/img/a.png", alt: "Template A" },
127
+ primaryCta: { label: "Start free", href: "/contact" },
128
+ secondaryCta: { label: "View demo", href: "https://a.salessmyth.com" },
129
+ },
130
+ });
131
+ });
132
+
133
+ it("renders What's included with one item per includes row", async () => {
134
+ const { sections } = await sectionsFor(A);
135
+ const included = sections.find((s) => s.id === "template-included");
136
+ expect(included?.data.headline).toBe("What's included");
137
+ expect(included?.data.items).toHaveLength(2);
138
+ });
139
+
140
+ it("renders Get it and no View demo for a priceless page without a demo", async () => {
141
+ const { sections } = await sectionsFor(B);
142
+ expect(sections[0]!.data.primaryCta).toEqual({
143
+ label: "Get it",
144
+ href: "/contact",
145
+ });
146
+ expect(sections[0]!.data).not.toHaveProperty("secondaryCta");
147
+ });
148
+
149
+ it("keeps an author's own leading hero and still renders What's included", async () => {
150
+ const own = {
151
+ type: "hero",
152
+ id: "own-hero",
153
+ data: { headline: "My own hero" },
154
+ };
155
+ const { sections } = await sectionsFor(A, [own]);
156
+ expect(sections.filter((s) => s.type === "hero").map((s) => s.id)).toEqual([
157
+ "own-hero",
158
+ ]);
159
+ // The author's hero leads (spec Clarification); What's included follows it.
160
+ expect(sections.map((s) => s.id)).toEqual([
161
+ "own-hero",
162
+ "template-included",
163
+ ]);
164
+ });
165
+
166
+ it("emits a Product main entity carrying an offers node", async () => {
167
+ const emitted = await pageToJsonLd(A as never, "en");
168
+ const graph = (emitted as { "@graph"?: unknown[] })["@graph"];
169
+ const blocks = (graph ??
170
+ (Array.isArray(emitted) ? emitted : [emitted])) as Array<
171
+ Record<string, unknown>
172
+ >;
173
+ const page = blocks.find((b) => b["@type"] === "WebPage") as {
174
+ mainEntity: Record<string, unknown>;
175
+ };
176
+ expect(page.mainEntity["@type"]).toBe("Product");
177
+ expect(page.mainEntity.offers).toMatchObject({
178
+ "@type": "Offer",
179
+ price: "0",
180
+ });
181
+ expect(String((page.mainEntity.offers as { url: string }).url)).toMatch(
182
+ /\/contact$/,
183
+ );
184
+ });
185
+ });
186
+
187
+ describe("TemplatePage detail — unknown slug", () => {
188
+ it("calls notFound through renderPage's detail branch", async () => {
189
+ const actual = jest.requireActual("@working-theory/page-renderer") as {
190
+ renderPage: (...args: unknown[]) => Promise<unknown>;
191
+ };
192
+ mockRenderPage.mockImplementationOnce((...args) =>
193
+ actual.renderPage(...args),
194
+ );
195
+ await expect(
196
+ TemplateDetailPage({
197
+ params: Promise.resolve({ locale: "en", slug: "no-such-template" }),
198
+ getMdxSource: jest.fn().mockResolvedValue(null) as never,
199
+ }),
200
+ ).rejects.toThrow("NEXT_NOT_FOUND");
201
+ expect(mockNotFound).toHaveBeenCalled();
202
+ });
203
+ });
@@ -0,0 +1,81 @@
1
+ // TemplatePage detail (#10051 D7) — one template at /templates/<slug>,
2
+ // listed by the /templates hub. Reads the TemplatePage MDX for the given
3
+ // slug and renders in sections mode: a generated hero (name, summary,
4
+ // thumbnail, categories, Get it / Start free, View demo) and "What's
5
+ // included", built from the frontmatter, then the page's own declared
6
+ // sections. A page whose first declared section is a `hero` keeps its own
7
+ // hero and drops the generated one. JSON-LD: a WebPage whose mainEntity is a
8
+ // schema.org Product carrying an Offer. Unknown slugs 404 through
9
+ // renderPage's detail branch.
10
+ //
11
+ // Gated off by default (surfaces.ts `templates` surface): no roster names
12
+ // this route, so a fresh scaffold excludes it. `create-ailk add-route
13
+ // templates` installs it.
14
+ import type {
15
+ getMdxSource as defaultGetMdxSource,
16
+ getPageSections as defaultGetPageSections,
17
+ } from "@working-theory/page-renderer";
18
+ import {
19
+ getPageSections as loadPageSections,
20
+ renderPage,
21
+ renderPageMetadata,
22
+ } from "@working-theory/page-renderer";
23
+ import type { Metadata } from "next";
24
+ import { getTranslations } from "next-intl/server";
25
+ import type { ReactElement } from "react";
26
+
27
+ import {
28
+ templateDetailSections,
29
+ templateLabels,
30
+ } from "../../../../lib/template-listing";
31
+
32
+ interface Props {
33
+ params: Promise<{ locale: string; slug: string }>;
34
+ getMdxSource?: typeof defaultGetMdxSource;
35
+ getPageSections?: typeof defaultGetPageSections;
36
+ }
37
+
38
+ type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
39
+
40
+ export async function generateMetadata({
41
+ params,
42
+ }: Pick<Props, "params">): Promise<Metadata> {
43
+ const { locale, slug } = await params;
44
+ return renderPageMetadata({ type: "TemplatePage", slug, locale });
45
+ }
46
+
47
+ export default async function Page({
48
+ params,
49
+ getMdxSource,
50
+ getPageSections,
51
+ }: Props): Promise<ReactElement> {
52
+ const { locale, slug } = await params;
53
+ const loadSections = getPageSections ?? loadPageSections;
54
+
55
+ /**
56
+ * The generated sections first, then the declared ones. Runs only when a
57
+ * source exists, so the translations read here never shadows renderPage's
58
+ * null-source 404 (no `translationsNamespace` is passed, #7537).
59
+ */
60
+ async function withTemplate(frontmatter: unknown): Promise<Section[]> {
61
+ const declared = await loadSections(frontmatter);
62
+ const t = await getTranslations({ namespace: "templates", locale });
63
+ const [hero, included] = templateDetailSections(
64
+ frontmatter,
65
+ templateLabels(t),
66
+ );
67
+ // An author's own leading hero takes the generated hero's place at the top.
68
+ const [first, ...rest] = declared;
69
+ return first?.type === "hero"
70
+ ? [first, included, ...rest]
71
+ : [hero, included, ...declared];
72
+ }
73
+
74
+ return renderPage({
75
+ type: "TemplatePage",
76
+ slug,
77
+ locale,
78
+ getPageSections: withTemplate,
79
+ ...(getMdxSource !== undefined && { getMdxSource }),
80
+ });
81
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Tests for /templates (#10051 D7, AC-4) — the TemplatesList hub.
3
+ *
4
+ * `renderPage`/`renderPageMetadata`/`getMdxSlugs` are mocked at the
5
+ * `@working-theory/page-renderer` boundary, the /reports test shape — each
6
+ * assertion inspects the sections the route hands `renderPage`. The JSON-LD
7
+ * is built from those same sections with the real `buildTemplatesList`, as
8
+ * `renderPage` does, so the card count and the ItemList are compared on one
9
+ * input.
10
+ */
11
+ import { buildTemplatesList } from "@working-theory/schema";
12
+
13
+ const mockRenderPage = jest.fn(async (..._args: unknown[]) => "<rendered/>");
14
+ const mockRenderPageMetadata = jest.fn((..._args: unknown[]) => undefined);
15
+ const mockGetMdxSlugs = jest.fn((..._args: unknown[]) =>
16
+ Promise.resolve<string[]>([]),
17
+ );
18
+ const mockNotFound = jest.fn(() => {
19
+ throw new Error("NEXT_NOT_FOUND");
20
+ });
21
+
22
+ jest.mock("next/navigation", () => ({
23
+ __esModule: true,
24
+ notFound: () => mockNotFound(),
25
+ }));
26
+
27
+ jest.mock("@working-theory/page-renderer", () => ({
28
+ __esModule: true,
29
+ getMdxSlugs: (...args: unknown[]) => mockGetMdxSlugs(...args),
30
+ renderPage: (...args: unknown[]) => mockRenderPage(...args),
31
+ renderPageMetadata: (...args: unknown[]) => mockRenderPageMetadata(...args),
32
+ // Never reached — every call in this suite passes both via DI props.
33
+ getMdxSource: jest.fn(),
34
+ getPageSections: jest.fn(),
35
+ }));
36
+
37
+ const MESSAGES: Record<string, string> = {
38
+ emptyState: "Templates coming soon.",
39
+ getIt: "Get it",
40
+ startFree: "Start free",
41
+ viewDemo: "View demo",
42
+ viewTemplate: "View template",
43
+ included: "What's included",
44
+ demoBadge: "Live demo",
45
+ };
46
+
47
+ jest.mock("next-intl/server", () => ({
48
+ __esModule: true,
49
+ getTranslations: () => Promise.resolve((key: string) => MESSAGES[key] ?? key),
50
+ }));
51
+
52
+ import TemplatesPage, { generateMetadata } from "../page";
53
+
54
+ const HUB_SOURCE = {
55
+ frontmatter: {
56
+ type: "TemplatesList",
57
+ slug: "templates",
58
+ title: "Templates",
59
+ description: "Every template we sell.",
60
+ },
61
+ body: "",
62
+ };
63
+
64
+ const TEMPLATE_SOURCES: Record<
65
+ string,
66
+ { frontmatter: Record<string, unknown>; body: string }
67
+ > = {
68
+ a: {
69
+ frontmatter: {
70
+ type: "TemplatePage",
71
+ slug: "a",
72
+ title: "Template A",
73
+ description: "Description A.",
74
+ summary: "Summary A.",
75
+ price: 0,
76
+ priceCurrency: "USD",
77
+ demoUrl: "https://a.salessmyth.com",
78
+ categories: ["accounting"],
79
+ thumbnail: "/img/a.png",
80
+ getItUrl: "/contact",
81
+ includes: [{ title: "Base site", description: "Pages." }],
82
+ },
83
+ body: "",
84
+ },
85
+ b: {
86
+ frontmatter: {
87
+ type: "TemplatePage",
88
+ slug: "b",
89
+ title: "Template B",
90
+ description: "Description B.",
91
+ getItUrl: "/contact",
92
+ includes: [{ title: "Base site", description: "Pages." }],
93
+ },
94
+ body: "",
95
+ },
96
+ c: {
97
+ frontmatter: {
98
+ type: "TemplatePage",
99
+ slug: "c",
100
+ title: "Template C",
101
+ description: "Description C.",
102
+ getItUrl: "/contact",
103
+ includes: [{ title: "Base site", description: "Pages." }],
104
+ },
105
+ body: "",
106
+ },
107
+ };
108
+
109
+ const CTA = { type: "cta", id: "closing", data: { headline: "Talk to us" } };
110
+
111
+ function loaderFor(slugs: string[]) {
112
+ mockGetMdxSlugs.mockResolvedValue([...slugs].reverse());
113
+ return jest.fn(async (type: string, slug: string) => {
114
+ if (type === "TemplatesList") return HUB_SOURCE;
115
+ return slugs.includes(slug) ? (TEMPLATE_SOURCES[slug] ?? null) : null;
116
+ });
117
+ }
118
+
119
+ type Section = { type: string; id: string; data: Record<string, unknown> };
120
+
121
+ async function renderedSections(slugs: string[], declared: Section[] = [CTA]) {
122
+ await TemplatesPage({
123
+ params: Promise.resolve({ locale: "en" }),
124
+ getMdxSource: loaderFor(slugs) as never,
125
+ getPageSections: jest.fn(async () => declared) as never,
126
+ });
127
+ const input = mockRenderPage.mock.calls[0]![0] as {
128
+ getPageSections: (fm: unknown) => Promise<Section[]>;
129
+ };
130
+ return input.getPageSections(HUB_SOURCE.frontmatter);
131
+ }
132
+
133
+ function cardsOf(sections: Section[]) {
134
+ const grid = sections.find((s) => s.id === "template-cards");
135
+ return (grid?.data.items ?? []) as Array<Record<string, unknown>>;
136
+ }
137
+
138
+ beforeEach(() => jest.clearAllMocks());
139
+
140
+ describe("TemplatesPage — hub seed absent", () => {
141
+ it("calls notFound() and never reaches renderPage", async () => {
142
+ await expect(
143
+ TemplatesPage({
144
+ params: Promise.resolve({ locale: "en" }),
145
+ getMdxSource: jest.fn().mockResolvedValue(null),
146
+ getPageSections: jest.fn(),
147
+ }),
148
+ ).rejects.toThrow("NEXT_NOT_FOUND");
149
+ expect(mockNotFound).toHaveBeenCalledTimes(1);
150
+ expect(mockRenderPage).not.toHaveBeenCalled();
151
+ });
152
+ });
153
+
154
+ describe("TemplatesPage — metadata", () => {
155
+ it("resolves TemplatesList slug templates", async () => {
156
+ await generateMetadata({ params: Promise.resolve({ locale: "en" }) });
157
+ expect(mockRenderPageMetadata).toHaveBeenCalledWith({
158
+ type: "TemplatesList",
159
+ slug: "templates",
160
+ locale: "en",
161
+ });
162
+ });
163
+ });
164
+
165
+ describe("TemplatesPage — the generated card grid (#10051 AC-4)", () => {
166
+ it("renders TemplatesList with the templates namespace", async () => {
167
+ await renderedSections(["a", "b"]);
168
+ expect(mockRenderPage.mock.calls[0]![0]).toMatchObject({
169
+ type: "TemplatesList",
170
+ slug: "templates",
171
+ translationsNamespace: "templates",
172
+ });
173
+ });
174
+
175
+ it("renders two cards in slug order with every D2 field present", async () => {
176
+ const cards = cardsOf(await renderedSections(["a", "b"]));
177
+ expect(cards).toEqual([
178
+ {
179
+ title: "Template A",
180
+ description: "Summary A.",
181
+ image: { src: "/img/a.png", alt: "Template A" },
182
+ eyebrow: "Accounting",
183
+ badge: "Live demo",
184
+ link: { label: "View template", href: "/templates/a" },
185
+ },
186
+ {
187
+ title: "Template B",
188
+ description: "Description B.",
189
+ link: { label: "View template", href: "/templates/b" },
190
+ },
191
+ ]);
192
+ });
193
+
194
+ it("renders three cards when a third source is added, with no hub change", async () => {
195
+ expect(cardsOf(await renderedSections(["a", "b", "c"]))).toHaveLength(3);
196
+ });
197
+
198
+ it("places the generated section before the seed's trailing cta", async () => {
199
+ const hero = {
200
+ type: "hero",
201
+ id: "hub-hero",
202
+ data: { headline: "Templates" },
203
+ };
204
+ const sections = await renderedSections(["a"], [hero, CTA]);
205
+ expect(sections.map((s) => s.id)).toEqual([
206
+ "hub-hero",
207
+ "template-cards",
208
+ "closing",
209
+ ]);
210
+ });
211
+
212
+ it("renders the empty-state note and no card with zero sources", async () => {
213
+ const sections = await renderedSections([]);
214
+ expect(cardsOf(sections)).toHaveLength(0);
215
+ expect(sections.find((s) => s.id === "template-cards-empty")?.data).toEqual(
216
+ {
217
+ body: "Templates coming soon.",
218
+ align: "center",
219
+ },
220
+ );
221
+ });
222
+
223
+ it("emits an ItemList with as many elements as cards", async () => {
224
+ const sections = await renderedSections(["a", "b", "c"]);
225
+ const jsonLd = buildTemplatesList({
226
+ type: "TemplatesList",
227
+ slug: "templates",
228
+ title: "Templates",
229
+ description: "Every template we sell.",
230
+ sections,
231
+ } as never) as unknown as { mainEntity: { itemListElement: unknown[] } };
232
+ expect(jsonLd.mainEntity.itemListElement).toHaveLength(
233
+ cardsOf(sections).length,
234
+ );
235
+ });
236
+ });
@@ -0,0 +1,108 @@
1
+ // TemplatesList hub (#10051 D7) — the /templates page. Reads the
2
+ // TemplatesList MDX via the content adapter, inserts one generated `items`
3
+ // section carrying a card per TemplatePage (so the visible card grid and the
4
+ // CollectionPage's ItemList JSON-LD — built from the same sections — always
5
+ // agree, the /alternatives shape), and emits via the dispatcher.
6
+ //
7
+ // notFound() when the hub seed is absent (the /alternatives rule). With zero
8
+ // TemplatePage files the generated section is a content-block carrying the
9
+ // translated empty-state note (the /reports rule). Either sits before the
10
+ // page's trailing closing CTA(s) (#7745 D4).
11
+ //
12
+ // Gated off by default (surfaces.ts `templates` surface): no roster names
13
+ // this route, so a fresh scaffold excludes it. `create-ailk add-route
14
+ // templates` installs it with one seed per AILK template.
15
+ import type {
16
+ getMdxSource as defaultGetMdxSource,
17
+ getPageSections as defaultGetPageSections,
18
+ } from "@working-theory/page-renderer";
19
+ import {
20
+ getMdxSource as loadMdxSource,
21
+ getPageSections as loadPageSections,
22
+ renderPage,
23
+ renderPageMetadata,
24
+ } from "@working-theory/page-renderer";
25
+ import type { Metadata } from "next";
26
+ import { notFound } from "next/navigation";
27
+ import { getTranslations } from "next-intl/server";
28
+ import type { ReactElement } from "react";
29
+
30
+ import {
31
+ insertBeforeClosingCtas,
32
+ listDetailPages,
33
+ } from "../../../lib/route-family";
34
+ import { templateCard, templateLabels } from "../../../lib/template-listing";
35
+
36
+ interface Props {
37
+ params: Promise<{ locale: string }>;
38
+ getMdxSource?: typeof defaultGetMdxSource;
39
+ getPageSections?: typeof defaultGetPageSections;
40
+ }
41
+
42
+ type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
43
+
44
+ export async function generateMetadata({
45
+ params,
46
+ }: Pick<Props, "params">): Promise<Metadata> {
47
+ const { locale } = await params;
48
+ return renderPageMetadata({
49
+ type: "TemplatesList",
50
+ slug: "templates",
51
+ locale,
52
+ });
53
+ }
54
+
55
+ export default async function Page({
56
+ params,
57
+ getMdxSource,
58
+ getPageSections,
59
+ }: Props): Promise<ReactElement> {
60
+ const { locale } = await params;
61
+ const load = getMdxSource ?? loadMdxSource;
62
+ const loadSections = getPageSections ?? loadPageSections;
63
+
64
+ const source = await load("TemplatesList", "templates", locale);
65
+ if (source === null) {
66
+ notFound();
67
+ }
68
+
69
+ const pages = await listDetailPages("TemplatePage", locale, load);
70
+ const t = await getTranslations({ namespace: "templates", locale });
71
+ const labels = templateLabels(t);
72
+
73
+ /**
74
+ * One generated section listing every TemplatePage — D4's "lists every
75
+ * template page automatically", and the ItemList JSON-LD agrees with it
76
+ * because `renderPage` builds both from these sections.
77
+ */
78
+ async function withTemplates(frontmatter: unknown): Promise<Section[]> {
79
+ const declared = await loadSections(frontmatter);
80
+ const generated: Section =
81
+ pages.length > 0
82
+ ? {
83
+ type: "items",
84
+ id: "template-cards",
85
+ // A tile is one click target per card (design review round 1, B1).
86
+ data: {
87
+ variant: "tile",
88
+ tileHover: "outline",
89
+ items: pages.map((page) => templateCard(page, labels)),
90
+ },
91
+ }
92
+ : {
93
+ type: "content-block",
94
+ id: "template-cards-empty",
95
+ data: { body: t("emptyState"), align: "center" },
96
+ };
97
+ return insertBeforeClosingCtas(declared, generated);
98
+ }
99
+
100
+ return renderPage({
101
+ type: "TemplatesList",
102
+ slug: "templates",
103
+ locale,
104
+ translationsNamespace: "templates",
105
+ getPageSections: withTemplates,
106
+ ...(getMdxSource !== undefined && { getMdxSource }),
107
+ });
108
+ }