create-ailk 0.27.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +84 -7
  2. package/dist/add-module-core.js +2 -1
  3. package/dist/add-module.d.ts +2 -1
  4. package/dist/add-module.js +9 -4
  5. package/dist/add-route.js +46 -19
  6. package/dist/body-heading.d.ts +56 -0
  7. package/dist/body-heading.js +394 -0
  8. package/dist/cli.js +28 -1
  9. package/dist/content-payloads.js +2 -2
  10. package/dist/derive-architecture.js +8 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +4 -1
  13. package/dist/lib/auth-secret-file.d.ts +6 -2
  14. package/dist/lib/auth-secret-file.js +46 -20
  15. package/dist/lib/chrome-readers.d.ts +21 -5
  16. package/dist/lib/chrome-readers.js +128 -20
  17. package/dist/lib/declared-env.d.ts +6 -0
  18. package/dist/lib/declared-env.js +2 -0
  19. package/dist/lib/env-catalog.js +20 -0
  20. package/dist/lib/import-graph.d.ts +16 -0
  21. package/dist/lib/import-graph.js +53 -0
  22. package/dist/lib/site-modules.d.ts +8 -0
  23. package/dist/lib/site-modules.js +34 -0
  24. package/dist/module-architecture.js +23 -14
  25. package/dist/paid-sections.d.ts +17 -0
  26. package/dist/paid-sections.js +39 -0
  27. package/dist/programmatic.d.ts +16 -0
  28. package/dist/programmatic.js +73 -4
  29. package/dist/remove-module.d.ts +98 -0
  30. package/dist/remove-module.js +620 -0
  31. package/dist/seed-flows.d.ts +2 -2
  32. package/dist/seed-flows.js +16 -9
  33. package/dist/site-roster.js +4 -1
  34. package/dist/starter-grid.d.ts +26 -0
  35. package/dist/starter-grid.js +34 -0
  36. package/dist/surfaces.d.ts +26 -0
  37. package/dist/surfaces.js +105 -9
  38. package/dist/sync-routes.d.ts +7 -0
  39. package/dist/sync-routes.js +53 -6
  40. package/dist/template-pages.d.ts +36 -0
  41. package/dist/template-pages.js +129 -0
  42. package/dist/template-record.d.ts +13 -4
  43. package/dist/template-record.js +8 -4
  44. package/dist/topology.d.ts +2 -1
  45. package/dist/topology.js +4 -2
  46. package/dist/update.d.ts +34 -2
  47. package/dist/update.js +395 -48
  48. package/package.json +3 -3
  49. package/release-scope.json +2 -1
  50. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  51. package/templates/.env.example +5 -2
  52. package/templates/apps/api/.env.example +9 -5
  53. package/templates/apps/api/CLAUDE.md +14 -9
  54. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  55. package/templates/apps/api/src/vercel-handler.ts +8 -1
  56. package/templates/apps/mcp/CLAUDE.md +18 -10
  57. package/templates/apps/web/.env.example +25 -11
  58. package/templates/apps/web/CLAUDE.md +45 -4
  59. package/templates/apps/web/__tests__/setup.ts +7 -0
  60. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  61. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  63. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  65. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  66. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  67. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  68. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  69. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  70. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  72. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  73. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  74. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  76. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  77. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  78. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  79. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  80. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  81. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  82. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  83. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  84. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  85. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  86. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  87. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  88. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  89. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  90. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  91. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  92. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  93. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  105. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  107. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  108. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  109. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  111. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  113. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  114. package/templates/apps/web/app/[locale]/layout.tsx +157 -139
  115. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  116. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  117. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  118. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  119. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  120. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  121. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  122. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  124. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  126. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  127. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  128. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  129. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  130. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  131. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  132. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  133. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  134. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  135. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  136. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  137. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  138. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  139. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  140. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  141. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  142. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +224 -0
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  144. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  145. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  146. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  147. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  148. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  149. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  150. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  151. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  152. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +237 -0
  153. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  154. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  155. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  156. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  157. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
  158. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
  159. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  160. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  161. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  162. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  163. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  164. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  165. package/templates/apps/web/auth.ts +102 -50
  166. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  167. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  168. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  169. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  170. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  171. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  172. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +279 -0
  173. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  174. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  175. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  176. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  177. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  178. package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
  179. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  180. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  181. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  182. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  183. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  184. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  185. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  186. package/templates/apps/web/lib/auth-secret.ts +57 -0
  187. package/templates/apps/web/lib/bot-check.ts +138 -0
  188. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  189. package/templates/apps/web/lib/deck-page.ts +6 -3
  190. package/templates/apps/web/lib/docs-index.ts +100 -0
  191. package/templates/apps/web/lib/docs-path.ts +94 -0
  192. package/templates/apps/web/lib/hub-children.tsx +318 -0
  193. package/templates/apps/web/lib/modules.generated.ts +9 -0
  194. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  195. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  196. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  197. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  198. package/templates/apps/web/lib/page-chrome.ts +367 -0
  199. package/templates/apps/web/lib/posting-title.ts +26 -4
  200. package/templates/apps/web/lib/pricing-page.ts +430 -0
  201. package/templates/apps/web/lib/relay.ts +31 -106
  202. package/templates/apps/web/lib/route-family.ts +69 -0
  203. package/templates/apps/web/lib/service-listing.ts +89 -0
  204. package/templates/apps/web/lib/services-path.ts +97 -0
  205. package/templates/apps/web/lib/site-header.ts +94 -0
  206. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  207. package/templates/apps/web/lib/template-listing.ts +169 -0
  208. package/templates/apps/web/messages/en.json +86 -30
  209. package/templates/apps/web/middleware.ts +171 -11
  210. package/templates/apps/web/next.config.mjs +211 -2
  211. package/templates/apps/web/package.json +1 -1
  212. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  213. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  214. package/templates/apps/web/sentry.server.config.ts +30 -2
  215. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  216. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  217. package/templates/apps/web/src/lib/routes.ts +15 -1
  218. package/templates/tools/declared-env.ts +7 -0
  219. package/versions.json +48 -48
