create-ailk 0.31.0 → 0.33.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 +50 -8
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/body-heading.d.ts +13 -0
- package/dist/body-heading.js +19 -5
- package/dist/cutover.d.ts +6 -1
- package/dist/cutover.js +23 -6
- package/dist/export-content.d.ts +6 -3
- package/dist/export-content.js +79 -10
- package/dist/language-import.d.ts +16 -3
- package/dist/language-import.js +27 -11
- package/dist/lib/import-mdx.d.ts +42 -0
- package/dist/lib/import-mdx.js +584 -65
- package/dist/lib/render-comparison.d.ts +51 -0
- package/dist/lib/render-comparison.js +205 -6
- package/dist/mdx-braces.d.ts +73 -0
- package/dist/mdx-braces.js +285 -0
- package/dist/migrate-content.d.ts +1 -1
- package/dist/migrate-content.js +4 -2
- package/dist/programmatic.js +2 -8
- package/dist/seed-flows.d.ts +1 -1
- package/dist/seed-flows.js +9 -4
- 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 +17 -0
- package/dist/update.d.ts +21 -1
- package/dist/update.js +142 -11
- package/package.json +3 -3
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/apps/api/CLAUDE.md +7 -5
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/mcp/.env.example +7 -1
- package/templates/apps/web/__tests__/setup.ts +9 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- 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__/contact.actions.test.ts +21 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
- package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
- 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 +3 -20
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
- package/templates/apps/web/auth.ts +22 -6
- package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +3 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +134 -10
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +20 -0
- package/templates/apps/web/lib/account-config.ts +52 -14
- package/templates/apps/web/lib/auth-email.ts +87 -5
- package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
- package/templates/apps/web/messages/en.json +26 -1
- package/templates/apps/web/src/lib/routes.ts +5 -3
- package/templates/database/seed.ts +7 -7
- package/templates/package.json +2 -1
- package/templates/tools/body-heading.ts +440 -0
- package/templates/tools/check-mdx-body.ts +123 -0
- package/templates/tools/mdx-braces.ts +303 -0
- package/versions.json +48 -48
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +0 -104
package/dist/lib/import-mdx.js
CHANGED
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
* reports `unchanged`; a changed layout on a live draft is refused
|
|
20
20
|
* `draft_is_live` and the page fails with that reason.
|
|
21
21
|
*
|
|
22
|
+
* A section string that holds inline HTML is converted first (#10804) — a
|
|
23
|
+
* link to `[label](href)`, a line break to a newline, a block to a blank
|
|
24
|
+
* line, every other tag unwrapped to its text — so the words and the layout
|
|
25
|
+
* carry no tag, which the hub's structure write refuses. The layout is then
|
|
26
|
+
* checked against the hub's section-string rules before any write, so a page
|
|
27
|
+
* the hub would refuse fails with the rule and the path, and a page whose
|
|
28
|
+
* draft holds no layout yet is never reported `unchanged`.
|
|
29
|
+
*
|
|
22
30
|
* create-ailk ships one runtime dependency, so the adapter is never imported:
|
|
23
31
|
* the caller injects it, and `loadSiteMdxAdapter` resolves the SITE's
|
|
24
32
|
* installed copy from its `apps/web` at run time (the #6937 precedent), and
|
|
@@ -29,7 +37,9 @@
|
|
|
29
37
|
* `<slug>/index.mdx` becomes a `BlogPosting` / `DocsDetail` page (the
|
|
30
38
|
* directory's own `index.mdx` its `BlogList` / `DocsList` index), its
|
|
31
39
|
* fumadocs frontmatter mapped to the stored vocabulary and written through the
|
|
32
|
-
* same converting path. Every outcome names the root it came from.
|
|
40
|
+
* same converting path. Every outcome names the root it came from. A key the
|
|
41
|
+
* vocabulary does not name rides in `siteFields`, and a blog post's tags are
|
|
42
|
+
* sent (#10806); a key the store cannot keep is named with its reason.
|
|
33
43
|
*
|
|
34
44
|
* Given `publish` (#10659 D2), each page with no Live version is set Live
|
|
35
45
|
* after it is written (`POST …/publish`, the draft tip); a page already Live
|
|
@@ -41,7 +51,8 @@
|
|
|
41
51
|
* a status and a `[a-z0-9_]` error code reach output (SEC-7876-S1).
|
|
42
52
|
*
|
|
43
53
|
* 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
|
|
54
|
+
* walk docs/specs/10701-import-mdx-walks-the-editorial-spec.md; its site
|
|
55
|
+
* fields docs/specs/10806-the-editorial-import-drops-blog-spec.md.
|
|
45
56
|
*/
|
|
46
57
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
47
58
|
import { createRequire } from "node:module";
|
|
@@ -61,6 +72,8 @@ const FRONTMATTER_PACKAGE = "gray-matter";
|
|
|
61
72
|
const LOCALE = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
62
73
|
/** `SLUG_PATTERN` in the adapter's `errors.ts`, restated: no runtime dep. */
|
|
63
74
|
const SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
75
|
+
/** A `parent`'s shape (`ServiceFieldsSchema` in the adapter's `types.ts`), restated: no runtime dep. */
|
|
76
|
+
const PARENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
64
77
|
/** Each editorial directory's page type and index type (`runtime-source.ts`). */
|
|
65
78
|
const EDITORIAL_TYPES = {
|
|
66
79
|
blog: { page: "BlogPosting", index: "BlogList" },
|
|
@@ -69,10 +82,15 @@ const EDITORIAL_TYPES = {
|
|
|
69
82
|
/** The slug the runtime reads as a surface's own index. */
|
|
70
83
|
const INDEX_SLUG = "index";
|
|
71
84
|
const INDEX_TYPES = new Set(Object.values(EDITORIAL_TYPES).map((k) => k.index));
|
|
85
|
+
/** Each index type's own slug, its editorial directory: `BlogList/blog` shares `/blog` with `BlogList/index` (#10805 D5). */
|
|
86
|
+
const INDEX_OWN_SLUG = new Map(Object.entries(EDITORIAL_TYPES).map(([dir, k]) => [k.index, dir]));
|
|
87
|
+
/** `PAGE_TAG_TYPES` (the validation package's `site-fields.ts`), restated: the blog collection keeps a sent `tags`. */
|
|
88
|
+
const PAGE_TAG_TYPES = new Set(["BlogPosting", "BlogList"]);
|
|
72
89
|
/**
|
|
73
90
|
* `fumadocsToArtifactFrontmatter` (`apps/web/lib/runtime-source.ts`) restated —
|
|
74
|
-
* fumadocs key to stored key — less `tags`, which
|
|
75
|
-
* `sections`, which `convert` moves. Any other key
|
|
91
|
+
* fumadocs key to stored key — less `tags`, which only a blog page sends, plus
|
|
92
|
+
* `sections`, which `convert` moves. Any other key rides in `siteFields`
|
|
93
|
+
* (#10806 D4).
|
|
76
94
|
*/
|
|
77
95
|
const EDITORIAL_FIELDS = {
|
|
78
96
|
title: "title",
|
|
@@ -87,6 +105,14 @@ const EDITORIAL_FIELDS = {
|
|
|
87
105
|
};
|
|
88
106
|
/** Keys not sent and not reported: the path decides both, and a strip marker. */
|
|
89
107
|
const EDITORIAL_IGNORED = new Set(["type", "slug", "oss"]);
|
|
108
|
+
/** Why a key is not sent (#10806 D4); `siteFieldRefusal`'s own reasons are the rest. */
|
|
109
|
+
const NOT_SENT = {
|
|
110
|
+
parent: "the path decides a blog post's parent",
|
|
111
|
+
agentTag: "a tag agents act on, or a site tag, is kept by AI",
|
|
112
|
+
notList: "not a list of tags",
|
|
113
|
+
full: "siteFields is full (32 keys or 64 KiB)",
|
|
114
|
+
olderSite: `the site's ${VALIDATION_PACKAGE} predates site fields; update its dependencies and re-run`,
|
|
115
|
+
};
|
|
90
116
|
/** `wksp_<uuidv7>` — the `@working-theory/ids` shape, restated: no runtime dep. */
|
|
91
117
|
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}$/;
|
|
92
118
|
/** A key is visible ASCII only: what an HTTP header can carry, with no space. */
|
|
@@ -166,10 +192,13 @@ function mdxFiles(root, prefix = []) {
|
|
|
166
192
|
: [];
|
|
167
193
|
});
|
|
168
194
|
}
|
|
195
|
+
/** Type, then slug, then a page with no `parent` before a series leaf (#10775 D4). */
|
|
169
196
|
function byTypeThenSlug(a, b) {
|
|
170
197
|
if (a.type !== b.type)
|
|
171
198
|
return a.type < b.type ? -1 : 1;
|
|
172
|
-
|
|
199
|
+
if (a.slug !== b.slug)
|
|
200
|
+
return a.slug < b.slug ? -1 : 1;
|
|
201
|
+
return Number(a.parent !== undefined) - Number(b.parent !== undefined);
|
|
173
202
|
}
|
|
174
203
|
function byPath(a, b) {
|
|
175
204
|
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
@@ -239,9 +268,11 @@ function plan(contentRoot, types, defaultLocale) {
|
|
|
239
268
|
};
|
|
240
269
|
}
|
|
241
270
|
/**
|
|
242
|
-
* The editorial walk (#10701 D2–D3): `L/{blog,docs}/<slug>.mdx` and
|
|
271
|
+
* The editorial walk (#10701 D2–D3, #10775 D2): `L/{blog,docs}/<slug>.mdx` and
|
|
243
272
|
* `<slug>/index.mdx` are posts and docs, the directory's own `index.mdx` its
|
|
244
|
-
* index page
|
|
273
|
+
* index page, and `L/blog/<series>/<post>.mdx` or `<post>/index.mdx` a post
|
|
274
|
+
* whose `parent` is the series. Every other file is skipped with the rule it
|
|
275
|
+
* failed.
|
|
245
276
|
*/
|
|
246
277
|
function planEditorial(editorialRoot, types, defaultLocale) {
|
|
247
278
|
const pages = [];
|
|
@@ -254,7 +285,7 @@ function planEditorial(editorialRoot, types, defaultLocale) {
|
|
|
254
285
|
// Sorted by path first, so within one directory `<slug>.mdx` comes before
|
|
255
286
|
// `<slug>/index.mdx` and holds the address (`.` sorts before `/`).
|
|
256
287
|
for (const { segments, link, path } of found.sort(byPath)) {
|
|
257
|
-
const [locale, dir, file, nested] = segments;
|
|
288
|
+
const [locale, dir, file, nested, leafFile] = segments;
|
|
258
289
|
const kinds = dir !== undefined && Object.hasOwn(EDITORIAL_TYPES, dir)
|
|
259
290
|
? EDITORIAL_TYPES[dir]
|
|
260
291
|
: undefined;
|
|
@@ -266,31 +297,52 @@ function planEditorial(editorialRoot, types, defaultLocale) {
|
|
|
266
297
|
skip(path, `locale ${locale} is not imported (only ${defaultLocale})`);
|
|
267
298
|
continue;
|
|
268
299
|
}
|
|
300
|
+
if (kinds === undefined || locale !== defaultLocale) {
|
|
301
|
+
skip(path, `not a post or doc: only ${defaultLocale}/blog and ${defaultLocale}/docs are walked`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
269
304
|
let type;
|
|
270
305
|
let slug;
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
}
|
|
306
|
+
let parent;
|
|
307
|
+
if (segments.length === 3 && file === "index.mdx") {
|
|
308
|
+
type = kinds.index;
|
|
309
|
+
slug = INDEX_SLUG;
|
|
284
310
|
}
|
|
285
|
-
if (
|
|
286
|
-
|
|
311
|
+
else if (segments.length === 3) {
|
|
312
|
+
type = kinds.page;
|
|
313
|
+
slug = file.slice(0, -".mdx".length);
|
|
314
|
+
}
|
|
315
|
+
else if (segments.length === 4 && nested === "index.mdx") {
|
|
316
|
+
type = kinds.page;
|
|
317
|
+
slug = file;
|
|
318
|
+
}
|
|
319
|
+
else if (dir === "docs") {
|
|
320
|
+
skip(path, "a nested doc: a doc is docs/<slug>.mdx or docs/<slug>/index.mdx");
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
else if (file === "category") {
|
|
324
|
+
skip(path, "under blog/category: /blog/category/<tag> is the blog's category index, so no post is served there");
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
else if (segments.length === 4 ||
|
|
328
|
+
(segments.length === 5 && leafFile === "index.mdx")) {
|
|
329
|
+
type = kinds.page;
|
|
330
|
+
slug = segments.length === 4 ? nested.slice(0, -".mdx".length) : nested;
|
|
331
|
+
parent = file;
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
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");
|
|
287
335
|
continue;
|
|
288
336
|
}
|
|
289
337
|
const invalid = !SLUG.test(slug)
|
|
290
338
|
? `not a valid slug: ${slug}`
|
|
291
|
-
: !
|
|
292
|
-
? `
|
|
293
|
-
:
|
|
339
|
+
: parent !== undefined && !PARENT.test(parent)
|
|
340
|
+
? `not a valid series: ${parent} (a parent is lowercase words joined by single hyphens)`
|
|
341
|
+
: parent === slug
|
|
342
|
+
? `a post cannot share its series' slug: ${slug}`
|
|
343
|
+
: !types.includes(type)
|
|
344
|
+
? `the site's adapter has no type ${type}`
|
|
345
|
+
: undefined;
|
|
294
346
|
pages.push({
|
|
295
347
|
root: "editorial",
|
|
296
348
|
path,
|
|
@@ -298,6 +350,7 @@ function planEditorial(editorialRoot, types, defaultLocale) {
|
|
|
298
350
|
slug,
|
|
299
351
|
localeTree: true,
|
|
300
352
|
...(invalid !== undefined && { invalid }),
|
|
353
|
+
...(parent !== undefined && { parent }),
|
|
301
354
|
});
|
|
302
355
|
}
|
|
303
356
|
return { pages: pages.sort(byTypeThenSlug), skipped };
|
|
@@ -309,31 +362,118 @@ function dateString(value) {
|
|
|
309
362
|
const iso = value.toISOString();
|
|
310
363
|
return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
|
|
311
364
|
}
|
|
365
|
+
/** Below this depth a value is left as read: the site's rule refuses it as too deep. */
|
|
366
|
+
const DATES_MAX_DEPTH = 8;
|
|
367
|
+
/** `value` with every `Date` to a bounded depth as `dateString` gives it (#10806 D4). */
|
|
368
|
+
function datesAsStrings(value, depth = 1) {
|
|
369
|
+
if (value instanceof Date)
|
|
370
|
+
return dateString(value);
|
|
371
|
+
if (depth > DATES_MAX_DEPTH)
|
|
372
|
+
return value;
|
|
373
|
+
if (Array.isArray(value))
|
|
374
|
+
return value.map((item) => datesAsStrings(item, depth + 1));
|
|
375
|
+
if (value === null || typeof value !== "object")
|
|
376
|
+
return value;
|
|
377
|
+
if (Object.getPrototypeOf(value) !== Object.prototype)
|
|
378
|
+
return value;
|
|
379
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, datesAsStrings(item, depth + 1)]));
|
|
380
|
+
}
|
|
381
|
+
/** A tag as printed: control characters as `?`, at most 64 characters. */
|
|
382
|
+
function printedTag(tag) {
|
|
383
|
+
// eslint-disable-next-line no-control-regex -- the controls are what is replaced
|
|
384
|
+
return String(tag).replace(/[\u0000-\u001f\u007f-\u009f]/g, "?").slice(0, 64);
|
|
385
|
+
}
|
|
312
386
|
/**
|
|
313
387
|
* An editorial file as the adapter would return a page (#10701 D4): its
|
|
314
388
|
* fumadocs frontmatter mapped to the stored vocabulary, the path deciding the
|
|
315
|
-
* type and slug
|
|
389
|
+
* type and slug — and, for a blog page, its `parent` (#10775 D3). Every other
|
|
390
|
+
* key the site's rule accepts rides in `siteFields`, and a blog page sends its
|
|
391
|
+
* `tags` (#10806 D4); the keys not sent are named with their reasons.
|
|
316
392
|
*/
|
|
317
|
-
function editorialSource(page, parsed) {
|
|
393
|
+
function editorialSource(page, parsed, rules) {
|
|
318
394
|
const frontmatter = {
|
|
319
395
|
type: page.type,
|
|
320
396
|
slug: page.slug,
|
|
321
397
|
};
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
398
|
+
const notSent = [];
|
|
399
|
+
const siteFields = {};
|
|
400
|
+
const blog = PAGE_TAG_TYPES.has(page.type);
|
|
401
|
+
for (const [key, raw] of Object.entries(parsed.data)) {
|
|
402
|
+
if (EDITORIAL_IGNORED.has(key))
|
|
403
|
+
continue;
|
|
404
|
+
if (blog && key === "parent") {
|
|
405
|
+
notSent.push({ key, reason: NOT_SENT.parent });
|
|
406
|
+
}
|
|
407
|
+
else if (Object.hasOwn(EDITORIAL_FIELDS, key)) {
|
|
408
|
+
frontmatter[EDITORIAL_FIELDS[key]] = dateString(raw);
|
|
409
|
+
}
|
|
410
|
+
else if (rules === undefined) {
|
|
411
|
+
notSent.push({ key, reason: NOT_SENT.olderSite });
|
|
412
|
+
}
|
|
413
|
+
else if (blog && key === "tags") {
|
|
414
|
+
if (!Array.isArray(raw)) {
|
|
415
|
+
notSent.push({ key, reason: NOT_SENT.notList });
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
// Sent as written, so the stored list is what the store computes; a
|
|
419
|
+
// tag it keeps for itself is named.
|
|
420
|
+
frontmatter["tags"] = raw;
|
|
421
|
+
const kept = new Set(rules.publicPageTags(raw));
|
|
422
|
+
for (const tag of new Set(raw.filter((tag) => !kept.has(tag)))) {
|
|
423
|
+
notSent.push({ key: `tags: ${printedTag(tag)}`, reason: NOT_SENT.agentTag });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
const value = datesAsStrings(raw);
|
|
428
|
+
const refused = rules.refusal(key, value);
|
|
429
|
+
const fits = Object.keys(siteFields).length < rules.maxKeys &&
|
|
430
|
+
new TextEncoder().encode(JSON.stringify({ ...siteFields, [key]: value })).length <=
|
|
431
|
+
rules.maxBytes;
|
|
432
|
+
if (refused !== null)
|
|
433
|
+
notSent.push({ key, reason: refused });
|
|
434
|
+
else if (!fits)
|
|
435
|
+
notSent.push({ key, reason: NOT_SENT.full });
|
|
436
|
+
else
|
|
437
|
+
siteFields[key] = value;
|
|
438
|
+
}
|
|
331
439
|
}
|
|
440
|
+
if (page.parent !== undefined)
|
|
441
|
+
frontmatter["parent"] = page.parent;
|
|
442
|
+
if (Object.keys(siteFields).length > 0)
|
|
443
|
+
frontmatter["siteFields"] = siteFields;
|
|
332
444
|
return {
|
|
333
445
|
source: { frontmatter, body: parsed.content },
|
|
334
|
-
|
|
446
|
+
notSent: notSent.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)),
|
|
335
447
|
};
|
|
336
448
|
}
|
|
449
|
+
/** `a (reason); b, c (reason)` — keys in order, one segment per reason. */
|
|
450
|
+
function notSentList(notSent) {
|
|
451
|
+
const byReason = new Map();
|
|
452
|
+
for (const { key, reason } of notSent) {
|
|
453
|
+
byReason.set(reason, [...(byReason.get(reason) ?? []), key]);
|
|
454
|
+
}
|
|
455
|
+
return [...byReason]
|
|
456
|
+
.map(([reason, keys]) => `${keys.join(", ")} (${reason})`)
|
|
457
|
+
.join("; ");
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The sent site fields and public tags a write's stored content lacks
|
|
461
|
+
* (#10806 D4): what a hub that predates site fields dropped.
|
|
462
|
+
*/
|
|
463
|
+
function notKept(sent, kept, rules) {
|
|
464
|
+
const keptFields = kept["siteFields"] !== null && typeof kept["siteFields"] === "object"
|
|
465
|
+
? kept["siteFields"]
|
|
466
|
+
: {};
|
|
467
|
+
const keptTags = Array.isArray(kept["tags"]) ? kept["tags"] : [];
|
|
468
|
+
const sentFields = Object.keys((sent["siteFields"] ?? {}));
|
|
469
|
+
const sentTags = PAGE_TAG_TYPES.has(String(sent["type"])) && Array.isArray(sent["tags"])
|
|
470
|
+
? rules.publicPageTags(sent["tags"])
|
|
471
|
+
: [];
|
|
472
|
+
return [
|
|
473
|
+
...sentFields.filter((key) => !Object.hasOwn(keptFields, key)).sort(),
|
|
474
|
+
...sentTags.filter((tag) => !keptTags.includes(tag)).map((tag) => `tags: ${printedTag(tag)}`),
|
|
475
|
+
];
|
|
476
|
+
}
|
|
337
477
|
// ── "Unchanged" (D4) ────────────────────────────────────────────────────────
|
|
338
478
|
function sortKeys(value) {
|
|
339
479
|
if (Array.isArray(value))
|
|
@@ -349,14 +489,21 @@ function canonical(value) {
|
|
|
349
489
|
return JSON.stringify(sortKeys(JSON.parse(JSON.stringify(value ?? null))));
|
|
350
490
|
}
|
|
351
491
|
/**
|
|
352
|
-
* Content
|
|
353
|
-
*
|
|
492
|
+
* Content as compared: the store's tagger writes `tags` on every version
|
|
493
|
+
* (#10342 D5), so they are dropped — except a blog page that sends them, whose
|
|
494
|
+
* public tags are the importer's (#10806 D4): compared as `publicTags` gives
|
|
495
|
+
* them, so a changed category is an update.
|
|
354
496
|
*/
|
|
355
|
-
function
|
|
497
|
+
function compared(value, sent, publicTags) {
|
|
356
498
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
357
499
|
return value;
|
|
358
|
-
const { tags
|
|
359
|
-
|
|
500
|
+
const { tags, ...rest } = value;
|
|
501
|
+
if (publicTags === undefined ||
|
|
502
|
+
!PAGE_TAG_TYPES.has(String(sent["type"])) ||
|
|
503
|
+
!Array.isArray(sent["tags"])) {
|
|
504
|
+
return rest;
|
|
505
|
+
}
|
|
506
|
+
return { ...rest, tags: publicTags(Array.isArray(tags) ? tags : []) };
|
|
360
507
|
}
|
|
361
508
|
function safeCode(value) {
|
|
362
509
|
return typeof value === "string" && SAFE_CODE.test(value) ? value : "unknown";
|
|
@@ -441,16 +588,18 @@ function wrongLocale(at, answer, locale) {
|
|
|
441
588
|
}
|
|
442
589
|
return failed(at, `the API does not serve locale ${locale}`, `not sent: the API does not serve locale ${locale}`);
|
|
443
590
|
}
|
|
444
|
-
async function writePage(t, page, locale, content, layout) {
|
|
591
|
+
async function writePage(t, page, locale, content, layout, publicTags) {
|
|
445
592
|
const { root, path, type, slug } = page;
|
|
446
593
|
const pagePath = `${t.base}/content/${encodeURIComponent(type)}/${encodeURIComponent(slug)}`;
|
|
447
594
|
const query = `?locale=${encodeURIComponent(locale)}`;
|
|
448
595
|
const address = `${pagePath}${query}`;
|
|
449
596
|
// The route's `statusOf`: anything but `draft` has a Live version.
|
|
450
597
|
let live = false;
|
|
598
|
+
let stored;
|
|
451
599
|
const outcome = (result) => ({
|
|
452
600
|
outcome: { root, path, result, type, slug },
|
|
453
601
|
live,
|
|
602
|
+
stored,
|
|
454
603
|
});
|
|
455
604
|
// The words are written; now the layout, under the token the draft holds
|
|
456
605
|
// (`null` for a page that has none yet).
|
|
@@ -464,7 +613,11 @@ async function writePage(t, page, locale, content, layout) {
|
|
|
464
613
|
if ("network" in put || put.status !== 200)
|
|
465
614
|
return refusedAnswer(page, put);
|
|
466
615
|
return (wrongLocale(page, put, locale) ??
|
|
467
|
-
outcome(
|
|
616
|
+
outcome(
|
|
617
|
+
// #10804 D6 — with no token the hub writes the layout (or refuses a
|
|
618
|
+
// live tip), so a dry run's `written: false` never makes it unchanged.
|
|
619
|
+
result === "unchanged" &&
|
|
620
|
+
(structureToken === null || put.data["written"] === true)
|
|
468
621
|
? "updated"
|
|
469
622
|
: result));
|
|
470
623
|
};
|
|
@@ -482,8 +635,9 @@ async function writePage(t, page, locale, content, layout) {
|
|
|
482
635
|
live = read.data["status"] !== "draft";
|
|
483
636
|
const token = read.data["structureToken"];
|
|
484
637
|
const structureToken = typeof token === "string" ? token : null;
|
|
485
|
-
|
|
486
|
-
|
|
638
|
+
stored = read.data["content"];
|
|
639
|
+
if (canonical(compared(stored, content, publicTags)) ===
|
|
640
|
+
canonical(compared(content, content, publicTags))) {
|
|
487
641
|
return done("unchanged", structureToken);
|
|
488
642
|
}
|
|
489
643
|
// The token is the version just compared against, so a draft saved
|
|
@@ -491,6 +645,7 @@ async function writePage(t, page, locale, content, layout) {
|
|
|
491
645
|
const saved = await call(t, "PATCH", address, content, version);
|
|
492
646
|
if ("network" in saved || saved.status !== 200)
|
|
493
647
|
return refusedAnswer(page, saved);
|
|
648
|
+
stored = saved.data["content"];
|
|
494
649
|
// A save carries the structure token forward (#10330 D5).
|
|
495
650
|
return (wrongLocale(page, saved, locale) ?? done("updated", structureToken));
|
|
496
651
|
}
|
|
@@ -503,9 +658,10 @@ async function writePage(t, page, locale, content, layout) {
|
|
|
503
658
|
if ("network" in created)
|
|
504
659
|
return refusedAnswer(page, created);
|
|
505
660
|
if (created.status === 201) {
|
|
661
|
+
stored = created.data["content"];
|
|
506
662
|
return wrongLocale(page, created, locale) ?? done("created");
|
|
507
663
|
}
|
|
508
|
-
// 409 slug_taken: another
|
|
664
|
+
// 409 slug_taken: another page holds this page's registry link (#10268
|
|
509
665
|
// D5) — reported as it is, since a re-read at this address finds nothing.
|
|
510
666
|
// Any other 409: created by someone else since the read — read it once more.
|
|
511
667
|
if (created.status !== 409 || created.code === "slug_taken") {
|
|
@@ -531,6 +687,323 @@ export function formatImportMdxReport(result) {
|
|
|
531
687
|
`created ${created} · updated ${updated} · unchanged ${unchanged} · skipped ${skipped} · failed ${failures}`,
|
|
532
688
|
].join("\n")}\n`;
|
|
533
689
|
}
|
|
690
|
+
// ── Inline HTML in section strings (#10804) ─────────────────────────────────
|
|
691
|
+
/** `HTML_TAG` (`content-draft.ts`, from `derive-publish.ts`, SEC-10589-01), restated: no runtime dep. */
|
|
692
|
+
const HTML_TAG = /<[A-Za-z!/?]/;
|
|
693
|
+
/** `checkSectionData`'s string and key rules (`content-draft.ts`), restated: no runtime dep. */
|
|
694
|
+
const SECTION_STRING_MAX = 2_000;
|
|
695
|
+
const SECTION_KEY_MAX = 100;
|
|
696
|
+
const SCRIPT_SCHEME = /^(?:javascript|vbscript|data):/;
|
|
697
|
+
/** `FAQ_ALLOWED_HREF` (`faq-questions.ts`), the hrefs `parseFaqAnswer` draws as links, restated. */
|
|
698
|
+
const LINK_HREF = /^(?:\/|#|https?:|mailto:|tel:)/i;
|
|
699
|
+
/** Elements removed with their content (D3). */
|
|
700
|
+
const DROPPED_ELEMENTS = new Set([
|
|
701
|
+
"script",
|
|
702
|
+
"style",
|
|
703
|
+
"template",
|
|
704
|
+
"iframe",
|
|
705
|
+
"noscript",
|
|
706
|
+
"svg",
|
|
707
|
+
"object",
|
|
708
|
+
"video",
|
|
709
|
+
"audio",
|
|
710
|
+
"canvas",
|
|
711
|
+
]);
|
|
712
|
+
/** Elements whose open or close tag is a paragraph break (D3). */
|
|
713
|
+
const BLOCK_ELEMENTS = new Set([
|
|
714
|
+
"p",
|
|
715
|
+
"div",
|
|
716
|
+
"section",
|
|
717
|
+
"article",
|
|
718
|
+
"header",
|
|
719
|
+
"footer",
|
|
720
|
+
"blockquote",
|
|
721
|
+
"figure",
|
|
722
|
+
"figcaption",
|
|
723
|
+
"pre",
|
|
724
|
+
"table",
|
|
725
|
+
"tr",
|
|
726
|
+
"dl",
|
|
727
|
+
"dt",
|
|
728
|
+
"dd",
|
|
729
|
+
"h1",
|
|
730
|
+
"h2",
|
|
731
|
+
"h3",
|
|
732
|
+
"h4",
|
|
733
|
+
"h5",
|
|
734
|
+
"h6",
|
|
735
|
+
"ul",
|
|
736
|
+
"ol",
|
|
737
|
+
"hr",
|
|
738
|
+
]);
|
|
739
|
+
/** What the layout keeps of a tag (D3): `a` only when it becomes a link. */
|
|
740
|
+
const CARRIED_ELEMENTS = new Set(["a", "br", "p", "ul", "li"]);
|
|
741
|
+
const NAMED_ENTITIES = {
|
|
742
|
+
amp: "&",
|
|
743
|
+
lt: "<",
|
|
744
|
+
gt: ">",
|
|
745
|
+
quot: '"',
|
|
746
|
+
apos: "'",
|
|
747
|
+
nbsp: "\u00a0",
|
|
748
|
+
};
|
|
749
|
+
const ENTITY = /&(?:#(\d+)|#[xX]([0-9A-Fa-f]+)|(amp|lt|gt|quot|apos|nbsp));/g;
|
|
750
|
+
const TAG_NAME = /\/?([A-Za-z][A-Za-z0-9-]*)/y;
|
|
751
|
+
const TAG_END = /[ \t\r\n\f]*(\/?)>/y;
|
|
752
|
+
/**
|
|
753
|
+
* One attribute, or one stray character a browser would skip. A `<` ends the
|
|
754
|
+
* scan whether it is unquoted or inside a quoted value, so a `<` that begins
|
|
755
|
+
* no tag costs only the gap to the next one and the tokenizer stays linear in
|
|
756
|
+
* the string (spec §9). D3 needs a quoted value to hold `>`, never `<`.
|
|
757
|
+
*/
|
|
758
|
+
const TAG_ATTR = /[ \t\r\n\f]*(?:([^ \t\r\n\f"'<>/=]+)(?:[ \t\r\n\f]*=[ \t\r\n\f]*(?:"([^"<]*)"|'([^'<]*)'|([^ \t\r\n\f<>]+)))?|[^ \t\r\n\f<>])/y;
|
|
759
|
+
const NEWLINE = 1;
|
|
760
|
+
const PARAGRAPH = 2;
|
|
761
|
+
function decodeEntities(text) {
|
|
762
|
+
return text.replace(ENTITY, (source, dec, hex, name) => {
|
|
763
|
+
if (name !== undefined)
|
|
764
|
+
return NAMED_ENTITIES[name];
|
|
765
|
+
const point = dec !== undefined ? Number(dec) : parseInt(hex, 16);
|
|
766
|
+
return point > 0 &&
|
|
767
|
+
point <= 0x10ffff &&
|
|
768
|
+
(point < 0xd800 || point > 0xdfff)
|
|
769
|
+
? String.fromCodePoint(point)
|
|
770
|
+
: source;
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
/** The tag at `at` (`<`, `/`?, a name, attributes, `/`?, `>`), or `undefined` when `<` begins none. */
|
|
774
|
+
function readTag(value, at) {
|
|
775
|
+
TAG_NAME.lastIndex = at + 1;
|
|
776
|
+
const name = TAG_NAME.exec(value)?.[1];
|
|
777
|
+
if (name === undefined)
|
|
778
|
+
return undefined;
|
|
779
|
+
let href;
|
|
780
|
+
for (let i = TAG_NAME.lastIndex;; i = TAG_ATTR.lastIndex) {
|
|
781
|
+
TAG_END.lastIndex = i;
|
|
782
|
+
const end = TAG_END.exec(value);
|
|
783
|
+
if (end !== null) {
|
|
784
|
+
return {
|
|
785
|
+
end: TAG_END.lastIndex,
|
|
786
|
+
close: value[at + 1] === "/",
|
|
787
|
+
selfClosing: end[1] === "/",
|
|
788
|
+
name: name.toLowerCase(),
|
|
789
|
+
...(href !== undefined && { href }),
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
TAG_ATTR.lastIndex = i;
|
|
793
|
+
const attr = TAG_ATTR.exec(value);
|
|
794
|
+
if (attr === null)
|
|
795
|
+
return undefined;
|
|
796
|
+
if (href === undefined && attr[1]?.toLowerCase() === "href") {
|
|
797
|
+
href = attr[2] ?? attr[3] ?? attr[4] ?? "";
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* A section string's inline HTML as the constrained Markdown the upstream
|
|
803
|
+
* renderers read (#10804 D3): a link with an allowed href becomes
|
|
804
|
+
* `[label](href)`, `<br>` a newline, a block or list item a paragraph break,
|
|
805
|
+
* and every other tag goes, its text kept. A string with no tag comes back
|
|
806
|
+
* byte-identical; the result never holds one. `notCarried` names, sorted,
|
|
807
|
+
* every tag the layout does not keep.
|
|
808
|
+
*/
|
|
809
|
+
export function inlineHtmlToMarkdown(value) {
|
|
810
|
+
if (!HTML_TAG.test(value))
|
|
811
|
+
return { text: value, notCarried: [] };
|
|
812
|
+
const pieces = [];
|
|
813
|
+
const notCarried = new Set();
|
|
814
|
+
const text = (raw) => {
|
|
815
|
+
const decoded = decodeEntities(raw.replace(/[ \t\r\n\f]+/g, " "));
|
|
816
|
+
decoded.split("\n").forEach((part, i) => {
|
|
817
|
+
if (i > 0)
|
|
818
|
+
pieces.push(NEWLINE);
|
|
819
|
+
if (part !== "")
|
|
820
|
+
pieces.push(part);
|
|
821
|
+
});
|
|
822
|
+
};
|
|
823
|
+
let link;
|
|
824
|
+
const closeLink = () => {
|
|
825
|
+
if (link === undefined)
|
|
826
|
+
return;
|
|
827
|
+
const label = pieces.slice(link.at);
|
|
828
|
+
const words = label.every((p) => typeof p === "string")
|
|
829
|
+
? label.join("")
|
|
830
|
+
: "";
|
|
831
|
+
if (words !== "" &&
|
|
832
|
+
!words.includes("]") &&
|
|
833
|
+
LINK_HREF.test(link.href) &&
|
|
834
|
+
!/[\s)]/.test(link.href)) {
|
|
835
|
+
pieces.splice(link.at, label.length, `[${words}](${link.href})`);
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
notCarried.add("a");
|
|
839
|
+
}
|
|
840
|
+
link = undefined;
|
|
841
|
+
};
|
|
842
|
+
let from = 0;
|
|
843
|
+
for (let lt = value.indexOf("<"); lt !== -1; lt = value.indexOf("<", Math.max(lt + 1, from))) {
|
|
844
|
+
if (value.startsWith("<!--", lt)) {
|
|
845
|
+
text(value.slice(from, lt));
|
|
846
|
+
const end = value.indexOf("-->", lt + 4);
|
|
847
|
+
from = end === -1 ? value.length : end + 3;
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
const tag = readTag(value, lt);
|
|
851
|
+
if (tag === undefined)
|
|
852
|
+
continue;
|
|
853
|
+
text(value.slice(from, lt));
|
|
854
|
+
from = tag.end;
|
|
855
|
+
const { name } = tag;
|
|
856
|
+
if (!CARRIED_ELEMENTS.has(name))
|
|
857
|
+
notCarried.add(name);
|
|
858
|
+
if (DROPPED_ELEMENTS.has(name)) {
|
|
859
|
+
if (!tag.close && !tag.selfClosing) {
|
|
860
|
+
const close = new RegExp(`</${name}[ \\t\\r\\n\\f]*>`, "gi");
|
|
861
|
+
close.lastIndex = from;
|
|
862
|
+
from = close.exec(value) === null ? value.length : close.lastIndex;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
else if (name === "a") {
|
|
866
|
+
closeLink();
|
|
867
|
+
if (!tag.close) {
|
|
868
|
+
link = { at: pieces.length, href: decodeEntities(tag.href ?? "") };
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
else if (name === "br") {
|
|
872
|
+
pieces.push(NEWLINE);
|
|
873
|
+
}
|
|
874
|
+
else if (name === "li") {
|
|
875
|
+
// An item with no text leaves no bullet.
|
|
876
|
+
if (tag.close && pieces.at(-1) === "- ")
|
|
877
|
+
pieces.pop();
|
|
878
|
+
pieces.push(PARAGRAPH);
|
|
879
|
+
if (!tag.close)
|
|
880
|
+
pieces.push("- ");
|
|
881
|
+
}
|
|
882
|
+
else if (BLOCK_ELEMENTS.has(name)) {
|
|
883
|
+
pieces.push(PARAGRAPH);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
text(value.slice(from));
|
|
887
|
+
closeLink();
|
|
888
|
+
// Spaces never touch a newline; a run holding a paragraph break, or two or
|
|
889
|
+
// more newlines, is one blank line; the ends are trimmed.
|
|
890
|
+
const parts = [];
|
|
891
|
+
let run;
|
|
892
|
+
for (const piece of pieces) {
|
|
893
|
+
if (typeof piece !== "string") {
|
|
894
|
+
if (run === undefined) {
|
|
895
|
+
while (parts.length > 0) {
|
|
896
|
+
const last = parts[parts.length - 1].replace(/ +$/, "");
|
|
897
|
+
if (last !== "") {
|
|
898
|
+
parts[parts.length - 1] = last;
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
parts.pop();
|
|
902
|
+
}
|
|
903
|
+
run = { newlines: 0, paragraph: false };
|
|
904
|
+
}
|
|
905
|
+
if (piece === PARAGRAPH)
|
|
906
|
+
run.paragraph = true;
|
|
907
|
+
else
|
|
908
|
+
run.newlines += 1;
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
let words = piece;
|
|
912
|
+
if (run !== undefined) {
|
|
913
|
+
words = words.replace(/^ +/, "");
|
|
914
|
+
if (words === "")
|
|
915
|
+
continue;
|
|
916
|
+
if (parts.length > 0) {
|
|
917
|
+
parts.push(run.paragraph || run.newlines > 1 ? "\n\n" : "\n");
|
|
918
|
+
}
|
|
919
|
+
run = undefined;
|
|
920
|
+
}
|
|
921
|
+
else if (words.startsWith(" ") && parts.at(-1)?.endsWith(" ") === true) {
|
|
922
|
+
words = words.slice(1);
|
|
923
|
+
}
|
|
924
|
+
parts.push(words);
|
|
925
|
+
}
|
|
926
|
+
return {
|
|
927
|
+
text: parts
|
|
928
|
+
.join("")
|
|
929
|
+
.replace(/^[ \n]+|[ \n]+$/g, "")
|
|
930
|
+
.replace(/<(?=[A-Za-z!/?])/g, "<\u2060"),
|
|
931
|
+
notCarried: [...notCarried].sort(),
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
const isPlainObject = (value) => value !== null &&
|
|
935
|
+
typeof value === "object" &&
|
|
936
|
+
[Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
937
|
+
/**
|
|
938
|
+
* Each section with every string in its `data` that holds a tag converted by
|
|
939
|
+
* `inlineHtmlToMarkdown` (#10804 D2); keys and tag-free strings are kept as
|
|
940
|
+
* they are. `notCarried` is the sorted union of the names it did not carry.
|
|
941
|
+
*/
|
|
942
|
+
export function sectionsWithoutHtml(sections) {
|
|
943
|
+
const names = new Set();
|
|
944
|
+
const walk = (value) => {
|
|
945
|
+
if (typeof value === "string") {
|
|
946
|
+
const out = inlineHtmlToMarkdown(value);
|
|
947
|
+
for (const name of out.notCarried)
|
|
948
|
+
names.add(name);
|
|
949
|
+
return out.text;
|
|
950
|
+
}
|
|
951
|
+
if (Array.isArray(value))
|
|
952
|
+
return value.map(walk);
|
|
953
|
+
if (!isPlainObject(value))
|
|
954
|
+
return value;
|
|
955
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, walk(child)]));
|
|
956
|
+
};
|
|
957
|
+
return {
|
|
958
|
+
sections: sections.map((section) => isPlainObject(section) && "data" in section
|
|
959
|
+
? { ...section, data: walk(section.data) }
|
|
960
|
+
: section),
|
|
961
|
+
notCarried: [...names].sort(),
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
/** The first string or key rule of the hub's that `value` breaks, at `path`. */
|
|
965
|
+
function refusalAt(value, path) {
|
|
966
|
+
if (typeof value === "string") {
|
|
967
|
+
// A URL parser drops tabs, newlines and leading control characters.
|
|
968
|
+
// eslint-disable-next-line no-control-regex -- the control range is the point
|
|
969
|
+
const scheme = value.replace(/[\u0000- ]/g, "").toLowerCase();
|
|
970
|
+
const message = value.length > SECTION_STRING_MAX
|
|
971
|
+
? `A section string may hold at most ${SECTION_STRING_MAX} characters`
|
|
972
|
+
: HTML_TAG.test(value)
|
|
973
|
+
? "A section string may not hold an HTML tag"
|
|
974
|
+
: SCRIPT_SCHEME.test(scheme)
|
|
975
|
+
? "A section string may not use a script-bearing URL scheme"
|
|
976
|
+
: undefined;
|
|
977
|
+
return message === undefined ? undefined : `${message} (${path})`;
|
|
978
|
+
}
|
|
979
|
+
if (value === null || typeof value !== "object")
|
|
980
|
+
return undefined;
|
|
981
|
+
const array = Array.isArray(value);
|
|
982
|
+
for (const [key, child] of Object.entries(value)) {
|
|
983
|
+
if (!array && (key.length > SECTION_KEY_MAX || HTML_TAG.test(key))) {
|
|
984
|
+
return `A section key must be a short plain name (${path}.${key})`;
|
|
985
|
+
}
|
|
986
|
+
const refusal = refusalAt(child, array ? `${path}[${key}]` : `${path}.${key}`);
|
|
987
|
+
if (refusal !== undefined)
|
|
988
|
+
return refusal;
|
|
989
|
+
}
|
|
990
|
+
return undefined;
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* The first section-string or key rule of the hub's structure write
|
|
994
|
+
* (`checkSectionData`) the layout breaks, as `<message> (sections[i].data<path>)`,
|
|
995
|
+
* or `undefined` (#10804 D5). The depth and size bounds are not restated.
|
|
996
|
+
*/
|
|
997
|
+
export function layoutRefusal(layout) {
|
|
998
|
+
for (const [index, section] of layout.sections.entries()) {
|
|
999
|
+
if (!isPlainObject(section) || section["data"] === undefined)
|
|
1000
|
+
continue;
|
|
1001
|
+
const refusal = refusalAt(section["data"], `sections[${index}].data`);
|
|
1002
|
+
if (refusal !== undefined)
|
|
1003
|
+
return refusal;
|
|
1004
|
+
}
|
|
1005
|
+
return undefined;
|
|
1006
|
+
}
|
|
534
1007
|
// ── The importer ────────────────────────────────────────────────────────────
|
|
535
1008
|
/** One whitespace-collapsed line of at most `ADAPTER_REASON_MAX` characters. */
|
|
536
1009
|
function adapterReason(err, stage = "adapter") {
|
|
@@ -540,14 +1013,19 @@ function adapterReason(err, stage = "adapter") {
|
|
|
540
1013
|
/**
|
|
541
1014
|
* What the page stores (#10534): with `sections` and a codec, its words after
|
|
542
1015
|
* the body's own text and its sections as the layout; else the page as read.
|
|
1016
|
+
* Both come from one array, its inline HTML converted (#10804 D2).
|
|
543
1017
|
*/
|
|
544
1018
|
function convert(source, codec) {
|
|
545
|
-
const { sections, ...frontmatter } = source.frontmatter;
|
|
546
|
-
if (!Array.isArray(
|
|
547
|
-
|
|
1019
|
+
const { sections: authored, ...frontmatter } = source.frontmatter;
|
|
1020
|
+
if (!Array.isArray(authored) ||
|
|
1021
|
+
authored.length === 0 ||
|
|
548
1022
|
codec === undefined) {
|
|
549
|
-
return {
|
|
1023
|
+
return {
|
|
1024
|
+
content: { ...source.frontmatter, body: source.body },
|
|
1025
|
+
notCarried: [],
|
|
1026
|
+
};
|
|
550
1027
|
}
|
|
1028
|
+
const { sections, notCarried } = sectionsWithoutHtml(authored);
|
|
551
1029
|
let words;
|
|
552
1030
|
try {
|
|
553
1031
|
words = codec(sections);
|
|
@@ -562,6 +1040,7 @@ function convert(source, codec) {
|
|
|
562
1040
|
body: lead === "" ? words : `${lead}\n\n${words}`,
|
|
563
1041
|
},
|
|
564
1042
|
layout: { kind: "sections", sections },
|
|
1043
|
+
notCarried,
|
|
565
1044
|
};
|
|
566
1045
|
}
|
|
567
1046
|
export async function importMdx(opts) {
|
|
@@ -583,15 +1062,17 @@ export async function importMdx(opts) {
|
|
|
583
1062
|
? { pages: [], skipped: [] }
|
|
584
1063
|
: planEditorial(editorialRoot, types, defaultLocale);
|
|
585
1064
|
const parseFrontmatter = opts.adapters.parseFrontmatter;
|
|
1065
|
+
const rules = opts.adapters.siteFields;
|
|
586
1066
|
// #10701 D3 — one route path holds one page, so an editorial index the run
|
|
587
|
-
// will write supersedes
|
|
588
|
-
// scaffold's teaser), which the create would refuse `slug_taken`.
|
|
1067
|
+
// will write supersedes the content page of its type at the surface's own
|
|
1068
|
+
// slug (the scaffold's teaser), which the create would refuse `slug_taken`.
|
|
1069
|
+
// Any other slug of the type has a link of its own (#10805 D5).
|
|
589
1070
|
const indexTypes = new Set(parseFrontmatter === undefined
|
|
590
1071
|
? []
|
|
591
1072
|
: editorial.pages
|
|
592
1073
|
.filter((p) => p.invalid === undefined && INDEX_TYPES.has(p.type))
|
|
593
1074
|
.map((p) => p.type));
|
|
594
|
-
const superseded = (p) => indexTypes.has(p.type) && p.slug
|
|
1075
|
+
const superseded = (p) => indexTypes.has(p.type) && p.slug === INDEX_OWN_SLUG.get(p.type);
|
|
595
1076
|
const pages = [
|
|
596
1077
|
...content.pages.filter((p) => !superseded(p)),
|
|
597
1078
|
...editorial.pages,
|
|
@@ -648,15 +1129,15 @@ export async function importMdx(opts) {
|
|
|
648
1129
|
}
|
|
649
1130
|
let read;
|
|
650
1131
|
try {
|
|
651
|
-
read = editorialSource(page, parseFrontmatter(readFileSync(join(editorialRoot, page.path), "utf8")));
|
|
1132
|
+
read = editorialSource(page, parseFrontmatter(readFileSync(join(editorialRoot, page.path), "utf8")), rules);
|
|
652
1133
|
}
|
|
653
1134
|
catch (err) {
|
|
654
1135
|
fail(adapterReason(err, "editorial"));
|
|
655
1136
|
continue;
|
|
656
1137
|
}
|
|
657
1138
|
source = read.source;
|
|
658
|
-
if (read.
|
|
659
|
-
onLine(`${page.path}: not imported — ${read.
|
|
1139
|
+
if (read.notSent.length > 0) {
|
|
1140
|
+
onLine(`${page.path}: not imported — ${notSentList(read.notSent)}`);
|
|
660
1141
|
}
|
|
661
1142
|
}
|
|
662
1143
|
else {
|
|
@@ -679,13 +1160,37 @@ export async function importMdx(opts) {
|
|
|
679
1160
|
fail(converted.reason);
|
|
680
1161
|
continue;
|
|
681
1162
|
}
|
|
1163
|
+
if (converted.notCarried.length > 0) {
|
|
1164
|
+
onLine(`${outcomePath(page)}: inline HTML not carried — ${converted.notCarried.join(", ")} (the layout holds no HTML; their text is kept)`);
|
|
1165
|
+
}
|
|
682
1166
|
if (converted.layout === undefined &&
|
|
683
1167
|
Array.isArray(source.frontmatter["sections"]) &&
|
|
684
1168
|
source.frontmatter["sections"].length > 0) {
|
|
685
1169
|
onLine(`${page.path}: keeps its words in sections — the site's ${VALIDATION_PACKAGE} has no words codec; update its dependencies and re-run`);
|
|
686
1170
|
}
|
|
687
|
-
|
|
1171
|
+
// #10804 D5 — a layout the hub would refuse fails here, in a dry run and
|
|
1172
|
+
// under --apply alike, before its words are saved.
|
|
1173
|
+
const refusal = converted.layout === undefined
|
|
1174
|
+
? undefined
|
|
1175
|
+
: layoutRefusal(converted.layout);
|
|
1176
|
+
if (refusal !== undefined) {
|
|
1177
|
+
fail(`layout: ${refusal}`);
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
const result = await writePage(t, page, defaultLocale, converted.content, converted.layout, rules?.publicPageTags);
|
|
688
1181
|
halt = result.halt;
|
|
1182
|
+
// #10806 D4 — an older hub strips what it has no member for; say so.
|
|
1183
|
+
// An answer that carries no content says nothing about what was kept.
|
|
1184
|
+
const dropped = rules === undefined ||
|
|
1185
|
+
page.root !== "editorial" ||
|
|
1186
|
+
"reason" in result.outcome ||
|
|
1187
|
+
result.stored === null ||
|
|
1188
|
+
typeof result.stored !== "object"
|
|
1189
|
+
? []
|
|
1190
|
+
: notKept(converted.content, result.stored, rules);
|
|
1191
|
+
if (dropped.length > 0) {
|
|
1192
|
+
onLine(`${page.path}: not kept by the hub: ${dropped.join(", ")} (the hub predates site fields; update it and re-run)`);
|
|
1193
|
+
}
|
|
689
1194
|
if (opts.publish !== true || "reason" in result.outcome) {
|
|
690
1195
|
record(result.outcome);
|
|
691
1196
|
continue;
|
|
@@ -773,16 +1278,19 @@ export async function loadSiteMdxAdapter(siteRoot) {
|
|
|
773
1278
|
!types.every((type) => typeof type === "string")) {
|
|
774
1279
|
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.`);
|
|
775
1280
|
}
|
|
776
|
-
// The codec the adapter's own validation package carries (#10534)
|
|
777
|
-
// that predates
|
|
778
|
-
|
|
1281
|
+
// The codec the adapter's own validation package carries (#10534), and its
|
|
1282
|
+
// site-field rule (#10806 D4); one that predates either, or none, leaves
|
|
1283
|
+
// pages unconverted or site fields unsent, and the run says so.
|
|
1284
|
+
let validation = {};
|
|
779
1285
|
try {
|
|
780
|
-
const
|
|
781
|
-
|
|
1286
|
+
const path = createRequire(entry).resolve(VALIDATION_PACKAGE);
|
|
1287
|
+
validation = (await import(pathToFileURL(path).href));
|
|
782
1288
|
}
|
|
783
1289
|
catch {
|
|
784
|
-
// Not resolvable from the adapter: no codec.
|
|
1290
|
+
// Not resolvable from the adapter: no codec and no site-field rule.
|
|
785
1291
|
}
|
|
1292
|
+
const codec = validation["sectionsToMarkdown"];
|
|
1293
|
+
const { siteFieldRefusal: refusal, publicPageTags, SITE_FIELDS_MAX_KEYS: maxKeys, SITE_FIELDS_MAX_BYTES: maxBytes, } = validation;
|
|
786
1294
|
// The adapter's own frontmatter parser (#10701 D1), for the editorial tree;
|
|
787
1295
|
// none leaves every editorial page failed, and the run says so.
|
|
788
1296
|
let matter;
|
|
@@ -798,6 +1306,17 @@ export async function loadSiteMdxAdapter(siteRoot) {
|
|
|
798
1306
|
...(typeof codec === "function" && {
|
|
799
1307
|
sectionsToMarkdown: codec,
|
|
800
1308
|
}),
|
|
1309
|
+
...(typeof refusal === "function" &&
|
|
1310
|
+
typeof publicPageTags === "function" &&
|
|
1311
|
+
typeof maxKeys === "number" &&
|
|
1312
|
+
typeof maxBytes === "number" && {
|
|
1313
|
+
siteFields: {
|
|
1314
|
+
refusal: refusal,
|
|
1315
|
+
publicPageTags: publicPageTags,
|
|
1316
|
+
maxKeys,
|
|
1317
|
+
maxBytes,
|
|
1318
|
+
},
|
|
1319
|
+
}),
|
|
801
1320
|
...(typeof matter === "function" && {
|
|
802
1321
|
parseFrontmatter: (raw) => {
|
|
803
1322
|
const { data, content } = matter(raw);
|