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
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Hub child cards (#10121) — the /alternatives (#6878 D9) and /templates
3
+ * (#10051 D7) shape, shared by the content hubs that list one child page
4
+ * type each (/for, /case-studies, /team, …).
5
+ *
6
+ * The hub adds a generated card per child page of its type that the hub's
7
+ * own sections do not already link: into the authored card grid that already
8
+ * lists its children when there is one, else as one new `items` section
9
+ * before any trailing closing CTA (#7745 D4). `renderPage` builds the CollectionPage
10
+ * ItemList JSON-LD from the same sections, so the visible cards and the
11
+ * ItemList always list the same pages. A child the hub already links keeps
12
+ * its hand-written card and gets no second one, so a hub whose MDX already
13
+ * links every child renders exactly as before.
14
+ *
15
+ * A prose hub (`bodyMode: "mdx-body"`) never reaches renderPage's
16
+ * SectionRenderer, so its generated section renders after the MDX body
17
+ * through renderPage's `trailing` slot.
18
+ *
19
+ * Base tree: no paid import, no `oss: false`.
20
+ */
21
+ import type {
22
+ getMdxSlugs as defaultGetMdxSlugs,
23
+ getMdxSource as defaultGetMdxSource,
24
+ getPageSections as defaultGetPageSections,
25
+ renderPage,
26
+ SchemaOrgType,
27
+ } from "@working-theory/page-renderer";
28
+ import {
29
+ getMdxSlugs as loadMdxSlugs,
30
+ getMdxSource as loadMdxSource,
31
+ getPageSections as loadPageSections,
32
+ } from "@working-theory/page-renderer";
33
+ import { SectionRenderer } from "@working-theory/ui/sections";
34
+
35
+ import {
36
+ type DetailPage,
37
+ insertBeforeClosingCtas,
38
+ listDetailPages,
39
+ } from "./route-family";
40
+
41
+ type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
42
+
43
+ /** One hub: its list page, its child type, and how its body renders. */
44
+ export interface HubConfig {
45
+ /** The list page type, e.g. `UseCasesList`. */
46
+ listType: SchemaOrgType;
47
+ /** The hub's slug, translations namespace and route segment, e.g. `for`. */
48
+ slug: string;
49
+ /** The child page type, e.g. `UseCase`. */
50
+ childType: SchemaOrgType;
51
+ /** The card link label. */
52
+ linkLabel: string;
53
+ /** The generated section's id. */
54
+ sectionId: string;
55
+ /**
56
+ * The hub's existing body rule: `sections` always, `mdx-body` always, or
57
+ * `composed` — sections when the frontmatter carries `sections[]`, the MDX
58
+ * body otherwise.
59
+ */
60
+ body: "sections" | "mdx-body" | "composed";
61
+ /** Which child pages the hub lists (default: all). */
62
+ includeChild?: (page: DetailPage) => boolean;
63
+ /**
64
+ * One listed page's card (default: title, description and a link to
65
+ * `/<slug>/<child>` labelled `linkLabel`).
66
+ */
67
+ card?: (page: DetailPage) => HubCard;
68
+ /** A last pass over the hub's final sections (default: none). */
69
+ mapSections?: (sections: Section[]) => Section[];
70
+ }
71
+
72
+ /** One generated card. */
73
+ export interface HubCard {
74
+ title: string;
75
+ description?: string | undefined;
76
+ link?: { label: string; href: string } | undefined;
77
+ }
78
+
79
+ /** A hub route's injectable loaders (tests pass their own). */
80
+ export interface HubLoaders {
81
+ getMdxSource?: typeof defaultGetMdxSource;
82
+ getPageSections?: typeof defaultGetPageSections;
83
+ getMdxSlugs?: typeof defaultGetMdxSlugs;
84
+ }
85
+
86
+ /**
87
+ * A link's path for matching: an absolute http(s) URL's pathname, the query
88
+ * and hash dropped, a leading `/` ensured and trailing `/`s trimmed.
89
+ */
90
+ function linkPath(href: string): string | null {
91
+ let path = href.trim();
92
+ if (/^https?:\/\//i.test(path)) {
93
+ try {
94
+ path = new URL(path).pathname;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+ path = path.split(/[?#]/)[0] ?? "";
100
+ if (!path.startsWith("/")) path = `/${path}`;
101
+ let end = path.length;
102
+ while (end > 0 && path[end - 1] === "/") end -= 1;
103
+ return path.slice(0, end);
104
+ }
105
+
106
+ /** A BCP-47-shaped first path segment: `en`, `pt-BR`, `zh-Hant`. */
107
+ const LOCALE_SEGMENT = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
108
+
109
+ /** The path without one leading locale segment (`/en/for/a` → `/for/a`). */
110
+ function withoutLocale(path: string): string {
111
+ const end = path.indexOf("/", 1);
112
+ if (end === -1) return path;
113
+ return LOCALE_SEGMENT.test(path.slice(1, end)) ? path.slice(end) : path;
114
+ }
115
+
116
+ /** Whether a link path names `target`, with or without a locale prefix. */
117
+ function linksTo(path: string, target: string): boolean {
118
+ return path === target || withoutLocale(path) === target;
119
+ }
120
+
121
+ /**
122
+ * Every link path an MDX body carries: markdown link targets and `href`
123
+ * attributes, the forms a prose hub links its pages with.
124
+ */
125
+ export function bodyLinkPaths(body: string): string[] {
126
+ const hrefs = [
127
+ ...body.matchAll(/\]\(\s*<?([^)\s>]+)/g),
128
+ ...body.matchAll(/\bhref\s*=\s*\{?\s*["']([^"']+)["']/g),
129
+ ].map((match) => match[1] ?? "");
130
+ return hrefs
131
+ .map(linkPath)
132
+ .filter((path): path is string => path !== null);
133
+ }
134
+
135
+ /**
136
+ * Every link path the sections carry: any `href` or `url` string at any
137
+ * depth, so an `items` card link, a `team` member's `url` and a CTA all count.
138
+ */
139
+ export function linkedPaths(sections: readonly unknown[]): string[] {
140
+ const paths: string[] = [];
141
+ const walk = (value: unknown): void => {
142
+ if (Array.isArray(value)) {
143
+ value.forEach(walk);
144
+ return;
145
+ }
146
+ if (value === null || typeof value !== "object") return;
147
+ for (const [key, child] of Object.entries(value)) {
148
+ if ((key === "href" || key === "url") && typeof child === "string") {
149
+ const path = linkPath(child);
150
+ if (path !== null) paths.push(path);
151
+ } else {
152
+ walk(child);
153
+ }
154
+ }
155
+ };
156
+ walk(sections);
157
+ return paths;
158
+ }
159
+
160
+ /**
161
+ * The generated section: one card per child page the hub's rendered content
162
+ * does not already link (`linked`, from `linkedPaths` or `bodyLinkPaths`; a
163
+ * locale prefix or an absolute URL still counts as a link), or `null` when
164
+ * there is none.
165
+ */
166
+ export function childCardsSection(
167
+ linked: readonly string[],
168
+ pages: readonly DetailPage[],
169
+ config: Pick<HubConfig, "slug" | "linkLabel" | "sectionId" | "card">,
170
+ ): Section | null {
171
+ // A child counts as linked at its route path or at its card's own href (a
172
+ // hub whose public base differs from its route, #10043).
173
+ const items = pages.flatMap((page) => {
174
+ const card = config.card?.(page) ?? defaultCard(page, config);
175
+ const targets = [`/${config.slug}/${page.slug}`];
176
+ if (card.link !== undefined) targets.push(card.link.href);
177
+ const linkedAlready = linked.some((path) =>
178
+ targets.some((t) => linksTo(path, t)),
179
+ );
180
+ return linkedAlready ? [] : [card];
181
+ });
182
+ if (items.length === 0) return null;
183
+ return { type: "items", id: config.sectionId, data: { items } } as Section;
184
+ }
185
+
186
+ function defaultCard(
187
+ { slug, frontmatter }: DetailPage,
188
+ config: Pick<HubConfig, "slug" | "linkLabel">,
189
+ ): HubCard {
190
+ const fm = frontmatter as { title?: string; description?: string };
191
+ return {
192
+ title: fm.title ?? slug,
193
+ description: fm.description ?? "",
194
+ link: { label: config.linkLabel, href: `/${config.slug}/${slug}` },
195
+ };
196
+ }
197
+
198
+ type CardItem = { link?: { href?: string; variant?: string } };
199
+
200
+ /**
201
+ * Place the generated cards. When an authored `items` section already lists
202
+ * children of this hub (a card linking `/<hub>/…`), the cards continue that
203
+ * grid, taking its card link variant, so the hub shows one list. Otherwise
204
+ * the generated section goes before the closing CTAs (#7745 D4).
205
+ */
206
+ export function placeChildCards(
207
+ declared: Section[],
208
+ generated: Section,
209
+ hubSlug: string,
210
+ ): Section[] {
211
+ const prefix = `/${hubSlug}/`;
212
+ const listsChildren = (section: Section): boolean =>
213
+ section.type === "items" &&
214
+ ((section.data as { items?: CardItem[] }).items ?? []).some((item) => {
215
+ const href = item.link?.href;
216
+ const path = href === undefined ? null : linkPath(href);
217
+ // Raw first: a short hub slug (`for`) itself looks like a locale.
218
+ return (
219
+ path !== null &&
220
+ (path.startsWith(prefix) || withoutLocale(path).startsWith(prefix))
221
+ );
222
+ });
223
+ let at = declared.length - 1;
224
+ while (at >= 0 && !listsChildren(declared[at] as Section)) at -= 1;
225
+ if (at === -1) return insertBeforeClosingCtas(declared, generated);
226
+
227
+ const host = declared[at] as Section & { data: { items: CardItem[] } };
228
+ const variant = host.data.items.find((item) => item.link?.variant)?.link
229
+ ?.variant;
230
+ const added = (generated.data as { items: CardItem[] }).items.map((item) =>
231
+ variant === undefined ? item : { ...item, link: { ...item.link, variant } },
232
+ );
233
+ const merged = {
234
+ ...host,
235
+ data: { ...host.data, items: [...host.data.items, ...added] },
236
+ } as Section;
237
+ return [...declared.slice(0, at), merged, ...declared.slice(at + 1)];
238
+ }
239
+
240
+ function hasSections(frontmatter: unknown): boolean {
241
+ const sections = (frontmatter as { sections?: unknown }).sections;
242
+ return Array.isArray(sections) && sections.length > 0;
243
+ }
244
+
245
+ type RenderPageInput = Parameters<typeof renderPage>[0];
246
+
247
+ /**
248
+ * Everything a hub's `renderPage` call needs beyond its literal `type` and
249
+ * `slug`, which stay written out in each page.tsx where the content-render
250
+ * lint reads them.
251
+ */
252
+ export async function hubPageInput(
253
+ config: HubConfig,
254
+ locale: string,
255
+ loaders: HubLoaders,
256
+ ): Promise<Omit<RenderPageInput, "type" | "slug">> {
257
+ const load = loaders.getMdxSource ?? loadMdxSource;
258
+ const base = {
259
+ locale,
260
+ translationsNamespace: config.slug,
261
+ ...(loaders.getMdxSource !== undefined && {
262
+ getMdxSource: loaders.getMdxSource,
263
+ }),
264
+ };
265
+
266
+ const source = await load(config.listType, config.slug, locale);
267
+ const mdxBody =
268
+ config.body === "mdx-body" ||
269
+ (config.body === "composed" &&
270
+ (source === null || !hasSections(source.frontmatter)));
271
+ if (source === null) {
272
+ // The hub's existing empty-state rule, unchanged.
273
+ return {
274
+ ...base,
275
+ ...(mdxBody && { bodyMode: "mdx-body" as const }),
276
+ ...(loaders.getPageSections !== undefined && {
277
+ getPageSections: loaders.getPageSections,
278
+ }),
279
+ };
280
+ }
281
+
282
+ const declared = await (loaders.getPageSections ?? loadPageSections)(
283
+ source.frontmatter,
284
+ );
285
+ const pages = await listDetailPages(
286
+ config.childType,
287
+ locale,
288
+ load,
289
+ loaders.getMdxSlugs ?? loadMdxSlugs,
290
+ );
291
+ // A prose hub renders its MDX body, never its declared sections, so its
292
+ // existing links are read from the body and its cards stand alone after it.
293
+ const linked = mdxBody
294
+ ? bodyLinkPaths(source.body)
295
+ : linkedPaths(declared);
296
+ const generated = childCardsSection(
297
+ linked,
298
+ config.includeChild ? pages.filter(config.includeChild) : pages,
299
+ config,
300
+ );
301
+ const placed =
302
+ generated === null
303
+ ? declared
304
+ : mdxBody
305
+ ? insertBeforeClosingCtas(declared, generated)
306
+ : placeChildCards(declared, generated, config.slug);
307
+ const sections = config.mapSections ? config.mapSections(placed) : placed;
308
+
309
+ return {
310
+ ...base,
311
+ getPageSections: async () => sections,
312
+ ...(mdxBody && { bodyMode: "mdx-body" as const }),
313
+ ...(mdxBody &&
314
+ generated !== null && {
315
+ trailing: <SectionRenderer sections={[generated]} locale={locale} />,
316
+ }),
317
+ };
318
+ }
@@ -7,3 +7,12 @@
7
7
 
8
8
  /** The scheduling module's booking pages (/meet, /private) exist. */
9
9
  export const SCHEDULING_PAGES: boolean = false;
10
+
11
+ /** The marketing module's public pages exist (#10040): false on a hub. */
12
+ export const MARKETING_PAGES: boolean = true;
13
+
14
+ /** The docs-blog module's /docs and /blog pages exist (#10040). */
15
+ export const DOCS_BLOG_PAGES: boolean = false;
16
+
17
+ /** Any public page exists: robots, sitemap, llms.txt and the noindex header read this (#10040 D6/D7). */
18
+ export const PUBLIC_PAGES: boolean = true;
@@ -0,0 +1,61 @@
1
+ // nav-label-check.ts — find nav keys a locale's messages file does not carry
2
+ // (#10045 D2). Run before `next build` by `scripts/check-nav-labels.ts`.
3
+ //
4
+ // A locale's catalog is `messages/<locale>.json` plus the site overlay
5
+ // `messages/site/<locale>.json` (falling back to `messages/site/en.json`),
6
+ // matching how `i18n/request.ts` loads them, so a key the site adds in its
7
+ // overlay counts as present.
8
+
9
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { navMessageKeys } from "./nav-label-keys";
13
+
14
+ export interface MissingNavLabel {
15
+ key: string;
16
+ /** The `messages/<locale>.json` file the key is missing from. */
17
+ file: string;
18
+ }
19
+
20
+ function readJson(file: string): unknown {
21
+ return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : undefined;
22
+ }
23
+
24
+ /** Every (key, messages file) pair where a used nav key has no text. */
25
+ export function findMissingNavLabels(
26
+ messagesDir: string,
27
+ keys: Iterable<string>,
28
+ ): MissingNavLabel[] {
29
+ const wanted = [...new Set(keys)].sort();
30
+ const englishOverlay = join(messagesDir, "site", "en.json");
31
+ const missing: MissingNavLabel[] = [];
32
+ const catalogs = readdirSync(messagesDir, { withFileTypes: true })
33
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
34
+ .map((entry) => entry.name)
35
+ .sort();
36
+ for (const name of catalogs) {
37
+ const file = join(messagesDir, name);
38
+ const ownOverlay = join(messagesDir, "site", name);
39
+ const overlay = existsSync(ownOverlay) ? ownOverlay : englishOverlay;
40
+ const present = new Set([
41
+ ...navMessageKeys(readJson(file)),
42
+ ...navMessageKeys(readJson(overlay)),
43
+ ]);
44
+ for (const key of wanted) {
45
+ if (!present.has(key)) missing.push({ key, file });
46
+ }
47
+ }
48
+ return missing;
49
+ }
50
+
51
+ /** One line per missing key, naming the key and the file. */
52
+ export function formatMissingNavLabels(
53
+ missing: readonly MissingNavLabel[],
54
+ ): string {
55
+ return missing
56
+ .map(
57
+ ({ key, file }) =>
58
+ `nav key "${key}" is used by the site nav but missing from ${file} (namespace "nav")`,
59
+ )
60
+ .join("\n");
61
+ }
@@ -0,0 +1,78 @@
1
+ // nav-label-keys.ts — the set of `nav` message keys the site chrome resolves
2
+ // (#10045).
3
+ //
4
+ // The header and footer render translated strings, but `nav-config.ts`
5
+ // carries only message keys. Every key is looked up in the `nav` namespace by
6
+ // name — there is no hand-written list of allowed keys — so adding a page to
7
+ // the nav is one entry in `nav-config.ts` plus its text in
8
+ // `messages/<locale>.json` (or the site overlay, `messages/site/<locale>.json`).
9
+ // `scripts/check-nav-labels.ts` fails the build when a key used here is
10
+ // missing from a locale's catalog.
11
+
12
+ import { footerColumns, navItems } from "./nav-config";
13
+
14
+ /**
15
+ * Every field on a nav-config entry that names a `nav` message key:
16
+ * `labelKey` (an item or action), `headingKey` (a flyout group or footer
17
+ * column) and `descKey` (a flyout feature's description). These are the only
18
+ * three key-bearing fields in `@working-theory/ui`'s chrome types.
19
+ */
20
+ const KEY_FIELDS: ReadonlySet<string> = new Set([
21
+ "labelKey",
22
+ "headingKey",
23
+ "descKey",
24
+ ]);
25
+
26
+ /**
27
+ * The `nav` keys the layout itself resolves by name, beside the ones
28
+ * `nav-config.ts` declares: the session actions, the centred footer's FAQ
29
+ * link and the menu controls.
30
+ */
31
+ export const LAYOUT_NAV_KEYS: readonly string[] = [
32
+ "account",
33
+ "signIn",
34
+ "signOut",
35
+ "signUp",
36
+ "faq",
37
+ "openMenu",
38
+ "closeMenu",
39
+ "moreLinks",
40
+ ];
41
+
42
+ /** Every key-bearing field value in the given nav configs, at any depth. */
43
+ export function collectNavLabelKeys(
44
+ ...configs: readonly unknown[]
45
+ ): Set<string> {
46
+ const keys = new Set<string>();
47
+ const walk = (value: unknown): void => {
48
+ if (Array.isArray(value)) {
49
+ for (const entry of value) walk(entry);
50
+ return;
51
+ }
52
+ if (typeof value !== "object" || value === null) return;
53
+ for (const [field, child] of Object.entries(value)) {
54
+ if (KEY_FIELDS.has(field)) {
55
+ if (typeof child === "string") keys.add(child);
56
+ } else {
57
+ walk(child);
58
+ }
59
+ }
60
+ };
61
+ for (const config of configs) walk(config);
62
+ return keys;
63
+ }
64
+
65
+ /** The string keys of a message catalog's `nav` namespace. */
66
+ export function navMessageKeys(catalog: unknown): string[] {
67
+ if (typeof catalog !== "object" || catalog === null) return [];
68
+ const nav = (catalog as Record<string, unknown>)["nav"];
69
+ if (typeof nav !== "object" || nav === null) return [];
70
+ return Object.entries(nav)
71
+ .filter(([, value]) => typeof value === "string")
72
+ .map(([key]) => key);
73
+ }
74
+
75
+ /** Every `nav` key the site chrome uses: `nav-config.ts`'s keys and the layout's own. */
76
+ export function usedNavLabelKeys(): Set<string> {
77
+ return new Set([...collectNavLabelKeys(navItems, footerColumns), ...LAYOUT_NAV_KEYS]);
78
+ }
@@ -32,6 +32,11 @@
32
32
  * is success; a 503 `lead_capture_not_configured` or an unreachable API
33
33
  * (status 0, 502, 504) is `unavailable`. Otherwise (a Free build has no flow
34
34
  * route) it posts `leads.subscribe`, as before.
35
+ *
36
+ * The bot check (#9255 D4): each write path runs `runBotCheck` after its
37
+ * consent refusal and immediately before its hub call, so one signup is
38
+ * checked exactly once. A bot gets the generic error and a check that cannot
39
+ * decide `unavailable`; neither is sent.
35
40
  */
36
41
 
37
42
  import { flows, leads, visitorForwardHeaders } from "@working-theory/api-client";
@@ -41,8 +46,9 @@ import { cookies, headers } from "next/headers";
41
46
  import { getLocale } from "next-intl/server";
42
47
  import { z } from "zod";
43
48
 
44
- import { checkRateLimit, clientIpFromHeaders } from "../src/lib/rate-limit";
49
+ import { clientIpFromHeaders, takeVisitorLimit } from "../src/lib/rate-limit";
45
50
 
51
+ import { runBotCheck } from "./bot-check";
46
52
  import { consentVisitorHeader, readConsentVisitor } from "./consent-visitor";
47
53
  import { inPageView, readServedMagnet } from "./in-page-magnet";
48
54
 
@@ -54,6 +60,8 @@ const RATE_LIMITED = "Too many signups from here. Please try again in a few minu
54
60
  // spend the bucket every visitor shares.
55
61
  const RATE_LIMIT_MAX_CALLS = 30;
56
62
  const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
63
+ /** Outside a request scope there is no address: the shared bucket. */
64
+ const NO_HEADERS = { get: () => null };
57
65
 
58
66
  /** The form's one field; zod's default message is the field error shown. */
59
67
  const newsletterFormSchema = z.object({ email: z.string().email() });
@@ -73,6 +81,18 @@ function isApiUnreachable(res: { status: number }): boolean {
73
81
  return res.status === 0 || res.status === 502 || res.status === 504;
74
82
  }
75
83
 
84
+ /** #9255 D4 — the spoke edge's bot check; `null` to go on to the write. */
85
+ async function botRefusal(): Promise<LeadCaptureFormState | null> {
86
+ const botCheck = await runBotCheck({
87
+ source: "bot-check",
88
+ target: "subscribeNewsletterAction",
89
+ scope: "write",
90
+ });
91
+ if (botCheck === "bot") return { kind: "error", message: GENERIC_ERROR };
92
+ if (botCheck === "unavailable") return { kind: "unavailable" };
93
+ return null;
94
+ }
95
+
76
96
  /**
77
97
  * The signup through the served newsletter magnet, or `null` when it does not
78
98
  * map (absent, draft, a Free build with no flow route, or not a one-field
@@ -102,6 +122,8 @@ async function submitThroughMagnet(
102
122
  for (const purpose of consent.purposes) values[`${consent.field}.${purpose}`] = true;
103
123
  }
104
124
  const payload = { stepKey: view.stepKey, values, locale };
125
+ const refused = await botRefusal();
126
+ if (refused !== null) return refused;
105
127
  try {
106
128
  const res = init
107
129
  ? await flows.submitStep(NEWSLETTER_FLOW_SLUG, payload, init)
@@ -142,15 +164,15 @@ export async function subscribeNewsletterAction(
142
164
  // No request scope to read — no forwarding, and the visitor is "unknown".
143
165
  }
144
166
 
145
- // No readable address: skip the per-visitor limit rather than put every
146
- // visitor in one shared bucket (the contact action's rule).
147
- const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
167
+ // No readable address: the shared, wider bucket, never a skip (#9967 D1,
168
+ // the contact action's rule).
148
169
  if (
149
- visitor !== "unknown" &&
150
- !checkRateLimit(`newsletter:${visitor}`, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
170
+ !takeVisitorLimit("newsletter", requestHeaders ?? NO_HEADERS, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
171
+ .allowed
151
172
  ) {
152
173
  return { kind: "error", message: RATE_LIMITED };
153
174
  }
175
+ const visitor = clientIpFromHeaders(requestHeaders ?? NO_HEADERS);
154
176
 
155
177
  let init: { headers: Record<string, string> } | undefined;
156
178
  if (requestHeaders) {
@@ -179,6 +201,9 @@ export async function subscribeNewsletterAction(
179
201
  // The Free path renders the seeded config, whose consent box gates the post.
180
202
  if (!consented) return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
181
203
 
204
+ const refused = await botRefusal();
205
+ if (refused !== null) return refused;
206
+
182
207
  const siteId = process.env.AILK_SITE_ID ?? "default";
183
208
  const body = { siteId, source: "newsletter", email: parsed.data.email };
184
209
  try {