@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/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
+ }
@@ -6,7 +6,7 @@
6
6
  * UA-default baselines per unique tagName by rendering throwaway elements.
7
7
  */
8
8
 
9
- import type { Page } from "puppeteer-core";
9
+ import type { ImportPage } from "./capture.ts";
10
10
 
11
11
  export interface CapturedStyle {
12
12
  /** Depth-first element-index path from <body>, e.g. [0, 2, 1]. */
@@ -163,7 +163,7 @@ const STYLE_ALLOWLIST: string[] = [
163
163
  * Run the style capture in a puppeteer page. Executes entirely in-browser via page.evaluate() — no
164
164
  * round-trips per element.
165
165
  */
166
- export async function captureStyles(page: Page): Promise<StyleCaptureResult> {
166
+ export async function captureStyles(page: ImportPage): Promise<StyleCaptureResult> {
167
167
  return page.evaluate((allowlist: string[]) => {
168
168
  const elements: { path: number[]; tagName: string; styles: Record<string, string> }[] = [];
169
169
  const tagsSeen = new Set<string>();
@@ -337,7 +337,10 @@ export async function captureStyles(page: Page): Promise<StyleCaptureResult> {
337
337
  * Re-capture computed styles at a different viewport width (for $media extraction). Returns only
338
338
  * the per-element styles — no UA defaults or media queries (reuse from base).
339
339
  */
340
- export async function captureStylesAtWidth(page: Page, width: number): Promise<CapturedStyle[]> {
340
+ export async function captureStylesAtWidth(
341
+ page: ImportPage,
342
+ width: number,
343
+ ): Promise<CapturedStyle[]> {
341
344
  await page.setViewport({ width, height: 900 });
342
345
  // Let layout reflow settle
343
346
  await page.evaluate(
package/src/verify.ts CHANGED
@@ -7,10 +7,10 @@
7
7
  * during import.
8
8
  */
9
9
 
10
- import { join } from "node:path";
10
+ import { extname, join } from "node:path";
11
11
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
12
12
  import type { Browser, Page } from "puppeteer-core";
13
- import { launchBrowser, closeBrowser } from "./capture.ts";
13
+ import { launchBrowser, closeBrowser } from "./browser-local.ts";
14
14
  import { diffScreenshots } from "./screenshot-diff.ts";
15
15
 
16
16
  export interface PageRef {
@@ -30,8 +30,27 @@ export interface VerifyOptions {
30
30
  viewportHeight?: number;
31
31
  /** Where to write diff images and the report (default: <projectDir>/verify/). */
32
32
  reportDir?: string;
33
- /** Pixelmatch threshold (default: 0.15). */
33
+ /**
34
+ * Pixelmatch's per-pixel COLOUR tolerance, 0..1 (default 0.15).
35
+ *
36
+ * It decides when two pixels count as the same colour, so it moves the score; it is not a
37
+ * pass/fail bar. `minFidelity` is the bar. Calling this one a "threshold" is what let a run
38
+ * scoring 8% exit 0 (issue #232).
39
+ */
34
40
  threshold?: number;
41
+ /**
42
+ * The average fidelity, 0..100, below which the run is a failure (default 0 — report only).
43
+ *
44
+ * Reported as `passed`; the caller decides what to do with it. `jx-import` exits non-zero.
45
+ */
46
+ minFidelity?: number;
47
+ /**
48
+ * Compare the whole scrollable page rather than the viewport (default true).
49
+ *
50
+ * It used to be the first 900px, which on a long marketing page is a small fraction of what was
51
+ * imported — most of the clone was never looked at.
52
+ */
53
+ fullPage?: boolean;
35
54
  /** Progress callback. */
36
55
  onProgress?: (msg: string) => void;
37
56
  /** Existing browser instance to reuse. */
@@ -47,6 +66,15 @@ export interface PageVerifyResult {
47
66
  diffImagePath: string;
48
67
  originalScreenshotPath: string;
49
68
  renderedScreenshotPath: string;
69
+ /**
70
+ * Console errors and uncaught exceptions the rendered page produced.
71
+ *
72
+ * A fidelity number says the clone looks wrong; these say why. The first import measured 404'd on
73
+ * 15 asset references and the verify pass reported a percentage and nothing else.
74
+ */
75
+ consoleErrors: string[];
76
+ /** Requests the rendered page made that failed or answered 4xx/5xx, as "<status> <url>". */
77
+ failedRequests: string[];
50
78
  error?: string;
51
79
  }
52
80
 
@@ -55,6 +83,18 @@ export interface VerifyResult {
55
83
  /** Average fidelity across all pages. */
56
84
  averageFidelity: number;
57
85
  reportDir: string;
86
+ /**
87
+ * Errors the compiler reported while building the project.
88
+ *
89
+ * Previously logged, written into report.json, and then dropped — `verifyProject` carried on and
90
+ * `importSite` never saw them, so a project that did not build still exited 0.
91
+ */
92
+ buildErrors: string[];
93
+ /**
94
+ * Whether this run met its bar: it built cleanly, every page rendered, and the average fidelity
95
+ * reached `minFidelity`. The single fact a caller needs to gate on.
96
+ */
97
+ passed: boolean;
58
98
  }
59
99
 
60
100
  /**
@@ -76,18 +116,59 @@ export function routeToUrlPath(route: string): string {
76
116
  return path;
77
117
  }
78
118
 
119
+ /** What the rendered page said about itself while it loaded. */
120
+ interface PageDiagnostics {
121
+ consoleErrors: string[];
122
+ failedRequests: string[];
123
+ }
124
+
125
+ /**
126
+ * Listen for the two things a fidelity percentage cannot tell you: what the page logged, and what
127
+ * it asked for and did not get.
128
+ *
129
+ * Attached before `goto`, because a 404 on an asset referenced in the HTML happens during the
130
+ * navigation. Registration is optional — a stand-in page that only screenshots is still usable, and
131
+ * diagnostics are then simply empty rather than a crash.
132
+ */
133
+ function watchPage(page: Page): PageDiagnostics {
134
+ const diagnostics: PageDiagnostics = { consoleErrors: [], failedRequests: [] };
135
+ if (typeof (page as Partial<Page>).on !== "function") {
136
+ return diagnostics;
137
+ }
138
+ page.on("console", (msg) => {
139
+ if (msg.type() === "error") {
140
+ diagnostics.consoleErrors.push(msg.text());
141
+ }
142
+ });
143
+ // `pageerror` carries `Error | unknown`: a page can reject with anything at all, and a thrown
144
+ // String is still a page that broke.
145
+ page.on("pageerror", (error) => {
146
+ diagnostics.consoleErrors.push(error instanceof Error ? error.message : String(error));
147
+ });
148
+ page.on("requestfailed", (request) => {
149
+ diagnostics.failedRequests.push(`failed ${request.url()}`);
150
+ });
151
+ page.on("response", (response) => {
152
+ if (response.status() >= 400) {
153
+ diagnostics.failedRequests.push(`${response.status()} ${response.url()}`);
154
+ }
155
+ });
156
+ return diagnostics;
157
+ }
158
+
79
159
  async function screenshotPage(
80
160
  page: Page,
81
161
  url: string,
82
162
  width: number,
83
163
  height: number,
164
+ fullPage: boolean,
84
165
  ): Promise<Buffer> {
85
166
  await page.setViewport({ width, height });
86
167
  await page.goto(url, { waitUntil: "networkidle0", timeout: 30_000 });
87
168
  await new Promise<void>((r) => {
88
169
  setTimeout(r, 500);
89
170
  });
90
- const screenshot = await page.screenshot({ type: "png", fullPage: false });
171
+ const screenshot = await page.screenshot({ fullPage, type: "png" });
91
172
  return Buffer.from(screenshot);
92
173
  }
93
174
 
@@ -99,9 +180,10 @@ export async function captureReferenceScreenshot(
99
180
  page: Page,
100
181
  width = 1440,
101
182
  height = 900,
183
+ fullPage = true,
102
184
  ): Promise<Buffer> {
103
185
  await page.setViewport({ width, height });
104
- const screenshot = await page.screenshot({ type: "png", fullPage: false });
186
+ const screenshot = await page.screenshot({ fullPage, type: "png" });
105
187
  return Buffer.from(screenshot);
106
188
  }
107
189
 
@@ -113,6 +195,39 @@ async function buildProject(projectDir: string): Promise<{ distDir: string; erro
113
195
  return { distDir, errors: result.errors };
114
196
  }
115
197
 
198
+ /**
199
+ * What the verify server declares for each extension it can serve.
200
+ *
201
+ * `.html`, `.css` and `.js` were the whole map, so every SVG, PNG, WOFF2 and JSON went out as
202
+ * `application/octet-stream`. A browser will not render an `<img src>` served as a binary blob and
203
+ * will not use a font it was handed as one, which cost the clone fidelity points that were not the
204
+ * importer's fault at all (issue #232).
205
+ */
206
+ const CONTENT_TYPES: Record<string, string> = {
207
+ ".avif": "image/avif",
208
+ ".css": "text/css",
209
+ ".gif": "image/gif",
210
+ ".htm": "text/html; charset=utf-8",
211
+ ".html": "text/html; charset=utf-8",
212
+ ".ico": "image/x-icon",
213
+ ".jpeg": "image/jpeg",
214
+ ".jpg": "image/jpeg",
215
+ ".js": "text/javascript; charset=utf-8",
216
+ ".json": "application/json; charset=utf-8",
217
+ ".map": "application/json; charset=utf-8",
218
+ ".mjs": "text/javascript; charset=utf-8",
219
+ ".otf": "font/otf",
220
+ ".png": "image/png",
221
+ ".svg": "image/svg+xml",
222
+ ".ttf": "font/ttf",
223
+ ".txt": "text/plain; charset=utf-8",
224
+ ".webmanifest": "application/manifest+json",
225
+ ".webp": "image/webp",
226
+ ".woff": "font/woff",
227
+ ".woff2": "font/woff2",
228
+ ".xml": "application/xml; charset=utf-8",
229
+ };
230
+
116
231
  /**
117
232
  * Serve a directory over HTTP using Bun's built-in server. Returns the server instance and the base
118
233
  * URL.
@@ -134,13 +249,8 @@ export function serveDirectory(dir: string): {
134
249
  const filePath = join(dir, pathname);
135
250
  const file = Bun.file(filePath);
136
251
  if (await file.exists()) {
137
- const contentType = filePath.endsWith(".html")
138
- ? "text/html"
139
- : filePath.endsWith(".css")
140
- ? "text/css"
141
- : filePath.endsWith(".js")
142
- ? "application/javascript"
143
- : "application/octet-stream";
252
+ const contentType =
253
+ CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
144
254
  return new Response(file, { headers: { "Content-Type": contentType } });
145
255
  }
146
256
  return new Response("Not Found", { status: 404 });
@@ -172,6 +282,8 @@ export async function verifyProject(opts: VerifyOptions): Promise<VerifyResult>
172
282
  viewportWidth = 1440,
173
283
  viewportHeight = 900,
174
284
  threshold = 0.15,
285
+ minFidelity = 0,
286
+ fullPage = true,
175
287
  onProgress = () => {},
176
288
  } = opts;
177
289
 
@@ -201,10 +313,14 @@ export async function verifyProject(opts: VerifyOptions): Promise<VerifyResult>
201
313
  diffImagePath: "",
202
314
  originalScreenshotPath: "",
203
315
  renderedScreenshotPath: "",
316
+ consoleErrors: [],
317
+ failedRequests: [],
204
318
  error: `Build failed: ${err.message}`,
205
319
  })),
206
320
  averageFidelity: 0,
207
321
  reportDir,
322
+ buildErrors: [`Build failed: ${err.message}`],
323
+ passed: false,
208
324
  };
209
325
  }
210
326
 
@@ -230,13 +346,23 @@ export async function verifyProject(opts: VerifyOptions): Promise<VerifyResult>
230
346
  const renderedUrl = `${baseUrl}${urlPath}`;
231
347
  onProgress(` Screenshotting rendered: ${renderedUrl}`);
232
348
  const renderedPage = await browser.newPage();
349
+ // Listeners go on before the navigation — a 404 on an asset named in the HTML happens
350
+ // During it, and that is exactly the class of failure the score alone cannot explain.
351
+ const diagnostics = watchPage(renderedPage);
233
352
  const renderedPng = await screenshotPage(
234
353
  renderedPage,
235
354
  renderedUrl,
236
355
  viewportWidth,
237
356
  viewportHeight,
357
+ fullPage,
238
358
  );
239
359
  await renderedPage.close();
360
+ if (diagnostics.failedRequests.length > 0) {
361
+ onProgress(` ${diagnostics.failedRequests.length} request(s) failed or 404'd`);
362
+ }
363
+ if (diagnostics.consoleErrors.length > 0) {
364
+ onProgress(` ${diagnostics.consoleErrors.length} console error(s)`);
365
+ }
240
366
 
241
367
  // Diff
242
368
  onProgress(" Computing pixel diff...");
@@ -262,6 +388,8 @@ export async function verifyProject(opts: VerifyOptions): Promise<VerifyResult>
262
388
  diffImagePath: diffPath,
263
389
  originalScreenshotPath: origPath,
264
390
  renderedScreenshotPath: renderedPath,
391
+ consoleErrors: diagnostics.consoleErrors,
392
+ failedRequests: diagnostics.failedRequests,
265
393
  });
266
394
  } catch (error) {
267
395
  const err = error as Error;
@@ -275,6 +403,8 @@ export async function verifyProject(opts: VerifyOptions): Promise<VerifyResult>
275
403
  diffImagePath: "",
276
404
  originalScreenshotPath: "",
277
405
  renderedScreenshotPath: "",
406
+ consoleErrors: [],
407
+ failedRequests: [],
278
408
  error: err.message,
279
409
  });
280
410
  }
@@ -290,18 +420,40 @@ export async function verifyProject(opts: VerifyOptions): Promise<VerifyResult>
290
420
  ) / 100
291
421
  : 0;
292
422
 
423
+ /*
424
+ * A run passes only if all three hold. Any one of them alone can be silently wrong: a project
425
+ * that did not build still renders SOMETHING, a page that failed to screenshot is excluded
426
+ * from the average rather than dragging it down, and a page that rendered can still be a
427
+ * different site. Previously none of them could fail the run — an 8%-fidelity clone printed
428
+ * `Done!` and exited 0, which is worse than failing because it is only visible to somebody who
429
+ * opens the result and looks at it (issue #232).
430
+ */
431
+ const errored = results.filter((r) => r.error);
432
+ const passed =
433
+ (buildErrors ?? []).length === 0 && errored.length === 0 && averageFidelity >= minFidelity;
434
+
293
435
  const report = {
294
436
  timestamp: new Date().toISOString(),
295
437
  projectDir,
296
438
  viewport: { width: viewportWidth, height: viewportHeight },
297
- threshold,
439
+ fullPage,
440
+ /** Pixelmatch's per-pixel colour tolerance — see `VerifyOptions.threshold`. */
441
+ colorTolerance: threshold,
442
+ minFidelity,
298
443
  averageFidelity,
444
+ passed,
299
445
  pages: results,
300
446
  buildErrors: buildErrors ?? [],
301
447
  };
302
448
  writeFileSync(join(reportDir, "report.json"), JSON.stringify(report, null, 2));
303
449
 
304
- return { pages: results, averageFidelity, reportDir };
450
+ return {
451
+ pages: results,
452
+ averageFidelity,
453
+ reportDir,
454
+ buildErrors: buildErrors ?? [],
455
+ passed,
456
+ };
305
457
  } finally {
306
458
  void server.stop();
307
459
  if (!opts.browser) {