create-ailk 0.30.0 → 0.32.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 (95) hide show
  1. package/README.md +108 -4
  2. package/component-catalog.json +524 -8
  3. package/component-catalog.md +8 -5
  4. package/dist/body-heading.d.ts +13 -0
  5. package/dist/body-heading.js +19 -5
  6. package/dist/cli.js +99 -0
  7. package/dist/cutover.d.ts +114 -0
  8. package/dist/cutover.js +511 -0
  9. package/dist/design-presets.js +20 -4
  10. package/dist/export-content.d.ts +55 -0
  11. package/dist/export-content.js +303 -0
  12. package/dist/inventory.d.ts +216 -0
  13. package/dist/inventory.js +833 -0
  14. package/dist/language-import.d.ts +127 -0
  15. package/dist/language-import.js +451 -0
  16. package/dist/lib/env-catalog.js +1 -1
  17. package/dist/lib/import-mdx.d.ts +38 -0
  18. package/dist/lib/import-mdx.js +445 -65
  19. package/dist/lib/render-comparison.d.ts +251 -0
  20. package/dist/lib/render-comparison.js +573 -0
  21. package/dist/mdx-braces.d.ts +73 -0
  22. package/dist/mdx-braces.js +285 -0
  23. package/dist/migrate-content.d.ts +6 -2
  24. package/dist/migrate-content.js +62 -14
  25. package/dist/programmatic.js +2 -8
  26. package/dist/seed-workspace.d.ts +3 -1
  27. package/dist/seed-workspace.js +45 -16
  28. package/dist/site-manifest.js +6 -38
  29. package/dist/site-roster.d.ts +46 -3
  30. package/dist/site-roster.js +124 -4
  31. package/dist/surfaces.d.ts +8 -0
  32. package/dist/surfaces.js +16 -0
  33. package/dist/topology.d.ts +12 -0
  34. package/dist/topology.js +24 -10
  35. package/dist/update.d.ts +21 -1
  36. package/dist/update.js +142 -11
  37. package/package.json +3 -3
  38. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  39. package/templates/.claude/hooks/content-source-guard.sh +75 -0
  40. package/templates/.claude/settings.json +9 -0
  41. package/templates/.github/workflows/ci.yml +4 -0
  42. package/templates/CLAUDE.md +5 -0
  43. package/templates/apps/mcp/.env.example +7 -1
  44. package/templates/apps/mcp/CLAUDE.md +8 -4
  45. package/templates/apps/web/.env.example +4 -2
  46. package/templates/apps/web/CLAUDE.md +16 -5
  47. package/templates/apps/web/__tests__/setup.ts +9 -0
  48. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +156 -4
  49. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
  50. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  51. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
  52. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
  53. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
  54. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
  55. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
  56. package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
  57. package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
  58. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
  59. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
  60. package/templates/apps/web/app/[locale]/layout.tsx +122 -27
  61. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
  62. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  63. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
  64. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
  67. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
  68. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
  69. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
  70. package/templates/apps/web/app/llms.txt/route.ts +16 -9
  71. package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
  72. package/templates/apps/web/jest.config.cjs +46 -1
  73. package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
  74. package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
  75. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +93 -0
  76. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
  77. package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
  78. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +145 -11
  79. package/templates/apps/web/lib/bot-check.ts +113 -13
  80. package/templates/apps/web/lib/in-page-magnet.ts +4 -1
  81. package/templates/apps/web/lib/page-chrome.ts +65 -5
  82. package/templates/apps/web/lib/relay.ts +22 -3
  83. package/templates/apps/web/lib/site-identity.ts +75 -24
  84. package/templates/apps/web/messages/en.json +5 -1
  85. package/templates/apps/web/next.config.mjs +15 -1
  86. package/templates/apps/web/package.json +1 -1
  87. package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
  88. package/templates/apps/web/scripts/content-snapshot.ts +33 -0
  89. package/templates/apps/web/src/lib/routes.ts +5 -3
  90. package/templates/package.json +3 -1
  91. package/templates/tools/body-heading.ts +440 -0
  92. package/templates/tools/check-content-source.ts +108 -0
  93. package/templates/tools/check-mdx-body.ts +123 -0
  94. package/templates/tools/mdx-braces.ts +303 -0
  95. package/versions.json +48 -48
@@ -12,11 +12,24 @@
12
12
  * a check that throws, answers no boolean or takes over 5 seconds is
13
13
  * `unavailable`. It runs only on Vercel (`VERCEL=1`); `AILK_BOT_CHECK=off`
