create-ailk 0.5.0 → 0.6.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 (299) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +336 -27
  3. package/component-catalog.md +25 -12
  4. package/dist/lib/api-boot-assertion.d.ts +46 -0
  5. package/dist/lib/api-boot-assertion.js +70 -0
  6. package/dist/lib/import-graph.d.ts +29 -0
  7. package/dist/lib/import-graph.js +140 -0
  8. package/dist/module-architecture.d.ts +1 -0
  9. package/dist/module-architecture.js +253 -127
  10. package/dist/surfaces.js +105 -7
  11. package/dist/template-manifest.d.ts +33 -0
  12. package/dist/template-manifest.js +99 -16
  13. package/dist/template-names.d.ts +1 -1
  14. package/dist/template-names.js +1 -0
  15. package/package.json +3 -2
  16. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
  17. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
  18. package/site-templates/marketing-site/ailk-template.json +1 -1
  19. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
  20. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
  21. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
  22. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
  23. package/site-templates/minimalism-site/ailk-template.json +2 -1
  24. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  25. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
  26. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  27. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  28. package/site-templates/simple-service/ailk-template.json +10 -0
  29. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  30. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  31. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  32. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  33. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  34. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  35. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  36. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  37. package/site-templates/simple-service/provenance.json +4 -0
  38. package/templates/.env.example +72 -7
  39. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  40. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  41. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  42. package/templates/apps/api/.env.example +13 -0
  43. package/templates/apps/api/CLAUDE.md +26 -2
  44. package/templates/apps/api/jest.config.cjs +3 -0
  45. package/templates/apps/api/package.json +1 -0
  46. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  47. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
  48. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  49. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  50. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
  51. package/templates/apps/api/src/config/index.ts +62 -0
  52. package/templates/apps/api/src/config/modules.ts +7 -3
  53. package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
  54. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  55. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  56. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  57. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  58. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  59. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  60. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  61. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  62. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  63. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  64. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  65. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  66. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  67. package/templates/apps/api/src/lib/site.ts +208 -9
  68. package/templates/apps/api/src/lib/stripe.ts +66 -0
  69. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
  70. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
  71. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  72. package/templates/apps/api/src/openapi/spec.ts +543 -2
  73. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  74. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  75. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  76. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  77. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  78. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  80. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  81. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  82. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  83. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  84. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  85. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  87. package/templates/apps/api/src/routes/content/index.ts +13 -73
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  89. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  90. package/templates/apps/api/src/routes/content/search.ts +129 -0
  91. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  92. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  93. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  94. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  95. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  96. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  97. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  98. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  99. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  100. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  101. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  102. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  103. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  104. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  105. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  106. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  107. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  108. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  109. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  110. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  111. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  112. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  113. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  114. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  115. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  116. package/templates/apps/api/src/server.ts +439 -49
  117. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  118. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  119. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  120. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  121. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  122. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  123. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  124. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  125. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  126. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  127. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  128. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  129. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  130. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  131. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  132. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  133. package/templates/apps/api/src/services/audit-log.ts +668 -0
  134. package/templates/apps/api/src/services/consent.ts +18 -0
  135. package/templates/apps/api/src/services/default-tenant.ts +70 -31
  136. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  137. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  138. package/templates/apps/api/src/services/entitlements.ts +80 -0
  139. package/templates/apps/api/src/services/lead-promotion.ts +13 -14
  140. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  141. package/templates/apps/api/src/services/leads-inbox.ts +23 -2
  142. package/templates/apps/api/src/services/listing-config.ts +27 -9
  143. package/templates/apps/api/src/services/project-listings.ts +201 -6
  144. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  145. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  146. package/templates/apps/api/src/services/revalidation.ts +173 -0
  147. package/templates/apps/api/src/services/verified-email.ts +71 -0
  148. package/templates/apps/api/src/vercel-handler.ts +75 -35
  149. package/templates/apps/mcp/.env.example +17 -0
  150. package/templates/apps/mcp/CLAUDE.md +57 -24
  151. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  152. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  153. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  154. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  155. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  156. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  157. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  158. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  159. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  160. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  161. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  162. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  163. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  164. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  165. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  166. package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
  167. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  168. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  169. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  170. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  171. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  172. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  173. package/templates/apps/mcp/jest.config.cjs +13 -0
  174. package/templates/apps/mcp/package.json +2 -0
  175. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  176. package/templates/apps/mcp/src/config/modules.ts +10 -5
  177. package/templates/apps/mcp/src/http-transport.ts +102 -29
  178. package/templates/apps/mcp/src/index.ts +73 -11
  179. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  180. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  181. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  182. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  183. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  184. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  185. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  186. package/templates/apps/mcp/src/server.ts +59 -35
  187. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  188. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  189. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  190. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  191. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  192. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  193. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  194. package/templates/apps/mcp/src/tools/index.ts +6 -4
  195. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  196. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  197. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  198. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  199. package/templates/apps/web/.env.example +71 -5
  200. package/templates/apps/web/CLAUDE.md +44 -0
  201. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  202. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  203. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  204. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  205. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  206. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  207. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  208. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  209. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  210. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  211. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  212. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  213. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  214. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  215. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  216. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  217. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  218. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  219. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  220. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
  221. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
  222. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  223. package/templates/apps/web/app/[locale]/layout.tsx +133 -87
  224. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  225. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  226. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  227. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  228. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  229. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  230. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  231. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  232. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  233. package/templates/apps/web/auth.ts +407 -8
  234. package/templates/apps/web/jest.config.cjs +8 -4
  235. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  236. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  237. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  238. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
  239. package/templates/apps/web/lib/auth-email.ts +318 -0
  240. package/templates/apps/web/lib/auth-env.ts +85 -0
  241. package/templates/apps/web/lib/auth-routes.ts +55 -0
  242. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  243. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  244. package/templates/apps/web/messages/en.json +187 -0
  245. package/templates/apps/web/package.json +2 -1
  246. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  247. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  248. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  249. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  250. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  251. package/templates/database/CHANGELOG.md +142 -0
  252. package/templates/database/README.md +10 -3
  253. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  254. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  255. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  256. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  257. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  258. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  259. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  260. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  261. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  262. package/templates/database/__tests__/tenancy.test.ts +141 -0
  263. package/templates/database/config/schema.prisma +79 -2
  264. package/templates/database/content/schema.prisma +87 -11
  265. package/templates/database/inbox/schema.prisma +452 -9
  266. package/templates/database/jest.config.cjs +5 -3
  267. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  268. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  269. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  270. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  271. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  272. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  273. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  274. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  275. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  276. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  277. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  278. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  279. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  280. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  281. package/templates/database/package.json +1 -1
  282. package/templates/database/tenancy.ts +86 -1
  283. package/templates/database/tsconfig.json +1 -0
  284. package/versions.json +34 -34
  285. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  286. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  287. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  288. package/templates/apps/api/src/routes/content/create.ts +0 -115
  289. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  290. package/templates/apps/api/src/routes/content/update.ts +0 -112
  291. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  292. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  293. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  294. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  295. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  296. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  297. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  298. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  299. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
