@jxsuite/import 0.40.0 → 0.40.2
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 +8 -30
- package/package.json +6 -4
- package/src/ai-componentize.ts +12 -2
- package/src/apply-accordions.ts +304 -0
- package/src/apply-disclosures.ts +275 -0
- package/src/apply-popovers.ts +298 -0
- package/src/apply-styles.ts +60 -11
- package/src/asset-collect.ts +2 -2
- package/src/asset-download.ts +10 -11
- package/src/asset-rewrite.ts +30 -6
- package/src/browser-local.ts +67 -0
- package/src/capture.ts +50 -61
- package/src/componentize.ts +35 -1
- package/src/crawl.ts +23 -17
- package/src/derived-geometry.ts +115 -0
- package/src/emit.ts +59 -34
- package/src/image-family.ts +227 -0
- package/src/index.ts +11 -5
- package/src/io.ts +52 -0
- package/src/media-extract.ts +75 -6
- package/src/pipeline.ts +673 -0
- package/src/run.ts +93 -503
- package/src/style-capture.ts +201 -162
- package/src/style-diff.ts +17 -4
- package/src/to-jx.ts +27 -1
- package/src/toggle-algebra.ts +167 -0
- package/src/verify.ts +1 -1
package/src/run.ts
CHANGED
|
@@ -1,35 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The local import — the orchestrator behind both the jx-import CLI and the Studio import endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Everything portable moved to `pipeline.ts`. What is left is what only a machine with a disk and a
|
|
5
|
+
* Chrome can do: the empty-directory guard, the `project.json` seed, the local write sink, the
|
|
6
|
+
* browser launch, and the verify phase (which builds the project with `@jxsuite/compiler` and
|
|
7
|
+
* serves it over `Bun.serve` to screenshot it — a hosted platform never executes a project's own
|
|
8
|
+
* JavaScript, so verification does not follow the pipeline anywhere).
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
11
|
import { existsSync, readdirSync } from "node:fs";
|
|
8
12
|
import { isAbsolute, join } from "node:path";
|
|
9
13
|
import { mkdir } from "node:fs/promises";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { captureStyles } from "./style-capture.ts";
|
|
14
|
-
import { extractMedia } from "./media-extract.ts";
|
|
15
|
-
import {
|
|
16
|
-
DEFAULT_BREAKPOINT_POLICY,
|
|
17
|
-
analyzeMediaQueries,
|
|
18
|
-
planBreakpoints,
|
|
19
|
-
skippedWidthQueries,
|
|
20
|
-
} from "./breakpoint-plan.ts";
|
|
14
|
+
import { closeBrowser, launchBrowser } from "./browser-local.ts";
|
|
15
|
+
import { createLocalIo } from "./io.ts";
|
|
16
|
+
import { runImportPipeline } from "./pipeline.ts";
|
|
21
17
|
import type { BreakpointPolicy } from "./breakpoint-plan.ts";
|
|
22
|
-
import {
|
|
23
|
-
import { applyStylesToTree } from "./apply-styles.ts";
|
|
24
|
-
import { collectAssets } from "./asset-collect.ts";
|
|
25
|
-
import { downloadAssets } from "./asset-download.ts";
|
|
26
|
-
import { rewriteAssetUrls } from "./asset-rewrite.ts";
|
|
27
|
-
import { applyTokens } from "./css-tokens.ts";
|
|
28
|
-
import { crawlSite } from "./crawl.ts";
|
|
29
|
-
import { detectLayout } from "./layout-detect.ts";
|
|
18
|
+
import type { ImportPhase, ImportProgressEvent } from "./pipeline.ts";
|
|
30
19
|
import type { Browser } from "puppeteer-core";
|
|
31
|
-
|
|
32
|
-
|
|
20
|
+
|
|
21
|
+
export type { ImportPhase, ImportProgressEvent } from "./pipeline.ts";
|
|
33
22
|
|
|
34
23
|
export interface ImportSiteOptions {
|
|
35
24
|
/** The page to import; must be http(s). */
|
|
@@ -76,35 +65,6 @@ export interface ImportSiteOptions {
|
|
|
76
65
|
signal?: AbortSignal;
|
|
77
66
|
}
|
|
78
67
|
|
|
79
|
-
export type ImportPhase =
|
|
80
|
-
| "seed"
|
|
81
|
-
| "launch"
|
|
82
|
-
| "capture"
|
|
83
|
-
| "crawl"
|
|
84
|
-
| "styles"
|
|
85
|
-
| "assets"
|
|
86
|
-
| "layout"
|
|
87
|
-
| "components"
|
|
88
|
-
| "ai"
|
|
89
|
-
| "emit"
|
|
90
|
-
| "verify"
|
|
91
|
-
| "done";
|
|
92
|
-
|
|
93
|
-
export interface ImportProgressEvent {
|
|
94
|
-
phase: ImportPhase;
|
|
95
|
-
message: string;
|
|
96
|
-
current?: number;
|
|
97
|
-
total?: number;
|
|
98
|
-
/**
|
|
99
|
-
* The project root, on the `seed` event alone.
|
|
100
|
-
*
|
|
101
|
-
* The destination exists and holds a valid `project.json` from that moment, which is what lets a
|
|
102
|
-
* host OPEN it and watch the rest of the run land in its own file tree — rather than staring at a
|
|
103
|
-
* welcome screen for the several minutes a crawl takes.
|
|
104
|
-
*/
|
|
105
|
-
root?: string;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
68
|
export interface ImportSiteResult {
|
|
109
69
|
outDir: string;
|
|
110
70
|
pages: { route: string; title: string; nodeCount: number }[];
|
|
@@ -144,13 +104,13 @@ export interface ImportSiteResult {
|
|
|
144
104
|
}
|
|
145
105
|
|
|
146
106
|
/**
|
|
147
|
-
*
|
|
107
|
+
* What the CLI and the OSS server ask for when the caller named no model.
|
|
148
108
|
*
|
|
149
|
-
* It
|
|
150
|
-
*
|
|
151
|
-
*
|
|
109
|
+
* It lives at the call site rather than inside `ai-componentize.ts`, because a default belongs to
|
|
110
|
+
* whoever knows the provider. This path talks to an OpenAI-compatible endpoint; a backend brokering
|
|
111
|
+
* Workers AI would 404 on this id, which is exactly why the module below it demands one.
|
|
152
112
|
*/
|
|
153
|
-
const
|
|
113
|
+
const DEFAULT_AI_MODEL = "gpt-4o-mini";
|
|
154
114
|
|
|
155
115
|
/** The one file the seed writes, and the only thing the empty-directory guard tolerates. */
|
|
156
116
|
const PROJECT_FILE = "project.json";
|
|
@@ -164,53 +124,6 @@ function seedName(url: string): string {
|
|
|
164
124
|
}
|
|
165
125
|
}
|
|
166
126
|
|
|
167
|
-
function throwIfAborted(signal?: AbortSignal): void {
|
|
168
|
-
if (signal?.aborted) {
|
|
169
|
-
throw new Error("Import aborted");
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function formatBytes(bytes: number): string {
|
|
174
|
-
if (bytes < 1024) {
|
|
175
|
-
return `${bytes} B`;
|
|
176
|
-
}
|
|
177
|
-
if (bytes < 1024 * 1024) {
|
|
178
|
-
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
179
|
-
}
|
|
180
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Run the heuristic componentize pass and refine it with the LLM. Returns undefined when the AI
|
|
185
|
-
* pass is off, componentization is disabled, or the heuristic found nothing (the emit step then
|
|
186
|
-
* runs its own heuristic pass as usual).
|
|
187
|
-
*/
|
|
188
|
-
async function maybeAiComponentize(
|
|
189
|
-
pageMap: Map<string, JxElement>,
|
|
190
|
-
componentizeOptions: false | { minInstances?: number; minDepth?: number },
|
|
191
|
-
ai: ImportSiteOptions["ai"],
|
|
192
|
-
progress: (phase: ImportPhase, message: string) => void,
|
|
193
|
-
): Promise<ComponentizeResult | undefined> {
|
|
194
|
-
if (!ai || componentizeOptions === false) {
|
|
195
|
-
return undefined;
|
|
196
|
-
}
|
|
197
|
-
const { componentize } = await import("./componentize.ts");
|
|
198
|
-
const { aiComponentize } = await import("./ai-componentize.ts");
|
|
199
|
-
progress("components", "Running heuristic componentization...");
|
|
200
|
-
const heuristic = componentize(pageMap, componentizeOptions);
|
|
201
|
-
if (heuristic.components.size === 0) {
|
|
202
|
-
return undefined;
|
|
203
|
-
}
|
|
204
|
-
progress("ai", `Found ${heuristic.components.size} component(s), refining with AI...`);
|
|
205
|
-
const refined = await aiComponentize(
|
|
206
|
-
heuristic,
|
|
207
|
-
{ apiKey: ai.apiKey, baseUrl: ai.baseUrl, model: ai.model },
|
|
208
|
-
(msg) => progress("ai", msg),
|
|
209
|
-
);
|
|
210
|
-
progress("ai", `AI refined ${refined.components.size} component(s)`);
|
|
211
|
-
return refined;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
127
|
/**
|
|
215
128
|
* Import a live website into a Jx project at `outDir`.
|
|
216
129
|
*
|
|
@@ -221,23 +134,7 @@ export async function importSite(
|
|
|
221
134
|
options: ImportSiteOptions,
|
|
222
135
|
onProgress?: (e: ImportProgressEvent) => void,
|
|
223
136
|
): Promise<ImportSiteResult> {
|
|
224
|
-
const {
|
|
225
|
-
url,
|
|
226
|
-
outDir,
|
|
227
|
-
maxDepth = 2,
|
|
228
|
-
maxPages = 25,
|
|
229
|
-
maxNodesPerPage = 5000,
|
|
230
|
-
styles = true,
|
|
231
|
-
assets = true,
|
|
232
|
-
scroll = true,
|
|
233
|
-
respectRobots = true,
|
|
234
|
-
componentize: componentizeOptions = {},
|
|
235
|
-
ai = false,
|
|
236
|
-
verify = false,
|
|
237
|
-
chromePath,
|
|
238
|
-
signal,
|
|
239
|
-
breakpoints: breakpointPolicy = DEFAULT_BREAKPOINT_POLICY,
|
|
240
|
-
} = options;
|
|
137
|
+
const { url, outDir, maxDepth = 2, ai = false, verify = false, chromePath, signal } = options;
|
|
241
138
|
|
|
242
139
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
243
140
|
throw new Error("URL must start with http:// or https://");
|
|
@@ -253,12 +150,8 @@ export async function importSite(
|
|
|
253
150
|
throw new Error(`Directory "${outDir}" is not empty`);
|
|
254
151
|
}
|
|
255
152
|
|
|
256
|
-
const progress = (
|
|
257
|
-
phase
|
|
258
|
-
message: string,
|
|
259
|
-
counts?: { current?: number; total?: number },
|
|
260
|
-
) => {
|
|
261
|
-
onProgress?.({ phase, message, ...counts });
|
|
153
|
+
const progress = (phase: ImportPhase, message: string) => {
|
|
154
|
+
onProgress?.({ phase, message });
|
|
262
155
|
};
|
|
263
156
|
const warnings: string[] = [];
|
|
264
157
|
const warn = (phase: ImportPhase, message: string) => {
|
|
@@ -271,418 +164,92 @@ export async function importSite(
|
|
|
271
164
|
|
|
272
165
|
try {
|
|
273
166
|
await seedProject();
|
|
274
|
-
if (maxDepth === 0) {
|
|
275
|
-
return await runSinglePage();
|
|
276
|
-
}
|
|
277
|
-
return await runCrawl();
|
|
278
|
-
} finally {
|
|
279
|
-
await closeBrowser();
|
|
280
|
-
}
|
|
281
167
|
|
|
282
|
-
/**
|
|
283
|
-
* Create the destination and give it a `project.json` valid enough to open.
|
|
284
|
-
*
|
|
285
|
-
* The emit phase rewrites this file completely, so nothing here is a guess at what the import
|
|
286
|
-
* will find — it is the minimum that makes the directory a PROJECT, so the caller can open it now
|
|
287
|
-
* instead of at the end. Everything a crawl writes then lands in a file tree somebody is
|
|
288
|
-
* watching.
|
|
289
|
-
*/
|
|
290
|
-
async function seedProject(): Promise<void> {
|
|
291
|
-
await mkdir(outDir, { recursive: true });
|
|
292
|
-
const seed = { name: seedName(url), imports: {}, images: { optimize: false } };
|
|
293
|
-
await Bun.write(join(outDir, PROJECT_FILE), `${JSON.stringify(seed, null, 2)}\n`);
|
|
294
|
-
onProgress?.({
|
|
295
|
-
message: `Created ${outDir}`,
|
|
296
|
-
phase: "seed",
|
|
297
|
-
root: outDir,
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// ─── Single-page mode: Phase 0-2 pipeline, no crawl overhead ────────────────
|
|
302
|
-
async function runSinglePage(): Promise<ImportSiteResult> {
|
|
303
168
|
progress("launch", "Launching browser...");
|
|
304
169
|
const browser = await launchBrowser(
|
|
305
170
|
chromePath === undefined ? {} : { executablePath: chromePath },
|
|
306
171
|
);
|
|
307
172
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
let breakpoints: Record<string, string> | undefined;
|
|
324
|
-
let styleTokens: Record<string, string> | undefined;
|
|
325
|
-
|
|
326
|
-
if (styles) {
|
|
327
|
-
throwIfAborted(signal);
|
|
328
|
-
progress("styles", "Capturing computed styles...");
|
|
329
|
-
const styleResult = await captureStyles(capture.page);
|
|
330
|
-
progress(
|
|
331
|
-
"styles",
|
|
332
|
-
`Captured styles for ${styleResult.elements.length} elements, ${Object.keys(styleResult.uaDefaults).length} tag baselines`,
|
|
333
|
-
);
|
|
334
|
-
|
|
335
|
-
progress("styles", "Diffing against UA defaults...");
|
|
336
|
-
const diffed = diffAllStyles(styleResult.elements, styleResult.uaDefaults);
|
|
337
|
-
progress("styles", `${diffed.length} elements with non-default styles`);
|
|
338
|
-
|
|
339
|
-
// Replace resolved values with var(--name) references
|
|
340
|
-
const propCount = Object.keys(styleResult.customProperties).length;
|
|
341
|
-
if (propCount > 0) {
|
|
342
|
-
const tokenResult = applyTokens(diffed, styleResult.customProperties);
|
|
343
|
-
if (tokenResult.replacements > 0) {
|
|
344
|
-
styleTokens = tokenResult.tokens;
|
|
345
|
-
progress(
|
|
346
|
-
"styles",
|
|
347
|
-
`Extracted ${Object.keys(tokenResult.tokens).length} design tokens (${tokenResult.replacements} replacements)`,
|
|
348
|
-
);
|
|
349
|
-
} else {
|
|
350
|
-
progress(
|
|
351
|
-
"styles",
|
|
352
|
-
`${propCount} custom properties found, but none matched computed values`,
|
|
353
|
-
);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
if (styleResult.mediaQueries.length > 0) {
|
|
358
|
-
progress(
|
|
359
|
-
"styles",
|
|
360
|
-
`Extracting @media breakpoints (${styleResult.mediaQueries.length} queries found)...`,
|
|
361
|
-
);
|
|
362
|
-
reportBreakpointPlan(styleResult.mediaQueries);
|
|
363
|
-
const media = await extractMedia(
|
|
364
|
-
capture.page,
|
|
365
|
-
styleResult.elements,
|
|
366
|
-
styleResult.uaDefaults,
|
|
367
|
-
styleResult.mediaQueries,
|
|
368
|
-
{ policy: breakpointPolicy },
|
|
369
|
-
);
|
|
370
|
-
const bpCount = Object.keys(media.breakpoints).length;
|
|
371
|
-
if (bpCount > 0) {
|
|
372
|
-
progress("styles", `${bpCount} breakpoints with style changes`);
|
|
373
|
-
({ breakpoints } = media);
|
|
374
|
-
applyStylesToTree(jx.document, diffed, media.deltas);
|
|
375
|
-
} else {
|
|
376
|
-
progress("styles", "No responsive breakpoints with style changes");
|
|
377
|
-
applyStylesToTree(jx.document, diffed);
|
|
378
|
-
}
|
|
379
|
-
} else {
|
|
380
|
-
progress("styles", "No @media queries found");
|
|
381
|
-
applyStylesToTree(jx.document, diffed);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Apply <html>/<body> computed styles to the root wrapper
|
|
385
|
-
if (Object.keys(styleResult.documentStyles).length > 0) {
|
|
386
|
-
if (!jx.document.style) {
|
|
387
|
-
jx.document.style = {};
|
|
388
|
-
}
|
|
389
|
-
for (const [prop, val] of Object.entries(styleResult.documentStyles)) {
|
|
390
|
-
const camel = kebabToCamel(prop);
|
|
391
|
-
jx.document.style[camel] = val;
|
|
392
|
-
}
|
|
393
|
-
progress(
|
|
173
|
+
const result = await runImportPipeline(
|
|
174
|
+
{
|
|
175
|
+
url,
|
|
176
|
+
browser,
|
|
177
|
+
io: createLocalIo(outDir),
|
|
178
|
+
maxDepth,
|
|
179
|
+
/* The model default is applied here so the pipeline can require one. See DEFAULT_AI_MODEL. */
|
|
180
|
+
ai: ai === false ? false : { ...ai, model: ai.model ?? DEFAULT_AI_MODEL },
|
|
181
|
+
referenceScreenshots: verify === false ? false : { fullPage: verifyFullPage },
|
|
182
|
+
...pick(options, [
|
|
183
|
+
"maxPages",
|
|
184
|
+
"maxNodesPerPage",
|
|
185
|
+
"breakpoints",
|
|
394
186
|
"styles",
|
|
395
|
-
`Applied ${Object.keys(styleResult.documentStyles).length} document-level styles to root`,
|
|
396
|
-
);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
let fontFaceRules: string[] | undefined;
|
|
401
|
-
let fontRewriteMap: Map<string, string> | undefined;
|
|
402
|
-
|
|
403
|
-
if (assets) {
|
|
404
|
-
throwIfAborted(signal);
|
|
405
|
-
progress("assets", "Collecting asset URLs...");
|
|
406
|
-
const collected = await collectAssets(capture.page);
|
|
407
|
-
progress(
|
|
408
|
-
"assets",
|
|
409
|
-
`Found ${collected.assets.length} assets (${collected.inlineSvgCount} inline SVGs kept, ${collected.stylesheets.length} stylesheets retained)`,
|
|
410
|
-
);
|
|
411
|
-
|
|
412
|
-
const allFontRules = collected.stylesheets.flatMap((s) => s.fontFaceRules);
|
|
413
|
-
if (allFontRules.length > 0) {
|
|
414
|
-
fontFaceRules = allFontRules;
|
|
415
|
-
progress("assets", `Found ${allFontRules.length} @font-face rules`);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
if (collected.assets.length > 0) {
|
|
419
|
-
progress("assets", "Downloading assets...");
|
|
420
|
-
const downloaded = await downloadAssets(collected.assets, outDir, url);
|
|
421
|
-
progress(
|
|
422
187
|
"assets",
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if (fontFaceRules) {
|
|
433
|
-
fontRewriteMap = new Map<string, string>();
|
|
434
|
-
for (const [originalUrl, localPath] of downloaded.rewriteMap) {
|
|
435
|
-
if (localPath.includes("/fonts/") || /\.(woff2?|ttf|otf|eot)$/i.test(localPath)) {
|
|
436
|
-
fontRewriteMap.set(originalUrl, localPath);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
progress("assets", "Rewriting asset URLs...");
|
|
442
|
-
const rewrites = rewriteAssetUrls(jx.document, downloaded.rewriteMap, url);
|
|
443
|
-
progress("assets", `Rewrote ${rewrites} URL references`);
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// Capture the reference screenshot before closing the page (for verify)
|
|
448
|
-
let referenceScreenshot: Buffer | undefined;
|
|
449
|
-
if (verify !== false) {
|
|
450
|
-
progress("verify", "Capturing reference screenshot...");
|
|
451
|
-
const { captureReferenceScreenshot } = await import("./verify.ts");
|
|
452
|
-
referenceScreenshot = await captureReferenceScreenshot(
|
|
453
|
-
capture.page,
|
|
454
|
-
undefined,
|
|
455
|
-
undefined,
|
|
456
|
-
verifyFullPage,
|
|
457
|
-
);
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
await capture.page.close();
|
|
461
|
-
|
|
462
|
-
throwIfAborted(signal);
|
|
463
|
-
const pageMap = new Map([["pages/index.json", jx.document]]);
|
|
464
|
-
const precomputedComponents = await maybeAiComponentize(
|
|
465
|
-
pageMap,
|
|
466
|
-
componentizeOptions,
|
|
467
|
-
ai,
|
|
468
|
-
progress,
|
|
188
|
+
"scroll",
|
|
189
|
+
"respectRobots",
|
|
190
|
+
"componentize",
|
|
191
|
+
"signal",
|
|
192
|
+
]),
|
|
193
|
+
},
|
|
194
|
+
onProgress,
|
|
469
195
|
);
|
|
470
|
-
|
|
471
|
-
throwIfAborted(signal);
|
|
472
|
-
progress("emit", "Writing project...");
|
|
473
|
-
const { files, classesStripped } = await emitMultiPageProject({
|
|
474
|
-
outDir,
|
|
475
|
-
title: capture.title,
|
|
476
|
-
pages: pageMap,
|
|
477
|
-
sourceUrl: url,
|
|
478
|
-
breakpoints,
|
|
479
|
-
baseWidth: CAPTURE_WIDTH,
|
|
480
|
-
componentizeOptions: precomputedComponents ? false : componentizeOptions,
|
|
481
|
-
precomputedComponents,
|
|
482
|
-
fontFaceRules,
|
|
483
|
-
fontRewriteMap,
|
|
484
|
-
styleTokens,
|
|
485
|
-
});
|
|
486
|
-
reportEmit(files.length, classesStripped);
|
|
487
|
-
|
|
488
|
-
let verifySummary: ImportSiteResult["verify"] = null;
|
|
489
|
-
if (verify !== false && referenceScreenshot) {
|
|
490
|
-
verifySummary = await runVerify(
|
|
491
|
-
new Map([["pages/index.json", { sourceUrl: url, screenshot: referenceScreenshot }]]),
|
|
492
|
-
browser,
|
|
493
|
-
);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
progress("done", `Imported ${url} → ${outDir}`);
|
|
497
|
-
return {
|
|
498
|
-
outDir,
|
|
499
|
-
pages: [{ route: "pages/index.json", title: capture.title, nodeCount: jx.nodeCount }],
|
|
500
|
-
fileCount: files.length,
|
|
501
|
-
verify: verifySummary,
|
|
502
|
-
warnings,
|
|
503
|
-
};
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// ─── Multi-page crawl mode (Phase 3) ────────────────────────────────────────
|
|
507
|
-
async function runCrawl(): Promise<ImportSiteResult> {
|
|
508
|
-
progress("launch", "Launching browser...");
|
|
509
|
-
await launchBrowser(chromePath === undefined ? {} : { executablePath: chromePath });
|
|
510
|
-
|
|
511
|
-
throwIfAborted(signal);
|
|
512
|
-
const result = await crawlSite({
|
|
513
|
-
url,
|
|
514
|
-
outDir,
|
|
515
|
-
maxDepth,
|
|
516
|
-
maxPages,
|
|
517
|
-
maxNodesPerPage,
|
|
518
|
-
skipStyles: !styles,
|
|
519
|
-
skipAssets: !assets,
|
|
520
|
-
respectRobots,
|
|
521
|
-
noScroll: !scroll,
|
|
522
|
-
captureScreenshots: verify !== false,
|
|
523
|
-
// The reference and the render must be framed the same way, or the diff pads one of them.
|
|
524
|
-
fullPageScreenshots: verifyFullPage,
|
|
525
|
-
breakpoints: breakpointPolicy,
|
|
526
|
-
onProgress: (msg) => progress("crawl", msg.trim()),
|
|
527
|
-
...(signal === undefined ? {} : { signal }),
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
progress(
|
|
531
|
-
"crawl",
|
|
532
|
-
`Crawled ${result.pages.length} page${result.pages.length === 1 ? "" : "s"}`,
|
|
533
|
-
{ current: result.pages.length, total: maxPages },
|
|
534
|
-
);
|
|
535
|
-
if (result.skippedByRobots.length > 0) {
|
|
536
|
-
progress("crawl", `Skipped ${result.skippedByRobots.length} URLs (robots.txt)`);
|
|
537
|
-
}
|
|
538
|
-
if (result.skippedByNodeCap.length > 0) {
|
|
539
|
-
warn(
|
|
540
|
-
"crawl",
|
|
541
|
-
`${result.skippedByNodeCap.length} pages exceeded node cap (styles/assets skipped)`,
|
|
542
|
-
);
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
const pageMap = new Map<string, JxElement>();
|
|
546
|
-
for (const page of result.pages) {
|
|
547
|
-
pageMap.set(page.route, page.jx.document);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// Layout detection
|
|
551
|
-
let layout: JxElement | undefined;
|
|
552
|
-
if (result.pages.length >= 2) {
|
|
553
|
-
throwIfAborted(signal);
|
|
554
|
-
progress("layout", "Detecting shared layout (header/footer)...");
|
|
555
|
-
const layoutResult = detectLayout(pageMap);
|
|
556
|
-
if (layoutResult) {
|
|
557
|
-
({ layout } = layoutResult);
|
|
558
|
-
const sharedCount = (Array.isArray(layout.children) ? layout.children.length : 0) - 1; // Minus the slot
|
|
559
|
-
progress("layout", `Found shared layout with ${sharedCount} shared elements`);
|
|
560
|
-
for (const [route, stripped] of layoutResult.strippedPages) {
|
|
561
|
-
pageMap.set(route, stripped);
|
|
562
|
-
}
|
|
563
|
-
} else {
|
|
564
|
-
progress("layout", "No shared layout detected");
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
throwIfAborted(signal);
|
|
569
|
-
const precomputedComponents = await maybeAiComponentize(
|
|
570
|
-
pageMap,
|
|
571
|
-
componentizeOptions,
|
|
572
|
-
ai,
|
|
573
|
-
progress,
|
|
574
|
-
);
|
|
575
|
-
|
|
576
|
-
throwIfAborted(signal);
|
|
577
|
-
progress("emit", "Writing project...");
|
|
578
|
-
const title = result.pages[0]?.title || new URL(url).hostname;
|
|
579
|
-
const { files, classesStripped } = await emitMultiPageProject({
|
|
580
|
-
outDir,
|
|
581
|
-
title,
|
|
582
|
-
sourceUrl: url,
|
|
583
|
-
pages: pageMap,
|
|
584
|
-
layout,
|
|
585
|
-
breakpoints: result.breakpoints,
|
|
586
|
-
baseWidth: CAPTURE_WIDTH,
|
|
587
|
-
componentizeOptions: precomputedComponents ? false : componentizeOptions,
|
|
588
|
-
precomputedComponents,
|
|
589
|
-
fontFaceRules: result.fontFaceRules.length > 0 ? result.fontFaceRules : undefined,
|
|
590
|
-
fontRewriteMap: result.fontRewriteMap.size > 0 ? result.fontRewriteMap : undefined,
|
|
591
|
-
styleTokens: result.styleTokens,
|
|
592
|
-
});
|
|
593
|
-
reportEmit(files.length, classesStripped);
|
|
196
|
+
warnings.push(...result.warnings);
|
|
594
197
|
|
|
595
198
|
let verifySummary: ImportSiteResult["verify"] = null;
|
|
596
199
|
if (verify !== false) {
|
|
597
|
-
|
|
598
|
-
for (const page of result.pages) {
|
|
599
|
-
if (page.screenshot) {
|
|
600
|
-
verifyPages.set(page.route, { sourceUrl: page.url, screenshot: page.screenshot });
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
if (verifyPages.size === 0) {
|
|
200
|
+
if (result.references.size === 0) {
|
|
604
201
|
progress("verify", "No reference screenshots captured — skipping verify");
|
|
605
202
|
} else {
|
|
606
|
-
|
|
203
|
+
/* Only the single-page run hands the verifier its browser. A crawl's pages were captured
|
|
204
|
+
and closed one at a time, so by now the connection has outlived every page it opened and
|
|
205
|
+
`verifyProject` launching its own is the same cost either way. */
|
|
206
|
+
verifySummary = await runVerify(result.references, maxDepth === 0 ? browser : undefined);
|
|
607
207
|
}
|
|
608
208
|
}
|
|
609
209
|
|
|
610
210
|
progress("done", `Imported ${url} → ${outDir}`);
|
|
611
211
|
return {
|
|
612
212
|
outDir,
|
|
613
|
-
pages: result.pages
|
|
614
|
-
|
|
615
|
-
title: p.title,
|
|
616
|
-
nodeCount: p.jx.nodeCount,
|
|
617
|
-
})),
|
|
618
|
-
fileCount: files.length,
|
|
213
|
+
pages: result.pages,
|
|
214
|
+
fileCount: result.files.length,
|
|
619
215
|
verify: verifySummary,
|
|
620
216
|
warnings,
|
|
621
217
|
};
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/** One emit line, naming what the strip pass removed — silence would read as "nothing to do". */
|
|
625
|
-
function reportEmit(fileCount: number, classesStripped: number): void {
|
|
626
|
-
const classes =
|
|
627
|
-
classesStripped > 0
|
|
628
|
-
? `, stripped ${classesStripped} source class name${classesStripped === 1 ? "" : "s"}`
|
|
629
|
-
: "";
|
|
630
|
-
progress("emit", `Wrote ${fileCount} files${classes}`);
|
|
218
|
+
} finally {
|
|
219
|
+
await closeBrowser();
|
|
631
220
|
}
|
|
632
221
|
|
|
633
222
|
/**
|
|
634
|
-
*
|
|
223
|
+
* Create the destination and give it a `project.json` valid enough to open.
|
|
635
224
|
*
|
|
636
|
-
* The
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
225
|
+
* The emit phase rewrites this file completely, so nothing here is a guess at what the import
|
|
226
|
+
* will find — it is the minimum that makes the directory a PROJECT, so the caller can open it now
|
|
227
|
+
* instead of at the end. Everything a crawl writes then lands in a file tree somebody is
|
|
228
|
+
* watching.
|
|
640
229
|
*/
|
|
641
|
-
function
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
const discovered = analyzeMediaQueries(mediaQueries);
|
|
651
|
-
if (discovered.length === 0) {
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
const { keep, fold } = planBreakpoints(discovered, breakpointPolicy);
|
|
655
|
-
if (keep.length === discovered.length) {
|
|
656
|
-
progress("styles", `Keeping all ${keep.length} declared breakpoints`);
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
659
|
-
const folded = new Map<string, string[]>();
|
|
660
|
-
for (const [from, to] of fold) {
|
|
661
|
-
if (from === to) {
|
|
662
|
-
continue;
|
|
663
|
-
}
|
|
664
|
-
folded.set(to, [...(folded.get(to) ?? []), from.replace(/^--/, "")]);
|
|
665
|
-
}
|
|
666
|
-
const detail = keep
|
|
667
|
-
.map((bp) => {
|
|
668
|
-
const into = folded.get(bp.name) ?? [];
|
|
669
|
-
return into.length > 0 ? `${bp.name} (+${into.join(", ")})` : bp.name;
|
|
670
|
-
})
|
|
671
|
-
.join(", ");
|
|
672
|
-
progress(
|
|
673
|
-
"styles",
|
|
674
|
-
`${discovered.length} breakpoints declared, keeping ${keep.length}: ${detail}`,
|
|
675
|
-
);
|
|
230
|
+
async function seedProject(): Promise<void> {
|
|
231
|
+
await mkdir(outDir, { recursive: true });
|
|
232
|
+
const seed = { name: seedName(url), imports: {}, images: { optimize: false } };
|
|
233
|
+
await Bun.write(join(outDir, PROJECT_FILE), `${JSON.stringify(seed, null, 2)}\n`);
|
|
234
|
+
onProgress?.({
|
|
235
|
+
message: `Created ${outDir}`,
|
|
236
|
+
phase: "seed",
|
|
237
|
+
root: outDir,
|
|
238
|
+
});
|
|
676
239
|
}
|
|
677
240
|
|
|
678
241
|
// ─── Phase 5: build + screenshot-diff against the original ─────────────────
|
|
679
242
|
async function runVerify(
|
|
680
|
-
|
|
243
|
+
references: Map<string, { sourceUrl: string; screenshot: Uint8Array }>,
|
|
681
244
|
browser?: Browser,
|
|
682
245
|
): Promise<ImportSiteResult["verify"]> {
|
|
683
246
|
throwIfAborted(signal);
|
|
684
247
|
progress("verify", "Verifying against the original...");
|
|
685
248
|
const { verifyProject } = await import("./verify.ts");
|
|
249
|
+
const verifyPages = new Map<string, { sourceUrl: string; screenshot: Buffer | string }>();
|
|
250
|
+
for (const [route, ref] of references) {
|
|
251
|
+
verifyPages.set(route, { sourceUrl: ref.sourceUrl, screenshot: Buffer.from(ref.screenshot) });
|
|
252
|
+
}
|
|
686
253
|
const verifyResult = await verifyProject({
|
|
687
254
|
projectDir: outDir,
|
|
688
255
|
pages: verifyPages,
|
|
@@ -741,3 +308,26 @@ export async function importSite(
|
|
|
741
308
|
};
|
|
742
309
|
}
|
|
743
310
|
}
|
|
311
|
+
|
|
312
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
313
|
+
if (signal?.aborted) {
|
|
314
|
+
throw new Error("Import aborted");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Forward the options this wrapper shares with the pipeline, and only the ones actually given.
|
|
320
|
+
*
|
|
321
|
+
* `exactOptionalPropertyTypes` is on, so `{ signal: undefined }` is a different thing from an
|
|
322
|
+
* absent `signal` and the pipeline would reject it. Copying present keys is also what keeps every
|
|
323
|
+
* default in ONE place — restating `maxPages = 25` here is how the CLI and the cloud drift apart.
|
|
324
|
+
*/
|
|
325
|
+
function pick<T extends object, K extends keyof T>(source: T, keys: readonly K[]): Pick<T, K> {
|
|
326
|
+
const out = {} as Pick<T, K>;
|
|
327
|
+
for (const key of keys) {
|
|
328
|
+
if (source[key] !== undefined) {
|
|
329
|
+
out[key] = source[key];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|