create-ailk 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/README.md +86 -9
  2. package/dist/add-module-core.js +2 -1
  3. package/dist/add-module.d.ts +2 -1
  4. package/dist/add-module.js +9 -4
  5. package/dist/add-route.js +46 -19
  6. package/dist/body-heading.d.ts +56 -0
  7. package/dist/body-heading.js +394 -0
  8. package/dist/cli.js +28 -1
  9. package/dist/content-payloads.js +2 -2
  10. package/dist/derive-architecture.js +8 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +4 -1
  13. package/dist/lib/auth-secret-file.d.ts +6 -2
  14. package/dist/lib/auth-secret-file.js +46 -20
  15. package/dist/lib/env-catalog.js +20 -0
  16. package/dist/lib/site-modules.d.ts +8 -0
  17. package/dist/lib/site-modules.js +34 -0
  18. package/dist/module-architecture.js +25 -14
  19. package/dist/paid-sections.d.ts +17 -0
  20. package/dist/paid-sections.js +39 -0
  21. package/dist/programmatic.d.ts +16 -0
  22. package/dist/programmatic.js +73 -4
  23. package/dist/remove-module.d.ts +98 -0
  24. package/dist/remove-module.js +620 -0
  25. package/dist/seed-flows.d.ts +2 -2
  26. package/dist/seed-flows.js +16 -9
  27. package/dist/site-roster.js +4 -1
  28. package/dist/starter-grid.d.ts +26 -0
  29. package/dist/starter-grid.js +34 -0
  30. package/dist/surfaces.d.ts +26 -0
  31. package/dist/surfaces.js +112 -11
  32. package/dist/sync-routes.d.ts +7 -0
  33. package/dist/sync-routes.js +53 -6
  34. package/dist/template-pages.d.ts +36 -0
  35. package/dist/template-pages.js +129 -0
  36. package/dist/topology.d.ts +2 -1
  37. package/dist/topology.js +4 -2
  38. package/dist/update.d.ts +3 -1
  39. package/dist/update.js +90 -6
  40. package/package.json +3 -3
  41. package/release-scope.json +2 -1
  42. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  43. package/templates/.env.example +12 -2
  44. package/templates/CLAUDE.md +20 -20
  45. package/templates/README.md +19 -19
  46. package/templates/apps/api/.env.example +16 -5
  47. package/templates/apps/api/CLAUDE.md +32 -11
  48. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  49. package/templates/apps/api/src/vercel-handler.ts +8 -1
  50. package/templates/apps/api/vercel.json +7 -1
  51. package/templates/apps/mcp/CLAUDE.md +18 -10
  52. package/templates/apps/web/.env.example +27 -0
  53. package/templates/apps/web/CLAUDE.md +56 -8
  54. package/templates/apps/web/__tests__/setup.ts +7 -0
  55. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
  58. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  59. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
  60. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
  61. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  63. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  66. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  67. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  68. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  69. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  70. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  72. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  73. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  74. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  76. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  77. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  78. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  79. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  80. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  81. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  82. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  83. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  84. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  85. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  86. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  87. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  88. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  89. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  90. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  91. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  92. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  93. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  105. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  107. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  108. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  109. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  110. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  111. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  114. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  115. package/templates/apps/web/app/[locale]/layout.tsx +161 -128
  116. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  117. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  118. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  119. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  120. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  121. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  122. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  124. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  126. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  127. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  128. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  129. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  131. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  132. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  133. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  134. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  135. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  136. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  137. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  138. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  139. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  140. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  141. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  142. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  144. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  145. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  146. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  148. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  149. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  150. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  151. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  152. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  153. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  154. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  155. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  156. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  157. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
  160. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  161. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  162. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  163. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  164. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  165. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  166. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  167. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  168. package/templates/apps/web/auth.ts +102 -50
  169. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  170. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  171. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  172. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  173. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  174. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  175. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  176. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  177. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  178. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  179. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  180. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  181. package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
  182. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  183. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  184. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  185. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  187. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  188. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  189. package/templates/apps/web/lib/auth-secret.ts +57 -0
  190. package/templates/apps/web/lib/bot-check.ts +138 -0
  191. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  192. package/templates/apps/web/lib/deck-page.ts +6 -3
  193. package/templates/apps/web/lib/docs-index.ts +100 -0
  194. package/templates/apps/web/lib/docs-path.ts +94 -0
  195. package/templates/apps/web/lib/hub-children.tsx +318 -0
  196. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
  197. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
  198. package/templates/apps/web/lib/modules.generated.ts +9 -0
  199. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  200. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  201. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  202. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  203. package/templates/apps/web/lib/page-chrome.ts +367 -0
  204. package/templates/apps/web/lib/posting-title.ts +26 -4
  205. package/templates/apps/web/lib/pricing-page.ts +430 -0
  206. package/templates/apps/web/lib/relay.ts +44 -4
  207. package/templates/apps/web/lib/route-family.ts +69 -0
  208. package/templates/apps/web/lib/service-listing.ts +89 -0
  209. package/templates/apps/web/lib/services-path.ts +97 -0
  210. package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
  211. package/templates/apps/web/lib/site-header.ts +94 -0
  212. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  213. package/templates/apps/web/lib/template-listing.ts +169 -0
  214. package/templates/apps/web/messages/en.json +144 -3
  215. package/templates/apps/web/middleware.ts +179 -12
  216. package/templates/apps/web/next.config.mjs +220 -3
  217. package/templates/apps/web/package.json +3 -2
  218. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  219. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  220. package/templates/apps/web/sentry.server.config.ts +30 -2
  221. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  222. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  223. package/templates/apps/web/src/lib/routes.ts +15 -1
  224. package/versions.json +48 -48
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The route-family helper (#10051 D5, AC-6) — the generic pieces a content
3
+ * hub uses: list every detail page of a type, and slot a generated section in
4
+ * before the page's closing CTAs.
5
+ */
6
+ import { insertBeforeClosingCtas, listDetailPages } from "../route-family";
7
+
8
+ jest.mock("@working-theory/page-renderer", () => ({
9
+ getMdxSlugs: jest.fn(async () => ["c", "a", "gone", "b"]),
10
+ getMdxSource: jest.fn(),
11
+ }));
12
+
13
+ describe("listDetailPages", () => {
14
+ it("returns every loaded page sorted by slug, dropping a null load", async () => {
15
+ const load = jest.fn(async (_type: string, slug: string) =>
16
+ slug === "gone" ? null : { frontmatter: { title: slug.toUpperCase() } },
17
+ );
18
+ const pages = await listDetailPages("TemplatePage", "en", load as never);
19
+ expect(pages).toEqual([
20
+ { slug: "a", frontmatter: { title: "A" } },
21
+ { slug: "b", frontmatter: { title: "B" } },
22
+ { slug: "c", frontmatter: { title: "C" } },
23
+ ]);
24
+ expect(load).toHaveBeenCalledWith("TemplatePage", "a", "en");
25
+ });
26
+ });
27
+
28
+ describe("insertBeforeClosingCtas", () => {
29
+ const section = (type: string, id: string) => ({ type, id }) as never;
30
+
31
+ it("places the section before a trailing run of two cta sections", () => {
32
+ const result = insertBeforeClosingCtas(
33
+ [section("hero", "h"), section("cta", "c1"), section("cta", "c2")],
34
+ section("items", "new"),
35
+ );
36
+ expect(result.map((s: { id: string }) => s.id)).toEqual([
37
+ "h",
38
+ "new",
39
+ "c1",
40
+ "c2",
41
+ ]);
42
+ });
43
+
44
+ it("appends when the page does not end in a cta", () => {
45
+ const result = insertBeforeClosingCtas(
46
+ [section("cta", "c1"), section("hero", "h")],
47
+ section("items", "new"),
48
+ );
49
+ expect(result.map((s: { id: string }) => s.id)).toEqual(["c1", "h", "new"]);
50
+ });
51
+
52
+ it("appends to an empty page", () => {
53
+ expect(insertBeforeClosingCtas([], section("items", "new"))).toHaveLength(
54
+ 1,
55
+ );
56
+ });
57
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ // #10043 D8, AC-6 — the services listing: a service's public path, a
5
+ // category's list of its services and the hub's re-pointed links.
6
+ import { PageSectionsSchema } from "@working-theory/validation";
7
+
8
+ type Mod = typeof import("../service-listing");
9
+
10
+ function load(value: string | undefined): Mod {
11
+ const saved = process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
12
+ if (value === undefined) delete process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
13
+ else process.env.NEXT_PUBLIC_AILK_SERVICES_PATH = value;
14
+ let mod: Mod | undefined;
15
+ try {
16
+ jest.isolateModules(() => {
17
+ mod = require("../service-listing") as Mod;
18
+ });
19
+ } finally {
20
+ if (saved === undefined) delete process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
21
+ else process.env.NEXT_PUBLIC_AILK_SERVICES_PATH = saved;
22
+ }
23
+ return mod!;
24
+ }
25
+
26
+ const labels = {
27
+ childrenHeading: "Services in this area",
28
+ viewService: "View service",
29
+ };
30
+
31
+ const pages = [
32
+ {
33
+ slug: "a",
34
+ frontmatter: {
35
+ title: "A",
36
+ description: "A desc.",
37
+ summary: "A sum.",
38
+ parent: "ai",
39
+ },
40
+ },
41
+ { slug: "ai", frontmatter: { title: "AI", description: "AI desc." } },
42
+ {
43
+ slug: "b",
44
+ frontmatter: { title: "B", description: "B desc.", parent: "ai" },
45
+ },
46
+ {
47
+ slug: "c",
48
+ frontmatter: { title: "C", description: "C desc.", parent: "other" },
49
+ },
50
+ ];
51
+
52
+ describe("service-listing with services.path /capabilities", () => {
53
+ const mod = load("/capabilities");
54
+
55
+ it("builds the public path under the base and the category", () => {
56
+ expect(mod.servicePublicPath("x")).toBe("/capabilities/x");
57
+ expect(mod.servicePublicPath("x", "ai")).toBe("/capabilities/ai/x");
58
+ });
59
+
60
+ it("lists a category's services in slug order, summary else description", () => {
61
+ const children = mod.childServices(pages, "ai");
62
+ const section = mod.serviceChildrenSection(children, labels);
63
+ expect(section.data.headline).toBe("Services in this area");
64
+ expect(section.data.items).toEqual([
65
+ {
66
+ title: "A",
67
+ description: "A sum.",
68
+ link: { label: "View service", href: "/capabilities/ai/a" },
69
+ },
70
+ {
71
+ title: "B",
72
+ description: "B desc.",
73
+ link: { label: "View service", href: "/capabilities/ai/b" },
74
+ },
75
+ ]);
76
+ expect(PageSectionsSchema.safeParse([section]).success).toBe(true);
77
+ });
78
+
79
+ it("re-points a hub section link onto the base and leaves an absolute URL", () => {
80
+ const sections = [
81
+ {
82
+ type: "items",
83
+ id: "s",
84
+ data: {
85
+ items: [
86
+ { title: "X", link: { label: "Go", href: "/services/x" } },
87
+ {
88
+ title: "Y",
89
+ link: { label: "Go", href: "https://example.com/services" },
90
+ },
91
+ ],
92
+ },
93
+ },
94
+ ];
95
+ const mapped = mod.withPublicServicesSections(sections);
96
+ expect(mapped[0]?.data.items.map((i) => i.link.href)).toEqual([
97
+ "/capabilities/x",
98
+ "https://example.com/services",
99
+ ]);
100
+ });
101
+ });
102
+
103
+ describe("service-listing with no services.path", () => {
104
+ const mod = load(undefined);
105
+
106
+ it("returns the same sections array", () => {
107
+ const sections = [{ type: "items", id: "s", data: { items: [] } }];
108
+ expect(mod.withPublicServicesSections(sections)).toBe(sections);
109
+ expect(mod.servicePublicPath("x", "ai")).toBe("/services/ai/x");
110
+ });
111
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ // #10043 D5, AC-7 — the services-path mappers. `SERVICES_PATH` is read at
5
+ // module load, so each setting loads the module afresh.
6
+ import type { NavItem } from "@working-theory/ui";
7
+ import { Menu as Icon } from "lucide-react";
8
+
9
+ type Mod = typeof import("../services-path");
10
+
11
+ function load(value: string | undefined): Mod {
12
+ const saved = process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
13
+ if (value === undefined) delete process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
14
+ else process.env.NEXT_PUBLIC_AILK_SERVICES_PATH = value;
15
+ let mod: Mod | undefined;
16
+ try {
17
+ jest.isolateModules(() => {
18
+ mod = require("../services-path") as Mod;
19
+ });
20
+ } finally {
21
+ if (saved === undefined) delete process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
22
+ else process.env.NEXT_PUBLIC_AILK_SERVICES_PATH = saved;
23
+ }
24
+ return mod!;
25
+ }
26
+
27
+ describe("services-path with services.path /capabilities", () => {
28
+ const mod = load("/capabilities");
29
+
30
+ it.each([
31
+ ["/services", "/capabilities"],
32
+ ["/services/x", "/capabilities/x"],
33
+ ["/services?a=1", "/capabilities?a=1"],
34
+ ["/services#top", "/capabilities#top"],
35
+ ["/servicesx", "/servicesx"],
36
+ ["/docs", "/docs"],
37
+ ["https://example.com/services", "https://example.com/services"],
38
+ ])("toPublicServicesHref(%s) is %s", (href, expected) => {
39
+ expect(mod.toPublicServicesHref(href)).toBe(expected);
40
+ });
41
+
42
+ it.each([
43
+ ["/capabilities", "/services"],
44
+ ["/capabilities/ai/x", "/services/ai/x"],
45
+ ["/capabilitiesx", null],
46
+ ["/services/x", null],
47
+ ])("toInternalServicesPath(%s) is %p", (bare, expected) => {
48
+ expect(mod.toInternalServicesPath(bare)).toBe(expected);
49
+ });
50
+
51
+ it("maps a nav item's path, a flyout feature and a flyout action", () => {
52
+ const items: NavItem[] = [
53
+ { labelKey: "services", path: "/services" },
54
+ {
55
+ labelKey: "more",
56
+ flyout: {
57
+ features: [{ labelKey: "x", path: "/services/x" }],
58
+ actions: [{ labelKey: "all", path: "/services", icon: Icon }],
59
+ },
60
+ },
61
+ { labelKey: "docs", path: "/docs" },
62
+ { labelKey: "other", path: "/servicesx" },
63
+ ];
64
+ const mapped = mod.withPublicServicesPaths(items);
65
+ expect(mapped[0]?.path).toBe("/capabilities");
66
+ expect(mapped[1]?.flyout?.features[0]?.path).toBe("/capabilities/x");
67
+ expect(mapped[1]?.flyout?.actions[0]?.path).toBe("/capabilities");
68
+ expect(mapped[2]?.path).toBe("/docs");
69
+ expect(mapped[3]?.path).toBe("/servicesx");
70
+ });
71
+
72
+ it("maps a footer item", () => {
73
+ const columns = [
74
+ {
75
+ headingKey: "h",
76
+ items: [
77
+ { labelKey: "x", path: "/services/x" },
78
+ { labelKey: "d", path: "/docs" },
79
+ ],
80
+ },
81
+ ];
82
+ expect(
83
+ mod.withPublicServicesColumns(columns)[0]?.items.map((i) => i.path),
84
+ ).toEqual(["/capabilities/x", "/docs"]);
85
+ });
86
+ });
87
+
88
+ describe("services-path with no services.path", () => {
89
+ const mod = load(undefined);
90
+
91
+ it("is the identity", () => {
92
+ expect(mod.SERVICES_PATH).toBe("/services");
93
+ expect(mod.toPublicServicesHref("/services/x")).toBe("/services/x");
94
+ expect(mod.toInternalServicesPath("/services/x")).toBeNull();
95
+ });
96
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The site's header settings (#10046) — `readSiteHeader` over a fixture
3
+ * `_site.mdx` set through `AILK_CONTENT_ROOT` (AC-2), and the pure
4
+ * `signedOutHeaderActions` composition over the generated auth pair (AC-3).
5
+ */
6
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ import type * as SiteHeaderModule from "../site-header";
11
+
12
+ let root: string;
13
+ const previousRoot = process.env.AILK_CONTENT_ROOT;
14
+ let mod: typeof SiteHeaderModule;
15
+ let warn: jest.SpyInstance;
16
+
17
+ beforeEach(() => {
18
+ root = mkdtempSync(join(tmpdir(), "site-header-"));
19
+ process.env.AILK_CONTENT_ROOT = root;
20
+ // A fresh module per test so the warn-once flag starts clear.
21
+ jest.isolateModules(() => {
22
+ mod = require("../site-header") as typeof SiteHeaderModule;
23
+ });
24
+ warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
25
+ });
26
+
27
+ afterEach(() => {
28
+ warn.mockRestore();
29
+ rmSync(root, { recursive: true, force: true });
30
+ if (previousRoot === undefined) delete process.env.AILK_CONTENT_ROOT;
31
+ else process.env.AILK_CONTENT_ROOT = previousRoot;
32
+ });
33
+
34
+ function writeSite(frontmatter: string): void {
35
+ writeFileSync(join(root, "_site.mdx"), `---\n${frontmatter}\n---\n`);
36
+ }
37
+
38
+ describe("readSiteHeader (#10046 AC-2)", () => {
39
+ it("returns the parsed header for a valid one", () => {
40
+ writeSite(
41
+ [
42
+ "siteName: Acme",
43
+ "header:",
44
+ " cta:",
45
+ " label: Start here",
46
+ " href: /diagnostic",
47
+ " signIn: hidden",
48
+ ].join("\n"),
49
+ );
50
+ expect(mod.readSiteHeader()).toEqual({
51
+ cta: { label: "Start here", href: "/diagnostic" },
52
+ signIn: "hidden",
53
+ });
54
+ expect(warn).not.toHaveBeenCalled();
55
+ });
56
+
57
+ it("returns undefined silently when _site.mdx is absent", () => {
58
+ expect(mod.readSiteHeader()).toBeUndefined();
59
+ expect(warn).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it("returns undefined silently when _site.mdx has no header", () => {
63
+ writeSite("siteName: Acme");
64
+ expect(mod.readSiteHeader()).toBeUndefined();
65
+ expect(warn).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it("returns undefined and warns once, naming the field and never the value", () => {
69
+ writeSite(
70
+ ["siteName: Acme", "header:", " cta:", " label: Go", " href: javascript:x"].join("\n"),
71
+ );
72
+ expect(mod.readSiteHeader()).toBeUndefined();
73
+ expect(mod.readSiteHeader()).toBeUndefined();
74
+ expect(warn).toHaveBeenCalledTimes(1);
75
+ const message = String(warn.mock.calls[0]?.[0]);
76
+ expect(message).toContain("cta");
77
+ expect(message).not.toContain("javascript");
78
+ });
79
+
80
+ it("a bad header.style never drops the cta, and is not this reader's warning (#10049 AC-4)", () => {
81
+ writeSite(
82
+ [
83
+ "siteName: Acme",
84
+ "header:",
85
+ " style: minimal",
86
+ " cta:",
87
+ " label: Start here",
88
+ " href: /diagnostic",
89
+ ].join("\n"),
90
+ );
91
+ expect(mod.readSiteHeader()).toEqual({
92
+ cta: { label: "Start here", href: "/diagnostic" },
93
+ });
94
+ expect(warn).not.toHaveBeenCalled();
95
+ });
96
+ });
97
+
98
+ describe("signedOutHeaderActions (#10046 AC-3)", () => {
99
+ const cta = { label: "Start here", href: "/diagnostic" };
100
+ const hubPair = [
101
+ { labelKey: "signIn", path: "/sign-in", variant: "outline-strong" as const },
102
+ { labelKey: "signUp", path: "/sign-up", variant: "primary" as const },
103
+ ];
104
+ const ctaAction = { labelKey: "headerCta", path: "/diagnostic", variant: "primary" };
105
+
106
+ function frozenCopy<T>(value: T): T {
107
+ return JSON.parse(JSON.stringify(value)) as T;
108
+ }
109
+
110
+ it("with no header, or signIn without a cta, returns the pair unchanged", () => {
111
+ const before = frozenCopy(hubPair);
112
+ expect(mod.signedOutHeaderActions(undefined, hubPair)).toEqual(hubPair);
113
+ expect(mod.signedOutHeaderActions({ signIn: "hidden" }, hubPair)).toEqual(hubPair);
114
+ expect(hubPair).toEqual(before);
115
+ });
116
+
117
+ it("with a cta, returns a ghost Sign in then the cta, and drops Sign up", () => {
118
+ const before = frozenCopy(hubPair);
119
+ expect(mod.signedOutHeaderActions({ cta }, hubPair)).toEqual([
120
+ { labelKey: "signIn", path: "/sign-in", variant: "ghost" },
121
+ ctaAction,
122
+ ]);
123
+ expect(hubPair).toEqual(before);
124
+ });
125
+
126
+ it("with a cta and signIn hidden, returns only the cta", () => {
127
+ const before = frozenCopy(hubPair);
128
+ expect(mod.signedOutHeaderActions({ cta, signIn: "hidden" }, hubPair)).toEqual([ctaAction]);
129
+ expect(hubPair).toEqual(before);
130
+ });
131
+
132
+ it("on a hub spoke, restyles the /account Sign in link", () => {
133
+ const spokePair = [{ labelKey: "signIn", path: "/account", variant: "outline-strong" as const }];
134
+ const before = frozenCopy(spokePair);
135
+ expect(mod.signedOutHeaderActions({ cta }, spokePair)).toEqual([
136
+ { labelKey: "signIn", path: "/account", variant: "ghost" },
137
+ ctaAction,
138
+ ]);
139
+ expect(spokePair).toEqual(before);
140
+ });
141
+
142
+ it("on a spoke without sign-in, returns only the cta", () => {
143
+ const none: typeof hubPair = [];
144
+ expect(mod.signedOutHeaderActions({ cta }, none)).toEqual([ctaAction]);
145
+ expect(none).toEqual([]);
146
+ });
147
+
148
+ it("exports the label key the cta action carries", () => {
149
+ expect(mod.HEADER_CTA_LABEL_KEY).toBe("headerCta");
150
+ });
151
+ });
@@ -30,7 +30,17 @@
30
30
  * reads the same files with the same frontmatter schema the manifest is
31
31
  * compiled from. See docs/internal/decisions/scaffold-structured-data-check.md.
32
32
  */
33
- import { existsSync, readdirSync, readFileSync } from "node:fs";
33
+ import {
34
+ copyFileSync,
35
+ existsSync,
36
+ mkdirSync,
37
+ mkdtempSync,
38
+ readdirSync,
39
+ readFileSync,
40
+ rmSync,
41
+ writeFileSync,
42
+ } from "node:fs";
43
+ import { tmpdir } from "node:os";
34
44
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
35
45
 
36
46
  import { render } from "@testing-library/react";
@@ -61,6 +71,7 @@ import { parse as parseYaml } from "yaml";
61
71
 
62
72
  // Registers the site's `_site.mdx` identity at module load, as the `[locale]`
63
73
  // layout does in production (#6597).
74
+ import { MARKETING_PAGES } from "../modules.generated";
64
75
  import { readSiteIdentity } from "../site-identity";
65
76
 
66
77
  // Detail routes call notFound() on a missing page; a thrown error is how the
@@ -537,21 +548,51 @@ function routeIdentity(
537
548
  return null;
538
549
  }
539
550
 
551
+ /**
552
+ * The hub's own slug a `<dir>/[slug]` route must not render (#10044 D13):
553
+ * when `<dir>/page.tsx` renders a fixed `renderPage({ type, slug: "<literal>" })`
554
+ * of the SAME type as the dynamic route, that page is the hub's own, served at
555
+ * `<dir>` — `/pricing`'s `pricing.mdx`, never `/pricing/pricing`. Keyed on
556
+ * the parent directory, not the type alone: `launch-service` renders a
557
+ * `Service` that `/services/[slug]` must still render.
558
+ */
559
+ function hubOwnSlug(file: string, type: string): string | null {
560
+ if (basename(dirname(file)) !== "[slug]") return null;
561
+ const parent = join(dirname(dirname(file)), "page.tsx");
562
+ if (!existsSync(parent)) return null;
563
+ const identity = routeIdentity(readFileSync(parent, "utf8"));
564
+ return identity !== null && identity.type === type ? identity.slug : null;
565
+ }
566
+
540
567
  type PageModule = {
541
568
  default: (props: {
542
- params: Promise<{ locale: string; slug?: string; detail?: string }>;
569
+ params: Promise<{
570
+ locale: string;
571
+ slug?: string;
572
+ detail?: string;
573
+ child?: string;
574
+ }>;
543
575
  }) => Promise<ReactElement>;
544
576
  };
545
577
 
578
+ /** #10043 D7 — a Service under its category, `/services/<parent>/<slug>`. */
579
+ const NESTED_SERVICE_ROUTE = "services/[slug]/[child]";
580
+
546
581
  async function renderRoute(
547
582
  file: string,
548
583
  route: string,
549
584
  slug: string | undefined,
585
+ parent?: string,
550
586
  ): Promise<HTMLElement> {
551
587
  // Resolved at test time from the walk above — a static import would dangle
552
588
  // in a scaffold that gated the surface off.
553
589
  const mod = require(file) as PageModule;
554
- const params: { locale: string; slug?: string; detail?: string } = {
590
+ const params: {
591
+ locale: string;
592
+ slug?: string;
593
+ detail?: string;
594
+ child?: string;
595
+ } = {
555
596
  locale: DEFAULT_LOCALE,
556
597
  };
557
598
  // #6897 D2 — params are built from the route's OWN dynamic segments: the
@@ -561,6 +602,10 @@ async function renderRoute(
561
602
  if (route === "[slug]/[detail]") {
562
603
  params.slug = MEET_PLACEHOLDER_HUB;
563
604
  if (slug !== undefined) params.detail = slug;
605
+ } else if (route === NESTED_SERVICE_ROUTE) {
606
+ // #10043 D7 — the category rides `slug`, the service `child`.
607
+ if (parent !== undefined) params.slug = parent;
608
+ if (slug !== undefined) params.child = slug;
564
609
  } else if (slug !== undefined) {
565
610
  params.slug = slug;
566
611
  }
@@ -578,6 +623,14 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
578
623
  it.each(routes)("$route", async ({ route, file }) => {
579
624
  const label = `/${DEFAULT_LOCALE}${route === "" ? "" : "/" + route}`;
580
625
  const src = readFileSync(file, "utf8");
626
+ // #10040 decision 1 — without the marketing module `/` redirects to
627
+ // /account before any content: no HomePage seed, no JSON-LD, and its
628
+ // page.tsx still names renderPage, so it is skipped here rather than
629
+ // listed in NO_STRUCTURED_DATA_ROUTES (which refuses a renderPage route).
630
+ if (route === "" && !MARKETING_PAGES) {
631
+ console.info(`structured-data: ${label} not checked — no marketing module, / redirects to /account (#10040)`);
632
+ return;
633
+ }
581
634
  const exempt = exemption(route, src);
582
635
  const identity = routeIdentity(src);
583
636
  if (exempt !== undefined) {
@@ -608,6 +661,8 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
608
661
  // site, not a route with nothing to check. A `[slug]` route with no
609
662
  // pages of its type is simply empty — say so, do not fail.
610
663
  let slugs: string[];
664
+ // #10043 D7 — a Service's category, for the nested route's params.
665
+ const parents = new Map<string, string>();
611
666
  if (identity.slug !== null) {
612
667
  const page = await adapter.getPageSource(
613
668
  type,
@@ -621,9 +676,22 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
621
676
  }
622
677
  slugs = [identity.slug];
623
678
  } else {
624
- slugs = (await adapter.listDetail(type, DEFAULT_LOCALE)).map(
625
- (p: ContentPage) => p.slug,
679
+ // The hub's own page shares this route's type but is served at the hub
680
+ // (#10044 D13), so it is never rendered here.
681
+ const hubSlug = hubOwnSlug(file, type);
682
+ const pages = (await adapter.listDetail(type, DEFAULT_LOCALE)).filter(
683
+ (p: ContentPage) => p.slug !== hubSlug,
626
684
  );
685
+ for (const p of pages) if (p.parent !== undefined) parents.set(p.slug, p.parent);
686
+ // #10043 D7 — a Service with a `parent` is served at the nested route
687
+ // only (its flat URL redirects there); every other one at `[slug]`.
688
+ const served =
689
+ route === NESTED_SERVICE_ROUTE
690
+ ? pages.filter((p: ContentPage) => p.parent !== undefined)
691
+ : route === "services/[slug]"
692
+ ? pages.filter((p: ContentPage) => p.parent === undefined)
693
+ : pages;
694
+ slugs = served.map((p: ContentPage) => p.slug);
627
695
  if (slugs.length === 0) {
628
696
  console.info(
629
697
  `structured-data: ${label} has no ${type} content in ${DEFAULT_LOCALE}; every slug serves 404 in production — nothing to check`,
@@ -638,13 +706,16 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
638
706
  file,
639
707
  route,
640
708
  identity.slug === null ? slug : undefined,
709
+ parents.get(slug),
641
710
  );
642
711
  const blocks = extractJsonLdBlocks(container);
643
712
  const pageLabel =
644
713
  identity.slug === null
645
714
  ? route === "[slug]/[detail]"
646
715
  ? `/${DEFAULT_LOCALE}/${MEET_PLACEHOLDER_HUB}/${slug}`
647
- : `${label.replace(/\[slug\]$/, "")}${slug}`
716
+ : route === NESTED_SERVICE_ROUTE
717
+ ? `/${DEFAULT_LOCALE}/services/${parents.get(slug)}/${slug}`
718
+ : `${label.replace(/\[slug\]$/, "")}${slug}`
648
719
  : label;
649
720
  if (blocks.length === 0) {
650
721
  failures.push(
@@ -661,6 +732,77 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
661
732
  });
662
733
  });
663
734
 
735
+ // ── 1b. A hub whose own page shares its detail type (#10044 D13) ───────────
736
+
737
+ describe("structured data — a hub's own page is not rendered at its detail route (#10044 AC-6)", () => {
738
+ const dynamicRoutes = walkRoutes(ROUTE_ROOT).flatMap(({ route, file }) => {
739
+ const identity = routeIdentity(readFileSync(file, "utf8"));
740
+ return identity !== null && identity.slug === null
741
+ ? [{ route, file, type: identity.type }]
742
+ : [];
743
+ });
744
+
745
+ it("drops exactly Pricing/pricing, and only when the pricing route family is present", () => {
746
+ const dropped = dynamicRoutes.flatMap(({ file, type }) => {
747
+ const slug = hubOwnSlug(file, type);
748
+ return slug === null ? [] : [`${type}/${slug}`];
749
+ });
750
+ const hasPricingFamily = dynamicRoutes.some(
751
+ ({ route }) => route === "pricing/[slug]",
752
+ );
753
+ expect(dropped).toEqual(hasPricingFamily ? ["Pricing/pricing"] : []);
754
+ });
755
+
756
+ // Only where the route family ships (a scaffold may gate /pricing off).
757
+ const pricingDetail = dynamicRoutes.find(
758
+ ({ route }) => route === "pricing/[slug]",
759
+ );
760
+ (pricingDetail === undefined ? it.skip : it)(
761
+ "renders and validates an offering pricing page, never the hub's own file",
762
+ async () => {
763
+ const root = mkdtempSync(join(tmpdir(), "ailk-10044-sd-"));
764
+ const prior = process.env.AILK_CONTENT_ROOT;
765
+ try {
766
+ mkdirSync(join(root, DEFAULT_LOCALE, "Pricing"), { recursive: true });
767
+ copyFileSync(join(CONTENT_ROOT, "_site.mdx"), join(root, "_site.mdx"));
768
+ writeFileSync(
769
+ join(root, DEFAULT_LOCALE, "Pricing", "pricing.mdx"),
770
+ '---\ntype: "Pricing"\nslug: "pricing"\ntitle: "Pricing"\ndescription: "Plans."\n---\n',
771
+ );
772
+ writeFileSync(
773
+ join(root, DEFAULT_LOCALE, "Pricing", "website-rebuild.mdx"),
774
+ '---\ntype: "Pricing"\nslug: "website-rebuild"\ntitle: "Website Rebuild"\ndescription: "What a rebuild costs."\nplans: ["ss-website-rebuild"]\n---\n',
775
+ );
776
+ process.env.AILK_CONTENT_ROOT = root;
777
+ const fixtureAdapter = createMdxAdapter({
778
+ contentRoot: root,
779
+ defaultLocale: DEFAULT_LOCALE,
780
+ });
781
+ const hubSlug = hubOwnSlug(pricingDetail!.file, "Pricing");
782
+ const slugs = (await fixtureAdapter.listDetail("Pricing", DEFAULT_LOCALE))
783
+ .map((p: ContentPage) => p.slug)
784
+ .filter((slug: string) => slug !== hubSlug);
785
+ expect(slugs).toEqual(["website-rebuild"]);
786
+
787
+ const label = `/${DEFAULT_LOCALE}/pricing/website-rebuild`;
788
+ const container = await renderRoute(
789
+ pricingDetail!.file,
790
+ "pricing/[slug]",
791
+ "website-rebuild",
792
+ );
793
+ const blocks = extractJsonLdBlocks(container);
794
+ expect(blocks.length).toBeGreaterThan(0);
795
+ expect(validateFor(blocks, "Pricing", label)).toBeNull();
796
+ expect(identityFailure(blocks, label)).toBeNull();
797
+ } finally {
798
+ if (prior === undefined) delete process.env.AILK_CONTENT_ROOT;
799
+ else process.env.AILK_CONTENT_ROOT = prior;
800
+ rmSync(root, { recursive: true, force: true });
801
+ }
802
+ },
803
+ );
804
+ });
805
+
664
806
  // ── 2a. exemption(route, src) — the generated-checkout routes (#8513) ──────
665
807
  //
666
808
  // Proof that the two conditional entries above key on the FILE, not the