14
14
  * turns every check off and `AILK_BOOKING_BOT_CHECK=off` the booking's alone.
15
+ * `AILK_BOT_CHECK=observe` (#10715) runs the check and logs what it found as
16
+ * `observed`, but passes every write — for diagnosis, never for production.
15
17
  *
16
- * NO SECRET IS LOGGED. A refusal logs `{ path, outcome }` and, for a failed
17
- * check, the error's name — never its message.
18
+ * A BotID-protected site MUST be reached directly (#10715): Cloudflare
19
+ * DNS-only, no reverse proxy in front of Vercel. Behind a proxy the browser
20
+ * solves BotID's challenge but the write reaches Vercel from the proxy's
21
+ * egress, so BotID judges every real visitor a bot. A request carrying
22
+ * `cf-ray` is `proxied`, and the first one per process logs an error naming
23
+ * that fix.
24
+ *
25
+ * NO SECRET IS LOGGED. A refusal logs `{ path, outcome }`, BotID's
26
+ * `classificationReason`, `bypassed` and `isVerifiedBot`, the request's
27
+ * `host` and `x-forwarded-host` and whether it was `proxied` — no other
28
+ * header value, no form field — and, for a failed check, the error's name —
29
+ * never its message.
18
30
  */
19
31
  import { checkBotId } from "botid/server";
32
+ import { headers } from "next/headers";
20
33
 
21
34
  export type BotCheckScope = "write" | "booking";
22
35
  export type BotCheckOutcome = "pass" | "bot" | "unavailable";
@@ -46,6 +59,11 @@ function switchedOff(scope: BotCheckScope): boolean {
46
59
  );
47
60
  }
48
61
 
62
+ /** `AILK_BOT_CHECK=observe`: the check runs and logs, but never refuses. */
63
+ function observing(): boolean {
64
+ return process.env.AILK_BOT_CHECK?.trim().toLowerCase() === "observe";
65
+ }
66
+
49
67
  /** True when the check runs for `scope`. Read on every call, never cached. */
50
68
  export function botCheckOn(scope: BotCheckScope): boolean {
51
69
  return process.env.VERCEL === "1" && !switchedOff(scope);
@@ -73,13 +91,21 @@ function namedError(name: string): Error {
73
91
  return err;
74
92
  }
75
93
 
94
+ /** BotID's answer: `isBot` decides, the rest says why. */
95
+ interface BotVerdict {
96
+ isBot: boolean;
97
+ classificationReason: string | null;
98
+ bypassed: boolean | null;
99
+ isVerifiedBot: boolean | null;
100
+ }
101
+
76
102
  /**
77
- * BotID's verdict on this request: `true` for a bot, `false` for a human.
78
- * Throws `TimeoutError` past `BOT_CHECK_TIMEOUT_MS` and `InvalidVerdictError`
79
- * when `isBot` is not a boolean, as `checkBotId` returns for an error answer
80
- * from Vercel.
103
+ * BotID's verdict on this request: `isBot` is `true` for a bot, `false` for a
104
+ * human. Throws `TimeoutError` past `BOT_CHECK_TIMEOUT_MS` and
105
+ * `InvalidVerdictError` when `isBot` is not a boolean, as `checkBotId`
106
+ * returns for an error answer from Vercel.
81
107
  */
82
- async function isBotVerdict(): Promise<boolean> {
108
+ async function botVerdict(): Promise<BotVerdict> {
83
109
  let timer: ReturnType<typeof setTimeout> | undefined;
84
110
  try {
85
111
  const verdict = await Promise.race([
@@ -91,14 +117,67 @@ async function isBotVerdict(): Promise<boolean> {
91
117
  );
92
118
  }),
93
119
  ]);
94
- const isBot = (verdict as { isBot?: unknown } | undefined)?.isBot;
120
+ const answer = verdict as Record<string, unknown> | undefined;
121
+ const isBot = answer?.isBot;
95
122
  if (typeof isBot !== "boolean") throw namedError("InvalidVerdictError");
96
- return isBot;
123
+ const reason = answer?.classificationReason;
124
+ return {
125
+ isBot,
126
+ classificationReason: typeof reason === "string" ? reason : null,
127
+ bypassed: typeof answer?.bypassed === "boolean" ? answer.bypassed : null,
128
+ isVerifiedBot:
129
+ typeof answer?.isVerifiedBot === "boolean"
130
+ ? answer.isVerifiedBot
131
+ : null,
132
+ };
97
133
  } finally {
98
134
  clearTimeout(timer);
99
135
  }
100
136
  }
101
137
 
138
+ /** The only request facts a log carries: the two hosts and a proxy marker. */
139
+ interface RequestFacts {
140
+ host: string | null;
141
+ forwardedHost: string | null;
142
+ proxied: boolean;
143
+ }
144
+
145
+ /** Read as `checkBotId` reads them; unreadable headers are no facts, never a throw. */
146
+ async function requestFacts(): Promise<RequestFacts> {
147
+ try {
148
+ const h = await headers();
149
+ return {
150
+ host: h.get("host"),
151
+ forwardedHost: h.get("x-forwarded-host"),
152
+ proxied: h.has("cf-ray"),
153
+ };
154
+ } catch {
155
+ return { host: null, forwardedHost: null, proxied: false };
156
+ }
157
+ }
158
+
159
+ const PROXY_FIX =
160
+ "Serve the site direct to Vercel: set its Cloudflare DNS record to DNS-only (no orange cloud), never behind a reverse proxy (#10715).";
161
+
162
+ let warnedProxied = false;
163
+
164
+ /** A proxied request makes BotID refuse real visitors; say so once per process. */
165
+ function warnProxiedOnce(
166
+ source: string,
167
+ target: string,
168
+ facts: RequestFacts,
169
+ ): void {
170
+ if (warnedProxied) return;
171
+ warnedProxied = true;
172
+ console.error(`[${source}] reverse proxy detected`, {
173
+ path: target,
174
+ outcome: "proxied",
175
+ host: facts.host,
176
+ forwardedHost: facts.forwardedHost,
177
+ fix: PROXY_FIX,
178
+ });
179
+ }
180
+
102
181
  /**
103
182
  * The check for one write. `source` names the log prefix and `target` the
104
183
  * relayed path or the action's name — never a field value. Never throws.
@@ -116,21 +195,42 @@ export async function runBotCheck({
116
195
  warnBotCheckInactiveOnce(scope);
117
196
  return "pass";
118
197
  }
119
- let isBot: boolean;
198
+ const observe = observing();
199
+ let verdict: BotVerdict | undefined;
200
+ let failure: unknown;
120
201
  try {
121
- isBot = await isBotVerdict();
202
+ verdict = await botVerdict();
122
203
  } catch (err) {
204
+ failure = err;
205
+ }
206
+ // Read after the verdict, so the timeout starts with the check.
207
+ const facts = await requestFacts();
208
+ if (facts.proxied) warnProxiedOnce(source, target, facts);
209
+ if (!verdict) {
123
210
  console.error(`[${source}] bot check failed`, {
124
211
  path: target,
125
212
  outcome: "bot-check-failed",
126
- error: err instanceof Error ? err.name : "unknown",
213
+ error: failure instanceof Error ? failure.name : "unknown",
127
214
  });
128
- return "unavailable";
215
+ return observe ? "pass" : "unavailable";
216
+ }
217
+ const { isBot, ...detail } = verdict;
218
+ if (observe) {
219
+ console.warn(`[${source}] bot check observed`, {
220
+ path: target,
221
+ outcome: "observed",
222
+ isBot,
223
+ ...detail,
224
+ ...facts,
225
+ });
226
+ return "pass";
129
227
  }
130
228
  if (isBot) {
131
229
  console.warn(`[${source}] bot refused`, {
132
230
  path: target,
133
231
  outcome: "bot-detected",
232
+ ...detail,
233
+ ...facts,
134
234
  });
135
235
  return "bot";
136
236
  }
@@ -325,6 +325,8 @@ export interface NewsletterSignupOptions {
325
325
  /** The footer's compact form: stacked, the label `sr-only`, the placeholder naming the field. */
326
326
  compact?: boolean;
327
327
  className?: string;
328
+ /** #10629 D4 — the consent gate; the bound newsletter section passes `"on-submit"`. Omitted → the block's default. */
329
+ consentGate?: "disable" | "on-submit";
328
330
  }