@@ -0,0 +1,450 @@
1
+ /**
2
+ * @file search.test.ts
3
+ * GET /v1/content/search — keyword search across published pages (#5831).
4
+ *
5
+ * Uses the real content adapter backed by a per-test temp dir (Issue #507).
6
+ * No internal module mocks — the route exercises the real adapter stack.
7
+ *
8
+ * The `category`-tagged fixtures are written here rather than through
9
+ * `writeFixturePage`, whose frontmatter parameter is `Record<string, string>`
10
+ * and so cannot express a YAML sequence.
11
+ */
12
+
13
+ import * as fs from "fs/promises";
14
+ import * as path from "path";
15
+
16
+ import type { FastifyInstance } from "fastify";
17
+
18
+ import { setupContentFixture } from "../../../__tests__/factories/content-fixture";
19
+ import { getContentAdapter } from "../../../lib/content-adapter";
20
+ import { contentRoutes } from "../index";
21
+ import { CONTENT_RATE_LIMIT_UNAUTH, CONTENT_RATE_LIMIT_WINDOW } from "../rate-limit";
22
+
23
+ const fixture = setupContentFixture();
24
+
25
+ /** Writes an MDX page with a `categories:` sequence into the locale path. */
26
+ async function writeCategorisedPage(
27
+ slug: string,
28
+ title: string,
29
+ description: string,
30
+ categories: readonly string[],
31
+ body: string,
32
+ ): Promise<void> {
33
+ const dir = path.join(fixture.getContentRoot(), "en", "Product");
34
+ await fs.mkdir(dir, { recursive: true });
35
+ const frontmatter = [
36
+ `type: "Product"`,
37
+ `slug: "${slug}"`,
38
+ `title: "${title}"`,
39
+ `description: "${description}"`,
40
+ ...(categories.length > 0
41
+ ? ["categories:", ...categories.map((c) => ` - ${c}`)]
42
+ : []),
43
+ ].join("\n");
44
+ await fs.writeFile(
45
+ path.join(dir, `${slug}.mdx`),
46
+ `---\n${frontmatter}\n---\n\n${body}\n`,
47
+ "utf8",
48
+ );
49
+ }
50
+
51
+ async function seedRoofPages(): Promise<void> {
52
+ await writeCategorisedPage(
53
+ "roof-replacement",
54
+ "Roof Replacement",
55
+ "A full roof replacement service.",
56
+ ["roofing"],
57
+ "We replace a roof in a day.",
58
+ );
59
+ await writeCategorisedPage(
60
+ "attic-ventilation",
61
+ "Attic Ventilation",
62
+ "Ventilation that protects the roof deck.",
63
+ ["hvac"],
64
+ "Attic airflow keeps a roof healthy.",
65
+ );
66
+ await writeCategorisedPage(
67
+ "untagged-roof-guide",
68
+ "Roof Care Guide",
69
+ "How to look after a roof.",
70
+ [],
71
+ "A roof lasts longer when it is inspected.",
72
+ );
73
+ }
74
+
75
+ describe("GET /v1/content/search", () => {
76
+ let app: FastifyInstance;
77
+
78
+ beforeEach(async () => {
79
+ app = require("fastify")();
80
+ await app.register(contentRoutes);
81
+ });
82
+
83
+ afterEach(async () => {
84
+ await app.close();
85
+ });
86
+
87
+ it("returns 200 with a JSON-LD ItemList envelope", async () => {
88
+ await seedRoofPages();
89
+
90
+ const res = await app.inject({
91
+ method: "GET",
92
+ url: "/v1/content/search?q=roof",
93
+ });
94
+
95
+ expect(res.statusCode).toBe(200);
96
+ const body = JSON.parse(res.body);
97
+ expect(body["@context"]).toBe("https://schema.org");
98
+ expect(body["@type"]).toBe("ItemList");
99
+ expect(body.query).toBe("roof");
100
+ expect(Array.isArray(body.items)).toBe(true);
101
+ expect(body.totalItems).toBe(body.items.length);
102
+ expect(body.items.map((i: { slug: string }) => i.slug).sort()).toEqual([
103
+ "attic-ventilation",
104
+ "roof-replacement",
105
+ "untagged-roof-guide",
106
+ ]);
107
+ });
108
+
109
+ it("filters on category — only the tagged page comes back", async () => {
110
+ await seedRoofPages();
111
+
112
+ const res = await app.inject({
113
+ method: "GET",
114
+ url: "/v1/content/search?q=roof&category=roofing",
115
+ });
116
+
117
+ expect(res.statusCode).toBe(200);
118
+ const body = JSON.parse(res.body);
119
+ expect(body.items.map((i: { slug: string }) => i.slug)).toEqual([
120
+ "roof-replacement",
121
+ ]);
122
+ expect(body.totalItems).toBe(1);
123
+ });
124
+
125
+ it("returns an empty ItemList for a registry slug nothing is tagged with", async () => {
126
+ await seedRoofPages();
127
+
128
+ const res = await app.inject({
129
+ method: "GET",
130
+ url: "/v1/content/search?q=roof&category=legal",
131
+ });
132
+
133
+ expect(res.statusCode).toBe(200);
134
+ const body = JSON.parse(res.body);
135
+ expect(body.items).toEqual([]);
136
+ expect(body.totalItems).toBe(0);
137
+ });
138
+
139
+ it("composes the type and category filters", async () => {
140
+ await seedRoofPages();
141
+
142
+ const res = await app.inject({
143
+ method: "GET",
144
+ url: "/v1/content/search?q=roof&type=HomePage&category=roofing",
145
+ });
146
+
147
+ expect(res.statusCode).toBe(200);
148
+ expect(JSON.parse(res.body).items).toEqual([]);
149
+ });
150
+
151
+ it("honours limit", async () => {
152
+ await seedRoofPages();
153
+
154
+ const res = await app.inject({
155
+ method: "GET",
156
+ url: "/v1/content/search?q=roof&limit=1",
157
+ });
158
+
159
+ expect(res.statusCode).toBe(200);
160
+ expect(JSON.parse(res.body).items).toHaveLength(1);
161
+ });
162
+
163
+ it("is not shadowed by the /v1/content/:type route", async () => {
164
+ const res = await app.inject({
165
+ method: "GET",
166
+ url: "/v1/content/search?q=roof",
167
+ });
168
+ // A `:type` match would 400 on the type enum; a search match 200s.
169
+ expect(res.statusCode).toBe(200);
170
+ expect(JSON.parse(res.body).query).toBe("roof");
171
+ });
172
+
173
+ describe("400s on an invalid query", () => {
174
+ it("rejects a missing q", async () => {
175
+ const res = await app.inject({
176
+ method: "GET",
177
+ url: "/v1/content/search",
178
+ });
179
+ expect(res.statusCode).toBe(400);
180
+ const body = JSON.parse(res.body);
181
+ expect(body.error).toBe("Invalid query parameters");
182
+ expect(Array.isArray(body.details)).toBe(true);
183
+ });
184
+
185
+ it("rejects an empty q", async () => {
186
+ const res = await app.inject({
187
+ method: "GET",
188
+ url: "/v1/content/search?q=",
189
+ });
190
+ expect(res.statusCode).toBe(400);
191
+ });
192
+
193
+ it("rejects a category outside the registry", async () => {
194
+ const res = await app.inject({
195
+ method: "GET",
196
+ url: "/v1/content/search?q=roof&category=not-a-real-category",
197
+ });
198
+ expect(res.statusCode).toBe(400);
199
+ const body = JSON.parse(res.body);
200
+ expect(body.details.some((d: { path: string[] }) => d.path.includes("category"))).toBe(true);
201
+ });
202
+
203
+ it("rejects a type outside the schema.org enum", async () => {
204
+ const res = await app.inject({
205
+ method: "GET",
206
+ url: "/v1/content/search?q=roof&type=NotAType",
207
+ });
208
+ expect(res.statusCode).toBe(400);
209
+ });
210
+
211
+ it("rejects a q longer than the 200-char ceiling", async () => {
212
+ const res = await app.inject({
213
+ method: "GET",
214
+ url: `/v1/content/search?q=${"a".repeat(201)}`,
215
+ });
216
+ expect(res.statusCode).toBe(400);
217
+ const body = JSON.parse(res.body);
218
+ expect(
219
+ body.details.some((d: { path: string[] }) => d.path.includes("q")),
220
+ ).toBe(true);
221
+ });
222
+
223
+ it("accepts a q of exactly 200 chars — the same ceiling the MCP tool enforces", async () => {
224
+ const res = await app.inject({
225
+ method: "GET",
226
+ url: `/v1/content/search?q=${"a".repeat(200)}`,
227
+ });
228
+ expect(res.statusCode).toBe(200);
229
+ });
230
+
231
+ it("rejects a limit above the 50 ceiling", async () => {
232
+ const res = await app.inject({
233
+ method: "GET",
234
+ url: "/v1/content/search?q=roof&limit=51",
235
+ });
236
+ expect(res.statusCode).toBe(400);
237
+ });
238
+ });
239
+
240
+ /**
241
+ * D6 — API ↔ MCP parity evidence for #5831.
242
+ *
243
+ * The tool and the route must return the same result set for the same
244
+ * arguments. `apps/api` cannot import `apps/mcp` (the hard import boundary
245
+ * in the root CLAUDE.md), so the shared seam is asserted instead: the route
246
+ * must return EXACTLY what `adapter.searchPages` returns for the options the
247
+ * MCP handler builds from the same arguments. `ailkSearchContent`'s own
248
+ * suite (`apps/mcp/__tests__/search_content.test.ts`) pins the other half —
249
+ * tool results ≡ `adapter.searchPages` for the same arguments — so the two
250
+ * halves compose into the 1:1 claim without either app importing the other.
251
+ */
252
+ describe("parity with the adapter the MCP tool calls (D6)", () => {
253
+ it("deep-equals adapter.searchPages items for the same arguments", async () => {
254
+ await seedRoofPages();
255
+
256
+ const res = await app.inject({
257
+ method: "GET",
258
+ url: "/v1/content/search?q=roof&category=roofing&limit=10&locale=en",
259
+ });
260
+ expect(res.statusCode).toBe(200);
261
+
262
+ // The exact option object `ailkSearchContent` builds for these arguments.
263
+ const expected = await getContentAdapter().searchPages("roof", {
264
+ limit: 10,
265
+ category: "roofing",
266
+ locale: "en",
267
+ });
268
+
269
+ expect(JSON.parse(res.body).items).toEqual(
270
+ JSON.parse(JSON.stringify(expected)),
271
+ );
272
+ });
273
+
274
+ it("deep-equals with no filters at all", async () => {
275
+ await seedRoofPages();
276
+
277
+ const res = await app.inject({
278
+ method: "GET",
279
+ url: "/v1/content/search?q=roof",
280
+ });
281
+
282
+ const expected = await getContentAdapter().searchPages("roof", {
283
+ limit: 10,
284
+ locale: "en",
285
+ });
286
+
287
+ expect(JSON.parse(res.body).items).toEqual(
288
+ JSON.parse(JSON.stringify(expected)),
289
+ );
290
+ });
291
+ });
292
+
293
+ /**
294
+ * `server.ts` registers the limiter GLOBALLY with the content ceiling, so
295
+ * this route is covered without registering one of its own. Asserting the
296
+ * shared constants is the honest check: a route test that registered its own
297
+ * limiter would prove nothing about the server.
298
+ */
299
+ describe("content rate limit", () => {
300
+ it("is governed by the same constants as the other content reads", async () => {
301
+ expect(CONTENT_RATE_LIMIT_UNAUTH).toBe(60);
302
+ expect(CONTENT_RATE_LIMIT_WINDOW).toBe("1 minute");
303
+ });
304
+
305
+ it("429s past the allowance under a limiter built from those constants", async () => {
306
+ const limited: FastifyInstance = require("fastify")();
307
+ const rateLimit = require("@fastify/rate-limit");
308
+ await limited.register(rateLimit, {
309
+ max: CONTENT_RATE_LIMIT_UNAUTH,
310
+ timeWindow: CONTENT_RATE_LIMIT_WINDOW,
311
+ keyGenerator: () => "shared-test-ip",
312
+ });
313
+ await limited.register(contentRoutes);
314
+
315
+ for (let i = 0; i < CONTENT_RATE_LIMIT_UNAUTH; i++) {
316
+ const ok = await limited.inject({
317
+ method: "GET",
318
+ url: "/v1/content/search?q=roof",
319
+ });
320
+ expect(ok.statusCode).toBe(200);
321
+ }
322
+
323
+ const overLimit = await limited.inject({
324
+ method: "GET",
325
+ url: "/v1/content/search?q=roof",
326
+ });
327
+ expect(overLimit.statusCode).toBe(429);
328
+
329
+ await limited.close();
330
+ });
331
+ });
332
+ });
333
+
334
+ // ─── #5852 / #5910 — the database path: which workspace a search read serves ─
335
+ //
336
+ // Appended, so every MDX case above is untouched. Database-free: the
337
+ // module-level Prisma singleton is faked via the repo's manual-mock
338
+ // convention (see get-page.test.ts's identical block — #5852), and
339
+ // `jest.mock` is hoisted above the imports by ts-jest regardless of where it
340
+ // is written. The MDX cases above never load `lib/prisma.js` — the
341
+ // resolver's import is deferred behind the `CONTENT_ADAPTER=database`
342
+ // branch — so the mock is inert for them.
343
+
344
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
345
+ jest.mock("../../../lib/prisma.js");
346
+
347
+ import {
348
+ DEFAULT_ORG_ID,
349
+ DEFAULT_WORKSPACE_ID,
350
+ } from "@working-theory/database/tenancy";
351
+
352
+ import { prisma } from "../../../lib/prisma.js";
353
+
354
+ /** A one-workspace deployment seeded with the well-known default (mirrors get-page.test.ts). */
355
+ function seedOneWorkspaceDeployment(): void {
356
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(1);
357
+ (prisma.workspace.findUnique as jest.Mock).mockResolvedValue({
358
+ id: DEFAULT_WORKSPACE_ID,
359
+ organizationId: DEFAULT_ORG_ID,
360
+ });
361
+ }
362
+
363
+ describe("GET /v1/content/search under CONTENT_ADAPTER=database (#5852 / #5910)", () => {
364
+ let app: FastifyInstance;
365
+
366
+ beforeEach(async () => {
367
+ jest.clearAllMocks();
368
+ process.env.CONTENT_ADAPTER = "database";
369
+ app = require("fastify")();
370
+ await app.register(contentRoutes);
371
+ });
372
+
373
+ afterEach(async () => {
374
+ delete process.env.CONTENT_ADAPTER;
375
+ await app.close();
376
+ });
377
+
378
+ // AC-1 — a resolvable single-workspace deployment returns the search
379
+ // ItemList, with no workspace id anywhere in the URL, and the underlying
380
+ // artifact read is bound to the resolved workspace.
381
+ it("returns the search ItemList for the seeded default workspace, with no workspace id in the URL", async () => {
382
+ seedOneWorkspaceDeployment();
383
+ (prisma.artifact.findMany as jest.Mock).mockResolvedValue([]);
384
+ (prisma.artifactVersion.findMany as jest.Mock).mockResolvedValue([]);
385
+
386
+ const url = "/v1/content/search?q=roof";
387
+ const res = await app.inject({ method: "GET", url });
388
+
389
+ expect(res.statusCode).toBe(200);
390
+ const body = JSON.parse(res.body);
391
+ expect(body["@type"]).toBe("ItemList");
392
+ expect(body.items).toEqual([]);
393
+ // The tenant is resolved server-side; it is never addressable from the wire.
394
+ expect(url).not.toMatch(/wksp_/);
395
+ expect(prisma.artifact.findMany).toHaveBeenCalledWith(
396
+ expect.objectContaining({
397
+ where: expect.objectContaining({ workspaceId: DEFAULT_WORKSPACE_ID }),
398
+ }),
399
+ );
400
+ });
401
+
402
+ // AC-1 — zero workspaces: 404 before any artifact query.
403
+ it("404s with zero workspaces, before any artifact query", async () => {
404
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(0);
405
+
406
+ const res = await app.inject({
407
+ method: "GET",
408
+ url: "/v1/content/search?q=roof",
409
+ });
410
+
411
+ expect(res.statusCode).toBe(404);
412
+ expect(prisma.artifact.findMany).not.toHaveBeenCalled();
413
+ });
414
+
415
+ // AC-1 — two-or-more workspaces: ambiguous, so no site resolves; 404 before
416
+ // any artifact query.
417
+ it("404s with two-or-more workspaces, before any artifact query", async () => {
418
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(2);
419
+
420
+ const res = await app.inject({
421
+ method: "GET",
422
+ url: "/v1/content/search?q=roof",
423
+ });
424
+
425
+ expect(res.statusCode).toBe(404);
426
+ expect(prisma.artifact.findMany).not.toHaveBeenCalled();
427
+ });
428
+
429
+ // The two unresolved arms answer with the SAME 404 body (AC-WCE-05 parity
430
+ // with get-page/list-pages) — a resolution failure carries no detail an
431
+ // unauthenticated caller could use to distinguish it from another cause.
432
+ it("answers both ambiguity arms with identical 404 bodies", async () => {
433
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(0);
434
+ const empty = await app.inject({
435
+ method: "GET",
436
+ url: "/v1/content/search?q=roof",
437
+ });
438
+
439
+ jest.clearAllMocks();
440
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(2);
441
+ const ambiguous = await app.inject({
442
+ method: "GET",
443
+ url: "/v1/content/search?q=roof",
444
+ });
445
+
446
+ expect(empty.statusCode).toBe(404);
447
+ expect(ambiguous.statusCode).toBe(404);
448
+ expect(ambiguous.body).toBe(empty.body);
449
+ });
450
+ });
@@ -5,34 +5,69 @@
5
5
  * document root, plus the raw frontmatter under `page` for clients that need
