create-ailk 0.30.0 → 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.
- package/README.md +71 -3
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/cli.js +99 -0
- package/dist/cutover.d.ts +109 -0
- package/dist/cutover.js +494 -0
- package/dist/design-presets.js +20 -4
- package/dist/export-content.d.ts +55 -0
- package/dist/export-content.js +303 -0
- package/dist/inventory.d.ts +216 -0
- package/dist/inventory.js +833 -0
- package/dist/language-import.d.ts +114 -0
- package/dist/language-import.js +435 -0
- package/dist/lib/env-catalog.js +1 -1
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +411 -64
- package/dist/lib/render-comparison.d.ts +200 -0
- package/dist/lib/render-comparison.js +374 -0
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +60 -14
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +24 -10
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/content-source-guard.sh +75 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/mcp/CLAUDE.md +8 -4
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +16 -5
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
- package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
- package/templates/apps/web/app/[locale]/layout.tsx +122 -27
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/llms.txt/route.ts +16 -9
- package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
- package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
- package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
- package/templates/apps/web/lib/bot-check.ts +113 -13
- package/templates/apps/web/lib/in-page-magnet.ts +4 -1
- package/templates/apps/web/lib/page-chrome.ts +65 -5
- package/templates/apps/web/lib/relay.ts +22 -3
- package/templates/apps/web/lib/site-identity.ts +75 -24
- package/templates/apps/web/messages/en.json +5 -1
- package/templates/apps/web/next.config.mjs +15 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
- package/templates/apps/web/scripts/content-snapshot.ts +33 -0
- package/templates/package.json +2 -1
- package/templates/tools/check-content-source.ts +108 -0
- package/versions.json +48 -48
package/dist/lib/import-mdx.js
CHANGED
|
@@ -10,17 +10,40 @@
|
|
|
10
10
|
* and reason and the run goes on. The seed step (U6b) and `migrate-content`
|
|
11
11
|
* (U6c) both call it; this module adds no command.
|
|
12
12
|
*
|
|
13
|
+
* A page whose frontmatter carries `sections` is converted (#10534): its
|
|
14
|
+
* words move into the Markdown body through the words codec
|
|
15
|
+
* (`sectionsToMarkdown`, ADR 0038), after the body it already had, and the
|
|
16
|
+
* sections — layout and data — go to the structure layer through
|
|
17
|
+
* `PUT …/structure` under the draft's structure token (#10643), never into the
|
|
18
|
+
* stored content. An unchanged layout writes nothing, so a re-run still
|
|
19
|
+
* reports `unchanged`; a changed layout on a live draft is refused
|
|
20
|
+
* `draft_is_live` and the page fails with that reason.
|
|
21
|
+
*
|
|
13
22
|
* create-ailk ships one runtime dependency, so the adapter is never imported:
|
|
14
23
|
* the caller injects it, and `loadSiteMdxAdapter` resolves the SITE's
|
|
15
|
-
* installed copy from its `apps/web` at run time (the #6937 precedent)
|
|
24
|
+
* installed copy from its `apps/web` at run time (the #6937 precedent), and
|
|
25
|
+
* the codec from the validation package that copy depends on.
|
|
26
|
+
*
|
|
27
|
+
* Given `editorialRoot` — a site's `apps/web/content` — it also walks the
|
|
28
|
+
* docs-blog tree (#10701): each `<locale>/{blog,docs}/<slug>.mdx` or
|
|
29
|
+
* `<slug>/index.mdx` becomes a `BlogPosting` / `DocsDetail` page (the
|
|
30
|
+
* directory's own `index.mdx` its `BlogList` / `DocsList` index), its
|
|
31
|
+
* fumadocs frontmatter mapped to the stored vocabulary and written through the
|
|
32
|
+
* same converting path. Every outcome names the root it came from.
|
|
33
|
+
*
|
|
34
|
+
* Given `publish` (#10659 D2), each page with no Live version is set Live
|
|
35
|
+
* after it is written (`POST …/publish`, the draft tip); a page already Live
|
|
36
|
+
* is never published over. The first refused publish stops the publishes
|
|
37
|
+
* that follow, never the saves.
|
|
16
38
|
*
|
|
17
39
|
* The key travels only as `authorization: Bearer <key>`, never across a
|
|
18
40
|
* redirect (`redirect: "error"`), and is never printed. Of an API answer only
|
|
19
41
|
* a status and a `[a-z0-9_]` error code reach output (SEC-7876-S1).
|
|
20
42
|
*
|
|
21
|
-
* Spec: docs/specs/10203-import-mdx-one-mdx-artifact-spec.md
|
|
43
|
+
* Spec: docs/specs/10203-import-mdx-one-mdx-artifact-spec.md; the editorial
|
|
44
|
+
* walk docs/specs/10701-import-mdx-walks-the-editorial-spec.md.
|
|
22
45
|
*/
|
|
23
|
-
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
46
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
24
47
|
import { createRequire } from "node:module";
|
|
25
48
|
import { dirname, join, resolve } from "node:path";
|
|
26
49
|
import { pathToFileURL } from "node:url";
|
|
@@ -30,8 +53,40 @@ import { TopologyError, normalizeHubUrl } from "../topology.js";
|
|
|
30
53
|
* import specifier of a package create-ailk does not depend on appears here.
|
|
31
54
|
*/
|
|
32
55
|
const CONTENT_ADAPTERS_PACKAGE = "@working-theory/content-adapters";
|
|
56
|
+
/** Where the words codec lives: a dependency of the adapter package. */
|
|
57
|
+
const VALIDATION_PACKAGE = "@working-theory/validation";
|
|
58
|
+
/** The adapter package's frontmatter parser, resolved from its entry. */
|
|
59
|
+
const FRONTMATTER_PACKAGE = "gray-matter";
|
|
33
60
|
/** The adapter's locale shape (`BCP47_RE` in its `mdx/adapter.ts`). */
|
|
34
61
|
const LOCALE = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
62
|
+
/** `SLUG_PATTERN` in the adapter's `errors.ts`, restated: no runtime dep. */
|
|
63
|
+
const SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
64
|
+
/** Each editorial directory's page type and index type (`runtime-source.ts`). */
|
|
65
|
+
const EDITORIAL_TYPES = {
|
|
66
|
+
blog: { page: "BlogPosting", index: "BlogList" },
|
|
67
|
+
docs: { page: "DocsDetail", index: "DocsList" },
|
|
68
|
+
};
|
|
69
|
+
/** The slug the runtime reads as a surface's own index. */
|
|
70
|
+
const INDEX_SLUG = "index";
|
|
71
|
+
const INDEX_TYPES = new Set(Object.values(EDITORIAL_TYPES).map((k) => k.index));
|
|
72
|
+
/**
|
|
73
|
+
* `fumadocsToArtifactFrontmatter` (`apps/web/lib/runtime-source.ts`) restated —
|
|
74
|
+
* fumadocs key to stored key — less `tags`, which the store keeps itself, plus
|
|
75
|
+
* `sections`, which `convert` moves. Any other key is not sent.
|
|
76
|
+
*/
|
|
77
|
+
const EDITORIAL_FIELDS = {
|
|
78
|
+
title: "title",
|
|
79
|
+
description: "description",
|
|
80
|
+
date: "publishedAt",
|
|
81
|
+
updated: "updatedAt",
|
|
82
|
+
image: "image",
|
|
83
|
+
author: "author",
|
|
84
|
+
order: "order",
|
|
85
|
+
parent: "parent",
|
|
86
|
+
sections: "sections",
|
|
87
|
+
};
|
|
88
|
+
/** Keys not sent and not reported: the path decides both, and a strip marker. */
|
|
89
|
+
const EDITORIAL_IGNORED = new Set(["type", "slug", "oss"]);
|
|
35
90
|
/** `wksp_<uuidv7>` — the `@working-theory/ids` shape, restated: no runtime dep. */
|
|
36
91
|
const WORKSPACE_ID = /^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
37
92
|
/** A key is visible ASCII only: what an HTTP header can carry, with no space. */
|
|
@@ -55,16 +110,19 @@ export class ImportMdxRefusedError extends Error {
|
|
|
55
110
|
function refuse(message) {
|
|
56
111
|
throw new ImportMdxRefusedError(`import-mdx: ${message} Nothing was sent.`);
|
|
57
112
|
}
|
|
58
|
-
function
|
|
59
|
-
let isDir;
|
|
113
|
+
export function isDirectory(path) {
|
|
60
114
|
try {
|
|
61
|
-
|
|
115
|
+
return statSync(path).isDirectory();
|
|
62
116
|
}
|
|
63
117
|
catch {
|
|
64
|
-
|
|
118
|
+
return false;
|
|
65
119
|
}
|
|
66
|
-
|
|
120
|
+
}
|
|
121
|
+
function checkOptions(opts, defaultLocale) {
|
|
122
|
+
if (!isDirectory(opts.contentRoot))
|
|
67
123
|
refuse(`the content root ${opts.contentRoot} is not a directory.`);
|
|
124
|
+
if (opts.editorialRoot !== undefined && !isDirectory(opts.editorialRoot))
|
|
125
|
+
refuse(`the editorial root ${opts.editorialRoot} is not a directory.`);
|
|
68
126
|
if (!KEY_SHAPE.test(opts.apiKey)) {
|
|
69
127
|
refuse("the API key is empty or carries a space, a control character or a non-ASCII character.");
|
|
70
128
|
}
|
|
@@ -113,6 +171,9 @@ function byTypeThenSlug(a, b) {
|
|
|
113
171
|
return a.type < b.type ? -1 : 1;
|
|
114
172
|
return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
|
|
115
173
|
}
|
|
174
|
+
function byPath(a, b) {
|
|
175
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
176
|
+
}
|
|
116
177
|
function plan(contentRoot, types, defaultLocale) {
|
|
117
178
|
const localePages = [];
|
|
118
179
|
const legacyPages = [];
|
|
@@ -123,6 +184,7 @@ function plan(contentRoot, types, defaultLocale) {
|
|
|
123
184
|
const [first, second, third] = segments;
|
|
124
185
|
if (link) {
|
|
125
186
|
skipped.push({
|
|
187
|
+
root: "content",
|
|
126
188
|
path,
|
|
127
189
|
result: "skipped",
|
|
128
190
|
reason: "a symbolic link is not followed",
|
|
@@ -132,6 +194,7 @@ function plan(contentRoot, types, defaultLocale) {
|
|
|
132
194
|
first === defaultLocale &&
|
|
133
195
|
types.includes(second)) {
|
|
134
196
|
localePages.push({
|
|
197
|
+
root: "content",
|
|
135
198
|
path,
|
|
136
199
|
type: second,
|
|
137
200
|
slug: slugOf(third),
|
|
@@ -140,6 +203,7 @@ function plan(contentRoot, types, defaultLocale) {
|
|
|
140
203
|
}
|
|
141
204
|
else if (segments.length === 2 && types.includes(first)) {
|
|
142
205
|
legacyPages.push({
|
|
206
|
+
root: "content",
|
|
143
207
|
path,
|
|
144
208
|
type: first,
|
|
145
209
|
slug: slugOf(second),
|
|
@@ -150,6 +214,7 @@ function plan(contentRoot, types, defaultLocale) {
|
|
|
150
214
|
LOCALE.test(first) &&
|
|
151
215
|
first !== defaultLocale) {
|
|
152
216
|
skipped.push({
|
|
217
|
+
root: "content",
|
|
153
218
|
path,
|
|
154
219
|
result: "skipped",
|
|
155
220
|
reason: `locale ${first} is not imported (only ${defaultLocale})`,
|
|
@@ -157,6 +222,7 @@ function plan(contentRoot, types, defaultLocale) {
|
|
|
157
222
|
}
|
|
158
223
|
else {
|
|
159
224
|
skipped.push({
|
|
225
|
+
root: "content",
|
|
160
226
|
path,
|
|
161
227
|
result: "skipped",
|
|
162
228
|
reason: "not a page: the adapter reads <locale>/<Type>/<slug>.mdx",
|
|
@@ -172,6 +238,102 @@ function plan(contentRoot, types, defaultLocale) {
|
|
|
172
238
|
skipped,
|
|
173
239
|
};
|
|
174
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* The editorial walk (#10701 D2–D3): `L/{blog,docs}/<slug>.mdx` and
|
|
243
|
+
* `<slug>/index.mdx` are posts and docs, the directory's own `index.mdx` its
|
|
244
|
+
* index page; anything deeper is no page the store can address.
|
|
245
|
+
*/
|
|
246
|
+
function planEditorial(editorialRoot, types, defaultLocale) {
|
|
247
|
+
const pages = [];
|
|
248
|
+
const skipped = [];
|
|
249
|
+
const skip = (path, reason) => skipped.push({ root: "editorial", path, result: "skipped", reason });
|
|
250
|
+
const found = mdxFiles(editorialRoot).map((f) => ({
|
|
251
|
+
...f,
|
|
252
|
+
path: f.segments.join("/"),
|
|
253
|
+
}));
|
|
254
|
+
// Sorted by path first, so within one directory `<slug>.mdx` comes before
|
|
255
|
+
// `<slug>/index.mdx` and holds the address (`.` sorts before `/`).
|
|
256
|
+
for (const { segments, link, path } of found.sort(byPath)) {
|
|
257
|
+
const [locale, dir, file, nested] = segments;
|
|
258
|
+
const kinds = dir !== undefined && Object.hasOwn(EDITORIAL_TYPES, dir)
|
|
259
|
+
? EDITORIAL_TYPES[dir]
|
|
260
|
+
: undefined;
|
|
261
|
+
if (link) {
|
|
262
|
+
skip(path, "a symbolic link is not followed");
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (kinds !== undefined && locale !== defaultLocale && LOCALE.test(locale)) {
|
|
266
|
+
skip(path, `locale ${locale} is not imported (only ${defaultLocale})`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
let type;
|
|
270
|
+
let slug;
|
|
271
|
+
if (kinds !== undefined && locale === defaultLocale) {
|
|
272
|
+
if (segments.length === 3 && file === "index.mdx") {
|
|
273
|
+
type = kinds.index;
|
|
274
|
+
slug = INDEX_SLUG;
|
|
275
|
+
}
|
|
276
|
+
else if (segments.length === 3) {
|
|
277
|
+
type = kinds.page;
|
|
278
|
+
slug = file.slice(0, -".mdx".length);
|
|
279
|
+
}
|
|
280
|
+
else if (segments.length === 4 && nested === "index.mdx") {
|
|
281
|
+
type = kinds.page;
|
|
282
|
+
slug = file;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (type === undefined || slug === undefined) {
|
|
286
|
+
skip(path, `not a post or doc: the editorial walk reads ${defaultLocale}/{blog,docs}/<slug>.mdx and ${defaultLocale}/{blog,docs}/<slug>/index.mdx`);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const invalid = !SLUG.test(slug)
|
|
290
|
+
? `not a valid slug: ${slug}`
|
|
291
|
+
: !types.includes(type)
|
|
292
|
+
? `the site's adapter has no type ${type}`
|
|
293
|
+
: undefined;
|
|
294
|
+
pages.push({
|
|
295
|
+
root: "editorial",
|
|
296
|
+
path,
|
|
297
|
+
type,
|
|
298
|
+
slug,
|
|
299
|
+
localeTree: true,
|
|
300
|
+
...(invalid !== undefined && { invalid }),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return { pages: pages.sort(byTypeThenSlug), skipped };
|
|
304
|
+
}
|
|
305
|
+
/** A YAML date as the store's string: a bare date as `YYYY-MM-DD`. */
|
|
306
|
+
function dateString(value) {
|
|
307
|
+
if (!(value instanceof Date) || Number.isNaN(value.getTime()))
|
|
308
|
+
return value;
|
|
309
|
+
const iso = value.toISOString();
|
|
310
|
+
return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* An editorial file as the adapter would return a page (#10701 D4): its
|
|
314
|
+
* fumadocs frontmatter mapped to the stored vocabulary, the path deciding the
|
|
315
|
+
* type and slug, and the keys not sent named.
|
|
316
|
+
*/
|
|
317
|
+
function editorialSource(page, parsed) {
|
|
318
|
+
const frontmatter = {
|
|
319
|
+
type: page.type,
|
|
320
|
+
slug: page.slug,
|
|
321
|
+
};
|
|
322
|
+
const dropped = [];
|
|
323
|
+
for (const [key, value] of Object.entries(parsed.data)) {
|
|
324
|
+
const stored = Object.hasOwn(EDITORIAL_FIELDS, key)
|
|
325
|
+
? EDITORIAL_FIELDS[key]
|
|
326
|
+
: undefined;
|
|
327
|
+
if (stored !== undefined)
|
|
328
|
+
frontmatter[stored] = dateString(value);
|
|
329
|
+
else if (!EDITORIAL_IGNORED.has(key))
|
|
330
|
+
dropped.push(key);
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
source: { frontmatter, body: parsed.content },
|
|
334
|
+
dropped: dropped.sort(),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
175
337
|
// ── "Unchanged" (D4) ────────────────────────────────────────────────────────
|
|
176
338
|
function sortKeys(value) {
|
|
177
339
|
if (Array.isArray(value))
|
|
@@ -186,6 +348,16 @@ function sortKeys(value) {
|
|
|
186
348
|
function canonical(value) {
|
|
187
349
|
return JSON.stringify(sortKeys(JSON.parse(JSON.stringify(value ?? null))));
|
|
188
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* Content without `tags`: the store's tagger writes them on every version and
|
|
353
|
+
* drops a sent value (#10342 D5), so they are never the importer's to compare.
|
|
354
|
+
*/
|
|
355
|
+
function withoutTags(value) {
|
|
356
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
357
|
+
return value;
|
|
358
|
+
const { tags: _tags, ...rest } = value;
|
|
359
|
+
return rest;
|
|
360
|
+
}
|
|
189
361
|
function safeCode(value) {
|
|
190
362
|
return typeof value === "string" && SAFE_CODE.test(value) ? value : "unknown";
|
|
191
363
|
}
|
|
@@ -242,84 +414,114 @@ async function call(t, method, url, body, ifMatch) {
|
|
|
242
414
|
};
|
|
243
415
|
}
|
|
244
416
|
}
|
|
245
|
-
function failed(
|
|
417
|
+
function failed(at, reason, halt) {
|
|
246
418
|
return {
|
|
247
|
-
outcome: { path, result: "failed", reason },
|
|
419
|
+
outcome: { root: at.root, path: at.path, result: "failed", reason },
|
|
248
420
|
...(halt !== undefined && { halt }),
|
|
249
421
|
};
|
|
250
422
|
}
|
|
251
423
|
/** A non-success answer: 401/403 stops the run, anything else fails the page. */
|
|
252
|
-
function refusedAnswer(
|
|
424
|
+
function refusedAnswer(at, answer) {
|
|
253
425
|
if ("network" in answer)
|
|
254
|
-
return failed(
|
|
426
|
+
return failed(at, answer.network);
|
|
255
427
|
const reason = `${answer.status} ${answer.code}`;
|
|
256
428
|
return answer.status === 401 || answer.status === 403
|
|
257
|
-
? failed(
|
|
258
|
-
: failed(
|
|
429
|
+
? failed(at, reason, `not sent: the API refused the key (${reason})`)
|
|
430
|
+
: failed(at, reason);
|
|
259
431
|
}
|
|
260
432
|
/**
|
|
261
433
|
* A success answered for another locale: the API does not serve `locale`. A
|
|
262
434
|
* success with no locale at all is not the API's answer shape: that page fails.
|
|
263
435
|
*/
|
|
264
|
-
function wrongLocale(
|
|
436
|
+
function wrongLocale(at, answer, locale) {
|
|
265
437
|
if ("network" in answer || answer.data["locale"] === locale)
|
|
266
438
|
return undefined;
|
|
267
439
|
if (typeof answer.data["locale"] !== "string") {
|
|
268
|
-
return failed(
|
|
440
|
+
return failed(at, `${answer.status} unknown`);
|
|
269
441
|
}
|
|
270
|
-
return failed(
|
|
442
|
+
return failed(at, `the API does not serve locale ${locale}`, `not sent: the API does not serve locale ${locale}`);
|
|
271
443
|
}
|
|
272
|
-
async function writePage(t, page, locale, content) {
|
|
273
|
-
const { path, type, slug } = page;
|
|
274
|
-
const
|
|
275
|
-
const
|
|
276
|
-
|
|
444
|
+
async function writePage(t, page, locale, content, layout) {
|
|
445
|
+
const { root, path, type, slug } = page;
|
|
446
|
+
const pagePath = `${t.base}/content/${encodeURIComponent(type)}/${encodeURIComponent(slug)}`;
|
|
447
|
+
const query = `?locale=${encodeURIComponent(locale)}`;
|
|
448
|
+
const address = `${pagePath}${query}`;
|
|
449
|
+
// The route's `statusOf`: anything but `draft` has a Live version.
|
|
450
|
+
let live = false;
|
|
451
|
+
const outcome = (result) => ({
|
|
452
|
+
outcome: { root, path, result, type, slug },
|
|
453
|
+
live,
|
|
277
454
|
});
|
|
455
|
+
// The words are written; now the layout, under the token the draft holds
|
|
456
|
+
// (`null` for a page that has none yet).
|
|
457
|
+
const done = async (result, structureToken = null) => {
|
|
458
|
+
if (layout === undefined)
|
|
459
|
+
return outcome(result);
|
|
460
|
+
const put = await call(t, "PUT", `${pagePath}/structure${query}`, {
|
|
461
|
+
structureToken,
|
|
462
|
+
structure: layout,
|
|
463
|
+
});
|
|
464
|
+
if ("network" in put || put.status !== 200)
|
|
465
|
+
return refusedAnswer(page, put);
|
|
466
|
+
return (wrongLocale(page, put, locale) ??
|
|
467
|
+
outcome(result === "unchanged" && put.data["written"] === true
|
|
468
|
+
? "updated"
|
|
469
|
+
: result));
|
|
470
|
+
};
|
|
278
471
|
for (let reread = false;; reread = true) {
|
|
279
472
|
const read = await call(t, "GET", address);
|
|
280
473
|
if ("network" in read)
|
|
281
|
-
return refusedAnswer(
|
|
474
|
+
return refusedAnswer(page, read);
|
|
282
475
|
if (read.status === 200) {
|
|
283
|
-
const mismatch = wrongLocale(
|
|
476
|
+
const mismatch = wrongLocale(page, read, locale);
|
|
284
477
|
if (mismatch !== undefined)
|
|
285
478
|
return mismatch;
|
|
286
479
|
const version = read.data["version"];
|
|
287
480
|
if (typeof version !== "string")
|
|
288
|
-
return failed(
|
|
289
|
-
|
|
290
|
-
|
|
481
|
+
return failed(page, "200 unknown");
|
|
482
|
+
live = read.data["status"] !== "draft";
|
|
483
|
+
const token = read.data["structureToken"];
|
|
484
|
+
const structureToken = typeof token === "string" ? token : null;
|
|
485
|
+
if (canonical(withoutTags(read.data["content"])) ===
|
|
486
|
+
canonical(withoutTags(content))) {
|
|
487
|
+
return done("unchanged", structureToken);
|
|
291
488
|
}
|
|
292
489
|
// The token is the version just compared against, so a draft saved
|
|
293
490
|
// since the read is a 409, never a silent overwrite.
|
|
294
491
|
const saved = await call(t, "PATCH", address, content, version);
|
|
295
492
|
if ("network" in saved || saved.status !== 200)
|
|
296
|
-
return refusedAnswer(
|
|
297
|
-
|
|
493
|
+
return refusedAnswer(page, saved);
|
|
494
|
+
// A save carries the structure token forward (#10330 D5).
|
|
495
|
+
return (wrongLocale(page, saved, locale) ?? done("updated", structureToken));
|
|
298
496
|
}
|
|
299
497
|
if (read.status !== 404 || reread)
|
|
300
|
-
return refusedAnswer(
|
|
498
|
+
return refusedAnswer(page, read);
|
|
301
499
|
const created = await call(t, "POST", `${t.base}/content`, {
|
|
302
500
|
...content,
|
|
303
501
|
locale,
|
|
304
502
|
});
|
|
305
503
|
if ("network" in created)
|
|
306
|
-
return refusedAnswer(
|
|
504
|
+
return refusedAnswer(page, created);
|
|
307
505
|
if (created.status === 201) {
|
|
308
|
-
return wrongLocale(
|
|
506
|
+
return wrongLocale(page, created, locale) ?? done("created");
|
|
309
507
|
}
|
|
310
508
|
// 409 slug_taken: another type holds this page's registry link (#10268
|
|
311
509
|
// D5) — reported as it is, since a re-read at this address finds nothing.
|
|
312
510
|
// Any other 409: created by someone else since the read — read it once more.
|
|
313
511
|
if (created.status !== 409 || created.code === "slug_taken") {
|
|
314
|
-
return refusedAnswer(
|
|
512
|
+
return refusedAnswer(page, created);
|
|
315
513
|
}
|
|
316
514
|
}
|
|
317
515
|
}
|
|
318
516
|
// ── The report (D6) ─────────────────────────────────────────────────────────
|
|
517
|
+
/** An outcome's printed path: an editorial one says so (#10701 D6). */
|
|
518
|
+
export function outcomePath(at) {
|
|
519
|
+
return at.root === "editorial" ? `editorial: ${at.path}` : at.path;
|
|
520
|
+
}
|
|
319
521
|
function outcomeLine(outcome) {
|
|
320
522
|
return "reason" in outcome
|
|
321
|
-
? `${outcome
|
|
322
|
-
: `${outcome
|
|
523
|
+
? `${outcomePath(outcome)}: ${outcome.result} — ${outcome.reason}`
|
|
524
|
+
: `${outcomePath(outcome)}: ${outcome.result}`;
|
|
323
525
|
}
|
|
324
526
|
/** The whole report, one line per page and a final count line. */
|
|
325
527
|
export function formatImportMdxReport(result) {
|
|
@@ -331,9 +533,36 @@ export function formatImportMdxReport(result) {
|
|
|
331
533
|
}
|
|
332
534
|
// ── The importer ────────────────────────────────────────────────────────────
|
|
333
535
|
/** One whitespace-collapsed line of at most `ADAPTER_REASON_MAX` characters. */
|
|
334
|
-
function adapterReason(err) {
|
|
536
|
+
function adapterReason(err, stage = "adapter") {
|
|
335
537
|
const message = err instanceof Error ? err.message : String(err);
|
|
336
|
-
return
|
|
538
|
+
return `${stage}: ${message.replace(/\s+/g, " ").trim().slice(0, ADAPTER_REASON_MAX)}`;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* What the page stores (#10534): with `sections` and a codec, its words after
|
|
542
|
+
* the body's own text and its sections as the layout; else the page as read.
|
|
543
|
+
*/
|
|
544
|
+
function convert(source, codec) {
|
|
545
|
+
const { sections, ...frontmatter } = source.frontmatter;
|
|
546
|
+
if (!Array.isArray(sections) ||
|
|
547
|
+
sections.length === 0 ||
|
|
548
|
+
codec === undefined) {
|
|
549
|
+
return { content: { ...source.frontmatter, body: source.body } };
|
|
550
|
+
}
|
|
551
|
+
let words;
|
|
552
|
+
try {
|
|
553
|
+
words = codec(sections);
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
return { reason: adapterReason(err, "codec") };
|
|
557
|
+
}
|
|
558
|
+
const lead = source.body.trimEnd();
|
|
559
|
+
return {
|
|
560
|
+
content: {
|
|
561
|
+
...frontmatter,
|
|
562
|
+
body: lead === "" ? words : `${lead}\n\n${words}`,
|
|
563
|
+
},
|
|
564
|
+
layout: { kind: "sections", sections },
|
|
565
|
+
};
|
|
337
566
|
}
|
|
338
567
|
export async function importMdx(opts) {
|
|
339
568
|
const defaultLocale = opts.defaultLocale ?? "en";
|
|
@@ -347,7 +576,38 @@ export async function importMdx(opts) {
|
|
|
347
576
|
onLine,
|
|
348
577
|
};
|
|
349
578
|
const contentRoot = resolve(opts.contentRoot);
|
|
350
|
-
const
|
|
579
|
+
const types = opts.adapters.SCHEMA_ORG_TYPES;
|
|
580
|
+
const content = plan(contentRoot, types, defaultLocale);
|
|
581
|
+
const editorialRoot = opts.editorialRoot === undefined ? undefined : resolve(opts.editorialRoot);
|
|
582
|
+
const editorial = editorialRoot === undefined
|
|
583
|
+
? { pages: [], skipped: [] }
|
|
584
|
+
: planEditorial(editorialRoot, types, defaultLocale);
|
|
585
|
+
const parseFrontmatter = opts.adapters.parseFrontmatter;
|
|
586
|
+
// #10701 D3 — one route path holds one page, so an editorial index the run
|
|
587
|
+
// will write supersedes a content page of its type at another slug (the
|
|
588
|
+
// scaffold's teaser), which the create would refuse `slug_taken`.
|
|
589
|
+
const indexTypes = new Set(parseFrontmatter === undefined
|
|
590
|
+
? []
|
|
591
|
+
: editorial.pages
|
|
592
|
+
.filter((p) => p.invalid === undefined && INDEX_TYPES.has(p.type))
|
|
593
|
+
.map((p) => p.type));
|
|
594
|
+
const superseded = (p) => indexTypes.has(p.type) && p.slug !== INDEX_SLUG;
|
|
595
|
+
const pages = [
|
|
596
|
+
...content.pages.filter((p) => !superseded(p)),
|
|
597
|
+
...editorial.pages,
|
|
598
|
+
];
|
|
599
|
+
const skipped = [
|
|
600
|
+
...[
|
|
601
|
+
...content.skipped,
|
|
602
|
+
...content.pages.filter(superseded).map((p) => ({
|
|
603
|
+
root: "content",
|
|
604
|
+
path: p.path,
|
|
605
|
+
result: "skipped",
|
|
606
|
+
reason: "superseded by the editorial index page",
|
|
607
|
+
})),
|
|
608
|
+
].sort(byPath),
|
|
609
|
+
...editorial.skipped,
|
|
610
|
+
];
|
|
351
611
|
const reader = opts.adapters.createMdxAdapter({ contentRoot, defaultLocale });
|
|
352
612
|
const outcomes = [];
|
|
353
613
|
const record = (outcome) => {
|
|
@@ -360,46 +620,98 @@ export async function importMdx(opts) {
|
|
|
360
620
|
// types may share a slug (#10268 D7): the store addresses by type too.
|
|
361
621
|
const addresses = new Map();
|
|
362
622
|
let halt;
|
|
623
|
+
let publishRefused;
|
|
624
|
+
let live = 0;
|
|
625
|
+
let published = 0;
|
|
363
626
|
for (const page of pages) {
|
|
364
627
|
const address = `${page.type}/${page.slug}`;
|
|
365
628
|
const holder = addresses.get(address);
|
|
629
|
+
const fail = (reason) => record({ root: page.root, path: page.path, result: "failed", reason });
|
|
630
|
+
if (page.invalid !== undefined) {
|
|
631
|
+
fail(page.invalid);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
366
634
|
if (holder !== undefined) {
|
|
367
|
-
|
|
368
|
-
path: page.path,
|
|
369
|
-
result: "failed",
|
|
370
|
-
reason: `same address as ${holder} (a page is addressed by locale, type and slug)`,
|
|
371
|
-
});
|
|
635
|
+
fail(`same address as ${holder} (a page is addressed by locale, type and slug)`);
|
|
372
636
|
continue;
|
|
373
637
|
}
|
|
374
|
-
addresses.set(address, page
|
|
638
|
+
addresses.set(address, outcomePath(page));
|
|
375
639
|
if (halt !== undefined) {
|
|
376
|
-
|
|
640
|
+
fail(halt);
|
|
377
641
|
continue;
|
|
378
642
|
}
|
|
379
643
|
let source;
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
644
|
+
if (page.root === "editorial") {
|
|
645
|
+
if (parseFrontmatter === undefined) {
|
|
646
|
+
fail(`editorial: no frontmatter parser — update the site's ${CONTENT_ADAPTERS_PACKAGE} and re-run`);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
let read;
|
|
650
|
+
try {
|
|
651
|
+
read = editorialSource(page, parseFrontmatter(readFileSync(join(editorialRoot, page.path), "utf8")));
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
fail(adapterReason(err, "editorial"));
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
source = read.source;
|
|
658
|
+
if (read.dropped.length > 0) {
|
|
659
|
+
onLine(`${page.path}: not imported — ${read.dropped.join(", ")} (the editorial walk sends only the fields the store keeps from a caller)`);
|
|
660
|
+
}
|
|
384
661
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
662
|
+
else {
|
|
663
|
+
try {
|
|
664
|
+
source = page.localeTree
|
|
665
|
+
? await reader.getPageSource(page.type, page.slug, defaultLocale)
|
|
666
|
+
: await reader.getPageSource(page.type, page.slug);
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
fail(adapterReason(err));
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
388
672
|
}
|
|
389
673
|
if (source === null) {
|
|
390
|
-
|
|
391
|
-
path: page.path,
|
|
392
|
-
result: "failed",
|
|
393
|
-
reason: "adapter: not read",
|
|
394
|
-
});
|
|
674
|
+
fail("adapter: not read");
|
|
395
675
|
continue;
|
|
396
676
|
}
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
677
|
+
const converted = convert(source, opts.adapters.sectionsToMarkdown);
|
|
678
|
+
if ("reason" in converted) {
|
|
679
|
+
fail(converted.reason);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (converted.layout === undefined &&
|
|
683
|
+
Array.isArray(source.frontmatter["sections"]) &&
|
|
684
|
+
source.frontmatter["sections"].length > 0) {
|
|
685
|
+
onLine(`${page.path}: keeps its words in sections — the site's ${VALIDATION_PACKAGE} has no words codec; update its dependencies and re-run`);
|
|
686
|
+
}
|
|
687
|
+
const result = await writePage(t, page, defaultLocale, converted.content, converted.layout);
|
|
401
688
|
halt = result.halt;
|
|
402
|
-
|
|
689
|
+
if (opts.publish !== true || "reason" in result.outcome) {
|
|
690
|
+
record(result.outcome);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (result.live === true) {
|
|
694
|
+
live += 1;
|
|
695
|
+
record(result.outcome);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
// #10659 D2 — a page with no Live version is set Live from its draft tip;
|
|
699
|
+
// after one refused publish, the rest are saved and not sent.
|
|
700
|
+
let unpublished = publishRefused;
|
|
701
|
+
if (unpublished === undefined) {
|
|
702
|
+
const answer = await call(t, "POST", `${t.base}/content/${encodeURIComponent(page.type)}/${encodeURIComponent(page.slug)}/publish?locale=${encodeURIComponent(defaultLocale)}`, {});
|
|
703
|
+
if (!("network" in answer) && answer.status === 200) {
|
|
704
|
+
published += 1;
|
|
705
|
+
live += 1;
|
|
706
|
+
record(result.outcome);
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
unpublished = `saved as a draft; not published (${"network" in answer ? answer.network : `${answer.status} ${answer.code}`})`;
|
|
710
|
+
if (!("network" in answer) && (answer.status === 401 || answer.status === 403)) {
|
|
711
|
+
publishRefused = unpublished;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
fail(unpublished);
|
|
403
715
|
}
|
|
404
716
|
skipped.forEach(record);
|
|
405
717
|
const counts = {
|
|
@@ -411,7 +723,11 @@ export async function importMdx(opts) {
|
|
|
411
723
|
};
|
|
412
724
|
for (const outcome of outcomes)
|
|
413
725
|
counts[outcome.result] += 1;
|
|
414
|
-
return {
|
|
726
|
+
return {
|
|
727
|
+
pages: outcomes,
|
|
728
|
+
counts,
|
|
729
|
+
...(opts.publish === true && { live, published }),
|
|
730
|
+
};
|
|
415
731
|
}
|
|
416
732
|
// ── The site's own adapter (D1) ─────────────────────────────────────────────
|
|
417
733
|
/** Whether a `node_modules` at or above `dir` holds the adapter package. */
|
|
@@ -457,5 +773,36 @@ export async function loadSiteMdxAdapter(siteRoot) {
|
|
|
457
773
|
!types.every((type) => typeof type === "string")) {
|
|
458
774
|
throw new ImportMdxRefusedError(`import-mdx: ${CONTENT_ADAPTERS_PACKAGE} in ${web} does not export createMdxAdapter and SCHEMA_ORG_TYPES. Install the site's dependencies (pnpm install) first. Nothing was sent.`);
|
|
459
775
|
}
|
|
460
|
-
|
|
776
|
+
// The codec the adapter's own validation package carries (#10534); one
|
|
777
|
+
// that predates it, or none, leaves pages unconverted and the run says so.
|
|
778
|
+
let codec;
|
|
779
|
+
try {
|
|
780
|
+
const validation = createRequire(entry).resolve(VALIDATION_PACKAGE);
|
|
781
|
+
codec = (await import(pathToFileURL(validation).href))["sectionsToMarkdown"];
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
// Not resolvable from the adapter: no codec.
|
|
785
|
+
}
|
|
786
|
+
// The adapter's own frontmatter parser (#10701 D1), for the editorial tree;
|
|
787
|
+
// none leaves every editorial page failed, and the run says so.
|
|
788
|
+
let matter;
|
|
789
|
+
try {
|
|
790
|
+
const parser = createRequire(entry).resolve(FRONTMATTER_PACKAGE);
|
|
791
|
+
matter = (await import(pathToFileURL(parser).href))["default"];
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
// Not resolvable from the adapter: no parser.
|
|
795
|
+
}
|
|
796
|
+
return {
|
|
797
|
+
...mod,
|
|
798
|
+
...(typeof codec === "function" && {
|
|
799
|
+
sectionsToMarkdown: codec,
|
|
800
|
+
}),
|
|
801
|
+
...(typeof matter === "function" && {
|
|
802
|
+
parseFrontmatter: (raw) => {
|
|
803
|
+
const { data, content } = matter(raw);
|
|
804
|
+
return { data, content };
|
|
805
|
+
},
|
|
806
|
+
}),
|
|
807
|
+
};
|
|
461
808
|
}
|