create-ailk 0.29.1 → 0.31.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 (101) hide show
  1. package/README.md +71 -3
  2. package/component-catalog.json +524 -8
  3. package/component-catalog.md +8 -5
  4. package/dist/add-route.js +1 -0
  5. package/dist/cli.js +114 -1
  6. package/dist/cutover.d.ts +109 -0
  7. package/dist/cutover.js +494 -0
  8. package/dist/design-presets.js +20 -4
  9. package/dist/export-content.d.ts +55 -0
  10. package/dist/export-content.js +303 -0
  11. package/dist/inventory.d.ts +216 -0
  12. package/dist/inventory.js +833 -0
  13. package/dist/language-import.d.ts +114 -0
  14. package/dist/language-import.js +435 -0
  15. package/dist/lib/env-catalog.js +2 -2
  16. package/dist/lib/import-mdx.d.ts +38 -0
  17. package/dist/lib/import-mdx.js +411 -64
  18. package/dist/lib/render-comparison.d.ts +200 -0
  19. package/dist/lib/render-comparison.js +374 -0
  20. package/dist/lib/scaffold-render-sweep.d.ts +1 -1
  21. package/dist/lib/scaffold-render-sweep.js +1 -1
  22. package/dist/migrate-content.d.ts +6 -2
  23. package/dist/migrate-content.js +60 -14
  24. package/dist/module-architecture.js +6 -1
  25. package/dist/provision-env.d.ts +28 -0
  26. package/dist/provision-env.js +210 -0
  27. package/dist/seed-workspace.d.ts +3 -1
  28. package/dist/seed-workspace.js +45 -16
  29. package/dist/surfaces.js +3 -0
  30. package/dist/topology.d.ts +12 -0
  31. package/dist/topology.js +28 -10
  32. package/package.json +3 -3
  33. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  34. package/templates/.claude/hooks/content-source-guard.sh +75 -0
  35. package/templates/.claude/settings.json +9 -0
  36. package/templates/.env.example +7 -0
  37. package/templates/.github/workflows/ci.yml +2 -0
  38. package/templates/CLAUDE.md +5 -0
  39. package/templates/apps/api/CLAUDE.md +6 -2
  40. package/templates/apps/mcp/.env.example +20 -0
  41. package/templates/apps/mcp/CLAUDE.md +46 -8
  42. package/templates/apps/web/.env.example +4 -2
  43. package/templates/apps/web/CLAUDE.md +39 -5
  44. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
  45. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
  46. package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
  47. package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
  48. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
  49. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
  50. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
  51. package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
  52. package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
  53. package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
  54. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
  55. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
  56. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  57. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
  58. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
  59. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
  60. package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
  61. package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
  62. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
  63. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
  64. package/templates/apps/web/app/[locale]/layout.tsx +122 -27
  65. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
  66. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
  67. package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
  68. package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
  69. package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
  70. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
  71. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
  72. package/templates/apps/web/app/llms.txt/route.ts +26 -9
  73. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
  74. package/templates/apps/web/app/openapi.json/route.ts +4 -0
  75. package/templates/apps/web/app/robots.txt/route.ts +18 -1
  76. package/templates/apps/web/app/sitemap.xml/route.ts +13 -0
  77. package/templates/apps/web/auth.ts +184 -7
  78. package/templates/apps/web/jest.config.cjs +46 -1
  79. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
  80. package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
  81. package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
  82. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
  83. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
  84. package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
  85. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
  86. package/templates/apps/web/lib/auth-routes.ts +5 -3
  87. package/templates/apps/web/lib/bot-check.ts +113 -13
  88. package/templates/apps/web/lib/in-page-magnet.ts +4 -1
  89. package/templates/apps/web/lib/mcp-oauth.ts +702 -0
  90. package/templates/apps/web/lib/page-chrome.ts +65 -5
  91. package/templates/apps/web/lib/relay.ts +22 -3
  92. package/templates/apps/web/lib/site-identity.ts +75 -24
  93. package/templates/apps/web/messages/en.json +47 -14
  94. package/templates/apps/web/next.config.mjs +15 -1
  95. package/templates/apps/web/package.json +1 -1
  96. package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
  97. package/templates/apps/web/scripts/content-snapshot.ts +33 -0
  98. package/templates/database/seed.ts +6 -0
  99. package/templates/package.json +2 -1
  100. package/templates/tools/check-content-source.ts +108 -0
  101. package/versions.json +48 -48
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The Language pass of `migrate-content` (#10735, spec
3
+ * docs/specs/10735-import-migration-inline-faq-items-spec.md D1–D7): every
4
+ * inline FAQ item and glossary-index term the importer reads becomes, or
5
+ * reuses, one Language item of its kind (#10733), and each faq /
6
+ * glossary-index section is written as pins in source order.
7
+ *
8
+ * `withLanguage` wraps the site's adapter module: the wrapped
9
+ * `getPageSource` reads the page, makes sure every item on it exists (created
10
+ * Live when this run creates it), and returns the frontmatter with those
11
+ * sections rewritten as `faq:<slug>` / `term:<slug>` pins. The importer then
12
+ * writes the page as it writes any other, so the pass covers exactly the
13
+ * pages the importer reads, and every item a page pins exists before its
14
+ * layout is written. An item write that fails throws, which fails that page.
15
+ *
16
+ * Nothing is sent until a page carries an item to move; the first one probes
17
+ * the collection (D6), and an API without it (`402`, `403`, `404`) turns the
18
+ * pass off for the run, so pages import inline as they did before.
19
+ *
20
+ * The key travels only as `authorization: Bearer <key>`, never across a
21
+ * redirect; of an API answer only a status and a `[a-z0-9_]` code reach a
22
+ * line, and no line carries an answer's words (SEC-7876-S1).
23
+ */
24
+ import type { MdxAdapterModule } from "./lib/import-mdx.js";
25
+ export type LanguageItemKind = "faq" | "term";
26
+ export type LanguageItemResult = "created" | "unchanged" | "kept" | "failed";
27
+ export interface LanguageImportOptions {
28
+ /** The normalized API origin. */
29
+ apiOrigin: string;
30
+ workspaceId: string;
31
+ apiKey: string;
32
+ locale: string;
33
+ /** The dry run's `readOnlyFetch`, or the real one with `apply`. */
34
+ fetch: typeof globalThis.fetch;
35
+ /** Whether the run writes; a dry run decides the plan without a write. */
36
+ apply: boolean;
37
+ /** The wait before a rate-limited retry. */
38
+ sleep?: (ms: number) => Promise<void>;
39
+ /** Each rate-limit wait, as the importer prints its own. */
40
+ onLine?: (line: string) => void;
41
+ }
42
+ export interface LanguageImportResult {
43
+ items: {
44
+ kind: LanguageItemKind;
45
+ slug: string;
46
+ result: LanguageItemResult;
47
+ reason?: string;
48
+ }[];
49
+ counts: Record<LanguageItemResult, number>;
50
+ /**
51
+ * One per page occurrence the database read will not render as the file
52
+ * does. `from` is the winning page's path or `"workspace"` (the item's
53
+ * words differ), `"not Live"`, `"repeat"` (a key twice in one section,
54
+ * shown once) or `"letter"` (a term lettered otherwise at read).
55
+ */
56
+ renderChanges: {
57
+ kind: LanguageItemKind;
58
+ slug: string;
59
+ page: string;
60
+ from: string;
61
+ }[];
62
+ /** An imported item the home page's kind pull now also lists (#10734 D3); not a render change. */
63
+ topUps: {
64
+ kind: LanguageItemKind;
65
+ slug: string;
66
+ page: string;
67
+ }[];
68
+ /** An FAQ whose source carried a `kicker`, which a Language FAQ has no field for (#10750). */
69
+ droppedKickers: {
70
+ slug: string;
71
+ page: string;
72
+ }[];
73
+ /** A file's question displaced from a slug the workspace holds for another (D2). */
74
+ displaced: {
75
+ slug: string;
76
+ next: string;
77
+ }[];
78
+ }
79
+ /** The pass, and what it decided once the importer is done. */
80
+ export interface LanguagePass {
81
+ adapters: MdxAdapterModule;
82
+ /** `off` is the probe's status when the API has no collection; `result` is absent when nothing was sent. */
83
+ report(): {
84
+ result?: LanguageImportResult;
85
+ off?: number;
86
+ };
87
+ }
88
+ type Words = Record<string, string>;
89
+ /**
90
+ * An FAQ's slug (D2): lower-cased, diacritics stripped, each run outside
91
+ * `[a-z0-9]` one `-`, trimmed of `-`, cut at 64; `faq` when nothing is left.
92
+ */
93
+ export declare function faqSlug(question: string): string;
94
+ /** A term's letter as the database read derives it (`glossaryTermOf`, #10734 D4). */
95
+ export declare function letterOf(term: string, locale: string): string;
96
+ /**
97
+ * Each pinned faq / glossary-index section with `items` / `terms` set to the
98
+ * words of its pins found in `items` (by kind, then slug), in pin order, and
99
+ * no `pinned` (D9). A pin with no entry in `items` is dropped, as the read
100
+ * drops a pin that is missing, off or disabled.
101
+ */
102
+ export declare function inlinePins(sections: readonly unknown[], items: Record<LanguageItemKind, Map<string, Words>>): unknown[];
103
+ /** The kind a section pins, when it is a faq / glossary-index section carrying `pinned`. */
104
+ export declare function pinnedKind(section: unknown): LanguageItemKind | undefined;
105
+ export declare function withLanguage(adapters: MdxAdapterModule, options: LanguageImportOptions): LanguagePass;
106
+ /** D6's one line when the API has no collection. */
107
+ export declare function languageOffLine(status: number): string;
108
+ /** A render change's line (D7). */
109
+ export declare function renderChangeLine(c: LanguageImportResult["renderChanges"][number]): string;
110
+ /** The counts line, in the dry run's verbs or `--apply`'s. */
111
+ export declare function languageCountsLine(result: LanguageImportResult, apply: boolean): string;
112
+ /** Every line D7 prints, in order: items, counts, render changes, top-ups, dropped kickers, displacements. */
113
+ export declare function languageLines(result: LanguageImportResult, apply: boolean): string[];
114
+ export {};
@@ -0,0 +1,435 @@
1
+ /** The section type that carries each kind, and the page type whose kind pull lists every Live item. */
2
+ const SECTION_TYPE = { faq: "faq", term: "glossary-index" };
3
+ const HOME_TYPE = { faq: "FAQPage", term: "GlossaryList" };
4
+ const PREFIX = { faq: "faq:", term: "term:" };
5
+ /** The answers that mean an API without the collection, or a workspace not licensed for it (D6). */
6
+ const OFF_STATUSES = new Set([402, 403, 404]);
7
+ /** `LANGUAGE_SLUG_PATTERN` (`@working-theory/validation`), restated: no runtime dep. */
8
+ const ITEM_SLUG = /^[a-z0-9](?:[a-z0-9-]{0,118}[a-z0-9])?$/;
9
+ const SAFE_CODE = /^[a-z0-9_]{1,64}$/;
10
+ const FAQ_SLUG_MAX = 64;
11
+ const REQUEST_TIMEOUT_MS = 15_000;
12
+ const RATE_LIMIT_RETRIES = 3;
13
+ const RATE_LIMIT_WAIT_MAX_S = 60;
14
+ /**
15
+ * An FAQ's slug (D2): lower-cased, diacritics stripped, each run outside
16
+ * `[a-z0-9]` one `-`, trimmed of `-`, cut at 64; `faq` when nothing is left.
17
+ */
18
+ export function faqSlug(question) {
19
+ const slug = question
20
+ .toLowerCase()
21
+ .normalize("NFKD")
22
+ .replace(/[\u0300-\u036f]/g, "")
23
+ .replace(/[^a-z0-9]+/g, "-")
24
+ .replace(/^-+|-+$/g, "")
25
+ .slice(0, FAQ_SLUG_MAX)
26
+ .replace(/-+$/, "");
27
+ return slug === "" ? "faq" : slug;
28
+ }
29
+ /** A term's letter as the database read derives it (`glossaryTermOf`, #10734 D4). */
30
+ export function letterOf(term, locale) {
31
+ const first = Array.from(term)[0] ?? "";
32
+ return /\p{L}/u.test(first) ? first.toLocaleUpperCase(locale) : "#";
33
+ }
34
+ /**
35
+ * Each pinned faq / glossary-index section with `items` / `terms` set to the
36
+ * words of its pins found in `items` (by kind, then slug), in pin order, and
37
+ * no `pinned` (D9). A pin with no entry in `items` is dropped, as the read
38
+ * drops a pin that is missing, off or disabled.
39
+ */
40
+ export function inlinePins(sections, items) {
41
+ return sections.map((section) => {
42
+ const kind = pinnedKind(section);
43
+ if (kind === undefined)
44
+ return section;
45
+ const { data, ...rest } = section;
46
+ const { pinned, items: _items, terms: _terms, ...kept } = data;
47
+ const entries = pinned.flatMap((pin) => {
48
+ if (typeof pin !== "string")
49
+ return [];
50
+ const slug = pin.slice(PREFIX[kind].length);
51
+ const words = pin.startsWith(PREFIX[kind]) ? items[kind].get(slug) : undefined;
52
+ if (words === undefined)
53
+ return [];
54
+ return kind === "faq"
55
+ ? [{ question: words["question"], answer: words["answer"] }]
56
+ : [{ term: words["term"], slug, definition: words["definition"], letter: words["letter"] }];
57
+ });
58
+ return { ...rest, data: { ...kept, [kind === "faq" ? "items" : "terms"]: entries } };
59
+ });
60
+ }
61
+ /** The kind a section pins, when it is a faq / glossary-index section carrying `pinned`. */
62
+ export function pinnedKind(section) {
63
+ if (section === null || typeof section !== "object")
64
+ return undefined;
65
+ const { type, data } = section;
66
+ const kind = type === "faq" ? "faq" : type === "glossary-index" ? "term" : undefined;
67
+ return kind !== undefined && Array.isArray(data?.["pinned"]) ? kind : undefined;
68
+ }
69
+ /** The entries of a section D5 rewrites, or `undefined` for a section returned as read. */
70
+ function entriesOf(section) {
71
+ if (section === null || typeof section !== "object")
72
+ return undefined;
73
+ const { type, data } = section;
74
+ if (data === null || typeof data !== "object" || Array.isArray(data))
75
+ return undefined;
76
+ const fields = data;
77
+ if ("pinned" in fields || "tags" in fields)
78
+ return undefined;
79
+ const str = (value) => typeof value === "string" && value !== "";
80
+ const list = type === "faq" ? fields["items"] : type === "glossary-index" ? fields["terms"] : undefined;
81
+ if (!Array.isArray(list) || list.length === 0)
82
+ return undefined;
83
+ const entries = [];
84
+ for (const raw of list) {
85
+ if (raw === null || typeof raw !== "object")
86
+ return undefined;
87
+ if (type === "faq") {
88
+ if (!str(raw["question"]) || !str(raw["answer"]))
89
+ return undefined;
90
+ entries.push({
91
+ key: raw["question"],
92
+ words: { question: raw["question"], answer: raw["answer"] },
93
+ kicker: raw["kicker"] !== undefined,
94
+ });
95
+ }
96
+ else {
97
+ const { term, slug, definition, letter } = raw;
98
+ if (!str(term) || !str(slug) || !str(definition) || typeof letter !== "string" || !ITEM_SLUG.test(slug)) {
99
+ return undefined;
100
+ }
101
+ entries.push({ key: slug, words: { term, definition }, letter, kicker: false });
102
+ }
103
+ }
104
+ return { kind: type === "faq" ? "faq" : "term", entries };
105
+ }
106
+ function reasonOf(answer) {
107
+ return "network" in answer ? answer.network : `${answer.status} ${answer.code}`;
108
+ }
109
+ export function withLanguage(adapters, options) {
110
+ const base = `${options.apiOrigin}/v1/workspaces/${encodeURIComponent(options.workspaceId)}/language`;
111
+ const query = `?locale=${encodeURIComponent(options.locale)}`;
112
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
113
+ let state = "unprobed";
114
+ let offStatus;
115
+ const items = new Map();
116
+ /** In-run FAQ slug claims, so two questions never share a slug (D2); a term's slug is its key. */
117
+ const claimed = new Map();
118
+ const occurrences = [];
119
+ const homes = [];
120
+ const kickers = [];
121
+ const displaced = [];
122
+ async function call(method, url, body) {
123
+ for (let retry = 0;; retry++) {
124
+ let response;
125
+ try {
126
+ response = await options.fetch(url, {
127
+ method,
128
+ headers: {
129
+ authorization: `Bearer ${options.apiKey}`,
130
+ ...(body !== undefined && { "content-type": "application/json" }),
131
+ },
132
+ ...(body !== undefined && { body: JSON.stringify(body) }),
133
+ redirect: "error",
134
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
135
+ });
136
+ }
137
+ catch (err) {
138
+ const name = err instanceof Error ? err.name.toLowerCase() : "";
139
+ return { network: `network ${SAFE_CODE.test(name) ? name : "unknown"}` };
140
+ }
141
+ if (response.status === 429 && retry < RATE_LIMIT_RETRIES) {
142
+ const header = response.headers.get("retry-after");
143
+ const asked = header !== null && /^\d+$/.test(header) ? Number(header) : 0;
144
+ const seconds = asked >= 1 && asked <= RATE_LIMIT_WAIT_MAX_S ? asked : RATE_LIMIT_WAIT_MAX_S;
145
+ options.onLine?.(`rate limited — waiting ${seconds}s`);
146
+ await sleep(seconds * 1000);
147
+ continue;
148
+ }
149
+ let envelope = {};
150
+ try {
151
+ envelope = ((await response.json()) ?? {});
152
+ }
153
+ catch {
154
+ // No JSON body: the status alone is reported.
155
+ }
156
+ const code = envelope.error?.code;
157
+ const data = envelope.data;
158
+ return {
159
+ status: response.status,
160
+ code: typeof code === "string" && SAFE_CODE.test(code) ? code : "unknown",
161
+ data: data !== null && typeof data === "object" ? data : {},
162
+ };
163
+ }
164
+ }
165
+ const itemUrl = (kind, slug) => `${base}/${kind}/${encodeURIComponent(slug)}`;
166
+ /** The stored item at a slug, `null` for none; throws on any other answer. */
167
+ async function read(kind, slug) {
168
+ const answer = await call("GET", `${itemUrl(kind, slug)}${query}`);
169
+ if (!("network" in answer) && answer.status === 404)
170
+ return null;
171
+ const item = "network" in answer ? undefined : answer.data["item"];
172
+ if ("network" in answer || answer.status !== 200 || item === undefined) {
173
+ throw new Error(`language: ${kind}/${slug}: ${reasonOf(answer)}`);
174
+ }
175
+ return {
176
+ words: (item["words"] ?? {}),
177
+ published: (item["publishedWords"] ?? null),
178
+ live: item["live"] === true,
179
+ };
180
+ }
181
+ /** Writes `words` (a create when `version` is `null`) and sets the item Live. */
182
+ async function write(item, words, version) {
183
+ const url = itemUrl(item.kind, item.slug);
184
+ const saved = await call("PUT", `${url}${query}`, { words, version });
185
+ const ok = (a) => !("network" in a) && (a.status === 200 || a.status === 201);
186
+ if (!ok(saved))
187
+ throw failure(item, reasonOf(saved));
188
+ const tip = ("network" in saved ? undefined : saved.data["item"])?.version;
189
+ if (typeof tip === "string")
190
+ item.version = tip;
191
+ else if (options.apply)
192
+ throw failure(item, `${saved.status} unknown`);
193
+ const live = await call("POST", `${url}/live${query}`, { live: true, ...(item.version !== undefined && { version: item.version }) });
194
+ if (!ok(live))
195
+ throw failure(item, reasonOf(live));
196
+ }
197
+ function failure(item, reason) {
198
+ item.failed = reason;
199
+ return new Error(`language: ${item.kind}/${item.slug}: ${reason}`);
200
+ }
201
+ /** The item an entry is pinned to, created or found (D2–D4). */
202
+ async function ensure(kind, entry, page, home) {
203
+ const mapKey = `${kind}\u0000${entry.key}`;
204
+ const known = items.get(mapKey);
205
+ if (known !== undefined) {
206
+ // A failed item fails every page that carries it, so none pins it.
207
+ if (known.failed !== undefined)
208
+ throw new Error(`language: ${kind}/${known.slug}: ${known.failed}`);
209
+ if (home && !known.winner.home) {
210
+ // D3 — the home page outranks a page read before it; an item this run
211
+ // created takes its words, and a found one is never written (D4).
212
+ known.winner = { page, words: entry.words, home: true };
213
+ if (known.created && options.apply)
214
+ await write(known, entry.words, known.version ?? null);
215
+ }
216
+ return known;
217
+ }
218
+ let slug = entry.key;
219
+ let stored;
220
+ if (kind === "term") {
221
+ stored = await read(kind, slug);
222
+ }
223
+ else {
224
+ const first = faqSlug(entry.key);
225
+ let skipped;
226
+ for (let n = 1;; n++) {
227
+ const candidate = n === 1 ? first : `${first}-${n}`;
228
+ const holder = claimed.get(candidate);
229
+ if (holder !== undefined && holder !== entry.key)
230
+ continue;
231
+ stored = await read(kind, candidate);
232
+ if (stored !== null && stored.words["question"] !== entry.key) {
233
+ skipped ??= candidate;
234
+ continue;
235
+ }
236
+ slug = candidate;
237
+ break;
238
+ }
239
+ if (skipped !== undefined)
240
+ displaced.push({ slug: skipped, next: slug });
241
+ }
242
+ if (kind === "faq")
243
+ claimed.set(slug, entry.key);
244
+ const item = {
245
+ kind,
246
+ slug,
247
+ created: stored === null,
248
+ winner: { page, words: entry.words, home },
249
+ ...(stored !== null && { stored }),
250
+ };
251
+ items.set(mapKey, item);
252
+ if (stored === null)
253
+ await write(item, entry.words, null);
254
+ return item;
255
+ }
256
+ /** D6 — `true` when the pass runs. */
257
+ async function probe() {
258
+ if (state !== "unprobed")
259
+ return state === "on";
260
+ const answer = await call("GET", `${base}?kind=faq&locale=${encodeURIComponent(options.locale)}`);
261
+ if ("network" in answer || (answer.status !== 200 && !OFF_STATUSES.has(answer.status))) {
262
+ throw new Error(`language: the collection probe answered ${reasonOf(answer)}`);
263
+ }
264
+ if (answer.status === 200) {
265
+ state = "on";
266
+ return true;
267
+ }
268
+ state = "off";
269
+ offStatus = answer.status;
270
+ return false;
271
+ }
272
+ async function rewrite(source, type, slug, page) {
273
+ const sections = source.frontmatter["sections"];
274
+ if (!Array.isArray(sections))
275
+ return source;
276
+ const found = sections.map(entriesOf);
277
+ if (found.every((f) => f === undefined) || !(await probe()))
278
+ return source;
279
+ const rewritten = [];
280
+ for (const [index, section] of sections.entries()) {
281
+ const moving = found[index];
282
+ if (moving === undefined) {
283
+ rewritten.push(section);
284
+ continue;
285
+ }
286
+ const { kind, entries } = moving;
287
+ const pins = [];
288
+ const seen = new Set();
289
+ for (const entry of entries) {
290
+ const item = await ensure(kind, entry, page, type === HOME_TYPE[kind]);
291
+ pins.push(`${PREFIX[kind]}${item.slug}`);
292
+ occurrences.push({
293
+ item,
294
+ page,
295
+ words: entry.words,
296
+ ...(entry.letter !== undefined && { letter: entry.letter }),
297
+ repeat: seen.has(item),
298
+ });
299
+ seen.add(item);
300
+ if (entry.kicker)
301
+ kickers.push({ slug: item.slug, page });
302
+ }
303
+ const { data, ...rest } = section;
304
+ const { items: _items, terms: _terms, ...kept } = data;
305
+ // `GlossaryIndexDataSchema` requires `terms`, so a pinned glossary-index carries `[]`.
306
+ rewritten.push({ ...rest, data: { ...kept, ...(kind === "term" && { terms: [] }), pinned: pins } });
307
+ }
308
+ for (const kind of ["faq", "term"]) {
309
+ if (type !== HOME_TYPE[kind])
310
+ continue;
311
+ const own = rewritten.filter((s) => s.type === SECTION_TYPE[kind]);
312
+ if (own.length === 0)
313
+ continue;
314
+ const shown = new Set();
315
+ for (const s of own) {
316
+ const data = s.data ?? {};
317
+ for (const pin of data["pinned"] ?? [])
318
+ shown.add(pin);
319
+ }
320
+ homes.push({ kind, slug, shown });
321
+ }
322
+ return { ...source, frontmatter: { ...source.frontmatter, sections: rewritten } };
323
+ }
324
+ function result() {
325
+ const list = [...items.values()];
326
+ const outcome = (item) => item.failed !== undefined
327
+ ? "failed"
328
+ : item.created
329
+ ? "created"
330
+ : same(item.stored.words, item.winner.words)
331
+ ? "unchanged"
332
+ : "kept";
333
+ const counts = { created: 0, unchanged: 0, kept: 0, failed: 0 };
334
+ for (const item of list)
335
+ counts[outcome(item)] += 1;
336
+ const renderChanges = [];
337
+ for (const o of occurrences) {
338
+ const { item } = o;
339
+ if (item.failed !== undefined)
340
+ continue;
341
+ const change = (from) => renderChanges.push({ kind: item.kind, slug: item.slug, page: o.page, from });
342
+ const shown = item.created ? item.winner.words : item.stored.live ? item.stored.published : null;
343
+ if (shown === null)
344
+ change("not Live");
345
+ else if (!same(o.words, shown))
346
+ change(item.created ? item.winner.page : "workspace");
347
+ else if (o.repeat)
348
+ change("repeat");
349
+ else if (o.letter !== undefined && o.letter !== letterOf(shown["term"] ?? "", options.locale))
350
+ change("letter");
351
+ }
352
+ const topUps = [];
353
+ for (const home of homes) {
354
+ for (const item of list) {
355
+ const key = `${PREFIX[item.kind]}${item.slug}`;
356
+ const live = item.created || item.stored.live;
357
+ if (item.kind === home.kind && item.failed === undefined && live && !home.shown.has(key)) {
358
+ topUps.push({ kind: item.kind, slug: item.slug, page: `/${home.slug}` });
359
+ }
360
+ }
361
+ }
362
+ return {
363
+ items: list.map((item) => ({
364
+ kind: item.kind,
365
+ slug: item.slug,
366
+ result: outcome(item),
367
+ ...(item.failed !== undefined && { reason: item.failed }),
368
+ })),
369
+ counts,
370
+ renderChanges,
371
+ topUps,
372
+ droppedKickers: kickers,
373
+ displaced,
374
+ };
375
+ }
376
+ return {
377
+ adapters: {
378
+ ...adapters,
379
+ createMdxAdapter(o) {
380
+ const reader = adapters.createMdxAdapter(o);
381
+ return {
382
+ async getPageSource(type, slug, locale) {
383
+ const source = locale === undefined ? await reader.getPageSource(type, slug) : await reader.getPageSource(type, slug, locale);
384
+ if (source === null || state === "off")
385
+ return source;
386
+ const page = `${locale === undefined ? "" : `${locale}/`}${type}/${slug}.mdx`;
387
+ return rewrite(source, type, slug, page);
388
+ },
389
+ };
390
+ },
391
+ },
392
+ report: () => state === "off" ? { off: offStatus } : state === "on" ? { result: result() } : {},
393
+ };
394
+ }
395
+ /** Whether two words records carry the same keys and values. */
396
+ function same(a, b) {
397
+ const keys = Object.keys(a);
398
+ return keys.length === Object.keys(b).length && keys.every((k) => a[k] === b[k]);
399
+ }
400
+ // ── The report (D7) ─────────────────────────────────────────────────────────
401
+ /** D6's one line when the API has no collection. */
402
+ export function languageOffLine(status) {
403
+ return `language: the API has no Language collection for this workspace (${status}); FAQ and glossary items stay inline`;
404
+ }
405
+ /** A render change's line (D7). */
406
+ export function renderChangeLine(c) {
407
+ const at = `language: ${c.kind}/${c.slug} on ${c.page}`;
408
+ switch (c.from) {
409
+ case "not Live":
410
+ return `${at}: is not Live, so ${c.page} will not show it`;
411
+ case "repeat":
412
+ return `${at}: repeats in one section, and the database read shows it once`;
413
+ case "letter":
414
+ return `${at}: is lettered otherwise at read than its file letters it`;
415
+ default:
416
+ return `${at}: shows ${c.from}'s words, not its own`;
417
+ }
418
+ }
419
+ /** The counts line, in the dry run's verbs or `--apply`'s. */
420
+ export function languageCountsLine(result, apply) {
421
+ const { created, unchanged, kept, failed } = result.counts;
422
+ return `language: ${apply ? "created" : "create"} ${created} · unchanged ${unchanged} · kept ${kept} · failed ${failed}`;
423
+ }
424
+ /** Every line D7 prints, in order: items, counts, render changes, top-ups, dropped kickers, displacements. */
425
+ export function languageLines(result, apply) {
426
+ const verb = (r) => (!apply && r === "created" ? "create" : r);
427
+ return [
428
+ ...result.items.map((i) => `language: ${i.kind}/${i.slug}: ${verb(i.result)}${i.reason === undefined ? "" : ` — ${i.reason}`}`),
429
+ languageCountsLine(result, apply),
430
+ ...result.renderChanges.map(renderChangeLine),
431
+ ...result.topUps.map((t) => `language: ${t.kind}/${t.slug}: now also on ${t.page}`),
432
+ ...result.droppedKickers.map((k) => `language: faq/${k.slug}: kicker dropped`),
433
+ ...result.displaced.map((d) => `language: faq/${d.slug}: the workspace holds a different question here; the file's is ${d.next}`),
434
+ ];
435
+ }
@@ -120,7 +120,7 @@ export const ENV_CATALOG = {
120
120
  secret: false,
121
121
  },
122
122
  CONTENT_REVALIDATE_SECRET: {
123
- description: "The shared secret /api/revalidate checks the API's publish webhook against; the same value as the API's.",
123
+ description: "The secret /api/revalidate checks the API's publish webhook against. On a site a hub serves, the value `spokes revalidate-url` printed for it — never the hub's own secret; otherwise the same value as the API's.",
124
124
  secret: true,
125
125
  },
126
126
  DATABASE_URL: {
@@ -164,7 +164,7 @@ export const ENV_CATALOG = {
164
164
  secret: false,
165
165
  },
166
166
  MCP_PUBLIC_HTTP_URL: {
167
- description: "The public URL of the MCP HTTP transport, advertised by /.well-known/mcp.json.",
167
+ description: "The public URL of the MCP HTTP transport (apps/mcp's `/mcp`), advertised by /.well-known/mcp.json; with BETTER_AUTH_URL it turns on the OAuth metadata. Set by create-ailk provision-env.",
168
168
  secret: false,
169
169
  },
170
170
  MCP_TRANSPORT: {
@@ -15,10 +15,29 @@ export interface MdxAdapterModule {
15
15
  } | null>;
16
16
  };
17
17
  SCHEMA_ORG_TYPES: readonly string[];
18
+ /**
19
+ * The words codec (#10534); without it a page keeps its words in `sections`
20
+ * and the run says so. Throws for sections the codec cannot carry.
21
+ */
22
+ sectionsToMarkdown?: (sections: readonly {
23
+ id: string;
24
+ type: string;
25
+ data?: unknown;
26
+ }[]) => string;
27
+ /**
28
+ * The adapter's frontmatter parser (#10701), for the editorial tree the
29
+ * adapter cannot read; without it every editorial page fails.
30
+ */
31
+ parseFrontmatter?: (raw: string) => {
32
+ data: Record<string, unknown>;
33
+ content: string;
34
+ };
18
35
  }
19
36
  export interface ImportMdxOptions {
20
37
  /** The site's content root (`content/`). */
21
38
  contentRoot: string;
39
+ /** The site's editorial root (`apps/web/content`); omitted, it is not walked. */
40
+ editorialRoot?: string;
22
41
  adapters: MdxAdapterModule;
23
42
  /** The API's origin: https, or http on a loopback host. */
24
43
  apiOrigin: string;
@@ -33,13 +52,19 @@ export interface ImportMdxOptions {
33
52
  sleep?: (ms: number) => Promise<void>;
34
53
  /** Each report line as it is decided, and each wait; defaults to nothing. */
35
54
  onLine?: (line: string) => void;
55
+ /** Set Live each written page that has no Live version (#10659 D2); off by default. */
56
+ publish?: boolean;
36
57
  }
58
+ /** The tree a page came from: `content/` or the site's `apps/web/content`. */
59
+ export type ImportMdxRoot = "content" | "editorial";
37
60
  export type ImportMdxOutcome = {
61
+ root: ImportMdxRoot;
38
62
  path: string;
39
63
  result: "created" | "updated" | "unchanged";
40
64
  type: string;
41
65
  slug: string;
42
66
  } | {
67
+ root: ImportMdxRoot;
43
68
  path: string;
44
69
  result: "skipped" | "failed";
45
70
  reason: string;
@@ -48,7 +73,19 @@ export type ImportMdxResultKind = ImportMdxOutcome["result"];
48
73
  export interface ImportMdxResult {
49
74
  pages: ImportMdxOutcome[];
50
75
  counts: Record<ImportMdxResultKind, number>;
76
+ /** With `publish`: the pages with a Live version at the end of the run. */
77
+ live?: number;
78
+ /** With `publish`: the publish requests answered 200. */
79
+ published?: number;
80
+ }
81
+ export declare function isDirectory(path: string): boolean;
82
+ /** Where an outcome is about: the root and the path below it. */
83
+ interface At {
84
+ root: ImportMdxRoot;
85
+ path: string;
51
86
  }
87
+ /** An outcome's printed path: an editorial one says so (#10701 D6). */
88
+ export declare function outcomePath(at: At): string;
52
89
  /** The whole report, one line per page and a final count line. */
53
90
  export declare function formatImportMdxReport(result: ImportMdxResult): string;
54
91
  export declare function importMdx(opts: ImportMdxOptions): Promise<ImportMdxResult>;
@@ -57,3 +94,4 @@ export declare function importMdx(opts: ImportMdxOptions): Promise<ImportMdxResu
57
94
  * create-ailk's own `node_modules`.
58
95
  */
59
96
  export declare function loadSiteMdxAdapter(siteRoot: string): Promise<MdxAdapterModule>;
97
+ export {};