@jxsuite/import 0.39.8 → 0.40.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/src/run.ts CHANGED
@@ -5,13 +5,21 @@
5
5
  */
6
6
 
7
7
  import { existsSync, readdirSync } from "node:fs";
8
- import { isAbsolute } from "node:path";
8
+ import { isAbsolute, join } from "node:path";
9
+ import { mkdir } from "node:fs/promises";
9
10
  import { capturePage, closeBrowser, launchBrowser } from "./capture.ts";
10
11
  import { convertToJx } from "./to-jx.ts";
11
12
  import { emitMultiPageProject } from "./emit.ts";
12
13
  import { captureStyles } from "./style-capture.ts";
13
- import { diffAllStyles, kebabToCamel } from "./style-diff.ts";
14
14
  import { extractMedia } from "./media-extract.ts";
15
+ import {
16
+ DEFAULT_BREAKPOINT_POLICY,
17
+ analyzeMediaQueries,
18
+ planBreakpoints,
19
+ skippedWidthQueries,
20
+ } from "./breakpoint-plan.ts";
21
+ import type { BreakpointPolicy } from "./breakpoint-plan.ts";
22
+ import { diffAllStyles, kebabToCamel } from "./style-diff.ts";
15
23
  import { applyStylesToTree } from "./apply-styles.ts";
16
24
  import { collectAssets } from "./asset-collect.ts";
17
25
  import { downloadAssets } from "./asset-download.ts";
