create-ailk 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -4
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/body-heading.d.ts +13 -0
- package/dist/body-heading.js +19 -5
- package/dist/cli.js +99 -0
- package/dist/cutover.d.ts +114 -0
- package/dist/cutover.js +511 -0
- package/dist/design-presets.js +20 -4
- package/dist/export-content.d.ts +55 -0
- package/dist/export-content.js +303 -0
- package/dist/inventory.d.ts +216 -0
- package/dist/inventory.js +833 -0
- package/dist/language-import.d.ts +127 -0
- package/dist/language-import.js +451 -0
- package/dist/lib/env-catalog.js +1 -1
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +445 -65
- package/dist/lib/render-comparison.d.ts +251 -0
- package/dist/lib/render-comparison.js +573 -0
- package/dist/mdx-braces.d.ts +73 -0
- package/dist/mdx-braces.js +285 -0
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +62 -14
- package/dist/programmatic.js +2 -8
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/site-manifest.js +6 -38
- package/dist/site-roster.d.ts +46 -3
- package/dist/site-roster.js +124 -4
- package/dist/surfaces.d.ts +8 -0
- package/dist/surfaces.js +16 -0
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +24 -10
- package/dist/update.d.ts +21 -1
- package/dist/update.js +142 -11
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/content-source-guard.sh +75 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.github/workflows/ci.yml +4 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/mcp/.env.example +7 -1
- package/templates/apps/mcp/CLAUDE.md +8 -4
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +16 -5
- package/templates/apps/web/__tests__/setup.ts +9 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +156 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
- package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
- package/templates/apps/web/app/[locale]/layout.tsx +122 -27
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/llms.txt/route.ts +16 -9
- package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
- package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
- package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +145 -11
- package/templates/apps/web/lib/bot-check.ts +113 -13
- package/templates/apps/web/lib/in-page-magnet.ts +4 -1
- package/templates/apps/web/lib/page-chrome.ts +65 -5
- package/templates/apps/web/lib/relay.ts +22 -3
- package/templates/apps/web/lib/site-identity.ts +75 -24
- package/templates/apps/web/messages/en.json +5 -1
- package/templates/apps/web/next.config.mjs +15 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
- package/templates/apps/web/scripts/content-snapshot.ts +33 -0
- package/templates/apps/web/src/lib/routes.ts +5 -3
- package/templates/package.json +3 -1
- package/templates/tools/body-heading.ts +440 -0
- package/templates/tools/check-content-source.ts +108 -0
- package/templates/tools/check-mdx-body.ts +123 -0
- package/templates/tools/mdx-braces.ts +303 -0
- package/versions.json +48 -48
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,42 @@ 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
|
+
/** A `parent`'s shape (`ServiceFieldsSchema` in the adapter's `types.ts`), restated: no runtime dep. */
|
|
65
|
+
const PARENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
66
|
+
/** Each editorial directory's page type and index type (`runtime-source.ts`). */
|
|
67
|
+
const EDITORIAL_TYPES = {
|
|
68
|
+
blog: { page: "BlogPosting", index: "BlogList" },
|
|
69
|
+
docs: { page: "DocsDetail", index: "DocsList" },
|
|
70
|
+
};
|
|
71
|
+
/** The slug the runtime reads as a surface's own index. */
|
|
72
|
+
const INDEX_SLUG = "index";
|
|
73
|
+
const INDEX_TYPES = new Set(Object.values(EDITORIAL_TYPES).map((k) => k.index));
|
|
74
|
+
/**
|
|
75
|
+
* `fumadocsToArtifactFrontmatter` (`apps/web/lib/runtime-source.ts`) restated —
|
|
76
|
+
* fumadocs key to stored key — less `tags`, which the store keeps itself, plus
|
|
77
|
+
* `sections`, which `convert` moves. Any other key is not sent.
|
|
78
|
+
*/
|
|
79
|
+
const EDITORIAL_FIELDS = {
|
|
80
|
+
title: "title",
|
|
81
|
+
description: "description",
|
|
82
|
+
date: "publishedAt",
|
|
83
|
+
updated: "updatedAt",
|
|
84
|
+
image: "image",
|
|
85
|
+
author: "author",
|
|
86
|
+
order: "order",
|
|
87
|
+
parent: "parent",
|
|
88
|
+
sections: "sections",
|
|
89
|
+
};
|
|
90
|
+
/** Keys not sent and not reported: the path decides both, and a strip marker. */
|
|
91
|
+
const EDITORIAL_IGNORED = new Set(["type", "slug", "oss"]);
|
|
35
92
|
/** `wksp_<uuidv7>` — the `@working-theory/ids` shape, restated: no runtime dep. */
|
|
36
93
|
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
94
|
/** A key is visible ASCII only: what an HTTP header can carry, with no space. */
|
|
@@ -55,16 +112,19 @@ export class ImportMdxRefusedError extends Error {
|
|
|
55
112
|
function refuse(message) {
|
|
56
113
|
throw new ImportMdxRefusedError(`import-mdx: ${message} Nothing was sent.`);
|
|
57
114
|
}
|
|
58
|
-
function
|
|
59
|
-
let isDir;
|
|
115
|
+
export function isDirectory(path) {
|
|
60
116
|
try {
|
|
61
|
-
|
|
117
|
+
return statSync(path).isDirectory();
|
|
62
118
|
}
|
|
63
119
|
catch {
|
|
64
|
-
|
|
120
|
+
return false;
|
|
65
121
|
}
|
|
66
|
-
|
|
122
|
+
}
|
|
123
|
+
function checkOptions(opts, defaultLocale) {
|
|
124
|
+
if (!isDirectory(opts.contentRoot))
|
|
67
125
|
refuse(`the content root ${opts.contentRoot} is not a directory.`);
|
|
126
|
+
if (opts.editorialRoot !== undefined && !isDirectory(opts.editorialRoot))
|
|
127
|
+
refuse(`the editorial root ${opts.editorialRoot} is not a directory.`);
|
|
68
128
|
if (!KEY_SHAPE.test(opts.apiKey)) {
|
|
69
129
|
refuse("the API key is empty or carries a space, a control character or a non-ASCII character.");
|
|
70
130
|
}
|
|
@@ -108,10 +168,16 @@ function mdxFiles(root, prefix = []) {
|
|
|
108
168
|
: [];
|
|
109
169
|
});
|
|
110
170
|
}
|
|
171
|
+
/** Type, then slug, then a page with no `parent` before a series leaf (#10775 D4). */
|
|
111
172
|
function byTypeThenSlug(a, b) {
|
|
112
173
|
if (a.type !== b.type)
|
|
113
174
|
return a.type < b.type ? -1 : 1;
|
|
114
|
-
|
|
175
|
+
if (a.slug !== b.slug)
|
|
176
|
+
return a.slug < b.slug ? -1 : 1;
|
|
177
|
+
return Number(a.parent !== undefined) - Number(b.parent !== undefined);
|
|
178
|
+
}
|
|
179
|
+
function byPath(a, b) {
|
|
180
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
115
181
|
}
|
|
116
182
|
function plan(contentRoot, types, defaultLocale) {
|
|
117
183
|
const localePages = [];
|
|
@@ -123,6 +189,7 @@ function plan(contentRoot, types, defaultLocale) {
|
|
|
123
189
|
const [first, second, third] = segments;
|
|
124
190
|
if (link) {
|
|
125
191
|
skipped.push({
|
|
192
|
+
root: "content",
|
|
126
193
|
path,
|
|
127
194
|
result: "skipped",
|
|
128
195
|
reason: "a symbolic link is not followed",
|
|
@@ -132,6 +199,7 @@ function plan(contentRoot, types, defaultLocale) {
|
|
|
132
199
|
first === defaultLocale &&
|
|
133
200
|
types.includes(second)) {
|
|
134
201
|
localePages.push({
|
|
202
|
+
root: "content",
|
|
135
203
|
path,
|
|
136
204
|
type: second,
|
|
137
205
|
slug: slugOf(third),
|
|
@@ -140,6 +208,7 @@ function plan(contentRoot, types, defaultLocale) {
|
|
|
140
208
|
}
|
|
141
209
|
else if (segments.length === 2 && types.includes(first)) {
|
|
142
210
|
legacyPages.push({
|
|
211
|
+
root: "content",
|
|
143
212
|
path,
|
|
144
213
|
type: first,
|
|
145
214
|
slug: slugOf(second),
|
|
@@ -150,6 +219,7 @@ function plan(contentRoot, types, defaultLocale) {
|
|
|
150
219
|
LOCALE.test(first) &&
|
|
151
220
|
first !== defaultLocale) {
|
|
152
221
|
skipped.push({
|
|
222
|
+
root: "content",
|
|
153
223
|
path,
|
|
154
224
|
result: "skipped",
|
|
155
225
|
reason: `locale ${first} is not imported (only ${defaultLocale})`,
|
|
@@ -157,6 +227,7 @@ function plan(contentRoot, types, defaultLocale) {
|
|
|
157
227
|
}
|
|
158
228
|
else {
|
|
159
229
|
skipped.push({
|
|
230
|
+
root: "content",
|
|
160
231
|
path,
|
|
161
232
|
result: "skipped",
|
|
162
233
|
reason: "not a page: the adapter reads <locale>/<Type>/<slug>.mdx",
|
|
@@ -172,6 +243,130 @@ function plan(contentRoot, types, defaultLocale) {
|
|
|
172
243
|
skipped,
|
|
173
244
|
};
|
|
174
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* The editorial walk (#10701 D2–D3, #10775 D2): `L/{blog,docs}/<slug>.mdx` and
|
|
248
|
+
* `<slug>/index.mdx` are posts and docs, the directory's own `index.mdx` its
|
|
249
|
+
* index page, and `L/blog/<series>/<post>.mdx` or `<post>/index.mdx` a post
|
|
250
|
+
* whose `parent` is the series. Every other file is skipped with the rule it
|
|
251
|
+
* failed.
|
|
252
|
+
*/
|
|
253
|
+
function planEditorial(editorialRoot, types, defaultLocale) {
|
|
254
|
+
const pages = [];
|
|
255
|
+
const skipped = [];
|
|
256
|
+
const skip = (path, reason) => skipped.push({ root: "editorial", path, result: "skipped", reason });
|
|
257
|
+
const found = mdxFiles(editorialRoot).map((f) => ({
|
|
258
|
+
...f,
|
|
259
|
+
path: f.segments.join("/"),
|
|
260
|
+
}));
|
|
261
|
+
// Sorted by path first, so within one directory `<slug>.mdx` comes before
|
|
262
|
+
// `<slug>/index.mdx` and holds the address (`.` sorts before `/`).
|
|
263
|
+
for (const { segments, link, path } of found.sort(byPath)) {
|
|
264
|
+
const [locale, dir, file, nested, leafFile] = segments;
|
|
265
|
+
const kinds = dir !== undefined && Object.hasOwn(EDITORIAL_TYPES, dir)
|
|
266
|
+
? EDITORIAL_TYPES[dir]
|
|
267
|
+
: undefined;
|
|
268
|
+
if (link) {
|
|
269
|
+
skip(path, "a symbolic link is not followed");
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (kinds !== undefined && locale !== defaultLocale && LOCALE.test(locale)) {
|
|
273
|
+
skip(path, `locale ${locale} is not imported (only ${defaultLocale})`);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (kinds === undefined || locale !== defaultLocale) {
|
|
277
|
+
skip(path, `not a post or doc: only ${defaultLocale}/blog and ${defaultLocale}/docs are walked`);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
let type;
|
|
281
|
+
let slug;
|
|
282
|
+
let parent;
|
|
283
|
+
if (segments.length === 3 && file === "index.mdx") {
|
|
284
|
+
type = kinds.index;
|
|
285
|
+
slug = INDEX_SLUG;
|
|
286
|
+
}
|
|
287
|
+
else if (segments.length === 3) {
|
|
288
|
+
type = kinds.page;
|
|
289
|
+
slug = file.slice(0, -".mdx".length);
|
|
290
|
+
}
|
|
291
|
+
else if (segments.length === 4 && nested === "index.mdx") {
|
|
292
|
+
type = kinds.page;
|
|
293
|
+
slug = file;
|
|
294
|
+
}
|
|
295
|
+
else if (dir === "docs") {
|
|
296
|
+
skip(path, "a nested doc: a doc is docs/<slug>.mdx or docs/<slug>/index.mdx");
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
else if (file === "category") {
|
|
300
|
+
skip(path, "under blog/category: /blog/category/<tag> is the blog's category index, so no post is served there");
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
else if (segments.length === 4 ||
|
|
304
|
+
(segments.length === 5 && leafFile === "index.mdx")) {
|
|
305
|
+
type = kinds.page;
|
|
306
|
+
slug = segments.length === 4 ? nested.slice(0, -".mdx".length) : nested;
|
|
307
|
+
parent = file;
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
skip(path, "too deep: a post is blog/<slug>.mdx, blog/<slug>/index.mdx, or one series down at blog/<series>/<post>.mdx or blog/<series>/<post>/index.mdx");
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const invalid = !SLUG.test(slug)
|
|
314
|
+
? `not a valid slug: ${slug}`
|
|
315
|
+
: parent !== undefined && !PARENT.test(parent)
|
|
316
|
+
? `not a valid series: ${parent} (a parent is lowercase words joined by single hyphens)`
|
|
317
|
+
: parent === slug
|
|
318
|
+
? `a post cannot share its series' slug: ${slug}`
|
|
319
|
+
: !types.includes(type)
|
|
320
|
+
? `the site's adapter has no type ${type}`
|
|
321
|
+
: undefined;
|
|
322
|
+
pages.push({
|
|
323
|
+
root: "editorial",
|
|
324
|
+
path,
|
|
325
|
+
type,
|
|
326
|
+
slug,
|
|
327
|
+
localeTree: true,
|
|
328
|
+
...(invalid !== undefined && { invalid }),
|
|
329
|
+
...(parent !== undefined && { parent }),
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return { pages: pages.sort(byTypeThenSlug), skipped };
|
|
333
|
+
}
|
|
334
|
+
/** A YAML date as the store's string: a bare date as `YYYY-MM-DD`. */
|
|
335
|
+
function dateString(value) {
|
|
336
|
+
if (!(value instanceof Date) || Number.isNaN(value.getTime()))
|
|
337
|
+
return value;
|
|
338
|
+
const iso = value.toISOString();
|
|
339
|
+
return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* An editorial file as the adapter would return a page (#10701 D4): its
|
|
343
|
+
* fumadocs frontmatter mapped to the stored vocabulary, the path deciding the
|
|
344
|
+
* type and slug — and, for a blog page, its `parent` (#10775 D3) — and the
|
|
345
|
+
* keys not sent named.
|
|
346
|
+
*/
|
|
347
|
+
function editorialSource(page, parsed) {
|
|
348
|
+
const frontmatter = {
|
|
349
|
+
type: page.type,
|
|
350
|
+
slug: page.slug,
|
|
351
|
+
};
|
|
352
|
+
const dropped = [];
|
|
353
|
+
const blog = page.type === "BlogPosting" || page.type === "BlogList";
|
|
354
|
+
for (const [key, value] of Object.entries(parsed.data)) {
|
|
355
|
+
const stored = Object.hasOwn(EDITORIAL_FIELDS, key) && !(blog && key === "parent")
|
|
356
|
+
? EDITORIAL_FIELDS[key]
|
|
357
|
+
: undefined;
|
|
358
|
+
if (stored !== undefined)
|
|
359
|
+
frontmatter[stored] = dateString(value);
|
|
360
|
+
else if (!EDITORIAL_IGNORED.has(key))
|
|
361
|
+
dropped.push(key);
|
|
362
|
+
}
|
|
363
|
+
if (page.parent !== undefined)
|
|
364
|
+
frontmatter["parent"] = page.parent;
|
|
365
|
+
return {
|
|
366
|
+
source: { frontmatter, body: parsed.content },
|
|
367
|
+
dropped: dropped.sort(),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
175
370
|
// ── "Unchanged" (D4) ────────────────────────────────────────────────────────
|
|
176
371
|
function sortKeys(value) {
|
|
177
372
|
if (Array.isArray(value))
|
|
@@ -186,6 +381,16 @@ function sortKeys(value) {
|
|
|
186
381
|
function canonical(value) {
|
|
187
382
|
return JSON.stringify(sortKeys(JSON.parse(JSON.stringify(value ?? null))));
|
|
188
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* Content without `tags`: the store's tagger writes them on every version and
|
|
386
|
+
* drops a sent value (#10342 D5), so they are never the importer's to compare.
|
|
387
|
+
*/
|
|
388
|
+
function withoutTags(value) {
|
|
389
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
390
|
+
return value;
|
|
391
|
+
const { tags: _tags, ...rest } = value;
|
|
392
|
+
return rest;
|
|
393
|
+
}
|
|
189
394
|
function safeCode(value) {
|
|
190
395
|
return typeof value === "string" && SAFE_CODE.test(value) ? value : "unknown";
|
|
191
396
|
}
|
|
@@ -242,84 +447,114 @@ async function call(t, method, url, body, ifMatch) {
|
|
|
242
447
|
};
|
|
243
448
|
}
|
|
244
449
|
}
|
|
245
|
-
function failed(
|
|
450
|
+
function failed(at, reason, halt) {
|
|
246
451
|
return {
|
|
247
|
-
outcome: { path, result: "failed", reason },
|
|
452
|
+
outcome: { root: at.root, path: at.path, result: "failed", reason },
|
|
248
453
|
...(halt !== undefined && { halt }),
|
|
249
454
|
};
|
|
250
455
|
}
|
|
251
456
|
/** A non-success answer: 401/403 stops the run, anything else fails the page. */
|
|
252
|
-
function refusedAnswer(
|
|
457
|
+
function refusedAnswer(at, answer) {
|
|
253
458
|
if ("network" in answer)
|
|
254
|
-
return failed(
|
|
459
|
+
return failed(at, answer.network);
|
|
255
460
|
const reason = `${answer.status} ${answer.code}`;
|
|
256
461
|
return answer.status === 401 || answer.status === 403
|
|
257
|
-
? failed(
|
|
258
|
-
: failed(
|
|
462
|
+
? failed(at, reason, `not sent: the API refused the key (${reason})`)
|
|
463
|
+
: failed(at, reason);
|
|
259
464
|
}
|
|
260
465
|
/**
|
|
261
466
|
* A success answered for another locale: the API does not serve `locale`. A
|
|
262
467
|
* success with no locale at all is not the API's answer shape: that page fails.
|
|
263
468
|
*/
|
|
264
|
-
function wrongLocale(
|
|
469
|
+
function wrongLocale(at, answer, locale) {
|
|
265
470
|
if ("network" in answer || answer.data["locale"] === locale)
|
|
266
471
|
return undefined;
|
|
267
472
|
if (typeof answer.data["locale"] !== "string") {
|
|
268
|
-
return failed(
|
|
473
|
+
return failed(at, `${answer.status} unknown`);
|
|
269
474
|
}
|
|
270
|
-
return failed(
|
|
475
|
+
return failed(at, `the API does not serve locale ${locale}`, `not sent: the API does not serve locale ${locale}`);
|
|
271
476
|
}
|
|
272
|
-
async function writePage(t, page, locale, content) {
|
|
273
|
-
const { path, type, slug } = page;
|
|
274
|
-
const
|
|
275
|
-
const
|
|
276
|
-
|
|
477
|
+
async function writePage(t, page, locale, content, layout) {
|
|
478
|
+
const { root, path, type, slug } = page;
|
|
479
|
+
const pagePath = `${t.base}/content/${encodeURIComponent(type)}/${encodeURIComponent(slug)}`;
|
|
480
|
+
const query = `?locale=${encodeURIComponent(locale)}`;
|
|
481
|
+
const address = `${pagePath}${query}`;
|
|
482
|
+
// The route's `statusOf`: anything but `draft` has a Live version.
|
|
483
|
+
let live = false;
|
|
484
|
+
const outcome = (result) => ({
|
|
485
|
+
outcome: { root, path, result, type, slug },
|
|
486
|
+
live,
|
|
277
487
|
});
|
|
488
|
+
// The words are written; now the layout, under the token the draft holds
|
|
489
|
+
// (`null` for a page that has none yet).
|
|
490
|
+
const done = async (result, structureToken = null) => {
|
|
491
|
+
if (layout === undefined)
|
|
492
|
+
return outcome(result);
|
|
493
|
+
const put = await call(t, "PUT", `${pagePath}/structure${query}`, {
|
|
494
|
+
structureToken,
|
|
495
|
+
structure: layout,
|
|
496
|
+
});
|
|
497
|
+
if ("network" in put || put.status !== 200)
|
|
498
|
+
return refusedAnswer(page, put);
|
|
499
|
+
return (wrongLocale(page, put, locale) ??
|
|
500
|
+
outcome(result === "unchanged" && put.data["written"] === true
|
|
501
|
+
? "updated"
|
|
502
|
+
: result));
|
|
503
|
+
};
|
|
278
504
|
for (let reread = false;; reread = true) {
|
|
279
505
|
const read = await call(t, "GET", address);
|
|
280
506
|
if ("network" in read)
|
|
281
|
-
return refusedAnswer(
|
|
507
|
+
return refusedAnswer(page, read);
|
|
282
508
|
if (read.status === 200) {
|
|
283
|
-
const mismatch = wrongLocale(
|
|
509
|
+
const mismatch = wrongLocale(page, read, locale);
|
|
284
510
|
if (mismatch !== undefined)
|
|
285
511
|
return mismatch;
|
|
286
512
|
const version = read.data["version"];
|
|
287
513
|
if (typeof version !== "string")
|
|
288
|
-
return failed(
|
|
289
|
-
|
|
290
|
-
|
|
514
|
+
return failed(page, "200 unknown");
|
|
515
|
+
live = read.data["status"] !== "draft";
|
|
516
|
+
const token = read.data["structureToken"];
|
|
517
|
+
const structureToken = typeof token === "string" ? token : null;
|
|
518
|
+
if (canonical(withoutTags(read.data["content"])) ===
|
|
519
|
+
canonical(withoutTags(content))) {
|
|
520
|
+
return done("unchanged", structureToken);
|
|
291
521
|
}
|
|
292
522
|
// The token is the version just compared against, so a draft saved
|
|
293
523
|
// since the read is a 409, never a silent overwrite.
|
|
294
524
|
const saved = await call(t, "PATCH", address, content, version);
|
|
295
525
|
if ("network" in saved || saved.status !== 200)
|
|
296
|
-
return refusedAnswer(
|
|
297
|
-
|
|
526
|
+
return refusedAnswer(page, saved);
|
|
527
|
+
// A save carries the structure token forward (#10330 D5).
|
|
528
|
+
return (wrongLocale(page, saved, locale) ?? done("updated", structureToken));
|
|
298
529
|
}
|
|
299
530
|
if (read.status !== 404 || reread)
|
|
300
|
-
return refusedAnswer(
|
|
531
|
+
return refusedAnswer(page, read);
|
|
301
532
|
const created = await call(t, "POST", `${t.base}/content`, {
|
|
302
533
|
...content,
|
|
303
534
|
locale,
|
|
304
535
|
});
|
|
305
536
|
if ("network" in created)
|
|
306
|
-
return refusedAnswer(
|
|
537
|
+
return refusedAnswer(page, created);
|
|
307
538
|
if (created.status === 201) {
|
|
308
|
-
return wrongLocale(
|
|
539
|
+
return wrongLocale(page, created, locale) ?? done("created");
|
|
309
540
|
}
|
|
310
541
|
// 409 slug_taken: another type holds this page's registry link (#10268
|
|
311
542
|
// D5) — reported as it is, since a re-read at this address finds nothing.
|
|
312
543
|
// Any other 409: created by someone else since the read — read it once more.
|
|
313
544
|
if (created.status !== 409 || created.code === "slug_taken") {
|
|
314
|
-
return refusedAnswer(
|
|
545
|
+
return refusedAnswer(page, created);
|
|
315
546
|
}
|
|
316
547
|
}
|
|
317
548
|
}
|
|
318
549
|
// ── The report (D6) ─────────────────────────────────────────────────────────
|
|
550
|
+
/** An outcome's printed path: an editorial one says so (#10701 D6). */
|
|
551
|
+
export function outcomePath(at) {
|
|
552
|
+
return at.root === "editorial" ? `editorial: ${at.path}` : at.path;
|
|
553
|
+
}
|
|
319
554
|
function outcomeLine(outcome) {
|
|
320
555
|
return "reason" in outcome
|
|
321
|
-
? `${outcome
|
|
322
|
-
: `${outcome
|
|
556
|
+
? `${outcomePath(outcome)}: ${outcome.result} — ${outcome.reason}`
|
|
557
|
+
: `${outcomePath(outcome)}: ${outcome.result}`;
|
|
323
558
|
}
|
|
324
559
|
/** The whole report, one line per page and a final count line. */
|
|
325
560
|
export function formatImportMdxReport(result) {
|
|
@@ -331,9 +566,36 @@ export function formatImportMdxReport(result) {
|
|
|
331
566
|
}
|
|
332
567
|
// ── The importer ────────────────────────────────────────────────────────────
|
|
333
568
|
/** One whitespace-collapsed line of at most `ADAPTER_REASON_MAX` characters. */
|
|
334
|
-
function adapterReason(err) {
|
|
569
|
+
function adapterReason(err, stage = "adapter") {
|
|
335
570
|
const message = err instanceof Error ? err.message : String(err);
|
|
336
|
-
return
|
|
571
|
+
return `${stage}: ${message.replace(/\s+/g, " ").trim().slice(0, ADAPTER_REASON_MAX)}`;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* What the page stores (#10534): with `sections` and a codec, its words after
|
|
575
|
+
* the body's own text and its sections as the layout; else the page as read.
|
|
576
|
+
*/
|
|
577
|
+
function convert(source, codec) {
|
|
578
|
+
const { sections, ...frontmatter } = source.frontmatter;
|
|
579
|
+
if (!Array.isArray(sections) ||
|
|
580
|
+
sections.length === 0 ||
|
|
581
|
+
codec === undefined) {
|
|
582
|
+
return { content: { ...source.frontmatter, body: source.body } };
|
|
583
|
+
}
|
|
584
|
+
let words;
|
|
585
|
+
try {
|
|
586
|
+
words = codec(sections);
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
return { reason: adapterReason(err, "codec") };
|
|
590
|
+
}
|
|
591
|
+
const lead = source.body.trimEnd();
|
|
592
|
+
return {
|
|
593
|
+
content: {
|
|
594
|
+
...frontmatter,
|
|
595
|
+
body: lead === "" ? words : `${lead}\n\n${words}`,
|
|
596
|
+
},
|
|
597
|
+
layout: { kind: "sections", sections },
|
|
598
|
+
};
|
|
337
599
|
}
|
|
338
600
|
export async function importMdx(opts) {
|
|
339
601
|
const defaultLocale = opts.defaultLocale ?? "en";
|
|
@@ -347,7 +609,38 @@ export async function importMdx(opts) {
|
|
|
347
609
|
onLine,
|
|
348
610
|
};
|
|
349
611
|
const contentRoot = resolve(opts.contentRoot);
|
|
350
|
-
const
|
|
612
|
+
const types = opts.adapters.SCHEMA_ORG_TYPES;
|
|
613
|
+
const content = plan(contentRoot, types, defaultLocale);
|
|
614
|
+
const editorialRoot = opts.editorialRoot === undefined ? undefined : resolve(opts.editorialRoot);
|
|
615
|
+
const editorial = editorialRoot === undefined
|
|
616
|
+
? { pages: [], skipped: [] }
|
|
617
|
+
: planEditorial(editorialRoot, types, defaultLocale);
|
|
618
|
+
const parseFrontmatter = opts.adapters.parseFrontmatter;
|
|
619
|
+
// #10701 D3 — one route path holds one page, so an editorial index the run
|
|
620
|
+
// will write supersedes a content page of its type at another slug (the
|
|
621
|
+
// scaffold's teaser), which the create would refuse `slug_taken`.
|
|
622
|
+
const indexTypes = new Set(parseFrontmatter === undefined
|
|
623
|
+
? []
|
|
624
|
+
: editorial.pages
|
|
625
|
+
.filter((p) => p.invalid === undefined && INDEX_TYPES.has(p.type))
|
|
626
|
+
.map((p) => p.type));
|
|
627
|
+
const superseded = (p) => indexTypes.has(p.type) && p.slug !== INDEX_SLUG;
|
|
628
|
+
const pages = [
|
|
629
|
+
...content.pages.filter((p) => !superseded(p)),
|
|
630
|
+
...editorial.pages,
|
|
631
|
+
];
|
|
632
|
+
const skipped = [
|
|
633
|
+
...[
|
|
634
|
+
...content.skipped,
|
|
635
|
+
...content.pages.filter(superseded).map((p) => ({
|
|
636
|
+
root: "content",
|
|
637
|
+
path: p.path,
|
|
638
|
+
result: "skipped",
|
|
639
|
+
reason: "superseded by the editorial index page",
|
|
640
|
+
})),
|
|
641
|
+
].sort(byPath),
|
|
642
|
+
...editorial.skipped,
|
|
643
|
+
];
|
|
351
644
|
const reader = opts.adapters.createMdxAdapter({ contentRoot, defaultLocale });
|
|
352
645
|
const outcomes = [];
|
|
353
646
|
const record = (outcome) => {
|
|
@@ -360,46 +653,98 @@ export async function importMdx(opts) {
|
|
|
360
653
|
// types may share a slug (#10268 D7): the store addresses by type too.
|
|
361
654
|
const addresses = new Map();
|
|
362
655
|
let halt;
|
|
656
|
+
let publishRefused;
|
|
657
|
+
let live = 0;
|
|
658
|
+
let published = 0;
|
|
363
659
|
for (const page of pages) {
|
|
364
660
|
const address = `${page.type}/${page.slug}`;
|
|
365
661
|
const holder = addresses.get(address);
|
|
662
|
+
const fail = (reason) => record({ root: page.root, path: page.path, result: "failed", reason });
|
|
663
|
+
if (page.invalid !== undefined) {
|
|
664
|
+
fail(page.invalid);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
366
667
|
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
|
-
});
|
|
668
|
+
fail(`same address as ${holder} (a page is addressed by locale, type and slug)`);
|
|
372
669
|
continue;
|
|
373
670
|
}
|
|
374
|
-
addresses.set(address, page
|
|
671
|
+
addresses.set(address, outcomePath(page));
|
|
375
672
|
if (halt !== undefined) {
|
|
376
|
-
|
|
673
|
+
fail(halt);
|
|
377
674
|
continue;
|
|
378
675
|
}
|
|
379
676
|
let source;
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
677
|
+
if (page.root === "editorial") {
|
|
678
|
+
if (parseFrontmatter === undefined) {
|
|
679
|
+
fail(`editorial: no frontmatter parser — update the site's ${CONTENT_ADAPTERS_PACKAGE} and re-run`);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
let read;
|
|
683
|
+
try {
|
|
684
|
+
read = editorialSource(page, parseFrontmatter(readFileSync(join(editorialRoot, page.path), "utf8")));
|
|
685
|
+
}
|
|
686
|
+
catch (err) {
|
|
687
|
+
fail(adapterReason(err, "editorial"));
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
source = read.source;
|
|
691
|
+
if (read.dropped.length > 0) {
|
|
692
|
+
onLine(`${page.path}: not imported — ${read.dropped.join(", ")} (the editorial walk sends only the fields the store keeps from a caller)`);
|
|
693
|
+
}
|
|
384
694
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
695
|
+
else {
|
|
696
|
+
try {
|
|
697
|
+
source = page.localeTree
|
|
698
|
+
? await reader.getPageSource(page.type, page.slug, defaultLocale)
|
|
699
|
+
: await reader.getPageSource(page.type, page.slug);
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
fail(adapterReason(err));
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
388
705
|
}
|
|
389
706
|
if (source === null) {
|
|
390
|
-
|
|
391
|
-
path: page.path,
|
|
392
|
-
result: "failed",
|
|
393
|
-
reason: "adapter: not read",
|
|
394
|
-
});
|
|
707
|
+
fail("adapter: not read");
|
|
395
708
|
continue;
|
|
396
709
|
}
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
710
|
+
const converted = convert(source, opts.adapters.sectionsToMarkdown);
|
|
711
|
+
if ("reason" in converted) {
|
|
712
|
+
fail(converted.reason);
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
if (converted.layout === undefined &&
|
|
716
|
+
Array.isArray(source.frontmatter["sections"]) &&
|
|
717
|
+
source.frontmatter["sections"].length > 0) {
|
|
718
|
+
onLine(`${page.path}: keeps its words in sections — the site's ${VALIDATION_PACKAGE} has no words codec; update its dependencies and re-run`);
|
|
719
|
+
}
|
|
720
|
+
const result = await writePage(t, page, defaultLocale, converted.content, converted.layout);
|
|
401
721
|
halt = result.halt;
|
|
402
|
-
|
|
722
|
+
if (opts.publish !== true || "reason" in result.outcome) {
|
|
723
|
+
record(result.outcome);
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (result.live === true) {
|
|
727
|
+
live += 1;
|
|
728
|
+
record(result.outcome);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
// #10659 D2 — a page with no Live version is set Live from its draft tip;
|
|
732
|
+
// after one refused publish, the rest are saved and not sent.
|
|
733
|
+
let unpublished = publishRefused;
|
|
734
|
+
if (unpublished === undefined) {
|
|
735
|
+
const answer = await call(t, "POST", `${t.base}/content/${encodeURIComponent(page.type)}/${encodeURIComponent(page.slug)}/publish?locale=${encodeURIComponent(defaultLocale)}`, {});
|
|
736
|
+
if (!("network" in answer) && answer.status === 200) {
|
|
737
|
+
published += 1;
|
|
738
|
+
live += 1;
|
|
739
|
+
record(result.outcome);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
unpublished = `saved as a draft; not published (${"network" in answer ? answer.network : `${answer.status} ${answer.code}`})`;
|
|
743
|
+
if (!("network" in answer) && (answer.status === 401 || answer.status === 403)) {
|
|
744
|
+
publishRefused = unpublished;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
fail(unpublished);
|
|
403
748
|
}
|
|
404
749
|
skipped.forEach(record);
|
|
405
750
|
const counts = {
|
|
@@ -411,7 +756,11 @@ export async function importMdx(opts) {
|
|
|
411
756
|
};
|
|
412
757
|
for (const outcome of outcomes)
|
|
413
758
|
counts[outcome.result] += 1;
|
|
414
|
-
return {
|
|
759
|
+
return {
|
|
760
|
+
pages: outcomes,
|
|
761
|
+
counts,
|
|
762
|
+
...(opts.publish === true && { live, published }),
|
|
763
|
+
};
|
|
415
764
|
}
|
|
416
765
|
// ── The site's own adapter (D1) ─────────────────────────────────────────────
|
|
417
766
|
/** Whether a `node_modules` at or above `dir` holds the adapter package. */
|
|
@@ -457,5 +806,36 @@ export async function loadSiteMdxAdapter(siteRoot) {
|
|
|
457
806
|
!types.every((type) => typeof type === "string")) {
|
|
458
807
|
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
808
|
}
|
|
460
|
-
|
|
809
|
+
// The codec the adapter's own validation package carries (#10534); one
|
|
810
|
+
// that predates it, or none, leaves pages unconverted and the run says so.
|
|
811
|
+
let codec;
|
|
812
|
+
try {
|
|
813
|
+
const validation = createRequire(entry).resolve(VALIDATION_PACKAGE);
|
|
814
|
+
codec = (await import(pathToFileURL(validation).href))["sectionsToMarkdown"];
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
// Not resolvable from the adapter: no codec.
|
|
818
|
+
}
|
|
819
|
+
// The adapter's own frontmatter parser (#10701 D1), for the editorial tree;
|
|
820
|
+
// none leaves every editorial page failed, and the run says so.
|
|
821
|
+
let matter;
|
|
822
|
+
try {
|
|
823
|
+
const parser = createRequire(entry).resolve(FRONTMATTER_PACKAGE);
|
|
824
|
+
matter = (await import(pathToFileURL(parser).href))["default"];
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
// Not resolvable from the adapter: no parser.
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
...mod,
|
|
831
|
+
...(typeof codec === "function" && {
|
|
832
|
+
sectionsToMarkdown: codec,
|
|
833
|
+
}),
|
|
834
|
+
...(typeof matter === "function" && {
|
|
835
|
+
parseFrontmatter: (raw) => {
|
|
836
|
+
const { data, content } = matter(raw);
|
|
837
|
+
return { data, content };
|
|
838
|
+
},
|
|
839
|
+
}),
|
|
840
|
+
};
|
|
461
841
|
}
|