@@ -8,42 +8,44 @@
8
8
  // HTML, so the card never flashes from the authored plan to the named one.
9
9
  //
10
10
  // The Offerings shown set (#8927, spec
11
- // docs/specs/8927-pricing-renders-the-shown-set-spec.md). Before rendering,
12
- // the page reads `GET /v1/content/shown-offerings` through the api-client,
13
- // shared per server instance for READ_TTL_MS, and renders one of three pages:
14
- // - unconnected (or production with no API origin) — today's MDX page,
15
- // exactly as above (D7);
16
- // - a current shown set — the workspace's Stripe cards in place of the
17
- // page's authored `pricing` sections, `?plan=` moving the recommendation
18
- // only for a `{plan}`-grammar value (D4, contract §3.7);
19
- // - anything else (expired, empty, a failed read) — the site's contact
20
- // email in place of those sections, never an error (D5).
11
+ // docs/specs/8927-pricing-renders-the-shown-set-spec.md). The page reads
12
+ // `GET /v1/content/shown-offerings` through the api-client, shared per server
13
+ // instance, and renders one of three pages — unconnected, a current shown
14
+ // set, or the contact email — through `pricingSections` in
15
+ // `lib/pricing-page.ts`, shared with every offering pricing page (#10044 D5).
16
+ // The page's own `plans` frontmatter narrows its Stripe cards; `plans: []`
17
+ // keeps its written sections and reads no Stripe data.
18
+ //
19
+ // The offering pricing pages (#10044 D6): every other `Pricing` file
20
+ // (`content/<locale>/Pricing/<offering>.mdx`, served at /pricing/<offering>)
21
+ // is listed after this page's own sections, before its closing CTA, as one
22
+ // generated `items` section — no hand-maintained list. A site with no
23
+ // offering page renders exactly as before.
24
+ //
21
25
  // Stripe text reaches markup only as React text and JSON-LD only through