6
6
  * the unrendered shape.
7
7
  *
8
- * 404 on missing slug, 400 on unknown type.
8
+ * 404 on missing slug, 400 on unknown type. A deployment whose site cannot be
9
+ * resolved (#5852) answers with the SAME 404 body as a missing slug.
9
10
  *
10
11
  * Phase 3 S3: optional `?locale=<locale>` query parameter. Validated against
11
12
  * `readLocales()` allowlist; invalid or unknown locale silently falls back to
12
13
  * the project's default locale (AC-i18n-07). The adapter applies the fallback
13
14
  * chain (locale → default-locale → legacy non-prefixed layout).
15
+ *
16
+ * #5890: optional `?include=body` adds the raw published MDX body beside
17
+ * `page`. It is opt-in: without it the handler takes the path it took before
18
+ * the parameter existed. An adapter with no `getPageSource` refuses the
19
+ * parameter with a 400 naming it, rather than 500ing on a missing method.
20
+ *
21
+ * #5927: on the `?include=body` path, `page` and `body` come from ONE
22
+ * `getPageSource` call — never a `getPageBySlug` call plus a second,
23
+ * independent `getPageSource` call. Two independent adapter reads can
24
+ * straddle a publish that lands between them (frontmatter from revision N,
25
+ * body from revision N+1); one read cannot.
14
26
  */
15
- import { SCHEMA_ORG_TYPES, type SchemaOrgType } from "@working-theory/content-adapters";
27
+ import {
28
+ SCHEMA_ORG_TYPES,
29
+ type ContentAdapter,
30
+ type ContentPage,
31
+ type MdxContentAdapter,
32
+ type MdxPageSource,
33
+ type SchemaOrgType,
34
+ } from "@working-theory/content-adapters";
16
35
  import { buildJsonLd, type ContentPageInput } from "@working-theory/schema";
17
36
  import type { FastifyPluginAsync } from "fastify";
18
37
  import { z } from "zod";
19
38
 
20
- import { getContentAdapter } from "../../lib/content-adapter.js";
21
39
  import { resolveLocale } from "../../lib/locales.js";
40
+ import { resolvePublicContentAdapter } from "../../services/public-site-resolver.js";
41
+
42
+ import { SLUG_PATTERN } from "./slug.js";
22
43
 
23
44
  const paramsSchema = z.object({
24
45
  type: z.enum(SCHEMA_ORG_TYPES),
25
- slug: z.string().min(1),
46
+ slug: z.string().regex(SLUG_PATTERN),
26
47
  });
27
48
 
28
49
  const querySchema = z.object({
29
50
  locale: z.string().optional(),
51
+ include: z.enum(["body"]).optional(),
30
52
  });
31
53
 
54
+ /**
55
+ * The raw-body capability, probed by the exact name the MDX adapter declares
56
+ * and the database adapter borrows (#5874) — the same idiom, and the same
57
+ * name, as `apps/mcp/src/lib/content-adapter.ts`.
58
+ */
59
+ function hasPageSource(
60
+ adapter: ContentAdapter,
61
+ ): adapter is ContentAdapter & Pick<MdxContentAdapter, "getPageSource"> {
62
+ return (
63
+ typeof (adapter as Partial<MdxContentAdapter>).getPageSource === "function"
64
+ );
65
+ }
66
+
32
67
  export const getPageRoute: FastifyPluginAsync = async (fastify) => {
33
68
  fastify.get<{
34
69
  Params: { type: string; slug: string };
35
- Querystring: { locale?: string };
70
+ Querystring: { locale?: string; include?: string };
36
71
  }>(
37
72
  "/v1/content/:type/:slug",
38
73
  async (request, reply) => {
@@ -53,15 +88,58 @@ export const getPageRoute: FastifyPluginAsync = async (fastify) => {
53
88
  }
54
89
 
55
90
  const { type, slug } = parsed.data;
91
+ const { include } = queryParsed.data;
56
92
  const locale = resolveLocale(queryParsed.data.locale);
57
93
 
58
94
  try {
59
- const adapter = getContentAdapter();
60
- const page = await adapter.getPageBySlug(
61
- type as SchemaOrgType,
62
- slug,
63
- locale,
64
- );
95
+ // Which site this read serves is resolved BEFORE any content query
96
+ // (#5852). A refusal reuses the SAME not-found body a missing page
97
+ // gets, below: a distinct body or code would tell an unauthenticated
98
+ // caller that its slug reached tenancy, which is the enumeration
99
+ // signal AC-WCE-05 denies.
100
+ const adapter = await resolvePublicContentAdapter(request);
101
+ if (adapter === null) {
102
+ return reply.status(404).send({
103
+ error: "Page not found",
104
+ type,
105
+ slug,
106
+ });
107
+ }
108
+
109
+ // Refused before any content query, so a caller asking for a body this
110
+ // deployment cannot serve is never charged a read whose result is then
111
+ // discarded.
112
+ let readSource: MdxContentAdapter["getPageSource"] | undefined;
113
+ if (include === "body") {
114
+ if (!hasPageSource(adapter)) {
115
+ return reply.status(400).send({
116
+ error: "Invalid query parameters",
117
+ details: [
118
+ {
119
+ path: ["include"],
120
+ message: "body is not available from this content adapter",
121
+ },
122
+ ],
123
+ });
124
+ }
125
+ readSource = adapter.getPageSource.bind(adapter);
126
+ }
127
+
128
+ // #5927: ONE adapter call supplies everything the response sends.
129
+ // `readSource` (bound above, only when `include=body`) returns BOTH
130
+ // the frontmatter and the body from the SAME read, so the two halves
131
+ // can never straddle a publish that lands between two independent
132
+ // calls. Without the parameter, `getPageBySlug` is the only call, as
133
+ // it always was. `source` stays `undefined` on that path — the
134
+ // `source && {...}` below never attaches a body to the response.
135
+ let source: MdxPageSource | null | undefined;
136
+ let page: ContentPage | null;
137
+ if (readSource) {
138
+ source = await readSource(type as SchemaOrgType, slug, locale);
139
+ page = source === null ? null : source.frontmatter;
140
+ } else {
141
+ page = await adapter.getPageBySlug(type as SchemaOrgType, slug, locale);
142
+ }
65
143
  if (!page) {
66
144
  return reply.status(404).send({
67
145
  error: "Page not found",
@@ -88,7 +166,11 @@ export const getPageRoute: FastifyPluginAsync = async (fastify) => {
88
166
  ...(page.sections !== undefined && { sections: page.sections }),
89
167
  };
90
168
  const jsonLd = buildJsonLd(input);
91
- return reply.send({ ...jsonLd, page });
169
+ return reply.send({
170
+ ...jsonLd,
171
+ page,
172
+ ...(source && { body: source.body }),
173
+ });
92
174
  } catch (error) {
93
175
  request.log.error({ err: error }, "[Content] getPage failed");
94
176
  return reply.status(500).send({ error: "Internal server error" });