create-ailk 0.29.1 → 0.31.0

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