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.
Files changed (86) hide show
  1. package/README.md +50 -8
  2. package/component-catalog.json +12 -1
  3. package/component-catalog.md +6 -5
  4. package/dist/body-heading.d.ts +13 -0
  5. package/dist/body-heading.js +19 -5
  6. package/dist/cutover.d.ts +6 -1
  7. package/dist/cutover.js +23 -6
  8. package/dist/export-content.d.ts +6 -3
  9. package/dist/export-content.js +79 -10
  10. package/dist/language-import.d.ts +16 -3
  11. package/dist/language-import.js +27 -11
  12. package/dist/lib/import-mdx.d.ts +42 -0
  13. package/dist/lib/import-mdx.js +584 -65
  14. package/dist/lib/render-comparison.d.ts +51 -0
  15. package/dist/lib/render-comparison.js +205 -6
  16. package/dist/mdx-braces.d.ts +73 -0
  17. package/dist/mdx-braces.js +285 -0
  18. package/dist/migrate-content.d.ts +1 -1
  19. package/dist/migrate-content.js +4 -2
  20. package/dist/programmatic.js +2 -8
  21. package/dist/seed-flows.d.ts +1 -1
  22. package/dist/seed-flows.js +9 -4
  23. package/dist/site-manifest.js +6 -38
  24. package/dist/site-roster.d.ts +46 -3
  25. package/dist/site-roster.js +124 -4
  26. package/dist/surfaces.d.ts +8 -0
  27. package/dist/surfaces.js +17 -0
  28. package/dist/update.d.ts +21 -1
  29. package/dist/update.js +142 -11
  30. package/package.json +3 -3
  31. package/templates/.github/workflows/ci.yml +2 -0
  32. package/templates/apps/api/CLAUDE.md +7 -5
  33. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  34. package/templates/apps/mcp/.env.example +7 -1
  35. package/templates/apps/web/__tests__/setup.ts +9 -0
  36. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
  37. package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
  38. package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
  39. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
  40. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
  41. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -0
  43. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  44. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  45. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
  46. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
  47. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +21 -0
  48. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
  49. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
  50. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
  51. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
  52. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
  53. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
  54. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
  55. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
  56. package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
  57. package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
  58. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
  59. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
  60. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  61. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
  62. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
  63. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
  64. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -20
  67. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
  68. package/templates/apps/web/auth.ts +22 -6
  69. package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
  70. package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
  71. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +3 -0
  72. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +134 -10
  73. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +20 -0
  74. package/templates/apps/web/lib/account-config.ts +52 -14
  75. package/templates/apps/web/lib/auth-email.ts +87 -5
  76. package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
  77. package/templates/apps/web/messages/en.json +26 -1
  78. package/templates/apps/web/src/lib/routes.ts +5 -3
  79. package/templates/database/seed.ts +7 -7
  80. package/templates/package.json +2 -1
  81. package/templates/tools/body-heading.ts +440 -0
  82. package/templates/tools/check-mdx-body.ts +123 -0
  83. package/templates/tools/mdx-braces.ts +303 -0
  84. package/versions.json +48 -48
  85. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
  86. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +0 -104
package/README.md CHANGED
@@ -393,6 +393,15 @@ merge-file` against the recorded version's own copy as the base. A clean
393
393
  **Added**, unless it sits under a route or directory the site deliberately
394
394
  does not carry (a gated-off surface, a spoke exclusion, or a directory you
395
395
  deleted entirely) — those are left alone.
396
+ - A surface no module owns (such as `projects`) that a page row of the
397
+ site's `.ailk-site-manifest.json` names, but `architecture.yaml` does not
398
+ declare, is in scope for adding: its route files are **Added** and
399
+ recorded, and `architecture.yaml` is regenerated to declare it. Reported
400
+ as **Surfaces declared from the roster**.
401
+ - A code-route row (`kind: "route"`) of `.ailk-site-manifest.json` that no
402
+ page file under `apps/web/app/[locale]/` serves (a spoke's `/sign-in`) is
403
+ dropped from the roster after the writes, every other byte of the file
404
+ kept. Reported as **Roster rows dropped, no route serves them**.
396
405
 
397
406
  A file outside the gated-off surfaces and spoke exclusions the site's scope
398
407
  no longer covers is **Skipped**, as is one that exists on disk but was never
@@ -425,7 +434,8 @@ never a shell. Fetching an old template version (when it is not the running
425
434
  package's own) reads the public npm registry at
426
435
  `https://registry.npmjs.org`, overridable with `AILK_NPM_REGISTRY_URL` for a
427
436
  mirror. `update` never touches content, except to give an mdx-body page
428
- with no `# ` heading its title and description (below), nor env files,
437
+ with no `# ` heading its title and description and to escape a bare `{` on
438
+ an mdx-body page (both below), nor env files,
429
439
  `.claude/`, lockfiles or any `package.json` — those stay `add-module`'s and
430
440
  the consumer version bump's job. Commit before running `update`, and review the diff after: a
431
441
  clean merge is still worth reading.
@@ -444,6 +454,27 @@ folded `>` block, say), is listed under **Needs review** for you to fix.
444
454
  Which pages are mdx-body is read from your own `apps/web/app` routes, and the
445
455
  step runs again harmlessly on every update.
446
456
 
