create-ailk 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -5
- package/dist/body-heading.d.ts +13 -0
- package/dist/body-heading.js +19 -5
- package/dist/cutover.d.ts +6 -1
- package/dist/cutover.js +23 -6
- package/dist/language-import.d.ts +16 -3
- package/dist/language-import.js +27 -11
- package/dist/lib/import-mdx.js +57 -24
- package/dist/lib/render-comparison.d.ts +51 -0
- package/dist/lib/render-comparison.js +205 -6
- package/dist/mdx-braces.d.ts +73 -0
- package/dist/mdx-braces.js +285 -0
- package/dist/migrate-content.d.ts +1 -1
- package/dist/migrate-content.js +4 -2
- package/dist/programmatic.js +2 -8
- package/dist/site-manifest.js +6 -38
- package/dist/site-roster.d.ts +46 -3
- package/dist/site-roster.js +124 -4
- package/dist/surfaces.d.ts +8 -0
- package/dist/surfaces.js +16 -0
- package/dist/update.d.ts +21 -1
- package/dist/update.js +142 -11
- package/package.json +3 -3
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/apps/mcp/.env.example +7 -1
- package/templates/apps/web/__tests__/setup.ts +9 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +3 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +134 -10
- package/templates/apps/web/src/lib/routes.ts +5 -3
- package/templates/package.json +2 -1
- package/templates/tools/body-heading.ts +440 -0
- package/templates/tools/check-mdx-body.ts +123 -0
- package/templates/tools/mdx-braces.ts +303 -0
- package/versions.json +47 -47
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
|
|
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,10 +656,15 @@ 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>`,
|
|
629
|
-
|
|
630
|
-
`
|
|
631
|
-
|
|
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` →
|
|
632
668
|
`publishedAt`, `updated` → `updatedAt`); keys the store does not keep from a
|
|
633
669
|
caller (`featured`, `sidebar`, `tags`) are not sent, and `--apply` names them
|
|
634
670
|
in one line per file. Each outcome carries `root: "content"` or
|
package/dist/body-heading.d.ts
CHANGED
|
@@ -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;
|
package/dist/body-heading.js
CHANGED
|
@@ -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
|
-
/**
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
|
@@ -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.
|
|
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
|
-
/**
|
|
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 {};
|
package/dist/language-import.js
CHANGED
|
@@ -88,7 +88,7 @@ function entriesOf(section) {
|
|
|
88
88
|
if (!str(raw["question"]) || !str(raw["answer"]))
|
|
89
89
|
return undefined;
|
|
90
90
|
entries.push({
|
|
91
|
-
key: raw["question"],
|
|
91
|
+
key: JSON.stringify([raw["question"], raw["answer"]]),
|
|
92
92
|
words: { question: raw["question"], answer: raw["answer"] },
|
|
93
93
|
kicker: raw["kicker"] !== undefined,
|
|
94
94
|
});
|
|
@@ -113,12 +113,13 @@ export function withLanguage(adapters, options) {
|
|
|
113
113
|
let state = "unprobed";
|
|
114
114
|
let offStatus;
|
|
115
115
|
const items = new Map();
|
|
116
|
-
/** In-run FAQ slug claims, so two
|
|
116
|
+
/** In-run FAQ slug claims, so two items never share a slug (D2, #10778 D2); a term's slug is its key. */
|
|
117
117
|
const claimed = new Map();
|
|
118
118
|
const occurrences = [];
|
|
119
119
|
const homes = [];
|
|
120
120
|
const kickers = [];
|
|
121
121
|
const displaced = [];
|
|
122
|
+
const collisions = [];
|
|
122
123
|
async function call(method, url, body) {
|
|
123
124
|
for (let retry = 0;; retry++) {
|
|
124
125
|
let response;
|
|
@@ -208,9 +209,11 @@ export function withLanguage(adapters, options) {
|
|
|
208
209
|
throw new Error(`language: ${kind}/${known.slug}: ${known.failed}`);
|
|
209
210
|
if (home && !known.winner.home) {
|
|
210
211
|
// D3 — the home page outranks a page read before it; an item this run
|
|
211
|
-
// created takes its words
|
|
212
|
+
// created takes its words unless they are already its own (#10778 D4),
|
|
213
|
+
// and a found one is never written (D4).
|
|
214
|
+
const rewords = known.created && options.apply && !same(known.winner.words, entry.words);
|
|
212
215
|
known.winner = { page, words: entry.words, home: true };
|
|
213
|
-
if (
|
|
216
|
+
if (rewords)
|
|
214
217
|
await write(known, entry.words, known.version ?? null);
|
|
215
218
|
}
|
|
216
219
|
return known;
|
|
@@ -221,15 +224,22 @@ export function withLanguage(adapters, options) {
|
|
|
221
224
|
stored = await read(kind, slug);
|
|
222
225
|
}
|
|
223
226
|
else {
|
|
224
|
-
const
|
|
227
|
+
const question = entry.words["question"];
|
|
228
|
+
const first = faqSlug(question);
|
|
225
229
|
let skipped;
|
|
230
|
+
let collided;
|
|
226
231
|
for (let n = 1;; n++) {
|
|
227
232
|
const candidate = n === 1 ? first : `${first}-${n}`;
|
|
233
|
+
// Claimed in this run by another pair: the same question answered otherwise
|
|
234
|
+
// is a collision (#10778 D2), another question that slugs alike is not.
|
|
228
235
|
const holder = claimed.get(candidate);
|
|
229
|
-
if (holder !== undefined
|
|
236
|
+
if (holder !== undefined) {
|
|
237
|
+
if (holder.winner.words["question"] === question)
|
|
238
|
+
collided ??= { slug: candidate, from: holder.winner.page };
|
|
230
239
|
continue;
|
|
240
|
+
}
|
|
231
241
|
stored = await read(kind, candidate);
|
|
232
|
-
if (stored !== null && stored.words["question"] !==
|
|
242
|
+
if (stored !== null && stored.words["question"] !== question) {
|
|
233
243
|
skipped ??= candidate;
|
|
234
244
|
continue;
|
|
235
245
|
}
|
|
@@ -238,9 +248,9 @@ export function withLanguage(adapters, options) {
|
|
|
238
248
|
}
|
|
239
249
|
if (skipped !== undefined)
|
|
240
250
|
displaced.push({ slug: skipped, next: slug });
|
|
251
|
+
if (collided !== undefined)
|
|
252
|
+
collisions.push({ ...collided, page, next: slug });
|
|
241
253
|
}
|
|
242
|
-
if (kind === "faq")
|
|
243
|
-
claimed.set(slug, entry.key);
|
|
244
254
|
const item = {
|
|
245
255
|
kind,
|
|
246
256
|
slug,
|
|
@@ -249,6 +259,8 @@ export function withLanguage(adapters, options) {
|
|
|
249
259
|
...(stored !== null && { stored }),
|
|
250
260
|
};
|
|
251
261
|
items.set(mapKey, item);
|
|
262
|
+
if (kind === "faq")
|
|
263
|
+
claimed.set(slug, item);
|
|
252
264
|
if (stored === null)
|
|
253
265
|
await write(item, entry.words, null);
|
|
254
266
|
return item;
|
|
@@ -355,7 +367,9 @@ export function withLanguage(adapters, options) {
|
|
|
355
367
|
const key = `${PREFIX[item.kind]}${item.slug}`;
|
|
356
368
|
const live = item.created || item.stored.live;
|
|
357
369
|
if (item.kind === home.kind && item.failed === undefined && live && !home.shown.has(key)) {
|
|
358
|
-
|
|
370
|
+
const shown = item.created ? item.winner.words : (item.stored.published ?? {});
|
|
371
|
+
const name = shown[item.kind === "faq" ? "question" : "term"] ?? "";
|
|
372
|
+
topUps.push({ kind: item.kind, slug: item.slug, page: `/${home.slug}`, name });
|
|
359
373
|
}
|
|
360
374
|
}
|
|
361
375
|
}
|
|
@@ -371,6 +385,7 @@ export function withLanguage(adapters, options) {
|
|
|
371
385
|
topUps,
|
|
372
386
|
droppedKickers: kickers,
|
|
373
387
|
displaced,
|
|
388
|
+
collisions,
|
|
374
389
|
};
|
|
375
390
|
}
|
|
376
391
|
return {
|
|
@@ -421,13 +436,14 @@ export function languageCountsLine(result, apply) {
|
|
|
421
436
|
const { created, unchanged, kept, failed } = result.counts;
|
|
422
437
|
return `language: ${apply ? "created" : "create"} ${created} · unchanged ${unchanged} · kept ${kept} · failed ${failed}`;
|
|
423
438
|
}
|
|
424
|
-
/** Every line D7 prints, in order: items, counts, render changes, top-ups, dropped kickers, displacements. */
|
|
439
|
+
/** Every line D7 prints, in order: items, counts, render changes, collisions, top-ups, dropped kickers, displacements. */
|
|
425
440
|
export function languageLines(result, apply) {
|
|
426
441
|
const verb = (r) => (!apply && r === "created" ? "create" : r);
|
|
427
442
|
return [
|
|
428
443
|
...result.items.map((i) => `language: ${i.kind}/${i.slug}: ${verb(i.result)}${i.reason === undefined ? "" : ` — ${i.reason}`}`),
|
|
429
444
|
languageCountsLine(result, apply),
|
|
430
445
|
...result.renderChanges.map(renderChangeLine),
|
|
446
|
+
...result.collisions.map((c) => `language: faq/${c.slug} on ${c.page}: not merged — ${c.from} answers the same question otherwise; ${c.page} pins faq/${c.next}`),
|
|
431
447
|
...result.topUps.map((t) => `language: ${t.kind}/${t.slug}: now also on ${t.page}`),
|
|
432
448
|
...result.droppedKickers.map((k) => `language: faq/${k.slug}: kicker dropped`),
|
|
433
449
|
...result.displaced.map((d) => `language: faq/${d.slug}: the workspace holds a different question here; the file's is ${d.next}`),
|
package/dist/lib/import-mdx.js
CHANGED
|
@@ -61,6 +61,8 @@ const FRONTMATTER_PACKAGE = "gray-matter";
|
|
|
61
61
|
const LOCALE = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
62
62
|
/** `SLUG_PATTERN` in the adapter's `errors.ts`, restated: no runtime dep. */
|
|
63
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]+)*$/;
|
|
64
66
|
/** Each editorial directory's page type and index type (`runtime-source.ts`). */
|
|
65
67
|
const EDITORIAL_TYPES = {
|
|
66
68
|
blog: { page: "BlogPosting", index: "BlogList" },
|
|
@@ -166,10 +168,13 @@ function mdxFiles(root, prefix = []) {
|
|
|
166
168
|
: [];
|
|
167
169
|
});
|
|
168
170
|
}
|
|
171
|
+
/** Type, then slug, then a page with no `parent` before a series leaf (#10775 D4). */
|
|
169
172
|
function byTypeThenSlug(a, b) {
|
|
170
173
|
if (a.type !== b.type)
|
|
171
174
|
return a.type < b.type ? -1 : 1;
|
|
172
|
-
|
|
175
|
+
if (a.slug !== b.slug)
|
|
176
|
+
return a.slug < b.slug ? -1 : 1;
|
|
177
|
+
return Number(a.parent !== undefined) - Number(b.parent !== undefined);
|
|
173
178
|
}
|
|
174
179
|
function byPath(a, b) {
|
|
175
180
|
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
@@ -239,9 +244,11 @@ function plan(contentRoot, types, defaultLocale) {
|
|
|
239
244
|
};
|
|
240
245
|
}
|
|
241
246
|
/**
|
|
242
|
-
* The editorial walk (#10701 D2–D3): `L/{blog,docs}/<slug>.mdx` and
|
|
247
|
+
* The editorial walk (#10701 D2–D3, #10775 D2): `L/{blog,docs}/<slug>.mdx` and
|
|
243
248
|
* `<slug>/index.mdx` are posts and docs, the directory's own `index.mdx` its
|
|
244
|
-
* index page
|
|
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.
|
|
245
252
|
*/
|
|
246
253
|
function planEditorial(editorialRoot, types, defaultLocale) {
|
|
247
254
|
const pages = [];
|
|
@@ -254,7 +261,7 @@ function planEditorial(editorialRoot, types, defaultLocale) {
|
|
|
254
261
|
// Sorted by path first, so within one directory `<slug>.mdx` comes before
|
|
255
262
|
// `<slug>/index.mdx` and holds the address (`.` sorts before `/`).
|
|
256
263
|
for (const { segments, link, path } of found.sort(byPath)) {
|
|
257
|
-
const [locale, dir, file, nested] = segments;
|
|
264
|
+
const [locale, dir, file, nested, leafFile] = segments;
|
|
258
265
|
const kinds = dir !== undefined && Object.hasOwn(EDITORIAL_TYPES, dir)
|
|
259
266
|
? EDITORIAL_TYPES[dir]
|
|
260
267
|
: undefined;
|
|
@@ -266,31 +273,52 @@ function planEditorial(editorialRoot, types, defaultLocale) {
|
|
|
266
273
|
skip(path, `locale ${locale} is not imported (only ${defaultLocale})`);
|
|
267
274
|
continue;
|
|
268
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
|
+
}
|
|
269
280
|
let type;
|
|
270
281
|
let slug;
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
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;
|
|
284
294
|
}
|
|
285
|
-
if (
|
|
286
|
-
skip(path,
|
|
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");
|
|
287
311
|
continue;
|
|
288
312
|
}
|
|
289
313
|
const invalid = !SLUG.test(slug)
|
|
290
314
|
? `not a valid slug: ${slug}`
|
|
291
|
-
: !
|
|
292
|
-
? `
|
|
293
|
-
:
|
|
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;
|
|
294
322
|
pages.push({
|
|
295
323
|
root: "editorial",
|
|
296
324
|
path,
|
|
@@ -298,6 +326,7 @@ function planEditorial(editorialRoot, types, defaultLocale) {
|
|
|
298
326
|
slug,
|
|
299
327
|
localeTree: true,
|
|
300
328
|
...(invalid !== undefined && { invalid }),
|
|
329
|
+
...(parent !== undefined && { parent }),
|
|
301
330
|
});
|
|
302
331
|
}
|
|
303
332
|
return { pages: pages.sort(byTypeThenSlug), skipped };
|
|
@@ -312,7 +341,8 @@ function dateString(value) {
|
|
|
312
341
|
/**
|
|
313
342
|
* An editorial file as the adapter would return a page (#10701 D4): its
|
|
314
343
|
* fumadocs frontmatter mapped to the stored vocabulary, the path deciding the
|
|
315
|
-
* type and slug
|
|
344
|
+
* type and slug — and, for a blog page, its `parent` (#10775 D3) — and the
|
|
345
|
+
* keys not sent named.
|
|
316
346
|
*/
|
|
317
347
|
function editorialSource(page, parsed) {
|
|
318
348
|
const frontmatter = {
|
|
@@ -320,8 +350,9 @@ function editorialSource(page, parsed) {
|
|
|
320
350
|
slug: page.slug,
|
|
321
351
|
};
|
|
322
352
|
const dropped = [];
|
|
353
|
+
const blog = page.type === "BlogPosting" || page.type === "BlogList";
|
|
323
354
|
for (const [key, value] of Object.entries(parsed.data)) {
|
|
324
|
-
const stored = Object.hasOwn(EDITORIAL_FIELDS, key)
|
|
355
|
+
const stored = Object.hasOwn(EDITORIAL_FIELDS, key) && !(blog && key === "parent")
|
|
325
356
|
? EDITORIAL_FIELDS[key]
|
|
326
357
|
: undefined;
|
|
327
358
|
if (stored !== undefined)
|
|
@@ -329,6 +360,8 @@ function editorialSource(page, parsed) {
|
|
|
329
360
|
else if (!EDITORIAL_IGNORED.has(key))
|
|
330
361
|
dropped.push(key);
|
|
331
362
|
}
|
|
363
|
+
if (page.parent !== undefined)
|
|
364
|
+
frontmatter["parent"] = page.parent;
|
|
332
365
|
return {
|
|
333
366
|
source: { frontmatter, body: parsed.content },
|
|
334
367
|
dropped: dropped.sort(),
|