@jxsuite/import 0.39.8 → 0.40.1

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/capture.ts CHANGED
@@ -1,5 +1,44 @@
1
- import { launch } from "puppeteer-core";
2
- import type { Browser, Page } from "puppeteer-core";
1
+ /**
2
+ * Page capture, and the browser surface the whole import is written against.
3
+ *
4
+ * That surface is declared STRUCTURALLY here rather than imported from `puppeteer-core`, and the
5
+ * reason is bundling, not taste: `puppeteer-core` is unloadable in workerd, and a Worker bundler
6
+ * follows value imports transitively, so a single one anywhere in `pipeline.ts`'s graph would fail
7
+ * the deploy. Naming only `newPage` — and, on a page, the five calls the phases actually make —
8
+ * lets the same code drive a local Chrome (`browser-local.ts`) or a `@cloudflare/puppeteer`
9
+ * session, both of which provide exactly this and more.
10
+ */
11
+
12
+ /**
13
+ * What `evaluate` hands the in-page function.
14
+ *
15
+ * A homomorphic mapped type rather than plain `Params`, because puppeteer's own `evaluate` declares
16
+ * its parameters through one (it unwraps `JSHandle`s there). Two generic signatures only relate
17
+ * when they are shaped alike, and with a bare tuple puppeteer's `Page` stops being assignable to
18
+ * `ImportPage` — which is the one thing this interface has to be true of.
19
+ */
20
+ export type PageArgs<Params extends unknown[]> = { [K in keyof Params]: Params[K] };
21
+
22
+ export interface ImportPage {
23
+ goto: (
24
+ url: string,
25
+ options?: {
26
+ waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
27
+ timeout?: number;
28
+ },
29
+ ) => Promise<unknown>;
30
+ setViewport: (viewport: { width: number; height: number }) => Promise<unknown>;
31
+ evaluate: <Params extends unknown[], R>(
32
+ fn: (...params: PageArgs<Params>) => R,
33
+ ...args: Params
34
+ ) => Promise<Awaited<R>>;
35
+ screenshot: (options?: { fullPage?: boolean; type?: "png" }) => Promise<Uint8Array>;
36
+ close: () => Promise<unknown>;
37
+ }
38
+
39
+ export interface ImportBrowser {
40
+ newPage: () => Promise<ImportPage>;
41
+ }
3
42
 