329
331
 
330
332
  /**
@@ -335,7 +337,7 @@ export interface NewsletterSignupOptions {
335
337
  */
336
338
  export function newsletterSignup(
337
339
  view: InPageView & { form: NewsletterFormView },
338
- { locale, fallbackEmail, compact = false, className }: NewsletterSignupOptions,
340
+ { locale, fallbackEmail, compact = false, className, consentGate }: NewsletterSignupOptions,
339
341
  ): ReactElement {
340
342
  const { form, ending } = view;
341
343
  const placeholder = form.placeholder ?? (compact ? form.label : undefined);
@@ -356,6 +358,7 @@ export function newsletterSignup(
356
358
  }),
357
359
  ...(fallbackEmail !== undefined && { fallbackEmail }),
358
360
  ...(className !== undefined && { className }),
361
+ ...(consentGate !== undefined && { consentGate }),
359
362
  });
360
363
  }
361
364
 
@@ -25,9 +25,12 @@ import {
25
25
  type SchemaOrgType,
26
26
  } from "@working-theory/page-renderer";
27
27
  import {
28
+ FooterStyleSchema,
28
29
  HeaderStyleSchema,
29
30
  ROUTE_FAMILY_KEYS,
30
31
  SiteConfigSchema,
32
+ SiteFooterSchema,
33
+ type ClosingFooterAction,
31
34
  type FooterStyle,
32
35
  type HeaderStyle,
33
36
  type RouteFamilyChrome,
@@ -130,11 +133,17 @@ export const readSiteChromeSettings = cache(
130
133
  )
131
134
  : undefined;
132
135
  if (headerStyle !== undefined) settings.headerStyle = headerStyle;
133
- const footerStyle = validated(
134
- SiteConfigSchema.shape.footer,
135
- record["footer"],
136
- "footer",
137
- )?.style;
136
+ // #10550 D3 — the style alone, so an invalid closing-footer key
137
+ // (read by `readClosingFooterSettings`) never drops it.
138
+ const footer = record["footer"];
139
+ const footerStyle =
140
+ footer && typeof footer === "object"
141
+ ? validated(
142
+ FooterStyleSchema,
143
+ (footer as Record<string, unknown>)["style"],
144
+ "footer.style",
145
+ )
146
+ : undefined;
138
147
  if (footerStyle !== undefined) settings.footerStyle = footerStyle;
139
148
  const layout = validated(
140
149
  SiteConfigSchema.shape.layout,
@@ -157,6 +166,57 @@ export const readSiteChromeSettings = cache(
157
166
  },
158
167
  );
159
168
 
169
+ /** The closing footer's site settings (#10550 D3). */
170
+ export interface ClosingFooterSettings {
171
+ locality?: string;
172
+ actions?: ClosingFooterAction[];
173
+ legalBar: boolean;
174
+ }
175
+
176
+ /**
177
+ * `_site.mdx`'s closing-footer keys — `footer.locality`, `footer.actions`
178
+ * and `footer.legalBar` (#10550 D3). Each validates on its own, so one bad
179
+ * key drops only itself with one warning; an absent file or key is silent.
180
+ * A `footer` that is not an object, and each key `SiteFooterSchema` does not
181
+ * name, warn once too — the whole-object read this replaced warned on both.
182
+ */
183
+ export const readClosingFooterSettings = cache(
184
+ function readClosingFooterSettings(): ClosingFooterSettings {
185
+ try {
186
+ const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
187
+ const match = FRONTMATTER_RE.exec(raw);
188
+ if (!match) return { legalBar: false };
189
+ const fm: unknown = parseYaml(match[1] ?? "");
190
+ if (!fm || typeof fm !== "object") return { legalBar: false };
191
+ const footer = (fm as Record<string, unknown>)["footer"];
192
+ if (footer === undefined) return { legalBar: false };
193
+ if (!footer || typeof footer !== "object" || Array.isArray(footer)) {
194
+ warnOnce("footer", "expected an object");
195
+ return { legalBar: false };
196
+ }
197
+ const record = footer as Record<string, unknown>;
198
+ const shape = SiteFooterSchema.shape;
199
+ for (const key of Object.keys(record)) {
200
+ if (!(key in shape)) warnOnce(`footer.${key}`, "unknown key");
201
+ }
202
+ const settings: ClosingFooterSettings = {
203
+ legalBar:
204
+ validated(shape.legalBar, record["legalBar"], "footer.legalBar") === true,
205
+ };
206
+ const locality = validated(shape.locality, record["locality"], "footer.locality");
207
+ if (locality !== undefined) settings.locality = locality;
208
+ const actions = validated(shape.actions, record["actions"], "footer.actions");
209
+ if (actions !== undefined) settings.actions = actions;
210
+ return settings;
211
+ } catch (err) {
212
+ if ((err as { code?: unknown }).code !== "ENOENT") {
213
+ warnOnce("frontmatter", "could not be read");
214
+ }
215
+ return { legalBar: false };
216
+ }
217
+ },
218
+ );
219
+
160
220
  function camelCase(prefix: string): string {
161
221
  return prefix
162
222
  .replace(/^\//, "")
@@ -49,7 +49,11 @@
49
49
  */
50
50
  import { isIP } from "node:net";
51
51
 
52
- import { relayToApi, visitorForwardHeaders } from "@working-theory/api-client";
52
+ import {
53
+ getApiBase,
54
+ relayToApi,
55
+ visitorForwardHeaders,
56
+ } from "@working-theory/api-client";
53
57
 
54
58
  import { browserApiOrigin } from "./api-origin.generated";
55
59
  import { runBotCheck } from "./bot-check";
@@ -333,6 +337,21 @@ function hubConfigured(): boolean {
333
337
  );
334
338
  }
335
339
 
340
+ /**
341
+ * True when the API address `relayToApi` would call is this site's own host
342
+ * (#10553): the call would land back on this relay. (The API client itself
343
+ * refuses this server's own loopback port.)
344
+ */
345
+ function apiIsOwnHost(request: Request): boolean {
346
+ try {
347
+ const api = new URL(getApiBase());
348
+ // Parsed under the API's scheme, so a default port compares equal.
349
+ return api.host === new URL(`${api.protocol}//${ownHost(request)}`).host;
350
+ } catch {
351
+ return false;
352
+ }
353
+ }
354
+
336
355
  /**
337
356
  * The host the browser addressed: the first `x-forwarded-host`, else `host`.
338
357
  * The site's edge must overwrite `x-forwarded-host`, never pass a client's on.
@@ -486,10 +505,10 @@ export async function handleRelay(request: Request): Promise<Response> {
486
505
  body = text;
487
506
  }
488
507
 
489
- if (!hubConfigured()) {
508
+ if (!hubConfigured() || apiIsOwnHost(request)) {
490
509
  console.error("[relay] hub call failed", {
491
510
  path: entry.path,
492
- outcome: "no-api-address",
511
+ outcome: hubConfigured() ? "own-address" : "no-api-address",
493
512
  });
494
513
  return refusal(502, "relay_unreachable", "The site could not reach its API");
495
514
  }
@@ -21,6 +21,12 @@ import { readdirSync, readFileSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
 
23
23
  import {
24
+ resolvedAdapterKind,
25
+ type ContentAdapter,
26
+ } from "@working-theory/content-adapters";
27
+ import {
28
+ getContentAdapter,
29
+ setSiteFeatureListReader,
24
30
  setSiteIdentity,
25
31
  type SiteIdentityRegistration,
26
32
  } from "@working-theory/page-renderer";
@@ -46,6 +52,59 @@ function readFrontmatter(path: string): Record<string, unknown> | undefined {
46
52
  }
47
53
  }
48
54
 
55
+ /**
56
+ * A product page's feature titles. A `feature` section in the `steps` variant
57
+ * is how the product is set up, not what it does (the product page carries
58
+ * both, #6658 D3), so its step titles never enter the featureList.
59
+ */
60
+ function featuresOf(page: { sections?: unknown }): string[] {
61
+ return featureListFromSections(
62
+ (Array.isArray(page.sections)
63
+ ? (page.sections as Array<{ type: string; data?: unknown }>)
64
+ : []
65
+ ).filter(
66
+ (section) =>
67
+ !(
68
+ section.type === "feature" &&
69
+ (section.data as { variant?: unknown } | undefined)?.variant === "steps"
70
+ ),
71
+ ),
72
+ );
73
+ }
74
+
75
+ /** Pages in the order the directory scan reads their seeds: by `<slug>.mdx`. */
76
+ function inSeedOrder<T extends { slug: string }>(pages: readonly T[]): T[] {
77
+ const key = (page: T): string => `${page.slug}.mdx`;
78
+ return [...pages].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
79
+ }
80
+
81
+ /**
82
+ * The product's `featureList` read through the content adapter (#10644 D4):
83
+ * the first published `SoftwareProduct` page's features, else the titles of
84
+ * the published `Feature` pages, else none — the directory scan's first two
85
+ * steps, in the same seed order.
86
+ */
87
+ export async function readFeatureListFromAdapter(
88
+ adapter: Pick<ContentAdapter, "listPages">,
89
+ locale: string,
90
+ ): Promise<string[]> {
91
+ const [product] = inSeedOrder(await adapter.listPages("SoftwareProduct", locale));
92
+ if (product !== undefined) return featuresOf(product);
93
+ return inSeedOrder(await adapter.listPages("Feature", locale))
94
+ .map((page) => page.title)
95
+ .filter((title): title is string => typeof title === "string" && title.length > 0);
96
+ }
97
+
98
+ /** Whether the identity carries a product, whose featureList the graph shows. */
99
+ function carriesProduct(identity: SiteIdentityRegistration["identity"]): boolean {
100
+ return identity.kind === "Organization" && identity.secondary?.kind === "SoftwareApplication";
101
+ }
102
+
103
+ /** `_site.mdx`'s `defaultLocale`, else `"en"`. */
104
+ function localeOf(defaultLocale: unknown): string {
105
+ return typeof defaultLocale === "string" ? defaultLocale : "en";
106
+ }
107
+
49
108
  /**
50
109
  * The product's `featureList`: the feature titles of the product's own page —
51
110
  * the first `SoftwareProduct` seed in the default locale, in sorted order
@@ -54,22 +113,6 @@ function readFrontmatter(path: string): Record<string, unknown> | undefined {
54
113
  * first match in sorted order). Empty when there is neither.
55
114
  */
56
115
  function readFeatureList(contentRoot: string, locale: string): string[] {
57
- // A `feature` section in the `steps` variant is how the product is set up,
58
- // not what it does (the product page carries both, #6658 D3), so its step
59
- // titles never enter the featureList.
60
- const featuresOf = (frontmatter: Record<string, unknown>): string[] =>
61
- featureListFromSections(
62
- (Array.isArray(frontmatter.sections)
63
- ? (frontmatter.sections as Array<{ type: string; data?: unknown }>)
64
- : []
65
- ).filter(
66
- (section) =>
67
- !(
68
- section.type === "feature" &&
69
- (section.data as { variant?: unknown } | undefined)?.variant === "steps"
70
- ),
71
- ),
72
- );
73
116
  // #6658 D3: the product's own detail page (the first `SoftwareProduct`
74
117
  // seed in sorted order) is where the feature sections live now.
75
118
  const productDir = join(contentRoot, locale, "SoftwareProduct");
@@ -133,16 +176,24 @@ export function readSiteIdentity(
133
176
  baseUrl: typeof baseUrl === "string" ? baseUrl : identity.url,
134
177
  identity,
135
178
  };
136
- if (
137
- identity.kind === "Organization" &&
138
- identity.secondary?.kind === "SoftwareApplication"
139
- ) {
140
- registration.featureList = readFeatureList(
141
- contentRoot,
142
- typeof defaultLocale === "string" ? defaultLocale : "en",
143
- );
179
+ // Under the database adapter the list is read through the adapter when a
180
+ // page's JSON-LD is composed (#10644 D3), never scanned here.
181
+ if (carriesProduct(identity) && resolvedAdapterKind() !== "database") {
182
+ registration.featureList = readFeatureList(contentRoot, localeOf(defaultLocale));
144
183
  }
145
184
  return registration;
146
185
  }
147
186
 
148
187
  setSiteIdentity(readSiteIdentity());
188
+
189
+ // #10644 D3, D4 — under the database adapter the product's featureList is
190
+ // read through the adapter each time a page's JSON-LD is composed: an async
191
+ // read cannot run at module load.
192
+ if (resolvedAdapterKind() === "database") {
193
+ const contentRoot = resolveContentRoot();
194
+ const site = readSiteIdentity(contentRoot);
195
+ if (site !== undefined && carriesProduct(site.identity)) {
196
+ const locale = localeOf(readFrontmatter(join(contentRoot, "_site.mdx"))?.defaultLocale);
197
+ setSiteFeatureListReader(() => readFeatureListFromAdapter(getContentAdapter(), locale));
198
+ }
199
+ }
@@ -179,7 +179,11 @@
179
179
  },
180
180
  "events": {
181
181
  "heading": "Events",
182
- "emptyState": "Events listing coming soon."
182
+ "emptyState": "Events listing coming soon.",
183
+ "addToCalendar": "Add to calendar",
184
+ "addToGoogleCalendar": "Google Calendar",
185
+ "addToOutlook": "Outlook",
186
+ "addToAppleCalendar": "Apple Calendar (.ics)"
183
187
  },
184
188
  "faq": {
185
189
  "heading": "FAQ",
@@ -6,6 +6,7 @@ import { withSentryConfig } from "@sentry/nextjs";
6
6
  import createNextIntlPlugin from "next-intl/plugin";
7
7
  import { DocsPathSchema, ServicesPathSchema } from "@working-theory/site-config/schema";
8
8
  import { legacyRedirects, ROUTES, SERVICES_ROUTE } from "@working-theory/templates/routes";
9
+ import { assetRemotePattern } from "@working-theory/validation";
9
10
  import { parseStealth } from "@working-theory/web-i18n/stealth";
10
11
  import { withBotId } from "botid/next/config";
11
12
 
@@ -279,6 +280,14 @@ function readServiceParents() {
279
280
  const servicesPathAtBuild = readServicesPathFromSiteMdx();
280
281
  readServiceParents();
281
282
 
283
+ // #10647 D6 — a database spoke's pages name library images as `asset:<key>`,
284
+ // which the hub resolves to its own `/v1/assets/:id` URLs. `next/image` admits
285
+ // that path on the hub's origin, and nothing else, when `AILK_API_URL` (the
286
+ // server-side hub origin this site already reads) is an http(s) URL at build
287
+ // time; otherwise there is no `images` key, as before. A change of hub origin
288
+ // needs a rebuild.
289
+ const assetPatternAtBuild = assetRemotePattern(process.env.AILK_API_URL);
290
+
282
291
  /** @type {import('next').NextConfig} */
283
292
  const nextConfig = {
284
293
  reactStrictMode: true,
@@ -302,7 +311,9 @@ const nextConfig = {
302
311
  // content/ directory that carries the schema-typed pages, so the standalone
303
312
  // output shipped without them and postbuild's assert-content-bundled failed.
304
313
  outputFileTracingIncludes: {
305
- "/**": ["../../content/**/*.mdx"],
314
+ // #10605 D6 — the last-good content snapshot. An exact path, never a glob
315
+ // over `.ailk/`: that directory also holds the lead-inbox fallback.
316
+ "/**": ["../../content/**/*.mdx", "./.ailk/content-snapshot.json"],
306
317
  // #10041 D8 — /sitemap.xml and /llms.txt list the docs by reading this
307
318
  // app's own content/<locale>/docs/ at request time.
308
319
  "/sitemap.xml": ["./content/**/*.mdx"],
@@ -349,6 +360,9 @@ const nextConfig = {
349
360
  // mutates a tracked file as a side effect of running the dev server,
350
361
  // which this scaffold does not want.
351
362
  agentRules: false,
363
+ ...(assetPatternAtBuild === null
364
+ ? {}
365
+ : { images: { remotePatterns: [assetPatternAtBuild] } }),
352
366
  env: {
353
367
  // Comma-separated locale list baked at build time — consumed by middleware
354
368
  // and the sitemap route. NEXT_PUBLIC_ prefix makes it available in Edge
@@ -6,7 +6,7 @@
6
6
  "license": "Apache-2.0",
7
7
  "scripts": {
8
8
  "dev": "next dev --webpack",
9
- "build": "tsx scripts/check-nav-labels.ts && next build --webpack",
9
+ "build": "tsx scripts/check-nav-labels.ts && tsx scripts/content-snapshot.ts && next build --webpack",
10
10
  "postbuild": "tsx scripts/assert-content-bundled.ts",
11
11
  "start": "next start",
12
12
  "type-check": "tsc --noEmit",
@@ -5,6 +5,13 @@
5
5
  * preserving their path relative to outputFileTracingRoot (repo root). So
6
6
  * <repo-root>/content/en/HomePage/home.mdx → .next/standalone/content/en/...
7
7
  *
8
+ * Under CONTENT_ADAPTER=database (#10646 D1) a site's pages live on its hub,
9
+ * so content/ may hold no page at all. The build's own content file is then
10
+ * the snapshot (#10605), traced by exact path: when the build wrote one, the
11
+ * sentinel is apps/web/.ailk/content-snapshot.json →
12
+ * .next/standalone/apps/web/.ailk/content-snapshot.json. Without one (an
13
+ * unreachable hub, #10605 D3), the .mdx walk below still runs.
14
+ *
8
15
  * See: docs/runbooks/page-renderer-deploy-verification.md §1 Cause 2
9
16
  */
10
17
 
@@ -38,12 +45,25 @@ function firstMdx(dir: string): string | undefined {
38
45
  }
39
46
  return undefined;
40
47
  }
41
- const sentinelAbs = firstMdx(contentDir);
42
- if (sentinelAbs === undefined) {
43
- console.error("[assert-content-bundled] ERROR: no .mdx file under content/ to trace.");
44
- process.exit(1);
48
+ const repoRoot = resolve(appWebDir, "..", "..");
49
+ const snapshotAbs = join(appWebDir, ".ailk", "content-snapshot.json");
50
+ const database = process.env.CONTENT_ADAPTER === "database";
51
+ const snapshotSentinel = database && existsSync(snapshotAbs);
52
+ let sentinelRelative: string;
53
+ if (snapshotSentinel) {
54
+ sentinelRelative = relative(repoRoot, snapshotAbs);
55
+ } else {
56
+ const sentinelAbs = firstMdx(contentDir);
57
+ if (sentinelAbs === undefined) {
58
+ console.error(
59
+ database
60
+ ? "[assert-content-bundled] ERROR: no content snapshot (.ailk/content-snapshot.json) and no .mdx file under content/ to trace."
61
+ : "[assert-content-bundled] ERROR: no .mdx file under content/ to trace.",
62
+ );
63
+ process.exit(1);
64
+ }
65
+ sentinelRelative = join("content", relative(contentDir, sentinelAbs));
45
66
  }
46
- const sentinelRelative = join("content", relative(contentDir, sentinelAbs));
47
67
  const sentinelPath = join(standaloneDir, sentinelRelative);
48
68
 
49
69
  if (!existsSync(standaloneDir)) {
@@ -60,11 +80,19 @@ if (!existsSync(sentinelPath)) {
60
80
  );
61
81
  console.error(" Expected path:", sentinelPath);
62
82
  console.error(
63
- " This means outputFileTracingIncludes did not bundle content/ into the build artifact.",
83
+ snapshotSentinel
84
+ ? " This means outputFileTracingIncludes did not bundle the content snapshot into the build artifact."
85
+ : " This means outputFileTracingIncludes did not bundle content/ into the build artifact.",
86
+ );
87
+ console.error(
88
+ snapshotSentinel
89
+ ? " The deployed site cannot fall back to its last-good hub content."
90
+ : " The deployed site cannot serve MDX content.",
64
91
  );
65
- console.error(" The deployed site cannot serve MDX content.");
66
92
  console.error(
67
- " Fix: verify outputFileTracingIncludes in apps/web/next.config.mjs covers '../../content/**/*.mdx'.",
93
+ ` Fix: verify outputFileTracingIncludes in apps/web/next.config.mjs covers ${
94
+ snapshotSentinel ? "'./.ailk/content-snapshot.json'" : "'../../content/**/*.mdx'"
95
+ }.`,
68
96
  );
69
97
  console.error(
70
98
  " See: docs/runbooks/page-renderer-deploy-verification.md §1 Cause 2",
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Write the last-good content snapshot (#10605 D5). Runs in `pnpm build`
3
+ * before `next build` — never in `prebuild`, which the docs-blog module owns
4
+ * in a scaffold.
5
+ *
6
+ * Under `CONTENT_ADAPTER=database` it reads every published page from the hub
7
+ * into `.ailk/content-snapshot.json`; under any other adapter it does nothing.
8
+ * An unreachable hub keeps the previous snapshot and the build goes on; only
9
+ * a configuration fault (a 4xx other than 404/429) fails the build.
10
+ */
11
+
12
+ import { join } from "node:path";
13
+
14
+ import { refreshContentSnapshot } from "@working-theory/page-renderer/snapshot";
15
+ import { readLocales } from "@working-theory/web-i18n";
16
+
17
+ function enabledLocales(): string[] {
18
+ try {
19
+ return readLocales(join(process.cwd(), "..", ".."));
20
+ } catch (error) {
21
+ // next.config.mjs's own fallback when project.yaml has no locales.
22
+ console.warn("[content-snapshot] locales unreadable, using en:", error);
23
+ return ["en"];
24
+ }
25
+ }
26
+
27
+ refreshContentSnapshot({ locales: enabledLocales() }).then(
28
+ (outcome) => console.log(`[content-snapshot] ${outcome}`),
29
+ (error: unknown) => {
30
+ console.error("[content-snapshot] ERROR:", error);
31
+ process.exit(1);
32
+ },
33
+ );
@@ -39,9 +39,11 @@ export function resolveBaseUrl(): string {
39
39
  * `packages/templates/src/__tests__/routes-map.test.ts`'s table test and
40
40
  * `apps/web/__tests__/routes/routes-map-identity.test.ts`'s differential
41
41
  * test over the live content tree). The signature is annotated explicitly so
42
- * this stays the exact `(type: string, slug: string) => string` contract the
43
- * three call sites (`sitemap.xml`, `llms.txt`, `docs-blog/llms-route.ts`)
44
- * depend on, even though `routeFor`'s own `slug` parameter is optional.
42
+ * this stays the exact `(type: string, slug: string) => string` contract its
43
+ * callers depend on, even though `routeFor`'s own `slug` parameter is
44
+ * optional. `sitemap.xml`, `llms.txt` and, since #10775, the docs-blog
45
+ * module's `docs-blog/llms-route.ts` call `pagePathFor` instead; the export
46
+ * stays for a docs-blog site that installed the older copy of that route.
45
47
  */
46
48
  export const slugToPath: (type: string, slug: string) => string = routeFor;
47
49