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,367 @@
1
+ /**
2
+ * Per-page chrome (#10049 D3) — which header and footer a page wears, and
3
+ * whether the home is a deck.
4
+ *
5
+ * Four levels, most specific first: the page's own frontmatter (`header:`,
6
+ * `footer:`, `layout:`), its route family's key in `_site.mdx` (`blog:`,
7
+ * `docs:`, …), the site defaults in `_site.mdx` (`header.style`,
8
+ * `footer.style`, `layout`), and last the design's own knobs in the
9
+ * generated `site-chrome.ts`. A site that sets nothing resolves to exactly
10
+ * the chrome the design gave it before this file existed.
11
+ *
12
+ * Blog posts and docs pages are served by the docs-blog slice, which this
13
+ * base file must never import, so they take their `blog:` / `docs:` family
14
+ * or the site default; their own frontmatter is not read here.
15
+ *
16
+ * `_site.mdx` is read locally rather than through
17
+ * `@working-theory/site-config/server`, as `site-header.ts` does (#1567).
18
+ */
19
+
20
+ import { readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+
23
+ import {
24
+ getMdxSource,
25
+ type SchemaOrgType,
26
+ } from "@working-theory/page-renderer";
27
+ import {
28
+ HeaderStyleSchema,
29
+ ROUTE_FAMILY_KEYS,
30
+ SiteConfigSchema,
31
+ type FooterStyle,
32
+ type HeaderStyle,
33
+ type RouteFamilyChrome,
34
+ type RouteFamilyKey,
35
+ type SiteLayout,
36
+ } from "@working-theory/site-config/schema";
37
+ import { ROUTES } from "@working-theory/templates/routes";
38
+ import { readLocales } from "@working-theory/web-i18n";
39
+ import { cache } from "react";
40
+ import { parse as parseYaml } from "yaml";
41
+ import type { ZodType } from "zod";
42
+
43
+ import { normalizeRoutePath } from "./closing-footer-path";
44
+ import {
45
+ isAlwaysScaffolded,
46
+ matchesRoute,
47
+ resolveTypeAndSlug,
48
+ } from "./deck-page";
49
+ import { toInternalDocsPath } from "./docs-path";
50
+ import { SERVICES_ROUTE, toInternalServicesPath } from "./services-path";
51
+ import { resolveContentRoot } from "./site-brand";
52
+
53
+ /** The chrome settings a site sets in `_site.mdx`. */
54
+ export interface SiteChromeSettings {
55
+ headerStyle?: HeaderStyle;
56
+ footerStyle?: FooterStyle;
57
+ layout?: SiteLayout;
58
+ families: Partial<Record<RouteFamilyKey, RouteFamilyChrome>>;
59
+ }
60
+
61
+ /** The chrome fields a page's own frontmatter sets. */
62
+ export interface PageChromeFields {
63
+ header?: HeaderStyle;
64
+ footer?: FooterStyle;
65
+ layout?: SiteLayout;
66
+ }
67
+
68
+ /**
69
+ * The design knobs the template level reads — a structural slice of the
70
+ * generated `SiteChrome`, so a scaffold generated before `innerPages` or
71
+ * `chromeStyle` existed still compiles.
72
+ */
73
+ export interface ChromeKnobs {
74
+ home: SiteLayout;
75
+ footer: "columns" | "centered";
76
+ innerPages?: "bare";
77
+ chromeStyle?: "minimal";
78
+ }
79
+
80
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
81
+
82
+ const warnedPaths = new Set<string>();
83
+
84
+ /** One warning per field path per process, naming the path and the Zod message — never the value. */
85
+ function warnOnce(path: string, message: string): void {
86
+ if (warnedPaths.has(path)) return;
87
+ warnedPaths.add(path);
88
+ console.warn(`[page-chrome] ignoring _site.mdx ${path}: ${message}`);
89
+ }
90
+
91
+ /** `value` parsed by `schema`, else `undefined` with a warning under `path`. */
92
+ function validated<T>(
93
+ schema: ZodType<T>,
94
+ value: unknown,
95
+ path: string,
96
+ ): T | undefined {
97
+ if (value === undefined) return undefined;
98
+ const parsed = schema.safeParse(value);
99
+ if (parsed.success) return parsed.data;
100
+ const issue = parsed.error.issues[0];
101
+ warnOnce(
102
+ [path, ...(issue?.path ?? [])].join("."),
103
+ issue?.message ?? "invalid",
104
+ );
105
+ return undefined;
106
+ }
107
+
108
+ /**
109
+ * `_site.mdx`'s chrome keys. Each key validates on its own, so one bad key
110
+ * drops only itself; an absent file, frontmatter or key is silent.
111
+ */
112
+ export const readSiteChromeSettings = cache(
113
+ function readSiteChromeSettings(): SiteChromeSettings {
114
+ try {
115
+ const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
116
+ const match = FRONTMATTER_RE.exec(raw);
117
+ if (!match) return { families: {} };
118
+ const fm: unknown = parseYaml(match[1] ?? "");
119
+ if (!fm || typeof fm !== "object") return { families: {} };
120
+ const record = fm as Record<string, unknown>;
121
+
122
+ const settings: SiteChromeSettings = { families: {} };
123
+ const header = record["header"];
124
+ const headerStyle =
125
+ header && typeof header === "object"
126
+ ? validated(
127
+ HeaderStyleSchema,
128
+ (header as Record<string, unknown>)["style"],
129
+ "header.style",
130
+ )
131
+ : undefined;
132
+ if (headerStyle !== undefined) settings.headerStyle = headerStyle;
133
+ const footerStyle = validated(
134
+ SiteConfigSchema.shape.footer,
135
+ record["footer"],
136
+ "footer",
137
+ )?.style;
138
+ if (footerStyle !== undefined) settings.footerStyle = footerStyle;
139
+ const layout = validated(
140
+ SiteConfigSchema.shape.layout,
141
+ record["layout"],
142
+ "layout",
143
+ );
144
+ if (layout !== undefined) settings.layout = layout;
145
+ for (const key of ROUTE_FAMILY_KEYS) {
146
+ const family = validated(SiteConfigSchema.shape[key], record[key], key);
147
+ if (family !== undefined) settings.families[key] = family;
148
+ }
149
+ return settings;
150
+ } catch (err) {
151
+ // An absent file is silent; unreadable frontmatter is worth one warning.
152
+ if ((err as { code?: unknown }).code !== "ENOENT") {
153
+ warnOnce("frontmatter", "could not be read");
154
+ }
155
+ return { families: {} };
156
+ }
157
+ },
158
+ );
159
+
160
+ function camelCase(prefix: string): string {
161
+ return prefix
162
+ .replace(/^\//, "")
163
+ .replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase());
164
+ }
165
+
166
+ /** Whether `segments` match a routes-map pattern, `:...slug` taking one or more segments. */
167
+ function matchesPattern(pattern: string, segments: readonly string[]): boolean {
168
+ const parts = pattern.split("/").filter((part) => part.length > 0);
169
+ for (let i = 0; i < parts.length; i += 1) {
170
+ const part = parts[i] ?? "";
171
+ if (part.startsWith(":...")) return segments.length > i;
172
+ if (i >= segments.length) return false;
173
+ if (!part.startsWith(":") && part !== segments[i]) return false;
174
+ }
175
+ return parts.length === segments.length;
176
+ }
177
+
178
+ /**
179
+ * The route family a pathname belongs to, else `null`: the first routes-map
180
+ * hub whose prefix is the path, prefixes it, or whose detail pattern matches
181
+ * it. A site's moved docs are mapped back onto `/docs` first, and its moved
182
+ * services onto `/services` (#10043 D9).
183
+ */
184
+ export function routeFamilyOf(
185
+ pathname: string,
186
+ locales: readonly string[],
187
+ ): RouteFamilyKey | null {
188
+ const bare = normalizeRoutePath(pathname, locales);
189
+ const path = toInternalDocsPath(bare) ?? toInternalServicesPath(bare) ?? bare;
190
+ const segments = path.split("/").filter((segment) => segment.length > 0);
191
+ for (const row of ROUTES) {
192
+ if (!row.hub) continue;
193
+ const { prefix, detailPattern } = row.hub;
194
+ if (
195
+ path === prefix ||
196
+ path.startsWith(`${prefix}/`) ||
197
+ matchesPattern(detailPattern, segments)
198
+ ) {
199
+ const key = camelCase(prefix);
200
+ return (ROUTE_FAMILY_KEYS as readonly string[]).includes(key)
201
+ ? (key as RouteFamilyKey)
202
+ : null;
203
+ }
204
+ }
205
+ return null;
206
+ }
207
+
208
+ /**
209
+ * The MDX content pages that could serve `pathname`, in the order Next.js
210
+ * tries them — `deckCandidates`' walk over every content row rather than the
211
+ * deck routes. Blog posts and docs pages live outside `content/`, so they are
212
+ * never candidates.
213
+ */
214
+ export function chromePageCandidates(
215
+ pathname: string,
216
+ locales: readonly string[],
217
+ ): Array<{ type: SchemaOrgType; slug: string }> {
218
+ const normalized = normalizeRoutePath(pathname, locales);
219
+ // #10043 D9 — a site's moved services are mapped back onto `/services`.
220
+ const path = toInternalServicesPath(normalized) ?? normalized;
221
+ if (path === "/") return [{ type: "HomePage", slug: "home" }];
222
+ const segments = path.split("/").filter((segment) => segment.length > 0);
223
+ // A service under its category (`/services/<category>/<slug>`): the nested
224
+ // row names no content directory, so the walk below would never name it.
225
+ if (segments.length === 3 && segments[0] === SERVICES_ROUTE.slice(1)) {
226
+ return [{ type: "Service", slug: segments[2]! }];
227
+ }
228
+ const candidates: Array<{ type: SchemaOrgType; slug: string }> = [];
229
+
230
+ for (const row of ROUTES) {
231
+ if (!row.contentPath?.startsWith("content/")) continue;
232
+ if (!matchesRoute(row.route, segments)) continue;
233
+ const resolved = resolveTypeAndSlug(row, row.route, segments);
234
+ if (resolved) {
235
+ candidates.push(resolved);
236
+ break;
237
+ }
238
+ }
239
+
240
+ if (segments.length === 1 && !isAlwaysScaffolded(path)) {
241
+ const slug = segments[0];
242
+ if (slug !== undefined) {
243
+ candidates.push({ type: "LandingPage", slug });
244
+ candidates.push({ type: "BlogList", slug });
245
+ }
246
+ }
247
+ return candidates;
248
+ }
249
+
250
+ /**
251
+ * The page's own chrome frontmatter — the first candidate with a source —
252
+ * else `{}`. A read that throws answers `{}`: the page's own render surfaces
253
+ * the error. Per-request memoised.
254
+ */
255
+ export const resolveChromePage = cache(async function resolveChromePage(
256
+ pathname: string,
257
+ locale: string,
258
+ ): Promise<PageChromeFields> {
259
+ try {
260
+ for (const candidate of chromePageCandidates(pathname, readLocales())) {
261
+ const source = await getMdxSource(candidate.type, candidate.slug, locale);
262
+ if (!source) continue;
263
+ const { header, footer, layout } = source.frontmatter as PageChromeFields;
264
+ return {
265
+ ...(header !== undefined ? { header } : {}),
266
+ ...(footer !== undefined ? { footer } : {}),
267
+ ...(layout !== undefined ? { layout } : {}),
268
+ };
269
+ }
270
+ } catch {
271
+ return {};
272
+ }
273
+ return {};
274
+ });
275
+
276
+ /** The home's layout: its own frontmatter, else the site's, else the design's. */
277
+ export function resolveHomeLayout(
278
+ site: SiteChromeSettings,
279
+ home: PageChromeFields,
280
+ chrome: Pick<ChromeKnobs, "home">,
281
+ ): SiteLayout {
282
+ return home.layout ?? site.layout ?? chrome.home;
283
+ }
284
+
285
+ export interface PageChromeInput {
286
+ isHome: boolean;
287
+ /** The resolved home layout is a deck. */
288
+ homeIsDeck: boolean;
289
+ family: RouteFamilyKey | null;
290
+ page: PageChromeFields;
291
+ site: SiteChromeSettings;
292
+ chrome: ChromeKnobs;
293
+ /** The page is listed in the design's `closingFooterPaths`. */
294
+ closingListed: boolean;
295
+ }
296
+
297
+ export interface ResolvedChrome {
298
+ header: HeaderStyle;
299
+ /** No page, family or site value set the header — the design's own treatment applies. */
300
+ headerFromTemplate: boolean;
301
+ /** `line` is the design's one-bar footer on a bare inner page; never a site value. */
302
+ footer: FooterStyle | "line";
303
+ }
304
+
305
+ /** The design's own header and footer for a page (#10049 D3's template table). */
306
+ function templateChrome(
307
+ input: PageChromeInput,
308
+ ): Pick<ResolvedChrome, "header" | "footer"> {
309
+ const { chrome } = input;
310
+ if (input.isHome) {
311
+ if (input.homeIsDeck && chrome.home === "deck") {
312
+ return {
313
+ header: "bare",
314
+ footer: chrome.chromeStyle === "minimal" ? "closing" : chrome.footer,
315
+ };
316
+ }
317
+ return { header: "full", footer: chrome.footer };
318
+ }
319
+ if (input.closingListed) {
320
+ return {
321
+ header: chrome.innerPages === "bare" ? "bare" : "full",
322
+ footer: "closing",
323
+ };
324
+ }
325
+ if (chrome.innerPages === "bare") return { header: "bare", footer: "line" };
326
+ return { header: "full", footer: chrome.footer };
327
+ }
328
+
329
+ /**
330
+ * The home's layout and chrome — the one resolution both the layout and the
331
+ * home page read, so the bar and the landing can never disagree.
332
+ */
333
+ export async function resolveHomeChrome(
334
+ locale: string,
335
+ chrome: ChromeKnobs,
336
+ ): Promise<{ layout: SiteLayout; chrome: ResolvedChrome; site: SiteChromeSettings }> {
337
+ const site = readSiteChromeSettings();
338
+ const page = await resolveChromePage("/", locale);
339
+ const layout = resolveHomeLayout(site, page, chrome);
340
+ return {
341
+ layout,
342
+ site,
343
+ chrome: resolvePageChrome({
344
+ isHome: true,
345
+ homeIsDeck: layout === "deck",
346
+ family: null,
347
+ page,
348
+ site,
349
+ chrome,
350
+ closingListed: false,
351
+ }),
352
+ };
353
+ }
354
+
355
+ /** Each axis takes the first value set: page → route family → site → design. */
356
+ export function resolvePageChrome(input: PageChromeInput): ResolvedChrome {
357
+ const template = templateChrome(input);
358
+ const family =
359
+ input.family !== null ? input.site.families[input.family] : undefined;
360
+ const header = input.page.header ?? family?.header ?? input.site.headerStyle;
361
+ const footer = input.page.footer ?? family?.footer ?? input.site.footerStyle;
362
+ return {
363
+ header: header ?? template.header,
364
+ headerFromTemplate: header === undefined,
365
+ footer: footer ?? template.footer,
366
+ };
367
+ }
@@ -5,13 +5,35 @@
5
5
  * is `/careers/<slug>`'s. In `lib/` (#8849 D9), not a route directory, so
6
6
  * every scaffold carries it: the `signal-collectors` walk and the Free apply
7
7
  * page (`careers/[slug]/[action]`) both read it.
8
+ *
9
+ * #10054 D2 — both pages read the posting through `postingForApply`, which
10
+ * also returns the magnet the posting names (`applicationFlow`, checked as a
11
+ * flow slug by the frontmatter schema); one read serves both.
8
12
  */
9
13
  import { getMdxSource } from "@working-theory/page-renderer";
10
14
 
11
- /** The posting's title in `locale`, or `null` when no posting has this slug. */
12
- export async function postingTitle(slug: string, locale: string): Promise<string | null> {
15
+ /** What an apply page needs from its posting. */
16
+ export interface ApplyPosting {
17
+ title: string;
18
+ /** The magnet this posting's apply page runs; absent, the Job application. */
19
+ applicationFlow?: string;
20
+ }
21
+
22
+ /** The posting in `locale`, or `null` when no posting has this slug or it has no title. */
23
+ export async function postingForApply(slug: string, locale: string): Promise<ApplyPosting | null> {
13
24
  const source = await getMdxSource("JobPosting", slug, locale);
14
25
  if (source === null) return null;
15
- const title = (source.frontmatter as { title?: unknown }).title;
16
- return typeof title === "string" && title.trim() !== "" ? title : null;
26
+ const { title, applicationFlow } = source.frontmatter as {
27
+ title?: unknown;
28
+ applicationFlow?: unknown;
29
+ };
30
+ if (typeof title !== "string" || title.trim() === "") return null;
31
+ return typeof applicationFlow === "string" && applicationFlow !== ""
32
+ ? { title, applicationFlow }
33
+ : { title };
34
+ }
35
+
36
+ /** The posting's title in `locale`, or `null` when no posting has this slug. */
37
+ export async function postingTitle(slug: string, locale: string): Promise<string | null> {
38
+ return (await postingForApply(slug, locale))?.title ?? null;
17
39
  }