create-ailk 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +108 -4
  2. package/component-catalog.json +524 -8
  3. package/component-catalog.md +8 -5
  4. package/dist/body-heading.d.ts +13 -0
  5. package/dist/body-heading.js +19 -5
  6. package/dist/cli.js +99 -0
  7. package/dist/cutover.d.ts +114 -0
  8. package/dist/cutover.js +511 -0
  9. package/dist/design-presets.js +20 -4
  10. package/dist/export-content.d.ts +55 -0
  11. package/dist/export-content.js +303 -0
  12. package/dist/inventory.d.ts +216 -0
  13. package/dist/inventory.js +833 -0
  14. package/dist/language-import.d.ts +127 -0
  15. package/dist/language-import.js +451 -0
  16. package/dist/lib/env-catalog.js +1 -1
  17. package/dist/lib/import-mdx.d.ts +38 -0
  18. package/dist/lib/import-mdx.js +445 -65
  19. package/dist/lib/render-comparison.d.ts +251 -0
  20. package/dist/lib/render-comparison.js +573 -0
  21. package/dist/mdx-braces.d.ts +73 -0
  22. package/dist/mdx-braces.js +285 -0
  23. package/dist/migrate-content.d.ts +6 -2
  24. package/dist/migrate-content.js +62 -14
  25. package/dist/programmatic.js +2 -8
  26. package/dist/seed-workspace.d.ts +3 -1
  27. package/dist/seed-workspace.js +45 -16
  28. package/dist/site-manifest.js +6 -38
  29. package/dist/site-roster.d.ts +46 -3
  30. package/dist/site-roster.js +124 -4
  31. package/dist/surfaces.d.ts +8 -0
  32. package/dist/surfaces.js +16 -0
  33. package/dist/topology.d.ts +12 -0
  34. package/dist/topology.js +24 -10
  35. package/dist/update.d.ts +21 -1
  36. package/dist/update.js +142 -11
  37. package/package.json +3 -3
  38. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  39. package/templates/.claude/hooks/content-source-guard.sh +75 -0
  40. package/templates/.claude/settings.json +9 -0
  41. package/templates/.github/workflows/ci.yml +4 -0
  42. package/templates/CLAUDE.md +5 -0
  43. package/templates/apps/mcp/.env.example +7 -1
  44. package/templates/apps/mcp/CLAUDE.md +8 -4
  45. package/templates/apps/web/.env.example +4 -2
  46. package/templates/apps/web/CLAUDE.md +16 -5
  47. package/templates/apps/web/__tests__/setup.ts +9 -0
  48. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +156 -4
  49. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
  50. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  51. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
  52. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
  53. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
  54. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
  55. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
  56. package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
  57. package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
  58. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
  59. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
  60. package/templates/apps/web/app/[locale]/layout.tsx +122 -27
  61. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
  62. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  63. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
  64. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
  67. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
  68. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
  69. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
  70. package/templates/apps/web/app/llms.txt/route.ts +16 -9
  71. package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
  72. package/templates/apps/web/jest.config.cjs +46 -1
  73. package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
  74. package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
  75. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +93 -0
  76. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
  77. package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
  78. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +145 -11
  79. package/templates/apps/web/lib/bot-check.ts +113 -13
  80. package/templates/apps/web/lib/in-page-magnet.ts +4 -1
  81. package/templates/apps/web/lib/page-chrome.ts +65 -5
  82. package/templates/apps/web/lib/relay.ts +22 -3
  83. package/templates/apps/web/lib/site-identity.ts +75 -24
  84. package/templates/apps/web/messages/en.json +5 -1
  85. package/templates/apps/web/next.config.mjs +15 -1
  86. package/templates/apps/web/package.json +1 -1
  87. package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
  88. package/templates/apps/web/scripts/content-snapshot.ts +33 -0
  89. package/templates/apps/web/src/lib/routes.ts +5 -3
  90. package/templates/package.json +3 -1
  91. package/templates/tools/body-heading.ts +440 -0
  92. package/templates/tools/check-content-source.ts +108 -0
  93. package/templates/tools/check-mdx-body.ts +123 -0
  94. package/templates/tools/mdx-braces.ts +303 -0
  95. package/versions.json +48 -48
@@ -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 checkOptions(opts, defaultLocale) {
59
- let isDir;
115
+ export function isDirectory(path) {
60
116
  try {
61
- isDir = statSync(opts.contentRoot).isDirectory();
117
+ return statSync(path).isDirectory();
62
118
  }
63
119
  catch {
64
- isDir = false;
120
+ return false;
65
121
  }
66
- if (!isDir)
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
- return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
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(path, reason, halt) {
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(path, answer) {
457
+ function refusedAnswer(at, answer) {
253
458
  if ("network" in answer)
254
- return failed(path, answer.network);
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(path, reason, `not sent: the API refused the key (${reason})`)
258
- : failed(path, reason);
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(path, answer, locale) {
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(path, `${answer.status} unknown`);
473
+ return failed(at, `${answer.status} unknown`);
269
474
  }
270
- return failed(path, `the API does not serve locale ${locale}`, `not sent: the API does not serve locale ${locale}`);
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 address = `${t.base}/content/${encodeURIComponent(type)}/${encodeURIComponent(slug)}?locale=${encodeURIComponent(locale)}`;
275
- const done = (result) => ({
276
- outcome: { path, result, type, slug },
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(path, read);
507
+ return refusedAnswer(page, read);
282
508
  if (read.status === 200) {
283
- const mismatch = wrongLocale(path, read, locale);
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(path, "200 unknown");
289
- if (canonical(read.data["content"]) === canonical(content)) {
290
- return done("unchanged");
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(path, saved);
297
- return wrongLocale(path, saved, locale) ?? done("updated");
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(path, read);
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(path, created);
537
+ return refusedAnswer(page, created);
307
538
  if (created.status === 201) {
308
- return wrongLocale(path, created, locale) ?? done("created");
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(path, created);
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.path}: ${outcome.result} — ${outcome.reason}`
322
- : `${outcome.path}: ${outcome.result}`;
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 `adapter: ${message.replace(/\s+/g, " ").trim().slice(0, ADAPTER_REASON_MAX)}`;
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 { pages, skipped } = plan(contentRoot, opts.adapters.SCHEMA_ORG_TYPES, defaultLocale);
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
- record({
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.path);
671
+ addresses.set(address, outcomePath(page));
375
672
  if (halt !== undefined) {
376
- record({ path: page.path, result: "failed", reason: halt });
673
+ fail(halt);
377
674
  continue;
378
675
  }
379
676
  let source;
380
- try {
381
- source = page.localeTree
382
- ? await reader.getPageSource(page.type, page.slug, defaultLocale)
383
- : await reader.getPageSource(page.type, page.slug);
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
- catch (err) {
386
- record({ path: page.path, result: "failed", reason: adapterReason(err) });
387
- continue;
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
- record({
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 result = await writePage(t, page, defaultLocale, {
398
- ...source.frontmatter,
399
- body: source.body,
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
- record(result.outcome);
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 { pages: outcomes, counts };
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
- return mod;
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
  }