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
@@ -22,6 +22,7 @@ import React from "react";
22
22
  import {
23
23
  formatMinorUnits,
24
24
  OFFERINGS_CHECKOUT_PATH,
25
+ offeringsBundleHref,
25
26
  offeringsCheckoutHref,
26
27
  offeringsPricing,
27
28
  type OfferingsPricing,
@@ -34,6 +35,8 @@ const LABELS: OfferingsPricingLabels = {
34
35
  monthly: (amount) => "M⟪" + amount,
35
36
  annual: (amount) => "Y⟪" + amount,
36
37
  oneTime: (amount) => "O⟪" + amount,
38
+ withRequired: (plan, required) => "W⟪" + plan + "⟫" + required,
39
+ depositNote: (deposit) => "D⟪" + deposit,
37
40
  };
38
41
 
39
42
  const OPTIONS = { locale: "en-US", labels: LABELS };
@@ -397,6 +400,117 @@ describe("offeringsPricing — periods, the switch and the fields (AC-2)", () =>
397
400
  });
398
401
  });
399
402
 
403
+ describe("offeringsPricing — a deposit plan's footnote (#10030 D10, AC-9)", () => {
404
+ it("sets the deposit note on a plan card, formatted in the one-time Price's currency, over a terms note", () => {
405
+ const set = shownSet((s) => {
406
+ const basic = offering(s, "basic");
407
+ basic.prices = basic.prices.filter((p) => p.period === "once");
408
+ basic.deposit = 50000;
409
+ basic.termsNote = "Terms";
410
+ });
411
+ expect(card(mapped(set), "basic").priceFootnote).toBe("D⟪$500");
412
+ expect(card(mapped(), "basic").priceFootnote).toBeUndefined();
413
+ expect(card(mapped(), "growth").priceFootnote).toBe("Cancel any time from your account");
414
+ });
415
+
416
+ it("sets the deposit note on a bundled plan card too, in the plan's own currency", () => {
417
+ const set = shownSet((s) => {
418
+ s.offerings = [
419
+ product("rebuild", "plan", [price("rebuild", "once", 2500000, "jpy")], { requires: "managed", deposit: 500000 }),
420
+ product("managed", "plan", [price("managed", "monthly", 300000, "usd")]),
421
+ ];
422
+ s.productIds = ["prod_rebuild", "prod_managed"];
423
+ s.recommended = { plan: null, addon: null };
424
+ });
425
+ expect(card(mapped(set), "rebuild").priceFootnote).toBe("D⟪¥500,000");
426
+ });
427
+ });
428
+
429
+ describe("offeringsPricing — a bundled plan links its required product (#10028 D9, AC-9)", () => {
430
+ /** The shown set: a one-time Rebuild plan requiring the Managed plan, and one allowed add-on. */
431
+ function bundleSet(
432
+ managedPeriods: Array<"monthly" | "yearly"> = ["monthly", "yearly"],
433
+ relations: Partial<OfferingsProduct> = { requires: "managed", addons: ["page"] },
434
+ ) {
435
+ return shownSet((set) => {
436
+ set.offerings = [
437
+ product("rebuild", "plan", [price("rebuild", "once", 2500000, "usd")], relations),
438
+ product("managed", "plan", managedPeriods.map((period) => price("managed", period, period === "monthly" ? 300000 : 3000000, "usd"))),
439
+ product("page", "addon", [price("page", "once", 150000, "usd")]),
440
+ ];
441
+ set.productIds = ["prod_rebuild", "prod_managed", "prod_page"];
442
+ set.recommended = { plan: null, addon: null };
443
+ });
444
+ }
445
+
446
+ it("with monthly and yearly shown: the monthly link on the card, the yearly link behind the toggle, and the combined phrase in both modes", () => {
447
+ const result = mapped(bundleSet());
448
+ const rebuild = card(result, "rebuild");
449
+ expect(rebuild.cta.href).toBe("/api/offerings/checkout/rebuild_once?with=managed_monthly");
450
+ expect(rebuild.annualCtaHref).toBe("/api/offerings/checkout/rebuild_once?with=managed_yearly");
451
+ expect(rebuild.price).toEqual({
452
+ monthly: "W⟪O⟪$25,000⟫M⟪$3,000",
453
+ annual: "W⟪O⟪$25,000⟫Y⟪$30,000",
454
+ });
455
+ expect(result.plans!.billingToggle).toBe(true);
456
+ });
457
+
458
+ it("with one recurring Price shown: exactly one link, the combined phrase as the custom price, no toggle", () => {
459
+ const result = mapped(bundleSet(["monthly"]));
460
+ const rebuild = card(result, "rebuild");
461
+ expect(rebuild.cta.href).toBe("/api/offerings/checkout/rebuild_once?with=managed_monthly");
462
+ expect(rebuild).not.toHaveProperty("annualCtaHref");
463
+ expect(rebuild.price).toEqual({ custom: "W⟪O⟪$25,000⟫M⟪$3,000" });
464
+ expect(result.plans!.billingToggle).toBe(false);
465
+ });
466
+
467
+ it("renders no card for the required product itself, and keeps the add-on tile", () => {
468
+ const result = mapped(bundleSet());
469
+ expect(plansOf(result).map((p) => p.key)).toEqual(["rebuild"]);
470
+ expect(addonsOf(result).map((a) => a.key)).toEqual(["page"]);
471
+ });
472
+
473
+ it("keeps the recommended highlight on a bundled plan, as a plain plan card has it", () => {
474
+ const set = bundleSet();
475
+ set.recommended = { plan: "prod_rebuild", addon: null };
476
+ set.offerings = set.offerings.map((o) => ({ ...o, recommended: o.productId === "prod_rebuild" }));
477
+ const rebuild = card(mapped(set), "rebuild");
478
+ expect(rebuild.highlighted).toBe(true);
479
+ expect(rebuild.highlightStyle).toBe("pill");
480
+ expect(rebuild.highlightLabel).toBe(LABELS.recommended);
481
+ });
482
+
483
+ it("renders no card for a plan whose required product is not shown at a recurring interval", () => {
484
+ // The required product shown at its one-time Price only: no recurring interval to link.
485
+ const onceOnly = mapped(
486
+ shownSet((set) => {
487
+ set.offerings = [
488
+ product("rebuild", "plan", [price("rebuild", "once", 2500000, "usd")], { requires: "managed" }),
489
+ product("managed", "plan", [price("managed", "once", 300000, "usd")]),
490
+ ];
491
+ set.productIds = ["prod_rebuild", "prod_managed"];
492
+ set.recommended = { plan: null, addon: null };
493
+ }),
494
+ );
495
+ expect(onceOnly!.plans).toBeNull();
496
+ // The required product not shown at all.
497
+ const unshown = mapped(
498
+ shownSet((set) => {
499
+ set.offerings = [product("rebuild", "plan", [price("rebuild", "once", 2500000, "usd")], { requires: "managed" })];
500
+ set.productIds = ["prod_rebuild"];
501
+ set.recommended = { plan: null, addon: null };
502
+ }),
503
+ );
504
+ expect(unshown!.plans).toBeNull();
505
+ });
506
+
507
+ it("builds the bundle link only from two grammar keys", () => {
508
+ expect(offeringsBundleHref("rebuild_once", "managed_monthly")).toBe("/api/offerings/checkout/rebuild_once?with=managed_monthly");
509
+ expect(offeringsBundleHref("rebuild_once", "Managed Monthly")).toBeNull();
510
+ expect(offeringsBundleHref("Rebuild", "managed_monthly")).toBeNull();
511
+ });
512
+ });
513
+
400
514
  describe("offeringsPricing — Stripe text stays text; every link is ours (AC-3)", () => {
401
515
  const HOSTILE_DESCRIPTION = "Fast <b>bold</b> javascript:alert(1) \u202Eevil";
402
516
 
@@ -575,6 +689,75 @@ describe("offeringsPricing — no copy of its own (AC-7)", () => {
575
689
  });
576
690
  });
