@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/README.md +8 -30
- package/package.json +6 -4
- package/src/ai-componentize.ts +12 -2
- package/src/apply-accordions.ts +304 -0
- package/src/apply-disclosures.ts +275 -0
- package/src/apply-popovers.ts +298 -0
- package/src/apply-styles.ts +60 -11
- package/src/asset-collect.ts +2 -2
- package/src/asset-download.ts +10 -11
- package/src/asset-rewrite.ts +30 -6
- package/src/browser-local.ts +67 -0
- package/src/capture.ts +50 -61
- package/src/componentize.ts +35 -1
- package/src/crawl.ts +23 -17
- package/src/derived-geometry.ts +115 -0
- package/src/emit.ts +59 -34
- package/src/image-family.ts +227 -0
- package/src/index.ts +11 -5
- package/src/io.ts +52 -0
- package/src/media-extract.ts +75 -6
- package/src/pipeline.ts +673 -0
- package/src/run.ts +93 -503
- package/src/style-capture.ts +201 -162
- package/src/style-diff.ts +17 -4
- package/src/to-jx.ts +27 -1
- package/src/toggle-algebra.ts +167 -0
- package/src/verify.ts +1 -1
package/src/asset-collect.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* SVGs are kept as-is (no download needed). Returns absolute URLs for downloading.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
8
|
+
import type { ImportPage } from "./capture.ts";
|
|
9
9
|
import { SRCSET_SEPARATOR } from "./srcset.ts";
|
|
10
10
|
|
|
11
11
|
export interface DiscoveredAsset {
|
|
@@ -46,7 +46,7 @@ export interface AssetCollectionResult {
|
|
|
46
46
|
* Collect all asset URLs from the live page DOM and stylesheets. Runs entirely in-browser via
|
|
47
47
|
* page.evaluate() — no per-element round-trips.
|
|
48
48
|
*/
|
|
49
|
-
export async function collectAssets(page:
|
|
49
|
+
export async function collectAssets(page: ImportPage): Promise<AssetCollectionResult> {
|
|
50
50
|
/*
|
|
51
51
|
* The separator crosses into the page as a SOURCE STRING: an evaluated body is serialised and
|
|
52
52
|
* re-parsed in the browser, so it cannot close over a value declared out here.
|
package/src/asset-download.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* subdirectories by type: images/, fonts/, icons/.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import { mkdir } from "node:fs/promises";
|
|
8
|
+
import { extname } from "node:path";
|
|
10
9
|
import type { DiscoveredAsset } from "./asset-collect.ts";
|
|
10
|
+
import type { ImportIo } from "./io.ts";
|
|
11
11
|
|
|
12
12
|
export interface DownloadResult {
|
|
13
13
|
/**
|
|
@@ -127,7 +127,7 @@ function isBlocked(url: string): boolean {
|
|
|
127
127
|
|
|
128
128
|
export async function downloadAssets(
|
|
129
129
|
assets: DiscoveredAsset[],
|
|
130
|
-
|
|
130
|
+
io: ImportIo,
|
|
131
131
|
sourceUrl?: string,
|
|
132
132
|
): Promise<DownloadResult> {
|
|
133
133
|
const rewriteMap = new Map<string, string>();
|
|
@@ -143,12 +143,11 @@ export async function downloadAssets(
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
// Create subdirectories
|
|
147
|
-
const assetsDir =
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
await mkdir(join(assetsDir, "other"), { recursive: true });
|
|
146
|
+
// Create subdirectories (a sink with no directories declines — see ImportIo.mkdir)
|
|
147
|
+
const assetsDir = "public/assets";
|
|
148
|
+
for (const subdir of ["images", "fonts", "icons", "other"]) {
|
|
149
|
+
await io.mkdir?.(`${assetsDir}/${subdir}`);
|
|
150
|
+
}
|
|
152
151
|
|
|
153
152
|
// Track filenames to avoid collisions
|
|
154
153
|
const usedNames = new Map<string, number>();
|
|
@@ -172,7 +171,7 @@ export async function downloadAssets(
|
|
|
172
171
|
}
|
|
173
172
|
usedNames.set(nameKey, count + 1);
|
|
174
173
|
|
|
175
|
-
const destPath =
|
|
174
|
+
const destPath = `${assetsDir}/${subdir}/${filename}`;
|
|
176
175
|
// The site-absolute path the built asset is served from. `public/` is the compiler's static
|
|
177
176
|
// ROOT — its contents land at dist/<path>, so the "public/" segment must not survive into a
|
|
178
177
|
// Reference, and the leading slash must be there or a reference from /components/x.js or a
|
|
@@ -195,7 +194,7 @@ export async function downloadAssets(
|
|
|
195
194
|
|
|
196
195
|
const buffer = await response.arrayBuffer();
|
|
197
196
|
totalBytes += buffer.byteLength;
|
|
198
|
-
await
|
|
197
|
+
await io.write(destPath, new Uint8Array(buffer));
|
|
199
198
|
rewriteMap.set(url, relativePath);
|
|
200
199
|
} catch {
|
|
201
200
|
failed.push(url);
|
package/src/asset-rewrite.ts
CHANGED
|
@@ -52,20 +52,34 @@ export function rewriteAssetUrls(
|
|
|
52
52
|
* the importer would fetch one set of URLs and rewrite another — the attribute would be
|
|
53
53
|
* reassembled around fragments nothing had downloaded.
|
|
54
54
|
*/
|
|
55
|
-
function rewriteSrcset(srcset: string): string {
|
|
56
|
-
|
|
55
|
+
function rewriteSrcset(srcset: string): { srcset: string; collapsedTo: string | null } {
|
|
56
|
+
const locals = new Set<string>();
|
|
57
|
+
let unresolved = false;
|
|
58
|
+
|
|
59
|
+
const rewritten = srcset
|
|
57
60
|
.split(SRCSET_SEPARATOR)
|
|
58
61
|
.map((entry) => {
|
|
59
62
|
const parts = entry.trim().split(/\s+/);
|
|
60
63
|
const [url = ""] = parts;
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
64
|
+
const local = rewriteUrl(url);
|
|
65
|
+
if (local) {
|
|
63
66
|
count += 1;
|
|
64
|
-
parts[0] =
|
|
67
|
+
parts[0] = local;
|
|
68
|
+
locals.add(local);
|
|
69
|
+
} else {
|
|
70
|
+
unresolved = true;
|
|
65
71
|
}
|
|
66
72
|
return parts.join(" ");
|
|
67
73
|
})
|
|
68
74
|
.join(", ");
|
|
75
|
+
|
|
76
|
+
/* Every candidate now points at the ONE file the family kept, so the attribute has become a
|
|
77
|
+
list of the same path repeated with different width descriptors — which lies to the browser
|
|
78
|
+
about what it can choose. Collapse it, and let the compiler build a real `srcset` from the
|
|
79
|
+
original it owns. A candidate that failed to resolve means the family is only partly local,
|
|
80
|
+
so the attribute stays as it is rather than silently narrowing the set. */
|
|
81
|
+
const collapsedTo = !unresolved && locals.size === 1 ? [...locals][0]! : null;
|
|
82
|
+
return { collapsedTo, srcset: rewritten };
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
function rewriteCssUrls(value: string): string {
|
|
@@ -125,7 +139,17 @@ export function rewriteAssetUrls(
|
|
|
125
139
|
|
|
126
140
|
// Srcset attribute
|
|
127
141
|
if (typeof attrs.srcset === "string") {
|
|
128
|
-
|
|
142
|
+
const result = rewriteSrcset(attrs.srcset);
|
|
143
|
+
if (result.collapsedTo === null) {
|
|
144
|
+
attrs.srcset = result.srcset;
|
|
145
|
+
} else {
|
|
146
|
+
delete attrs.srcset;
|
|
147
|
+
/* `sizes` describes a layout that no longer exists once the candidate list is gone, and
|
|
148
|
+
the compiler honours an author `sizes` over its own container measurement — so a stale
|
|
149
|
+
one would misdescribe the imported layout rather than merely be redundant. */
|
|
150
|
+
delete attrs.sizes;
|
|
151
|
+
attrs.src = result.collapsedTo;
|
|
152
|
+
}
|
|
129
153
|
}
|
|
130
154
|
}
|
|
131
155
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launching a real Chrome on this machine — the one module in the import that loads
|
|
3
|
+
* `puppeteer-core` as a VALUE.
|
|
4
|
+
*
|
|
5
|
+
* It is separate from `capture.ts` for a bundling reason rather than a tidiness one.
|
|
6
|
+
* `puppeteer-core` cannot run in workerd at all (it reaches for `child_process`, `net` and a binary
|
|
7
|
+
* on disk), and a Worker bundler follows value imports transitively — so a single `import { launch
|
|
8
|
+
* }` anywhere in `pipeline.ts`'s graph would pull the whole of it in and fail the deploy. Keeping
|
|
9
|
+
* the launch here, and having every phase take a browser it was HANDED, is what lets Jx Cloud run
|
|
10
|
+
* the same pipeline against a `@cloudflare/puppeteer` session.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { launch } from "puppeteer-core";
|
|
14
|
+
import type { Browser } from "puppeteer-core";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_CHROME_PATHS = [
|
|
17
|
+
"google-chrome-stable",
|
|
18
|
+
"google-chrome",
|
|
19
|
+
"chromium-browser",
|
|
20
|
+
"chromium",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function findChrome(executablePath?: string): string {
|
|
24
|
+
if (executablePath) {
|
|
25
|
+
return executablePath;
|
|
26
|
+
}
|
|
27
|
+
const env = process.env.CHROME_PATH;
|
|
28
|
+
if (env) {
|
|
29
|
+
return env;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const name of DEFAULT_CHROME_PATHS) {
|
|
33
|
+
const path = Bun.which(name);
|
|
34
|
+
if (path) {
|
|
35
|
+
return path;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw new Error(
|
|
39
|
+
"Could not find Chrome/Chromium. Set CHROME_PATH or install google-chrome-stable.",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let _browser: Browser | null = null;
|
|
44
|
+
|
|
45
|
+
export interface LaunchOptions {
|
|
46
|
+
/** Explicit browser binary. Wins over CHROME_PATH and PATH discovery. */
|
|
47
|
+
executablePath?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function launchBrowser(options?: LaunchOptions): Promise<Browser> {
|
|
51
|
+
if (_browser?.connected) {
|
|
52
|
+
return _browser;
|
|
53
|
+
}
|
|
54
|
+
_browser = await launch({
|
|
55
|
+
executablePath: findChrome(options?.executablePath),
|
|
56
|
+
headless: true,
|
|
57
|
+
args: ["--no-sandbox", "--disable-gpu"],
|
|
58
|
+
});
|
|
59
|
+
return _browser;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function closeBrowser(): Promise<void> {
|
|
63
|
+
if (_browser?.connected) {
|
|
64
|
+
await _browser.close();
|
|
65
|
+
_browser = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
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/componentize.ts
CHANGED
|
@@ -185,6 +185,39 @@ function setNodeAt(root: JxElement, path: number[], replacement: JxElement): JxE
|
|
|
185
185
|
return result;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Tags that must never become a component ROOT, because the element itself carries the behaviour.
|
|
190
|
+
*
|
|
191
|
+
* A component's template root takes the component's own tag name, so promoting one of these
|
|
192
|
+
* replaces it with a custom element and the browser semantics go with it: `<details>` stops being a
|
|
193
|
+
* disclosure, `<summary>` stops being its label, `<dialog>` stops being a dialog, and a table or
|
|
194
|
+
* list part is torn out of the parent structure that gives it meaning. The children of one of these
|
|
195
|
+
* are still fair game — only the root is refused.
|
|
196
|
+
*
|
|
197
|
+
* This matters most for the widgets `apply-accordions.ts` converts: rewriting an accordion into
|
|
198
|
+
* `<details name>` makes its rows structurally identical, which is exactly the shape componentize
|
|
199
|
+
* looks for, so without this guard the pass would hand back a working disclosure and componentize
|
|
200
|
+
* would immediately turn it into an inert custom element.
|
|
201
|
+
*/
|
|
202
|
+
const NEVER_A_COMPONENT_ROOT = new Set([
|
|
203
|
+
"details",
|
|
204
|
+
"summary",
|
|
205
|
+
"dialog",
|
|
206
|
+
"table",
|
|
207
|
+
"thead",
|
|
208
|
+
"tbody",
|
|
209
|
+
"tfoot",
|
|
210
|
+
"tr",
|
|
211
|
+
"td",
|
|
212
|
+
"th",
|
|
213
|
+
"li",
|
|
214
|
+
"dt",
|
|
215
|
+
"dd",
|
|
216
|
+
"option",
|
|
217
|
+
"optgroup",
|
|
218
|
+
"figcaption",
|
|
219
|
+
]);
|
|
220
|
+
|
|
188
221
|
function collectSubtrees(
|
|
189
222
|
node: JxElement,
|
|
190
223
|
pageRoute: string,
|
|
@@ -193,7 +226,8 @@ function collectSubtrees(
|
|
|
193
226
|
out: SubtreeLocation[],
|
|
194
227
|
) {
|
|
195
228
|
// Skip root node (path.length === 0) — don't componentize entire pages
|
|
196
|
-
|
|
229
|
+
const tag = String(node.tagName ?? "").toLowerCase();
|
|
230
|
+
if (path.length > 0 && nodeDepth(node) >= minDepth && !NEVER_A_COMPONENT_ROOT.has(tag)) {
|
|
197
231
|
out.push({ pageRoute, path: [...path], node });
|
|
198
232
|
}
|
|
199
233
|
if (Array.isArray(node.children)) {
|
package/src/crawl.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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";
|
|
@@ -14,12 +15,16 @@ import type { Breakpoint, BreakpointPolicy } from "./breakpoint-plan.ts";
|
|
|
14
15
|
import { applyStylesToTree } from "./apply-styles.ts";
|
|
15
16
|
import { collectAssets } from "./asset-collect.ts";
|
|
16
17
|
import { downloadAssets } from "./asset-download.ts";
|
|
18
|
+
import { applyFamilyAliases, planImageFamilies } from "./image-family.ts";
|
|
17
19
|
import { rewriteAssetUrls } from "./asset-rewrite.ts";
|
|
18
20
|
import { applyTokens } from "./css-tokens.ts";
|
|
19
21
|
|
|
20
22
|
export interface CrawlOptions {
|
|
21
23
|
url: string;
|
|
22
|
-
|
|
24
|
+
/** The browser to capture with. The crawl never launches one — see `browser-local.ts`. */
|
|
25
|
+
browser: ImportBrowser;
|
|
26
|
+
/** Where downloaded assets go. */
|
|
27
|
+
io: ImportIo;
|
|
23
28
|
maxDepth: number;
|
|
24
29
|
maxPages: number;
|
|
25
30
|
maxNodesPerPage: number;
|
|
@@ -52,8 +57,8 @@ export interface CrawledPage {
|
|
|
52
57
|
jx: ToJxResult;
|
|
53
58
|
depth: number;
|
|
54
59
|
links: string[];
|
|
55
|
-
/** Reference screenshot PNG
|
|
56
|
-
screenshot?:
|
|
60
|
+
/** Reference screenshot PNG bytes, present when captureScreenshots is true. */
|
|
61
|
+
screenshot?: Uint8Array | undefined;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
export interface CrawlResult {
|
|
@@ -187,7 +192,8 @@ function isDisallowed(url: string, disallowedPaths: Set<string>): boolean {
|
|
|
187
192
|
export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
|
|
188
193
|
const {
|
|
189
194
|
url,
|
|
190
|
-
|
|
195
|
+
browser,
|
|
196
|
+
io,
|
|
191
197
|
maxDepth,
|
|
192
198
|
maxPages,
|
|
193
199
|
maxNodesPerPage,
|
|
@@ -199,7 +205,6 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
|
|
|
199
205
|
} = options;
|
|
200
206
|
|
|
201
207
|
const { origin } = new URL(url);
|
|
202
|
-
const browser = await launchBrowser();
|
|
203
208
|
|
|
204
209
|
// Fetch robots.txt
|
|
205
210
|
let disallowedPaths = new Set<string>();
|
|
@@ -261,11 +266,9 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
|
|
|
261
266
|
` ⚠ Page exceeds node cap (${jx.nodeCount} > ${maxNodesPerPage}), skipping styles/assets`,
|
|
262
267
|
);
|
|
263
268
|
skippedByNodeCap.push(entry.url);
|
|
264
|
-
let screenshot:
|
|
269
|
+
let screenshot: Uint8Array | undefined;
|
|
265
270
|
if (options.captureScreenshots) {
|
|
266
|
-
screenshot =
|
|
267
|
-
await capture.page.screenshot({ fullPage: fullPageScreenshots, type: "png" }),
|
|
268
|
-
);
|
|
271
|
+
screenshot = await capture.page.screenshot({ fullPage: fullPageScreenshots, type: "png" });
|
|
269
272
|
}
|
|
270
273
|
await capture.page.close();
|
|
271
274
|
|
|
@@ -325,7 +328,7 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
|
|
|
325
328
|
mergedBreakpoints = {};
|
|
326
329
|
}
|
|
327
330
|
Object.assign(mergedBreakpoints, media.breakpoints);
|
|
328
|
-
applyStylesToTree(jx.document, diffed, media.deltas);
|
|
331
|
+
applyStylesToTree(jx.document, diffed, media.deltas, media.breakpoints);
|
|
329
332
|
} else {
|
|
330
333
|
applyStylesToTree(jx.document, diffed);
|
|
331
334
|
}
|
|
@@ -363,7 +366,12 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
|
|
|
363
366
|
}
|
|
364
367
|
|
|
365
368
|
if (collected.assets.length > 0) {
|
|
366
|
-
|
|
369
|
+
/* One member per responsive family reaches the network; every dropped derivative is
|
|
370
|
+
aliased onto the file that WAS written, so a reference to any rung of the ladder still
|
|
371
|
+
resolves. The compiler regenerates the sizes from the original it now owns. */
|
|
372
|
+
const families = planImageFamilies(collected.assets);
|
|
373
|
+
const downloaded = await downloadAssets(families.keep, io, entry.url);
|
|
374
|
+
applyFamilyAliases(downloaded.rewriteMap, families.alias);
|
|
367
375
|
if (downloaded.rewriteMap.size > 0) {
|
|
368
376
|
rewriteAssetUrls(jx.document, downloaded.rewriteMap, entry.url);
|
|
369
377
|
// Collect font rewrites (R2)
|
|
@@ -381,11 +389,9 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
|
|
|
381
389
|
}
|
|
382
390
|
}
|
|
383
391
|
|
|
384
|
-
let screenshot:
|
|
392
|
+
let screenshot: Uint8Array | undefined;
|
|
385
393
|
if (options.captureScreenshots) {
|
|
386
|
-
screenshot =
|
|
387
|
-
await capture.page.screenshot({ fullPage: fullPageScreenshots, type: "png" }),
|
|
388
|
-
);
|
|
394
|
+
screenshot = await capture.page.screenshot({ fullPage: fullPageScreenshots, type: "png" });
|
|
389
395
|
}
|
|
390
396
|
await capture.page.close();
|
|
391
397
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop the `width` and `height` a browser MEASURED, keeping the ones a site actually authored.
|
|
3
|
+
*
|
|
4
|
+
* `getComputedStyle` returns used values, so `width` comes back as the pixels an element happens to
|
|
5
|
+
* occupy rather than the `100%` or `auto` that produced them. Written into a document as an
|
|
6
|
+
* unconditional style, that pins the layout: a full-width section captured at a 1440px viewport
|
|
7
|
+
* becomes `width: 1440px` and never fills anything else again, which is visible the moment a canvas
|
|
8
|
+
* is dragged wider than the width the import was taken at. On the reference corpus this was 814
|
|
9
|
+
* elements pinned at 1000px or more, and `width` and `height` were the two most common declarations
|
|
10
|
+
* in the entire project.
|
|
11
|
+
*
|
|
12
|
+
* `media-extract.ts` already removes these from BREAKPOINT deltas by sampling each band twice and
|
|
13
|
+
* keeping only what both samples agree on. That technique cannot rescue the base, for a reason
|
|
14
|
+
* worth writing down: a second sample must stay inside its own media band or it measures different
|
|
15
|
+
* rules, and the base band is bounded below by the site's widest `min-width` breakpoint. A
|
|
16
|
+
* container with `max-width: 1390px` measures 1390 at every width in that band, so it agrees with
|
|
17
|
+
* itself and survives — while being exactly the fluid element the reader will notice.
|
|
18
|
+
*
|
|
19
|
+
* So the base needs a different question, and the useful one is structural rather than numerical:
|
|
20
|
+
* **would this element have got that width on its own?** A block-level box in normal flow fills its
|
|
21
|
+
* containing block by definition, and a `max-width` or `flex-basis` that narrows it is captured
|
|
22
|
+
* separately and survives — so its measured `width` states a fact the layout already implies, and
|
|
23
|
+
* removing it restores the fluidity without losing the constraint. A replaced element, an
|
|
24
|
+
* out-of-flow box and a shrink-to-fit inline box are all sized by something the layout does NOT
|
|
25
|
+
* imply, so their measurements are kept.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { JxElement } from "@jxsuite/schema/types";
|
|
29
|
+
|
|
30
|
+
/** Elements sized by their own content or attributes rather than by their container. */
|
|
31
|
+
const REPLACED = new Set([
|
|
32
|
+
"img",
|
|
33
|
+
"svg",
|
|
34
|
+
"video",
|
|
35
|
+
"canvas",
|
|
36
|
+
"iframe",
|
|
37
|
+
"object",
|
|
38
|
+
"embed",
|
|
39
|
+
"input",
|
|
40
|
+
"select",
|
|
41
|
+
"textarea",
|
|
42
|
+
"progress",
|
|
43
|
+
"meter",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** A box taken out of the flow is positioned deliberately, so its measurements may be intentional. */
|
|
47
|
+
const OUT_OF_FLOW = new Set(["absolute", "fixed", "sticky"]);
|
|
48
|
+
|
|
49
|
+
/** A bare pixel length, which is the only shape a measurement takes. */
|
|
50
|
+
const PIXEL_LENGTH = /^-?[\d.]+px$/;
|
|
51
|
+
|
|
52
|
+
function isMeasured(value: unknown): boolean {
|
|
53
|
+
return typeof value === "string" && PIXEL_LENGTH.test(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether this node holds anything that gives it a height of its own.
|
|
58
|
+
*
|
|
59
|
+
* The distinction matters only for `height`. A box with content is as tall as that content, and
|
|
60
|
+
* pinning it breaks the moment text reflows at a narrower width — the single most damaging way a
|
|
61
|
+
* clone can fail. An EMPTY box is different: a spacer's entire purpose is its height, and there is
|
|
62
|
+
* nothing else in the document that would reproduce it.
|
|
63
|
+
*/
|
|
64
|
+
function hasContent(node: JxElement): boolean {
|
|
65
|
+
if (typeof node.textContent === "string" && node.textContent.trim().length > 0) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
69
|
+
return children.some((child) =>
|
|
70
|
+
typeof child === "string" ? child.trim().length > 0 : child !== undefined,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Remove measured geometry from a style object, in place.
|
|
76
|
+
*
|
|
77
|
+
* @param {JxElement} node - The element the style belongs to; its tag and content decide the answer
|
|
78
|
+
* @param {Record<string, unknown>} style - The style object to filter, mutated
|
|
79
|
+
* @param {Record<string, unknown>} [context] - Where `position` and `display` are read from, when
|
|
80
|
+
* they are not in `style` itself. A breakpoint delta carries only what CHANGED, so it usually
|
|
81
|
+
* states neither, and judging it on its own would strip an absolutely-positioned box's width on
|
|
82
|
+
* the grounds that it looked like an in-flow one.
|
|
83
|
+
* @returns {number} How many declarations were removed
|
|
84
|
+
*/
|
|
85
|
+
export function dropDerivedGeometry(
|
|
86
|
+
node: JxElement,
|
|
87
|
+
style: Record<string, unknown>,
|
|
88
|
+
context: Record<string, unknown> = style,
|
|
89
|
+
): number {
|
|
90
|
+
const tag = String(node.tagName ?? "").toLowerCase();
|
|
91
|
+
if (REPLACED.has(tag)) {
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
const position = style["position"] ?? context["position"];
|
|
95
|
+
if (OUT_OF_FLOW.has(String(position ?? ""))) {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
/* A shrink-to-fit box is sized by its content in the inline direction, so its width is not
|
|
99
|
+
implied by its container the way a block-level box's is. */
|
|
100
|
+
const display = style["display"] ?? context["display"];
|
|
101
|
+
if (String(display ?? "").startsWith("inline")) {
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let dropped = 0;
|
|
106
|
+
if (isMeasured(style["width"])) {
|
|
107
|
+
delete style["width"];
|
|
108
|
+
dropped += 1;
|
|
109
|
+
}
|
|
110
|
+
if (isMeasured(style["height"]) && hasContent(node)) {
|
|
111
|
+
delete style["height"];
|
|
112
|
+
dropped += 1;
|
|
113
|
+
}
|
|
114
|
+
return dropped;
|
|
115
|
+
}
|