create-ailk 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) 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/env-catalog.js +20 -0
  16. package/dist/lib/site-modules.d.ts +8 -0
  17. package/dist/lib/site-modules.js +34 -0
  18. package/dist/module-architecture.js +23 -14
  19. package/dist/paid-sections.d.ts +17 -0
  20. package/dist/paid-sections.js +39 -0
  21. package/dist/programmatic.d.ts +16 -0
  22. package/dist/programmatic.js +73 -4
  23. package/dist/remove-module.d.ts +98 -0
  24. package/dist/remove-module.js +620 -0
  25. package/dist/seed-flows.d.ts +2 -2
  26. package/dist/seed-flows.js +16 -9
  27. package/dist/site-roster.js +4 -1
  28. package/dist/starter-grid.d.ts +26 -0
  29. package/dist/starter-grid.js +34 -0
  30. package/dist/surfaces.d.ts +26 -0
  31. package/dist/surfaces.js +105 -9
  32. package/dist/sync-routes.d.ts +7 -0
  33. package/dist/sync-routes.js +53 -6
  34. package/dist/template-pages.d.ts +36 -0
  35. package/dist/template-pages.js +129 -0
  36. package/dist/topology.d.ts +2 -1
  37. package/dist/topology.js +4 -2
  38. package/dist/update.d.ts +3 -1
  39. package/dist/update.js +38 -4
  40. package/package.json +3 -3
  41. package/release-scope.json +2 -1
  42. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  43. package/templates/.env.example +5 -2
  44. package/templates/apps/api/.env.example +9 -5
  45. package/templates/apps/api/CLAUDE.md +14 -9
  46. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  47. package/templates/apps/api/src/vercel-handler.ts +8 -1
  48. package/templates/apps/mcp/CLAUDE.md +18 -10
  49. package/templates/apps/web/.env.example +25 -11
  50. package/templates/apps/web/CLAUDE.md +45 -4
  51. package/templates/apps/web/__tests__/setup.ts +7 -0
  52. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  53. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  54. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  55. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  56. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  57. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  58. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  59. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  60. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  61. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  62. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  63. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  64. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  65. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  66. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  67. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  68. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  69. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  70. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  71. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  72. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  73. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  74. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  75. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  76. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  77. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  78. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  79. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  80. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  81. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  82. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  83. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  84. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  89. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  93. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  97. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  98. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  99. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  100. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  101. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  102. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  103. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  104. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  105. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  106. package/templates/apps/web/app/[locale]/layout.tsx +157 -139
  107. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  108. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  109. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  110. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  111. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  112. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  114. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  115. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  116. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  117. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  118. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  119. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  120. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  121. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  122. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  123. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  124. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  125. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  126. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  127. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  128. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  129. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  130. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  131. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  132. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  133. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  134. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  135. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  136. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  137. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  139. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  140. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  141. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  142. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  143. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  144. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  145. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  146. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  148. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  149. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
  150. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
  151. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  152. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  153. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  154. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  155. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  156. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  157. package/templates/apps/web/auth.ts +102 -50
  158. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  159. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  160. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  161. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  162. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  163. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  164. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  165. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  166. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  167. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  168. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  169. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  170. package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
  171. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  172. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  173. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  174. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  175. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  176. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  177. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  178. package/templates/apps/web/lib/auth-secret.ts +57 -0
  179. package/templates/apps/web/lib/bot-check.ts +138 -0
  180. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  181. package/templates/apps/web/lib/deck-page.ts +6 -3
  182. package/templates/apps/web/lib/docs-index.ts +100 -0
  183. package/templates/apps/web/lib/docs-path.ts +94 -0
  184. package/templates/apps/web/lib/hub-children.tsx +318 -0
  185. package/templates/apps/web/lib/modules.generated.ts +9 -0
  186. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  187. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  188. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  189. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  190. package/templates/apps/web/lib/page-chrome.ts +367 -0
  191. package/templates/apps/web/lib/posting-title.ts +26 -4
  192. package/templates/apps/web/lib/pricing-page.ts +430 -0
  193. package/templates/apps/web/lib/relay.ts +31 -106
  194. package/templates/apps/web/lib/route-family.ts +69 -0
  195. package/templates/apps/web/lib/service-listing.ts +89 -0
  196. package/templates/apps/web/lib/services-path.ts +97 -0
  197. package/templates/apps/web/lib/site-header.ts +94 -0
  198. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  199. package/templates/apps/web/lib/template-listing.ts +169 -0
  200. package/templates/apps/web/messages/en.json +86 -30
  201. package/templates/apps/web/middleware.ts +171 -11
  202. package/templates/apps/web/next.config.mjs +211 -2
  203. package/templates/apps/web/package.json +1 -1
  204. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  205. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  206. package/templates/apps/web/sentry.server.config.ts +30 -2
  207. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  208. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  209. package/templates/apps/web/src/lib/routes.ts +15 -1
  210. package/versions.json +48 -48