577
691
 
692
+ describe("offeringsPricing — an offering page's `plans` (#10044 D4, AC-3)", () => {
693
+ /** The fixture plus a one-time Rebuild plan requiring the Managed plan. */
694
+ function withBundle(): WorkspaceShownSet {
695
+ return shownSet((set) => {
696
+ set.offerings = [
697
+ ...set.offerings,
698
+ product("rebuild", "plan", [price("rebuild", "once", 2500000, "usd")], {
699
+ requires: "managed",
700
+ }),
701
+ product("managed", "plan", [price("managed", "monthly", 300000, "usd")]),
702
+ ];
703
+ set.productIds = set.offerings.map((o) => o.productId);
704
+ });
705
+ }
706
+
707
+ function narrowed(set: WorkspaceShownSet, plans: string[]): OfferingsPricing {
708
+ const result = offeringsPricing(set, { ...OPTIONS, plans });
709
+ if (!result) throw new Error("expected a mapping");
710
+ return result;
711
+ }
712
+
713
+ it("maps exactly as today when `plans` is absent", () => {
714
+ expect(offeringsPricing(shownSet(), OPTIONS)).toEqual(
715
+ offeringsPricing(shownSet(), { ...OPTIONS, plans: undefined }),
716
+ );
717
+ expect(offeringsPricing(withBundle(), OPTIONS)).toEqual(
718
+ offeringsPricing(withBundle(), { ...OPTIONS, plans: undefined }),
719
+ );
720
+ });
721
+
722
+ it("keeps only the listed plan card and the listed add-on tile", () => {
723
+ const result = narrowed(shownSet(), ["growth", "audit-pack"]);
724
+ expect(plansOf(result).map((p) => p.key)).toEqual(["growth"]);
725
+ expect(addonsOf(result).map((a) => a.key)).toEqual(["audit-pack"]);
726
+ });
727
+
728
+ it("renders a listed bundled plan's card although its required product is not listed, and gives the required product no card", () => {
729
+ const result = narrowed(withBundle(), ["rebuild"]);
730
+ expect(plansOf(result).map((p) => p.key)).toEqual(["rebuild"]);
731
+ expect(card(result, "rebuild").cta.href).toBe(
732
+ "/api/offerings/checkout/rebuild_once?with=managed_monthly",
733
+ );
734
+ expect(result.addons).toBeNull();
735
+ });
736
+
737
+ it("renders no card for a listed bundle-only product", () => {
738
+ const result = narrowed(withBundle(), ["managed"]);
739
+ expect(result.plans).toBeNull();
740
+ expect(result.addons).toBeNull();
741
+ });
742
+
743
+ it("skips a listed slug the shown set does not carry", () => {
744
+ const result = narrowed(shownSet(), ["not-shown", "basic"]);
745
+ expect(plansOf(result).map((p) => p.key)).toEqual(["basic"]);
746
+ expect(result.addons).toBeNull();
747
+ });
748
+
749
+ it("keeps the shown set's order, not the order of `plans`", () => {
750
+ const result = narrowed(shownSet(), ["basic", "starter"]);
751
+ expect(plansOf(result).map((p) => p.key)).toEqual(["starter", "basic"]);
752
+ });
753
+
754
+ it("maps both fragments to null for `plans: []`", () => {
755
+ const result = narrowed(shownSet(), []);
756
+ expect(result.plans).toBeNull();
757
+ expect(result.addons).toBeNull();
758
+ });
759
+ });
760
+
578
761
  describe("offeringsPricing — the route's wire shape (#8927 D3, AC-7)", () => {
579
762
  /** The shown set as `GET /v1/content/shown-offerings` sends it: no ids. */
580
763
  function wireSet(): Omit<
@@ -0,0 +1,412 @@
1
+ /**
2
+ * The page-chrome resolver (#10049 D3): the route-family map (AC-2), the
3
+ * most-specific-wins resolution over the design's defaults (AC-3), and the
4
+ * two cached reads — `_site.mdx`'s chrome keys and a page's own frontmatter
5
+ * (AC-4). The reads run over a scratch content root (`AILK_CONTENT_ROOT`),
6
+ * so the real readers parse real files.
7
+ */
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import { FrontmatterSchema } from "@working-theory/content-adapters";
13
+ import {
14
+ FOOTER_STYLES,
15
+ HEADER_STYLES,
16
+ ROUTE_FAMILY_KEYS,
17
+ } from "@working-theory/site-config/schema";
18
+ import { ROUTES } from "@working-theory/templates/routes";
19
+
20
+ import {
21
+ readSiteChromeSettings,
22
+ resolveChromePage,
23
+ resolveHomeLayout,
24
+ resolvePageChrome,
25
+ routeFamilyOf,
26
+ type PageChromeInput,
27
+ type SiteChromeSettings,
28
+ } from "../page-chrome";
29
+ import type { SiteChrome } from "../site-chrome";
30
+
31
+ // ── AC-2 — the route families ────────────────────────────────────────────
32
+
33
+ function camel(prefix: string): string {
34
+ return prefix
35
+ .replace(/^\//, "")
36
+ .replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase());
37
+ }
38
+
39
+ describe("route families (#10049 AC-2)", () => {
40
+ it("ROUTE_FAMILY_KEYS is every routes-map hub prefix, camel-cased, in order", () => {
41
+ const hubs = ROUTES.flatMap((row) =>
42
+ row.hub ? [camel(row.hub.prefix)] : [],
43
+ );
44
+ expect([...ROUTE_FAMILY_KEYS]).toEqual(hubs);
45
+ });
46
+
47
+ it("the page frontmatter's header and footer enums equal the site's style lists", () => {
48
+ const shape = FrontmatterSchema.shape;
49
+ expect(shape.header.unwrap().options).toEqual([...HEADER_STYLES]);
50
+ expect(shape.footer.unwrap().options).toEqual([...FOOTER_STYLES]);
51
+ });
52
+
53
+ it.each([
54
+ ["/blog", ["en"], "blog"],
55
+ ["/blog/a/b", ["en"], "blog"],
56
+ ["/vs/x", ["en"], "alternatives"],
57
+ ["/pricing/x", ["en"], "pricing"],
58
+ ["/es/case-studies/x", ["en", "es"], "caseStudies"],
59
+ ["/about", ["en"], null],
60
+ ["/blogging", ["en"], null],
61
+ ["/", ["en"], null],
62
+ ])("routeFamilyOf(%s) with locales %j is %p", (pathname, locales, family) => {
63
+ expect(routeFamilyOf(pathname, locales)).toBe(family);
64
+ });
65
+
66
+ it("follows a site's moved docs (NEXT_PUBLIC_AILK_DOCS_PATH)", () => {
67
+ const saved = process.env["NEXT_PUBLIC_AILK_DOCS_PATH"];
68
+ process.env["NEXT_PUBLIC_AILK_DOCS_PATH"] = "/working-theory/docs";
69
+ try {
70
+ jest.isolateModules(() => {
71
+ const fresh =
72
+ require("../page-chrome") as typeof import("../page-chrome");
73
+ expect(fresh.routeFamilyOf("/working-theory/docs/x", ["en"])).toBe(
74
+ "docs",
75
+ );
76
+ expect(fresh.routeFamilyOf("/working-theory/docs", ["en"])).toBe(
77
+ "docs",
78
+ );
79
+ });
80
+ } finally {
81
+ if (saved === undefined) delete process.env["NEXT_PUBLIC_AILK_DOCS_PATH"];
82
+ else process.env["NEXT_PUBLIC_AILK_DOCS_PATH"] = saved;
83
+ }
84
+ });
85
+ });
86
+
87
+ // ── AC-3 — resolution ────────────────────────────────────────────────────
88
+
89
+ const STANDARD: SiteChrome = {
90
+ nav: "full",
91
+ footer: "columns",
92
+ announcementBanner: false,
93
+ home: "default",
94
+ };
95
+ const MINIMALISM: SiteChrome = {
96
+ nav: "mark-only",
97
+ footer: "centered",
98
+ announcementBanner: false,
99
+ home: "deck",
100
+ deckPresentation: true,
101
+ innerPages: "bare",
102
+ colorMode: "light",
103
+ chromeStyle: "minimal",
104
+ };
105
+ const NOTHING: SiteChromeSettings = { families: {} };
106
+
107
+ function input(over: Partial<PageChromeInput>): PageChromeInput {
108
+ return {
109
+ isHome: false,
110
+ homeIsDeck: false,
111
+ family: null,
112
+ page: {},
113
+ site: NOTHING,
114
+ chrome: STANDARD,
115
+ closingListed: false,
116
+ ...over,
117
+ };
118
+ }
119
+
120
+ describe("resolvePageChrome — the template level (#10049 AC-3)", () => {
121
+ it.each<[string, Partial<PageChromeInput>, string, string]>([
122
+ [
123
+ "home, deck, minimal deck design",
124
+ { isHome: true, homeIsDeck: true, chrome: MINIMALISM },
125
+ "bare",
126
+ "closing",
127
+ ],
128
+ [
129
+ "home, deck, non-minimal deck design",
130
+ {
131
+ isHome: true,
132
+ homeIsDeck: true,
133
+ chrome: { ...STANDARD, home: "deck", footer: "centered" },
134
+ },
135
+ "bare",
136
+ "centered",
137
+ ],
138
+ ["home, otherwise", { isHome: true }, "full", "columns"],
139
+ [
140
+ "inner, closing listed, bare inner pages",
141
+ { closingListed: true, chrome: MINIMALISM },
142
+ "bare",
143
+ "closing",
144
+ ],
145
+ ["inner, closing listed", { closingListed: true }, "full", "closing"],
146
+ ["inner, bare inner pages", { chrome: MINIMALISM }, "bare", "line"],
147
+ ["inner, otherwise", {}, "full", "columns"],
148
+ ])("%s → (%j) %s / %s", (_name, over, header, footer) => {
149
+ expect(resolvePageChrome(input(over))).toEqual({
150
+ header,
151
+ footer,
152
+ headerFromTemplate: true,
153
+ });
154
+ });
155
+
156
+ it("a standard design whose home resolves deck keeps the full header and its own footer", () => {
157
+ expect(
158
+ resolvePageChrome(input({ isHome: true, homeIsDeck: true })),
159
+ ).toEqual({
160
+ header: "full",
161
+ footer: "columns",
162
+ headerFromTemplate: true,
163
+ });
164
+ });
165
+
166
+ it("a minimalism site that turns its deck home off gets the full bar", () => {
167
+ expect(
168
+ resolvePageChrome(
169
+ input({ isHome: true, homeIsDeck: false, chrome: MINIMALISM }),
170
+ ),
171
+ ).toEqual({ header: "full", footer: "centered", headerFromTemplate: true });
172
+ });
173
+
174
+ it("a closing-listed page whose own footer is columns ends on columns", () => {
175
+ expect(
176
+ resolvePageChrome(
177
+ input({ closingListed: true, page: { footer: "columns" } }),
178
+ ).footer,
179
+ ).toBe("columns");
180
+ });
181
+ });
182
+
183
+ describe("resolvePageChrome — most specific wins (#10049 AC-3)", () => {
184
+ const site: SiteChromeSettings = {
185
+ headerStyle: "none",
186
+ footerStyle: "none",
187
+ families: { blog: { header: "bare", footer: "centered" } },
188
+ };
189
+
190
+ it("site beats template, on each axis", () => {
191
+ expect(resolvePageChrome(input({ site }))).toEqual({
192
+ header: "none",
193
+ footer: "none",
194
+ headerFromTemplate: false,
195
+ });
196
+ });
197
+
198
+ it("family beats site, on each axis", () => {
199
+ expect(resolvePageChrome(input({ site, family: "blog" }))).toEqual({
200
+ header: "bare",
201
+ footer: "centered",
202
+ headerFromTemplate: false,
203
+ });
204
+ });
205
+
206
+ it("page beats family, on each axis", () => {
207
+ expect(
208
+ resolvePageChrome(
209
+ input({
210
+ site,
211
+ family: "blog",
212
+ page: { header: "full", footer: "closing" },
213
+ }),
214
+ ),
215
+ ).toEqual({ header: "full", footer: "closing", headerFromTemplate: false });
216
+ });
217
+
218
+ it("the axes resolve independently", () => {
219
+ expect(
220
+ resolvePageChrome(
221
+ input({
222
+ site: {
223
+ footerStyle: "centered",
224
+ families: { blog: { header: "none" } },
225
+ },
226
+ family: "blog",
227
+ page: { footer: "none" },
228
+ }),
229
+ ),
230
+ ).toEqual({ header: "none", footer: "none", headerFromTemplate: false });
231
+ expect(
232
+ resolvePageChrome(input({ site: { headerStyle: "bare", families: {} } })),
233
+ ).toEqual({ header: "bare", footer: "columns", headerFromTemplate: false });
234
+ });
235
+
236
+ it("a family key for another family has no effect", () => {
237
+ expect(resolvePageChrome(input({ site, family: "docs" })).header).toBe(
238
+ "none",
239
+ );
240
+ expect(
241
+ resolvePageChrome(
242
+ input({
243
+ family: "docs",
244
+ site: { families: { blog: { header: "bare" } } },
245
+ }),
246
+ ).header,
247
+ ).toBe("full");
248
+ });
249
+ });
250
+
251
+ describe("resolveHomeLayout (#10049 AC-3)", () => {
252
+ it("the home's own layout beats the site's, which beats the design's", () => {
253
+ expect(
254
+ resolveHomeLayout(
255
+ { layout: "default", families: {} },
256
+ { layout: "deck" },
257
+ STANDARD,
258
+ ),
259
+ ).toBe("deck");
260
+ expect(
261
+ resolveHomeLayout({ layout: "deck", families: {} }, {}, STANDARD),
262
+ ).toBe("deck");
263
+ expect(
264
+ resolveHomeLayout({ layout: "default", families: {} }, {}, MINIMALISM),
265
+ ).toBe("default");
266
+ expect(resolveHomeLayout(NOTHING, {}, MINIMALISM)).toBe("deck");
267
+ expect(resolveHomeLayout(NOTHING, { layout: "default" }, MINIMALISM)).toBe(
268
+ "default",
269
+ );
270
+ });
271
+ });
272
+
273
+ // ── AC-4 — the reads ─────────────────────────────────────────────────────
274
+
275
+ let root: string;
276
+ const savedRoot = process.env["AILK_CONTENT_ROOT"];
277
+ let warn: jest.SpyInstance;
278
+
279
+ function writeSite(lines: string[]): void {
280
+ writeFileSync(
281
+ join(root, "_site.mdx"),
282
+ `---\n${["siteName: Acme", ...lines].join("\n")}\n---\n`,
283
+ );
284
+ }
285
+
286
+ function writePage(type: string, slug: string, lines: string[]): void {
287
+ mkdirSync(join(root, "en", type), { recursive: true });
288
+ writeFileSync(
289
+ join(root, "en", type, `${slug}.mdx`),
290
+ `---\n${[`type: ${type}`, `slug: ${slug}`, "title: T", "description: D", ...lines].join("\n")}\n---\n\nBody.\n`,
291
+ );
292
+ }
293
+
294
+ describe("readSiteChromeSettings and resolveChromePage (#10049 AC-4)", () => {
295
+ beforeEach(() => {
296
+ root = mkdtempSync(join(tmpdir(), "page-chrome-"));
297
+ process.env["AILK_CONTENT_ROOT"] = root;
298
+ warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
299
+ });
300
+ afterEach(() => {
301
+ warn.mockRestore();
302
+ rmSync(root, { recursive: true, force: true });
303
+ if (savedRoot === undefined) delete process.env["AILK_CONTENT_ROOT"];
304
+ else process.env["AILK_CONTENT_ROOT"] = savedRoot;
305
+ });
306
+
307
+ it("returns each valid key", () => {
308
+ writeSite([
309
+ "header:",
310
+ " style: bare",
311
+ "footer:",
312
+ " style: closing",
313
+ "layout: deck",
314
+ "blog:",
315
+ " header: none",
316
+ "caseStudies:",
317
+ " footer: centered",
318
+ ]);
319
+ expect(readSiteChromeSettings()).toEqual({
320
+ headerStyle: "bare",
321
+ footerStyle: "closing",
322
+ layout: "deck",
323
+ families: {
324
+ blog: { header: "none" },
325
+ caseStudies: { footer: "centered" },
326
+ },
327
+ });
328
+ });
329
+
330
+ it("drops a bad header.style alone and warns once across calls, naming the path and never the value", () => {
331
+ writeSite(["header:", " style: minimal", "blog:", " header: bare"]);
332
+ const first = readSiteChromeSettings();
333
+ const second = readSiteChromeSettings();
334
+ for (const settings of [first, second]) {
335
+ expect(settings.headerStyle).toBeUndefined();
336
+ expect(settings.families).toEqual({ blog: { header: "bare" } });
337
+ }
338
+ expect(warn).toHaveBeenCalledTimes(1);
339
+ const message = String(warn.mock.calls[0]?.[0]);
340
+ expect(message).toContain("header.style");
341
+ expect(message).not.toContain("minimal");
342
+ });
343
+
344
+ it("an absent _site.mdx returns no settings and no warning", () => {
345
+ expect(readSiteChromeSettings()).toEqual({ families: {} });
346
+ expect(warn).not.toHaveBeenCalled();
347
+ });
348
+
349
+ it("resolveChromePage reads a page's and the home's own chrome frontmatter, and never a blog post", async () => {
350
+ writePage("AboutPage", "about", ["header: bare", "footer: centered"]);
351
+ writePage("HomePage", "home", ["layout: deck"]);
352
+ await expect(resolveChromePage("/about", "en")).resolves.toEqual({
353
+ header: "bare",
354
+ footer: "centered",
355
+ });
356
+ await expect(resolveChromePage("/", "en")).resolves.toEqual({
357
+ layout: "deck",
358
+ });
359
+ await expect(resolveChromePage("/blog/x", "en")).resolves.toEqual({});
360
+ });
361
+
362
+ it("resolveChromePage is empty for a page that sets nothing or does not exist", async () => {
363
+ writePage("AboutPage", "about", []);
364
+ await expect(resolveChromePage("/about", "en")).resolves.toEqual({});
365
+ await expect(resolveChromePage("/no-such-page", "en")).resolves.toEqual({});
366
+ });
367
+ });
368
+
369
+ // ── #10043 AC-7 — the services base and its categories ───────────────────
370
+
371
+ describe("services base and categories (#10043 AC-7)", () => {
372
+ const locales = ["en", "pt-BR"];
373
+
374
+ function withServicesPath<T>(
375
+ value: string | undefined,
376
+ run: (mod: typeof import("../page-chrome")) => T,
377
+ ): T {
378
+ const saved = process.env["NEXT_PUBLIC_AILK_SERVICES_PATH"];
379
+ if (value === undefined) delete process.env["NEXT_PUBLIC_AILK_SERVICES_PATH"];
380
+ else process.env["NEXT_PUBLIC_AILK_SERVICES_PATH"] = value;
381
+ try {
382
+ let result: T | undefined;
383
+ jest.isolateModules(() => {
384
+ result = run(require("../page-chrome") as typeof import("../page-chrome"));
385
+ });
386
+ return result as T;
387
+ } finally {
388
+ if (saved === undefined) delete process.env["NEXT_PUBLIC_AILK_SERVICES_PATH"];
389
+ else process.env["NEXT_PUBLIC_AILK_SERVICES_PATH"] = saved;
390
+ }
391
+ }
392
+
393
+ it("keeps a moved base and a nested service in the services family", () => {
394
+ withServicesPath("/capabilities", (mod) => {
395
+ expect(mod.routeFamilyOf("/capabilities/ai/x", locales)).toBe("services");
396
+ expect(mod.routeFamilyOf("/capabilities", locales)).toBe("services");
397
+ });
398
+ });
399
+
400
+ it("names the nested service as the chrome page, moved or not", () => {
401
+ withServicesPath("/capabilities", (mod) => {
402
+ expect(mod.chromePageCandidates("/capabilities/ai/x", locales)).toEqual([
403
+ { type: "Service", slug: "x" },
404
+ ]);
405
+ });
406
+ withServicesPath(undefined, (mod) => {
407
+ expect(mod.chromePageCandidates("/services/ai/x", locales)).toEqual([
408
+ { type: "Service", slug: "x" },
409
+ ]);
410
+ });
411
+ });
412
+ });