22
26
  // renderPage's serializer (T4); the metadata stays the MDX page's (T8).
23
- import { offerings } from "@working-theory/api-client";
24
27
  import {
28
+ getMdxSource as loadMdxSource,
25
29
  getPageSections as defaultGetPageSections,
26
30
  renderPage,
27
31
  renderPageMetadata,
28
32
  } from "@working-theory/page-renderer";
29
33
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
30
- import { ContactFallback } from "@working-theory/ui";
31
- import {
32
- LOOKUP_KEY_PLAN_PATTERN,
33
- type PageSection,
34
- } from "@working-theory/validation";
35
34
  import type { Metadata } from "next";
36
35
  import { getTranslations } from "next-intl/server";
37
36
  import type { ReactElement } from "react";
38
37
 
39
- import { browserApiOrigin } from "../../../lib/api-origin.generated";
40
38
  import {
41
- offeringsPricing,
42
- type OfferingsPricing,
43
- type OfferingsPricingLabels,
44
- } from "../../../lib/offerings-pricing";
45
- import { contactFormText } from "../contact/contact-form-text";
46
- import { readSiteContactEmail } from "../contact/site-contact-email";
39
+ offeringPricingPages,
40
+ pagePlans,
41
+ pricingPageCard,
42
+ pricingSections,
43
+ pricingSectionSlots,
44
+ } from "../../../lib/pricing-page";
45
+ import {
46
+ insertBeforeClosingCtas,
47
+ listDetailPages,
48
+ } from "../../../lib/route-family";
47
49
 
48
50
  // #8927 D2 — never prerendered; the Offerings read behind it is shared for READ_TTL_MS.
49
51
  export const dynamic = "force-dynamic";
@@ -53,275 +55,11 @@ interface Props {
53
55
  searchParams?: Promise<Record<string, string | string[] | undefined>>;
54
56
  getMdxSource?: typeof defaultGetMdxSource;
55
57
  getPageSections?: typeof defaultGetPageSections;
58
+ /** The offering pages' reader (#10044 D6); a test's DI seam. */
59
+ listPages?: typeof listDetailPages;
56
60
  }
57
61
 
