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,89 @@
1
+ /**
2
+ * The services listing (#10043 D8) — a service's public URL under the site's
3
+ * services base and its category, the list a category page shows of its
4
+ * services, and the hub's own section links re-pointed onto the base. Pure,
5
+ * no I/O: the routes load the pages and the translated labels and pass them
6
+ * in, so no user-facing copy lives here (customer-facing-i18n, ADR 0019).
7
+ *
8
+ * Base tree: no paid import, no `oss: false`.
9
+ */
10
+ import { pagePath } from "@working-theory/templates/routes";
11
+ import type { PageSection } from "@working-theory/validation";
12
+
13
+ import type { DetailPage } from "./route-family";
14
+ import {
15
+ SERVICES_PATH,
16
+ SERVICES_ROUTE,
17
+ toPublicServicesHref,
18
+ } from "./services-path";
19
+
20
+ type ItemsSection = Extract<PageSection, { type: "items" }>;
21
+
22
+ /** The `services` messages namespace labels a category's list reads (#10043 D11). */
23
+ export interface ServiceListingLabels {
24
+ childrenHeading: string;
25
+ viewService: string;
26
+ }
27
+
28
+ /** A service's public path: `<base>[/<category>]/<slug>`. */
29
+ export function servicePublicPath(slug: string, parent?: string): string {
30
+ return pagePath({ type: "Service", slug, parent }, SERVICES_PATH);
31
+ }
32
+
33
+ /** The services whose `parent` is `category`, from a slug-sorted page list. */
34
+ export function childServices(
35
+ pages: DetailPage[],
36
+ category: string,
37
+ ): DetailPage[] {
38
+ return pages.filter((page) => page.frontmatter.parent === category);
39
+ }
40
+
41
+ /** The list a category page renders after its body: one card per child service. */
42
+ export function serviceChildrenSection(
43
+ children: DetailPage[],
44
+ labels: ServiceListingLabels,
45
+ ): ItemsSection {
46
+ const text = (value: unknown): string | undefined =>
47
+ typeof value === "string" && value.length > 0 ? value : undefined;
48
+ return {
49
+ type: "items",
50
+ id: "service-children",
51
+ data: {
52
+ headline: labels.childrenHeading,
53
+ items: children.map((child) => ({
54
+ title: text(child.frontmatter.title) ?? child.slug,
55
+ description:
56
+ text(child.frontmatter.summary) ??
57
+ text(child.frontmatter.description) ??
58
+ "",
59
+ link: {
60
+ label: labels.viewService,
61
+ href: servicePublicPath(child.slug, text(child.frontmatter.parent)),
62
+ },
63
+ })),
64
+ },
65
+ };
66
+ }
67
+
68
+ function mapHrefs(value: unknown): unknown {
69
+ if (Array.isArray(value)) return value.map(mapHrefs);
70
+ if (value === null || typeof value !== "object") return value;
71
+ return Object.fromEntries(
72
+ Object.entries(value).map(([key, entry]) => [
73
+ key,
74
+ key === "href" && typeof entry === "string"
75
+ ? toPublicServicesHref(entry)
76
+ : mapHrefs(entry),
77
+ ]),
78
+ );
79
+ }
80
+
81
+ /**
82
+ * The hub's sections with every site `href` starting `/services` re-pointed
83
+ * onto the base, at any depth; the same array on a site with no
84
+ * `services.path`.
85
+ */
86
+ export function withPublicServicesSections<S>(sections: S[]): S[] {
87
+ if (SERVICES_PATH === SERVICES_ROUTE) return sections;
88
+ return sections.map((section) => mapHrefs(section) as S);
89
+ }
@@ -0,0 +1,97 @@
1
+ // services-path.ts — where the services live (#10043 D5).
2
+ //
3
+ // A site sets `services: { path: /capabilities }` in `content/_site.mdx`;
4
+ // `next.config.mjs` parses it at build and bakes it into
5
+ // `NEXT_PUBLIC_AILK_SERVICES_PATH`. The services routes never move — they stay
6
+ // at `app/[locale]/services/` (`SERVICES_ROUTE`) — and the middleware maps the
7
+ // public path onto them and answers the old `/services` URLs with a 308. Every
8
+ // surface that names a services URL reads `SERVICES_PATH` here.
9
+ //
10
+ // Edge-safe and client-safe: this file imports nothing at runtime (the
11
+ // `NavItem` import is type-only), so the middleware and the client signed-in
12
+ // nav can both read it. The shape is `docs-path.ts`'s (#10041).
13
+ import type { NavItem } from "@working-theory/ui";
14
+
15
+ /** The internal services route — the directory under `app/[locale]/`. Always `/services`. */
16
+ export const SERVICES_ROUTE = "/services";
17
+
18
+ /** The services' public base: the site's `services.path`, or `/services` when it sets none. */
19
+ export const SERVICES_PATH: string =
20
+ process.env.NEXT_PUBLIC_AILK_SERVICES_PATH || SERVICES_ROUTE;
21
+
22
+ /**
23
+ * `href` at the internal services route — `/services`, or starting
24
+ * `/services/`, `/services?` or `/services#` — with that prefix replaced by
25
+ * `SERVICES_PATH`. Anything else (absolute URLs, `/servicesx`, a
26
+ * locale-prefixed path) is returned unchanged. The identity on a site with no
27
+ * `services.path`.
28
+ */
29
+ export function toPublicServicesHref(href: string): string {
30
+ if (SERVICES_PATH === SERVICES_ROUTE) return href;
31
+ const rest = href.slice(SERVICES_ROUTE.length);
32
+ if (href.startsWith(SERVICES_ROUTE) && (rest === "" || /^[/?#]/.test(rest))) {
33
+ return SERVICES_PATH + rest;
34
+ }
35
+ return href;
36
+ }
37
+
38
+ /**
39
+ * A locale-less pathname at or under `SERVICES_PATH`, mapped onto the internal
40
+ * route (`/services` + the rest); `null` for anything else, and always `null`
41
+ * on a site with no `services.path`.
42
+ */
43
+ export function toInternalServicesPath(bare: string): string | null {
44
+ if (SERVICES_PATH === SERVICES_ROUTE) return null;
45
+ if (bare === SERVICES_PATH) return SERVICES_ROUTE;
46
+ if (bare.startsWith(`${SERVICES_PATH}/`)) {
47
+ return SERVICES_ROUTE + bare.slice(SERVICES_PATH.length);
48
+ }
49
+ return null;
50
+ }
51
+
52
+ type NavFlyout = NonNullable<NavItem["flyout"]>;
53
+
54
+ function mapPath<T extends { path: string }>(entry: T): T {
55
+ return { ...entry, path: toPublicServicesHref(entry.path) };
56
+ }
57
+
58
+ function mapFlyout(flyout: NavFlyout): NavFlyout {
59
+ return {
60
+ ...flyout,
61
+ features: flyout.features.map(mapPath),
62
+ ...(flyout.groups
63
+ ? {
64
+ groups: flyout.groups.map((group) => ({
65
+ ...group,
66
+ items: group.items.map(mapPath),
67
+ })),
68
+ }
69
+ : {}),
70
+ actions: flyout.actions.map(mapPath),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Copies of the nav items with every services path mapped onto
76
+ * `SERVICES_PATH` — the item's own `path` and each flyout feature, group item
77
+ * and action. Every other field is carried by reference.
78
+ */
79
+ export function withPublicServicesPaths(items: NavItem[]): NavItem[] {
80
+ return items.map((item) => ({
81
+ ...item,
82
+ ...(item.path !== undefined
83
+ ? { path: toPublicServicesHref(item.path) }
84
+ : {}),
85
+ ...(item.flyout ? { flyout: mapFlyout(item.flyout) } : {}),
86
+ }));
87
+ }
88
+
89
+ /** Copies of the footer columns with every item's services path mapped onto `SERVICES_PATH`. */
90
+ export function withPublicServicesColumns<
91
+ C extends { items: { path: string }[] },
92
+ >(columns: C[]): C[] {
93
+ return columns.map((column) => ({
94
+ ...column,
95
+ items: column.items.map(mapPath),
96
+ }));
97
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The site's header settings (#10046) — `_site.mdx`'s top-level `header`.
3
+ *
4
+ * `header.cta` replaces the signed-out Sign in / Sign up pair with the site's
5
+ * own button; `header.signIn` keeps Sign in beside it as a quieter text link
6
+ * (`link`, the default) or hides it. Signed in, the header is unchanged.
7
+ *
8
+ * A local frontmatter read rather than `@working-theory/site-config/server`,
9
+ * for the same reason as `app/[locale]/contact/site-contact-email.ts`:
10
+ * `architecture.yaml` keeps `node:fs` out of a tree that also carries edge
11
+ * routes (#1567). The value is validated against the canonical schema from
12
+ * the client-safe `/schema` entry.
13
+ */
14
+
15
+ import { readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ import {
19
+ SiteConfigSchema,
20
+ type SiteHeader,
21
+ } from "@working-theory/site-config/schema";
22
+ import type { NavAction } from "@working-theory/ui/chrome";
23
+ import { parse as parseYaml } from "yaml";
24
+
25
+ import { resolveContentRoot } from "./site-brand";
26
+
27
+ /** The `labelKey` the CTA action carries; its test id is `nav-action-headerCta`. */
28
+ export const HEADER_CTA_LABEL_KEY = "headerCta";
29
+
30
+ type LinkAction = Extract<NavAction, { path: string }>;
31
+
32
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
33
+
34
+ const HeaderSchema = SiteConfigSchema.shape.header.unwrap();
35
+
36
+ let warned = false;
37
+
38
+ /**
39
+ * The validated `header`, else `undefined`. An absent file, frontmatter or
40
+ * key is silent; an invalid `header` warns once per process, naming the field
41
+ * path and never the value.
42
+ */
43
+ export function readSiteHeader(): SiteHeader | undefined {
44
+ try {
45
+ const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
46
+ const match = FRONTMATTER_RE.exec(raw);
47
+ if (!match) return undefined;
48
+ const fm: unknown = parseYaml(match[1] ?? "");
49
+ if (!fm || typeof fm !== "object") return undefined;
50
+
51
+ const { header } = fm as { header?: unknown };
52
+ if (header === undefined) return undefined;
53
+
54
+ // #10049 — `style` is page-chrome.ts's to validate and warn about; set it
55
+ // aside so a bad style never drops the button.
56
+ const parsed = HeaderSchema.safeParse(
57
+ header !== null && typeof header === "object" ? { ...header, style: undefined } : header,
58
+ );
59
+ if (parsed.success) return parsed.data;
60
+ if (!warned) {
61
+ warned = true;
62
+ const issue = parsed.error.issues[0];
63
+ console.warn(
64
+ `[site-header] ignoring _site.mdx header: ${["header", ...(issue?.path ?? [])].join(".")} ${issue?.message ?? ""}`,
65
+ );
66
+ }
67
+ return undefined;
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The signed-out header actions. No `cta` → the auth pair as given. A `cta`
75
+ * → Sign in restyled as a text link (unless `signIn: hidden`), then the CTA
76
+ * as the primary button; Sign up is dropped. Never mutates `authActions`.
77
+ */
78
+ export function signedOutHeaderActions(
79
+ header: SiteHeader | undefined,
80
+ authActions: readonly LinkAction[],
81
+ ): LinkAction[] {
82
+ const cta = header?.cta;
83
+ if (!cta) return [...authActions];
84
+
85
+ const signIn = authActions.find((action) => action.labelKey === "signIn");
86
+ const signInPart: LinkAction[] =
87
+ signIn && header.signIn !== "hidden"
88
+ ? [{ ...signIn, variant: "ghost" }]
89
+ : [];
90
+ return [
91
+ ...signInPart,
92
+ { labelKey: HEADER_CTA_LABEL_KEY, path: cta.href, variant: "primary" },
93
+ ];
94
+ }
@@ -13,10 +13,45 @@
13
13
  // carries the submitter's address as `/sign-in?email=…` and the hub's
14
14
  // authorize step as `login_hint=…` — personal data GA and Sentry must not get.
15
15
  //
16
+ // `ref` joined by #9966 (D2, SEC-9966-7): the hub's consent page,
17
+ // `/account/connect?ref=…`, carries a sealed authorize continuation.
18
+ //
16
19
  // Mirrored (duplicated, not shared) in packages/analytics/src/scrub.ts,
17
20
  // since apps/* must not import @working-theory/* packages — keep both lists
18
21
  // in sync (#5613).
19
- export const TOKEN_QUERY_PARAMS = ["g", "session_id", "email", "login_hint"];
22
+ export const TOKEN_QUERY_PARAMS = [
23
+ "g",
24
+ "session_id",
25
+ "email",
26
+ "login_hint",
27
+ "ref",
28
+ ];
29
+
30
+ // The same grant id rides a PATH too: `deliverables.openGrant` fetches
31
+ // `<hub>/v1/deliverables/<grantId>` server-side, so a sampled server trace or
32
+ // a fetch breadcrumb would carry it (#9162, PR #9152's security review). The
33
+ // id segment is replaced anywhere in the string, so a span description
34
+ // (`GET <url>`) is redacted too when a caller passes one. `/v1/deliverables/open/<slug>` is the ungated
35
+ // read; a slug is not a credential. apps/web only: the analytics mirror sees
36
+ // page URLs, never this server-side fetch.
37
+ const GRANT_PATH = /(\/v1\/deliverables\/)(?!open(?:[/?#\s]|$))[^/?#\s]+/g;
38
+ const GRANT_PATH_REDACTED = "$1:grantId";
39
+
40
+ // #9969 D9 (#8329) — the Stripe SDK's own request URLs carry a customer id:
41
+ // `customers.retrieve` in the path (`/v1/customers/cus_…`) and
42
+ // `subscriptions.list` in the query (`?customer=cus_…`), and server-side
43
+ // Sentry records those outgoing requests. A changeset once stated this as a
44
+ // precondition for turning server telemetry on; it now lives here, with its
45
+ // enforcement. Server-side only, so not mirrored into packages/analytics:
46
+ // GA sees page URLs, never a Stripe API URL.
47
+ const STRIPE_ID_QUERY_PARAMS = ["customer"];
48
+ const STRIPE_CUSTOMER_SEGMENT = /\/cus_[A-Za-z0-9]+(?=[/?#\s]|$)/g;
49
+ const STRIPE_CUSTOMER_SEGMENT_REDACTED = "/cus_redacted";
50
+
51
+ const SCRUBBED_QUERY_PARAMS = [
52
+ ...TOKEN_QUERY_PARAMS,
53
+ ...STRIPE_ID_QUERY_PARAMS,
54
+ ];
20
55
 
21
56
  // Sentry navigation breadcrumbs (`data.to`/`data.from`) are relative
22
57
  // (`path?search`, no origin) rather than the absolute URLs GA and
@@ -29,7 +64,10 @@ function hasScheme(url: string): boolean {
29
64
  return /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(url) || url.startsWith("//");
30
65
  }
31
66
 
32
- export function scrubTelemetryUrl(url: string): string {
67
+ export function scrubTelemetryUrl(input: string): string {
68
+ const url = input
69
+ .replace(GRANT_PATH, GRANT_PATH_REDACTED)
70
+ .replace(STRIPE_CUSTOMER_SEGMENT, STRIPE_CUSTOMER_SEGMENT_REDACTED);
33
71
  const relative = !hasScheme(url);
34
72
  let parsed: URL;
35
73
  try {
@@ -39,7 +77,7 @@ export function scrubTelemetryUrl(url: string): string {
39
77
  }
40
78
 
41
79
  let changed = false;
42
- for (const param of TOKEN_QUERY_PARAMS) {
80
+ for (const param of SCRUBBED_QUERY_PARAMS) {
43
81
  if (parsed.searchParams.has(param)) {
44
82
  parsed.searchParams.delete(param);
45
83
  changed = true;
@@ -52,3 +90,30 @@ export function scrubTelemetryUrl(url: string): string {
52
90
  const result = parsed.toString();
53
91
  return relative ? result.slice(RELATIVE_URL_BASE.length) : result;
54
92
  }
93
+
94
+ /**
95
+ * A bare query string (`?a=1&b=2`, with or without the `?`) with the same
96
+ * params dropped — the SDK records an outgoing request's query under
97
+ * `http.query` apart from its URL (#9969 D9).
98
+ */
99
+ export function scrubTelemetryQuery(query: string): string {
100
+ const params = new URLSearchParams(query);
101
+ let changed = false;
102
+ for (const param of SCRUBBED_QUERY_PARAMS) {
103
+ if (params.has(param)) {
104
+ params.delete(param);
105
+ changed = true;
106
+ }
107
+ }
108
+ if (!changed) return query;
109
+ const rest = params.toString();
110
+ return rest === "" ? "" : `${query.startsWith("?") ? "?" : ""}${rest}`;
111
+ }
112
+
113
+ /** An HTTP client span's `<METHOD> <url>` with its URL scrubbed (#9969 D9). */
114
+ export function scrubSpanDescription(description: string): string {
115
+ const match = /^([A-Z]+) (\S+)$/.exec(description);
116
+ return match
117
+ ? `${match[1]} ${scrubTelemetryUrl(match[2]!)}`
118
+ : scrubTelemetryUrl(description);
119
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The template-listing mapping (#10051 D6) — a TemplatePage's frontmatter to
3
+ * the /templates hub's card and to the generated sections of its own
4
+ * /templates/<slug> page. Pure, no I/O: the routes load the pages and the
5
+ * translated labels and pass them in, so no user-facing copy lives here
6
+ * (customer-facing-i18n, ADR 0019).
7
+ *
8
+ * Base tree: no paid import, no `oss: false`.
9
+ */
10
+ import { SITE_CATEGORIES } from "@working-theory/schema/categories";
11
+ import type { PageSection } from "@working-theory/validation";
12
+
13
+ /** The `templates` messages namespace, read by the routes (#10051 D8). */
14
+ export interface TemplateLabels {
15
+ getIt: string;
16
+ startFree: string;
17
+ viewDemo: string;
18
+ viewTemplate: string;
19
+ included: string;
20
+ demoBadge: string;
21
+ /** The tail of a category label cut at {@link MAX_CATEGORY_NAMES}: "+9 more". */
22
+ moreCategories: (count: number) => string;
23
+ }
24
+
25
+ /** Read the labels from the `templates` namespace's translator. */
26
+ export function templateLabels(
27
+ t: (key: keyof TemplateLabels, values?: { count: number }) => string,
28
+ ): TemplateLabels {
29
+ return {
30
+ getIt: t("getIt"),
31
+ startFree: t("startFree"),
32
+ viewDemo: t("viewDemo"),
33
+ viewTemplate: t("viewTemplate"),
34
+ included: t("included"),
35
+ demoBadge: t("demoBadge"),
36
+ moreCategories: (count) => t("moreCategories", { count }),
37
+ };
38
+ }
39
+
40
+ /**
41
+ * At most this many category names show in a card or hero label; the rest
42
+ * collapse into "+N more", so a template listed under twelve categories keeps
43
+ * a one-line label and the card titles in a row stay aligned (design review
44
+ * round 1, B2).
45
+ */
46
+ export const MAX_CATEGORY_NAMES = 3;
47
+
48
+ /** The TemplatePage frontmatter fields this mapping reads. */
49
+ interface TemplateFrontmatter {
50
+ title?: string;
51
+ description?: string;
52
+ summary?: string;
53
+ thumbnail?: string;
54
+ categories?: readonly string[];
55
+ demoUrl?: string;
56
+ getItUrl?: string;
57
+ price?: number;
58
+ includes?: readonly { title: string; description: string }[];
59
+ }
60
+
61
+ type CardItem = Extract<
62
+ PageSection,
63
+ { type: "items" }
64
+ >["data"]["items"][number];
65
+
66
+ function fields(frontmatter: unknown): TemplateFrontmatter {
67
+ return (frontmatter ?? {}) as TemplateFrontmatter;
68
+ }
69
+
70
+ /**
71
+ * The known category display names, the first {@link MAX_CATEGORY_NAMES}
72
+ * joined and the rest counted; undefined when none is known.
73
+ */
74
+ function categoryNames(
75
+ categories: readonly string[] | undefined,
76
+ labels: TemplateLabels,
77
+ ): string | undefined {
78
+ const names = (categories ?? []).flatMap((slug) => {
79
+ const found = SITE_CATEGORIES.find((category) => category.slug === slug);
80
+ return found === undefined ? [] : [found.name];
81
+ });
82
+ if (names.length === 0) return undefined;
83
+ const shown = names.slice(0, MAX_CATEGORY_NAMES);
84
+ const hidden = names.length - shown.length;
85
+ return hidden > 0
86
+ ? [...shown, labels.moreCategories(hidden)].join(" · ")
87
+ : shown.join(" · ");
88
+ }
89
+
90
+ /** One /templates card for a TemplatePage. */
91
+ export function templateCard(
92
+ page: { slug: string; frontmatter: unknown },
93
+ labels: TemplateLabels,
94
+ ): CardItem {
95
+ const fm = fields(page.frontmatter);
96
+ const title = fm.title ?? page.slug;
97
+ const eyebrow = categoryNames(fm.categories, labels);
98
+ return {
99
+ title,
100
+ description: fm.summary ?? fm.description ?? "",
101
+ ...(fm.thumbnail !== undefined && {
102
+ image: { src: fm.thumbnail, alt: title },
103
+ }),
104
+ ...(eyebrow !== undefined && { eyebrow }),
105
+ ...(fm.demoUrl !== undefined && { badge: labels.demoBadge }),
106
+ link: { label: labels.viewTemplate, href: `/templates/${page.slug}` },
107
+ };
108
+ }
109
+
110
+ /** "Start free" for a template priced at exactly 0, else "Get it". */
111
+ export function templateActionLabel(
112
+ price: number | undefined,
113
+ labels: TemplateLabels,
114
+ ): string {
115
+ return price === 0 ? labels.startFree : labels.getIt;
116
+ }
117
+
118
+ /**
119
+ * The generated top of a /templates/<slug> page: a hero carrying the name,
120
+ * summary, thumbnail, categories, the Get it / Start free action and the
121
+ * View demo link, then the "What's included" list.
122
+ */
123
+ export function templateDetailSections(
124
+ frontmatter: unknown,
125
+ labels: TemplateLabels,
126
+ ): [PageSection, PageSection] {
127
+ const fm = fields(frontmatter);
128
+ const title = fm.title ?? "";
129
+ const subheadline = fm.summary ?? fm.description;
130
+ const eyebrow = categoryNames(fm.categories, labels);
131
+ const hero: PageSection = {
132
+ type: "hero",
133
+ id: "template-hero",
134
+ data: {
135
+ headline: title,
136
+ ...(subheadline !== undefined && { subheadline }),
137
+ ...(eyebrow !== undefined && { eyebrow }),
138
+ ...(fm.thumbnail !== undefined && {
139
+ image: { src: fm.thumbnail, alt: title },
140
+ }),
141
+ ...(fm.getItUrl !== undefined && {
142
+ primaryCta: {
143
+ label: templateActionLabel(fm.price, labels),
144
+ href: fm.getItUrl,
145
+ },
146
+ }),
147
+ ...(fm.demoUrl !== undefined && {
148
+ secondaryCta: {
149
+ label: labels.viewDemo,
150
+ href: fm.demoUrl,
151
+ variant: "outline" as const,
152
+ },
153
+ }),
154
+ },
155
+ };
156
+ const included: PageSection = {
157
+ type: "items",
158
+ id: "template-included",
159
+ data: {
160
+ headline: labels.included,
161
+ variant: "minimal",
162
+ items: (fm.includes ?? []).map(({ title: rowTitle, description }) => ({
163
+ title: rowTitle,
164
+ description,
165
+ })),
166
+ },
167
+ };
168
+ return [hero, included];
169
+ }