@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/README.md
CHANGED
|
@@ -25,9 +25,35 @@ jx-import <url> [options]
|
|
|
25
25
|
--ai-components Use an LLM to refine component names and props
|
|
26
26
|
--ai-model <model> Model for AI componentization (default: gpt-4o-mini)
|
|
27
27
|
--verify After import, build and screenshot-diff vs the original
|
|
28
|
-
--
|
|
28
|
+
--min-fidelity <n> Fail (exit 1) below this average fidelity 0..100 (default: 25)
|
|
29
|
+
--verify-threshold <n> Per-pixel colour tolerance 0..1 for the diff (default: 0.15)
|
|
30
|
+
--verify-viewport-only Diff only the first viewport instead of the whole page
|
|
29
31
|
```
|
|
30
32
|
|
|
33
|
+
### What `--verify` can fail on
|
|
34
|
+
|
|
35
|
+
`--verify` builds the emitted project, serves it, screenshots every page and pixel-diffs each
|
|
36
|
+
against a reference captured during the import. The run exits non-zero when any of three things is
|
|
37
|
+
true: the project did not build cleanly, a page could not be rendered, or the average fidelity is
|
|
38
|
+
below `--min-fidelity`.
|
|
39
|
+
|
|
40
|
+
`--verify-threshold` is **not** that bar. It is pixelmatch's per-pixel colour tolerance: it decides
|
|
41
|
+
when two pixels count as the same colour, so it moves the score without ever deciding the outcome.
|
|
42
|
+
`--min-fidelity` is the bar, and it defaults to a deliberately low `25`, a floor for "this is not a
|
|
43
|
+
clone of anything" rather than a quality target. A faithful import of a complicated site still
|
|
44
|
+
lands well under 100 for reasons no importer can fix (a rotating hero, a font rendering a hair
|
|
45
|
+
differently).
|
|
46
|
+
|
|
47
|
+
`verify/report.json` carries the whole picture: per-page fidelity, the console errors and failed or
|
|
48
|
+
404'd requests each rendered page produced, and any build errors. When a page scores badly, read
|
|
49
|
+
`failedRequests` first, because a percentage cannot tell you that fifteen images 404'd and that
|
|
50
|
+
list can.
|
|
51
|
+
|
|
52
|
+
**Comparing two runs.** The reference screenshot is captured fresh each time, so two runs of the
|
|
53
|
+
same URL are not directly comparable. On a site with a rotating hero the spread across identical
|
|
54
|
+
runs has been measured at around 4 points, which is wide enough to hide a small regression. Compare
|
|
55
|
+
a fidelity number against another run of the same code, not against one from a different session.
|
|
56
|
+
|
|
31
57
|
Environment: `CHROME_PATH` (explicit browser binary; otherwise `google-chrome-stable`,
|
|
32
58
|
`google-chrome`, `chromium-browser`, or `chromium` is discovered on PATH), `OPENAI_API_KEY` and
|
|
33
59
|
`OPENAI_BASE_URL` for `--ai-components`.
|
|
@@ -52,7 +78,8 @@ const result = await importSite(
|
|
|
52
78
|
```
|
|
53
79
|
|
|
54
80
|
Lower-level building blocks (capture, style diffing, crawling, layout detection, componentization,
|
|
55
|
-
emit, verify) are exported from the package root
|
|
81
|
+
emit, verify) are exported from the package root; `./capture`, `./to-jx`, `./emit`, `./run` and
|
|
82
|
+
`./verify` are also addressable as subpaths. See `src/index.ts`.
|
|
56
83
|
|
|
57
84
|
Verification (`verify` option / `--verify`) additionally needs `@jxsuite/compiler` (an optional
|
|
58
85
|
dependency) to build the emitted project before screenshot-diffing it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/import",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.1",
|
|
4
4
|
"description": "Clone a live website into a Jx project",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -20,7 +20,11 @@
|
|
|
20
20
|
"./capture": "./src/capture.ts",
|
|
21
21
|
"./to-jx": "./src/to-jx.ts",
|
|
22
22
|
"./emit": "./src/emit.ts",
|
|
23
|
-
"./
|
|
23
|
+
"./io": "./src/io.ts",
|
|
24
|
+
"./pipeline": "./src/pipeline.ts",
|
|
25
|
+
"./run": "./src/run.ts",
|
|
26
|
+
"./verify": "./src/verify.ts",
|
|
27
|
+
"./breakpoint-plan": "./src/breakpoint-plan.ts"
|
|
24
28
|
},
|
|
25
29
|
"publishConfig": {
|
|
26
30
|
"provenance": true
|
|
@@ -31,8 +35,8 @@
|
|
|
31
35
|
"import": "bun run src/cli.ts"
|
|
32
36
|
},
|
|
33
37
|
"dependencies": {
|
|
34
|
-
"@jxsuite/markup": "^0.4.
|
|
35
|
-
"@jxsuite/schema": "^1.
|
|
38
|
+
"@jxsuite/markup": "^0.4.9",
|
|
39
|
+
"@jxsuite/schema": "^2.1.0",
|
|
36
40
|
"pixelmatch": "^7.2.0",
|
|
37
41
|
"pngjs": "^7.0.0",
|
|
38
42
|
"puppeteer-core": "^24.9.0"
|
|
@@ -42,6 +46,6 @@
|
|
|
42
46
|
"happy-dom": "^20.10.6"
|
|
43
47
|
},
|
|
44
48
|
"optionalDependencies": {
|
|
45
|
-
"@jxsuite/compiler": "^
|
|
49
|
+
"@jxsuite/compiler": "^3.1.0"
|
|
46
50
|
}
|
|
47
51
|
}
|
package/src/ai-componentize.ts
CHANGED
|
@@ -15,7 +15,17 @@ import type { ExtractedComponent, ComponentizeResult } from "./componentize.ts";
|
|
|
15
15
|
export interface AiComponentizeOptions {
|
|
16
16
|
apiKey: string;
|
|
17
17
|
baseUrl?: string | undefined;
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The model id, and it has no default on purpose.
|
|
20
|
+
*
|
|
21
|
+
* `"gpt-4o-mini"` used to be one, which was harmless while the only caller was the OSS CLI
|
|
22
|
+
* pointing at OpenAI and wrong the moment a backend brokered anything else: Workers AI answers a
|
|
23
|
+
* model it does not know with a 404, `callLlm` reads that as "the LLM failed", and every
|
|
24
|
+
* component silently keeps its heuristic `component-div-0` name. A required id makes the caller
|
|
25
|
+
* that knows which provider it is talking to say so. The CLI and the OSS server still default to
|
|
26
|
+
* `gpt-4o-mini` — at their own call site, in `run.ts`.
|
|
27
|
+
*/
|
|
28
|
+
model: string;
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
interface RenameResult {
|
|
@@ -58,7 +68,7 @@ Respond with ONLY the JSON object, no markdown fences or explanation.`;
|
|
|
58
68
|
|
|
59
69
|
async function callLlm(prompt: string, opts: AiComponentizeOptions): Promise<RenameResult | null> {
|
|
60
70
|
const baseUrl = opts.baseUrl || "https://api.openai.com/v1";
|
|
61
|
-
const model = opts
|
|
71
|
+
const { model } = opts;
|
|
62
72
|
|
|
63
73
|
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
64
74
|
method: "POST",
|
package/src/asset-collect.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
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
|
+
import { SRCSET_SEPARATOR } from "./srcset.ts";
|
|
9
10
|
|
|
10
11
|
export interface DiscoveredAsset {
|
|
11
12
|
/** Absolute URL of the asset. */
|
|
@@ -45,8 +46,13 @@ export interface AssetCollectionResult {
|
|
|
45
46
|
* Collect all asset URLs from the live page DOM and stylesheets. Runs entirely in-browser via
|
|
46
47
|
* page.evaluate() — no per-element round-trips.
|
|
47
48
|
*/
|
|
48
|
-
export async function collectAssets(page:
|
|
49
|
-
|
|
49
|
+
export async function collectAssets(page: ImportPage): Promise<AssetCollectionResult> {
|
|
50
|
+
/*
|
|
51
|
+
* The separator crosses into the page as a SOURCE STRING: an evaluated body is serialised and
|
|
52
|
+
* re-parsed in the browser, so it cannot close over a value declared out here.
|
|
53
|
+
*/
|
|
54
|
+
const result = await page.evaluate((srcsetSeparator: string) => {
|
|
55
|
+
const separator = new RegExp(srcsetSeparator);
|
|
50
56
|
const assets: { url: string; source: string }[] = [];
|
|
51
57
|
const seen = new Set<string>();
|
|
52
58
|
let inlineSvgCount = 0;
|
|
@@ -73,7 +79,7 @@ export async function collectAssets(page: Page): Promise<AssetCollectionResult>
|
|
|
73
79
|
|
|
74
80
|
function parseSrcset(srcset: string): string[] {
|
|
75
81
|
return srcset
|
|
76
|
-
.split(
|
|
82
|
+
.split(separator)
|
|
77
83
|
.map((entry) => entry.trim().split(/\s+/)[0] ?? "")
|
|
78
84
|
.filter(Boolean);
|
|
79
85
|
}
|
|
@@ -193,7 +199,7 @@ export async function collectAssets(page: Page): Promise<AssetCollectionResult>
|
|
|
193
199
|
inlineSvgCount: number;
|
|
194
200
|
stylesheets: { href: string | null; cssText: string | null; fontFaceRules: string[] }[];
|
|
195
201
|
};
|
|
196
|
-
});
|
|
202
|
+
}, SRCSET_SEPARATOR.source);
|
|
197
203
|
|
|
198
204
|
// R2: For cross-origin sheets we couldn't access (cssText=null, has href), fetch them
|
|
199
205
|
// In-browser to get @font-face rules (e.g. Google Fonts loaded via <link>)
|
package/src/asset-download.ts
CHANGED
|
@@ -5,14 +5,15 @@
|
|
|
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
|
/**
|
|
14
|
-
* Maps original absolute URL
|
|
15
|
-
* "
|
|
14
|
+
* Maps the original absolute URL to the site-absolute path the built asset is served from (e.g.
|
|
15
|
+
* "/assets/images/hero.jpg") — NOT the path it is written to on disk, which keeps the public/
|
|
16
|
+
* prefix the compiler copies from.
|
|
16
17
|
*/
|
|
17
18
|
rewriteMap: Map<string, string>;
|
|
18
19
|
/** URLs that failed to download. */
|
|
@@ -126,7 +127,7 @@ function isBlocked(url: string): boolean {
|
|
|
126
127
|
|
|
127
128
|
export async function downloadAssets(
|
|
128
129
|
assets: DiscoveredAsset[],
|
|
129
|
-
|
|
130
|
+
io: ImportIo,
|
|
130
131
|
sourceUrl?: string,
|
|
131
132
|
): Promise<DownloadResult> {
|
|
132
133
|
const rewriteMap = new Map<string, string>();
|
|
@@ -142,12 +143,11 @@ export async function downloadAssets(
|
|
|
142
143
|
}
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
// Create subdirectories
|
|
146
|
-
const assetsDir =
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
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
|
+
}
|
|
151
151
|
|
|
152
152
|
// Track filenames to avoid collisions
|
|
153
153
|
const usedNames = new Map<string, number>();
|
|
@@ -171,8 +171,12 @@ export async function downloadAssets(
|
|
|
171
171
|
}
|
|
172
172
|
usedNames.set(nameKey, count + 1);
|
|
173
173
|
|
|
174
|
-
const destPath =
|
|
175
|
-
|
|
174
|
+
const destPath = `${assetsDir}/${subdir}/${filename}`;
|
|
175
|
+
// The site-absolute path the built asset is served from. `public/` is the compiler's static
|
|
176
|
+
// ROOT — its contents land at dist/<path>, so the "public/" segment must not survive into a
|
|
177
|
+
// Reference, and the leading slash must be there or a reference from /components/x.js or a
|
|
178
|
+
// Nested route resolves against the wrong base.
|
|
179
|
+
const relativePath = `/assets/${subdir}/${filename}`;
|
|
176
180
|
|
|
177
181
|
try {
|
|
178
182
|
const headers: Record<string, string> = {
|
|
@@ -190,7 +194,7 @@ export async function downloadAssets(
|
|
|
190
194
|
|
|
191
195
|
const buffer = await response.arrayBuffer();
|
|
192
196
|
totalBytes += buffer.byteLength;
|
|
193
|
-
await
|
|
197
|
+
await io.write(destPath, new Uint8Array(buffer));
|
|
194
198
|
rewriteMap.set(url, relativePath);
|
|
195
199
|
} catch {
|
|
196
200
|
failed.push(url);
|
package/src/asset-rewrite.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { JxElement, JxStyle } from "@jxsuite/schema/types";
|
|
9
|
+
import { SRCSET_SEPARATOR } from "./srcset.ts";
|
|
9
10
|
|
|
10
11
|
/** Rewrite all asset URLs in a Jx tree in-place. Returns the count of rewrites performed. */
|
|
11
12
|
export function rewriteAssetUrls(
|
|
@@ -46,9 +47,14 @@ export function rewriteAssetUrls(
|
|
|
46
47
|
return null;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/*
|
|
51
|
+
* Split by the same rule the collector downloads by (`SRCSET_SEPARATOR`). If the two disagreed,
|
|
52
|
+
* the importer would fetch one set of URLs and rewrite another — the attribute would be
|
|
53
|
+
* reassembled around fragments nothing had downloaded.
|
|
54
|
+
*/
|
|
49
55
|
function rewriteSrcset(srcset: string): string {
|
|
50
56
|
return srcset
|
|
51
|
-
.split(
|
|
57
|
+
.split(SRCSET_SEPARATOR)
|
|
52
58
|
.map((entry) => {
|
|
53
59
|
const parts = entry.trim().split(/\s+/);
|
|
54
60
|
const [url = ""] = parts;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide which of a site's breakpoints the imported project gets.
|
|
3
|
+
*
|
|
4
|
+
* A real site declares breakpoints the way it grew: one framework's four, a theme's three, a
|
|
5
|
+
* plugin's two, a hand-written pair for one component. Taken literally that is what the importer
|
|
6
|
+
* used to emit — nine `$media` entries, unsorted, named `--767` and `--1025`, each one a canvas
|
|
7
|
+
* size in Studio and a column in every style editor. Nobody authors against nine breakpoints, and
|
|
8
|
+
* nobody asked for these nine.
|
|
9
|
+
*
|
|
10
|
+
* So the widths a site DECLARES and the widths a project KEEPS are two different questions, and
|
|
11
|
+
* this module is the second one. It is pure — no browser, no page, no capture — because the
|
|
12
|
+
* decision has to be made BEFORE the re-capture pass: each kept breakpoint costs a viewport change
|
|
13
|
+
* and a full style walk, so planning first is what makes a limited import faster rather than merely
|
|
14
|
+
* tidier.
|
|
15
|
+
*
|
|
16
|
+
* Nothing is thrown away silently. Every discovered width that is not kept is FOLDED into the kept
|
|
17
|
+
* one nearest it under the caller's rounding rule, so the styles that width carried still reach the
|
|
18
|
+
* project — at the breakpoint the project actually has.
|
|
19
|
+
*
|
|
20
|
+
* @docs studio/design/breakpoints
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One breakpoint: what to call it, what to emit, and the viewport width to sample it at. */
|
|
24
|
+
export interface Breakpoint {
|
|
25
|
+
/** Jx breakpoint name, e.g. `--768`. */
|
|
26
|
+
name: string;
|
|
27
|
+
/** The CSS media query to write into `project.json`'s `$media`, e.g. `(max-width: 768px)`. */
|
|
28
|
+
query: string;
|
|
29
|
+
/**
|
|
30
|
+
* The viewport width to re-capture styles at.
|
|
31
|
+
*
|
|
32
|
+
* Usually the query's own width, and deliberately NOT so under an explicit override: the styles
|
|
33
|
+
* flip at the width the SITE declared, so that is where they must be sampled, even when the
|
|
34
|
+
* project is going to call the breakpoint something else.
|
|
35
|
+
*/
|
|
36
|
+
testWidth: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How a requested width is matched against the widths a site actually declares. */
|
|
40
|
+
export type BreakpointRounding = "nearest" | "down" | "up";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What to do with the widths a capture discovered.
|
|
44
|
+
*
|
|
45
|
+
* - `all` — keep every one, sorted. What the importer did before there was a choice.
|
|
46
|
+
* - `limit` — keep `count` of them, evenly spaced across the discovered range (3 → the narrowest, the
|
|
47
|
+
* middle one, and the widest). The default, because "too many breakpoints" is the complaint this
|
|
48
|
+
* exists to answer.
|
|
49
|
+
* - `explicit` — keep the widths the author named, each backed by the declared width nearest it.
|
|
50
|
+
*/
|
|
51
|
+
export type BreakpointPolicy =
|
|
52
|
+
| { mode: "all" }
|
|
53
|
+
| { mode: "limit"; count: number; rounding: BreakpointRounding }
|
|
54
|
+
| { mode: "explicit"; widths: number[]; rounding: BreakpointRounding };
|
|
55
|
+
|
|
56
|
+
/** What a new import gets when nobody says otherwise. */
|
|
57
|
+
export const DEFAULT_BREAKPOINT_POLICY: BreakpointPolicy = {
|
|
58
|
+
count: 3,
|
|
59
|
+
mode: "limit",
|
|
60
|
+
rounding: "nearest",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The most breakpoints a project may be asked to keep. Past this, "limit" is not limiting. */
|
|
64
|
+
export const MAX_BREAKPOINTS = 12;
|
|
65
|
+
|
|
66
|
+
/** The viewport widths an override may name. Outside this a "width" is a typo, not a breakpoint. */
|
|
67
|
+
export const MIN_BREAKPOINT_WIDTH = 120;
|
|
68
|
+
export const MAX_BREAKPOINT_WIDTH = 4000;
|
|
69
|
+
|
|
70
|
+
export interface BreakpointPlanResult {
|
|
71
|
+
/** The breakpoints to capture and emit, ascending by `testWidth`. */
|
|
72
|
+
keep: Breakpoint[];
|
|
73
|
+
/**
|
|
74
|
+
* Discovered breakpoint name → the kept breakpoint name its style deltas merge into.
|
|
75
|
+
*
|
|
76
|
+
* A kept breakpoint maps to itself, so a caller can look up every discovered name without a
|
|
77
|
+
* fallback branch.
|
|
78
|
+
*/
|
|
79
|
+
fold: Map<string, string>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse a media query into a pixel width, or null when it is not a simple one-clause width query.
|
|
84
|
+
*
|
|
85
|
+
* Anchored and `px`-only on purpose: `(min-width: 48rem)` has no pixel width without a root font
|
|
86
|
+
* size, and `(min-width:768px) and (max-width:1024px)` names a band rather than a threshold. Both
|
|
87
|
+
* are real and both are refused here rather than guessed at — {@link skippedWidthQueries} is how the
|
|
88
|
+
* import reports what it could not read.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} query - A media query's condition text
|
|
91
|
+
* @returns {{ type: "min" | "max"; width: number } | null}
|
|
92
|
+
*/
|
|
93
|
+
export function parseWidthQuery(query: string): { type: "min" | "max"; width: number } | null {
|
|
94
|
+
const minMatch = query.match(/^\s*\(\s*min-width\s*:\s*([\d.]+)px\s*\)\s*$/);
|
|
95
|
+
if (minMatch) {
|
|
96
|
+
return { type: "min", width: Number(minMatch[1]) };
|
|
97
|
+
}
|
|
98
|
+
const maxMatch = query.match(/^\s*\(\s*max-width\s*:\s*([\d.]+)px\s*\)\s*$/);
|
|
99
|
+
if (maxMatch) {
|
|
100
|
+
return { type: "max", width: Number(maxMatch[1]) };
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The Jx breakpoint name for a width, e.g. `768` → `--768`. */
|
|
106
|
+
export function breakpointName(width: number): string {
|
|
107
|
+
return `--${width}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Analyze discovered `@media` queries and produce a set of testable breakpoints. Only simple
|
|
112
|
+
* one-clause `min-width` / `max-width` queries in `px` are readable as a breakpoint;
|
|
113
|
+
* {@link skippedWidthQueries} names the rest, so an import can say what it could not use instead of
|
|
114
|
+
* dropping it in silence.
|
|
115
|
+
*
|
|
116
|
+
* @param {readonly string[]} queries
|
|
117
|
+
* @returns {Breakpoint[]} Ascending by width, deduplicated
|
|
118
|
+
*/
|
|
119
|
+
export function analyzeMediaQueries(queries: readonly string[]): Breakpoint[] {
|
|
120
|
+
const seen = new Set<number>();
|
|
121
|
+
const breakpoints: Breakpoint[] = [];
|
|
122
|
+
|
|
123
|
+
for (const query of queries) {
|
|
124
|
+
const parsed = parseWidthQuery(query);
|
|
125
|
+
if (!parsed || seen.has(parsed.width)) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
seen.add(parsed.width);
|
|
129
|
+
|
|
130
|
+
// Both directions are tested AT the declared width: for max-width it is the upper bound of the
|
|
131
|
+
// Narrow rules, for min-width it is where the wide rules start applying.
|
|
132
|
+
breakpoints.push({ name: breakpointName(parsed.width), query, testWidth: parsed.width });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
breakpoints.sort((a, b) => a.testWidth - b.testWidth);
|
|
136
|
+
return breakpoints;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The width-ish queries {@link analyzeMediaQueries} could not read — a compound condition, a `rem`
|
|
141
|
+
* threshold, a range syntax. A query with no width in it at all (`prefers-color-scheme`, `hover`,
|
|
142
|
+
* `print`) is not listed: it is not a breakpoint and its absence is not a loss.
|
|
143
|
+
*
|
|
144
|
+
* @param {readonly string[]} queries
|
|
145
|
+
* @returns {string[]}
|
|
146
|
+
*/
|
|
147
|
+
export function skippedWidthQueries(queries: readonly string[]): string[] {
|
|
148
|
+
const skipped: string[] = [];
|
|
149
|
+
for (const query of queries) {
|
|
150
|
+
if (parseWidthQuery(query)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (/\bwidth\b/i.test(query)) {
|
|
154
|
+
skipped.push(query);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return [...new Set(skipped)];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The candidate nearest `target` under `rounding`.
|
|
162
|
+
*
|
|
163
|
+
* Each rule falls back to the nearest end of the range rather than to nothing: a `down` request
|
|
164
|
+
* below every candidate still has an answer, and returning null there would silently drop a width
|
|
165
|
+
* the author explicitly asked for.
|
|
166
|
+
*
|
|
167
|
+
* @param {readonly number[]} candidates - Ascending, non-empty
|
|
168
|
+
* @param {number} target
|
|
169
|
+
* @param {BreakpointRounding} rounding
|
|
170
|
+
* @returns {number}
|
|
171
|
+
*/
|
|
172
|
+
function pickNearest(
|
|
173
|
+
candidates: readonly number[],
|
|
174
|
+
target: number,
|
|
175
|
+
rounding: BreakpointRounding,
|
|
176
|
+
): number {
|
|
177
|
+
if (rounding === "down") {
|
|
178
|
+
let best = candidates[0]!;
|
|
179
|
+
for (const width of candidates) {
|
|
180
|
+
if (width <= target) {
|
|
181
|
+
best = width;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return best;
|
|
185
|
+
}
|
|
186
|
+
if (rounding === "up") {
|
|
187
|
+
for (const width of candidates) {
|
|
188
|
+
if (width >= target) {
|
|
189
|
+
return width;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return candidates.at(-1)!;
|
|
193
|
+
}
|
|
194
|
+
// Nearest, ties to the narrower width — deterministic, and the narrower styles are the ones a
|
|
195
|
+
// Reader is more likely to have meant when two are equidistant.
|
|
196
|
+
let best = candidates[0]!;
|
|
197
|
+
let bestDelta = Math.abs(best - target);
|
|
198
|
+
for (const width of candidates) {
|
|
199
|
+
const delta = Math.abs(width - target);
|
|
200
|
+
if (delta < bestDelta) {
|
|
201
|
+
best = width;
|
|
202
|
+
bestDelta = delta;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return best;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* `count` indices evenly spaced across `[0, length - 1]`, both ends included.
|
|
210
|
+
*
|
|
211
|
+
* A single breakpoint takes the MEDIAN rather than the narrowest: asked for one, an author wants
|
|
212
|
+
* the width the site's layout actually turns at, and the narrowest is usually a phone edge case.
|
|
213
|
+
*/
|
|
214
|
+
function evenIndices(length: number, count: number): number[] {
|
|
215
|
+
if (count >= length) {
|
|
216
|
+
return [...Array.from({ length }).keys()];
|
|
217
|
+
}
|
|
218
|
+
if (count === 1) {
|
|
219
|
+
return [Math.floor((length - 1) / 2)];
|
|
220
|
+
}
|
|
221
|
+
const out: number[] = [];
|
|
222
|
+
for (let i = 0; i < count; i += 1) {
|
|
223
|
+
out.push(Math.round((i * (length - 1)) / (count - 1)));
|
|
224
|
+
}
|
|
225
|
+
return [...new Set(out)];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Choose the breakpoints an import keeps, and where the rest of them go.
|
|
230
|
+
*
|
|
231
|
+
* @param {readonly Breakpoint[]} discovered - Everything the capture found, in any order
|
|
232
|
+
* @param {BreakpointPolicy} policy
|
|
233
|
+
* @returns {BreakpointPlanResult}
|
|
234
|
+
*/
|
|
235
|
+
export function planBreakpoints(
|
|
236
|
+
discovered: readonly Breakpoint[],
|
|
237
|
+
policy: BreakpointPolicy = DEFAULT_BREAKPOINT_POLICY,
|
|
238
|
+
): BreakpointPlanResult {
|
|
239
|
+
const sorted = discovered.toSorted((a, b) => a.testWidth - b.testWidth);
|
|
240
|
+
if (sorted.length === 0) {
|
|
241
|
+
return { fold: new Map(), keep: [] };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const widths = sorted.map((bp) => bp.testWidth);
|
|
245
|
+
const byWidth = new Map(sorted.map((bp) => [bp.testWidth, bp]));
|
|
246
|
+
|
|
247
|
+
const keep: Breakpoint[] = [];
|
|
248
|
+
if (policy.mode === "all") {
|
|
249
|
+
keep.push(...sorted);
|
|
250
|
+
} else if (policy.mode === "limit") {
|
|
251
|
+
const count = Math.max(1, Math.min(MAX_BREAKPOINTS, Math.trunc(policy.count) || 1));
|
|
252
|
+
for (const index of evenIndices(sorted.length, count)) {
|
|
253
|
+
keep.push(sorted[index]!);
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
/* An override names the widths the PROJECT will have; each is backed by the declared width
|
|
257
|
+
nearest it, because that is where the site's own rules actually change. Two overrides that
|
|
258
|
+
land on the same declared width collapse into one — emitting the same styles twice under two
|
|
259
|
+
names would be two canvases showing the same thing. */
|
|
260
|
+
const requested = [...new Set(policy.widths.map((w) => Math.trunc(w)))]
|
|
261
|
+
.filter((w) => w >= MIN_BREAKPOINT_WIDTH && w <= MAX_BREAKPOINT_WIDTH)
|
|
262
|
+
.toSorted((a, b) => a - b)
|
|
263
|
+
.slice(0, MAX_BREAKPOINTS);
|
|
264
|
+
const claimed = new Set<number>();
|
|
265
|
+
for (const target of requested) {
|
|
266
|
+
const backing = pickNearest(widths, target, policy.rounding);
|
|
267
|
+
if (claimed.has(backing)) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
claimed.add(backing);
|
|
271
|
+
const source = byWidth.get(backing)!;
|
|
272
|
+
const parsed = parseWidthQuery(source.query);
|
|
273
|
+
keep.push({
|
|
274
|
+
name: breakpointName(target),
|
|
275
|
+
query: parsed ? `(${parsed.type}-width: ${target}px)` : source.query,
|
|
276
|
+
testWidth: backing,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
keep.splice(0, keep.length, ...keep.toSorted((a, b) => a.testWidth - b.testWidth));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const fold = new Map<string, string>();
|
|
283
|
+
if (keep.length === 0) {
|
|
284
|
+
return { fold, keep };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const keptWidths = keep.map((bp) => bp.testWidth);
|
|
288
|
+
const keptByWidth = new Map(keep.map((bp) => [bp.testWidth, bp]));
|
|
289
|
+
const rounding = policy.mode === "all" ? "nearest" : policy.rounding;
|
|
290
|
+
for (const bp of sorted) {
|
|
291
|
+
const target =
|
|
292
|
+
keptByWidth.get(bp.testWidth) ??
|
|
293
|
+
keptByWidth.get(pickNearest(keptWidths, bp.testWidth, rounding))!;
|
|
294
|
+
fold.set(bp.name, target.name);
|
|
295
|
+
}
|
|
296
|
+
return { fold, keep };
|
|
297
|
+
}
|
|
@@ -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
|
+
}
|