58
- type PricingSection = Extract<PageSection, { type: "pricing" }>;
59
- type PricingData = PricingSection["data"];
60
-
61
- /**
62
- * Every `pricing` section with `recommended` set to `plan` (#8842). Each
63
- * section matches the key against its own plans, so a second plan group
64
- * keeps its own default unless it carries the same key.
65
- */
66
- function withRecommendedPlan(
67
- sections: PageSection[],
68
- plan: string,
69
- ): PageSection[] {
70
- return sections.map((section) =>
71
- section.type === "pricing"
72
- ? {
73
- ...section,
74
- // `recommended` is a render-time field the section reads, not an
75
- // authored one, so it is not in the validated section schema.
76
- data: { ...section.data, recommended: plan } as typeof section.data,
77
- }
78
- : section,
79
- );
80
- }
81
-
82
- type OfferingsRead =
83
- | { kind: "unconnected" }
84
- | { kind: "shown"; shownSet: unknown }
85
- | { kind: "contact" };
86
-
87
- const CONTACT: OfferingsRead = { kind: "contact" };
88
-
89
- /**
90
- * How long the Offerings read is shared across requests (D2): a view carries
91
- * no visitor headers, so every view would otherwise count against this
92
- * server's one per-IP limit at the API. A product switched off disappears
93
- * within it. The contact page's window (#8731).
94
- */
95
- const READ_TTL_MS = 15_000;
96
-
97
- const reads = new Map<string, { at: number; read: Promise<OfferingsRead> }>();
98
-
99
- /**
100
- * `read()`'s result, shared per instance for READ_TTL_MS. Concurrent calls
101
- * share one in-flight read, and a failed read is shared the same way, so a
102
- * burst against an unreachable API is one call.
103
- */
104
- function sharedRead(
105
- key: string,
106
- read: () => Promise<OfferingsRead>,
107
- ): Promise<OfferingsRead> {
108
- const now = Date.now();
109
- const hit = reads.get(key);
110
- if (hit && now - hit.at < READ_TTL_MS) return hit.read;
111
- const fresh = read();
112
- reads.set(key, { at: now, read: fresh });
113
- return fresh;
114
- }
115
-
116
- /**
117
- * Whether an API origin is configured: the three names `getApiBase()` in
118
- * packages/api-client/src/http.ts reads, in step with it on a hub. Without one
119
- * that function answers `localhost:3001`. The browser-facing origin comes from
120
- * the topology seam (#7874 D12), which a spoke renders with no env read, so a
121
- * spoke passes its own `env:check` (#9480). A spoke diverges on purpose: the
122
- * browser-facing name set on one at runtime counts as no origin here.
123
- */
124
- function apiOriginConfigured(): boolean {
125
- return Boolean(
126
- process.env.AILK_API_URL ||
127
- browserApiOrigin() ||
128
- process.env.NEXT_PUBLIC_API_URL,
129
- );
130
- }
131
-
132
- /**
133
- * The shown-offerings read (D2). A production deployment with no API has no
134
- * connection to read, so it is unconnected without a call. The body is
135
- * untrusted: anything but `unconnected` or `shown` — `empty`, an unknown
136
- * status, a non-object body, a non-2xx, a throw — is the contact email.
137
- */
138
- function readShownOfferings(): Promise<OfferingsRead> {
139
- if (process.env.NODE_ENV === "production" && !apiOriginConfigured()) {
140
- return Promise.resolve({ kind: "unconnected" });
141
- }
142
- return sharedRead("shown-offerings", async () => {
143
- try {
144
- const res = await offerings.shown();
145
- if (!res.ok) return CONTACT;
146
- const body: unknown = res.body;
147
- if (body === null || typeof body !== "object") return CONTACT;
148
- const { status, shownSet } = body as {
149
- status?: unknown;
150
- shownSet?: unknown;
151
- };
152
- if (status === "unconnected") return { kind: "unconnected" };
153
- if (status === "shown") return { kind: "shown", shownSet };
154
- return CONTACT;
155
- } catch {
156
- return CONTACT;
157
- }
158
- });
159
- }
160
-
161
- /** Keeps the `{amount}` placeholder for `period` to fill (D6). */
162
- const KEEP_AMOUNT_PLACEHOLDER = { amount: "{amount}" };
163
-
164
- /** The mapper's labels and the plans section's recommendation label (D6). */
165
- async function offeringsCopy(locale: string): Promise<{
166
- labels: OfferingsPricingLabels;
167
- recommendedLabel: string;
168
- addonsHeadline: string;
169
- }> {
170
- const t = await getTranslations({ locale, namespace: "pricingOfferings" });
171
- const period = (key: "monthly" | "annual" | "oneTime") => {
172
- const phrase = t(key, KEEP_AMOUNT_PLACEHOLDER);
173
- return (amount: string) => phrase.replace("{amount}", () => amount);
174
- };
175
- return {
176
- labels: {
177
- cta: t("cta"),
178
- recommended: t("recommended"),
179
- monthly: period("monthly"),
180
- annual: period("annual"),
181
- oneTime: period("oneTime"),
182
- },
183
- recommendedLabel: t("recommendedForYou"),
184
- addonsHeadline: t("addonsHeadline"),
185
- };
186
- }
187
-
188
- /** The authored section copy a Stripe section keeps — never a plan or price fact. */
189
- const AUTHORED_COPY_FIELDS = [
190
- "eyebrow",
191
- "headline",
192
- "description",
193
- "header",
194
- "headlineSize",
195
- "toggleStyle",
196
- "featureListStyle",
197
- "gapAbove",
198
- "separation",
199
- "gap",
200
- "surface",
201
- "note",
202
- "collapseFeaturesOnMobile",
203
- ] as const satisfies readonly (keyof PricingData)[];
204
-
205
- function authoredCopy(
206
- section: PricingSection | undefined,
207
- extra: readonly (keyof PricingData)[] = [],
208
- ): Partial<PricingData> {
209
- const copy: Partial<Record<keyof PricingData, unknown>> = {};
210
- if (!section) return copy as Partial<PricingData>;
211
- for (const field of [...AUTHORED_COPY_FIELDS, ...extra]) {
212
- if (section.data[field] !== undefined) copy[field] = section.data[field];
213
- }
214
- return copy as Partial<PricingData>;
215
- }
216
-
217
- function isPricing(section: PageSection): section is PricingSection {
218
- return section.type === "pricing";
219
- }
220
-
221
- /** Where a section with no slot goes: after a leading hero, else first. */
222
- function insertionIndex(sections: PageSection[]): number {
223
- return sections[0]?.type === "hero" ? 1 : 0;
224
- }
225
-
226
- /**
227
- * The page's sections with the Stripe fragments in place of its authored
228
- * `pricing` sections (D4). The first non-`addons` pricing section is the
229
- * plans slot and the first `addons` one the add-ons slot; every other pricing
230
- * section, and a slot whose fragment is `null`, is removed. A fragment with no
231
- * slot is inserted after a leading hero (the add-ons directly after the
232
- * plans). No other section moves.
233
- */
234
- function withOfferings(
235
- sections: PageSection[],
236
- pricing: OfferingsPricing,
237
- plan: string | undefined,
238
- recommendedLabel: string,
239
- addonsHeadline: string,
240
- ): PageSection[] {
241
- const plansSlot = sections.find(
242
- (s): s is PricingSection => isPricing(s) && s.data.variant !== "addons",
243
- );
244
- const addonsSlot = sections.find(
245
- (s): s is PricingSection => isPricing(s) && s.data.variant === "addons",
246
- );
247
- const recommend =
248
- plan !== undefined && LOOKUP_KEY_PLAN_PATTERN.test(plan) ? plan : undefined;
249
- const plansSection: PricingSection | null = pricing.plans && {
250
- id: plansSlot?.id ?? "offerings-plans",
251
- type: "pricing",
252
- data: {
253
- ...authoredCopy(plansSlot),
254
- ...pricing.plans,
255
- // A `?plan=` recommendation moves the mapper's highlight onto the named
256
- // plan and wears `recommendedLabel` (#8842); the mapper's own
257
- // "Recommended" pill label would ride along as a second label, so it is
258
- // dropped from every card when a recommendation is in play.
259
- ...(recommend !== undefined &&
260
- pricing.plans.plans.some(
261
- (card) => card.key?.toLowerCase() === recommend.toLowerCase(),
262
- )
263
- ? {
264
- plans: pricing.plans.plans.map(({ highlightLabel: _, ...card }) => card),
265
- }
266
- : {}),
267
- // Render-time fields the section reads (#8842), not authored ones.
268
- recommendedLabel,
269
- ...(recommend !== undefined ? { recommended: recommend } : {}),
270
- } as PricingData,
271
- };
272
- const addonsSection: PricingSection | null = pricing.addons && {
273
- id: addonsSlot?.id ?? "offerings-addons",
274
- type: "pricing",
275
- data: {
276
- // An inserted add-ons section (no authored slot) sits tight under the
277
- // plans and carries its own headline, so a lone tile is never an
278
- // unlabeled band (design review B2).
279
- ...(addonsSlot ? {} : { headline: addonsHeadline, gapAbove: "tight" }),
280
- ...authoredCopy(addonsSlot, ["addonLayout"]),
281
- ...pricing.addons,
282
- } as PricingData,
283
- };
284
-
285
- const placed: PageSection[] = [];
286
- for (const section of sections) {
287
- if (!isPricing(section)) placed.push(section);
288
- else if (section === plansSlot && plansSection) placed.push(plansSection);
289
- else if (section === addonsSlot && addonsSection)
290
- placed.push(addonsSection);
291
- }
292
- if (plansSection && !plansSlot) {
293
- placed.splice(insertionIndex(placed), 0, plansSection);
294
- }
295
- if (addonsSection && !addonsSlot) {
296
- const plansAt = plansSection ? placed.indexOf(plansSection) : -1;
297
- placed.splice(
298
- plansAt >= 0 ? plansAt + 1 : insertionIndex(placed),
299
- 0,
300
- addonsSection,
301
- );
302
- }
303
- return placed;
304
- }
305
-
306
- /** The section the contact email is slotted into (D5). */
307
- const CONTACT_SECTION_ID = "offerings-contact";
308
-
309
- /**
310
- * The page's sections with every `pricing` section removed and one
311
- * `contact-form` section in the first one's place, else after a leading hero
312
- * (D5). It contributes no JSON-LD, so the page publishes no `Offer`.
313
- */
314
- function withContactFallback(sections: PageSection[]): PageSection[] {
315
- const first = sections.findIndex(isPricing);
316
- const kept: PageSection[] = sections.filter((s) => !isPricing(s));
317
- const contact = {
318
- id: CONTACT_SECTION_ID,
319
- type: "contact-form",
320
- data: { fields: [] },
321
- } as PageSection;
322
- kept.splice(first >= 0 ? first : insertionIndex(kept), 0, contact);
323
- return kept;
324
- }
62
+ type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
325
63
 
