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,303 @@
1
+ /**
2
+ * `create-ailk export-content [<dir>] [--dry-run]` — write every published
3
+ * page back to its content file, sections recombined (#10648, spec
4
+ * docs/specs/10648-the-cutover-kit-frozen-inventory-spec.md D4–D6).
5
+ *
6
+ * The rollback that keeps published edits (roadmap D15): `git revert` would
7
+ * lose every edit made in the Admin since the cutover; this writes the
8
+ * workspace's published pages back as the files the importer reads, so a
9
+ * re-run of `migrate-content` reports every page `unchanged`.
10
+ *
11
+ * Reads, cross-checked (D4): only rows the workspace list reports `published`
12
+ * are exported, so the draft tip IS the published version and its raw content
13
+ * is exactly what the importer compares against; the public read supplies the
14
+ * published `sections` and a `publishedVersionId` that must equal the row's.
15
+ * A page with a draft ahead (`published_with_draft`) fails by name: its
16
+ * published version cannot be read with an agent key, and writing the draft
17
+ * would put unpublished words on the live site at rollback.
18
+ *
19
+ * Recombination (D5) is the exact inverse of the importer's conversion: the
20
+ * words go back into the sections' data through `applyWords`, and the words
21
+ * run the importer appended (`sectionsToMarkdown`, after a blank line) comes
22
+ * off the body. The file is `key: JSON.stringify(value)` frontmatter (JSON is
23
+ * YAML flow; create-ailk has no YAML dependency) and the body unchanged.
24
+ *
25
+ * Pure module: no `import.meta.url`, so jest imports it directly.
26
+ */
27
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
28
+ import { dirname, join, resolve } from "node:path";
29
+ import { parseArgs } from "node:util";
30
+ import { listWorkspacePages, loadSiteWordsCodec, readDraftPage, readPublishedPage, readSiteLocales, resolveWorkspaceAccess, } from "./inventory.js";
31
+ import { inlinePins, letterOf, pinnedKind } from "./language-import.js";
32
+ import { ParseError } from "./parse-args.js";
33
+ /** A refusal the CLI exits 2 on, thrown before any request is sent. */
34
+ export class ExportContentRefusedError extends Error {
35
+ exitCode = 2;
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = "ExportContentRefusedError";
39
+ }
40
+ }
41
+ /**
42
+ * The editorial tree's directories and the stored-to-fumadocs key renames —
43
+ * the inverse of `import-mdx.ts`'s `EDITORIAL_FIELDS` (runtime-source D3,
44
+ * restated: create-ailk cannot import `apps/web`).
45
+ */
46
+ const EDITORIAL_DIRS = {
47
+ BlogList: { dir: "blog", index: true },
48
+ BlogPosting: { dir: "blog", index: false },
49
+ DocsList: { dir: "docs", index: true },
50
+ DocsDetail: { dir: "docs", index: false },
51
+ };
52
+ const EDITORIAL_RENAMES = { publishedAt: "date", updatedAt: "updated" };
53
+ /** Keys an editorial file does not carry: its path decides both. */
54
+ const EDITORIAL_DROPPED = new Set(["type", "slug"]);
55
+ const INDEX_SLUG = "index";
56
+ /** The store's address shapes, restated (create-ailk has no runtime dep): a path built from them stays in the site. */
57
+ const TYPE_SHAPE = /^[A-Z][A-Za-z]*$/;
58
+ const SLUG_SHAPE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
59
+ const LOCALE_SHAPE = /^[a-z]{2}(-[A-Z]{2})?$/;
60
+ /** A pinned item's read times out as the kit's other reads do (`inventory.ts`). */
61
+ const ITEM_READ_TIMEOUT_MS = 15_000;
62
+ export const SPOKE_HINT = "the site's read does not serve this workspace's published version — is the spoke at --store database?";
63
+ // ── File addressing (D6) ────────────────────────────────────────────────────
64
+ /** The site-relative file a stored page is written to, and whether it is editorial. */
65
+ export function fileFor(targetDir, type, slug, locale) {
66
+ const exists = (path) => existsSync(join(targetDir, path));
67
+ const editorial = EDITORIAL_DIRS[type];
68
+ if (editorial !== undefined && editorial.index === (slug === INDEX_SLUG)) {
69
+ const base = `apps/web/content/${locale}/${editorial.dir}`;
70
+ if (editorial.index)
71
+ return { path: `${base}/index.mdx`, editorial: true };
72
+ // The importer reads `<slug>.mdx` first and `<slug>/index.mdx` (#10701);
73
+ // a page is written back to the form it was read from.
74
+ const flat = `${base}/${slug}.mdx`;
75
+ const nested = `${base}/${slug}/index.mdx`;
76
+ return { path: !exists(flat) && exists(nested) ? nested : flat, editorial: true };
77
+ }
78
+ const tree = `content/${locale}/${type}/${slug}.mdx`;
79
+ const legacy = `content/${type}/${slug}.mdx`;
80
+ return { path: !exists(tree) && exists(legacy) ? legacy : tree, editorial: false };
81
+ }
82
+ // ── Recombination (D5) ──────────────────────────────────────────────────────
83
+ /**
84
+ * The file whose import yields `content` again with `sections` as its layout,
85
+ * or why there is none.
86
+ */
87
+ export function recombine(content, sections, codec, editorial, pinned) {
88
+ const { body: rawBody, ...rest } = content;
89
+ let body = typeof rawBody === "string" ? rawBody : "";
90
+ const frontmatter = {};
91
+ for (const [key, value] of Object.entries(rest)) {
92
+ if (editorial && EDITORIAL_DROPPED.has(key))
93
+ continue;
94
+ frontmatter[editorial ? (EDITORIAL_RENAMES[key] ?? key) : key] = value;
95
+ }
96
+ if (sections !== undefined && sections.length > 0) {
97
+ const applied = codec.applyWords(body, sections);
98
+ if (applied.issues.length > 0) {
99
+ const codes = [...new Set(applied.issues.map((i) => `${i.code} (${i.sectionId})`))].join(", ");
100
+ return { reason: `the words blocks do not match the published sections: ${codes}` };
101
+ }
102
+ let words;
103
+ try {
104
+ words = codec.sectionsToMarkdown(applied.sections);
105
+ }
106
+ catch (err) {
107
+ return { reason: `codec: ${err.message}` };
108
+ }
109
+ // The importer writes `<lead>\n\n<words>`, or `<words>` alone.
110
+ const lead = body === words ? "" : body.endsWith(`\n\n${words}`) ? body.slice(0, -words.length - 2) : undefined;
111
+ if (lead === undefined || lead !== lead.trimEnd()) {
112
+ return { reason: "words blocks are not where import writes them" };
113
+ }
114
+ body = lead;
115
+ // #10735 D9 — a pinned section is written with its Live items inline.
116
+ frontmatter["sections"] = pinned === undefined ? applied.sections : inlinePins(applied.sections, pinned);
117
+ }
118
+ const lines = Object.entries(frontmatter)
119
+ .filter(([, value]) => value !== undefined)
120
+ .map(([key, value]) => `${key}: ${JSON.stringify(value)}`);
121
+ return { text: `---\n${lines.join("\n")}\n---\n${body}` };
122
+ }
123
+ export async function exportContent(opts) {
124
+ const targetDir = resolve(opts.targetDir);
125
+ const env = opts.env ?? process.env;
126
+ const onLine = opts.onLine ?? (() => undefined);
127
+ const fetch = opts.fetch ?? globalThis.fetch;
128
+ const dryRun = opts.dryRun === true;
129
+ const refuse = (message) => {
130
+ throw new ExportContentRefusedError(`export-content: ${message} Nothing was written.`);
131
+ };
132
+ const access = resolveWorkspaceAccess(targetDir, { apiUrl: opts.apiUrl, workspaceId: opts.workspaceId, env }, refuse);
133
+ const { defaultLocale: locale } = readSiteLocales(targetDir);
134
+ const codec = opts.codec ?? (await loadSiteWordsCodec(targetDir, refuse));
135
+ onLine(dryRun
136
+ ? `Planning the export of workspace ${access.workspaceId} into ${opts.targetDir}. Nothing is written with --dry-run.`
137
+ : `Exporting workspace ${access.workspaceId}'s published pages into ${opts.targetDir}.`);
138
+ const rows = (await listWorkspacePages(access, fetch, locale, refuse))
139
+ .filter((r) => r.locale === locale && r.status !== "draft")
140
+ .sort((a, b) => (`${a.type}/${a.slug}` < `${b.type}/${b.slug}` ? -1 : 1));
141
+ const pages = [];
142
+ for (const row of rows) {
143
+ const page = await exportPage(row);
144
+ pages.push(page);
145
+ onLine(page.result === "failed"
146
+ ? `${page.type}/${page.slug}: failed — ${page.reason}`
147
+ : `${page.file}: ${page.result}`);
148
+ }
149
+ const count = (result) => pages.filter((p) => p.result === result).length;
150
+ onLine(`created ${count("created")} · written ${count("written")} · unchanged ${count("unchanged")} · failed ${count("failed")}`);
151
+ return { exitCode: count("failed") > 0 ? 1 : 0, pages };
152
+ async function exportPage(row) {
153
+ const { type, slug } = row;
154
+ const failed = (reason, file = null) => ({
155
+ type,
156
+ slug,
157
+ file,
158
+ result: "failed",
159
+ reason,
160
+ });
161
+ if (row.status === "published_with_draft") {
162
+ return failed("a draft is ahead of the published version, so the published words cannot be read. Publish or revert the draft in Admin, then re-run.");
163
+ }
164
+ if (!TYPE_SHAPE.test(type) || !SLUG_SHAPE.test(slug) || !LOCALE_SHAPE.test(locale)) {
165
+ return failed("not a page address a file can be written at");
166
+ }
167
+ const draft = await readDraftPage(access, fetch, type, slug, locale);
168
+ if (draft.content === undefined)
169
+ return failed(`the draft read answered ${draft.status}`);
170
+ if (draft.version !== row.publishedVersionId) {
171
+ return failed("the draft read is not the published version; re-run");
172
+ }
173
+ const published = await readPublishedPage(access, fetch, type, slug, locale);
174
+ if (!published.found || published.publishedVersionId !== row.publishedVersionId) {
175
+ return failed(SPOKE_HINT);
176
+ }
177
+ const pinned = await pinnedItems(published.sections ?? [], locale);
178
+ if (typeof pinned === "string")
179
+ return failed(pinned);
180
+ const { path, editorial } = fileFor(targetDir, type, slug, locale);
181
+ const file = recombine(draft.content, published.sections, codec, editorial, pinned);
182
+ if ("reason" in file)
183
+ return failed(file.reason, path);
184
+ const absolute = join(targetDir, path);
185
+ const current = existsSync(absolute) ? readFileSync(absolute, "utf8") : undefined;
186
+ const result = current === undefined ? "created" : current === file.text ? "unchanged" : "written";
187
+ if (!dryRun && result !== "unchanged") {
188
+ mkdirSync(dirname(absolute), { recursive: true });
189
+ writeFileSync(absolute, file.text);
190
+ }
191
+ return { type, slug, file: path, result };
192
+ }
193
+ /**
194
+ * The Live words of every item the page's faq / glossary-index sections pin
195
+ * (#10735 D9), by kind and slug — `undefined` when none pins — or why the
196
+ * page cannot be written. A missing, off or disabled item has no entry.
197
+ */
198
+ async function pinnedItems(sections, locale) {
199
+ const items = { faq: new Map(), term: new Map() };
200
+ let any = false;
201
+ for (const section of sections) {
202
+ const data = section.data;
203
+ if (["faq", "glossary-index"].includes(String(section.type)) && data?.["tags"] !== undefined) {
204
+ return "a tag pull cannot be written to a file; pin its items or remove it, then re-run";
205
+ }
206
+ const kind = pinnedKind(section);
207
+ if (kind === undefined)
208
+ continue;
209
+ any = true;
210
+ for (const pin of data["pinned"]) {
211
+ const prefix = `${kind}:`;
212
+ if (typeof pin !== "string" || !pin.startsWith(prefix) || !SLUG_SHAPE.test(pin.slice(prefix.length)))
213
+ continue;
214
+ const itemSlug = pin.slice(prefix.length);
215
+ if (items[kind].has(itemSlug))
216
+ continue;
217
+ let status = 0;
218
+ let body;
219
+ try {
220
+ const response = await fetch(`${access.origin}/v1/workspaces/${encodeURIComponent(access.workspaceId)}/language/${kind}/${itemSlug}?locale=${encodeURIComponent(locale)}`, {
221
+ headers: { authorization: `Bearer ${access.apiKey}` },
222
+ // A redirect would re-send the key to another URL.
223
+ redirect: "error",
224
+ signal: AbortSignal.timeout(ITEM_READ_TIMEOUT_MS),
225
+ });
226
+ status = response.status;
227
+ body = (await response.json().catch(() => undefined));
228
+ }
229
+ catch {
230
+ // A timeout, a network failure or a redirect: status 0.
231
+ }
232
+ if (status === 404)
233
+ continue;
234
+ if (status !== 200)
235
+ return `the ${kind} item read answered ${status}`;
236
+ const item = body?.data?.item;
237
+ const words = item?.publishedWords;
238
+ if (item?.live !== true || words === null || words === undefined)
239
+ continue;
240
+ items[kind].set(itemSlug, kind === "term" ? { ...words, letter: letterOf(words["term"] ?? "", locale) } : words);
241
+ }
242
+ }
243
+ return any ? items : undefined;
244
+ }
245
+ }
246
+ export const EXPORT_CONTENT_HELP = `create-ailk export-content — write every published page back to its content file.
247
+
248
+ Usage:
249
+ AILK_AGENT_KEY=<key> create-ailk export-content [<dir>] [--dry-run] [--api-url <origin>] [--workspace <wksp_id>]
250
+
251
+ Arguments:
252
+ <dir> The site. Defaults to the current dir.
253
+
254
+ Options:
255
+ --dry-run Print what would be written, and write nothing.
256
+ --api-url <origin> The API origin that holds the workspace — on a
257
+ spoke, its hub's. Else AILK_API_URL.
258
+ --workspace <wksp_id> The workspace. Else AILK_WORKSPACE_ID.
259
+ -h, --help Show this help.
260
+
261
+ The rollback for a cutover: every page the workspace list reports published
262
+ in the default locale is written to the file migrate-content reads it from
263
+ — content/en/<Type>/<slug>.mdx (or the legacy content/<Type>/<slug>.mdx that
264
+ exists), apps/web/content/en/blog/<slug>.mdx for a post and the same under
265
+ docs for a docs page — with its words moved back into its sections, so a
266
+ re-run of migrate-content reports every page unchanged. Published edits are
267
+ kept. A page with a draft ahead of its published version fails by name:
268
+ publish or revert it in Admin, then re-run. The site's own API read must
269
+ serve the workspace's published version (on a spoke, the hub's row at
270
+ --store database). Each page prints created, written, unchanged or failed.
271
+ The agent key needs the content-edit grant; on a spoke, AILK_SPOKE_KEY is
272
+ needed too. Exit 0 when no page failed, 1 when any did, 2 on a refusal.
273
+ `;
274
+ export function parseExportContentArgs(argv) {
275
+ let parsed;
276
+ try {
277
+ parsed = parseArgs({
278
+ args: argv,
279
+ strict: true,
280
+ allowPositionals: true,
281
+ options: {
282
+ "dry-run": { type: "boolean", default: false },
283
+ "api-url": { type: "string" },
284
+ workspace: { type: "string" },
285
+ help: { type: "boolean", default: false, short: "h" },
286
+ },
287
+ });
288
+ }
289
+ catch (err) {
290
+ throw new ParseError(`export-content: ${err.message}`);
291
+ }
292
+ const [target, ...rest] = parsed.positionals;
293
+ if (rest.length > 0) {
294
+ throw new ParseError(`export-content takes at most one target directory, got ${parsed.positionals.length} arguments`);
295
+ }
296
+ return {
297
+ target,
298
+ dryRun: parsed.values["dry-run"] === true,
299
+ apiUrl: parsed.values["api-url"],
300
+ workspaceId: parsed.values.workspace,
301
+ help: parsed.values.help === true,
302
+ };
303
+ }
@@ -0,0 +1,216 @@
1
+ /** The kit's directory in the site: committed, unlike `.ailk/`. */
2
+ export declare const CUTOVER_DIR = ".ailk-cutover";
3
+ export declare const INVENTORY_FILE = "inventory.json";
4
+ export declare const AUDIT_FILE = "audit.json";
5
+ /** The spoke's key for the hub's public read (`x-ailk-spoke-key`). */
6
+ export declare const SPOKE_KEY_ENV_VAR = "AILK_SPOKE_KEY";
7
+ /** A refusal the CLI exits 2 on. */
8
+ export declare class InventoryRefusedError extends Error {
9
+ readonly exitCode = 2;
10
+ constructor(message: string);
11
+ }
12
+ export type InventorySource = "sitemap" | "crawl" | "roster" | "probe";
13
+ export interface FrozenEntry {
14
+ path: string;
15
+ /** `page` for `text/html`, `file` for anything else. */
16
+ kind: "page" | "file";
17
+ locale: string;
18
+ from: InventorySource[];
19
+ prose: boolean;
20
+ images: string[];
21
+ }
22
+ export interface ProseExclusion {
23
+ path: string;
24
+ reason: string;
25
+ by: "inventory" | "operator";
26
+ }
27
+ export interface InventoryFile {
28
+ version: 1;
29
+ origin: string;
30
+ frozenAt: string;
31
+ defaultLocale: string;
32
+ docsPath: string;
33
+ sources: {
34
+ sitemap: number;
35
+ crawl: number;
36
+ roster: number;
37
+ };
38
+ frozen: FrozenEntry[];
39
+ notFrozen: {
40
+ path: string;
41
+ status: number;
42
+ location?: string;
43
+ from: InventorySource[];
44
+ }[];
45
+ images: {
46
+ url: string;
47
+ status: number;
48
+ pages: string[];
49
+ }[];
50
+ proseExclusions: ProseExclusion[];
51
+ /** URL path → `Type/slug`, written by the operator for code-built pages. */
52
+ joins: Record<string, string>;
53
+ }
54
+ export declare const AUDIT_VERDICTS: readonly ["pass", "excluded", "read-failed", "unjoined", "no-page", "ambiguous", "locale-not-imported", "not-published", "words-not-in-markdown"];
55
+ export type AuditVerdict = (typeof AUDIT_VERDICTS)[number];
56
+ export interface AuditPage {
57
+ path: string;
58
+ join: string | null;
59
+ verdict: AuditVerdict;
60
+ prose: boolean;
61
+ published: boolean | null;
62
+ wordsInMarkdown: boolean | null;
63
+ }
64
+ export interface AuditFile {
65
+ version: 1;
66
+ inventoryFrozenAt: string;
67
+ pages: AuditPage[];
68
+ /** Workspace pages no frozen URL joins. */
69
+ orphans: string[];
70
+ counts: Record<AuditVerdict, number>;
71
+ }
72
+ /** What the kit reads the workspace with. */
73
+ export interface WorkspaceAccess {
74
+ origin: string;
75
+ workspaceId: string;
76
+ apiKey: string;
77
+ /** Set on a spoke: the hub's public read answers the spoke's workspace with it. */
78
+ spokeKey?: string;
79
+ }
80
+ /** The words codec's two functions the kit uses, typed structurally. */
81
+ export interface WordsCodec {
82
+ applyWords(markdown: string, sections: readonly unknown[]): {
83
+ sections: unknown[];
84
+ issues: {
85
+ code: string;
86
+ sectionId: string;
87
+ }[];
88
+ };
89
+ sectionsToMarkdown(sections: readonly unknown[]): string;
90
+ }
91
+ /** One row of the workspace list, as the editor's view gives it. */
92
+ export interface WorkspaceRow {
93
+ type: string;
94
+ slug: string;
95
+ kind: "page" | "post" | "file";
96
+ locale: string;
97
+ status: "draft" | "published" | "published_with_draft";
98
+ version: string;
99
+ publishedVersionId: string | null;
100
+ }
101
+ /** A value given, or `undefined` for an absent or empty one. */
102
+ export declare function given(value: string | undefined): string | undefined;
103
+ /**
104
+ * The key, workspace and API origin, as `migrate-content` reads them, plus the
105
+ * spoke key on a spoke. Every miss is refused through `refuse`, before any
106
+ * request.
107
+ */
108
+ export declare function resolveWorkspaceAccess(targetDir: string, o: {
109
+ apiUrl?: string | undefined;
110
+ workspaceId?: string | undefined;
111
+ env: NodeJS.ProcessEnv;
112
+ }, refuse: (message: string) => never): WorkspaceAccess;
113
+ /** A site origin: http(s), reduced to its origin. */
114
+ export declare function siteOrigin(value: string, flag: string, refuse: (message: string) => never): string;
115
+ /**
116
+ * The workspace's pages in `locale`, through the editor's view. A row with no
117
+ * `version` is the member projection — a key without `content-edit` — which
118
+ * reports every live row `published` and would hide a draft ahead: refused.
119
+ * @throws Error on a read that does not answer 200.
120
+ */
121
+ export declare function listWorkspacePages(access: WorkspaceAccess, fetch: typeof globalThis.fetch, locale: string, refuse: (message: string) => never): Promise<WorkspaceRow[]>;
122
+ export type PublishedRead = {
123
+ found: false;
124
+ status: number;
125
+ } | {
126
+ found: true;
127
+ sections: unknown[] | undefined;
128
+ body: string;
129
+ publishedVersionId: string | undefined;
130
+ };
131
+ /** The public read of one page with its body: what the site serves. */
132
+ export declare function readPublishedPage(access: WorkspaceAccess, fetch: typeof globalThis.fetch, type: string, slug: string, locale: string): Promise<PublishedRead>;
133
+ /** The draft tip's raw stored content — the value the importer compares against. */
134
+ export declare function readDraftPage(access: WorkspaceAccess, fetch: typeof globalThis.fetch, type: string, slug: string, locale: string): Promise<{
135
+ status: number;
136
+ content?: Record<string, unknown>;
137
+ version?: string;
138
+ }>;
139
+ /**
140
+ * The site's words codec: `@working-theory/validation` resolved from its
141
+ * `apps/web`, the name held in a constant so no import specifier names it.
142
+ */
143
+ export declare function loadSiteWordsCodec(targetDir: string, refuse: (message: string) => never): Promise<WordsCodec>;
144
+ /** Equal as JSON, keys sorted at every depth. */
145
+ export declare function sameJson(a: unknown, b: unknown): boolean;
146
+ /** The site's locales from `project.yaml` `product`, read without a YAML dependency. */
147
+ export declare function readSiteLocales(targetDir: string): {
148
+ defaultLocale: string;
149
+ locales: string[];
150
+ };
151
+ /**
152
+ * Whether a page carries prose (D3): inside `<main>` when it has one, with its
153
+ * script, style, noscript, nav, header, footer and form elements removed, a
154
+ * `<p>`, heading, `<li>` or `<blockquote>` with non-whitespace text.
155
+ */
156
+ export declare function isProseBearing(html: string): boolean;
157
+ interface RosterRoute {
158
+ route: string;
159
+ pageType: string | null;
160
+ }
161
+ type Join = {
162
+ join: string;
163
+ } | {
164
+ verdict: "unjoined" | "no-page" | "ambiguous";
165
+ join?: string;
166
+ };
167
+ /** A URL's workspace page: the operator's join, then the roster, then the editorial prefixes. */
168
+ export declare function joinUrl(rawPath: string, ctx: {
169
+ joins: Record<string, string>;
170
+ roster: RosterRoute[];
171
+ rows: WorkspaceRow[];
172
+ docsPath: string;
173
+ defaultLocale: string;
174
+ }): Join;
175
+ export interface InventoryOptions {
176
+ targetDir: string;
177
+ /** `--origin`: the site as served today. Needed only to freeze. */
178
+ origin?: string | undefined;
179
+ /** `--refreeze`: crawl again, keeping the operator's joins and exclusions. */
180
+ refreeze?: boolean;
181
+ /** `--docs-path`; defaults to `/docs`. */
182
+ docsPath?: string | undefined;
183
+ apiUrl?: string | undefined;
184
+ workspaceId?: string | undefined;
185
+ env?: NodeJS.ProcessEnv;
186
+ fetch?: typeof globalThis.fetch;
187
+ /** Injectable for tests; defaults to the site's installed codec. */
188
+ codec?: WordsCodec;
189
+ /** Injectable for tests: the freeze's one wall-clock value. */
190
+ now?: () => Date;
191
+ onLine?: (line: string) => void;
192
+ }
193
+ export interface InventoryResult {
194
+ exitCode: 0 | 1;
195
+ inventory: InventoryFile;
196
+ audit: AuditFile;
197
+ }
198
+ /**
199
+ * The inventory in the site, or `undefined` when none is frozen.
200
+ * @throws Error on a file that is not JSON or not a version-1 inventory.
201
+ */
202
+ export declare function readInventory(targetDir: string): InventoryFile | undefined;
203
+ export declare function formatAuditCounts(counts: Record<AuditVerdict, number>): string;
204
+ export declare function inventory(opts: InventoryOptions): Promise<InventoryResult>;
205
+ export interface InventoryArgs {
206
+ target: string | undefined;
207
+ origin: string | undefined;
208
+ refreeze: boolean;
209
+ docsPath: string | undefined;
210
+ apiUrl: string | undefined;
211
+ workspaceId: string | undefined;
212
+ help: boolean;
213
+ }
214
+ export declare const INVENTORY_HELP = "create-ailk inventory \u2014 freeze what a site serves today, and audit it against its workspace.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk inventory [<dir>] --origin <url> [--refreeze] [--docs-path <path>] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --origin <url> The site as served today. Needed to freeze; a\n frozen inventory keeps its own.\n --refreeze Crawl again. The operator's joins and exclusions\n are kept; the inventory's own are recomputed.\n --docs-path <path> Where the site serves its docs. Default /docs.\n --api-url <origin> The API origin that holds the workspace \u2014 on a\n spoke, its hub's. Else AILK_API_URL.\n --workspace <wksp_id> The workspace. Else AILK_WORKSPACE_ID.\n -h, --help Show this help.\n\nThe first run writes .ailk-cutover/inventory.json: every URL the sitemap,\na crawl of the site and the route roster name, each probed with no\nredirect followed. A direct 200 is frozen with its prose status and its\nimages; anything else is listed as not frozen with its status. A later\nrun reads that file and crawls nothing. Commit it.\n\nEvery run writes .ailk-cutover/audit.json: each frozen page joined to its\nworkspace page \u2014 by the inventory's \"joins\", then the roster, then /blog and\nthe docs path \u2014 and whether it is published and whether its words are in\nits Markdown. The agent key needs the content-edit grant; on a spoke,\nAILK_SPOKE_KEY is needed too. Exit 0 when every prose page passes, 1 when\nany does not (one line each), 2 on a refusal.\n";
215
+ export declare function parseInventoryArgs(argv: string[]): InventoryArgs;
216
+ export {};