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
@@ -0,0 +1,833 @@
1
+ /**
2
+ * `create-ailk inventory <dir> [--origin <url>] [--refreeze]` — freeze what a
3
+ * site serves today, and audit it against its workspace (#10648, spec
4
+ * docs/specs/10648-the-cutover-kit-frozen-inventory-spec.md D1–D3).
5
+ *
6
+ * The one inventory shape the cutover kit shares (D1): every URL the sitemap,
7
+ * a same-origin crawl and the route roster name, probed once with no redirect
8
+ * followed. A direct 200 is `frozen` — with its kind, locale, sources, prose
9
+ * status and images — and anything else is `notFrozen` with its status, so
10
+ * nothing is dropped silently. The file is written once
11
+ * (`.ailk-cutover/inventory.json`, committed in the site); a second run reads
12
+ * it and crawls nothing, and `--refreeze` crawls again keeping the operator's
13
+ * `joins` and exclusions.
14
+ *
15
+ * Every run rewrites `.ailk-cutover/audit.json` (D2, D3): each frozen page
16
+ * joined to its workspace page by a deterministic rule, and its verdict —
17
+ * whether it is published and whether its words are in its Markdown, read
18
+ * through the words codec (ADR 0038). Exit 0 only when every prose page passes.
19
+ *
20
+ * The workspace reads here (`resolveWorkspaceAccess`, `listWorkspacePages`,
21
+ * `readPublishedPage`, `readDraftPage`, `loadSiteWordsCodec`) are the kit's
22
+ * shared ones: `export-content` and `cutover` use them too. Credentials follow
23
+ * `migrate-content`: the key from `AILK_AGENT_KEY` only, sent only as a bearer
24
+ * token to the API origin, never across a redirect, never printed.
25
+ *
26
+ * Pure module: no `import.meta.url`, so jest imports it directly.
27
+ */
28
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
29
+ import { createRequire } from "node:module";
30
+ import { join, resolve } from "node:path";
31
+ import { pathToFileURL } from "node:url";
32
+ import { parseArgs } from "node:util";
33
+ import { IN_FLIGHT, mapLimit } from "./lib/render-comparison.js";
34
+ import { sitemapLocPaths } from "./lib/scaffold-render-sweep.js";
35
+ import { ParseError } from "./parse-args.js";
36
+ import { AGENT_KEY_ENV_VAR, WORKSPACE_ID_ENV_VAR } from "./seed-workspace.js";
37
+ import { readSiteRoster } from "./site-roster.js";
38
+ import { HUB_ORIGIN_ENV_VAR, TopologyError, normalizeHubUrl, readSiteTopology, } from "./topology.js";
39
+ /** The kit's directory in the site: committed, unlike `.ailk/`. */
40
+ export const CUTOVER_DIR = ".ailk-cutover";
41
+ export const INVENTORY_FILE = "inventory.json";
42
+ export const AUDIT_FILE = "audit.json";
43
+ /** The spoke's key for the hub's public read (`x-ailk-spoke-key`). */
44
+ export const SPOKE_KEY_ENV_VAR = "AILK_SPOKE_KEY";
45
+ /** The package the words codec comes from, resolved from the site at run time. */
46
+ const VALIDATION_PACKAGE = "@working-theory/validation";
47
+ const DEFAULT_DOCS_PATH = "/docs";
48
+ const CRAWL_CAP = 5_000;
49
+ const REQUEST_TIMEOUT_MS = 15_000;
50
+ /** Paths every site is probed for, frozen as files when they answer 200. */
51
+ const FILE_PATHS = ["/sitemap.xml", "/robots.txt", "/llms.txt"];
52
+ /** A refusal the CLI exits 2 on. */
53
+ export class InventoryRefusedError extends Error {
54
+ exitCode = 2;
55
+ constructor(message) {
56
+ super(message);
57
+ this.name = "InventoryRefusedError";
58
+ }
59
+ }
60
+ export const AUDIT_VERDICTS = [
61
+ "pass",
62
+ "excluded",
63
+ "read-failed",
64
+ "unjoined",
65
+ "no-page",
66
+ "ambiguous",
67
+ "locale-not-imported",
68
+ "not-published",
69
+ "words-not-in-markdown",
70
+ ];
71
+ /** A value given, or `undefined` for an absent or empty one. */
72
+ export function given(value) {
73
+ const trimmed = value?.trim();
74
+ return trimmed === undefined || trimmed === "" ? undefined : trimmed;
75
+ }
76
+ const KEY_SHAPE = /^[\x21-\x7e]+$/;
77
+ const WORKSPACE_ID = /^wksp_[0-9a-f-]{36}$/;
78
+ /**
79
+ * The key, workspace and API origin, as `migrate-content` reads them, plus the
80
+ * spoke key on a spoke. Every miss is refused through `refuse`, before any
81
+ * request.
82
+ */
83
+ export function resolveWorkspaceAccess(targetDir, o, refuse) {
84
+ const apiKey = given(o.env[AGENT_KEY_ENV_VAR]);
85
+ if (apiKey === undefined || !KEY_SHAPE.test(apiKey)) {
86
+ refuse(`set ${AGENT_KEY_ENV_VAR} to an agent key with the content-edit grant. It is read from the environment only — never a flag or a file.`);
87
+ }
88
+ const workspaceId = given(o.workspaceId) ?? given(o.env[WORKSPACE_ID_ENV_VAR]);
89
+ if (workspaceId === undefined || !WORKSPACE_ID.test(workspaceId)) {
90
+ refuse(`no workspace id (wksp_<uuidv7>) was given. Pass --workspace <wksp_id>, or set ${WORKSPACE_ID_ENV_VAR}.`);
91
+ }
92
+ const raw = given(o.apiUrl) ?? given(o.env[HUB_ORIGIN_ENV_VAR]);
93
+ if (raw === undefined) {
94
+ refuse(`no API origin was given. Pass --api-url <origin>, or set ${HUB_ORIGIN_ENV_VAR}.`);
95
+ }
96
+ let origin;
97
+ try {
98
+ origin = normalizeHubUrl(raw);
99
+ }
100
+ catch (err) {
101
+ if (!(err instanceof TopologyError))
102
+ throw err;
103
+ refuse("the API origin must be an https: origin (http: only on a loopback host) with no path, query, fragment or credentials.");
104
+ }
105
+ let topology;
106
+ try {
107
+ topology = readSiteTopology(targetDir);
108
+ }
109
+ catch (err) {
110
+ if (!(err instanceof TopologyError))
111
+ throw err;
112
+ refuse(err.message);
113
+ }
114
+ if (topology !== "spoke")
115
+ return { origin, workspaceId, apiKey };
116
+ const spokeKey = given(o.env[SPOKE_KEY_ENV_VAR]);
117
+ if (spokeKey === undefined || !KEY_SHAPE.test(spokeKey)) {
118
+ refuse(`this site is a spoke: set ${SPOKE_KEY_ENV_VAR} so the hub's public read answers its workspace.`);
119
+ }
120
+ return { origin, workspaceId, apiKey, spokeKey };
121
+ }
122
+ /** A site origin: http(s), reduced to its origin. */
123
+ export function siteOrigin(value, flag, refuse) {
124
+ let url;
125
+ try {
126
+ url = new URL(value);
127
+ }
128
+ catch {
129
+ refuse(`${flag} is not a URL.`);
130
+ }
131
+ if (url.protocol !== "https:" && url.protocol !== "http:")
132
+ refuse(`${flag} must be an http(s) origin.`);
133
+ return url.origin;
134
+ }
135
+ async function getJson(fetch, url, headers) {
136
+ let response;
137
+ try {
138
+ response = await fetch(url, {
139
+ headers,
140
+ // A redirect would re-send the key to another URL.
141
+ redirect: "error",
142
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
143
+ });
144
+ }
145
+ catch {
146
+ // A timeout, a network failure or a redirect: status 0, the read failed.
147
+ return { status: 0, body: undefined };
148
+ }
149
+ let body;
150
+ try {
151
+ body = await response.json();
152
+ }
153
+ catch {
154
+ body = undefined;
155
+ }
156
+ return { status: response.status, body };
157
+ }
158
+ const bearer = (access) => ({ authorization: `Bearer ${access.apiKey}` });
159
+ /**
160
+ * The workspace's pages in `locale`, through the editor's view. A row with no
161
+ * `version` is the member projection — a key without `content-edit` — which
162
+ * reports every live row `published` and would hide a draft ahead: refused.
163
+ * @throws Error on a read that does not answer 200.
164
+ */
165
+ export async function listWorkspacePages(access, fetch, locale, refuse) {
166
+ const { status, body } = await getJson(fetch, `${access.origin}/v1/workspaces/${encodeURIComponent(access.workspaceId)}/content?locale=${encodeURIComponent(locale)}`, bearer(access));
167
+ const data = body?.data;
168
+ if (status !== 200 || !Array.isArray(data)) {
169
+ throw new Error(`the workspace list answered ${status}`);
170
+ }
171
+ const rows = data.filter((row) => row["kind"] !== "file" && typeof row["type"] === "string" && typeof row["slug"] === "string");
172
+ if (rows.some((row) => typeof row["version"] !== "string")) {
173
+ refuse(`the workspace list answered its published-only projection: ${AGENT_KEY_ENV_VAR} needs the content-edit grant.`);
174
+ }
175
+ return rows;
176
+ }
177
+ /** The public read of one page with its body: what the site serves. */
178
+ export async function readPublishedPage(access, fetch, type, slug, locale) {
179
+ const { status, body } = await getJson(fetch, `${access.origin}/v1/content/${encodeURIComponent(type)}/${encodeURIComponent(slug)}?include=body&locale=${encodeURIComponent(locale)}`, access.spokeKey === undefined ? {} : { "x-ailk-spoke-key": access.spokeKey });
180
+ const doc = body;
181
+ if (status !== 200 || doc?.page === undefined)
182
+ return { found: false, status };
183
+ return {
184
+ found: true,
185
+ sections: Array.isArray(doc.page.sections) ? doc.page.sections : undefined,
186
+ body: typeof doc.body === "string" ? doc.body : "",
187
+ publishedVersionId: typeof doc.publishedVersionId === "string" ? doc.publishedVersionId : undefined,
188
+ };
189
+ }
190
+ /** The draft tip's raw stored content — the value the importer compares against. */
191
+ export async function readDraftPage(access, fetch, type, slug, locale) {
192
+ const { status, body } = await getJson(fetch, `${access.origin}/v1/workspaces/${encodeURIComponent(access.workspaceId)}/content/${encodeURIComponent(type)}/${encodeURIComponent(slug)}?locale=${encodeURIComponent(locale)}`, bearer(access));
193
+ const data = body?.data;
194
+ const content = data?.content;
195
+ if (status !== 200 || content === null || typeof content !== "object")
196
+ return { status };
197
+ return {
198
+ status,
199
+ content: content,
200
+ ...(typeof data?.version === "string" && { version: data.version }),
201
+ };
202
+ }
203
+ /**
204
+ * The site's words codec: `@working-theory/validation` resolved from its
205
+ * `apps/web`, the name held in a constant so no import specifier names it.
206
+ */
207
+ export async function loadSiteWordsCodec(targetDir, refuse) {
208
+ const web = join(resolve(targetDir), "apps", "web");
209
+ let mod = {};
210
+ try {
211
+ const entry = createRequire(join(web, "package.json")).resolve(VALIDATION_PACKAGE);
212
+ mod = (await import(pathToFileURL(entry).href));
213
+ }
214
+ catch {
215
+ // Reported below.
216
+ }
217
+ if (typeof mod["applyWords"] !== "function" || typeof mod["sectionsToMarkdown"] !== "function") {
218
+ refuse(`${VALIDATION_PACKAGE} with the words codec is not installed in ${web}. Install the site's dependencies (pnpm install) first.`);
219
+ }
220
+ return mod;
221
+ }
222
+ function sortKeys(value) {
223
+ if (Array.isArray(value))
224
+ return value.map(sortKeys);
225
+ if (value === null || typeof value !== "object")
226
+ return value;
227
+ return Object.fromEntries(Object.keys(value)
228
+ .sort()
229
+ .map((key) => [key, sortKeys(value[key])]));
230
+ }
231
+ /** Equal as JSON, keys sorted at every depth. */
232
+ export function sameJson(a, b) {
233
+ return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b));
234
+ }
235
+ /** The site's locales from `project.yaml` `product`, read without a YAML dependency. */
236
+ export function readSiteLocales(targetDir) {
237
+ const path = join(targetDir, "project.yaml");
238
+ const fallback = { defaultLocale: "en", locales: ["en"] };
239
+ if (!existsSync(path))
240
+ return fallback;
241
+ const lines = readFileSync(path, "utf8").split("\n");
242
+ const start = lines.findIndex((line) => /^product:\s*$/.test(line));
243
+ if (start < 0)
244
+ return fallback;
245
+ const unquote = (v) => v.trim().replace(/^["']|["']$/g, "");
246
+ let defaultLocale = fallback.defaultLocale;
247
+ let locales;
248
+ for (let i = start + 1; i < lines.length && /^(\s|$)/.test(lines[i]); i++) {
249
+ const line = lines[i].replace(/\s+#.*$/, "");
250
+ const def = /^\s+default_locale:\s*(\S+)/.exec(line);
251
+ if (def)
252
+ defaultLocale = unquote(def[1]);
253
+ const list = /^\s+locales:\s*(.*)$/.exec(line);
254
+ if (!list)
255
+ continue;
256
+ const flow = /^\[(.*)\]$/.exec(list[1].trim());
257
+ if (flow) {
258
+ locales = flow[1].split(",").map(unquote).filter((v) => v !== "");
259
+ }
260
+ else {
261
+ locales = [];
262
+ for (let j = i + 1; j < lines.length && /^\s+-\s/.test(lines[j]); j++) {
263
+ locales.push(unquote(lines[j].replace(/^\s+-\s/, "")));
264
+ }
265
+ }
266
+ }
267
+ return { defaultLocale, locales: locales !== undefined && locales.length > 0 ? locales : [defaultLocale] };
268
+ }
269
+ // ── The crawl (D1) ──────────────────────────────────────────────────────────
270
+ function decodeEntities(value) {
271
+ return value
272
+ .replace(/&amp;/g, "&")
273
+ .replace(/&quot;/g, '"')
274
+ .replace(/&#0*39;|&apos;/g, "'")
275
+ .replace(/&lt;/g, "<")
276
+ .replace(/&gt;/g, ">");
277
+ }
278
+ /** Each `<tag …>` of `names` with its attributes, names lower-cased. */
279
+ function tags(html, names) {
280
+ const out = [];
281
+ for (const tag of html.matchAll(new RegExp(`<(?:${names})\\b([^>]*)>`, "gi"))) {
282
+ const attrs = {};
283
+ for (const a of tag[1].matchAll(/([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) {
284
+ attrs[a[1].toLowerCase()] = decodeEntities(a[3] ?? a[4] ?? a[5] ?? "");
285
+ }
286
+ out.push(attrs);
287
+ }
288
+ return out;
289
+ }
290
+ /** The same-origin page paths `html` links to: query and hash stripped, `/_next/` and `/api/` skipped. */
291
+ function linkedPaths(html, pageUrl, origin) {
292
+ const paths = [];
293
+ for (const { href } of tags(html, "a")) {
294
+ if (href === undefined)
295
+ continue;
296
+ let url;
297
+ try {
298
+ url = new URL(href, pageUrl);
299
+ }
300
+ catch {
301
+ continue;
302
+ }
303
+ if (url.origin !== origin)
304
+ continue;
305
+ if (url.pathname.startsWith("/_next/") || url.pathname.startsWith("/api/"))
306
+ continue;
307
+ paths.push(url.pathname);
308
+ }
309
+ return paths;
310
+ }
311
+ /** Every `<img src>`, every `srcset` candidate and `og:image`, as absolute URLs. */
312
+ function imageUrls(html, pageUrl) {
313
+ const raw = [];
314
+ for (const attrs of tags(html, "img|source")) {
315
+ if (attrs["src"] !== undefined)
316
+ raw.push(attrs["src"]);
317
+ for (const candidate of (attrs["srcset"] ?? "").split(",")) {
318
+ const url = candidate.trim().split(/\s+/)[0];
319
+ if (url)
320
+ raw.push(url);
321
+ }
322
+ }
323
+ for (const attrs of tags(html, "meta")) {
324
+ if (attrs["property"] === "og:image" && attrs["content"])
325
+ raw.push(attrs["content"]);
326
+ }
327
+ const urls = new Set();
328
+ for (const value of raw) {
329
+ try {
330
+ urls.add(new URL(value, pageUrl).href);
331
+ }
332
+ catch {
333
+ // Not a URL: not an image the sweep can fetch.
334
+ }
335
+ }
336
+ return [...urls].sort();
337
+ }
338
+ const NOT_PROSE_ELEMENTS = ["script", "style", "noscript", "nav", "header", "footer", "form"];
339
+ /**
340
+ * Whether a page carries prose (D3): inside `<main>` when it has one, with its
341
+ * script, style, noscript, nav, header, footer and form elements removed, a
342
+ * `<p>`, heading, `<li>` or `<blockquote>` with non-whitespace text.
343
+ */
344
+ export function isProseBearing(html) {
345
+ let scope = /<main\b[^>]*>([\s\S]*?)<\/main>/i.exec(html)?.[1] ?? html;
346
+ for (const name of NOT_PROSE_ELEMENTS) {
347
+ scope = scope.replace(new RegExp(`<${name}\\b[^>]*>[\\s\\S]*?<\\/${name}>`, "gi"), "");
348
+ }
349
+ for (const match of scope.matchAll(/<(p|h[1-6]|li|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/gi)) {
350
+ if (match[2].replace(/<[^>]*>/g, "").trim() !== "")
351
+ return true;
352
+ }
353
+ return false;
354
+ }
355
+ async function probe(fetch, url, readBody = true) {
356
+ try {
357
+ const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
358
+ const type = response.headers.get("content-type") ?? "";
359
+ const kind = type.includes("text/html") ? "page" : "file";
360
+ const location = response.headers.get("location");
361
+ const body = readBody && response.status === 200 ? await response.text() : undefined;
362
+ return {
363
+ status: response.status,
364
+ kind,
365
+ ...(location !== null && { location }),
366
+ ...(body !== undefined && (kind === "page" ? { html: body } : { text: body })),
367
+ };
368
+ }
369
+ catch {
370
+ return { status: 0, kind: "file" };
371
+ }
372
+ }
373
+ /** The static `page.tsx` routes under `apps/web/app`, `[locale]` and `(group)` dropped, dynamic ones skipped. */
374
+ function appRoutes(targetDir) {
375
+ const routes = [];
376
+ const walk = (dir, segments) => {
377
+ let entries;
378
+ try {
379
+ entries = readdirSync(dir, { withFileTypes: true });
380
+ }
381
+ catch {
382
+ return;
383
+ }
384
+ for (const entry of entries) {
385
+ if (entry.isFile() && entry.name === "page.tsx")
386
+ routes.push(`/${segments.join("/")}`);
387
+ if (!entry.isDirectory() || /^[_@]/.test(entry.name))
388
+ continue;
389
+ if (entry.name === "[locale]" || /^\(.*\)$/.test(entry.name))
390
+ walk(join(dir, entry.name), segments);
391
+ else if (!entry.name.includes("["))
392
+ walk(join(dir, entry.name), [...segments, entry.name]);
393
+ }
394
+ };
395
+ walk(join(targetDir, "apps", "web", "app"), []);
396
+ return routes;
397
+ }
398
+ function rosterRoutes(targetDir) {
399
+ const roster = readSiteRoster(targetDir);
400
+ return (roster?.rows ?? []).flatMap(({ value }) => typeof value["route"] === "string"
401
+ ? [{ route: value["route"], pageType: typeof value["pageType"] === "string" ? value["pageType"] : null }]
402
+ : []);
403
+ }
404
+ /** A route's or a path's non-empty segments. */
405
+ function routePattern(route) {
406
+ return route.split("/").filter((s) => s !== "");
407
+ }
408
+ const isDynamic = (segment) => /^\[[^.\]]+\]$/.test(segment);
409
+ function matchRoute(route, path) {
410
+ const pattern = routePattern(route);
411
+ const segments = routePattern(path);
412
+ if (pattern.length !== segments.length)
413
+ return undefined;
414
+ let slug;
415
+ for (const [i, part] of pattern.entries()) {
416
+ if (isDynamic(part))
417
+ slug = segments[i];
418
+ else if (part !== segments[i])
419
+ return undefined;
420
+ }
421
+ return slug === undefined ? {} : { slug };
422
+ }
423
+ const sorted = (values) => [...values].sort();
424
+ const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
425
+ async function crawl(c) {
426
+ const from = new Map();
427
+ const probes = new Map();
428
+ const add = (path, source) => {
429
+ const set = from.get(path) ?? new Set();
430
+ set.add(source);
431
+ from.set(path, set);
432
+ if (from.size > CRAWL_CAP) {
433
+ c.refuse(`the site names more than ${CRAWL_CAP} URLs; the inventory stops at that cap.`);
434
+ }
435
+ };
436
+ const probeAll = async (paths) => {
437
+ const todo = [...new Set(paths)].filter((p) => !probes.has(p));
438
+ const results = await mapLimit(todo, IN_FLIGHT, (p) => probe(c.fetch, c.origin + p));
439
+ todo.forEach((p, i) => probes.set(p, results[i]));
440
+ return todo;
441
+ };
442
+ // The sitemap, one level of index.
443
+ await probeAll(["/sitemap.xml"]);
444
+ const sitemapPaths = [];
445
+ const sitemap = probes.get("/sitemap.xml");
446
+ const xml = sitemap.text ?? sitemap.html;
447
+ if (sitemap.status === 200 && xml !== undefined) {
448
+ const locs = (text) => {
449
+ try {
450
+ return sitemapLocPaths(text);
451
+ }
452
+ catch {
453
+ return [];
454
+ }
455
+ };
456
+ if (/<sitemapindex\b/.test(xml)) {
457
+ const children = locs(xml);
458
+ const texts = await mapLimit(children, IN_FLIGHT, async (p) => (await probe(c.fetch, c.origin + p)).text ?? "");
459
+ for (const text of texts)
460
+ sitemapPaths.push(...locs(text));
461
+ }
462
+ else {
463
+ sitemapPaths.push(...locs(xml));
464
+ }
465
+ }
466
+ for (const p of sitemapPaths)
467
+ add(p, "sitemap");
468
+ // The roster: static routes, each `[slug]` row over the list's slugs, the app's static pages.
469
+ const rosterPaths = new Set();
470
+ for (const { route, pageType } of rosterRoutes(c.targetDir)) {
471
+ const pattern = routePattern(route);
472
+ const dynamic = pattern.filter(isDynamic).length;
473
+ if (dynamic === 0)
474
+ rosterPaths.add(route === "" ? "/" : route);
475
+ else if (dynamic === 1 && pageType !== null && !route.includes("...")) {
476
+ for (const row of c.rows) {
477
+ if (row.type !== pageType)
478
+ continue;
479
+ rosterPaths.add(`/${pattern.map((s) => (isDynamic(s) ? row.slug : s)).join("/")}`);
480
+ }
481
+ }
482
+ }
483
+ for (const route of appRoutes(c.targetDir))
484
+ rosterPaths.add(route);
485
+ for (const p of rosterPaths)
486
+ add(p, "roster");
487
+ // The crawl: from `/`, and from every page any source names.
488
+ add("/", "crawl");
489
+ for (const p of FILE_PATHS)
490
+ add(p, "probe");
491
+ let pending = [...from.keys()];
492
+ const crawlPaths = new Set(["/"]);
493
+ while (pending.length > 0) {
494
+ const probed = await probeAll(pending);
495
+ pending = [];
496
+ for (const path of probed) {
497
+ const result = probes.get(path);
498
+ if (result.status !== 200 || result.html === undefined)
499
+ continue;
500
+ for (const linked of linkedPaths(result.html, c.origin + path, c.origin)) {
501
+ crawlPaths.add(linked);
502
+ const known = from.has(linked);
503
+ add(linked, "crawl");
504
+ if (!known)
505
+ pending.push(linked);
506
+ }
507
+ }
508
+ }
509
+ const frozen = [];
510
+ const notFrozen = [];
511
+ const noProse = [];
512
+ const imagePages = new Map();
513
+ for (const path of sorted(from.keys())) {
514
+ const sources = sorted(from.get(path));
515
+ const result = probes.get(path);
516
+ if (result.status !== 200) {
517
+ notFrozen.push({
518
+ path,
519
+ status: result.status,
520
+ ...(result.location !== undefined && { location: result.location }),
521
+ from: sources,
522
+ });
523
+ continue;
524
+ }
525
+ const first = routePattern(path)[0];
526
+ const locale = first !== undefined && c.locales.includes(first) ? first : c.defaultLocale;
527
+ const images = result.html === undefined ? [] : imageUrls(result.html, c.origin + path);
528
+ const prose = result.kind === "page" && result.html !== undefined && isProseBearing(result.html);
529
+ if (result.kind === "page" && !prose)
530
+ noProse.push(path);
531
+ for (const url of images)
532
+ imagePages.set(url, (imagePages.get(url) ?? new Set()).add(path));
533
+ frozen.push({ path, kind: result.kind, locale, from: sources, prose, images });
534
+ }
535
+ const imageList = sorted(imagePages.keys());
536
+ const statuses = await mapLimit(imageList, IN_FLIGHT, async (url) => (await probe(c.fetch, url, false)).status);
537
+ return {
538
+ frozen,
539
+ notFrozen,
540
+ images: imageList.map((url, i) => ({ url, status: statuses[i], pages: sorted(imagePages.get(url)) })),
541
+ sources: { sitemap: new Set(sitemapPaths).size, crawl: crawlPaths.size, roster: rosterPaths.size },
542
+ noProse,
543
+ };
544
+ }
545
+ /** A URL's workspace page: the operator's join, then the roster, then the editorial prefixes. */
546
+ export function joinUrl(rawPath, ctx) {
547
+ const prefix = `/${ctx.defaultLocale}`;
548
+ const path = rawPath === prefix ? "/" : rawPath.startsWith(`${prefix}/`) ? rawPath.slice(prefix.length) : rawPath;
549
+ const keys = new Set(ctx.rows.map((r) => `${r.type}/${r.slug}`));
550
+ const found = (join) => (keys.has(join) ? { join } : { verdict: "no-page", join });
551
+ const operator = ctx.joins[rawPath] ?? ctx.joins[path];
552
+ if (operator !== undefined)
553
+ return found(operator);
554
+ for (const { route, pageType } of ctx.roster) {
555
+ if (pageType === null)
556
+ continue;
557
+ const match = matchRoute(route, path);
558
+ if (match === undefined)
559
+ continue;
560
+ if (match.slug !== undefined)
561
+ return found(`${pageType}/${match.slug}`);
562
+ const ofType = ctx.rows.filter((r) => r.type === pageType);
563
+ if (ofType.length === 0)
564
+ return { verdict: "no-page" };
565
+ if (ofType.length > 1)
566
+ return { verdict: "ambiguous" };
567
+ return { join: `${pageType}/${ofType[0].slug}` };
568
+ }
569
+ for (const [base, list, detail] of [
570
+ ["/blog", "BlogList", "BlogPosting"],
571
+ [ctx.docsPath, "DocsList", "DocsDetail"],
572
+ ]) {
573
+ if (path === base)
574
+ return found(`${list}/index`);
575
+ const rest = path.startsWith(`${base}/`) ? path.slice(base.length + 1) : undefined;
576
+ if (rest !== undefined && rest !== "" && !rest.includes("/"))
577
+ return found(`${detail}/${rest}`);
578
+ }
579
+ return { verdict: "unjoined" };
580
+ }
581
+ /** Whether a published page's words are in its Markdown (D3). */
582
+ function wordsInMarkdown(read, codec) {
583
+ if (read.sections === undefined || read.sections.length === 0)
584
+ return read.body.trim() !== "";
585
+ const applied = codec.applyWords(read.body, read.sections);
586
+ return applied.issues.length === 0 && sameJson(applied.sections, read.sections);
587
+ }
588
+ async function audit(o) {
589
+ const { inv } = o;
590
+ const excluded = new Set(inv.proseExclusions.map((e) => e.path));
591
+ const rowOf = new Map(o.rows.map((r) => [`${r.type}/${r.slug}`, r]));
592
+ const joined = new Set();
593
+ const pages = await mapLimit(inv.frozen.filter((e) => e.kind === "page"), IN_FLIGHT, async (entry) => {
594
+ const j = joinUrl(entry.path, {
595
+ joins: inv.joins,
596
+ roster: o.roster,
597
+ rows: o.rows,
598
+ docsPath: inv.docsPath,
599
+ defaultLocale: inv.defaultLocale,
600
+ });
601
+ const join = j.join ?? null;
602
+ const row = join === null ? undefined : rowOf.get(join);
603
+ if (row !== undefined)
604
+ joined.add(join);
605
+ const published = row === undefined ? null : row.status === "published";
606
+ const page = (verdict, words = null) => ({
607
+ path: entry.path,
608
+ join,
609
+ verdict,
610
+ prose: !excluded.has(entry.path),
611
+ published,
612
+ wordsInMarkdown: words,
613
+ });
614
+ if (excluded.has(entry.path))
615
+ return page("excluded");
616
+ if (entry.locale !== inv.defaultLocale)
617
+ return page("locale-not-imported");
618
+ if ("verdict" in j)
619
+ return page(j.verdict);
620
+ if (!published)
621
+ return page("not-published");
622
+ const read = await readPublishedPage(o.access, o.fetch, row.type, row.slug, inv.defaultLocale);
623
+ if (!read.found || read.publishedVersionId !== row.publishedVersionId)
624
+ return page("read-failed");
625
+ const words = wordsInMarkdown(read, o.codec);
626
+ return page(words ? "pass" : "words-not-in-markdown", words);
627
+ });
628
+ const counts = Object.fromEntries(AUDIT_VERDICTS.map((v) => [v, 0]));
629
+ for (const p of pages)
630
+ counts[p.verdict] += 1;
631
+ return {
632
+ version: 1,
633
+ inventoryFrozenAt: inv.frozenAt,
634
+ pages: pages.sort(byPath),
635
+ orphans: sorted(rowOf.keys()).filter((key) => !joined.has(key)),
636
+ counts,
637
+ };
638
+ }
639
+ /** The inventory file as written: fixed key order, sorted arrays. */
640
+ function writeInventory(path, inv) {
641
+ const ordered = {
642
+ version: 1,
643
+ origin: inv.origin,
644
+ frozenAt: inv.frozenAt,
645
+ defaultLocale: inv.defaultLocale,
646
+ docsPath: inv.docsPath,
647
+ sources: { sitemap: inv.sources.sitemap, crawl: inv.sources.crawl, roster: inv.sources.roster },
648
+ frozen: [...inv.frozen].sort(byPath).map((e) => ({
649
+ path: e.path,
650
+ kind: e.kind,
651
+ locale: e.locale,
652
+ from: sorted(e.from),
653
+ prose: e.prose,
654
+ images: sorted(e.images),
655
+ })),
656
+ notFrozen: [...inv.notFrozen].sort(byPath),
657
+ images: [...inv.images].sort((a, b) => (a.url < b.url ? -1 : 1)),
658
+ proseExclusions: [...inv.proseExclusions].sort(byPath),
659
+ joins: Object.fromEntries(Object.entries(inv.joins).sort(([a], [b]) => (a < b ? -1 : 1))),
660
+ };
661
+ writeFileSync(path, `${JSON.stringify(ordered, null, 2)}\n`);
662
+ }
663
+ /**
664
+ * The inventory in the site, or `undefined` when none is frozen.
665
+ * @throws Error on a file that is not JSON or not a version-1 inventory.
666
+ */
667
+ export function readInventory(targetDir) {
668
+ const path = join(targetDir, CUTOVER_DIR, INVENTORY_FILE);
669
+ if (!existsSync(path))
670
+ return undefined;
671
+ const inv = JSON.parse(readFileSync(path, "utf8"));
672
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
673
+ if (!isObject(inv) ||
674
+ inv.version !== 1 ||
675
+ typeof inv.origin !== "string" ||
676
+ typeof inv.frozenAt !== "string" ||
677
+ typeof inv.defaultLocale !== "string" ||
678
+ typeof inv.docsPath !== "string" ||
679
+ !Array.isArray(inv.frozen) ||
680
+ !Array.isArray(inv.notFrozen) ||
681
+ !Array.isArray(inv.images) ||
682
+ !Array.isArray(inv.proseExclusions) ||
683
+ !isObject(inv.joins)) {
684
+ throw new Error(`${CUTOVER_DIR}/${INVENTORY_FILE} is not a version-1 inventory`);
685
+ }
686
+ return inv;
687
+ }
688
+ export function formatAuditCounts(counts) {
689
+ return AUDIT_VERDICTS.map((v) => `${v} ${counts[v]}`).join(" · ");
690
+ }
691
+ export async function inventory(opts) {
692
+ const targetDir = resolve(opts.targetDir);
693
+ const env = opts.env ?? process.env;
694
+ const onLine = opts.onLine ?? (() => undefined);
695
+ const fetch = opts.fetch ?? globalThis.fetch;
696
+ const refuse = (message) => {
697
+ throw new InventoryRefusedError(`inventory: ${message}`);
698
+ };
699
+ const access = resolveWorkspaceAccess(targetDir, { apiUrl: opts.apiUrl, workspaceId: opts.workspaceId, env }, refuse);
700
+ const dir = join(targetDir, CUTOVER_DIR);
701
+ const path = join(dir, INVENTORY_FILE);
702
+ let previous;
703
+ try {
704
+ previous = readInventory(targetDir);
705
+ }
706
+ catch (err) {
707
+ refuse(`${CUTOVER_DIR}/${INVENTORY_FILE} is not JSON (${err.message}).`);
708
+ }
709
+ const freeze = previous === undefined || opts.refreeze === true;
710
+ if (!freeze && given(opts.origin) !== undefined && siteOrigin(opts.origin, "--origin", refuse) !== previous.origin) {
711
+ refuse(`the inventory was frozen from ${previous.origin}, not ${opts.origin}. Pass --refreeze to freeze again.`);
712
+ }
713
+ const rawOrigin = given(opts.origin) ?? previous?.origin;
714
+ if (freeze && rawOrigin === undefined)
715
+ refuse("no inventory is frozen yet: pass --origin <url>, the site as served today.");
716
+ const origin = rawOrigin === undefined ? undefined : siteOrigin(rawOrigin, "--origin", refuse);
717
+ const { defaultLocale, locales } = readSiteLocales(targetDir);
718
+ let roster;
719
+ try {
720
+ roster = rosterRoutes(targetDir);
721
+ }
722
+ catch (err) {
723
+ refuse(err.message);
724
+ }
725
+ const codec = opts.codec ?? (await loadSiteWordsCodec(targetDir, refuse));
726
+ const rows = (await listWorkspacePages(access, fetch, defaultLocale, refuse)).filter((r) => r.locale === defaultLocale);
727
+ let inv;
728
+ if (freeze) {
729
+ onLine(`Freezing ${origin} into ${CUTOVER_DIR}/${INVENTORY_FILE}.`);
730
+ const crawled = await crawl({ origin: origin, targetDir, rows, locales, defaultLocale, fetch, refuse });
731
+ const kept = (previous?.proseExclusions ?? []).filter((e) => e.by === "operator");
732
+ const keptPaths = new Set(kept.map((e) => e.path));
733
+ inv = {
734
+ version: 1,
735
+ origin: origin,
736
+ frozenAt: (opts.now ?? (() => new Date()))().toISOString(),
737
+ defaultLocale,
738
+ docsPath: given(opts.docsPath) ?? previous?.docsPath ?? DEFAULT_DOCS_PATH,
739
+ sources: crawled.sources,
740
+ frozen: crawled.frozen,
741
+ notFrozen: crawled.notFrozen,
742
+ images: crawled.images,
743
+ proseExclusions: [
744
+ ...kept,
745
+ ...crawled.noProse
746
+ .filter((p) => !keptPaths.has(p))
747
+ .map((p) => ({ path: p, reason: "no-prose", by: "inventory" })),
748
+ ],
749
+ joins: previous?.joins ?? {},
750
+ };
751
+ mkdirSync(dir, { recursive: true });
752
+ writeInventory(path, inv);
753
+ inv = readInventory(targetDir);
754
+ }
755
+ else {
756
+ inv = previous;
757
+ onLine(`Read the frozen ${CUTOVER_DIR}/${INVENTORY_FILE} (${inv.frozenAt}); nothing was crawled.`);
758
+ }
759
+ onLine(`${inv.frozen.length} frozen URL(s), ${inv.notFrozen.length} not frozen.`);
760
+ const result = await audit({ inv, rows, roster, access, fetch, codec });
761
+ writeFileSync(join(dir, AUDIT_FILE), `${JSON.stringify(result, null, 2)}\n`);
762
+ const failing = result.pages.filter((p) => p.verdict !== "pass" && p.verdict !== "excluded");
763
+ for (const p of failing)
764
+ onLine(`${p.path}: ${p.verdict}${p.join === null ? "" : ` (${p.join})`}`);
765
+ onLine(formatAuditCounts(result.counts));
766
+ return { exitCode: failing.length > 0 ? 1 : 0, inventory: inv, audit: result };
767
+ }
768
+ export const INVENTORY_HELP = `create-ailk inventory — freeze what a site serves today, and audit it against its workspace.
769
+
770
+ Usage:
771
+ AILK_AGENT_KEY=<key> create-ailk inventory [<dir>] --origin <url> [--refreeze] [--docs-path <path>] [--api-url <origin>] [--workspace <wksp_id>]
772
+
773
+ Arguments:
774
+ <dir> The site. Defaults to the current dir.
775
+
776
+ Options:
777
+ --origin <url> The site as served today. Needed to freeze; a
778
+ frozen inventory keeps its own.
779
+ --refreeze Crawl again. The operator's joins and exclusions
780
+ are kept; the inventory's own are recomputed.
781
+ --docs-path <path> Where the site serves its docs. Default /docs.
782
+ --api-url <origin> The API origin that holds the workspace — on a
783
+ spoke, its hub's. Else AILK_API_URL.
784
+ --workspace <wksp_id> The workspace. Else AILK_WORKSPACE_ID.
785
+ -h, --help Show this help.
786
+
787
+ The first run writes ${CUTOVER_DIR}/${INVENTORY_FILE}: every URL the sitemap,
788
+ a crawl of the site and the route roster name, each probed with no
789
+ redirect followed. A direct 200 is frozen with its prose status and its
790
+ images; anything else is listed as not frozen with its status. A later
791
+ run reads that file and crawls nothing. Commit it.
792
+
793
+ Every run writes ${CUTOVER_DIR}/${AUDIT_FILE}: each frozen page joined to its
794
+ workspace page — by the inventory's "joins", then the roster, then /blog and
795
+ the docs path — and whether it is published and whether its words are in
796
+ its Markdown. The agent key needs the content-edit grant; on a spoke,
797
+ AILK_SPOKE_KEY is needed too. Exit 0 when every prose page passes, 1 when
798
+ any does not (one line each), 2 on a refusal.
799
+ `;
800
+ export function parseInventoryArgs(argv) {
801
+ let parsed;
802
+ try {
803
+ parsed = parseArgs({
804
+ args: argv,
805
+ strict: true,
806
+ allowPositionals: true,
807
+ options: {
808
+ origin: { type: "string" },
809
+ refreeze: { type: "boolean", default: false },
810
+ "docs-path": { type: "string" },
811
+ "api-url": { type: "string" },
812
+ workspace: { type: "string" },
813
+ help: { type: "boolean", default: false, short: "h" },
814
+ },
815
+ });
816
+ }
817
+ catch (err) {
818
+ throw new ParseError(`inventory: ${err.message}`);
819
+ }
820
+ const [target, ...rest] = parsed.positionals;
821
+ if (rest.length > 0) {
822
+ throw new ParseError(`inventory takes at most one target directory, got ${parsed.positionals.length} arguments`);
823
+ }
824
+ return {
825
+ target,
826
+ origin: parsed.values.origin,
827
+ refreeze: parsed.values.refreeze === true,
828
+ docsPath: parsed.values["docs-path"],
829
+ apiUrl: parsed.values["api-url"],
830
+ workspaceId: parsed.values.workspace,
831
+ help: parsed.values.help === true,
832
+ };
833
+ }