326
64
  export async function generateMetadata({
327
65
  params,
@@ -335,71 +73,48 @@ export default async function Page({
335
73
  searchParams,
336
74
  getMdxSource,
337
75
  getPageSections = defaultGetPageSections,
76
+ listPages = listDetailPages,
338
77
  }: Props): Promise<ReactElement> {
339
78
  const { locale } = await params;
340
79
  const search = (await searchParams) ?? {};
341
80
  const plan = Array.isArray(search.plan) ? search.plan[0] : search.plan;
342
- const read = await readShownOfferings();
343
81
 
344
- if (read.kind === "unconnected") {
345
- return renderPage({
346
- type: "Pricing",
347
- slug: "pricing",
82
+ /**
83
+ * The page's sections in its Offerings state, then the offering pricing
84
+ * pages list. Runs only when the hub's source exists.
85
+ */
86
+ async function withPricing(frontmatter: unknown): Promise<Section[]> {
87
+ const sections = await pricingSections(await getPageSections(frontmatter), {
88
+ plans: pagePlans(frontmatter),
89
+ plan,
348
90
  locale,
349
- translationsNamespace: "pricing",
350
- ...(getMdxSource !== undefined && { getMdxSource }),
351
- getPageSections: plan
352
- ? async (frontmatter) =>
353
- withRecommendedPlan(await getPageSections(frontmatter), plan)
354
- : getPageSections,
91
+ });
92
+ const pages = offeringPricingPages(
93
+ await listPages("Pricing", locale, getMdxSource ?? loadMdxSource),
94
+ );
95
+ if (pages.length === 0) return sections;
96
+ const t = await getTranslations({ locale, namespace: "pricingPages" });
97
+ const labels = { viewPricing: t("viewPricing") };
98
+ return insertBeforeClosingCtas(sections, {
99
+ type: "items",
100
+ id: "pricing-pages",
101
+ data: {
102
+ headline: t("heading"),
103
+ // A tile is one click target per card (the /templates hub's rule).
104
+ variant: "tile",
105
+ tileHover: "outline",
106
+ items: pages.map((page) => pricingPageCard(page, labels)),
107
+ },
355
108
  });
356
109
  }
357
110
 
358
- if (read.kind === "shown") {
359
- const { labels, recommendedLabel, addonsHeadline } =
360
- await offeringsCopy(locale);
361
- const pricing = offeringsPricing(read.shownSet, { locale, labels });
362
- if (pricing && (pricing.plans || pricing.addons)) {
363
- return renderPage({
364
- type: "Pricing",
365
- slug: "pricing",
366
- locale,
367
- translationsNamespace: "pricing",
368
- ...(getMdxSource !== undefined && { getMdxSource }),
369
- getPageSections: async (frontmatter) =>
370
- withOfferings(
371
- await getPageSections(frontmatter),
372
- pricing,
373
- plan,
374
- recommendedLabel,
375
- addonsHeadline,
376
- ),
377
- });
378
- }
379
- }
380
-
381
- const text = contactFormText(
382
- await getTranslations({ locale, namespace: "contact" }),
383
- );
384
111
  return renderPage({
385
112
  type: "Pricing",
386
113
  slug: "pricing",
387
114
  locale,
388
115
  translationsNamespace: "pricing",
389
116
  ...(getMdxSource !== undefined && { getMdxSource }),
390
- getPageSections: async (frontmatter) =>
391
- withContactFallback(await getPageSections(frontmatter)),
392
- sectionSlots: {
393
- [CONTACT_SECTION_ID]: (
394
- <ContactFallback
395
- email={readSiteContactEmail()}
396
- text={{
397
- heading: text.unavailableHeading,
398
- body: text.unavailableBody,
399
- noEmail: text.unavailableNoEmail,
400
- }}
401
- />
402
- ),
403
- },
117
+ getPageSections: withPricing,
118
+ sectionSlots: await pricingSectionSlots(locale),
404
119
  });
405
120
  }
@@ -1,7 +1,11 @@
1
1
  // Product detail page. Reads the Product MDX for the given slug, renders via
2
- // next-mdx-remote/rsc, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
2
+ // @working-theory/page-renderer, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
3
3
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
4
- import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
4
+ import {
5
+ getMdxSource as loadMdxSource,
6
+ renderPage,
7
+ renderPageMetadata,
8
+ } from "@working-theory/page-renderer";
5
9
  import type { Metadata } from "next";
6
10
  import type { ReactElement } from "react";
7
11
 
@@ -22,12 +26,25 @@ export default async function Page({
22
26
  getMdxSource,
23
27
  }: Props): Promise<ReactElement> {
24
28
  const { locale, slug } = await params;
29
+ // A page that composes `sections[]` renders them (JSON-LD is built from
30
+ // the same sections); a prose page renders its MDX body — the rule
31
+ // /vs/[slug] applies (#10122).
32
+ const source = await (getMdxSource ?? loadMdxSource)("Product", slug, locale);
33
+ const composed =
34
+ source !== null &&
35
+ Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
36
+ ((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) >
37
+ 0;
25
38
  return renderPage({
26
39
  type: "Product",
27
40
  slug,
28
41
  locale,
29
- bodyMode: "mdx-body",
30
- populatedDescriptionTestId: "product-description",
42
+ ...(composed
43
+ ? {}
44
+ : {
45
+ bodyMode: "mdx-body" as const,
46
+ populatedDescriptionTestId: "product-description",
47
+ }),
31
48
  ...(getMdxSource !== undefined && { getMdxSource }),
32
49
  });
33
50
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Hub child cards (#10121) for /products. The shared cases live in
3
+ * `lib/__tests__/hub-children.fixtures.ts`; this file sits beside the route so
4
+ * a scaffold without /products drops it with the route (#10157).
5
+ */
6
+ const mockRenderPage = jest.fn(async (..._args: unknown[]) => "<rendered/>");
7
+
8
+ jest.mock("@working-theory/page-renderer", () => ({
9
+ __esModule: true,
10
+ renderPage: (...args: unknown[]) => mockRenderPage(...args),
11
+ renderPageMetadata: jest.fn(),
12
+ // Never reached — every call in this suite passes its loaders via DI.
13
+ getMdxSlugs: jest.fn(),
14
+ getMdxSource: jest.fn(),
15
+ getPageSections: jest.fn(),
16
+ }));
17
+
18
+ jest.mock("@working-theory/ui/sections", () => ({
19
+ __esModule: true,
20
+ SectionRenderer: () => null,
21
+ }));
22
+
23
+ import ProductsPage from "../page";
24
+ import {
25
+ describeHub,
26
+ type HubPage,
27
+ } from "../../../../lib/__tests__/hub-children.fixtures";
28
+
29
+ describeHub(
30
+ ["products", ProductsPage as HubPage, "ProductsList", "Product", "mdx-body"],
31
+ mockRenderPage,
32
+ );
@@ -70,6 +70,8 @@ describe("ProductsPage — snapshot regression", () => {
70
70
  const getMdxSourceEmpty = jest.fn().mockResolvedValue(null);
71
71
  const element = await ProductsPage({
72
72
  params: Promise.resolve({ locale: "en" }),
73
+ // No child pages: this snapshot pins the hub's own render (#10121).
74
+ getMdxSlugs: async () => [],
73
75
  getMdxSource: getMdxSourceEmpty,
74
76
  });
75
77
  const { container } = render(element);
@@ -83,11 +85,14 @@ describe("ProductsPage — snapshot regression", () => {
83
85
  const getMdxSourcePopulated = jest.fn().mockResolvedValue(source);
84
86
  const element = await ProductsPage({
85
87
  params: Promise.resolve({ locale: "en" }),
88
+ // No child pages: this snapshot pins the hub's own render (#10121).
89
+ getMdxSlugs: async () => [],
86
90
  getMdxSource: getMdxSourcePopulated,
87
91
  });
88
92
  const { container } = render(element);
89
93
  expect(container.innerHTML).toMatchInlineSnapshot(`
90
- "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Products","description":"All products.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"SoftwareApplication"}]}}</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">Products</h1><p data-testid="products-description" class="mt-block-tight text-lead">All products.</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-remote" data-source="
94
+ "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Products","description":"All products.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"SoftwareApplication"}]}}</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-remote" data-source="# Products
95
+
91
96
  Body."></div></div></article></div></div></div></main>"
92
97
  `);
93
98
  });
@@ -110,7 +115,8 @@ describe("ProductSlugPage — snapshot regression", () => {
110
115
  });
111
116
  const { container } = render(element);
112
117
  expect(container.innerHTML).toMatchInlineSnapshot(`
113
- "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AI Launch Kit","description":"The AX-first OSS template.","mainEntity":{"@context":"https://schema.org","@type":"SoftwareApplication","name":"AI Launch Kit","description":"The AX-first OSS template."}},{"@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":"Products","item":"http://localhost:3000/en/products"},{"@type":"ListItem","position":3,"name":"AI Launch Kit","item":"http://localhost:3000/en/products/launch-kit"}]}]}</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">AI Launch Kit</h1><p data-testid="product-description" class="mt-block-tight text-lead">The AX-first OSS template.</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-remote" data-source="
118
+ "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AI Launch Kit","description":"The AX-first OSS template.","mainEntity":{"@context":"https://schema.org","@type":"SoftwareApplication","name":"AI Launch Kit","description":"The AX-first OSS template."}},{"@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":"Products","item":"http://localhost:3000/en/products"},{"@type":"ListItem","position":3,"name":"AI Launch Kit","item":"http://localhost:3000/en/products/launch-kit"}]}]}</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-remote" data-source="# AI Launch Kit
119
+
114
120
  Body."></div></div></article></div></div></div></main>"
115
121
  `);
116
122
  });