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.
- package/README.md +108 -4
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/body-heading.d.ts +13 -0
- package/dist/body-heading.js +19 -5
- package/dist/cli.js +99 -0
- package/dist/cutover.d.ts +114 -0
- package/dist/cutover.js +511 -0
- package/dist/design-presets.js +20 -4
- package/dist/export-content.d.ts +55 -0
- package/dist/export-content.js +303 -0
- package/dist/inventory.d.ts +216 -0
- package/dist/inventory.js +833 -0
- package/dist/language-import.d.ts +127 -0
- package/dist/language-import.js +451 -0
- package/dist/lib/env-catalog.js +1 -1
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +445 -65
- package/dist/lib/render-comparison.d.ts +251 -0
- package/dist/lib/render-comparison.js +573 -0
- package/dist/mdx-braces.d.ts +73 -0
- package/dist/mdx-braces.js +285 -0
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +62 -14
- package/dist/programmatic.js +2 -8
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/site-manifest.js +6 -38
- package/dist/site-roster.d.ts +46 -3
- package/dist/site-roster.js +124 -4
- package/dist/surfaces.d.ts +8 -0
- package/dist/surfaces.js +16 -0
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +24 -10
- package/dist/update.d.ts +21 -1
- package/dist/update.js +142 -11
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/content-source-guard.sh +75 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.github/workflows/ci.yml +4 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/mcp/.env.example +7 -1
- package/templates/apps/mcp/CLAUDE.md +8 -4
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +16 -5
- package/templates/apps/web/__tests__/setup.ts +9 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +156 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
- package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
- package/templates/apps/web/app/[locale]/layout.tsx +122 -27
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/llms.txt/route.ts +16 -9
- package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
- package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
- package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +145 -11
- package/templates/apps/web/lib/bot-check.ts +113 -13
- package/templates/apps/web/lib/in-page-magnet.ts +4 -1
- package/templates/apps/web/lib/page-chrome.ts +65 -5
- package/templates/apps/web/lib/relay.ts +22 -3
- package/templates/apps/web/lib/site-identity.ts +75 -24
- package/templates/apps/web/messages/en.json +5 -1
- package/templates/apps/web/next.config.mjs +15 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
- package/templates/apps/web/scripts/content-snapshot.ts +33 -0
- package/templates/apps/web/src/lib/routes.ts +5 -3
- package/templates/package.json +3 -1
- package/templates/tools/body-heading.ts +440 -0
- package/templates/tools/check-content-source.ts +108 -0
- package/templates/tools/check-mdx-body.ts +123 -0
- package/templates/tools/mdx-braces.ts +303 -0
- 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(
|
|
1014
|
-
|
|
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(
|
|
1294
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
},
|