@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/src/run.ts CHANGED
@@ -1,35 +1,24 @@
1
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.
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 { capturePage, closeBrowser, launchBrowser } from "./capture.ts";
11
- import { convertToJx } from "./to-jx.ts";
12
- import { emitMultiPageProject } from "./emit.ts";
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 { diffAllStyles, kebabToCamel } from "./style-diff.ts";
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
- import type { JxElement } from "@jxsuite/schema/types";
32
- import type { ComponentizeResult } from "./componentize.ts";
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
- * The viewport the base capture runs at, and therefore the project's base width.
107
+ * What the CLI and the OSS server ask for when the caller named no model.
148
108
  *
149
- * It is `capture.ts`'s own default; naming it here is what lets the emitted `$media` carry a `"--"`
150
- * base entry that agrees with the styles actually sampled. Without one, Studio's canvas falls back
151
- * to 320px — narrower than every breakpoint an import discovers.
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 CAPTURE_WIDTH = 1440;
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: ImportPhase,
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
- throwIfAborted(signal);
309
- progress("capture", "Capturing page...");
310
- const capture = await capturePage(url, browser, { scrollToBottom: scroll });
311
- progress("capture", `Captured: "${capture.title}" (${capture.links.length} links found)`);
312
-
313
- progress("capture", "Converting to Jx...");
314
- const jx = convertToJx(capture.bodyHtml);
315
- progress(
316
- "capture",
317
- `Converted: ${jx.nodeCount} nodes, ${jx.collectedStyles.length} inline stylesheets collected`,
318
- );
319
- if (jx.nodeCount > maxNodesPerPage) {
320
- warn("capture", `Large page (${jx.nodeCount} nodes). Studio may be slow.`);
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
- `Downloaded ${downloaded.rewriteMap.size} assets (${formatBytes(downloaded.totalBytes)})`,
424
- );
425
- if (downloaded.failed.length > 0) {
426
- warn("assets", `${downloaded.failed.length} assets failed to download`);
427
- }
428
- if (downloaded.skipped.length > 0) {
429
- progress("assets", `Skipped ${downloaded.skipped.length} tracking/analytics URLs`);
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
- const verifyPages = new Map<string, { sourceUrl: string; screenshot: Buffer | string }>();
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
- verifySummary = await runVerify(verifyPages);
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.map((p) => ({
614
- route: p.route,
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
- * Say which breakpoints the project is getting, and where the others went.
223
+ * Create the destination and give it a `project.json` valid enough to open.
635
224
  *
636
- * The plan is a decision made ON THE AUTHOR'S BEHALF — nine declared widths become three — so it
637
- * has to be visible. "9 breakpoints found, keeping 3" with the fold spelled out is the difference
638
- * between a project that mysteriously has different breakpoints from its source and one whose
639
- * import said so at the time.
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 reportBreakpointPlan(mediaQueries: readonly string[]): void {
642
- const skipped = skippedWidthQueries(mediaQueries);
643
- if (skipped.length > 0) {
644
- warn(
645
- "styles",
646
- `${skipped.length} width media ${skipped.length === 1 ? "query" : "queries"} could not be ` +
647
- `read (only single-clause px min-width/max-width is supported): ${skipped.join(", ")}`,
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
- verifyPages: Map<string, { sourceUrl: string; screenshot: Buffer | string }>,
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
+ }