create-ailk 0.29.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -3
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/add-route.js +1 -0
- package/dist/cli.js +114 -1
- package/dist/cutover.d.ts +109 -0
- package/dist/cutover.js +494 -0
- package/dist/design-presets.js +20 -4
- package/dist/export-content.d.ts +55 -0
- package/dist/export-content.js +303 -0
- package/dist/inventory.d.ts +216 -0
- package/dist/inventory.js +833 -0
- package/dist/language-import.d.ts +114 -0
- package/dist/language-import.js +435 -0
- package/dist/lib/env-catalog.js +2 -2
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +411 -64
- package/dist/lib/render-comparison.d.ts +200 -0
- package/dist/lib/render-comparison.js +374 -0
- package/dist/lib/scaffold-render-sweep.d.ts +1 -1
- package/dist/lib/scaffold-render-sweep.js +1 -1
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +60 -14
- package/dist/module-architecture.js +6 -1
- package/dist/provision-env.d.ts +28 -0
- package/dist/provision-env.js +210 -0
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/surfaces.js +3 -0
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +28 -10
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/content-source-guard.sh +75 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.env.example +7 -0
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/api/CLAUDE.md +6 -2
- package/templates/apps/mcp/.env.example +20 -0
- package/templates/apps/mcp/CLAUDE.md +46 -8
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +39 -5
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
- package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
- package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
- package/templates/apps/web/app/[locale]/layout.tsx +122 -27
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
- package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
- package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
- package/templates/apps/web/app/llms.txt/route.ts +26 -9
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
- package/templates/apps/web/app/openapi.json/route.ts +4 -0
- package/templates/apps/web/app/robots.txt/route.ts +18 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +13 -0
- package/templates/apps/web/auth.ts +184 -7
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
- package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
- package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
- package/templates/apps/web/lib/auth-routes.ts +5 -3
- package/templates/apps/web/lib/bot-check.ts +113 -13
- package/templates/apps/web/lib/in-page-magnet.ts +4 -1
- package/templates/apps/web/lib/mcp-oauth.ts +702 -0
- package/templates/apps/web/lib/page-chrome.ts +65 -5
- package/templates/apps/web/lib/relay.ts +22 -3
- package/templates/apps/web/lib/site-identity.ts +75 -24
- package/templates/apps/web/messages/en.json +47 -14
- package/templates/apps/web/next.config.mjs +15 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
- package/templates/apps/web/scripts/content-snapshot.ts +33 -0
- package/templates/database/seed.ts +6 -0
- package/templates/package.json +2 -1
- package/templates/tools/check-content-source.ts +108 -0
- package/versions.json +48 -48
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/** The two viewports every page is compared at. */
|
|
2
|
+
export declare const VIEWPORTS: readonly [{
|
|
3
|
+
readonly name: "desktop";
|
|
4
|
+
readonly width: 1440;
|
|
5
|
+
readonly height: 900;
|
|
6
|
+
}, {
|
|
7
|
+
readonly name: "mobile";
|
|
8
|
+
readonly width: 390;
|
|
9
|
+
readonly height: 844;
|
|
10
|
+
}];
|
|
11
|
+
/** Requests in flight at once, for a sweep or a crawl. */
|
|
12
|
+
export declare const IN_FLIGHT = 4;
|
|
13
|
+
/** The page's words: `main`'s rendered text, else `body`'s. */
|
|
14
|
+
export declare const TEXT_EXPRESSION = "(() => { const root = document.querySelector(\"main\") ?? document.body; return root ? root.innerText : \"\"; })()";
|
|
15
|
+
/** Every JSON-LD script's text, in document order. */
|
|
16
|
+
export declare const JSONLD_EXPRESSION = "Array.from(document.querySelectorAll('script[type=\"application/ld+json\"]'), (s) => s.textContent ?? \"\")";
|
|
17
|
+
/** An image as 8-bit RGBA, row-major. */
|
|
18
|
+
export interface RgbaImage {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
data: Uint8Array;
|
|
22
|
+
}
|
|
23
|
+
export type DecodedPng = {
|
|
24
|
+
ok: true;
|
|
25
|
+
image: RgbaImage;
|
|
26
|
+
} | {
|
|
27
|
+
ok: false;
|
|
28
|
+
reason: "unsupported-png";
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* An 8-bit RGB or RGBA, non-interlaced PNG as RGBA, every scanline filter
|
|
32
|
+
* undone. Anything else — another depth or colour type, interlacing, a
|
|
33
|
+
* truncated or corrupt stream — is `unsupported-png`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function decodePng(png: Buffer): DecodedPng;
|
|
36
|
+
/** How many pixels differ in any channel, or `size-differs`. */
|
|
37
|
+
export declare function diffPixels(a: RgbaImage, b: RgbaImage): number | "size-differs";
|
|
38
|
+
export type TextVerdict = {
|
|
39
|
+
verdict: "same";
|
|
40
|
+
} | {
|
|
41
|
+
verdict: "differs";
|
|
42
|
+
line: number;
|
|
43
|
+
before: string;
|
|
44
|
+
after: string;
|
|
45
|
+
} | {
|
|
46
|
+
verdict: "not-compared";
|
|
47
|
+
};
|
|
48
|
+
export type JsonLdVerdict = {
|
|
49
|
+
verdict: "same";
|
|
50
|
+
} | {
|
|
51
|
+
verdict: "differs";
|
|
52
|
+
index: number;
|
|
53
|
+
before: string | null;
|
|
54
|
+
after: string | null;
|
|
55
|
+
} | {
|
|
56
|
+
verdict: "not-compared";
|
|
57
|
+
};
|
|
58
|
+
export type PixelVerdict = {
|
|
59
|
+
verdict: "same" | "within-tolerance" | "differs";
|
|
60
|
+
differing: number;
|
|
61
|
+
total: number;
|
|
62
|
+
} | {
|
|
63
|
+
verdict: "size-differs" | "unsupported-png" | "not-compared";
|
|
64
|
+
};
|
|
65
|
+
/** Whitespace runs collapsed, then line for line; `differs` names the first differing line (1-based). */
|
|
66
|
+
export declare function compareText(before: string, after: string): TextVerdict;
|
|
67
|
+
/** `same` only when the two arrays are equal string for string — never after a parse. */
|
|
68
|
+
export declare function compareJsonLd(before: readonly string[], after: readonly string[]): JsonLdVerdict;
|
|
69
|
+
/** The slice of a Playwright `Page` the comparison uses. */
|
|
70
|
+
export interface ComparisonPage {
|
|
71
|
+
setViewportSize(size: {
|
|
72
|
+
width: number;
|
|
73
|
+
height: number;
|
|
74
|
+
}): Promise<void>;
|
|
75
|
+
goto(url: string, options: {
|
|
76
|
+
waitUntil: "load";
|
|
77
|
+
timeout: number;
|
|
78
|
+
}): Promise<{
|
|
79
|
+
status(): number;
|
|
80
|
+
} | null>;
|
|
81
|
+
url(): string;
|
|
82
|
+
evaluate(expression: string): Promise<unknown>;
|
|
83
|
+
screenshot(options: {
|
|
84
|
+
fullPage: true;
|
|
85
|
+
animations: "disabled";
|
|
86
|
+
}): Promise<Buffer>;
|
|
87
|
+
}
|
|
88
|
+
export interface ViewportComparison {
|
|
89
|
+
viewport: string;
|
|
90
|
+
/** `ok`, or why that side did not render. */
|
|
91
|
+
render: {
|
|
92
|
+
before: string;
|
|
93
|
+
after: string;
|
|
94
|
+
};
|
|
95
|
+
text: TextVerdict;
|
|
96
|
+
jsonLd: JsonLdVerdict;
|
|
97
|
+
/** Absent when the comparison takes no screenshots (the verify step). */
|
|
98
|
+
pixels?: PixelVerdict;
|
|
99
|
+
shots?: {
|
|
100
|
+
before: string;
|
|
101
|
+
after: string;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export interface PageComparison {
|
|
105
|
+
path: string;
|
|
106
|
+
n: number;
|
|
107
|
+
pass: boolean;
|
|
108
|
+
viewports: ViewportComparison[];
|
|
109
|
+
}
|
|
110
|
+
interface Side {
|
|
111
|
+
origin: string;
|
|
112
|
+
page: ComparisonPage;
|
|
113
|
+
}
|
|
114
|
+
export interface ComparePageOptions {
|
|
115
|
+
path: string;
|
|
116
|
+
/** The page's number in the run, naming its screenshots. */
|
|
117
|
+
n: number;
|
|
118
|
+
before: Side;
|
|
119
|
+
after: Side;
|
|
120
|
+
/** The share of pixels that may differ; `0` is exact. */
|
|
121
|
+
tolerance: number;
|
|
122
|
+
screenshots: boolean;
|
|
123
|
+
/** Stores one screenshot under `shots/<name>`. */
|
|
124
|
+
writeShot: (name: string, png: Buffer) => void;
|
|
125
|
+
}
|
|
126
|
+
/** One frozen URL at every viewport, before against after. */
|
|
127
|
+
export declare function comparePage(o: ComparePageOptions): Promise<PageComparison>;
|
|
128
|
+
/** `fn` over `items`, at most `limit` at once; results in input order. */
|
|
129
|
+
export declare function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>;
|
|
130
|
+
export interface SweepResult {
|
|
131
|
+
pass: boolean;
|
|
132
|
+
/** One row per path, in the order given; `status` 0 for a network failure. */
|
|
133
|
+
results: {
|
|
134
|
+
path: string;
|
|
135
|
+
status: number;
|
|
136
|
+
}[];
|
|
137
|
+
}
|
|
138
|
+
/** Every path on `origin` must answer 200 directly; a redirect is not a 200. */
|
|
139
|
+
export declare function sweep(o: {
|
|
140
|
+
origin: string;
|
|
141
|
+
paths: readonly string[];
|
|
142
|
+
fetch: typeof globalThis.fetch;
|
|
143
|
+
headers?: Record<string, string>;
|
|
144
|
+
}): Promise<SweepResult>;
|
|
145
|
+
export interface ComparisonReport {
|
|
146
|
+
before: string;
|
|
147
|
+
after: string;
|
|
148
|
+
pages: PageComparison[];
|
|
149
|
+
sweep: SweepResult;
|
|
150
|
+
}
|
|
151
|
+
/** The eye-read table: one row per URL and viewport, linking its screenshot pair. */
|
|
152
|
+
export declare function comparisonMarkdown(report: ComparisonReport): string;
|
|
153
|
+
/** `comparison.json` and `comparison.md` in `dir`; the screenshots are already in `dir/shots`. */
|
|
154
|
+
export declare function writeComparisonEvidence(dir: string, report: ComparisonReport): void;
|
|
155
|
+
/** The slice of a Playwright route the header scoping uses. */
|
|
156
|
+
interface RouteLike {
|
|
157
|
+
request(): {
|
|
158
|
+
url(): string;
|
|
159
|
+
headers(): Record<string, string>;
|
|
160
|
+
};
|
|
161
|
+
continue(options?: {
|
|
162
|
+
headers?: Record<string, string>;
|
|
163
|
+
}): Promise<void>;
|
|
164
|
+
}
|
|
165
|
+
/** The slice of Playwright's `chromium` the comparison launches. */
|
|
166
|
+
export interface ChromiumLike {
|
|
167
|
+
launch(): Promise<{
|
|
168
|
+
newContext(): Promise<{
|
|
169
|
+
newPage(): Promise<ComparisonPage & {
|
|
170
|
+
route(url: string, handler: (route: RouteLike) => Promise<void>): Promise<void>;
|
|
171
|
+
}>;
|
|
172
|
+
}>;
|
|
173
|
+
close(): Promise<void>;
|
|
174
|
+
}>;
|
|
175
|
+
}
|
|
176
|
+
export declare const PLAYWRIGHT_INSTALL_HINT = "pnpm add -D @playwright/test && pnpm exec playwright install chromium";
|
|
177
|
+
/** `chromium` from `playwright` or `@playwright/test`, resolved from each dir in turn. */
|
|
178
|
+
export declare function loadChromium(dirs: readonly string[]): ChromiumLike | undefined;
|
|
179
|
+
export interface RunComparisonOptions {
|
|
180
|
+
chromium: ChromiumLike;
|
|
181
|
+
before: string;
|
|
182
|
+
after: string;
|
|
183
|
+
/** Headers the `after` origin's requests carry (a preview's protection bypass). */
|
|
184
|
+
afterHeaders?: Record<string, string>;
|
|
185
|
+
/** The frozen page paths compared in the browser. */
|
|
186
|
+
pages: readonly string[];
|
|
187
|
+
/** Every frozen path swept on `after`. */
|
|
188
|
+
sweepPaths: readonly string[];
|
|
189
|
+
tolerance: number;
|
|
190
|
+
screenshots: boolean;
|
|
191
|
+
/** Where `comparison.json`, `comparison.md` and `shots/` go. */
|
|
192
|
+
dir: string;
|
|
193
|
+
fetch: typeof globalThis.fetch;
|
|
194
|
+
onLine?: (line: string) => void;
|
|
195
|
+
}
|
|
196
|
+
/** Every page compared, the sweep run, the evidence written; `pass` gates the step. */
|
|
197
|
+
export declare function runComparison(o: RunComparisonOptions): Promise<ComparisonReport & {
|
|
198
|
+
pass: boolean;
|
|
199
|
+
}>;
|
|
200
|
+
export {};
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cutover comparison (#10648, spec
|
|
3
|
+
* docs/specs/10648-the-cutover-kit-frozen-inventory-spec.md D9).
|
|
4
|
+
*
|
|
5
|
+
* Per frozen page URL, at two viewports, in Chromium, on both origins: the
|
|
6
|
+
* render (a direct 200 that settles on the requested path, `render-verdict.ts`'s
|
|
7
|
+
* rule), the text of `main`, every JSON-LD script string for string, and a
|
|
8
|
+
* full-page screenshot compared pixel by pixel. Then a sweep: every frozen URL
|
|
9
|
+
* must answer 200 with no redirect. A DOM diff alone is never the evidence
|
|
10
|
+
* (roadmap § 9, rules/outcome-evidence.md): the pixels gate the verdict, and
|
|
11
|
+
* `comparison.md` links every before/after pair for the eye read.
|
|
12
|
+
*
|
|
13
|
+
* create-ailk has one runtime dependency, so nothing here imports Playwright:
|
|
14
|
+
* the PNG decode and diff are pure functions over buffers (`node:zlib` only),
|
|
15
|
+
* a page is reached only through the `ComparisonPage` slice it is handed, and
|
|
16
|
+
* `loadChromium` resolves the site's own Playwright at run time. Jest drives
|
|
17
|
+
* all of it without a browser.
|
|
18
|
+
*/
|
|
19
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { createRequire } from "node:module";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { inflateSync } from "node:zlib";
|
|
23
|
+
/** The two viewports every page is compared at. */
|
|
24
|
+
export const VIEWPORTS = [
|
|
25
|
+
{ name: "desktop", width: 1440, height: 900 },
|
|
26
|
+
{ name: "mobile", width: 390, height: 844 },
|
|
27
|
+
];
|
|
28
|
+
/** How long one navigation may take before the render fails. */
|
|
29
|
+
const NAVIGATION_TIMEOUT_MS = 60_000;
|
|
30
|
+
/** How long one sweep request may take. */
|
|
31
|
+
const SWEEP_TIMEOUT_MS = 15_000;
|
|
32
|
+
/** Requests in flight at once, for a sweep or a crawl. */
|
|
33
|
+
export const IN_FLIGHT = 4;
|
|
34
|
+
/** The page's words: `main`'s rendered text, else `body`'s. */
|
|
35
|
+
export const TEXT_EXPRESSION = '(() => { const root = document.querySelector("main") ?? document.body; return root ? root.innerText : ""; })()';
|
|
36
|
+
/** Every JSON-LD script's text, in document order. */
|
|
37
|
+
export const JSONLD_EXPRESSION = 'Array.from(document.querySelectorAll(\'script[type="application/ld+json"]\'), (s) => s.textContent ?? "")';
|
|
38
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
39
|
+
function paeth(a, b, c) {
|
|
40
|
+
const p = a + b - c;
|
|
41
|
+
const pa = Math.abs(p - a);
|
|
42
|
+
const pb = Math.abs(p - b);
|
|
43
|
+
const pc = Math.abs(p - c);
|
|
44
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* An 8-bit RGB or RGBA, non-interlaced PNG as RGBA, every scanline filter
|
|
48
|
+
* undone. Anything else — another depth or colour type, interlacing, a
|
|
49
|
+
* truncated or corrupt stream — is `unsupported-png`.
|
|
50
|
+
*/
|
|
51
|
+
export function decodePng(png) {
|
|
52
|
+
const unsupported = { ok: false, reason: "unsupported-png" };
|
|
53
|
+
try {
|
|
54
|
+
if (png.length < 8 || !png.subarray(0, 8).equals(PNG_SIGNATURE))
|
|
55
|
+
return unsupported;
|
|
56
|
+
let width = 0;
|
|
57
|
+
let height = 0;
|
|
58
|
+
let channels = 0;
|
|
59
|
+
const idat = [];
|
|
60
|
+
for (let at = 8; at + 8 <= png.length;) {
|
|
61
|
+
const length = png.readUInt32BE(at);
|
|
62
|
+
const type = png.toString("ascii", at + 4, at + 8);
|
|
63
|
+
const data = png.subarray(at + 8, at + 8 + length);
|
|
64
|
+
if (type === "IHDR") {
|
|
65
|
+
width = data.readUInt32BE(0);
|
|
66
|
+
height = data.readUInt32BE(4);
|
|
67
|
+
const [depth, colour, compression, filter, interlace] = data.subarray(8, 13);
|
|
68
|
+
if (depth !== 8 || compression !== 0 || filter !== 0 || interlace !== 0)
|
|
69
|
+
return unsupported;
|
|
70
|
+
if (colour === 2)
|
|
71
|
+
channels = 3;
|
|
72
|
+
else if (colour === 6)
|
|
73
|
+
channels = 4;
|
|
74
|
+
else
|
|
75
|
+
return unsupported;
|
|
76
|
+
}
|
|
77
|
+
else if (type === "IDAT") {
|
|
78
|
+
idat.push(data);
|
|
79
|
+
}
|
|
80
|
+
else if (type === "IEND") {
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
at += 12 + length;
|
|
84
|
+
}
|
|
85
|
+
if (channels === 0 || width === 0 || height === 0)
|
|
86
|
+
return unsupported;
|
|
87
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
88
|
+
const stride = width * channels;
|
|
89
|
+
if (raw.length !== height * (stride + 1))
|
|
90
|
+
return unsupported;
|
|
91
|
+
const rgba = new Uint8Array(width * height * 4);
|
|
92
|
+
let prev = new Uint8Array(stride);
|
|
93
|
+
for (let y = 0; y < height; y++) {
|
|
94
|
+
const filter = raw[y * (stride + 1)];
|
|
95
|
+
const row = Uint8Array.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
|
|
96
|
+
for (let i = 0; i < stride; i++) {
|
|
97
|
+
const a = i >= channels ? row[i - channels] : 0;
|
|
98
|
+
const b = prev[i];
|
|
99
|
+
const c = i >= channels ? prev[i - channels] : 0;
|
|
100
|
+
const predicted = filter === 0 ? 0
|
|
101
|
+
: filter === 1 ? a
|
|
102
|
+
: filter === 2 ? b
|
|
103
|
+
: filter === 3 ? (a + b) >> 1
|
|
104
|
+
: filter === 4 ? paeth(a, b, c)
|
|
105
|
+
: -1;
|
|
106
|
+
if (predicted < 0)
|
|
107
|
+
return unsupported;
|
|
108
|
+
row[i] = (row[i] + predicted) & 0xff;
|
|
109
|
+
}
|
|
110
|
+
for (let x = 0; x < width; x++) {
|
|
111
|
+
const to = (y * width + x) * 4;
|
|
112
|
+
const from = x * channels;
|
|
113
|
+
rgba[to] = row[from];
|
|
114
|
+
rgba[to + 1] = row[from + 1];
|
|
115
|
+
rgba[to + 2] = row[from + 2];
|
|
116
|
+
rgba[to + 3] = channels === 4 ? row[from + 3] : 255;
|
|
117
|
+
}
|
|
118
|
+
prev = row;
|
|
119
|
+
}
|
|
120
|
+
return { ok: true, image: { width, height, data: rgba } };
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return unsupported;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** How many pixels differ in any channel, or `size-differs`. */
|
|
127
|
+
export function diffPixels(a, b) {
|
|
128
|
+
if (a.width !== b.width || a.height !== b.height)
|
|
129
|
+
return "size-differs";
|
|
130
|
+
let differing = 0;
|
|
131
|
+
for (let i = 0; i < a.data.length; i += 4) {
|
|
132
|
+
if (a.data[i] !== b.data[i] ||
|
|
133
|
+
a.data[i + 1] !== b.data[i + 1] ||
|
|
134
|
+
a.data[i + 2] !== b.data[i + 2] ||
|
|
135
|
+
a.data[i + 3] !== b.data[i + 3]) {
|
|
136
|
+
differing += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return differing;
|
|
140
|
+
}
|
|
141
|
+
function textLines(text) {
|
|
142
|
+
return text
|
|
143
|
+
.split("\n")
|
|
144
|
+
.map((line) => line.replace(/\s+/g, " ").trim())
|
|
145
|
+
.filter((line) => line !== "");
|
|
146
|
+
}
|
|
147
|
+
/** Whitespace runs collapsed, then line for line; `differs` names the first differing line (1-based). */
|
|
148
|
+
export function compareText(before, after) {
|
|
149
|
+
const a = textLines(before);
|
|
150
|
+
const b = textLines(after);
|
|
151
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
152
|
+
if (a[i] !== b[i]) {
|
|
153
|
+
return { verdict: "differs", line: i + 1, before: a[i] ?? "", after: b[i] ?? "" };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { verdict: "same" };
|
|
157
|
+
}
|
|
158
|
+
/** `same` only when the two arrays are equal string for string — never after a parse. */
|
|
159
|
+
export function compareJsonLd(before, after) {
|
|
160
|
+
for (let i = 0; i < Math.max(before.length, after.length); i++) {
|
|
161
|
+
if (before[i] !== after[i]) {
|
|
162
|
+
return { verdict: "differs", index: i, before: before[i] ?? null, after: after[i] ?? null };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { verdict: "same" };
|
|
166
|
+
}
|
|
167
|
+
function pixelVerdict(before, after, tolerance) {
|
|
168
|
+
const a = decodePng(before);
|
|
169
|
+
const b = decodePng(after);
|
|
170
|
+
if (!a.ok || !b.ok)
|
|
171
|
+
return { verdict: "unsupported-png" };
|
|
172
|
+
const differing = diffPixels(a.image, b.image);
|
|
173
|
+
if (differing === "size-differs")
|
|
174
|
+
return { verdict: "size-differs" };
|
|
175
|
+
const total = a.image.width * a.image.height;
|
|
176
|
+
const verdict = differing === 0 ? "same" : differing / total <= tolerance ? "within-tolerance" : "differs";
|
|
177
|
+
return { verdict, differing, total };
|
|
178
|
+
}
|
|
179
|
+
function passes(v) {
|
|
180
|
+
return v.verdict === "same" || v.verdict === "within-tolerance";
|
|
181
|
+
}
|
|
182
|
+
async function renderSide(side, path, viewport, screenshot) {
|
|
183
|
+
const failed = (render) => ({ render, text: "", jsonLd: [] });
|
|
184
|
+
await side.page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
185
|
+
let status;
|
|
186
|
+
try {
|
|
187
|
+
const response = await side.page.goto(side.origin + path, {
|
|
188
|
+
waitUntil: "load",
|
|
189
|
+
timeout: NAVIGATION_TIMEOUT_MS,
|
|
190
|
+
});
|
|
191
|
+
status = response?.status();
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
return failed(`navigation failed: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
|
|
195
|
+
}
|
|
196
|
+
if (status !== 200)
|
|
197
|
+
return failed(status === undefined ? "no response" : `status ${status}, expected 200`);
|
|
198
|
+
const settled = new URL(side.page.url()).pathname;
|
|
199
|
+
if (settled !== path)
|
|
200
|
+
return failed(`requested "${path}" but the page settled on "${settled}"`);
|
|
201
|
+
const text = await side.page.evaluate(TEXT_EXPRESSION);
|
|
202
|
+
const jsonLd = await side.page.evaluate(JSONLD_EXPRESSION);
|
|
203
|
+
return {
|
|
204
|
+
render: "ok",
|
|
205
|
+
text: typeof text === "string" ? text : "",
|
|
206
|
+
jsonLd: Array.isArray(jsonLd) ? jsonLd.map(String) : [],
|
|
207
|
+
...(screenshot && {
|
|
208
|
+
png: await side.page.screenshot({ fullPage: true, animations: "disabled" }),
|
|
209
|
+
}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/** One frozen URL at every viewport, before against after. */
|
|
213
|
+
export async function comparePage(o) {
|
|
214
|
+
const viewports = [];
|
|
215
|
+
for (const viewport of VIEWPORTS) {
|
|
216
|
+
const before = await renderSide(o.before, o.path, viewport, o.screenshots);
|
|
217
|
+
const after = await renderSide(o.after, o.path, viewport, o.screenshots);
|
|
218
|
+
const rendered = before.render === "ok" && after.render === "ok";
|
|
219
|
+
const result = {
|
|
220
|
+
viewport: viewport.name,
|
|
221
|
+
render: { before: before.render, after: after.render },
|
|
222
|
+
text: rendered ? compareText(before.text, after.text) : { verdict: "not-compared" },
|
|
223
|
+
jsonLd: rendered ? compareJsonLd(before.jsonLd, after.jsonLd) : { verdict: "not-compared" },
|
|
224
|
+
};
|
|
225
|
+
if (o.screenshots) {
|
|
226
|
+
if (before.png !== undefined && after.png !== undefined) {
|
|
227
|
+
const shots = {
|
|
228
|
+
before: `${o.n}-${viewport.name}-before.png`,
|
|
229
|
+
after: `${o.n}-${viewport.name}-after.png`,
|
|
230
|
+
};
|
|
231
|
+
o.writeShot(shots.before, before.png);
|
|
232
|
+
o.writeShot(shots.after, after.png);
|
|
233
|
+
result.pixels = pixelVerdict(before.png, after.png, o.tolerance);
|
|
234
|
+
result.shots = shots;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
result.pixels = { verdict: "not-compared" };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
viewports.push(result);
|
|
241
|
+
}
|
|
242
|
+
const pass = viewports.every((v) => v.render.before === "ok" &&
|
|
243
|
+
v.render.after === "ok" &&
|
|
244
|
+
passes(v.text) &&
|
|
245
|
+
passes(v.jsonLd) &&
|
|
246
|
+
(v.pixels === undefined || passes(v.pixels)));
|
|
247
|
+
return { path: o.path, n: o.n, pass, viewports };
|
|
248
|
+
}
|
|
249
|
+
// ── The sweep (D9) ──────────────────────────────────────────────────────────
|
|
250
|
+
/** `fn` over `items`, at most `limit` at once; results in input order. */
|
|
251
|
+
export async function mapLimit(items, limit, fn) {
|
|
252
|
+
const results = new Array(items.length);
|
|
253
|
+
let next = 0;
|
|
254
|
+
const worker = async () => {
|
|
255
|
+
while (next < items.length) {
|
|
256
|
+
const i = next++;
|
|
257
|
+
results[i] = await fn(items[i]);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
261
|
+
return results;
|
|
262
|
+
}
|
|
263
|
+
/** Every path on `origin` must answer 200 directly; a redirect is not a 200. */
|
|
264
|
+
export async function sweep(o) {
|
|
265
|
+
const results = await mapLimit(o.paths, IN_FLIGHT, async (path) => {
|
|
266
|
+
try {
|
|
267
|
+
const response = await o.fetch(o.origin + path, {
|
|
268
|
+
redirect: "manual",
|
|
269
|
+
...(o.headers !== undefined && { headers: o.headers }),
|
|
270
|
+
signal: AbortSignal.timeout(SWEEP_TIMEOUT_MS),
|
|
271
|
+
});
|
|
272
|
+
return { path, status: response.status };
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return { path, status: 0 };
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
return { pass: results.every((r) => r.status === 200), results };
|
|
279
|
+
}
|
|
280
|
+
function cell(v) {
|
|
281
|
+
return v === undefined ? "—" : v.verdict;
|
|
282
|
+
}
|
|
283
|
+
/** The eye-read table: one row per URL and viewport, linking its screenshot pair. */
|
|
284
|
+
export function comparisonMarkdown(report) {
|
|
285
|
+
const lines = [
|
|
286
|
+
`# Comparison: ${report.before} → ${report.after}`,
|
|
287
|
+
"",
|
|
288
|
+
"| # | Path | Viewport | Render | Text | JSON-LD | Pixels | Screenshots |",
|
|
289
|
+
"| - | ---- | -------- | ------ | ---- | ------- | ------ | ----------- |",
|
|
290
|
+
];
|
|
291
|
+
for (const page of report.pages) {
|
|
292
|
+
for (const v of page.viewports) {
|
|
293
|
+
const render = v.render.before === "ok" && v.render.after === "ok"
|
|
294
|
+
? "ok"
|
|
295
|
+
: `before: ${v.render.before}; after: ${v.render.after}`;
|
|
296
|
+
const shots = v.shots === undefined
|
|
297
|
+
? "—"
|
|
298
|
+
: `[before](shots/${v.shots.before}) · [after](shots/${v.shots.after})`;
|
|
299
|
+
lines.push(`| ${page.n} | \`${page.path}\` | ${v.viewport} | ${render.replace(/\|/g, "\\|")} | ${cell(v.text)} | ${cell(v.jsonLd)} | ${cell(v.pixels)} | ${shots} |`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const failed = report.sweep.results.filter((r) => r.status !== 200);
|
|
303
|
+
lines.push("", `## Sweep: ${report.sweep.results.length} URL(s) on ${report.after}, ${failed.length} not 200`, "", ...failed.map((r) => `- \`${r.path}\` → ${r.status}`));
|
|
304
|
+
return `${lines.join("\n")}\n`;
|
|
305
|
+
}
|
|
306
|
+
/** `comparison.json` and `comparison.md` in `dir`; the screenshots are already in `dir/shots`. */
|
|
307
|
+
export function writeComparisonEvidence(dir, report) {
|
|
308
|
+
mkdirSync(dir, { recursive: true });
|
|
309
|
+
writeFileSync(join(dir, "comparison.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
310
|
+
writeFileSync(join(dir, "comparison.md"), comparisonMarkdown(report));
|
|
311
|
+
}
|
|
312
|
+
export const PLAYWRIGHT_INSTALL_HINT = "pnpm add -D @playwright/test && pnpm exec playwright install chromium";
|
|
313
|
+
/** `chromium` from `playwright` or `@playwright/test`, resolved from each dir in turn. */
|
|
314
|
+
export function loadChromium(dirs) {
|
|
315
|
+
for (const dir of dirs) {
|
|
316
|
+
for (const name of ["playwright", "@playwright/test"]) {
|
|
317
|
+
try {
|
|
318
|
+
const mod = createRequire(join(dir, "package.json"))(name);
|
|
319
|
+
if (mod.chromium !== undefined)
|
|
320
|
+
return mod.chromium;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Not installed here: the next candidate.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
/** Every page compared, the sweep run, the evidence written; `pass` gates the step. */
|
|
330
|
+
export async function runComparison(o) {
|
|
331
|
+
const shotsDir = join(o.dir, "shots");
|
|
332
|
+
if (o.screenshots)
|
|
333
|
+
mkdirSync(shotsDir, { recursive: true });
|
|
334
|
+
const browser = await o.chromium.launch();
|
|
335
|
+
const pages = [];
|
|
336
|
+
try {
|
|
337
|
+
const before = await (await browser.newContext()).newPage();
|
|
338
|
+
const after = await (await browser.newContext()).newPage();
|
|
339
|
+
const headers = o.afterHeaders;
|
|
340
|
+
if (headers !== undefined) {
|
|
341
|
+
// Only requests to the `after` origin carry the headers (a preview's
|
|
342
|
+
// protection bypass): never a third-party subresource, never another origin.
|
|
343
|
+
const origin = new URL(o.after).origin;
|
|
344
|
+
await after.route("**/*", (route) => new URL(route.request().url()).origin === origin
|
|
345
|
+
? route.continue({ headers: { ...route.request().headers(), ...headers } })
|
|
346
|
+
: route.continue());
|
|
347
|
+
}
|
|
348
|
+
for (const [i, path] of o.pages.entries()) {
|
|
349
|
+
const result = await comparePage({
|
|
350
|
+
path,
|
|
351
|
+
n: i + 1,
|
|
352
|
+
before: { origin: o.before, page: before },
|
|
353
|
+
after: { origin: o.after, page: after },
|
|
354
|
+
tolerance: o.tolerance,
|
|
355
|
+
screenshots: o.screenshots,
|
|
356
|
+
writeShot: (name, png) => writeFileSync(join(shotsDir, name), png),
|
|
357
|
+
});
|
|
358
|
+
o.onLine?.(`${result.pass ? "same" : "DIFFERS"} ${path}`);
|
|
359
|
+
pages.push(result);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
await browser.close();
|
|
364
|
+
}
|
|
365
|
+
const swept = await sweep({
|
|
366
|
+
origin: o.after,
|
|
367
|
+
paths: o.sweepPaths,
|
|
368
|
+
fetch: o.fetch,
|
|
369
|
+
...(o.afterHeaders !== undefined && { headers: o.afterHeaders }),
|
|
370
|
+
});
|
|
371
|
+
const report = { before: o.before, after: o.after, pages, sweep: swept };
|
|
372
|
+
writeComparisonEvidence(o.dir, report);
|
|
373
|
+
return { ...report, pass: swept.pass && pages.every((p) => p.pass) };
|
|
374
|
+
}
|
|
@@ -143,7 +143,7 @@ export declare function assessConfigGatedRoute(input: {
|
|
|
143
143
|
* The environment the sitemap sweep's server runs under (#8501): `env` with
|
|
144
144
|
* every `CONFIG_GATED_ROUTES` variable removed.
|
|
145
145
|
*
|
|
146
|
-
* `/sitemap.xml` is
|
|
146
|
+
* `/sitemap.xml` is rendered on request, from the content seeds, so it
|
|
147
147
|
* describes the scaffold AS GENERATED — and a config-gated route's seeds are
|
|
148
148
|
* served only while its variable is unset (the Free placeholder hand-off,
|
|
149
149
|
* #6942). Set it and the route serves its live module page instead: `/meet`
|
|
@@ -285,7 +285,7 @@ function headingTexts(html) {
|
|
|
285
285
|
* The environment the sitemap sweep's server runs under (#8501): `env` with
|
|
286
286
|
* every `CONFIG_GATED_ROUTES` variable removed.
|
|
287
287
|
*
|
|
288
|
-
* `/sitemap.xml` is
|
|
288
|
+
* `/sitemap.xml` is rendered on request, from the content seeds, so it
|
|
289
289
|
* describes the scaffold AS GENERATED — and a config-gated route's seeds are
|
|
290
290
|
* served only while its variable is unset (the Free placeholder hand-off,
|
|
291
291
|
* #6942). Set it and the route serves its live module page instead: `/meet`
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type LanguageImportResult } from "./language-import.js";
|
|
1
2
|
import { type ImportMdxResult, type MdxAdapterModule } from "./lib/import-mdx.js";
|
|
2
3
|
/** A refusal the CLI exits 2 on, thrown before any request is sent. */
|
|
3
4
|
export declare class MigrateContentRefusedError extends Error {
|
|
@@ -27,12 +28,15 @@ export interface MigrateContentOptions {
|
|
|
27
28
|
/**
|
|
28
29
|
* A fetch that sends `GET`s through `inner` and never sends a write: a `POST`
|
|
29
30
|
* is answered `201` and a `PATCH` `200` locally, with the one member of a
|
|
30
|
-
* write's answer `importMdx` reads (`data.locale`)
|
|
31
|
+
* write's answer `importMdx` reads (`data.locale`), and the structure write's
|
|
32
|
+
* `PUT` (#10534) `200` as a layout unchanged (`data.written: false`). Any
|
|
33
|
+
* other method throws.
|
|
31
34
|
*/
|
|
32
35
|
export declare function readOnlyFetch(inner: typeof globalThis.fetch, locale: string): typeof globalThis.fetch;
|
|
33
36
|
export declare function migrateContent(opts: MigrateContentOptions): Promise<{
|
|
34
37
|
result: ImportMdxResult;
|
|
35
38
|
exitCode: 0 | 1;
|
|
39
|
+
language?: LanguageImportResult;
|
|
36
40
|
}>;
|
|
37
41
|
export interface MigrateContentArgs {
|
|
38
42
|
target: string | undefined;
|
|
@@ -41,5 +45,5 @@ export interface MigrateContentArgs {
|
|
|
41
45
|
workspaceId: string | undefined;
|
|
42
46
|
help: boolean;
|
|
43
47
|
}
|
|
44
|
-
export declare const MIGRATE_CONTENT_HELP = "create-ailk migrate-content \u2014 import an existing site's pages into its workspace.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk migrate-content [<dir>] [--apply] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --apply Write the pages. Without it the command reads the\n workspace, prints what it would do, and writes\n nothing.\n --api-url <origin> The API origin that holds the workspace \u2014 on a\n spoke, its hub's. Else AILK_API_URL. https: only\n (http: on localhost or 127.0.0.1).\n --workspace <wksp_id> The workspace the pages are written to. Else\n AILK_WORKSPACE_ID.\n -h, --help Show this help.\n\nEach content/en/<Type>/<slug>.mdx page (and each legacy\ncontent/<Type>/<slug>.mdx) is written to the workspace as a draft through\nthe API's content routes, with the agent key, which is read from\nAILK_AGENT_KEY only and needs the content-edit grant. Nothing is published,\nso the live site does not change. The site needs no .ailk-site-manifest.json\nand may be a spoke; its dependencies must be installed.\n\nA dry run prints create, update, unchanged, skipped or failed per page. It\ncannot see a write the API would refuse at --apply (a conflict or a server\nerror),
|
|
48
|
+
export declare const MIGRATE_CONTENT_HELP = "create-ailk migrate-content \u2014 import an existing site's pages into its workspace.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk migrate-content [<dir>] [--apply] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --apply Write the pages. Without it the command reads the\n workspace, prints what it would do, and writes\n nothing.\n --api-url <origin> The API origin that holds the workspace \u2014 on a\n spoke, its hub's. Else AILK_API_URL. https: only\n (http: on localhost or 127.0.0.1).\n --workspace <wksp_id> The workspace the pages are written to. Else\n AILK_WORKSPACE_ID.\n -h, --help Show this help.\n\nEach content/en/<Type>/<slug>.mdx page (and each legacy\ncontent/<Type>/<slug>.mdx) is written to the workspace as a draft through\nthe API's content routes, with the agent key, which is read from\nAILK_AGENT_KEY only and needs the content-edit grant. Nothing is published,\nso the live site does not change. The site needs no .ailk-site-manifest.json\nand may be a spoke; its dependencies must be installed. A page whose\nfrontmatter carries sections is converted: its words are written to the\npage's Markdown and its sections to its layout.\n\nEach FAQ item and glossary-index term becomes one Language item of its kind,\nset Live when the run creates it, and its section pins the items in the\norder the file lists them. A question on several pages is one item, with\nthe FAQ page's words (the glossary page's, for a term). Lines prefixed\nlanguage: report each item, and each page the database read will not show\nas its file does. An API with no Language collection leaves them inline.\n\nWhen the site has apps/web/content, each blog post and docs page in it is\nwritten too: apps/web/content/en/blog/<slug>.mdx (or <slug>/index.mdx) as a\nBlogPosting, the same under docs as a DocsDetail, and each directory's own\nindex.mdx as its BlogList or DocsList index. Their lines are prefixed\neditorial: \u2014 each outcome's root is content or editorial.\n\nA dry run prints create, update, unchanged, skipped or failed per page. It\ncannot see a write the API would refuse at --apply (a conflict or a server\nerror), nor a layout change on a page whose words did not change, and a\npage whose content fails its page type is planned and saved\nwith errors the editor shows, not failed. --apply prints created, updated,\nunchanged, skipped or failed per page, so a re-run is safe. Exit 0 when no\npage failed, 1 when any did, 2 on a refusal (nothing is sent).\n";
|
|
45
49
|
export declare function parseMigrateContentArgs(argv: string[]): MigrateContentArgs;
|