457
+ **Placeholder braces (#10773).** MDX reads a `{` in a page body as
458
+ JavaScript, so on an mdx-body page a bare placeholder such as
459
+ `{First engagement}` fails the page with a 500, and one that parses
460
+ (`{Tagline}`) renders as nothing. After the heading step, `update` writes a
461
+ `\` before every bare `{` on each mdx-body page — never in frontmatter, fenced
462
+ or inline code, a JSX tag or a `{/* … */}` comment — and keeps the page's
463
+ change only when your site's own MDX parser (the `@mdx-js/mdx` its
464
+ `next-mdx-remote` uses) then reads the page with no expression left but
465
+ comments. Each such page is listed under **Placeholder brace escaped**; one
466
+ byte per brace changes and nothing else. A page it cannot escape safely (a
467
+ mangled `{/ … /}` comment, or another MDX error the escape does not reach)
468
+ is left alone and listed under **Needs review** with the line, and so is
469
+ every page with a bare brace while the site's dependencies are not installed
470
+ (run `pnpm install`, then `update` again). A fresh scaffold also runs
471
+ `pnpm mdx-body:check` in `preflight` and CI, which names each bare brace by
472
+ file, line and column before a deploy. A site scaffolded before it gains the
473
+ check by copying `tools/check-mdx-body.ts`, `tools/mdx-braces.ts` and
474
+ `tools/body-heading.ts` from a fresh scaffold and adding
475
+ `"mdx-body:check": "tsx tools/check-mdx-body.ts"` to its `package.json`
476
+ scripts and `preflight`.
477
+
447
478
  ### Adopting an existing site
448
479
 
449
480
  A site that create-ailk did not scaffold, or that was scaffolded before
@@ -625,13 +656,24 @@ a workspace: each `content/en/<Type>/<slug>.mdx` page, and each legacy
625
656
 
626
657
  When the site has the docs-blog tree `apps/web/content`, its posts and docs
627
658
  are imported too (#10701): `apps/web/content/en/blog/<slug>.mdx` and
628
- `blog/<slug>/index.mdx` become `BlogPosting/<slug>`, the same under `docs`
629
- become `DocsDetail/<slug>`, and each directory's own `index.mdx` becomes its
630
- `BlogList/index` or `DocsList/index` — replacing a `content/` teaser of that
631
- type. Their fumadocs frontmatter is mapped to the stored fields (`date` →
632
- `publishedAt`, `updated` → `updatedAt`); keys the store does not keep from a
633
- caller (`featured`, `sidebar`, `tags`) are not sent, and `--apply` names them
634
- in one line per file. Each outcome carries `root: "content"` or
659
+ `blog/<slug>/index.mdx` become `BlogPosting/<slug>`, or one series down,
660
+ `blog/<series>/<post>.mdx` (or `<post>/index.mdx`), as a `BlogPosting/<post>`
661
+ whose `parent` is the series (#10775) — served at `/blog/<series>/<post>`, the
662
+ URL its file serves at; the same under `docs` become `DocsDetail/<slug>`, and
663
+ each directory's own `index.mdx` becomes its `BlogList/index` or
664
+ `DocsList/index` — replacing a `content/` teaser of that type. A blog post's
665
+ `parent` comes from its path, never its frontmatter, and a file the walk skips
666
+ is listed with the rule it failed. Their fumadocs frontmatter is mapped to
667
+ the stored fields (`date` →
668
+ `publishedAt`, `updated` → `updatedAt`); every other key — the template's
669
+ `featured` and `sidebar`, or a site's own such as `imageAlt` or `sources` —
670
+ is sent in the page's `siteFields` under its own name, and a blog post's
671
+ `tags` are sent as its categories (#10806). A key the store cannot keep is
672
+ named with its reason under `--apply`, in one line per file:
673
+ `<path>: not imported — <key> (<reason>); …`. A hub that predates site
674
+ fields and drops them is named too: `<path>: not kept by the hub: <keys> (…)`.
675
+ `export-content` writes `siteFields` back as top-level frontmatter keys.
676
+ Each outcome carries `root: "content"` or
635
677
  `root: "editorial"`, and an editorial line is printed prefixed `editorial: `.
636
678
 
637
679
  ```bash
@@ -17,7 +17,7 @@
17
17
  "importPath": "@working-theory/ui/blocks",
18
18
  "rootBarrel": "@working-theory/ui",
19
19
  "sourceRoot": "packages/ui/src/blocks",
20
- "count": 107
20
+ "count": 108
21
21
  },
22
22
  {
23
23
  "name": "chrome",
@@ -1695,6 +1695,17 @@
1695
1695
  "descriptionGrade": "strong",
1696
1696
  "sectionType": null
1697
1697
  },
1698
+ {
1699
+ "name": "ScoreBar",
1700
+ "tier": "blocks",
1701
+ "sourcePath": "packages/ui/src/blocks/ScoreBar.tsx",
1702
+ "importPath": "@working-theory/ui/blocks",
1703
+ "exported": true,
1704
+ "description": "One labelled score bar on a scored magnet's results page: a label (the overall score's, or a dimension's name), the points out of the most a respondent could score, the Level name that share falls in, and one bar filled to the evaluator's own `pct` (`evaluateWaitlistScoring` returns `{ points, max, pct, level }` for the total and per dimension).",
1705
+ "descriptionSource": "doc-comment",
1706
+ "descriptionGrade": "strong",
1707
+ "sectionType": null
1708
+ },
1698
1709
  {
1699
1710
  "name": "SectionActionRow",
1700
1711
  "tier": "blocks",
@@ -15,13 +15,13 @@ The component inventory of `@working-theory/ui` and the shells of `@working-theo
15
15
  3. Check the Primitives, Panels and Shells tables too — a low-level control, a collapsible layout container, or a route-level frame lives there, not under Sections or Blocks.
16
16
  4. Only then build. If you do build, you are declaring the capability is genuinely absent.
17
17
 
18
- Inventory: **324 component files** across 16 tiers — 60 sections, 107 blocks, 19 chrome, 79 primitives, 6 panels, 8 shells, 5 docs-blog-blocks, 1 docs-blog-chrome, 2 docs-blog-shells, 2 scheduling-sections, 6 scheduling-blocks, 5 signal-collectors-sections, 9 signal-collectors-blocks, 2 commerce-sections, 12 commerce-blocks, 1 commerce-panels. 321 distinct names: `SchedulerEmbed`, `Separator`, `Tabs` exist in more than one tier as genuinely different components, listed once per tier with its own source path.
18
+ Inventory: **325 component files** across 16 tiers — 60 sections, 108 blocks, 19 chrome, 79 primitives, 6 panels, 8 shells, 5 docs-blog-blocks, 1 docs-blog-chrome, 2 docs-blog-shells, 2 scheduling-sections, 6 scheduling-blocks, 5 signal-collectors-sections, 9 signal-collectors-blocks, 2 commerce-sections, 12 commerce-blocks, 1 commerce-panels. 322 distinct names: `SchedulerEmbed`, `Separator`, `Tabs` exist in more than one tier as genuinely different components, listed once per tier with its own source path.
19
19
 
20
- Description coverage: **320/324** components carry a capability description derived from source — 45 from the section registry's hand-authored when-to-use text, 240 from the component's own doc-comment header, 35 from its tier barrel's roster line. **4** have none; they are listed under [Undescribed components](#undescribed-components) rather than given a sentence invented from their name.
20
+ Description coverage: **321/325** components carry a capability description derived from source — 45 from the section registry's hand-authored when-to-use text, 241 from the component's own doc-comment header, 35 from its tier barrel's roster line. **4** have none; they are listed under [Undescribed components](#undescribed-components) rather than given a sentence invented from their name.
21
21
 
22
- Of those, **286** are *strong* — a capability sentence you could match a requirement against — and **34** are *weak*: either one short clause, or a long one that spends itself on wiring (client boundaries, class names, which primitives it composes) instead of on what the thing is for. Weak rows are marked `(weak)` and listed in full under [Weak descriptions](#weak-descriptions). Coverage is 99%, but **findable-without-luck coverage is 88%**.
22
+ Of those, **287** are *strong* — a capability sentence you could match a requirement against — and **34** are *weak*: either one short clause, or a long one that spends itself on wiring (client boundaries, class names, which primitives it composes) instead of on what the thing is for. Weak rows are marked `(weak)` and listed in full under [Weak descriptions](#weak-descriptions). Coverage is 99%, but **findable-without-luck coverage is 88%**.
23
23
 
24
- Per tier, strong out of total: **Sections** 59/60 · **Blocks** 99/107 · **Chrome** 10/19 · **Primitives** 64/79 (4 undescribed) · **Panels** 5/6 · **Shells** 7/8 · **Docs + Blog Blocks** 5/5 · **Docs + Blog Chrome** 1/1 · **Docs + Blog Shells** 0/2 · **Scheduling Sections** 2/2 · **Scheduling Blocks** 6/6 · **Signal-Collectors Sections** 5/5 · **Signal-Collectors Blocks** 8/9 · **Commerce Sections** 2/2 · **Commerce Blocks** 12/12 · **Commerce Panels** 1/1. A tier low on this line is a tier where a capability search is most likely to come up empty on something that exists.
24
+ Per tier, strong out of total: **Sections** 59/60 · **Blocks** 100/108 · **Chrome** 10/19 · **Primitives** 64/79 (4 undescribed) · **Panels** 5/6 · **Shells** 7/8 · **Docs + Blog Blocks** 5/5 · **Docs + Blog Chrome** 1/1 · **Docs + Blog Shells** 0/2 · **Scheduling Sections** 2/2 · **Scheduling Blocks** 6/6 · **Signal-Collectors Sections** 5/5 · **Signal-Collectors Blocks** 8/9 · **Commerce Sections** 2/2 · **Commerce Blocks** 12/12 · **Commerce Panels** 1/1. A tier low on this line is a tier where a capability search is most likely to come up empty on something that exists.
25
25
 
26
26
  That grade is **mechanical**. It counts what it can measure — how much a description adds beyond the component's own name, how much of it is spent on wiring, whether the extraction came out damaged — and it cannot tell a fluent paragraph about the wrong subject from a useful one. A person reading these tables will find more rows unusable than the weak count admits. Treat it as a floor on the problem, not a measurement of it, and read the source before concluding a capability is absent.
27
27
 
@@ -124,7 +124,7 @@ Import: `import { X } from "@working-theory/ui/sections";` (or from the `@workin
124
124
 
125
125
  ---
126
126
 
127
- ## Blocks (107)
127
+ ## Blocks (108)
128
128
 
129
129
  Intermediate composition tiles — the pieces sections are built from. Compose primitives and panels; never import a section.
130
130
 
@@ -215,6 +215,7 @@ Import: `import { X } from "@working-theory/ui/blocks";` (or from the `@working-
215
215
  | `ResponsiveSheet` | ONE component, TWO forms, ONE open pair. Renders its `children` as a Radix Dialog at `md` (768px, --breakpoint-md) and up, and as a full-screen Radix Sheet below it — never both at once. | `import { ResponsiveSheet } from "@working-theory/ui/blocks"` |
216
216
  | `RowGroup` | The bordered box that groups ListRows with hairline dividers. The reference is `packages-paid/admin/design/demos/first-steps/demo.html`'s `.box` (`background:var(--bg);border:1px solid var(--border); border-radius:6px;overflow:hidden`) with `.row{border-top:…}` and `.row:first-child{border-top:0}` — i.e. hairlines BETWEEN rows, none above the first. | `import { RowGroup } from "@working-theory/ui/blocks"` |
217
217
  | `SchedulerEmbed` | A declarative third-party booking iframe. Renders a full-width, border-0, lazy-loaded &lt;iframe> at `src` with a graceful loading skeleton (shown until the iframe's own `load` event fires) and a timeout-gated plain-link fallback beneath it — for environments that block third-party frames (ad blockers, a strict CSP `frame-src`) where the iframe may load blank or never load at all. | `import { SchedulerEmbed } from "@working-theory/ui/blocks"` · `packages/ui/src/blocks/SchedulerEmbed.tsx` |
218
+ | `ScoreBar` | One labelled score bar on a scored magnet's results page: a label (the overall score's, or a dimension's name), the points out of the most a respondent could score, the Level name that share falls in, and one bar filled to the evaluator's own `pct` (`evaluateWaitlistScoring` returns `{ points, max, pct, level }` for the total and per dimension). | `import { ScoreBar } from "@working-theory/ui/blocks"` |
218
219
  | `SectionActionRow` | The bottom-anchored, centered action row a section places BENEATH its own content, at the 44×44px target-size floor. It is the block form of the placement established for `stair-steps`: a bounded 1-2 action list rendered after the section body rather than inside the section header. | `import { SectionActionRow } from "@working-theory/ui/blocks"` |
219
220
  | `SectionHeader` | Alignment-parameterized section header (eyebrow/subtitle/description/supportLink). The same component serves two ranks (headingLevel="section" \| "subsection") and three alignments (align="start" \| "center" \| "end"). It knows nothing about columns/splits/carousels — it is width-flexible and fills whatever container the section places it in. | `import { SectionHeader } from "@working-theory/ui/blocks"` |
220
221
  | `ShareBar` | The reader-facing counterpart to DocsActionBar. Both surfaces share the same SplitButton *affordance + slot* (outline variant, right-justified on the post title row) but fill it with different actions: DocsActionBar carries the AI-ingestion set (Copy page + Open in …), ShareBar carries the social-share set (X · LinkedIn · Bluesky · Copy link). One primitive, two configs. | `import { ShareBar } from "@working-theory/ui/blocks"` |
@@ -41,6 +41,19 @@ export interface BodyHeadingPlan {
41
41
  }
42
42
  /** What `migrateBodyHeadings` would write, and the heading-less pages it cannot. Never throws for a page. */
43
43
  export declare function planBodyHeadings(contentDir: string, rule: MdxBodyRule): BodyHeadingPlan;
44
+ export interface MdxBodyPage {
45
+ path: string;
46
+ /** The file's text. */
47
+ raw: string;
48
+ /** Offset of the body in `raw`; 0 for a page with no frontmatter. */
49
+ bodyStart: number;
50
+ }
51
+ /**
52
+ * Every page under `contentDir` that renders as mdx-body (#10773 D6): the
53
+ * `rulePages` walk, less an `unless-sections` page whose `sections` is
54
+ * non-empty or unreadable. An unreadable file is skipped. Sorted by path.
55
+ */
56
+ export declare function mdxBodyPages(contentDir: string, rule: MdxBodyRule): MdxBodyPage[];
44
57
  /** Every mdx-body page whose body does not have exactly one `# ` heading. */
45
58
  export declare function headingViolations(contentDir: string, rule: MdxBodyRule): Array<{
46
59
  path: string;
@@ -14,7 +14,9 @@
14
14
  * - how many `# ` headings a body has (`countBodyHeadings`);
15
15
  * - the codemod that gives a page with none `# <title>` and its description
16
16
  * paragraph (`addBodyHeading`, `planBodyHeadings`, `migrateBodyHeadings`).
17
- * A page with one or more headings is never written.
17
+ * A page with one or more headings is never written;
18
+ * - the mdx-body pages of a content tree, with their bodies (`mdxBodyPages`),
19
+ * which the bare-brace pass and check read (#10773).
18
20
  *
19
21
  * Frontmatter is read with a dependency-free subset of YAML for the three
20
22
  * keys it needs — `create-ailk` ships exactly one runtime dependency (`tar`),
@@ -358,9 +360,13 @@ export function planBodyHeadings(contentDir, rule) {
358
360
  }
359
361
  return plan;
360
362
  }
361
- /** Every mdx-body page whose body does not have exactly one `# ` heading. */
362
- export function headingViolations(contentDir, rule) {
363
- const violations = [];
363
+ /**
364
+ * Every page under `contentDir` that renders as mdx-body (#10773 D6): the
365
+ * `rulePages` walk, less an `unless-sections` page whose `sections` is
366
+ * non-empty or unreadable. An unreadable file is skipped. Sorted by path.
367
+ */
368
+ export function mdxBodyPages(contentDir, rule) {
369
+ const pages = [];
364
370
  for (const { path, type } of rulePages(contentDir, rule)) {
365
371
  let raw;
366
372
  try {
@@ -379,7 +385,15 @@ export function headingViolations(contentDir, rule) {
379
385
  continue;
380
386
  }
381
387
  }
382
- const count = countBodyHeadings(split === undefined ? raw : raw.slice(split.bodyStart));
388
+ pages.push({ path, raw, bodyStart: split?.bodyStart ?? 0 });
389
+ }
390
+ return pages;
391
+ }
392
+ /** Every mdx-body page whose body does not have exactly one `# ` heading. */
393
+ export function headingViolations(contentDir, rule) {
394
+ const violations = [];
395
+ for (const { path, raw, bodyStart } of mdxBodyPages(contentDir, rule)) {
396
+ const count = countBodyHeadings(raw.slice(bodyStart));
383
397
  if (count !== 1)
384
398
  violations.push({ path, count });
385
399
  }
package/dist/cutover.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type WordsCodec, type WorkspaceRow } from "./inventory.js";
2
2
  import { type LanguageImportResult } from "./language-import.js";
3
3
  import { type ImportMdxOutcome, type ImportMdxResult, type MdxAdapterModule } from "./lib/import-mdx.js";
4
+ import { type ChromiumLike } from "./lib/render-comparison.js";
4
5
  export declare const CUTOVER_STEPS: readonly ["import", "publish", "inventory", "comparison", "set-env", "deploy", "verify", "remove-files"];
5
6
  export type CutoverStep = (typeof CUTOVER_STEPS)[number];
6
7
  export declare const ENV_TARGETS: readonly ["preview", "production"];
@@ -46,6 +47,8 @@ export interface CutoverDeps {
46
47
  sweep: string[];
47
48
  screenshots: boolean;
48
49
  dir: string;
50
+ /** Step 1's kind-pull top-ups, left out of their page's after side (#10753). */
51
+ topUps: LanguageImportResult["topUps"];
49
52
  }) => Promise<{
50
53
  pass: boolean;
51
54
  summary: string;
@@ -74,6 +77,8 @@ export interface CutoverOptions {
74
77
  codec?: WordsCodec;
75
78
  /** Injectable for tests: names the run's directory. */
76
79
  now?: () => Date;
80
+ /** Injectable for tests; defaults to the site's installed Playwright Chromium. */
81
+ chromium?: ChromiumLike;
77
82
  deps?: Partial<CutoverDeps>;
78
83
  onLine?: (line: string) => void;
79
84
  }
@@ -105,5 +110,5 @@ export interface CutoverArgs {
105
110
  workspaceId: string | undefined;
106
111
  help: boolean;
107
112
  }
108
- export declare const CUTOVER_HELP = "create-ailk cutover \u2014 move a site from its page files to its database, one checked step at a time.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk cutover [<dir>] --before <origin> --after <origin> [--apply] [--env-target preview|production] [--docs-path <path>] [--pixel-tolerance <ratio>] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --before <origin> The site served from its files.\n --after <origin> A deployment reading the database (a preview\n with CONTENT_ADAPTER=database).\n --apply Run the steps. Without it the command plans\n them: it sends only reads, spawns nothing and\n writes nothing.\n --env-target <target> preview (the default) or production \u2014 where\n step 5 sets CONTENT_ADAPTER and step 6 deploys.\n --docs-path <path> Where the site serves its docs. Default /docs.\n --pixel-tolerance <ratio> The share of a screenshot's pixels that may\n differ. Default 0.\n --api-url <origin> The API origin that holds the workspace \u2014 on a\n spoke, its hub's. Else AILK_API_URL.\n --workspace <wksp_id> The workspace. Else AILK_WORKSPACE_ID.\n -h, --help Show this help.\n\nSteps, in this order; the first that fails stops the run:\n 1 import create-ailk migrate-content <dir> --apply; stops, before\n writing, when the import would change a page's render\n 2 publish every imported page is published (publish in Admin)\n 3 inventory create-ailk inventory <dir> --origin <before>; audit passes\n 4 comparison text, JSON-LD and screenshots per frozen page, before and\n after, at 1440 and 390 wide; every frozen URL answers 200\n 5 set env vercel env add CONTENT_ADAPTER <target> = database (apps/web)\n 6 deploy vercel deploy [--prod] (apps/web)\n 7 verify text and JSON-LD per frozen page on the deployment; 200s\n 8 remove files git rm --quiet -- <the imported files> (staged, not committed)\n\nEach run writes .ailk-cutover/runs/<UTC time>/run.json and the comparison's\nevidence beside it (gitignored). The comparison needs Playwright's Chromium:\npnpm add -D @playwright/test && pnpm exec playwright install chromium. When VERCEL_AUTOMATION_BYPASS_SECRET is set,\nrequests to --after and the deployment carry it. The agent key needs the\ncontent-edit grant; on a spoke, AILK_SPOKE_KEY is needed too. The rollback\nis create-ailk export-content (docs/internal/runbooks/site-cutover.md).\nExit 0 when every step passed (a dry run: when the import plan has no\nfailed page), 1 when one failed, 2 on a refusal.\n";
113
+ export declare const CUTOVER_HELP = "create-ailk cutover \u2014 move a site from its page files to its database, one checked step at a time.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk cutover [<dir>] --before <origin> --after <origin> [--apply] [--env-target preview|production] [--docs-path <path>] [--pixel-tolerance <ratio>] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --before <origin> The site served from its files.\n --after <origin> A deployment reading the database (a preview\n with CONTENT_ADAPTER=database).\n --apply Run the steps. Without it the command plans\n them: it sends only reads, spawns nothing and\n writes nothing.\n --env-target <target> preview (the default) or production \u2014 where\n step 5 sets CONTENT_ADAPTER and step 6 deploys.\n --docs-path <path> Where the site serves its docs. Default /docs.\n --pixel-tolerance <ratio> The share of a screenshot's pixels that may\n differ. Default 0.\n --api-url <origin> The API origin that holds the workspace \u2014 on a\n spoke, its hub's. Else AILK_API_URL.\n --workspace <wksp_id> The workspace. Else AILK_WORKSPACE_ID.\n -h, --help Show this help.\n\nSteps, in this order; the first that fails stops the run:\n 1 import create-ailk migrate-content <dir> --apply; stops, before\n writing, when the import would change a page's render\n 2 publish every imported page is published (publish in Admin)\n 3 inventory create-ailk inventory <dir> --origin <before>; audit passes\n 4 comparison text, JSON-LD and screenshots per frozen page, before and\n after, at 1440 and 390 wide; every frozen URL answers 200;\n /faq's and /glossary's kind-pull top-ups from step 1 are\n left out of the compare\n 5 set env vercel env add CONTENT_ADAPTER <target> = database (apps/web)\n 6 deploy vercel deploy [--prod] (apps/web)\n 7 verify text and JSON-LD per frozen page on the deployment; 200s\n 8 remove files git rm --quiet -- <the imported files> (staged, not committed)\n\nEach run writes .ailk-cutover/runs/<UTC time>/run.json and the comparison's\nevidence beside it (gitignored). The comparison needs Playwright's Chromium:\npnpm add -D @playwright/test && pnpm exec playwright install chromium. When VERCEL_AUTOMATION_BYPASS_SECRET is set,\nrequests to --after and the deployment carry it. The agent key needs the\ncontent-edit grant; on a spoke, AILK_SPOKE_KEY is needed too. The rollback\nis create-ailk export-content (docs/internal/runbooks/site-cutover.md).\nExit 0 when every step passed (a dry run: when the import plan has no\nfailed page), 1 when one failed, 2 on a refusal.\n";
109
114
  export declare function parseCutoverArgs(argv: string[]): CutoverArgs;
package/dist/cutover.js CHANGED
@@ -95,6 +95,13 @@ const vercelEnvArgs = (target) => ({
95
95
  ls: ["env", "ls", target],
96
96
  });
97
97
  const deployArgs = (target) => (target === "production" ? ["deploy", "--prod"] : ["deploy"]);
98
+ /** `/faq: faq/a, faq/b; /glossary: term/c`, pages and keys in `topUps` order. */
99
+ function topUpsLine(topUps) {
100
+ const byPage = new Map();
101
+ for (const t of topUps)
102
+ byPage.set(t.page, [...(byPage.get(t.page) ?? []), `${t.kind}/${t.slug}`]);
103
+ return [...byPage].map(([page, keys]) => `${page}: ${keys.join(", ")}`).join("; ");
104
+ }
98
105
  // ── The command (D7) ────────────────────────────────────────────────────────
99
106
  export async function cutover(opts) {
100
107
  const targetDir = resolve(opts.targetDir);
@@ -165,7 +172,7 @@ export async function cutover(opts) {
165
172
  };
166
173
  },
167
174
  compare: async (o) => {
168
- const chromium = loadChromium([targetDir, process.cwd()]);
175
+ const chromium = opts.chromium ?? loadChromium([targetDir, process.cwd()]);
169
176
  if (chromium === undefined) {
170
177
  return { pass: false, summary: `no Playwright Chromium found — ${PLAYWRIGHT_INSTALL_HINT}` };
171
178
  }
@@ -181,12 +188,15 @@ export async function cutover(opts) {
181
188
  dir: o.dir,
182
189
  fetch,
183
190
  onLine: (line) => onLine(` ${line}`),
191
+ exclude: o.topUps.reduce((by, { page, ...item }) => ({ ...by, [page]: [...(by[page] ?? []), item] }), {}),
184
192
  });
185
193
  const differing = report.pages.filter((p) => !p.pass).map((p) => p.path);
186
194
  const not200 = report.sweep.results.filter((r) => r.status !== 200).length;
195
+ const excludedOn = new Set(report.pages.filter((p) => p.excluded !== undefined).map((p) => p.path));
196
+ const excluded = o.topUps.filter((t) => excludedOn.has(t.page));
187
197
  return {
188
198
  pass: report.pass,
189
- summary: `${report.pages.length - differing.length}/${report.pages.length} page(s) the same; sweep: ${not200} of ${report.sweep.results.length} not 200${differing.length > 0 ? `; differs: ${differing.join(", ")}` : ""}`,
199
+ summary: `${report.pages.length - differing.length}/${report.pages.length} page(s) the same; sweep: ${not200} of ${report.sweep.results.length} not 200${differing.length > 0 ? `; differs: ${differing.join(", ")}` : ""}${excluded.length > 0 ? `; excluded kind-pull top-ups: ${topUpsLine(excluded)}` : ""}`,
190
200
  };
191
201
  },
192
202
  run: spawnRunner,
@@ -215,6 +225,7 @@ export async function cutover(opts) {
215
225
  : `Planning the cutover of ${opts.targetDir}: ${before} (files) → ${after} (database), env target ${target}. Nothing is written or spawned without --apply.`);
216
226
  // State the steps hand on.
217
227
  let outcomes = [];
228
+ let topUps = [];
218
229
  let deployed;
219
230
  const STEP = {
220
231
  import: async () => {
@@ -234,6 +245,7 @@ export async function cutover(opts) {
234
245
  }
235
246
  const { result, language } = await deps.migrate({ apply });
236
247
  outcomes = result.pages;
248
+ topUps = language?.topUps ?? [];
237
249
  const { created, updated, unchanged, skipped, failed } = result.counts;
238
250
  const counts = `created ${created} · updated ${updated} · unchanged ${unchanged} · skipped ${skipped} · failed ${failed}`;
239
251
  const changed = changes(language);
@@ -286,13 +298,16 @@ export async function cutover(opts) {
286
298
  return {
287
299
  ok: true,
288
300
  command,
289
- evidence: [frozen === undefined ? "every URL the inventory freezes" : `${frozen.pages.length} page(s), ${frozen.sweep.length} URL(s) swept`],
301
+ evidence: [
302
+ frozen === undefined ? "every URL the inventory freezes" : `${frozen.pages.length} page(s), ${frozen.sweep.length} URL(s) swept`,
303
+ ...(topUps.length > 0 ? [`would exclude kind-pull top-ups: ${topUpsLine(topUps)}`] : []),
304
+ ],
290
305
  };
291
306
  }
292
307
  if (frozen === undefined)
293
308
  return { ok: false, command, evidence: ["no frozen inventory"] };
294
309
  const dir = join(runDir, "comparison");
295
- const { pass, summary } = await deps.compare({ after, ...frozen, screenshots: true, dir });
310
+ const { pass, summary } = await deps.compare({ after, ...frozen, screenshots: true, dir, topUps });
296
311
  return { ok: pass, command, evidence: [summary, `${dir}/comparison.md`] };
297
312
  },
298
313
  "set-env": async () => {
@@ -335,7 +350,7 @@ export async function cutover(opts) {
335
350
  if (frozen === undefined)
336
351
  return { ok: false, command, evidence: ["no frozen inventory"] };
337
352
  const dir = join(runDir, "verify");
338
- const { pass, summary } = await deps.compare({ after: deployed, ...frozen, screenshots: false, dir });
353
+ const { pass, summary } = await deps.compare({ after: deployed, ...frozen, screenshots: false, dir, topUps });
339
354
  return { ok: pass, command, evidence: [summary, `${dir}/comparison.md`] };
340
355
  },
341
356
  "remove-files": async () => {
@@ -428,7 +443,9 @@ Steps, in this order; the first that fails stops the run:
428
443
  2 publish every imported page is published (publish in Admin)
429
444
  3 inventory create-ailk inventory <dir> --origin <before>; audit passes
430
445
  4 comparison text, JSON-LD and screenshots per frozen page, before and
431
- after, at 1440 and 390 wide; every frozen URL answers 200
446
+ after, at 1440 and 390 wide; every frozen URL answers 200;
447
+ /faq's and /glossary's kind-pull top-ups from step 1 are
448
+ left out of the compare
432
449
  5 set env vercel env add CONTENT_ADAPTER <target> = database (apps/web)
433
450
  6 deploy vercel deploy [--prod] (apps/web)
434
451
  7 verify text and JSON-LD per frozen page on the deployment; 200s
@@ -6,8 +6,11 @@ export declare class ExportContentRefusedError extends Error {
6
6
  constructor(message: string);
7
7
  }
8
8
  export declare const SPOKE_HINT = "the site's read does not serve this workspace's published version \u2014 is the spoke at --store database?";
9
- /** The site-relative file a stored page is written to, and whether it is editorial. */
10
- export declare function fileFor(targetDir: string, type: string, slug: string, locale: string): {
9
+ /**
10
+ * The site-relative file a stored page is written to, and whether it is
11
+ * editorial. A blog post's `parent` is its series' directory (#10811 D1).
12
+ */
13
+ export declare function fileFor(targetDir: string, type: string, slug: string, locale: string, parent?: string): {
11
14
  path: string;
12
15
  editorial: boolean;
13
16
  };
@@ -51,5 +54,5 @@ export interface ExportContentArgs {
51
54
  workspaceId: string | undefined;
52
55
  help: boolean;
53
56
  }
54
- export declare const EXPORT_CONTENT_HELP = "create-ailk export-content \u2014 write every published page back to its content file.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk export-content [<dir>] [--dry-run] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --dry-run Print what would be written, and write nothing.\n --api-url <origin> The API origin that holds the workspace \u2014 on a\n spoke, its hub's. Else AILK_API_URL.\n --workspace <wksp_id> The workspace. Else AILK_WORKSPACE_ID.\n -h, --help Show this help.\n\nThe rollback for a cutover: every page the workspace list reports published\nin the default locale is written to the file migrate-content reads it from\n\u2014 content/en/<Type>/<slug>.mdx (or the legacy content/<Type>/<slug>.mdx that\nexists), apps/web/content/en/blog/<slug>.mdx for a post and the same under\ndocs for a docs page \u2014 with its words moved back into its sections, so a\nre-run of migrate-content reports every page unchanged. Published edits are\nkept. A page with a draft ahead of its published version fails by name:\npublish or revert it in Admin, then re-run. The site's own API read must\nserve the workspace's published version (on a spoke, the hub's row at\n--store database). Each page prints created, written, unchanged or failed.\nThe agent key needs the content-edit grant; on a spoke, AILK_SPOKE_KEY is\nneeded too. Exit 0 when no page failed, 1 when any did, 2 on a refusal.\n";
57
+ export declare const EXPORT_CONTENT_HELP = "create-ailk export-content \u2014 write every published page back to its content file.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk export-content [<dir>] [--dry-run] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --dry-run Print what would be written, and write nothing.\n --api-url <origin> The API origin that holds the workspace \u2014 on a\n spoke, its hub's. Else AILK_API_URL.\n --workspace <wksp_id> The workspace. Else AILK_WORKSPACE_ID.\n -h, --help Show this help.\n\nThe rollback for a cutover: every page the workspace list reports published\nin the default locale is written to the file migrate-content reads it from\n\u2014 content/en/<Type>/<slug>.mdx (or the legacy content/<Type>/<slug>.mdx that\nexists), apps/web/content/en/blog/<slug>.mdx for a post,\napps/web/content/en/blog/<series>/<slug>.mdx for a post in a series and the\nsame under docs for a docs page; a post or doc goes to whichever of\n<slug>.mdx or <slug>/index.mdx is on disk, <slug>.mdx when neither is \u2014 with\nits words moved back into its sections, so a re-run of migrate-content\nreports every page unchanged. Published edits are\nkept. A page with a draft ahead of its published version fails by name:\npublish or revert it in Admin, then re-run. The site's own API read must\nserve the workspace's published version (on a spoke, the hub's row at\n--store database). Each page prints created, written, unchanged or failed.\nThe agent key needs the content-edit grant; on a spoke, AILK_SPOKE_KEY is\nneeded too. Exit 0 when no page failed, 1 when any did, 2 on a refusal.\n";
55
58
  export declare function parseExportContentArgs(argv: string[]): ExportContentArgs;
@@ -24,7 +24,7 @@
24
24
  *
25
25
  * Pure module: no `import.meta.url`, so jest imports it directly.
26
26
  */
27
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
28
28
  import { dirname, join, resolve } from "node:path";
29
29
  import { parseArgs } from "node:util";
30
30
  import { listWorkspacePages, loadSiteWordsCodec, readDraftPage, readPublishedPage, readSiteLocales, resolveWorkspaceAccess, } from "./inventory.js";
@@ -52,21 +52,32 @@ const EDITORIAL_DIRS = {
52
52
  const EDITORIAL_RENAMES = { publishedAt: "date", updatedAt: "updated" };
53
53
  /** Keys an editorial file does not carry: its path decides both. */
54
54
  const EDITORIAL_DROPPED = new Set(["type", "slug"]);
55
+ /**
56
+ * Never written from `siteFields` (#10806 D6): a mapped fumadocs key, an
57
+ * address key, the strip marker, or a prototype name.
58
+ */
59
+ const NOT_SITE_FIELDS = new Set(["date", "updated", "type", "slug", "oss", "siteFields", "__proto__"]);
55
60
  const INDEX_SLUG = "index";
56
61
  /** The store's address shapes, restated (create-ailk has no runtime dep): a path built from them stays in the site. */
57
62
  const TYPE_SHAPE = /^[A-Z][A-Za-z]*$/;
58
63
  const SLUG_SHAPE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
59
64
  const LOCALE_SHAPE = /^[a-z]{2}(-[A-Z]{2})?$/;
65
+ /** A blog series' shape (`PARENT` in `lib/import-mdx.ts`), restated: a directory built from it stays in the blog. */
66
+ const SERIES_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
60
67
  /** A pinned item's read times out as the kit's other reads do (`inventory.ts`). */
61
68
  const ITEM_READ_TIMEOUT_MS = 15_000;
62
69
  export const SPOKE_HINT = "the site's read does not serve this workspace's published version — is the spoke at --store database?";
63
70
  // ── File addressing (D6) ────────────────────────────────────────────────────
64
- /** The site-relative file a stored page is written to, and whether it is editorial. */
65
- export function fileFor(targetDir, type, slug, locale) {
71
+ /**
72
+ * The site-relative file a stored page is written to, and whether it is
73
+ * editorial. A blog post's `parent` is its series' directory (#10811 D1).
74
+ */
75
+ export function fileFor(targetDir, type, slug, locale, parent) {
66
76
  const exists = (path) => existsSync(join(targetDir, path));
67
77
  const editorial = EDITORIAL_DIRS[type];
68
78
  if (editorial !== undefined && editorial.index === (slug === INDEX_SLUG)) {
69
- const base = `apps/web/content/${locale}/${editorial.dir}`;
79
+ const series = type === "BlogPosting" && parent !== undefined ? `/${parent}` : "";
80
+ const base = `apps/web/content/${locale}/${editorial.dir}${series}`;
70
81
  if (editorial.index)
71
82
  return { path: `${base}/index.mdx`, editorial: true };
72
83
  // The importer reads `<slug>.mdx` first and `<slug>/index.mdx` (#10701);
@@ -88,11 +99,20 @@ export function recombine(content, sections, codec, editorial, pinned) {
88
99
  const { body: rawBody, ...rest } = content;
89
100
  let body = typeof rawBody === "string" ? rawBody : "";
90
101
  const frontmatter = {};
91
- for (const [key, value] of Object.entries(rest)) {
102
+ const { siteFields, ...stored } = rest;
103
+ for (const [key, value] of Object.entries(editorial ? stored : rest)) {
92
104
  if (editorial && EDITORIAL_DROPPED.has(key))
93
105
  continue;
94
106
  frontmatter[editorial ? (EDITORIAL_RENAMES[key] ?? key) : key] = value;
95
107
  }
108
+ // #10806 D6 — an editorial page's site fields are its file's own keys, after
109
+ // the mapped ones; a mapped key always wins.
110
+ if (editorial && siteFields !== null && typeof siteFields === "object" && !Array.isArray(siteFields)) {
111
+ for (const [key, value] of Object.entries(siteFields)) {
112
+ if (!Object.hasOwn(frontmatter, key) && !NOT_SITE_FIELDS.has(key))
113
+ frontmatter[key] = value;
114
+ }
115
+ }
96
116
  if (sections !== undefined && sections.length > 0) {
97
117
  const applied = codec.applyWords(body, sections);
98
118
  if (applied.issues.length > 0) {
@@ -170,6 +190,15 @@ export async function exportContent(opts) {
170
190
  if (draft.version !== row.publishedVersionId) {
171
191
  return failed("the draft read is not the published version; re-run");
172
192
  }
193
+ // #10811 D4 — a blog post's series becomes a directory, so it must be one
194
+ // the importer reads back; the API's value is not printed.
195
+ const stored = type === "BlogPosting" ? draft.content["parent"] : undefined;
196
+ const parent = typeof stored === "string" && SERIES_SHAPE.test(stored) && stored !== slug && stored !== "category"
197
+ ? stored
198
+ : undefined;
199
+ if (stored !== undefined && parent === undefined) {
200
+ return failed("its series is not one a file can be written under (lowercase words joined by single hyphens, not its own slug, not category)");
201
+ }
173
202
  const published = await readPublishedPage(access, fetch, type, slug, locale);
174
203
  if (!published.found || published.publishedVersionId !== row.publishedVersionId) {
175
204
  return failed(SPOKE_HINT);
@@ -177,8 +206,18 @@ export async function exportContent(opts) {
177
206
  const pinned = await pinnedItems(published.sections ?? [], locale);
178
207
  if (typeof pinned === "string")
179
208
  return failed(pinned);
180
- const { path, editorial } = fileFor(targetDir, type, slug, locale);
181
- const file = recombine(draft.content, published.sections, codec, editorial, pinned);
209
+ const { path, editorial } = fileFor(targetDir, type, slug, locale, parent);
210
+ let content = draft.content;
211
+ if (parent !== undefined && editorial) {
212
+ const holder = addressHolder(slug, parent);
213
+ if (holder !== undefined) {
214
+ return failed(`${holder} would hold this post's address on import; remove it, then re-run`, path);
215
+ }
216
+ // #10811 D3 — the path carries the series, as it carries type and slug.
217
+ const { parent: _series, ...unparented } = content;
218
+ content = unparented;
219
+ }
220
+ const file = recombine(content, published.sections, codec, editorial, pinned);
182
221
  if ("reason" in file)
183
222
  return failed(file.reason, path);
184
223
  const absolute = join(targetDir, path);
@@ -190,6 +229,33 @@ export async function exportContent(opts) {
190
229
  }
191
230
  return { type, slug, file: path, result };
192
231
  }
232
+ /**
233
+ * The first other file, in path order, that the editorial walk reads as
234
+ * this series post's `BlogPosting/<slug>` (#10811 D5): the top-level forms,
235
+ * or the same post under another series. The walk skips `category`, `_`
236
+ * entries and symbolic links, and refuses a series not in its shape or
237
+ * named after its own post, so none of those holds the address.
238
+ */
239
+ function addressHolder(slug, parent) {
240
+ const blog = `apps/web/content/${locale}/blog`;
241
+ const series = existsSync(join(targetDir, blog))
242
+ ? readdirSync(join(targetDir, blog), { withFileTypes: true })
243
+ .filter((d) => d.isDirectory() && SERIES_SHAPE.test(d.name) && ![parent, slug, "category"].includes(d.name))
244
+ .map((d) => `${blog}/${d.name}`)
245
+ : [];
246
+ // Every directory below `blog` a real directory and the file a real file.
247
+ const walked = (path) => {
248
+ const parts = path.slice(blog.length + 1).split("/");
249
+ return parts.every((_, i) => {
250
+ const stat = lstatSync(join(targetDir, blog, ...parts.slice(0, i + 1)), { throwIfNoEntry: false });
251
+ return i === parts.length - 1 ? stat?.isFile() === true : stat?.isDirectory() === true;
252
+ });
253
+ };
254
+ return [blog, ...series]
255
+ .flatMap((dir) => [`${dir}/${slug}.mdx`, `${dir}/${slug}/index.mdx`])
256
+ .filter(walked)
257
+ .sort()[0];
258
+ }
193
259
  /**
194
260
  * The Live words of every item the page's faq / glossary-index sections pin
195
261
  * (#10735 D9), by kind and slug — `undefined` when none pins — or why the
@@ -261,9 +327,12 @@ Options:
261
327
  The rollback for a cutover: every page the workspace list reports published
262
328
  in the default locale is written to the file migrate-content reads it from
263
329
  — content/en/<Type>/<slug>.mdx (or the legacy content/<Type>/<slug>.mdx that
264
- exists), apps/web/content/en/blog/<slug>.mdx for a post and the same under
265
- docs for a docs page — with its words moved back into its sections, so a
266
- re-run of migrate-content reports every page unchanged. Published edits are
330
+ exists), apps/web/content/en/blog/<slug>.mdx for a post,
331
+ apps/web/content/en/blog/<series>/<slug>.mdx for a post in a series and the
332
+ same under docs for a docs page; a post or doc goes to whichever of
333
+ <slug>.mdx or <slug>/index.mdx is on disk, <slug>.mdx when neither is — with
334
+ its words moved back into its sections, so a re-run of migrate-content
335
+ reports every page unchanged. Published edits are
267
336
  kept. A page with a draft ahead of its published version fails by name:
268
337
  publish or revert it in Admin, then re-run. The site's own API read must
269
338
  serve the workspace's published version (on a spoke, the hub's row at
@@ -8,7 +8,9 @@
8
8
  * `withLanguage` wraps the site's adapter module: the wrapped
9
9
  * `getPageSource` reads the page, makes sure every item on it exists (created
10
10
  * Live when this run creates it), and returns the frontmatter with those
11
- * sections rewritten as `faq:<slug>` / `term:<slug>` pins. The importer then
11
+ * sections rewritten as `faq:<slug>` / `term:<slug>` pins. An FAQ item is one
12
+ * question AND its answer (#10778), so a page that answers another page's
13
+ * question otherwise gets its own item, reported as a collision. The importer then
12
14
  * writes the page as it writes any other, so the pass covers exactly the
13
15
  * pages the importer reads, and every item a page pins exists before its
14
16
  * layout is written. An item write that fails throws, which fails that page.
@@ -59,11 +61,15 @@ export interface LanguageImportResult {
59
61
  page: string;
60
62
  from: string;
61
63
  }[];
62
- /** An imported item the home page's kind pull now also lists (#10734 D3); not a render change. */
64
+ /**
65
+ * An imported item the home page's kind pull now also lists (#10734 D3); not
66
+ * a render change. `name` is the question or term the read renders it with.
67
+ */
63
68
  topUps: {
64
69
  kind: LanguageItemKind;
65
70
  slug: string;
66
71
  page: string;
72
+ name: string;
67
73
  }[];
68
74
  /** An FAQ whose source carried a `kicker`, which a Language FAQ has no field for (#10750). */
69
75
  droppedKickers: {
@@ -75,6 +81,13 @@ export interface LanguageImportResult {
75
81
  slug: string;
76
82
  next: string;
77
83
  }[];
84
+ /** A page's FAQ whose question another page answers otherwise, so it got its own item (#10778). */
85
+ collisions: {
86
+ slug: string;
87
+ page: string;
88
+ from: string;
89
+ next: string;
90
+ }[];
78
91
  }
79
92
  /** The pass, and what it decided once the importer is done. */
80
93
  export interface LanguagePass {
@@ -109,6 +122,6 @@ export declare function languageOffLine(status: number): string;
109
122
  export declare function renderChangeLine(c: LanguageImportResult["renderChanges"][number]): string;
110
123
  /** The counts line, in the dry run's verbs or `--apply`'s. */
111
124
  export declare function languageCountsLine(result: LanguageImportResult, apply: boolean): string;
112
- /** Every line D7 prints, in order: items, counts, render changes, top-ups, dropped kickers, displacements. */
125
+ /** Every line D7 prints, in order: items, counts, render changes, collisions, top-ups, dropped kickers, displacements. */
113
126
  export declare function languageLines(result: LanguageImportResult, apply: boolean): string[];
114
127
  export {};