@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/README.md +29 -2
- package/package.json +9 -5
- package/src/ai-componentize.ts +12 -2
- package/src/asset-collect.ts +11 -5
- package/src/asset-download.ts +18 -14
- package/src/asset-rewrite.ts +7 -1
- package/src/breakpoint-plan.ts +297 -0
- package/src/browser-local.ts +67 -0
- package/src/capture.ts +50 -61
- package/src/cli.ts +106 -2
- package/src/componentize.ts +13 -0
- package/src/crawl.ts +50 -12
- package/src/emit.ts +136 -37
- package/src/index.ts +32 -8
- package/src/io.ts +52 -0
- package/src/media-extract.ts +53 -77
- package/src/pipeline.ts +605 -0
- package/src/run.ts +208 -403
- package/src/srcset.ts +32 -0
- package/src/strip-classes.ts +73 -0
- package/src/style-capture.ts +6 -3
- package/src/verify.ts +166 -14
package/src/capture.ts
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
11
|
-
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:
|
|
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
|
|
107
|
+
browser: ImportBrowser,
|
|
118
108
|
options?: CaptureOptions,
|
|
119
109
|
): Promise<CaptureResult> {
|
|
120
110
|
const { scrollToBottom = true } = options ?? {};
|
|
121
|
-
const
|
|
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
|
-
--
|
|
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
|
|
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`,
|
package/src/componentize.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
40
|
-
screenshot?:
|
|
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
|
-
|
|
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:
|
|
268
|
+
let screenshot: Uint8Array | undefined;
|
|
245
269
|
if (options.captureScreenshots) {
|
|
246
|
-
screenshot =
|
|
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,
|
|
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:
|
|
386
|
+
let screenshot: Uint8Array | undefined;
|
|
349
387
|
if (options.captureScreenshots) {
|
|
350
|
-
screenshot =
|
|
388
|
+
screenshot = await capture.page.screenshot({ fullPage: fullPageScreenshots, type: "png" });
|
|
351
389
|
}
|
|
352
390
|
await capture.page.close();
|
|
353
391
|
|