@jxsuite/import 0.34.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 +58 -0
- package/package.json +47 -0
- package/src/ai-componentize.ts +307 -0
- package/src/apply-styles.ts +103 -0
- package/src/asset-collect.ts +254 -0
- package/src/asset-download.ts +201 -0
- package/src/asset-rewrite.ts +143 -0
- package/src/capture.ts +159 -0
- package/src/cli.ts +153 -0
- package/src/componentize.ts +348 -0
- package/src/crawl.ts +391 -0
- package/src/css-tokens.ts +156 -0
- package/src/emit.ts +231 -0
- package/src/index.ts +53 -0
- package/src/layout-detect.ts +151 -0
- package/src/media-extract.ts +121 -0
- package/src/run.ts +528 -0
- package/src/screenshot-diff.ts +92 -0
- package/src/style-capture.ts +386 -0
- package/src/style-diff.ts +183 -0
- package/src/to-jx.ts +89 -0
- package/src/verify.ts +311 -0
package/src/run.ts
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic import pipeline — the orchestrator behind both the jx-import CLI and the Studio
|
|
3
|
+
* import endpoint. Runs the same phase sequence as the CLI (capture → styles → assets →
|
|
4
|
+
* componentize → emit → verify), reporting progress through a callback instead of the console.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
8
|
+
import { isAbsolute } from "node:path";
|
|
9
|
+
import { capturePage, closeBrowser, launchBrowser } from "./capture.ts";
|
|
10
|
+
import { convertToJx } from "./to-jx.ts";
|
|
11
|
+
import { emitMultiPageProject } from "./emit.ts";
|
|
12
|
+
import { captureStyles } from "./style-capture.ts";
|
|
13
|
+
import { diffAllStyles, kebabToCamel } from "./style-diff.ts";
|
|
14
|
+
import { extractMedia } from "./media-extract.ts";
|
|
15
|
+
import { applyStylesToTree } from "./apply-styles.ts";
|
|
16
|
+
import { collectAssets } from "./asset-collect.ts";
|
|
17
|
+
import { downloadAssets } from "./asset-download.ts";
|
|
18
|
+
import { rewriteAssetUrls } from "./asset-rewrite.ts";
|
|
19
|
+
import { applyTokens } from "./css-tokens.ts";
|
|
20
|
+
import { crawlSite } from "./crawl.ts";
|
|
21
|
+
import { detectLayout } from "./layout-detect.ts";
|
|
22
|
+
import type { Browser } from "puppeteer-core";
|
|
23
|
+
import type { JxElement } from "@jxsuite/schema/types";
|
|
24
|
+
import type { ComponentizeResult } from "./componentize.ts";
|
|
25
|
+
|
|
26
|
+
export interface ImportSiteOptions {
|
|
27
|
+
/** The page to import; must be http(s). */
|
|
28
|
+
url: string;
|
|
29
|
+
/** Absolute path to the (empty or absent) project directory to write. */
|
|
30
|
+
outDir: string;
|
|
31
|
+
/** Max crawl depth; 0 = single-page pipeline (default 2). */
|
|
32
|
+
maxDepth?: number;
|
|
33
|
+
/** Max pages to capture in crawl mode (default 25). */
|
|
34
|
+
maxPages?: number;
|
|
35
|
+
/** Skip styles/assets for pages above this node count (default 5000). */
|
|
36
|
+
maxNodesPerPage?: number;
|
|
37
|
+
/** Capture computed styles (default true). */
|
|
38
|
+
styles?: boolean;
|
|
39
|
+
/** Download and rewrite assets (default true). */
|
|
40
|
+
assets?: boolean;
|
|
41
|
+
/** Scroll to the bottom before capture to trigger lazy content (default true). */
|
|
42
|
+
scroll?: boolean;
|
|
43
|
+
/** Respect robots.txt in crawl mode (default true). */
|
|
44
|
+
respectRobots?: boolean;
|
|
45
|
+
/** Heuristic component extraction; false to skip (default on with standard thresholds). */
|
|
46
|
+
componentize?: false | { minInstances?: number; minDepth?: number };
|
|
47
|
+
/** LLM refinement of component/prop names; false/undefined to skip. */
|
|
48
|
+
ai?: false | { apiKey: string; baseUrl?: string | undefined; model?: string | undefined };
|
|
49
|
+
/** Build + screenshot-diff the emitted project against the original; false to skip. */
|
|
50
|
+
verify?: false | { threshold?: number };
|
|
51
|
+
/** Explicit browser binary (wins over CHROME_PATH and PATH discovery). */
|
|
52
|
+
chromePath?: string;
|
|
53
|
+
/** Aborts between phases (and between crawled pages). */
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ImportPhase =
|
|
58
|
+
| "launch"
|
|
59
|
+
| "capture"
|
|
60
|
+
| "crawl"
|
|
61
|
+
| "styles"
|
|
62
|
+
| "assets"
|
|
63
|
+
| "layout"
|
|
64
|
+
| "components"
|
|
65
|
+
| "ai"
|
|
66
|
+
| "emit"
|
|
67
|
+
| "verify"
|
|
68
|
+
| "done";
|
|
69
|
+
|
|
70
|
+
export interface ImportProgressEvent {
|
|
71
|
+
phase: ImportPhase;
|
|
72
|
+
message: string;
|
|
73
|
+
current?: number;
|
|
74
|
+
total?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ImportSiteResult {
|
|
78
|
+
outDir: string;
|
|
79
|
+
pages: { route: string; title: string; nodeCount: number }[];
|
|
80
|
+
fileCount: number;
|
|
81
|
+
verify: { averageFidelity: number; reportDir: string } | null;
|
|
82
|
+
warnings: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
86
|
+
if (signal?.aborted) {
|
|
87
|
+
throw new Error("Import aborted");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatBytes(bytes: number): string {
|
|
92
|
+
if (bytes < 1024) {
|
|
93
|
+
return `${bytes} B`;
|
|
94
|
+
}
|
|
95
|
+
if (bytes < 1024 * 1024) {
|
|
96
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
97
|
+
}
|
|
98
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run the heuristic componentize pass and refine it with the LLM. Returns undefined when the AI
|
|
103
|
+
* pass is off, componentization is disabled, or the heuristic found nothing (the emit step then
|
|
104
|
+
* runs its own heuristic pass as usual).
|
|
105
|
+
*/
|
|
106
|
+
async function maybeAiComponentize(
|
|
107
|
+
pageMap: Map<string, JxElement>,
|
|
108
|
+
componentizeOptions: false | { minInstances?: number; minDepth?: number },
|
|
109
|
+
ai: ImportSiteOptions["ai"],
|
|
110
|
+
progress: (phase: ImportPhase, message: string) => void,
|
|
111
|
+
): Promise<ComponentizeResult | undefined> {
|
|
112
|
+
if (!ai || componentizeOptions === false) {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const { componentize } = await import("./componentize.ts");
|
|
116
|
+
const { aiComponentize } = await import("./ai-componentize.ts");
|
|
117
|
+
progress("components", "Running heuristic componentization...");
|
|
118
|
+
const heuristic = componentize(pageMap, componentizeOptions);
|
|
119
|
+
if (heuristic.components.size === 0) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
progress("ai", `Found ${heuristic.components.size} component(s), refining with AI...`);
|
|
123
|
+
const refined = await aiComponentize(
|
|
124
|
+
heuristic,
|
|
125
|
+
{ apiKey: ai.apiKey, baseUrl: ai.baseUrl, model: ai.model },
|
|
126
|
+
(msg) => progress("ai", msg),
|
|
127
|
+
);
|
|
128
|
+
progress("ai", `AI refined ${refined.components.size} component(s)`);
|
|
129
|
+
return refined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Import a live website into a Jx project at `outDir`.
|
|
134
|
+
*
|
|
135
|
+
* @param {ImportSiteOptions} options
|
|
136
|
+
* @param {(e: ImportProgressEvent) => void} [onProgress]
|
|
137
|
+
*/
|
|
138
|
+
export async function importSite(
|
|
139
|
+
options: ImportSiteOptions,
|
|
140
|
+
onProgress?: (e: ImportProgressEvent) => void,
|
|
141
|
+
): Promise<ImportSiteResult> {
|
|
142
|
+
const {
|
|
143
|
+
url,
|
|
144
|
+
outDir,
|
|
145
|
+
maxDepth = 2,
|
|
146
|
+
maxPages = 25,
|
|
147
|
+
maxNodesPerPage = 5000,
|
|
148
|
+
styles = true,
|
|
149
|
+
assets = true,
|
|
150
|
+
scroll = true,
|
|
151
|
+
respectRobots = true,
|
|
152
|
+
componentize: componentizeOptions = {},
|
|
153
|
+
ai = false,
|
|
154
|
+
verify = false,
|
|
155
|
+
chromePath,
|
|
156
|
+
signal,
|
|
157
|
+
} = options;
|
|
158
|
+
|
|
159
|
+
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
160
|
+
throw new Error("URL must start with http:// or https://");
|
|
161
|
+
}
|
|
162
|
+
if (!isAbsolute(outDir)) {
|
|
163
|
+
throw new Error(`outDir must be an absolute path, got "${outDir}"`);
|
|
164
|
+
}
|
|
165
|
+
if (existsSync(outDir) && readdirSync(outDir).length > 0) {
|
|
166
|
+
throw new Error(`Directory "${outDir}" is not empty`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const progress = (
|
|
170
|
+
phase: ImportPhase,
|
|
171
|
+
message: string,
|
|
172
|
+
counts?: { current?: number; total?: number },
|
|
173
|
+
) => {
|
|
174
|
+
onProgress?.({ phase, message, ...counts });
|
|
175
|
+
};
|
|
176
|
+
const warnings: string[] = [];
|
|
177
|
+
const warn = (phase: ImportPhase, message: string) => {
|
|
178
|
+
warnings.push(message);
|
|
179
|
+
progress(phase, `⚠ ${message}`);
|
|
180
|
+
};
|
|
181
|
+
const verifyThreshold = verify === false ? 0.15 : (verify.threshold ?? 0.15);
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
if (maxDepth === 0) {
|
|
185
|
+
return await runSinglePage();
|
|
186
|
+
}
|
|
187
|
+
return await runCrawl();
|
|
188
|
+
} finally {
|
|
189
|
+
await closeBrowser();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Single-page mode: Phase 0-2 pipeline, no crawl overhead ────────────────
|
|
193
|
+
async function runSinglePage(): Promise<ImportSiteResult> {
|
|
194
|
+
progress("launch", "Launching browser...");
|
|
195
|
+
const browser = await launchBrowser(
|
|
196
|
+
chromePath === undefined ? {} : { executablePath: chromePath },
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
throwIfAborted(signal);
|
|
200
|
+
progress("capture", "Capturing page...");
|
|
201
|
+
const capture = await capturePage(url, browser, { scrollToBottom: scroll });
|
|
202
|
+
progress("capture", `Captured: "${capture.title}" (${capture.links.length} links found)`);
|
|
203
|
+
|
|
204
|
+
progress("capture", "Converting to Jx...");
|
|
205
|
+
const jx = convertToJx(capture.bodyHtml);
|
|
206
|
+
progress(
|
|
207
|
+
"capture",
|
|
208
|
+
`Converted: ${jx.nodeCount} nodes, ${jx.collectedStyles.length} inline stylesheets collected`,
|
|
209
|
+
);
|
|
210
|
+
if (jx.nodeCount > maxNodesPerPage) {
|
|
211
|
+
warn("capture", `Large page (${jx.nodeCount} nodes). Studio may be slow.`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let breakpoints: Record<string, string> | undefined;
|
|
215
|
+
let styleTokens: Record<string, string> | undefined;
|
|
216
|
+
|
|
217
|
+
if (styles) {
|
|
218
|
+
throwIfAborted(signal);
|
|
219
|
+
progress("styles", "Capturing computed styles...");
|
|
220
|
+
const styleResult = await captureStyles(capture.page);
|
|
221
|
+
progress(
|
|
222
|
+
"styles",
|
|
223
|
+
`Captured styles for ${styleResult.elements.length} elements, ${Object.keys(styleResult.uaDefaults).length} tag baselines`,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
progress("styles", "Diffing against UA defaults...");
|
|
227
|
+
const diffed = diffAllStyles(styleResult.elements, styleResult.uaDefaults);
|
|
228
|
+
progress("styles", `${diffed.length} elements with non-default styles`);
|
|
229
|
+
|
|
230
|
+
// Replace resolved values with var(--name) references
|
|
231
|
+
const propCount = Object.keys(styleResult.customProperties).length;
|
|
232
|
+
if (propCount > 0) {
|
|
233
|
+
const tokenResult = applyTokens(diffed, styleResult.customProperties);
|
|
234
|
+
if (tokenResult.replacements > 0) {
|
|
235
|
+
styleTokens = tokenResult.tokens;
|
|
236
|
+
progress(
|
|
237
|
+
"styles",
|
|
238
|
+
`Extracted ${Object.keys(tokenResult.tokens).length} design tokens (${tokenResult.replacements} replacements)`,
|
|
239
|
+
);
|
|
240
|
+
} else {
|
|
241
|
+
progress(
|
|
242
|
+
"styles",
|
|
243
|
+
`${propCount} custom properties found, but none matched computed values`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (styleResult.mediaQueries.length > 0) {
|
|
249
|
+
progress(
|
|
250
|
+
"styles",
|
|
251
|
+
`Extracting @media breakpoints (${styleResult.mediaQueries.length} queries found)...`,
|
|
252
|
+
);
|
|
253
|
+
const media = await extractMedia(
|
|
254
|
+
capture.page,
|
|
255
|
+
styleResult.elements,
|
|
256
|
+
styleResult.uaDefaults,
|
|
257
|
+
styleResult.mediaQueries,
|
|
258
|
+
);
|
|
259
|
+
const bpCount = Object.keys(media.breakpoints).length;
|
|
260
|
+
if (bpCount > 0) {
|
|
261
|
+
progress("styles", `${bpCount} breakpoints with style changes`);
|
|
262
|
+
({ breakpoints } = media);
|
|
263
|
+
applyStylesToTree(jx.document, diffed, media.deltas);
|
|
264
|
+
} else {
|
|
265
|
+
progress("styles", "No responsive breakpoints with style changes");
|
|
266
|
+
applyStylesToTree(jx.document, diffed);
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
progress("styles", "No @media queries found");
|
|
270
|
+
applyStylesToTree(jx.document, diffed);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Apply <html>/<body> computed styles to the root wrapper
|
|
274
|
+
if (Object.keys(styleResult.documentStyles).length > 0) {
|
|
275
|
+
if (!jx.document.style) {
|
|
276
|
+
jx.document.style = {};
|
|
277
|
+
}
|
|
278
|
+
for (const [prop, val] of Object.entries(styleResult.documentStyles)) {
|
|
279
|
+
const camel = kebabToCamel(prop);
|
|
280
|
+
jx.document.style[camel] = val;
|
|
281
|
+
}
|
|
282
|
+
progress(
|
|
283
|
+
"styles",
|
|
284
|
+
`Applied ${Object.keys(styleResult.documentStyles).length} document-level styles to root`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let fontFaceRules: string[] | undefined;
|
|
290
|
+
let fontRewriteMap: Map<string, string> | undefined;
|
|
291
|
+
|
|
292
|
+
if (assets) {
|
|
293
|
+
throwIfAborted(signal);
|
|
294
|
+
progress("assets", "Collecting asset URLs...");
|
|
295
|
+
const collected = await collectAssets(capture.page);
|
|
296
|
+
progress(
|
|
297
|
+
"assets",
|
|
298
|
+
`Found ${collected.assets.length} assets (${collected.inlineSvgCount} inline SVGs kept, ${collected.stylesheets.length} stylesheets retained)`,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const allFontRules = collected.stylesheets.flatMap((s) => s.fontFaceRules);
|
|
302
|
+
if (allFontRules.length > 0) {
|
|
303
|
+
fontFaceRules = allFontRules;
|
|
304
|
+
progress("assets", `Found ${allFontRules.length} @font-face rules`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (collected.assets.length > 0) {
|
|
308
|
+
progress("assets", "Downloading assets...");
|
|
309
|
+
const downloaded = await downloadAssets(collected.assets, outDir, url);
|
|
310
|
+
progress(
|
|
311
|
+
"assets",
|
|
312
|
+
`Downloaded ${downloaded.rewriteMap.size} assets (${formatBytes(downloaded.totalBytes)})`,
|
|
313
|
+
);
|
|
314
|
+
if (downloaded.failed.length > 0) {
|
|
315
|
+
warn("assets", `${downloaded.failed.length} assets failed to download`);
|
|
316
|
+
}
|
|
317
|
+
if (downloaded.skipped.length > 0) {
|
|
318
|
+
progress("assets", `Skipped ${downloaded.skipped.length} tracking/analytics URLs`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (fontFaceRules) {
|
|
322
|
+
fontRewriteMap = new Map<string, string>();
|
|
323
|
+
for (const [originalUrl, localPath] of downloaded.rewriteMap) {
|
|
324
|
+
if (localPath.includes("/fonts/") || /\.(woff2?|ttf|otf|eot)$/i.test(localPath)) {
|
|
325
|
+
fontRewriteMap.set(originalUrl, localPath);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
progress("assets", "Rewriting asset URLs...");
|
|
331
|
+
const rewrites = rewriteAssetUrls(jx.document, downloaded.rewriteMap, url);
|
|
332
|
+
progress("assets", `Rewrote ${rewrites} URL references`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Capture the reference screenshot before closing the page (for verify)
|
|
337
|
+
let referenceScreenshot: Buffer | undefined;
|
|
338
|
+
if (verify !== false) {
|
|
339
|
+
progress("verify", "Capturing reference screenshot...");
|
|
340
|
+
const { captureReferenceScreenshot } = await import("./verify.ts");
|
|
341
|
+
referenceScreenshot = await captureReferenceScreenshot(capture.page);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await capture.page.close();
|
|
345
|
+
|
|
346
|
+
throwIfAborted(signal);
|
|
347
|
+
const pageMap = new Map([["pages/index.json", jx.document]]);
|
|
348
|
+
const precomputedComponents = await maybeAiComponentize(
|
|
349
|
+
pageMap,
|
|
350
|
+
componentizeOptions,
|
|
351
|
+
ai,
|
|
352
|
+
progress,
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
throwIfAborted(signal);
|
|
356
|
+
progress("emit", "Writing project...");
|
|
357
|
+
const { files } = await emitMultiPageProject({
|
|
358
|
+
outDir,
|
|
359
|
+
title: capture.title,
|
|
360
|
+
pages: pageMap,
|
|
361
|
+
sourceUrl: url,
|
|
362
|
+
breakpoints,
|
|
363
|
+
componentizeOptions: precomputedComponents ? false : componentizeOptions,
|
|
364
|
+
precomputedComponents,
|
|
365
|
+
fontFaceRules,
|
|
366
|
+
fontRewriteMap,
|
|
367
|
+
styleTokens,
|
|
368
|
+
});
|
|
369
|
+
progress("emit", `Wrote ${files.length} files`);
|
|
370
|
+
|
|
371
|
+
let verifySummary: ImportSiteResult["verify"] = null;
|
|
372
|
+
if (verify !== false && referenceScreenshot) {
|
|
373
|
+
verifySummary = await runVerify(
|
|
374
|
+
new Map([["pages/index.json", { sourceUrl: url, screenshot: referenceScreenshot }]]),
|
|
375
|
+
browser,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
progress("done", `Imported ${url} → ${outDir}`);
|
|
380
|
+
return {
|
|
381
|
+
outDir,
|
|
382
|
+
pages: [{ route: "pages/index.json", title: capture.title, nodeCount: jx.nodeCount }],
|
|
383
|
+
fileCount: files.length,
|
|
384
|
+
verify: verifySummary,
|
|
385
|
+
warnings,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─── Multi-page crawl mode (Phase 3) ────────────────────────────────────────
|
|
390
|
+
async function runCrawl(): Promise<ImportSiteResult> {
|
|
391
|
+
progress("launch", "Launching browser...");
|
|
392
|
+
await launchBrowser(chromePath === undefined ? {} : { executablePath: chromePath });
|
|
393
|
+
|
|
394
|
+
throwIfAborted(signal);
|
|
395
|
+
const result = await crawlSite({
|
|
396
|
+
url,
|
|
397
|
+
outDir,
|
|
398
|
+
maxDepth,
|
|
399
|
+
maxPages,
|
|
400
|
+
maxNodesPerPage,
|
|
401
|
+
skipStyles: !styles,
|
|
402
|
+
skipAssets: !assets,
|
|
403
|
+
respectRobots,
|
|
404
|
+
noScroll: !scroll,
|
|
405
|
+
captureScreenshots: verify !== false,
|
|
406
|
+
onProgress: (msg) => progress("crawl", msg.trim()),
|
|
407
|
+
...(signal === undefined ? {} : { signal }),
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
progress(
|
|
411
|
+
"crawl",
|
|
412
|
+
`Crawled ${result.pages.length} page${result.pages.length === 1 ? "" : "s"}`,
|
|
413
|
+
{ current: result.pages.length, total: maxPages },
|
|
414
|
+
);
|
|
415
|
+
if (result.skippedByRobots.length > 0) {
|
|
416
|
+
progress("crawl", `Skipped ${result.skippedByRobots.length} URLs (robots.txt)`);
|
|
417
|
+
}
|
|
418
|
+
if (result.skippedByNodeCap.length > 0) {
|
|
419
|
+
warn(
|
|
420
|
+
"crawl",
|
|
421
|
+
`${result.skippedByNodeCap.length} pages exceeded node cap (styles/assets skipped)`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const pageMap = new Map<string, JxElement>();
|
|
426
|
+
for (const page of result.pages) {
|
|
427
|
+
pageMap.set(page.route, page.jx.document);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Layout detection
|
|
431
|
+
let layout: JxElement | undefined;
|
|
432
|
+
if (result.pages.length >= 2) {
|
|
433
|
+
throwIfAborted(signal);
|
|
434
|
+
progress("layout", "Detecting shared layout (header/footer)...");
|
|
435
|
+
const layoutResult = detectLayout(pageMap);
|
|
436
|
+
if (layoutResult) {
|
|
437
|
+
({ layout } = layoutResult);
|
|
438
|
+
const sharedCount = (Array.isArray(layout.children) ? layout.children.length : 0) - 1; // Minus the slot
|
|
439
|
+
progress("layout", `Found shared layout with ${sharedCount} shared elements`);
|
|
440
|
+
for (const [route, stripped] of layoutResult.strippedPages) {
|
|
441
|
+
pageMap.set(route, stripped);
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
progress("layout", "No shared layout detected");
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
throwIfAborted(signal);
|
|
449
|
+
const precomputedComponents = await maybeAiComponentize(
|
|
450
|
+
pageMap,
|
|
451
|
+
componentizeOptions,
|
|
452
|
+
ai,
|
|
453
|
+
progress,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
throwIfAborted(signal);
|
|
457
|
+
progress("emit", "Writing project...");
|
|
458
|
+
const title = result.pages[0]?.title || new URL(url).hostname;
|
|
459
|
+
const { files } = await emitMultiPageProject({
|
|
460
|
+
outDir,
|
|
461
|
+
title,
|
|
462
|
+
sourceUrl: url,
|
|
463
|
+
pages: pageMap,
|
|
464
|
+
layout,
|
|
465
|
+
breakpoints: result.breakpoints,
|
|
466
|
+
componentizeOptions: precomputedComponents ? false : componentizeOptions,
|
|
467
|
+
precomputedComponents,
|
|
468
|
+
fontFaceRules: result.fontFaceRules.length > 0 ? result.fontFaceRules : undefined,
|
|
469
|
+
fontRewriteMap: result.fontRewriteMap.size > 0 ? result.fontRewriteMap : undefined,
|
|
470
|
+
styleTokens: result.styleTokens,
|
|
471
|
+
});
|
|
472
|
+
progress("emit", `Wrote ${files.length} files`);
|
|
473
|
+
|
|
474
|
+
let verifySummary: ImportSiteResult["verify"] = null;
|
|
475
|
+
if (verify !== false) {
|
|
476
|
+
const verifyPages = new Map<string, { sourceUrl: string; screenshot: Buffer | string }>();
|
|
477
|
+
for (const page of result.pages) {
|
|
478
|
+
if (page.screenshot) {
|
|
479
|
+
verifyPages.set(page.route, { sourceUrl: page.url, screenshot: page.screenshot });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (verifyPages.size === 0) {
|
|
483
|
+
progress("verify", "No reference screenshots captured — skipping verify");
|
|
484
|
+
} else {
|
|
485
|
+
verifySummary = await runVerify(verifyPages);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
progress("done", `Imported ${url} → ${outDir}`);
|
|
490
|
+
return {
|
|
491
|
+
outDir,
|
|
492
|
+
pages: result.pages.map((p) => ({
|
|
493
|
+
route: p.route,
|
|
494
|
+
title: p.title,
|
|
495
|
+
nodeCount: p.jx.nodeCount,
|
|
496
|
+
})),
|
|
497
|
+
fileCount: files.length,
|
|
498
|
+
verify: verifySummary,
|
|
499
|
+
warnings,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ─── Phase 5: build + screenshot-diff against the original ─────────────────
|
|
504
|
+
async function runVerify(
|
|
505
|
+
verifyPages: Map<string, { sourceUrl: string; screenshot: Buffer | string }>,
|
|
506
|
+
browser?: Browser,
|
|
507
|
+
): Promise<ImportSiteResult["verify"]> {
|
|
508
|
+
throwIfAborted(signal);
|
|
509
|
+
progress("verify", "Verifying against the original...");
|
|
510
|
+
const { verifyProject } = await import("./verify.ts");
|
|
511
|
+
const verifyResult = await verifyProject({
|
|
512
|
+
projectDir: outDir,
|
|
513
|
+
pages: verifyPages,
|
|
514
|
+
threshold: verifyThreshold,
|
|
515
|
+
onProgress: (msg) => progress("verify", msg),
|
|
516
|
+
...(browser === undefined ? {} : { browser }),
|
|
517
|
+
});
|
|
518
|
+
for (const page of verifyResult.pages) {
|
|
519
|
+
const status = page.error ? `ERROR: ${page.error}` : `${page.fidelity}% fidelity`;
|
|
520
|
+
progress("verify", `${page.route} — ${status}`);
|
|
521
|
+
}
|
|
522
|
+
progress("verify", `Average fidelity: ${verifyResult.averageFidelity}%`);
|
|
523
|
+
return {
|
|
524
|
+
averageFidelity: verifyResult.averageFidelity,
|
|
525
|
+
reportDir: verifyResult.reportDir,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot-diff — pixel-level PNG comparison for fidelity scoring.
|
|
3
|
+
*
|
|
4
|
+
* Compares two same-size PNG screenshots and returns a fidelity percentage (100% = identical, 0% =
|
|
5
|
+
* completely different) plus an optional diff image.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { PNG } from "pngjs";
|
|
9
|
+
import pixelmatch from "pixelmatch";
|
|
10
|
+
|
|
11
|
+
export interface DiffResult {
|
|
12
|
+
/** 0..100 — percentage of pixels that match (within threshold). */
|
|
13
|
+
fidelity: number;
|
|
14
|
+
/** Total pixel count in the comparison area. */
|
|
15
|
+
totalPixels: number;
|
|
16
|
+
/** Number of pixels that differ beyond the threshold. */
|
|
17
|
+
mismatchedPixels: number;
|
|
18
|
+
/** Width of the compared images. */
|
|
19
|
+
width: number;
|
|
20
|
+
/** Height of the compared images. */
|
|
21
|
+
height: number;
|
|
22
|
+
/** PNG buffer of the diff visualization (mismatches highlighted in red). */
|
|
23
|
+
diffPng: Buffer;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DiffOptions {
|
|
27
|
+
/** Pixelmatch color threshold (0..1, default 0.15 — tolerant of antialiasing/compression). */
|
|
28
|
+
threshold?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compare two PNG buffers pixel-by-pixel.
|
|
33
|
+
*
|
|
34
|
+
* If images differ in size, the smaller is padded (treated as white) to match the larger — size
|
|
35
|
+
* mismatch alone doesn't throw.
|
|
36
|
+
*/
|
|
37
|
+
export function diffScreenshots(
|
|
38
|
+
pngA: Buffer | Uint8Array,
|
|
39
|
+
pngB: Buffer | Uint8Array,
|
|
40
|
+
opts: DiffOptions = {},
|
|
41
|
+
): DiffResult {
|
|
42
|
+
const { threshold = 0.15 } = opts;
|
|
43
|
+
|
|
44
|
+
const imgA = PNG.sync.read(Buffer.from(pngA));
|
|
45
|
+
const imgB = PNG.sync.read(Buffer.from(pngB));
|
|
46
|
+
|
|
47
|
+
const width = Math.max(imgA.width, imgB.width);
|
|
48
|
+
const height = Math.max(imgA.height, imgB.height);
|
|
49
|
+
|
|
50
|
+
const dataA = padToSize(imgA, width, height);
|
|
51
|
+
const dataB = padToSize(imgB, width, height);
|
|
52
|
+
|
|
53
|
+
const diff = new PNG({ width, height });
|
|
54
|
+
const mismatchedPixels = pixelmatch(dataA, dataB, diff.data, width, height, {
|
|
55
|
+
threshold,
|
|
56
|
+
includeAA: true,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const totalPixels = width * height;
|
|
60
|
+
const fidelity = totalPixels > 0 ? ((totalPixels - mismatchedPixels) / totalPixels) * 100 : 100;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
fidelity: Math.round(fidelity * 100) / 100,
|
|
64
|
+
totalPixels,
|
|
65
|
+
mismatchedPixels,
|
|
66
|
+
width,
|
|
67
|
+
height,
|
|
68
|
+
diffPng: PNG.sync.write(diff),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pad a PNG's pixel data to a target size with white pixels. Returns the raw RGBA Uint8Array at the
|
|
74
|
+
* target dimensions.
|
|
75
|
+
*/
|
|
76
|
+
function padToSize(img: PNG, targetWidth: number, targetHeight: number): Uint8Array {
|
|
77
|
+
if (img.width === targetWidth && img.height === targetHeight) {
|
|
78
|
+
return img.data;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const out = new Uint8Array(targetWidth * targetHeight * 4);
|
|
82
|
+
// Fill with white (255,255,255,255)
|
|
83
|
+
out.fill(255);
|
|
84
|
+
|
|
85
|
+
for (let y = 0; y < img.height; y++) {
|
|
86
|
+
const srcOffset = y * img.width * 4;
|
|
87
|
+
const dstOffset = y * targetWidth * 4;
|
|
88
|
+
out.set(img.data.subarray(srcOffset, srcOffset + img.width * 4), dstOffset);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return out;
|
|
92
|
+
}
|