@@ -34,6 +42,14 @@ export interface ImportSiteOptions {
34
42
  maxPages?: number;
35
43
  /** Skip styles/assets for pages above this node count (default 5000). */
36
44
  maxNodesPerPage?: number;
45
+ /**
46
+ * Which of the site's declared breakpoints the project keeps (default: three, evenly spaced).
47
+ *
48
+ * A real site declares as many breakpoints as it has accumulated frameworks; nine `$media`
49
+ * entries is nine canvas sizes in Studio and nine columns in every style editor, and nobody
50
+ * authors against that. See `breakpoint-plan.ts`.
51
+ */
52
+ breakpoints?: BreakpointPolicy;
37
53
  /** Capture computed styles (default true). */
38
54
  styles?: boolean;
39
55
  /** Download and rewrite assets (default true). */
@@ -46,8 +62,14 @@ export interface ImportSiteOptions {
46
62
  componentize?: false | { minInstances?: number; minDepth?: number };
47
63
  /** LLM refinement of component/prop names; false/undefined to skip. */
48
64
  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 };
65
+ /**
66
+ * Build + screenshot-diff the emitted project against the original; false to skip.
67
+ *
68
+ * `threshold` is pixelmatch's per-pixel COLOUR tolerance, not a bar; `minFidelity` is the bar,
69
+ * and the run reports `passed` against it. `fullPage` compares the whole scrollable page (default
70
+ * true) rather than the first viewport.
71
+ */
72
+ verify?: false | { threshold?: number; minFidelity?: number; fullPage?: boolean | undefined };
51
73
  /** Explicit browser binary (wins over CHROME_PATH and PATH discovery). */
52
74
  chromePath?: string;
53
75
  /** Aborts between phases (and between crawled pages). */
@@ -55,6 +77,7 @@ export interface ImportSiteOptions {
55
77
  }
56
78
 
57
79
  export type ImportPhase =
80
+ | "seed"
58
81
  | "launch"
59
82
  | "capture"
60
83
  | "crawl"
@@ -72,16 +95,75 @@ export interface ImportProgressEvent {
72
95
  message: string;
73
96
  current?: number;
74
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;
75
106
  }
76
107
 
77
108
  export interface ImportSiteResult {
78
109
  outDir: string;
79
110
  pages: { route: string; title: string; nodeCount: number }[];
80
111
  fileCount: number;
81
- verify: { averageFidelity: number; reportDir: string } | null;
112
+ verify: {
113
+ averageFidelity: number;
114
+ reportDir: string;
115
+ /**
116
+ * Whether the run met its bar — it built cleanly, every page rendered, and the average reached
117
+ * `minFidelity`. Nothing about verify could fail before this existed: a clone scoring 8%
118
+ * finished exactly like one scoring 95% (issue #232).
119
+ */
120
+ passed: boolean;
121
+ /** The bar `passed` was measured against. */
122
+ minFidelity: number;
123
+ /** Errors the compiler reported building the project — recorded, and now also enforced. */
124
+ buildErrors: string[];
125
+ /**
126
+ * Per page, because the average cannot name one.
127
+ *
128
+ * "Average fidelity 84%" is a fact nobody can act on; "the pricing page renders at 61%" is a
129
+ * decision — retry it, patch it by hand, or accept it. The verifier computed both and only the
130
+ * average was reported, so the actionable half was thrown away one level below the caller.
131
+ *
132
+ * The counts alongside it are what a percentage cannot say: a page that 404s on fifteen images
133
+ * scores badly, and only `failedRequests` says why.
134
+ */
135
+ pages: {
136
+ route: string;
137
+ fidelity: number;
138
+ consoleErrors: number;
139
+ failedRequests: number;
140
+ error?: string;
141
+ }[];
142
+ } | null;
82
143
  warnings: string[];
83
144
  }
84
145
 
146
+ /**
147
+ * The viewport the base capture runs at, and therefore the project's base width.
148
+ *
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.
152
+ */
153
+ const CAPTURE_WIDTH = 1440;
154
+
155
+ /** The one file the seed writes, and the only thing the empty-directory guard tolerates. */
156
+ const PROJECT_FILE = "project.json";
157
+
158
+ /** A placeholder project name from the URL, replaced by the page title at emit. */
159
+ function seedName(url: string): string {
160
+ try {
161
+ return new URL(url).hostname.replace(/^www\./, "") || "Imported Site";
162
+ } catch {
163
+ return "Imported Site";
164
+ }
165
+ }
166
+
85
167
  function throwIfAborted(signal?: AbortSignal): void {
86
168
  if (signal?.aborted) {
87
169
  throw new Error("Import aborted");
@@ -154,6 +236,7 @@ export async function importSite(
154
236
  verify = false,
155
237
  chromePath,
156
238
  signal,
239
+ breakpoints: breakpointPolicy = DEFAULT_BREAKPOINT_POLICY,
157
240
  } = options;
158
241
 
159
242
  if (!url.startsWith("http://") && !url.startsWith("https://")) {
@@ -162,7 +245,11 @@ export async function importSite(
162
245
  if (!isAbsolute(outDir)) {
163
246
  throw new Error(`outDir must be an absolute path, got "${outDir}"`);
164
247
  }
165
- if (existsSync(outDir) && readdirSync(outDir).length > 0) {
248
+ /* The guard admits the run's OWN seed and nothing else. It has to: the destination is created and
249
+ given a `project.json` before the browser launches, so a host can open the project and watch the
250
+ rest of the run arrive in its file tree — and a guard that could not tell that seed from a
251
+ stranger's files would refuse every import at its second statement. */
252
+ if (existsSync(outDir) && readdirSync(outDir).some((entry) => entry !== PROJECT_FILE)) {
166
253
  throw new Error(`Directory "${outDir}" is not empty`);
167
254
  }
168
255
 
@@ -179,8 +266,11 @@ export async function importSite(
179
266
  progress(phase, `⚠ ${message}`);
180
267
  };
181
268
  const verifyThreshold = verify === false ? 0.15 : (verify.threshold ?? 0.15);
269
+ const verifyMinFidelity = verify === false ? 0 : (verify.minFidelity ?? 0);
270
+ const verifyFullPage = verify === false ? true : (verify.fullPage ?? true);
182
271
 
183
272
  try {
273
+ await seedProject();
184
274
  if (maxDepth === 0) {
185
275
  return await runSinglePage();
186
276
  }
@@ -189,6 +279,25 @@ export async function importSite(
189
279
  await closeBrowser();
190
280
  }
191
281
 
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
+
192
301
  // ─── Single-page mode: Phase 0-2 pipeline, no crawl overhead ────────────────
193
302
  async function runSinglePage(): Promise<ImportSiteResult> {
194
303
  progress("launch", "Launching browser...");
@@ -250,11 +359,13 @@ export async function importSite(
250
359
  "styles",
251
360
  `Extracting @media breakpoints (${styleResult.mediaQueries.length} queries found)...`,
252
361
  );
362
+ reportBreakpointPlan(styleResult.mediaQueries);
253
363
  const media = await extractMedia(
254
364
  capture.page,
255
365
  styleResult.elements,
256
366
  styleResult.uaDefaults,
257
367
  styleResult.mediaQueries,
368
+ { policy: breakpointPolicy },
258
369
  );
259
370
  const bpCount = Object.keys(media.breakpoints).length;
260
371
  if (bpCount > 0) {
@@ -338,7 +449,12 @@ export async function importSite(
338
449
  if (verify !== false) {
339
450
  progress("verify", "Capturing reference screenshot...");
340
451
  const { captureReferenceScreenshot } = await import("./verify.ts");
341
- referenceScreenshot = await captureReferenceScreenshot(capture.page);
452
+ referenceScreenshot = await captureReferenceScreenshot(
453
+ capture.page,
454
+ undefined,
455
+ undefined,
456
+ verifyFullPage,
457
+ );
342
458
  }
343
459
 
344
460
  await capture.page.close();
@@ -354,19 +470,20 @@ export async function importSite(
354
470
 
355
471
  throwIfAborted(signal);
356
472
  progress("emit", "Writing project...");
357
- const { files } = await emitMultiPageProject({
473
+ const { files, classesStripped } = await emitMultiPageProject({
358
474
  outDir,
359
475
  title: capture.title,
360
476
  pages: pageMap,
361
477
  sourceUrl: url,
362
478
  breakpoints,
479
+ baseWidth: CAPTURE_WIDTH,
363
480
  componentizeOptions: precomputedComponents ? false : componentizeOptions,
364
481
  precomputedComponents,
365
482
  fontFaceRules,
366
483
  fontRewriteMap,
367
484
  styleTokens,
368
485
  });
369
- progress("emit", `Wrote ${files.length} files`);
486
+ reportEmit(files.length, classesStripped);
370
487
 
371
488
  let verifySummary: ImportSiteResult["verify"] = null;
372
489
  if (verify !== false && referenceScreenshot) {
@@ -403,6 +520,9 @@ export async function importSite(
403
520
  respectRobots,
404
521
  noScroll: !scroll,
405
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,
406
526
  onProgress: (msg) => progress("crawl", msg.trim()),
407
527
  ...(signal === undefined ? {} : { signal }),
408
528
  });
@@ -456,20 +576,21 @@ export async function importSite(
456
576
  throwIfAborted(signal);
457
577
  progress("emit", "Writing project...");
458
578
  const title = result.pages[0]?.title || new URL(url).hostname;
459
- const { files } = await emitMultiPageProject({
579
+ const { files, classesStripped } = await emitMultiPageProject({
460
580
  outDir,
461
581
  title,
462
582
  sourceUrl: url,
463
583
  pages: pageMap,
464
584
  layout,
465
585
  breakpoints: result.breakpoints,
586
+ baseWidth: CAPTURE_WIDTH,
466
587
  componentizeOptions: precomputedComponents ? false : componentizeOptions,
467
588
  precomputedComponents,
468
589
  fontFaceRules: result.fontFaceRules.length > 0 ? result.fontFaceRules : undefined,
469
590
  fontRewriteMap: result.fontRewriteMap.size > 0 ? result.fontRewriteMap : undefined,
470
591
  styleTokens: result.styleTokens,
471
592
  });
472
- progress("emit", `Wrote ${files.length} files`);
593
+ reportEmit(files.length, classesStripped);
473
594
 
474
595
  let verifySummary: ImportSiteResult["verify"] = null;
475
596
  if (verify !== false) {
@@ -500,6 +621,60 @@ export async function importSite(
500
621
  };
501
622
  }
502
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}`);
631
+ }
632
+
633
+ /**
634
+ * Say which breakpoints the project is getting, and where the others went.
635
+ *
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.
640
+ */
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
+ );
676
+ }
677
+
503
678
  // ─── Phase 5: build + screenshot-diff against the original ─────────────────
504
679
  async function runVerify(
505
680
  verifyPages: Map<string, { sourceUrl: string; screenshot: Buffer | string }>,
@@ -512,17 +687,57 @@ export async function importSite(
512
687
  projectDir: outDir,
513
688
  pages: verifyPages,
514
689
  threshold: verifyThreshold,
690
+ minFidelity: verifyMinFidelity,
691
+ fullPage: verifyFullPage,
515
692
  onProgress: (msg) => progress("verify", msg),
516
693
  ...(browser === undefined ? {} : { browser }),
517
694
  });
695
+ /*
696
+ * Build errors were logged inside the verifier and dropped there. They are warnings on the
697
+ * import as a whole: a project that did not compile is not a clone of anything, however the
698
+ * pixels happen to score.
699
+ */
700
+ for (const error of verifyResult.buildErrors) {
701
+ warn("verify", `Build error: ${error}`);
702
+ }
518
703
  for (const page of verifyResult.pages) {
519
704
  const status = page.error ? `ERROR: ${page.error}` : `${page.fidelity}% fidelity`;
520
- progress("verify", `${page.route} — ${status}`);
705
+ const misses =
706
+ page.failedRequests.length > 0 ? `, ${page.failedRequests.length} failed request(s)` : "";
707
+ progress("verify", `${page.route} — ${status}${misses}`);
521
708
  }
522
709
  progress("verify", `Average fidelity: ${verifyResult.averageFidelity}%`);
710
+ if (!verifyResult.passed) {
711
+ warn(
712
+ "verify",
713
+ `Verification failed: average fidelity ${verifyResult.averageFidelity}% ` +
714
+ `(minimum ${verifyMinFidelity}%). See ${verifyResult.reportDir}/report.json`,
715
+ );
716
+ }
523
717
  return {
524
718
  averageFidelity: verifyResult.averageFidelity,
525
719
  reportDir: verifyResult.reportDir,
720
+ passed: verifyResult.passed,
721
+ minFidelity: verifyMinFidelity,
722
+ buildErrors: verifyResult.buildErrors,
723
+ pages: verifyResult.pages.map((page) => {
724
+ const entry: {
725
+ route: string;
726
+ fidelity: number;
727
+ consoleErrors: number;
728
+ failedRequests: number;
729
+ error?: string;
730
+ } = {
731
+ route: page.route,
732
+ fidelity: page.fidelity,
733
+ consoleErrors: page.consoleErrors.length,
734
+ failedRequests: page.failedRequests.length,
735
+ };
736
+ if (page.error !== undefined) {
737
+ entry.error = page.error;
738
+ }
739
+ return entry;
740
+ }),
526
741
  };
527
742
  }
528
743
  }
package/src/srcset.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Where one `srcset` entry ends and the next begins.
3
+ *
4
+ * Its own module because two passes have to agree about it: `asset-collect.ts` decides from it what
5
+ * gets DOWNLOADED, and `asset-rewrite.ts` reassembles the attribute from it. If they disagreed the
6
+ * importer would fetch one set of URLs and rewrite another.
7
+ */
8
+
9
+ /**
10
+ * A `srcset` separator is a comma that starts a NEW URL. A comma inside a URL never is.
11
+ *
12
+ * Splitting on every comma shreds any URL carrying commas in its own path, and Wix, Cloudinary and
13
+ * imgix all encode image transforms exactly that way — `.../fill/w_375,h_127,al_c,q_85/logo.png`
14
+ * became a dozen unfetchable fragments. On one Wix site that was 109 failed downloads, and because
15
+ * a failed download leaves the reference untouched, 46 `static.wixstatic.com` URLs stayed in the
16
+ * emitted page: the "clone" served its images from the host it had cloned (issue #231).
17
+ *
18
+ * The lookahead lists the ways a URL can begin — absolute, `data:`, protocol-relative, root- or
19
+ * dot-relative, or a bare `name.ext` — which is what distinguishes a separator from a transform
20
+ * parameter. No `g` flag: `String.split` needs none, and a sticky `lastIndex` shared across calls
21
+ * is a bug waiting to happen.
22
+ */
23
+ export const SRCSET_SEPARATOR =
24
+ /,(?=\s*(?:https?:\/\/|data:|\/\/|\/|\.{1,2}\/|[A-Za-z0-9_-]+\.[A-Za-z]{2,5}[/?#\s]))/;
25
+
26
+ /** The URL of each entry in a `srcset`, descriptors (`2x`, `800w`) dropped. */
27
+ export function parseSrcset(srcset: string): string[] {
28
+ return srcset
29
+ .split(SRCSET_SEPARATOR)
30
+ .map((entry) => entry.trim().split(/\s+/)[0] ?? "")
31
+ .filter(Boolean);
32
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Strip the source site's CSS classes out of an imported tree.
3
+ *
4
+ * The importer never resolves a class to a style. Every visual fact it emits comes from
5
+ * `getComputedStyle` (`style-capture.ts` → `style-diff.ts` → `apply-styles.ts`), and the only CSS
6
+ * it writes is `public/assets/fonts.css`, built from `@font-face` rules alone — the original
7
+ * stylesheets are collected for their fonts and then discarded. So an imported `class="hero
8
+ * grid-cols-3 lg:pt-12"` names rules that do not exist in the emitted project: it is dead weight
9
+ * that survives into every page, layout and component a reader then has to edit around.
10
+ *
11
+ * It is worse than inert in one place. `componentize.ts` treats every attribute as a prop
12
+ * candidate, so a class that happens to differ between two instances of the same block becomes a
13
+ * `${state.class}` prop with a name nobody chose. That is handled at the source there; this module
14
+ * is what guarantees nothing reaches disk regardless.
15
+ *
16
+ * **This is not a claim that Jx is classless.** `className` is a first-class schema property
17
+ * (`specs/spec.md` §8.1) and the compiler emits generated classes for nested style rules (§9.2);
18
+ * neither changes. What is removed here is one specific thing: class names imported from a site
19
+ * whose stylesheets were not imported with them.
20
+ *
21
+ * `id`, `role` and `aria-*` are deliberately left alone — anchors and accessibility survive the
22
+ * round trip, and unlike a class they mean the same thing without the original CSS.
23
+ *
24
+ * @docs studio/projects/create
25
+ */
26
+
27
+ import type { JxElement } from "@jxsuite/schema/types";
28
+
29
+ /** The two spellings a class can arrive in — `attributes.class` from HTML, `className` from Jx. */
30
+ const CLASS_KEYS = ["class", "className"] as const;
31
+
32
+ /**
33
+ * Remove class names from `node` and every descendant, in place.
34
+ *
35
+ * @param {JxElement | string} node - A tree node; strings (text) pass through untouched.
36
+ * @returns {number} How many class names were removed, for the progress line.
37
+ */
38
+ export function stripClasses(node: JxElement | string): number {
39
+ if (typeof node !== "object" || node === null) {
40
+ return 0;
41
+ }
42
+
43
+ let removed = 0;
44
+
45
+ if ("className" in node && node.className !== undefined) {
46
+ delete node.className;
47
+ removed += 1;
48
+ }
49
+
50
+ const { attributes } = node;
51
+ if (attributes && typeof attributes === "object") {
52
+ for (const key of CLASS_KEYS) {
53
+ if (key in attributes) {
54
+ delete (attributes as Record<string, unknown>)[key];
55
+ removed += 1;
56
+ }
57
+ }
58
+ /* An `attributes` map emptied by the strip is deleted rather than left as `{}`: the emitted JSON
59
+ is read by a person, and a bare `"attributes": {}` on every node is the same noise the classes
60
+ were. `htmlToJx` never writes an empty one either, so this restores its own invariant. */
61
+ if (Object.keys(attributes).length === 0) {
62
+ delete node.attributes;
63
+ }
64
+ }
65
+
66
+ if (Array.isArray(node.children)) {
67
+ for (const child of node.children) {
68
+ removed += stripClasses(child as JxElement | string);
69
+ }
70
+ }
71
+
72
+ return removed;
73
+ }