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
@@ -18,6 +18,7 @@ import {
18
18
  import { ROUTES } from "@working-theory/templates/routes";
19
19
 
20
20
  import {
21
+ readClosingFooterSettings,
21
22
  readSiteChromeSettings,
22
23
  resolveChromePage,
23
24
  resolveHomeLayout,
@@ -28,6 +29,9 @@ import {
28
29
  } from "../page-chrome";
29
30
  import type { SiteChrome } from "../site-chrome";
30
31
 
32
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
33
+ process.env.CONTENT_ADAPTER = "mdx";
34
+
31
35
  // ── AC-2 — the route families ────────────────────────────────────────────
32
36
 
33
37
  function camel(prefix: string): string {
@@ -346,6 +350,95 @@ describe("readSiteChromeSettings and resolveChromePage (#10049 AC-4)", () => {
346
350
  expect(warn).not.toHaveBeenCalled();
347
351
  });
348
352
 
353
+ describe("readClosingFooterSettings (#10550 AC-1)", () => {
354
+ // Each case's read is cached per call site only through React's `cache`,
355
+ // which is a no-op outside a request, and its warnings are once per
356
+ // process — so every case resets the module.
357
+ function fresh(): typeof import("../page-chrome") {
358
+ let mod: typeof import("../page-chrome") | undefined;
359
+ jest.isolateModules(() => {
360
+ mod = require("../page-chrome") as typeof import("../page-chrome");
361
+ });
362
+ return mod!;
363
+ }
364
+
365
+ const VALID_FOOTER = [
366
+ "footer:",
367
+ " style: closing",
368
+ " locality: Colorado Springs, CO",
369
+ " actions:",
370
+ " - label: Get in touch →",
371
+ " href: /contact",
372
+ " - label: See my pricing",
373
+ " href: /pricing",
374
+ " legalBar: true",
375
+ ];
376
+
377
+ it("reads the three settings beside the style", () => {
378
+ writeSite(VALID_FOOTER);
379
+ const mod = fresh();
380
+ expect(mod.readClosingFooterSettings()).toEqual({
381
+ locality: "Colorado Springs, CO",
382
+ actions: [
383
+ { label: "Get in touch →", href: "/contact" },
384
+ { label: "See my pricing", href: "/pricing" },
385
+ ],
386
+ legalBar: true,
387
+ });
388
+ expect(mod.readSiteChromeSettings().footerStyle).toBe("closing");
389
+ expect(warn).not.toHaveBeenCalled();
390
+ });
391
+
392
+ it("an invalid actions drops itself alone, with one warning that never echoes the value", () => {
393
+ writeSite([
394
+ "footer:",
395
+ " style: closing",
396
+ " locality: Colorado Springs, CO",
397
+ " actions:",
398
+ " - label: x",
399
+ " href: https://x.test",
400
+ " legalBar: true",
401
+ ]);
402
+ const mod = fresh();
403
+ const settings = mod.readClosingFooterSettings();
404
+ expect(settings.actions).toBeUndefined();
405
+ expect(settings.locality).toBe("Colorado Springs, CO");
406
+ expect(settings.legalBar).toBe(true);
407
+ expect(mod.readSiteChromeSettings().footerStyle).toBe("closing");
408
+ expect(warn).toHaveBeenCalledTimes(1);
409
+ const message = String(warn.mock.calls[0]?.[0]);
410
+ expect(message).toContain("footer.actions");
411
+ expect(message).not.toContain("https://x.test");
412
+ });
413
+
414
+ it("no footer key is the bar off and no warning", () => {
415
+ writeSite([]);
416
+ expect(fresh().readClosingFooterSettings()).toEqual({ legalBar: false });
417
+ expect(warn).not.toHaveBeenCalled();
418
+ });
419
+
420
+ it("an unknown footer key warns once under its path and keeps the style", () => {
421
+ writeSite(["footer:", " style: closing", " colour: red"]);
422
+ const mod = fresh();
423
+ expect(mod.readClosingFooterSettings()).toEqual({ legalBar: false });
424
+ expect(mod.readSiteChromeSettings().footerStyle).toBe("closing");
425
+ expect(warn).toHaveBeenCalledTimes(1);
426
+ expect(String(warn.mock.calls[0]?.[0])).toContain("footer.colour");
427
+ });
428
+
429
+ it("a footer that is not an object warns once under footer", () => {
430
+ writeSite(["footer: closing"]);
431
+ expect(fresh().readClosingFooterSettings()).toEqual({ legalBar: false });
432
+ expect(warn).toHaveBeenCalledTimes(1);
433
+ expect(String(warn.mock.calls[0]?.[0])).toContain("_site.mdx footer:");
434
+ });
435
+
436
+ it("an absent _site.mdx is the bar off and no warning", () => {
437
+ expect(readClosingFooterSettings()).toEqual({ legalBar: false });
438
+ expect(warn).not.toHaveBeenCalled();
439
+ });
440
+ });
441
+
349
442
  it("resolveChromePage reads a page's and the home's own chrome frontmatter, and never a blog post", async () => {
350
443
  writePage("AboutPage", "about", ["header: bare", "footer: centered"]);
351
444
  writePage("HomePage", "home", ["layout: deck"]);
@@ -214,6 +214,31 @@ describe("AC-1 — forwarding, one entry each", () => {
214
214
  });
215
215
  });
216
216
 
217
+ describe("never this site's own address (#10553)", () => {
218
+ const priorOrigin = process.env.__NEXT_PRIVATE_ORIGIN;
219
+ afterEach(() => {
220
+ if (priorOrigin === undefined) delete process.env.__NEXT_PRIVATE_ORIGIN;
221
+ else process.env.__NEXT_PRIVATE_ORIGIN = priorOrigin;
222
+ });
223
+
224
+ it("answers 502 and calls nothing when the API address is this site's own host", async () => {
225
+ process.env.AILK_API_URL = SITE;
226
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
227
+ expect(res.status).toBe(502);
228
+ expect(await errorCode(res)).toBe("relay_unreachable");
229
+ expect(fetchMock).not.toHaveBeenCalled();
230
+ });
231
+
232
+ it("answers 502 and calls nothing when AILK_API_URL is unset and the fallback is this server's own port", async () => {
233
+ delete process.env.AILK_API_URL;
234
+ process.env.__NEXT_PRIVATE_ORIGIN = "http://localhost:3001";
235
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
236
+ expect(res.status).toBe(502);
237
+ expect(await errorCode(res)).toBe("relay_unreachable");
238
+ expect(fetchMock).not.toHaveBeenCalled();
239
+ });
240
+ });
241
+
217
242
  describe("AC-2 — only the list", () => {
218
243
  it.each([
219
244
  ["POST", "/v1/workspaces"],
@@ -1010,10 +1035,10 @@ describe("the booking bot check (#9889)", () => {
1010
1035
  expect(checkBotIdMock).toHaveBeenCalledTimes(1);
1011
1036
  expect(checkBotIdMock.mock.calls[0]).toEqual([]);
1012
1037
  expect(warnSpy).toHaveBeenCalledTimes(1);
1013
- expect(warnSpy).toHaveBeenCalledWith("[relay] bot refused", {
1014
- path: BOOK,
1015
- outcome: "bot-detected",
1016
- });
1038
+ expect(warnSpy).toHaveBeenCalledWith(
1039
+ "[relay] bot refused",
1040
+ expect.objectContaining({ path: BOOK, outcome: "bot-detected" }),
1041
+ );
1017
1042
  });
1018
1043
  });
1019
1044
 
@@ -1290,10 +1315,10 @@ describe("the write bot check on the lead routes (#9255)", () => {
1290
1315
  expect(await errorCode(res)).toBe("bot_detected");
1291
1316
  expect(fetchMock).not.toHaveBeenCalled();
1292
1317
  expect(checkBotIdMock).toHaveBeenCalledTimes(1);
1293
- expect(warnSpy).toHaveBeenCalledWith("[relay] bot refused", {
1294
- path,
1295
- outcome: "bot-detected",
1296
- });
1318
+ expect(warnSpy).toHaveBeenCalledWith(
1319
+ "[relay] bot refused",
1320
+ expect.objectContaining({ path, outcome: "bot-detected" }),
1321
+ );
1297
1322
  });
1298
1323
 
1299
1324
  it.each(LEAD_ROUTES)(
@@ -0,0 +1,196 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * The identity featureList under the database adapter (#10644 AC-2, D4): read
7
+ * through the content adapter when a page's JSON-LD is composed, never from a
8
+ * directory scan. Under the mdx adapter no reader is registered.
9
+ */
10
+ import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ import type { ContentAdapter } from "@working-theory/content-adapters";
15
+
16
+ import { readFeatureListFromAdapter, readSiteIdentity } from "../site-identity";
17
+
18
+ type PageRenderer = typeof import("@working-theory/page-renderer");
19
+
20
+ // A spy on every directory read, so the no-scan case can see one.
21
+ jest.mock("node:fs", () => {
22
+ const actual = jest.requireActual<typeof import("node:fs")>("node:fs");
23
+ return { ...actual, readdirSync: jest.fn(actual.readdirSync) };
24
+ });
25
+
26
+ const mockGetContentAdapter = jest.fn<Pick<ContentAdapter, "listPages">, []>();
27
+ jest.mock("@working-theory/page-renderer", () => ({
28
+ ...jest.requireActual<PageRenderer>("@working-theory/page-renderer"),
29
+ getContentAdapter: () => mockGetContentAdapter(),
30
+ }));
31
+
32
+ type Row = { type: string; slug: string; title: string; sections?: unknown[] };
33
+
34
+ function stubAdapter(rows: Partial<Record<string, Row[]>>): Pick<ContentAdapter, "listPages"> {
35
+ // eslint-disable-next-line no-restricted-syntax -- a partial row stub is not a full ContentPage; the reader reads only slug, title and sections
36
+ return {
37
+ listPages: jest.fn(async (type: string) => rows[type] ?? []),
38
+ } as unknown as Pick<ContentAdapter, "listPages">;
39
+ }
40
+
41
+ const feature = (titles: string[], variant?: string): unknown => ({
42
+ type: "feature",
43
+ data: { ...(variant ? { variant } : {}), features: titles.map((title) => ({ title })) },
44
+ });
45
+
46
+ const product = (slug: string, sections: unknown[]): Row => ({
47
+ type: "SoftwareProduct",
48
+ slug,
49
+ title: slug,
50
+ sections,
51
+ });
52
+
53
+ const PRODUCT_ROWS = {
54
+ SoftwareProduct: [
55
+ product("zeta", [feature(["Z1"])]),
56
+ product("alpha", [feature(["A1", "A2"]), feature(["S1"], "steps")]),
57
+ ],
58
+ };
59
+
60
+ describe("readFeatureListFromAdapter (#10644 D4)", () => {
61
+ it("the first product page in seed order gives its features, steps excluded", async () => {
62
+ expect(await readFeatureListFromAdapter(stubAdapter(PRODUCT_ROWS), "en")).toEqual(["A1", "A2"]);
63
+ });
64
+
65
+ it("orders by file name, so a-b.mdx comes before a.mdx", async () => {
66
+ const adapter = stubAdapter({
67
+ SoftwareProduct: [product("a", [feature(["from-a"])]), product("a-b", [feature(["from-a-b"])])],
68
+ });
69
+ expect(await readFeatureListFromAdapter(adapter, "en")).toEqual(["from-a-b"]);
70
+ });
71
+
72
+ it("with no product page, the Feature page titles in seed order, empty titles dropped", async () => {
73
+ const adapter = stubAdapter({
74
+ Feature: [
75
+ { type: "Feature", slug: "b-feature", title: "Beta" },
76
+ { type: "Feature", slug: "a-feature", title: "Alpha" },
77
+ { type: "Feature", slug: "c-feature", title: "" },
78
+ ],
79
+ });
80
+ expect(await readFeatureListFromAdapter(adapter, "en")).toEqual(["Alpha", "Beta"]);
81
+ });
82
+
83
+ it("with neither, an empty list", async () => {
84
+ expect(await readFeatureListFromAdapter(stubAdapter({}), "en")).toEqual([]);
85
+ });
86
+ });
87
+
88
+ describe("the featureList registration with no content directory (#10644 AC-2)", () => {
89
+ const ORIGIN = "https://example.com";
90
+ let root: string;
91
+ const saved = {
92
+ adapter: process.env.CONTENT_ADAPTER,
93
+ contentRoot: process.env.AILK_CONTENT_ROOT,
94
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
95
+ };
96
+
97
+ function restore(name: string, value: string | undefined): void {
98
+ if (value === undefined) delete process.env[name];
99
+ else process.env[name] = value;
100
+ }
101
+
102
+ let renderer: PageRenderer | undefined;
103
+
104
+ /** Load site-identity afresh; return the page-renderer instance it registered with. */
105
+ function loadSiteIdentity(): PageRenderer {
106
+ jest.isolateModules(() => {
107
+ jest.requireActual("../site-identity");
108
+ renderer = jest.requireMock<PageRenderer>("@working-theory/page-renderer");
109
+ });
110
+ return renderer!;
111
+ }
112
+
113
+ const appNode = (result: Record<string, unknown>): Record<string, unknown> | undefined =>
114
+ ((result["@graph"] ?? []) as Array<Record<string, unknown>>).find(
115
+ (node) => node["@type"] === "SoftwareApplication",
116
+ );
117
+
118
+ beforeEach(() => {
119
+ root = mkdtempSync(join(tmpdir(), "ailk-10644-site-"));
120
+ writeFileSync(
121
+ join(root, "_site.mdx"),
122
+ [
123
+ "---",
124
+ "siteName: Acme",
125
+ `baseUrl: ${ORIGIN}`,
126
+ "siteIdentity:",
127
+ " kind: Organization",
128
+ " name: Acme Inc.",
129
+ ` url: ${ORIGIN}`,
130
+ " secondary:",
131
+ " kind: SoftwareApplication",
132
+ " name: Acme App",
133
+ ` url: ${ORIGIN}/app`,
134
+ "---",
135
+ "",
136
+ ].join("\n"),
137
+ );
138
+ process.env.AILK_CONTENT_ROOT = root;
139
+ process.env.NEXT_PUBLIC_SITE_URL = ORIGIN;
140
+ mockGetContentAdapter.mockReset();
141
+ mockGetContentAdapter.mockReturnValue(stubAdapter(PRODUCT_ROWS));
142
+ });
143
+
144
+ afterEach(() => {
145
+ renderer?.setSiteFeatureListReader(undefined);
146
+ renderer?.setSiteIdentity(undefined);
147
+ renderer = undefined;
148
+ rmSync(root, { recursive: true, force: true });
149
+ restore("CONTENT_ADAPTER", saved.adapter);
150
+ restore("AILK_CONTENT_ROOT", saved.contentRoot);
151
+ restore("NEXT_PUBLIC_SITE_URL", saved.siteUrl);
152
+ });
153
+
154
+ it("database: readSiteIdentity carries no featureList and scans no directory under the root", () => {
155
+ process.env.CONTENT_ADAPTER = "database";
156
+ const readdir = jest.mocked(readdirSync);
157
+ readdir.mockClear();
158
+ const registration = readSiteIdentity(root);
159
+ expect(registration).toBeDefined();
160
+ expect(registration).not.toHaveProperty("featureList");
161
+ const underRoot = readdir.mock.calls.filter(([path]) => String(path).startsWith(root));
162
+ expect(underRoot).toEqual([]);
163
+ });
164
+
165
+ it("database: the composed SoftwareApplication carries the adapter's featureList", async () => {
166
+ process.env.CONTENT_ADAPTER = "database";
167
+ const renderer = loadSiteIdentity();
168
+ const result = await renderer.pageToJsonLd(
169
+ { type: "AboutPage", slug: "about", title: "About" } as Parameters<PageRenderer["pageToJsonLd"]>[0],
170
+ "en",
171
+ );
172
+ expect(appNode(result as Record<string, unknown>)?.["featureList"]).toEqual(["A1", "A2"]);
173
+ });
174
+
175
+ it("mdx: no reader is registered, and the adapter is never read", async () => {
176
+ delete process.env.CONTENT_ADAPTER;
177
+ const renderer = loadSiteIdentity();
178
+ const result = await renderer.pageToJsonLd(
179
+ { type: "AboutPage", slug: "about", title: "About" } as Parameters<PageRenderer["pageToJsonLd"]>[0],
180
+ "en",
181
+ [],
182
+ {
183
+ siteName: "Acme",
184
+ baseUrl: ORIGIN,
185
+ identity: {
186
+ kind: "Organization",
187
+ name: "Acme Inc.",
188
+ url: ORIGIN,
189
+ secondary: { kind: "SoftwareApplication", name: "Acme App", url: `${ORIGIN}/app` },
190
+ },
191
+ },
192
+ );
193
+ expect(appNode(result as Record<string, unknown>)).not.toHaveProperty("featureList");
194
+ expect(mockGetContentAdapter).toHaveBeenCalledTimes(0);
195
+ });
196
+ });
@@ -29,6 +29,16 @@
29
29
  * `tools/content-compile`, which the scaffold does not ship, and the adapter
30
30
  * reads the same files with the same frontmatter schema the manifest is
31
31
  * compiled from. See docs/internal/decisions/scaffold-structured-data-check.md.
32
+ *
33
+ * The pages come from the site's active content source (#10704):
34
+ * `resolvedAdapterKind()`, the resolver every page reads through. Under `mdx`
35
+ * (the default) they are the files under `content/`. Under `database` — a
36
+ * spoke that serves every page from its hub and may hold no page file — they
37
+ * are the hub's published pages as the build's last-good snapshot holds them
38
+ * (`.ailk/content-snapshot.json`, written by `pnpm --filter web build` under
39
+ * `CONTENT_ADAPTER=database`), read through the site's own content source
40
+ * with the API origin pinned to a refused loopback, so a test run reads no
41
+ * hub and needs no key.
32
42
  */
33
43
  import {
34
44
  copyFileSync,
@@ -51,8 +61,11 @@ import type {
51
61
  import {
52
62
  SCHEMA_ORG_TYPES,
53
63
  createMdxAdapter,
64
+ resolvedAdapterKind,
54
65
  } from "@working-theory/content-adapters";
55
66
  import {
67
+ getContentAdapter,
68
+ getMdxSource,
56
69
  getPageSections,
57
70
  pageToJsonLd,
58
71
  siteOrigin,
@@ -158,6 +171,94 @@ const adapter = createMdxAdapter({
158
171
  defaultLocale: DEFAULT_LOCALE,
159
172
  });
160
173
 
174
+ // ── #10704 D2 — a site that serves its pages from its hub ──────────────────
175
+
176
+ const SOURCE = resolvedAdapterKind();
177
+ // page-renderer's `CONTENT_SNAPSHOT_PATH`, which this jest config maps no
178
+ // subpath to read; create-ailk's structured-data-check suite pins the two
179
+ // equal. Relative to cwd, as the content source reads it.
180
+ const SNAPSHOT_REL = ".ailk/content-snapshot.json";
181
+
182
+ // No read may reach a hub: every page and list read fails as unavailable and
183
+ // the content source answers from the snapshot, the path a deployed spoke
184
+ // takes while its hub is down.
185
+ if (SOURCE === "database") process.env.AILK_API_URL = "http://127.0.0.1:9";
186
+
187
+ /**
188
+ * Every page the snapshot holds, keyed by its cache tag
189
+ * (`content:<workspace>:<type>:<slug>:<locale>`, `cache-tags.ts`), or why it
190
+ * holds none. Read only under `database`.
191
+ */
192
+ function readHubPages(): {
193
+ pages: Array<{ locale: string; type: string; slug: string }>;
194
+ problem: string | null;
195
+ } {
196
+ const path = join(process.cwd(), SNAPSHOT_REL);
197
+ const build = "run `pnpm --filter web build` under CONTENT_ADAPTER=database to write it";
198
+ if (!existsSync(path)) {
199
+ return { pages: [], problem: `${path} does not exist — ${build}` };
200
+ }
201
+ let parsed: { version?: unknown; pages?: unknown; lists?: unknown } | null;
202
+ try {
203
+ parsed = JSON.parse(readFileSync(path, "utf8")) as typeof parsed;
204
+ } catch {
205
+ parsed = null;
206
+ }
207
+ if (
208
+ parsed === null ||
209
+ typeof parsed !== "object" ||
210
+ parsed.version !== 1 ||
211
+ typeof parsed.pages !== "object" ||
212
+ parsed.pages === null ||
213
+ typeof parsed.lists !== "object" ||
214
+ parsed.lists === null
215
+ ) {
216
+ return { pages: [], problem: `${path} is not a version-1 content snapshot — ${build}` };
217
+ }
218
+ const pages = Object.keys(parsed.pages).map((tag) => {
219
+ const parts = tag.split(":");
220
+ return {
221
+ type: parts[2] ?? "",
222
+ slug: parts.slice(3, -1).join(":"),
223
+ locale: parts[parts.length - 1] ?? "",
224
+ };
225
+ });
226
+ if (pages.length === 0) {
227
+ return { pages, problem: `${path} holds no page — ${build}` };
228
+ }
229
+ return { pages, problem: null };
230
+ }
231
+
232
+ const HUB = SOURCE === "database" ? readHubPages() : { pages: [], problem: null };
233
+
234
+ /** The snapshot's (locale, type) pairs, in the shape the content walk takes. */
235
+ function hubTypeDirs(): Array<{ locale: string; typeDir: string }> {
236
+ const seen = new Set<string>();
237
+ const out: Array<{ locale: string; typeDir: string }> = [];
238
+ for (const { locale, type } of HUB.pages) {
239
+ if (seen.has(`${locale}/${type}`)) continue;
240
+ seen.add(`${locale}/${type}`);
241
+ out.push({ locale, typeDir: type });
242
+ }
243
+ return out.sort((a, b) =>
244
+ `${a.locale}/${a.typeDir}`.localeCompare(`${b.locale}/${b.typeDir}`),
245
+ );
246
+ }
247
+
248
+ /** A type's pages in a locale, from the active source. */
249
+ function listDetailFor(type: PageType, locale: string): Promise<ContentPage[]> {
250
+ return SOURCE === "database"
251
+ ? getContentAdapter().listDetail(type, locale)
252
+ : adapter.listDetail(type, locale);
253
+ }
254
+
255
+ /** One page's source, from the active source (`ContentAdapter` has no `getPageSource`). */
256
+ function pageSourceFor(type: PageType, slug: string, locale: string) {
257
+ return SOURCE === "database"
258
+ ? getMdxSource(type, slug, locale)
259
+ : adapter.getPageSource(type, slug, locale);
260
+ }
261
+
161
262
  // ── JSON-LD flatten + validate (the ten lines from tools/scrape-jsonld.ts) ──
162
263
 
163
264
  function flattenBlocks(parsed: unknown): JsonLdObject[] {
@@ -244,9 +345,21 @@ function identityFailure(blocks: JsonLdObject[], label: string): string | null {
244
345
  // ── 1. Content-driven: every content page builds valid JSON-LD ──────────────
245
346
 
246
347
  describe("structured data — every content page builds valid JSON-LD", () => {
247
- const typeDirs = contentTypeDirs();
348
+ const typeDirs = SOURCE === "database" ? hubTypeDirs() : contentTypeDirs();
349
+ let hubPagesChecked = 0;
350
+
351
+ afterAll(() => {
352
+ if (SOURCE === "database") {
353
+ console.info(`structured-data: ${hubPagesChecked} hub page(s) checked from ${SNAPSHOT_REL}`);
354
+ }
355
+ });
248
356
 
249
357
  it("finds content to check", () => {
358
+ if (SOURCE === "database") {
359
+ // A hub-served site with no snapshot has nothing to check: misconfigured.
360
+ expect(HUB.problem).toBeNull();
361
+ return;
362
+ }
250
363
  // A site with no content directory at all is misconfigured, not clean.
251
364
  expect(existsSync(CONTENT_ROOT)).toBe(true);
252
365
  expect(typeDirs.length).toBeGreaterThan(0);
@@ -274,14 +387,17 @@ describe("structured data — every content page builds valid JSON-LD", () => {
274
387
  }
275
388
  const type = typeDir as PageType;
276
389
 
277
- const pages: ContentPage[] = await adapter.listDetail(type, locale);
278
- const dirPath = join(CONTENT_ROOT, locale, typeDir);
279
- const mdxCount = readdirSync(dirPath).filter(
280
- (n) => n.endsWith(".mdx") && !n.startsWith("_"),
281
- ).length;
390
+ const pages: ContentPage[] = await listDetailFor(type, locale);
282
391
  // The adapter drops a page whose frontmatter fails the schema; that
283
392
  // page would render nothing, so count it here rather than lose it.
284
- expect(pages.length).toBe(mdxCount);
393
+ // Under `database` the count is the pages the hub published (#10704).
394
+ const expected =
395
+ SOURCE === "database"
396
+ ? HUB.pages.filter((p) => p.locale === locale && p.type === type).length
397
+ : readdirSync(join(CONTENT_ROOT, locale, typeDir)).filter(
398
+ (n) => n.endsWith(".mdx") && !n.startsWith("_"),
399
+ ).length;
400
+ expect(pages.length).toBe(expected);
285
401
 
286
402
  const failures: string[] = [];
287
403
  for (const page of pages) {
@@ -295,6 +411,7 @@ describe("structured data — every content page builds valid JSON-LD", () => {
295
411
  if (failure !== null) failures.push(failure);
296
412
  }
297
413
  expect(failures).toEqual([]);
414
+ if (SOURCE === "database") hubPagesChecked += pages.length;
298
415
  },
299
416
  );
300
417
  });
@@ -632,7 +749,17 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
632
749
  return;
633
750
  }
634
751
  const exempt = exemption(route, src);
635
- const identity = routeIdentity(src);
752
+ // #10552 (merged in #10586) put a program-Event renderPage ahead of the
753
+ // meet hub's BookCall one in `[slug]/[detail]`. The params this check
754
+ // builds for that route are the meet hub's (`renderRoute`), so its checked
755
+ // type stays BookCall; the Event branch is covered by the page's own suite.
756
+ if (route === "[slug]/[detail]") {
757
+ expect(src).toMatch(/renderPage\(\{\s*type:\s*"BookCall",\s*slug:\s*detail\b/);
758
+ }
759
+ const identity =
760
+ route === "[slug]/[detail]"
761
+ ? { type: "BookCall", slug: null }
762
+ : routeIdentity(src);
636
763
  if (exempt !== undefined) {
637
764
  // Declared, with a reason, in NO_STRUCTURED_DATA_ROUTES above. The
638
765
  // reason is false for a route that does render through renderPage —
@@ -664,14 +791,16 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
664
791
  // #10043 D7 — a Service's category, for the nested route's params.
665
792
  const parents = new Map<string, string>();
666
793
  if (identity.slug !== null) {
667
- const page = await adapter.getPageSource(
794
+ const page = await pageSourceFor(
668
795
  type,
669
796
  identity.slug,
670
797
  DEFAULT_LOCALE,
671
798
  );
672
799
  if (page === null) {
673
800
  throw new Error(
674
- `${label}: renders ${type}/${identity.slug}, but content/${DEFAULT_LOCALE}/${type}/${identity.slug}.mdx does not exist — the route serves 404 in production.`,
801
+ SOURCE === "database"
802
+ ? `${label}: renders ${type}/${identity.slug}, but the hub serves no such page (${SNAPSHOT_REL}) — the route serves 404 in production.`
803
+ : `${label}: renders ${type}/${identity.slug}, but content/${DEFAULT_LOCALE}/${type}/${identity.slug}.mdx does not exist — the route serves 404 in production.`,
675
804
  );
676
805
  }
677
806
  slugs = [identity.slug];
@@ -679,7 +808,7 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
679
808
  // The hub's own page shares this route's type but is served at the hub
680
809
  // (#10044 D13), so it is never rendered here.
681
810
  const hubSlug = hubOwnSlug(file, type);
682
- const pages = (await adapter.listDetail(type, DEFAULT_LOCALE)).filter(
811
+ const pages = (await listDetailFor(type, DEFAULT_LOCALE)).filter(
683
812
  (p: ContentPage) => p.slug !== hubSlug,
684
813
  );
685
814
  for (const p of pages) if (p.parent !== undefined) parents.set(p.slug, p.parent);
@@ -762,6 +891,8 @@ describe("structured data — a hub's own page is not rendered at its detail rou
762
891
  async () => {
763
892
  const root = mkdtempSync(join(tmpdir(), "ailk-10044-sd-"));
764
893
  const prior = process.env.AILK_CONTENT_ROOT;
894
+ // #10704 D2 — the fixture is files, whatever source the site serves from.
895
+ const priorSource = process.env.CONTENT_ADAPTER;
765
896
  try {
766
897
  mkdirSync(join(root, DEFAULT_LOCALE, "Pricing"), { recursive: true });
767
898
  copyFileSync(join(CONTENT_ROOT, "_site.mdx"), join(root, "_site.mdx"));
@@ -774,6 +905,7 @@ describe("structured data — a hub's own page is not rendered at its detail rou
774
905
  '---\ntype: "Pricing"\nslug: "website-rebuild"\ntitle: "Website Rebuild"\ndescription: "What a rebuild costs."\nplans: ["ss-website-rebuild"]\n---\n',
775
906
  );
776
907
  process.env.AILK_CONTENT_ROOT = root;
908
+ process.env.CONTENT_ADAPTER = "mdx";
777
909
  const fixtureAdapter = createMdxAdapter({
778
910
  contentRoot: root,
779
911
  defaultLocale: DEFAULT_LOCALE,
@@ -797,6 +929,8 @@ describe("structured data — a hub's own page is not rendered at its detail rou
797
929
  } finally {
798
930
  if (prior === undefined) delete process.env.AILK_CONTENT_ROOT;
799
931
  else process.env.AILK_CONTENT_ROOT = prior;
932
+ if (priorSource === undefined) delete process.env.CONTENT_ADAPTER;
933
+ else process.env.CONTENT_ADAPTER = priorSource;
800
934
  rmSync(root, { recursive: true, force: true });
801
935
  }
802
936
  },