@@ -13,7 +13,9 @@
13
13
  * - T2 — every Stripe string is emitted as a plain text field, with format
14
14
  * characters removed; nothing is parsed as HTML or Markdown (D8).
15
15
  * - T3 — every link is `OFFERINGS_CHECKOUT_PATH` plus a lookup key that
16
- * `parseLookupKey` accepts; no href is built from any other field (D5).
16
+ * `parseLookupKey` accepts — plus, for a bundled plan, `?with=` and the
17
+ * required product's key (#10028 D9); no href is built from any other
18
+ * field (D5).
17
19
  * - §3.7 — `yearly` reads as annual and `once` as one-time at render, never
18
20
  * in the key (D2).
19
21
  *
@@ -49,6 +51,10 @@ export interface OfferingsPricingLabels {
49
51
  monthly(amount: string): string;
50
52
  annual(amount: string): string;
51
53
  oneTime(amount: string): string;
54
+ /** A bundled plan's phrase: its one-time phrase plus its required product's recurring phrase (#10028 D9). */
55
+ withRequired(plan: string, required: string): string;
56
+ /** A deposit plan's footnote, from the formatted deposit (#10030 D10). */
57
+ depositNote(deposit: string): string;
52
58
  }
53
59
 
54
60
  export interface OfferingsPricing {
@@ -69,6 +75,21 @@ export function offeringsCheckoutHref(lookupKey: string): string | null {
69
75
  : null;
70
76
  }
71
77
 
78
+ /**
79
+ * The CTA target for a bundled plan (#10028 D9): the plan's key plus the
80
+ * required product's key as `?with=`, or `null` when either is outside the
81
+ * grammar. Both keys are `[a-z0-9_-]` only, so neither needs encoding.
82
+ */
83
+ export function offeringsBundleHref(
84
+ planKey: string,
85
+ requiredKey: string,
86
+ ): string | null {
87
+ const base = offeringsCheckoutHref(planKey);
88
+ return base && parseLookupKey(requiredKey)
89
+ ? `${base}?with=${requiredKey}`
90
+ : null;
91
+ }
92
+
72
93
  /**
73
94
  * Whether `lookupKey` is a grammar key of a Price in a current, non-empty
74
95
  * shown set — the keys `/pricing`'s cards are built from (#9051 D4).
@@ -194,6 +215,63 @@ function firstRecommended(products: OfferingsProduct[]): string | undefined {
194
215
  return products.find((p) => p.recommended)?.plan;
195
216
  }
196
217
 
218
+ /**
219
+ * A bundled plan's card (#10028 D9): the plan's one-time Price with its
220
+ * required product's recurring Prices — one link per shown recurring
221
+ * interval. With monthly and yearly, the monthly link is the CTA and the
222
+ * yearly one the annual link, each mode phrasing both amounts; with one,
223
+ * that link alone and the phrase as the custom price. No card when the plan's
224
+ * own one-time Price, or a recurring Price of the required product, is not
225
+ * shown.
226
+ */
227
+ function bundleCard(
228
+ product: OfferingsProduct,
229
+ required: OfferingsProduct,
230
+ highlighted: boolean,
231
+ locale: string,
232
+ labels: OfferingsPricingLabels,
233
+ ): PricingPlanCard | null {
234
+ const once = priceFor(product, PERIOD.once);
235
+ const monthly = priceFor(required, PERIOD.monthly);
236
+ const yearly = priceFor(required, PERIOD.yearly);
237
+ const first = monthly ?? yearly;
238
+ if (!once || !first) return null;
239
+ const href = offeringsBundleHref(once.lookupKey, first.lookupKey);
240
+ if (!href) return null;
241
+ const oneTime = phrase(once, locale, labels);
242
+ const combined = (recurring: OfferingsPrice) =>
243
+ labels.withRequired(oneTime, phrase(recurring, locale, labels));
244
+ const price: PricingPlanCard["price"] = {};
245
+ if (monthly && yearly) {
246
+ price.monthly = combined(monthly);
247
+ price.annual = combined(yearly);
248
+ } else {
249
+ price.custom = combined(first);
250
+ }
251
+ const card: PricingPlanCard = {
252
+ key: product.plan,
253
+ name: stripFormatCharacters(product.name),
254
+ price,
255
+ features: features(product),
256
+ cta: { label: labels.cta, href },
257
+ };
258
+ // The deposit note wins the footnote; the terms note shows only without one.
259
+ // NO_HAND_IMPLEMENT_OK: builder-assessors run for #10030 — the builder is the implementer.
260
+ if (product.deposit !== undefined)
261
+ card.priceFootnote = labels.depositNote(formatMinorUnits(product.deposit, once.currency, locale));
262
+ else if (product.termsNote)
263
+ card.priceFootnote = stripFormatCharacters(product.termsNote);
264
+ const annualHref =
265
+ monthly && yearly && offeringsBundleHref(once.lookupKey, yearly.lookupKey);
266
+ if (annualHref) card.annualCtaHref = annualHref;
267
+ if (highlighted) {
268
+ card.highlighted = true;
269
+ card.highlightStyle = "pill";
270
+ card.highlightLabel = labels.recommended;
271
+ }
272
+ return card;
273
+ }
274
+
197
275
  function planCard(
198
276
  product: OfferingsProduct,
199
277
  highlighted: boolean,
@@ -227,6 +305,10 @@ function planCard(
227
305
  if (product.note) card.priceSubhead = stripFormatCharacters(product.note);
228
306
  if (product.termsNote)
229
307
  card.priceFootnote = stripFormatCharacters(product.termsNote);
308
+ // A deposit plan's footnote (#10030 D10), in its one-time Price's currency;
309
+ // it takes the footnote over a terms note (the rarer field).
310
+ if (product.deposit !== undefined && once)
311
+ card.priceFootnote = labels.depositNote(formatMinorUnits(product.deposit, once.currency, locale));
230
312
  // Annual mode shows the yearly Price only when a monthly one is beside it,
231
313
  // so only then does the card need a second link (D5, D6).
232
314
  const annualHref =
@@ -280,28 +362,75 @@ function isPresent<T>(value: T | null): value is T {
280
362
  return value !== null;
281
363
  }
282
364
 
365
+ /**
366
+ * The products whose `{plan}` is in `plans`, plus every product a kept one
367
+ * `requires`, in the snapshot's order (#10044 D4); `plans` absent → all.
368
+ * One level of `requires` is followed: a required product is sold only
369
+ * inside its bundle and requires nothing itself (#10028 decision 6).
370
+ */
371
+ function narrowToPlans(
372
+ offerings: OfferingsProduct[],
373
+ plans: readonly string[] | undefined,
374
+ ): OfferingsProduct[] {
375
+ if (plans === undefined) return offerings;
376
+ const listed = new Set(plans);
377
+ const kept = new Set(
378
+ offerings.filter((o) => listed.has(o.plan)).map((o) => o.plan),
379
+ );
380
+ for (const o of offerings) {
381
+ if (kept.has(o.plan) && o.requires !== undefined) kept.add(o.requires);
382
+ }
383
+ return offerings.filter((o) => kept.has(o.plan));
384
+ }
385
+
283
386
  /**
284
387
  * The shown set as Pricing section data, or `null` when there is nothing to
285
388
  * show: a value the shown-set schema refuses, a snapshot that is not
286
389
  * `current`, or no offerings. Each fragment is `null` when its group is empty.
390
+ *
391
+ * `plans` (#10044 D4) narrows the set to an offering page's own products:
392
+ * those whose `{plan}` it lists, plus every product a kept one `requires`, in
393
+ * the snapshot's order. Absent, every shown product is mapped, as before.
287
394
  */
288
395
  export function offeringsPricing(
289
396
  shownSet: unknown,
290
- options: { locale: string; labels: OfferingsPricingLabels },
397
+ options: {
398
+ locale: string;
399
+ labels: OfferingsPricingLabels;
400
+ plans?: readonly string[] | undefined;
401
+ },
291
402
  ): OfferingsPricing | null {
292
403
  const parsed = shownSetSchema.safeParse(shownSet);
293
404
  if (!parsed.success) return null;
294
- const { state, offerings } = parsed.data;
295
- if (state !== "current" || offerings.length === 0) return null;
405
+ const { state, offerings: shown } = parsed.data;
406
+ if (state !== "current" || shown.length === 0) return null;
296
407
  const { locale, labels } = options;
297
408
 
298
- const planProducts = offerings.filter((o) => o.ailkGroup === "plan");
409
+ // #10028 D9 — a product some shown plan requires is sold only inside that
410
+ // bundle (decision 6), so it gets no card of its own. Read over the whole
411
+ // shown set, so a page listing only that product still shows it no card.
412
+ const requiredPlans = new Set(
413
+ shown.map((o) => o.requires).filter((plan) => plan !== undefined),
414
+ );
415
+ const offerings = narrowToPlans(shown, options.plans);
416
+ const planProducts = offerings.filter(
417
+ (o) => o.ailkGroup === "plan" && !requiredPlans.has(o.plan),
418
+ );
299
419
  const addonProducts = offerings.filter((o) => o.ailkGroup === "addon");
300
420
  const recommendedPlan = firstRecommended(planProducts);
301
421
  const recommendedAddon = firstRecommended(addonProducts);
302
422
 
303
423
  const plans = planProducts
304
- .map((p) => planCard(p, p.plan === recommendedPlan, locale, labels))
424
+ .map((p) => {
425
+ const required =
426
+ p.requires === undefined
427
+ ? undefined
428
+ : offerings.find((o) => o.plan === p.requires);
429
+ if (p.requires !== undefined) {
430
+ return required ? bundleCard(p, required, p.plan === recommendedPlan, locale, labels) : null;
431
+ }
432
+ return planCard(p, p.plan === recommendedPlan, locale, labels);
433
+ })
305
434
  .filter(isPresent);
306
435
  const addons = addonProducts
307
436
  .map((p) => addonTile(p, p.plan === recommendedAddon, locale, labels))
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Per-page chrome (#10049 D3) — which header and footer a page wears, and
3
+ * whether the home is a deck.
4
+ *
5
+ * Four levels, most specific first: the page's own frontmatter (`header:`,
6
+ * `footer:`, `layout:`), its route family's key in `_site.mdx` (`blog:`,
7
+ * `docs:`, …), the site defaults in `_site.mdx` (`header.style`,
8
+ * `footer.style`, `layout`), and last the design's own knobs in the
9
+ * generated `site-chrome.ts`. A site that sets nothing resolves to exactly
10
+ * the chrome the design gave it before this file existed.
11
+ *
12
+ * Blog posts and docs pages are served by the docs-blog slice, which this
13
+ * base file must never import, so they take their `blog:` / `docs:` family
14
+ * or the site default; their own frontmatter is not read here.
15
+ *
16
+ * `_site.mdx` is read locally rather than through
17
+ * `@working-theory/site-config/server`, as `site-header.ts` does (#1567).
18
+ */
19
+
20
+ import { readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+
23
+ import {
24
+ getMdxSource,
25
+ type SchemaOrgType,
26
+ } from "@working-theory/page-renderer";
27
+ import {
28
+ HeaderStyleSchema,
29
+ ROUTE_FAMILY_KEYS,
30
+ SiteConfigSchema,
31
+ type FooterStyle,
32
+ type HeaderStyle,
33
+ type RouteFamilyChrome,
34
+ type RouteFamilyKey,
35
+ type SiteLayout,
36
+ } from "@working-theory/site-config/schema";
37
+ import { ROUTES } from "@working-theory/templates/routes";
38
+ import { readLocales } from "@working-theory/web-i18n";
39
+ import { cache } from "react";
40
+ import { parse as parseYaml } from "yaml";
41
+ import type { ZodType } from "zod";
42
+
43
+ import { normalizeRoutePath } from "./closing-footer-path";
44
+ import {
45
+ isAlwaysScaffolded,
46
+ matchesRoute,
47
+ resolveTypeAndSlug,
48
+ } from "./deck-page";
49
+ import { toInternalDocsPath } from "./docs-path";
50
+ import { SERVICES_ROUTE, toInternalServicesPath } from "./services-path";
51
+ import { resolveContentRoot } from "./site-brand";
52
+
53
+ /** The chrome settings a site sets in `_site.mdx`. */
54
+ export interface SiteChromeSettings {
55
+ headerStyle?: HeaderStyle;
56
+ footerStyle?: FooterStyle;
57
+ layout?: SiteLayout;
58
+ families: Partial<Record<RouteFamilyKey, RouteFamilyChrome>>;
59
+ }
60
+
61
+ /** The chrome fields a page's own frontmatter sets. */
62
+ export interface PageChromeFields {
63
+ header?: HeaderStyle;
64
+ footer?: FooterStyle;
65
+ layout?: SiteLayout;
66
+ }
67
+
68
+ /**
69
+ * The design knobs the template level reads — a structural slice of the
70
+ * generated `SiteChrome`, so a scaffold generated before `innerPages` or
71
+ * `chromeStyle` existed still compiles.
72
+ */
73
+ export interface ChromeKnobs {
74
+ home: SiteLayout;
75
+ footer: "columns" | "centered";
76
+ innerPages?: "bare";
77
+ chromeStyle?: "minimal";
78
+ }
79
+
80
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
81
+
82
+ const warnedPaths = new Set<string>();
83
+
84
+ /** One warning per field path per process, naming the path and the Zod message — never the value. */
85
+ function warnOnce(path: string, message: string): void {
86
+ if (warnedPaths.has(path)) return;
87
+ warnedPaths.add(path);
88
+ console.warn(`[page-chrome] ignoring _site.mdx ${path}: ${message}`);
89
+ }
90
+
91
+ /** `value` parsed by `schema`, else `undefined` with a warning under `path`. */
92
+ function validated<T>(
93
+ schema: ZodType<T>,
94
+ value: unknown,
95
+ path: string,
96
+ ): T | undefined {
97
+ if (value === undefined) return undefined;
98
+ const parsed = schema.safeParse(value);
99
+ if (parsed.success) return parsed.data;
100
+ const issue = parsed.error.issues[0];
101
+ warnOnce(
102
+ [path, ...(issue?.path ?? [])].join("."),
103
+ issue?.message ?? "invalid",
104
+ );
105
+ return undefined;
106
+ }
107
+
108
+ /**
109
+ * `_site.mdx`'s chrome keys. Each key validates on its own, so one bad key
110
+ * drops only itself; an absent file, frontmatter or key is silent.
111
+ */
112
+ export const readSiteChromeSettings = cache(
113
+ function readSiteChromeSettings(): SiteChromeSettings {
114
+ try {
115
+ const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
116
+ const match = FRONTMATTER_RE.exec(raw);
117
+ if (!match) return { families: {} };
118
+ const fm: unknown = parseYaml(match[1] ?? "");
119
+ if (!fm || typeof fm !== "object") return { families: {} };
120
+ const record = fm as Record<string, unknown>;
121
+
122
+ const settings: SiteChromeSettings = { families: {} };
123
+ const header = record["header"];
124
+ const headerStyle =
125
+ header && typeof header === "object"
126
+ ? validated(
127
+ HeaderStyleSchema,
128
+ (header as Record<string, unknown>)["style"],
129
+ "header.style",
130
+ )
131
+ : undefined;
132
+ if (headerStyle !== undefined) settings.headerStyle = headerStyle;
133
+ const footerStyle = validated(
134
+ SiteConfigSchema.shape.footer,
135
+ record["footer"],
136
+ "footer",
137
+ )?.style;
138
+ if (footerStyle !== undefined) settings.footerStyle = footerStyle;
139
+ const layout = validated(
140
+ SiteConfigSchema.shape.layout,
141
+ record["layout"],
142
+ "layout",
143
+ );
144
+ if (layout !== undefined) settings.layout = layout;
145
+ for (const key of ROUTE_FAMILY_KEYS) {
146
+ const family = validated(SiteConfigSchema.shape[key], record[key], key);
147
+ if (family !== undefined) settings.families[key] = family;
148
+ }
149
+ return settings;
150
+ } catch (err) {
151
+ // An absent file is silent; unreadable frontmatter is worth one warning.
152
+ if ((err as { code?: unknown }).code !== "ENOENT") {
153
+ warnOnce("frontmatter", "could not be read");
154
+ }
155
+ return { families: {} };
156
+ }
157
+ },
158
+ );
159
+
160
+ function camelCase(prefix: string): string {
161
+ return prefix
162
+ .replace(/^\//, "")
163
+ .replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase());
164
+ }
165
+
166
+ /** Whether `segments` match a routes-map pattern, `:...slug` taking one or more segments. */
167
+ function matchesPattern(pattern: string, segments: readonly string[]): boolean {
168
+ const parts = pattern.split("/").filter((part) => part.length > 0);
169
+ for (let i = 0; i < parts.length; i += 1) {
170
+ const part = parts[i] ?? "";
171
+ if (part.startsWith(":...")) return segments.length > i;
172
+ if (i >= segments.length) return false;
173
+ if (!part.startsWith(":") && part !== segments[i]) return false;
174
+ }
175
+ return parts.length === segments.length;
176
+ }
177
+
178
+ /**
179
+ * The route family a pathname belongs to, else `null`: the first routes-map
180
+ * hub whose prefix is the path, prefixes it, or whose detail pattern matches
181
+ * it. A site's moved docs are mapped back onto `/docs` first, and its moved
182
+ * services onto `/services` (#10043 D9).
183
+ */
184
+ export function routeFamilyOf(
185
+ pathname: string,
186
+ locales: readonly string[],
187
+ ): RouteFamilyKey | null {
188
+ const bare = normalizeRoutePath(pathname, locales);
189
+ const path = toInternalDocsPath(bare) ?? toInternalServicesPath(bare) ?? bare;
190
+ const segments = path.split("/").filter((segment) => segment.length > 0);
191
+ for (const row of ROUTES) {
192
+ if (!row.hub) continue;
193
+ const { prefix, detailPattern } = row.hub;
194
+ if (
195
+ path === prefix ||
196
+ path.startsWith(`${prefix}/`) ||
197
+ matchesPattern(detailPattern, segments)
198
+ ) {
199
+ const key = camelCase(prefix);
200
+ return (ROUTE_FAMILY_KEYS as readonly string[]).includes(key)
201
+ ? (key as RouteFamilyKey)
202
+ : null;
203
+ }
204
+ }
205
+ return null;
206
+ }
207
+
208
+ /**
209
+ * The MDX content pages that could serve `pathname`, in the order Next.js
210
+ * tries them — `deckCandidates`' walk over every content row rather than the
211
+ * deck routes. Blog posts and docs pages live outside `content/`, so they are
212
+ * never candidates.
213
+ */
214
+ export function chromePageCandidates(
215
+ pathname: string,
216
+ locales: readonly string[],
217
+ ): Array<{ type: SchemaOrgType; slug: string }> {
218
+ const normalized = normalizeRoutePath(pathname, locales);
219
+ // #10043 D9 — a site's moved services are mapped back onto `/services`.
220
+ const path = toInternalServicesPath(normalized) ?? normalized;
221
+ if (path === "/") return [{ type: "HomePage", slug: "home" }];
222
+ const segments = path.split("/").filter((segment) => segment.length > 0);
223
+ // A service under its category (`/services/<category>/<slug>`): the nested
224
+ // row names no content directory, so the walk below would never name it.
225
+ if (segments.length === 3 && segments[0] === SERVICES_ROUTE.slice(1)) {
226
+ return [{ type: "Service", slug: segments[2]! }];
227
+ }
228
+ const candidates: Array<{ type: SchemaOrgType; slug: string }> = [];
229
+
230
+ for (const row of ROUTES) {
231
+ if (!row.contentPath?.startsWith("content/")) continue;
232
+ if (!matchesRoute(row.route, segments)) continue;
233
+ const resolved = resolveTypeAndSlug(row, row.route, segments);
234
+ if (resolved) {
235
+ candidates.push(resolved);
236
+ break;
237
+ }
238
+ }
239
+
240
+ if (segments.length === 1 && !isAlwaysScaffolded(path)) {
241
+ const slug = segments[0];
242
+ if (slug !== undefined) {
243
+ candidates.push({ type: "LandingPage", slug });
244
+ candidates.push({ type: "BlogList", slug });
245
+ }
246
+ }
247
+ return candidates;
248
+ }
249
+
250
+ /**
251
+ * The page's own chrome frontmatter — the first candidate with a source —
252
+ * else `{}`. A read that throws answers `{}`: the page's own render surfaces
253
+ * the error. Per-request memoised.
254
+ */
255
+ export const resolveChromePage = cache(async function resolveChromePage(
256
+ pathname: string,
257
+ locale: string,
258
+ ): Promise<PageChromeFields> {
259
+ try {
260
+ for (const candidate of chromePageCandidates(pathname, readLocales())) {
261
+ const source = await getMdxSource(candidate.type, candidate.slug, locale);
262
+ if (!source) continue;
263
+ const { header, footer, layout } = source.frontmatter as PageChromeFields;
264
+ return {
265
+ ...(header !== undefined ? { header } : {}),
266
+ ...(footer !== undefined ? { footer } : {}),
267
+ ...(layout !== undefined ? { layout } : {}),
268
+ };
269
+ }
270
+ } catch {
271
+ return {};
272
+ }
273
+ return {};
274
+ });
275
+
276
+ /** The home's layout: its own frontmatter, else the site's, else the design's. */
277
+ export function resolveHomeLayout(
278
+ site: SiteChromeSettings,
279
+ home: PageChromeFields,
280
+ chrome: Pick<ChromeKnobs, "home">,
281
+ ): SiteLayout {
282
+ return home.layout ?? site.layout ?? chrome.home;
283
+ }
284
+
285
+ export interface PageChromeInput {
286
+ isHome: boolean;
287
+ /** The resolved home layout is a deck. */
288
+ homeIsDeck: boolean;
289
+ family: RouteFamilyKey | null;
290
+ page: PageChromeFields;
291
+ site: SiteChromeSettings;
292
+ chrome: ChromeKnobs;
293
+ /** The page is listed in the design's `closingFooterPaths`. */
294
+ closingListed: boolean;
295
+ }
296
+
297
+ export interface ResolvedChrome {
298
+ header: HeaderStyle;
299
+ /** No page, family or site value set the header — the design's own treatment applies. */
300
+ headerFromTemplate: boolean;
301
+ /** `line` is the design's one-bar footer on a bare inner page; never a site value. */
302
+ footer: FooterStyle | "line";
303
+ }
304
+
305
+ /** The design's own header and footer for a page (#10049 D3's template table). */
306
+ function templateChrome(
307
+ input: PageChromeInput,
308
+ ): Pick<ResolvedChrome, "header" | "footer"> {
309
+ const { chrome } = input;
310
+ if (input.isHome) {
311
+ if (input.homeIsDeck && chrome.home === "deck") {
312
+ return {
313
+ header: "bare",
314
+ footer: chrome.chromeStyle === "minimal" ? "closing" : chrome.footer,
315
+ };
316
+ }
317
+ return { header: "full", footer: chrome.footer };
318
+ }
319
+ if (input.closingListed) {
320
+ return {
321
+ header: chrome.innerPages === "bare" ? "bare" : "full",
322
+ footer: "closing",
323
+ };
324
+ }
325
+ if (chrome.innerPages === "bare") return { header: "bare", footer: "line" };
326
+ return { header: "full", footer: chrome.footer };
327
+ }
328
+
329
+ /**
330
+ * The home's layout and chrome — the one resolution both the layout and the
331
+ * home page read, so the bar and the landing can never disagree.
332
+ */
333
+ export async function resolveHomeChrome(
334
+ locale: string,
335
+ chrome: ChromeKnobs,
336
+ ): Promise<{ layout: SiteLayout; chrome: ResolvedChrome; site: SiteChromeSettings }> {
337
+ const site = readSiteChromeSettings();
338
+ const page = await resolveChromePage("/", locale);
339
+ const layout = resolveHomeLayout(site, page, chrome);
340
+ return {
341
+ layout,
342
+ site,
343
+ chrome: resolvePageChrome({
344
+ isHome: true,
345
+ homeIsDeck: layout === "deck",
346
+ family: null,
347
+ page,
348
+ site,
349
+ chrome,
350
+ closingListed: false,
351
+ }),
352
+ };
353
+ }
354
+
355
+ /** Each axis takes the first value set: page → route family → site → design. */
356
+ export function resolvePageChrome(input: PageChromeInput): ResolvedChrome {
357
+ const template = templateChrome(input);
358
+ const family =
359
+ input.family !== null ? input.site.families[input.family] : undefined;
360
+ const header = input.page.header ?? family?.header ?? input.site.headerStyle;
361
+ const footer = input.page.footer ?? family?.footer ?? input.site.footerStyle;
362
+ return {
363
+ header: header ?? template.header,
364
+ headerFromTemplate: header === undefined,
365
+ footer: footer ?? template.footer,
366
+ };
367
+ }
@@ -5,13 +5,35 @@
5
5
  * is `/careers/<slug>`'s. In `lib/` (#8849 D9), not a route directory, so
6
6
  * every scaffold carries it: the `signal-collectors` walk and the Free apply
7
7
  * page (`careers/[slug]/[action]`) both read it.
8
+ *
9
+ * #10054 D2 — both pages read the posting through `postingForApply`, which
10
+ * also returns the magnet the posting names (`applicationFlow`, checked as a
11
+ * flow slug by the frontmatter schema); one read serves both.
8
12
  */
9
13
  import { getMdxSource } from "@working-theory/page-renderer";
10
14
 
11
- /** The posting's title in `locale`, or `null` when no posting has this slug. */
12
- export async function postingTitle(slug: string, locale: string): Promise<string | null> {
15
+ /** What an apply page needs from its posting. */
16
+ export interface ApplyPosting {
17
+ title: string;
18
+ /** The magnet this posting's apply page runs; absent, the Job application. */
19
+ applicationFlow?: string;
20
+ }
21
+
22
+ /** The posting in `locale`, or `null` when no posting has this slug or it has no title. */
23
+ export async function postingForApply(slug: string, locale: string): Promise<ApplyPosting | null> {
13
24
  const source = await getMdxSource("JobPosting", slug, locale);
14
25
  if (source === null) return null;
15
- const title = (source.frontmatter as { title?: unknown }).title;
16
- return typeof title === "string" && title.trim() !== "" ? title : null;
26
+ const { title, applicationFlow } = source.frontmatter as {
27
+ title?: unknown;
28
+ applicationFlow?: unknown;
29
+ };
30
+ if (typeof title !== "string" || title.trim() === "") return null;
31
+ return typeof applicationFlow === "string" && applicationFlow !== ""
32
+ ? { title, applicationFlow }
33
+ : { title };
34
+ }
35
+
36
+ /** The posting's title in `locale`, or `null` when no posting has this slug. */
37
+ export async function postingTitle(slug: string, locale: string): Promise<string | null> {
38
+ return (await postingForApply(slug, locale))?.title ?? null;
17
39
  }