4
43
  export interface CaptureResult {
5
44
  url: string;
@@ -7,61 +46,8 @@ export interface CaptureResult {
7
46
  bodyHtml: string;
8
47
  /** Discovered same-origin links for the crawler (Phase 3). */
9
48
  links: string[];
10
- /** The puppeteer Page, kept open for style capture (Phase 1). Caller must close. */
11
- page: Page;
12
- }
13
-
14
- const DEFAULT_CHROME_PATHS = [
15
- "google-chrome-stable",
16
- "google-chrome",
17
- "chromium-browser",
18
- "chromium",
19
- ];
20
-
21
- function findChrome(executablePath?: string): string {
22
- if (executablePath) {
23
- return executablePath;
24
- }
25
- const env = process.env.CHROME_PATH;
26
- if (env) {
27
- return env;
28
- }
29
-
30
- for (const name of DEFAULT_CHROME_PATHS) {
31
- const path = Bun.which(name);
32
- if (path) {
33
- return path;
34
- }
35
- }
36
- throw new Error(
37
- "Could not find Chrome/Chromium. Set CHROME_PATH or install google-chrome-stable.",
38
- );
39
- }
40
-
41
- let _browser: Browser | null = null;
42
-
43
- export interface LaunchOptions {
44
- /** Explicit browser binary. Wins over CHROME_PATH and PATH discovery. */
45
- executablePath?: string;
46
- }
47
-
48
- export async function launchBrowser(options?: LaunchOptions): Promise<Browser> {
49
- if (_browser?.connected) {
50
- return _browser;
51
- }
52
- _browser = await launch({
53
- executablePath: findChrome(options?.executablePath),
54
- headless: true,
55
- args: ["--no-sandbox", "--disable-gpu"],
56
- });
57
- return _browser;
58
- }
59
-
60
- export async function closeBrowser(): Promise<void> {
61
- if (_browser?.connected) {
62
- await _browser.close();
63
- _browser = null;
64
- }
49
+ /** The page, kept open for style capture (Phase 1). Caller must close. */
50
+ page: ImportPage;
65
51
  }
66
52
 
67
53
  export interface CaptureOptions {
@@ -73,7 +59,7 @@ export interface CaptureOptions {
73
59
  * Scroll the page to the bottom in steps to trigger lazy-loaded images and intersection-observer
74
60
  * content, then scroll back to top. Settles between steps to let content render.
75
61
  */
76
- async function scrollToRevealAll(page: Page): Promise<void> {
62
+ async function scrollToRevealAll(page: ImportPage): Promise<void> {
77
63
  await page.evaluate(async () => {
78
64
  const delay = (ms: number) =>
79
65
  new Promise<void>((r) => {
@@ -111,15 +97,18 @@ async function scrollToRevealAll(page: Page): Promise<void> {
111
97
  /**
112
98
  * Capture a page's DOM. Returns the page object still open — the caller is responsible for closing
113
99
  * it (after style capture in Phase 1, or immediately).
100
+ *
101
+ * The browser is a parameter and has no default. It used to fall back to launching a local Chrome,
102
+ * and that fallback was the value import of `puppeteer-core` that kept this module — and everything
103
+ * that reaches it — out of a Worker.
114
104
  */
115
105
  export async function capturePage(
116
106
  url: string,
117
- browser?: Browser,
107
+ browser: ImportBrowser,
118
108
  options?: CaptureOptions,
119
109
  ): Promise<CaptureResult> {
120
110
  const { scrollToBottom = true } = options ?? {};
121
- const br = browser ?? (await launchBrowser());
122
- const page: Page = await br.newPage();
111
+ const page = await browser.newPage();
123
112
  await page.setViewport({ width: 1440, height: 900 });
124
113
 
125
114
  await page.goto(url, { waitUntil: "networkidle0", timeout: 30_000 });
package/src/cli.ts CHANGED
@@ -3,6 +3,13 @@
3
3
  import { resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { importSite } from "./run.ts";
6
+ import {
7
+ DEFAULT_BREAKPOINT_POLICY,
8
+ MAX_BREAKPOINTS,
9
+ MAX_BREAKPOINT_WIDTH,
10
+ MIN_BREAKPOINT_WIDTH,
11
+ } from "./breakpoint-plan.ts";
12
+ import type { BreakpointPolicy, BreakpointRounding } from "./breakpoint-plan.ts";
6
13
 
7
14
  function usage(): never {
8
15
  console.log(`Usage: jx-import <url> [options]
@@ -14,6 +21,10 @@ Options:
14
21
  --depth <n> Max crawl depth (default: 2, 0 = single page)
15
22
  --max-pages <n> Max pages to capture (default: 25)
16
23
  --max-nodes-per-page <n> Skip styles/assets for pages above this (default: 5000)
24
+ --max-breakpoints <n> Keep at most n of the site's breakpoints (default: 3, 0 = keep all)
25
+ --breakpoints <list> Keep these widths instead, e.g. 640,1024,1440
26
+ --breakpoint-rounding <r> How a kept width matches a declared one: nearest|down|up
27
+ (default: nearest)
17
28
  --no-styles Skip CSS capture
18
29
  --no-assets Skip asset download
19
30
  --no-crawl Single page only (equivalent to --depth 0)
@@ -25,7 +36,10 @@ Options:
25
36
  --ai-components Use LLM to refine component names and props (Phase 4 AI pass)
26
37
  --ai-model <model> Model for AI componentization (default: gpt-4o-mini)
27
38
  --verify After import, build and screenshot-diff vs original (Phase 5)
28
- --verify-threshold <n> Pixel diff threshold 0..1 (default: 0.15)
39
+ --min-fidelity <n> Fail (exit 1) below this average fidelity 0..100 (default: 25)
40
+ --verify-threshold <n> Per-pixel colour tolerance 0..1 for the diff (default: 0.15).
41
+ Moves the score; it is NOT the pass bar — --min-fidelity is.
42
+ --verify-viewport-only Diff only the first viewport instead of the whole page
29
43
 
30
44
  Environment:
31
45
  OPENAI_API_KEY Required for --ai-components
@@ -37,6 +51,8 @@ Examples:
37
51
  jx-import https://example.com --depth 1 --max-pages 10
38
52
  jx-import https://example.com --no-crawl
39
53
  jx-import https://example.com --out sites/my-clone --no-styles
54
+ jx-import https://example.com --breakpoints 640,1024,1440
55
+ jx-import https://example.com --max-breakpoints 0
40
56
  jx-import https://example.com --verify
41
57
  jx-import https://example.com --ai-components`);
42
58
  process.exit(1);
@@ -73,7 +89,60 @@ const verifyThreshold =
73
89
  verifyThresholdIdx !== -1 && args[verifyThresholdIdx + 1]
74
90
  ? Number(args[verifyThresholdIdx + 1]) || 0.15
75
91
  : 0.15;
92
+ /*
93
+ * The floor below which the output is not a clone of anything.
94
+ *
95
+ * It is deliberately low rather than a quality bar: a real import of a complicated site lands well
96
+ * under 100 for reasons nobody can fix from here (a rotating hero, a font that renders a hair
97
+ * differently), and failing those would train people to pass `--min-fidelity 0` and stop looking.
98
+ * Under a quarter of pixels matching is a different question — that is the 8%-fidelity Wix clone
99
+ * that printed `Done!` and exited 0, which is worse than a failure because the only way to see it
100
+ * is to open the result (issue #232).
101
+ */
102
+ const minFidelity = parseIntArg(args, "--min-fidelity", 25);
103
+ const verifyFullPage = !args.includes("--verify-viewport-only");
76
104
 
105
+ /**
106
+ * The breakpoint policy from the flags.
107
+ *
108
+ * `--breakpoints` wins over `--max-breakpoints` because it is the more specific answer: naming the
109
+ * widths says everything a count could have.
110
+ */
111
+ function breakpointPolicy(): BreakpointPolicy {
112
+ const roundingIdx = args.indexOf("--breakpoint-rounding");
113
+ const roundingArg = roundingIdx === -1 ? "" : (args[roundingIdx + 1] ?? "");
114
+ const rounding: BreakpointRounding =
115
+ roundingArg === "down" || roundingArg === "up" ? roundingArg : "nearest";
116
+
117
+ const widthsIdx = args.indexOf("--breakpoints");
118
+ const widthsArg = widthsIdx === -1 ? "" : (args[widthsIdx + 1] ?? "");
119
+ if (widthsArg) {
120
+ const widths = widthsArg
121
+ .split(",")
122
+ .map((part) => Math.trunc(Number(part.trim())))
123
+ .filter((n) => Number.isFinite(n) && n >= MIN_BREAKPOINT_WIDTH && n <= MAX_BREAKPOINT_WIDTH);
124
+ if (widths.length === 0) {
125
+ console.error(
126
+ `Error: --breakpoints needs widths between ${MIN_BREAKPOINT_WIDTH} and ` +
127
+ `${MAX_BREAKPOINT_WIDTH}, e.g. --breakpoints 640,1024,1440`,
128
+ );
129
+ process.exit(1);
130
+ }
131
+ return { mode: "explicit", rounding, widths };
132
+ }
133
+
134
+ if (!args.includes("--max-breakpoints")) {
135
+ return DEFAULT_BREAKPOINT_POLICY;
136
+ }
137
+ const count = parseIntArg(args, "--max-breakpoints", 3);
138
+ // Zero is the only spelling of "keep all" a count can have, and it reads better than omitting the
139
+ // Flag when a script is choosing between the two.
140
+ return count <= 0
141
+ ? { mode: "all" }
142
+ : { count: Math.min(MAX_BREAKPOINTS, count), mode: "limit", rounding };
143
+ }
144
+
145
+ const breakpoints = breakpointPolicy();
77
146
  const maxDepth = noCrawl ? 0 : parseIntArg(args, "--depth", 2);
78
147
  const maxPages = parseIntArg(args, "--max-pages", 25);
79
148
  const maxNodesPerPage = parseIntArg(args, "--max-nodes-per-page", 5000);
@@ -116,6 +185,7 @@ async function main() {
116
185
  {
117
186
  url: url as string,
118
187
  outDir,
188
+ breakpoints,
119
189
  maxDepth,
120
190
  maxPages,
121
191
  maxNodesPerPage,
@@ -125,7 +195,9 @@ async function main() {
125
195
  respectRobots: !args.includes("--no-robots"),
126
196
  componentize: noComponents ? false : { minInstances, minDepth },
127
197
  ai,
128
- verify: doVerify ? { threshold: verifyThreshold } : false,
198
+ verify: doVerify
199
+ ? { threshold: verifyThreshold, minFidelity, fullPage: verifyFullPage }
200
+ : false,
129
201
  },
130
202
  (e) => console.log(` ${e.message}`),
131
203
  );
@@ -136,9 +208,41 @@ async function main() {
136
208
  }
137
209
  if (result.verify) {
138
210
  console.log(` Average fidelity: ${result.verify.averageFidelity}%`);
211
+ for (const page of result.verify.pages) {
212
+ const misses = [
213
+ page.failedRequests > 0 ? `${page.failedRequests} failed request(s)` : "",
214
+ page.consoleErrors > 0 ? `${page.consoleErrors} console error(s)` : "",
215
+ ].filter(Boolean);
216
+ if (misses.length > 0) {
217
+ console.log(` ${page.route}: ${misses.join(", ")}`);
218
+ }
219
+ }
139
220
  console.log(` Report: ${result.verify.reportDir}/report.json`);
140
221
  }
141
222
 
223
+ /*
224
+ * A verify that cannot fail is a report, not a gate. An import nobody opens and looks at is
225
+ * exactly the case the exit code exists for, so a build error or a fidelity under the floor
226
+ * ends the run non-zero and says which one it was.
227
+ */
228
+ if (result.verify && !result.verify.passed) {
229
+ for (const error of result.verify.buildErrors) {
230
+ console.error(`Build error: ${error}`);
231
+ }
232
+ console.error(
233
+ `\nVerification FAILED — the project is at ${outDir}, but it does not match the original.`,
234
+ );
235
+ if (result.verify.buildErrors.length === 0) {
236
+ console.error(
237
+ ` Average fidelity ${result.verify.averageFidelity}% is below the ` +
238
+ `${result.verify.minFidelity}% minimum. Pass --min-fidelity to change it.`,
239
+ );
240
+ }
241
+ console.error(` Report: ${result.verify.reportDir}/report.json`);
242
+ process.exitCode = 1;
243
+ return;
244
+ }
245
+
142
246
  console.log(`\nDone! Open in Studio:`);
143
247
  console.log(
144
248
  ` http://localhost:3000/packages/studio/index.html?project=${outDir}/project.json`,
@@ -54,6 +54,16 @@ function structuralHash(node: JxElement | string): string {
54
54
  return `<${tag}>${childHashes.join(",")}`;
55
55
  }
56
56
 
57
+ /**
58
+ * Attributes that may not become a component prop.
59
+ *
60
+ * A prop is something a person fills in per instance, and a class is not one: `emit.ts` strips the
61
+ * source site's classes on the way out (`strip-classes.ts`), so a class that happened to differ
62
+ * between two instances of the same block would otherwise mint a `${state.class}` prop whose value
63
+ * is then deleted — a prop with a name nobody chose, wired to nothing.
64
+ */
65
+ const PRESENTATION_ATTRS = new Set(["class", "className"]);
66
+
57
67
  function collectLeafValues(node: JxElement | string, prefix: string, out: Map<string, string>) {
58
68
  if (typeof node === "string") {
59
69
  out.set(`${prefix}.$text`, node);
@@ -64,6 +74,9 @@ function collectLeafValues(node: JxElement | string, prefix: string, out: Map<st
64
74
  }
65
75
  if (node.attributes) {
66
76
  for (const [k, v] of Object.entries(node.attributes)) {
77
+ if (PRESENTATION_ATTRS.has(k)) {
78
+ continue;
79
+ }
67
80
  if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
68
81
  out.set(`${prefix}.attr.${k}`, String(v));
69
82
  }
package/src/crawl.ts CHANGED
@@ -1,10 +1,17 @@
1
- import { launchBrowser, capturePage } from "./capture.ts";
2
- import type { CaptureResult } from "./capture.ts";
1
+ import { capturePage } from "./capture.ts";
2
+ import type { CaptureResult, ImportBrowser } from "./capture.ts";
3
+ import type { ImportIo } from "./io.ts";
3
4
  import { convertToJx } from "./to-jx.ts";
4
5
  import type { ToJxResult } from "./to-jx.ts";
5
6
  import { captureStyles } from "./style-capture.ts";
6
7
  import { diffAllStyles, kebabToCamel } from "./style-diff.ts";
7
8
  import { extractMedia } from "./media-extract.ts";
9
+ import {
10
+ DEFAULT_BREAKPOINT_POLICY,
11
+ analyzeMediaQueries,
12
+ planBreakpoints,
13
+ } from "./breakpoint-plan.ts";
14
+ import type { Breakpoint, BreakpointPolicy } from "./breakpoint-plan.ts";
8
15
  import { applyStylesToTree } from "./apply-styles.ts";
9
16
  import { collectAssets } from "./asset-collect.ts";
10
17
  import { downloadAssets } from "./asset-download.ts";
@@ -13,7 +20,10 @@ import { applyTokens } from "./css-tokens.ts";
13
20
 
14
21
  export interface CrawlOptions {
15
22
  url: string;
16
- outDir: string;
23
+ /** The browser to capture with. The crawl never launches one — see `browser-local.ts`. */
24
+ browser: ImportBrowser;
25
+ /** Where downloaded assets go. */
26
+ io: ImportIo;
17
27
  maxDepth: number;
18
28
  maxPages: number;
19
29
  maxNodesPerPage: number;
@@ -24,6 +34,16 @@ export interface CrawlOptions {
24
34
  noScroll?: boolean;
25
35
  /** Capture a reference screenshot of each page before closing (for --verify). */
26
36
  captureScreenshots?: boolean;
37
+ /**
38
+ * Capture the whole scrollable page rather than the viewport (default true).
39
+ *
40
+ * It has to agree with what `verifyProject` screenshots on the other side. A viewport reference
41
+ * diffed against a full-page render is compared by padding the shorter image, so the two would
42
+ * disagree over everything below the fold before a single style was compared.
43
+ */
44
+ fullPageScreenshots?: boolean;
45
+ /** Which of the site's declared breakpoints the project keeps (see `breakpoint-plan.ts`). */
46
+ breakpoints?: BreakpointPolicy | undefined;
27
47
  onProgress?: (msg: string) => void;
28
48
  /** Abort the crawl between pages; the loop throws before capturing the next page. */
29
49
  signal?: AbortSignal;
@@ -36,8 +56,8 @@ export interface CrawledPage {
36
56
  jx: ToJxResult;
37
57
  depth: number;
38
58
  links: string[];
39
- /** Reference screenshot PNG buffer, present when captureScreenshots is true. */
40
- screenshot?: Buffer | undefined;
59
+ /** Reference screenshot PNG bytes, present when captureScreenshots is true. */
60
+ screenshot?: Uint8Array | undefined;
41
61
  }
42
62
 
43
63
  export interface CrawlResult {
@@ -171,18 +191,19 @@ function isDisallowed(url: string, disallowedPaths: Set<string>): boolean {
171
191
  export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
172
192
  const {
173
193
  url,
174
- outDir,
194
+ browser,
195
+ io,
175
196
  maxDepth,
176
197
  maxPages,
177
198
  maxNodesPerPage,
178
199
  skipStyles,
179
200
  skipAssets,
180
201
  respectRobots,
202
+ fullPageScreenshots = true,
181
203
  onProgress = console.log,
182
204
  } = options;
183
205
 
184
206
  const { origin } = new URL(url);
185
- const browser = await launchBrowser();
186
207
 
187
208
  // Fetch robots.txt
188
209
  let disallowedPaths = new Set<string>();
@@ -202,6 +223,9 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
202
223
  const skippedByRobots: string[] = [];
203
224
  const skippedByNodeCap: string[] = [];
204
225
  let mergedBreakpoints: Record<string, string> | undefined;
226
+ /* Decided on the first page that declares a width query, then reused. See ExtractMediaOptions.plan
227
+ — a per-page plan would union back up to the count the policy just refused. */
228
+ let breakpointPlan: Breakpoint[] | undefined;
205
229
  let mergedTokens: Record<string, string> | undefined;
206
230
  const allFontFaceRules: string[] = [];
207
231
  const fontRewriteMap = new Map<string, string>();
@@ -241,9 +265,9 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
241
265
  ` ⚠ Page exceeds node cap (${jx.nodeCount} > ${maxNodesPerPage}), skipping styles/assets`,
242
266
  );
243
267
  skippedByNodeCap.push(entry.url);
244
- let screenshot: Buffer | undefined;
268
+ let screenshot: Uint8Array | undefined;
245
269
  if (options.captureScreenshots) {
246
- screenshot = Buffer.from(await capture.page.screenshot({ type: "png" }));
270
+ screenshot = await capture.page.screenshot({ fullPage: fullPageScreenshots, type: "png" });
247
271
  }
248
272
  await capture.page.close();
249
273
 
@@ -277,11 +301,25 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
277
301
  }
278
302
 
279
303
  if (styleResult.mediaQueries.length > 0) {
304
+ if (!breakpointPlan) {
305
+ const declared = analyzeMediaQueries(styleResult.mediaQueries);
306
+ breakpointPlan = planBreakpoints(
307
+ declared,
308
+ options.breakpoints ?? DEFAULT_BREAKPOINT_POLICY,
309
+ ).keep;
310
+ if (breakpointPlan.length < declared.length) {
311
+ onProgress?.(
312
+ ` ${declared.length} breakpoints declared, keeping ` +
313
+ `${breakpointPlan.length}: ${breakpointPlan.map((b) => b.name).join(", ")}\n`,
314
+ );
315
+ }
316
+ }
280
317
  const media = await extractMedia(
281
318
  capture.page,
282
319
  styleResult.elements,
283
320
  styleResult.uaDefaults,
284
321
  styleResult.mediaQueries,
322
+ { plan: breakpointPlan },
285
323
  );
286
324
  const bpCount = Object.keys(media.breakpoints).length;
287
325
  if (bpCount > 0) {
@@ -327,7 +365,7 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
327
365
  }
328
366
 
329
367
  if (collected.assets.length > 0) {
330
- const downloaded = await downloadAssets(collected.assets, outDir, entry.url);
368
+ const downloaded = await downloadAssets(collected.assets, io, entry.url);
331
369
  if (downloaded.rewriteMap.size > 0) {
332
370
  rewriteAssetUrls(jx.document, downloaded.rewriteMap, entry.url);
333
371
  // Collect font rewrites (R2)
@@ -345,9 +383,9 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
345
383
  }
346
384
  }
347
385
 
348
- let screenshot: Buffer | undefined;
386
+ let screenshot: Uint8Array | undefined;
349
387
  if (options.captureScreenshots) {
350
- screenshot = Buffer.from(await capture.page.screenshot({ type: "png" }));
388
+ screenshot = await capture.page.screenshot({ fullPage: fullPageScreenshots, type: "png" });
351
389
  }
352
390
  await capture.page.close();
353
391