@jxsuite/import 0.39.8 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -2
- package/package.json +7 -5
- package/src/asset-collect.ts +9 -3
- package/src/asset-download.ts +8 -3
- package/src/asset-rewrite.ts +7 -1
- package/src/breakpoint-plan.ts +297 -0
- package/src/cli.ts +106 -2
- package/src/componentize.ts +13 -0
- package/src/crawl.ts +40 -2
- package/src/emit.ts +104 -11
- package/src/index.ts +21 -3
- package/src/media-extract.ts +51 -75
- package/src/run.ts +227 -12
- package/src/srcset.ts +32 -0
- package/src/strip-classes.ts +73 -0
- package/src/verify.ts +165 -13
package/src/crawl.ts
CHANGED
|
@@ -5,6 +5,12 @@ import type { ToJxResult } from "./to-jx.ts";
|
|
|
5
5
|
import { captureStyles } from "./style-capture.ts";
|
|
6
6
|
import { diffAllStyles, kebabToCamel } from "./style-diff.ts";
|
|
7
7
|
import { extractMedia } from "./media-extract.ts";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_BREAKPOINT_POLICY,
|
|
10
|
+
analyzeMediaQueries,
|
|
11
|
+
planBreakpoints,
|
|
12
|
+
} from "./breakpoint-plan.ts";
|
|
13
|
+
import type { Breakpoint, BreakpointPolicy } from "./breakpoint-plan.ts";
|
|
8
14
|
import { applyStylesToTree } from "./apply-styles.ts";
|
|
9
15
|
import { collectAssets } from "./asset-collect.ts";
|
|
10
16
|
import { downloadAssets } from "./asset-download.ts";
|
|
@@ -24,6 +30,16 @@ export interface CrawlOptions {
|
|
|
24
30
|
noScroll?: boolean;
|
|
25
31
|
/** Capture a reference screenshot of each page before closing (for --verify). */
|
|
26
32
|
captureScreenshots?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Capture the whole scrollable page rather than the viewport (default true).
|
|
35
|
+
*
|
|
36
|
+
* It has to agree with what `verifyProject` screenshots on the other side. A viewport reference
|
|
37
|
+
* diffed against a full-page render is compared by padding the shorter image, so the two would
|
|
38
|
+
* disagree over everything below the fold before a single style was compared.
|
|
39
|
+
*/
|
|
40
|
+
fullPageScreenshots?: boolean;
|
|
41
|
+
/** Which of the site's declared breakpoints the project keeps (see `breakpoint-plan.ts`). */
|
|
42
|
+
breakpoints?: BreakpointPolicy | undefined;
|
|
27
43
|
onProgress?: (msg: string) => void;
|
|
28
44
|
/** Abort the crawl between pages; the loop throws before capturing the next page. */
|
|
29
45
|
signal?: AbortSignal;
|
|
@@ -178,6 +194,7 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
|
|
|
178
194
|
skipStyles,
|
|
179
195
|
skipAssets,
|
|
180
196
|
respectRobots,
|
|
197
|
+
fullPageScreenshots = true,
|
|
181
198
|
onProgress = console.log,
|
|
182
199
|
} = options;
|
|
183
200
|
|
|
@@ -202,6 +219,9 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
|
|
|
202
219
|
const skippedByRobots: string[] = [];
|
|
203
220
|
const skippedByNodeCap: string[] = [];
|
|
204
221
|
let mergedBreakpoints: Record<string, string> | undefined;
|
|
222
|
+
/* Decided on the first page that declares a width query, then reused. See ExtractMediaOptions.plan
|
|
223
|
+
— a per-page plan would union back up to the count the policy just refused. */
|
|
224
|
+
let breakpointPlan: Breakpoint[] | undefined;
|
|
205
225
|
let mergedTokens: Record<string, string> | undefined;
|
|
206
226
|
const allFontFaceRules: string[] = [];
|
|
207
227
|
const fontRewriteMap = new Map<string, string>();
|
|
@@ -243,7 +263,9 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
|
|
|
243
263
|
skippedByNodeCap.push(entry.url);
|
|
244
264
|
let screenshot: Buffer | undefined;
|
|
245
265
|
if (options.captureScreenshots) {
|
|
246
|
-
screenshot = Buffer.from(
|
|
266
|
+
screenshot = Buffer.from(
|
|
267
|
+
await capture.page.screenshot({ fullPage: fullPageScreenshots, type: "png" }),
|
|
268
|
+
);
|
|
247
269
|
}
|
|
248
270
|
await capture.page.close();
|
|
249
271
|
|
|
@@ -277,11 +299,25 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
|
|
|
277
299
|
}
|
|
278
300
|
|
|
279
301
|
if (styleResult.mediaQueries.length > 0) {
|
|
302
|
+
if (!breakpointPlan) {
|
|
303
|
+
const declared = analyzeMediaQueries(styleResult.mediaQueries);
|
|
304
|
+
breakpointPlan = planBreakpoints(
|
|
305
|
+
declared,
|
|
306
|
+
options.breakpoints ?? DEFAULT_BREAKPOINT_POLICY,
|
|
307
|
+
).keep;
|
|
308
|
+
if (breakpointPlan.length < declared.length) {
|
|
309
|
+
onProgress?.(
|
|
310
|
+
` ${declared.length} breakpoints declared, keeping ` +
|
|
311
|
+
`${breakpointPlan.length}: ${breakpointPlan.map((b) => b.name).join(", ")}\n`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
280
315
|
const media = await extractMedia(
|
|
281
316
|
capture.page,
|
|
282
317
|
styleResult.elements,
|
|
283
318
|
styleResult.uaDefaults,
|
|
284
319
|
styleResult.mediaQueries,
|
|
320
|
+
{ plan: breakpointPlan },
|
|
285
321
|
);
|
|
286
322
|
const bpCount = Object.keys(media.breakpoints).length;
|
|
287
323
|
if (bpCount > 0) {
|
|
@@ -347,7 +383,9 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
|
|
|
347
383
|
|
|
348
384
|
let screenshot: Buffer | undefined;
|
|
349
385
|
if (options.captureScreenshots) {
|
|
350
|
-
screenshot = Buffer.from(
|
|
386
|
+
screenshot = Buffer.from(
|
|
387
|
+
await capture.page.screenshot({ fullPage: fullPageScreenshots, type: "png" }),
|
|
388
|
+
);
|
|
351
389
|
}
|
|
352
390
|
await capture.page.close();
|
|
353
391
|
|
package/src/emit.ts
CHANGED
|
@@ -2,8 +2,31 @@ import { join, dirname } from "node:path";
|
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
3
|
import type { JxElement } from "@jxsuite/schema/types";
|
|
4
4
|
import { componentize } from "./componentize.ts";
|
|
5
|
+
import { stripClasses } from "./strip-classes.ts";
|
|
5
6
|
import type { ComponentizeOptions, ComponentizeResult } from "./componentize.ts";
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The `$media` key the base width lives under — a number of CSS pixels, not a query.
|
|
10
|
+
*
|
|
11
|
+
* Studio's canvas reads it to size the default artboard (`utils/canvas-media.ts`), and an import
|
|
12
|
+
* never wrote one, so every imported project's base canvas fell back to 320px while its narrowest
|
|
13
|
+
* real breakpoint was three times that. Mirrors `settings/contexts-section.ts`'s `BASE_KEY`.
|
|
14
|
+
*/
|
|
15
|
+
const BASE_MEDIA_KEY = "--";
|
|
16
|
+
|
|
17
|
+
/** The width a `--<width>` breakpoint key names, for sorting. Non-numeric keys sort first. */
|
|
18
|
+
function mediaKeyWidth(key: string): number {
|
|
19
|
+
const width = Number(key.replace(/^--/, ""));
|
|
20
|
+
return Number.isFinite(width) ? width : -1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface EmitResult {
|
|
24
|
+
/** Absolute paths of every file written. */
|
|
25
|
+
files: string[];
|
|
26
|
+
/** How many source-site class names were removed on the way out. */
|
|
27
|
+
classesStripped: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
7
30
|
export interface EmitOptions {
|
|
8
31
|
outDir: string;
|
|
9
32
|
title: string;
|
|
@@ -11,6 +34,8 @@ export interface EmitOptions {
|
|
|
11
34
|
sourceUrl: string;
|
|
12
35
|
/** $media breakpoints to seed into project.json (Phase 1). */
|
|
13
36
|
breakpoints?: Record<string, string>;
|
|
37
|
+
/** The capture viewport width, written as the `$media` base entry. */
|
|
38
|
+
baseWidth?: number;
|
|
14
39
|
/** Phase 4: componentization options. Pass false to skip. */
|
|
15
40
|
componentizeOptions?: ComponentizeOptions | false;
|
|
16
41
|
}
|
|
@@ -25,6 +50,8 @@ export interface MultiEmitOptions {
|
|
|
25
50
|
layout?: JxElement | undefined;
|
|
26
51
|
/** $media breakpoints to seed into project.json. */
|
|
27
52
|
breakpoints?: Record<string, string> | undefined;
|
|
53
|
+
/** The capture viewport width, written as the `$media` base entry. */
|
|
54
|
+
baseWidth?: number | undefined;
|
|
28
55
|
/** Phase 4: componentization options. Pass false to skip. */
|
|
29
56
|
componentizeOptions?: ComponentizeOptions | false | undefined;
|
|
30
57
|
/** Pre-computed componentization result (from AI pass). Overrides componentizeOptions. */
|
|
@@ -43,14 +70,16 @@ export async function emitProject({
|
|
|
43
70
|
document,
|
|
44
71
|
sourceUrl,
|
|
45
72
|
breakpoints,
|
|
73
|
+
baseWidth,
|
|
46
74
|
componentizeOptions,
|
|
47
|
-
}: EmitOptions): Promise<
|
|
75
|
+
}: EmitOptions): Promise<EmitResult> {
|
|
48
76
|
return emitMultiPageProject({
|
|
49
77
|
outDir,
|
|
50
78
|
title,
|
|
51
79
|
sourceUrl,
|
|
52
80
|
pages: new Map([["pages/index.json", document]]),
|
|
53
81
|
breakpoints,
|
|
82
|
+
baseWidth,
|
|
54
83
|
componentizeOptions,
|
|
55
84
|
});
|
|
56
85
|
}
|
|
@@ -58,35 +87,53 @@ export async function emitProject({
|
|
|
58
87
|
export async function emitMultiPageProject({
|
|
59
88
|
outDir,
|
|
60
89
|
title,
|
|
61
|
-
sourceUrl,
|
|
62
90
|
pages,
|
|
63
91
|
layout,
|
|
64
92
|
breakpoints,
|
|
93
|
+
baseWidth,
|
|
65
94
|
componentizeOptions,
|
|
66
95
|
precomputedComponents,
|
|
67
96
|
fontFaceRules,
|
|
68
97
|
fontRewriteMap,
|
|
69
98
|
styleTokens,
|
|
70
|
-
}: MultiEmitOptions): Promise<
|
|
99
|
+
}: MultiEmitOptions): Promise<EmitResult> {
|
|
71
100
|
await mkdir(join(outDir, "pages"), { recursive: true });
|
|
72
101
|
await mkdir(join(outDir, "layouts"), { recursive: true });
|
|
73
102
|
await mkdir(join(outDir, "components"), { recursive: true });
|
|
74
103
|
await mkdir(join(outDir, "public"), { recursive: true });
|
|
75
104
|
|
|
105
|
+
/*
|
|
106
|
+
* Only keys the project schema declares (#228). `title` and `description` are page-level keys and
|
|
107
|
+
* `$style` is not a key at all, and all three made every imported project fail `jx validate` —
|
|
108
|
+
* and, because Studio's Contexts editor validates the WHOLE configuration before saving, made an
|
|
109
|
+
* imported project's base width uneditable, reporting `(root): must NOT have unevaluated
|
|
110
|
+
* properties` three times while naming nothing.
|
|
111
|
+
*
|
|
112
|
+
* The source URL has no schema-legal home and does not get one. `$head`'s description meta is the
|
|
113
|
+
* nearest candidate and the wrong one: it is the site's own user-facing description, and
|
|
114
|
+
* "Imported from …" is provenance, not a description of the site. The importer reports it instead.
|
|
115
|
+
*/
|
|
76
116
|
const projectJson: Record<string, unknown> = {
|
|
77
117
|
name: title || "Imported Site",
|
|
78
|
-
title: title || "Imported Site",
|
|
79
|
-
description: `Imported from ${sourceUrl}`,
|
|
80
118
|
imports: {},
|
|
81
119
|
images: { optimize: false },
|
|
82
120
|
};
|
|
83
121
|
|
|
122
|
+
if (baseWidth !== undefined) {
|
|
123
|
+
projectJson.$media = { [BASE_MEDIA_KEY]: `${Math.round(baseWidth)}px` };
|
|
124
|
+
}
|
|
84
125
|
if (breakpoints && Object.keys(breakpoints).length > 0) {
|
|
85
|
-
|
|
126
|
+
/* Ascending, and the base first. `$media` is a map, so its order IS the order Studio's Contexts
|
|
127
|
+
list and the pane's size switcher offer these in; a crawl merged them in page-visit order,
|
|
128
|
+
which is not an order at all. */
|
|
129
|
+
const sorted = Object.entries(breakpoints).toSorted(
|
|
130
|
+
([a], [b]) => mediaKeyWidth(a) - mediaKeyWidth(b),
|
|
131
|
+
);
|
|
132
|
+
projectJson.$media = { ...(projectJson.$media as object), ...Object.fromEntries(sorted) };
|
|
86
133
|
}
|
|
87
134
|
|
|
88
135
|
if (styleTokens && Object.keys(styleTokens).length > 0) {
|
|
89
|
-
projectJson
|
|
136
|
+
projectJson.style = styleTokens;
|
|
90
137
|
}
|
|
91
138
|
|
|
92
139
|
const files: string[] = [];
|
|
@@ -123,12 +170,41 @@ export async function emitMultiPageProject({
|
|
|
123
170
|
if (fontFaceRules && fontFaceRules.length > 0) {
|
|
124
171
|
let fontCss = fontFaceRules.join("\n\n");
|
|
125
172
|
if (fontRewriteMap) {
|
|
173
|
+
// A @font-face rule carries the url() form its AUTHOR wrote — usually root-relative
|
|
174
|
+
// ("/wp-content/.../lato.woff2"), sometimes protocol-relative. The rewrite map is keyed by the
|
|
175
|
+
// Absolute URL the downloader resolved, so matching on that alone rewrote nothing: every
|
|
176
|
+
// Downloaded font stayed unreferenced and the page fell back to system fonts.
|
|
177
|
+
//
|
|
178
|
+
// Replaced in ONE pass, longest form first. Replacing form by form re-scans text this loop
|
|
179
|
+
// Already rewrote, and a bare pathname ("/a.woff2") then matches inside the local path it
|
|
180
|
+
// Just produced ("/assets/fonts/a.woff2" to "/assets/fonts/assets/fonts/a.woff2").
|
|
181
|
+
const byForm = new Map<string, string>();
|
|
126
182
|
for (const [originalUrl, localPath] of fontRewriteMap) {
|
|
127
|
-
|
|
128
|
-
const relativePath = localPath.startsWith("public/")
|
|
183
|
+
const local = localPath.startsWith("public/")
|
|
129
184
|
? localPath.slice("public/".length)
|
|
130
185
|
: localPath;
|
|
131
|
-
|
|
186
|
+
const href = local.startsWith("/") ? local : `/${local}`;
|
|
187
|
+
const forms = new Set([originalUrl]);
|
|
188
|
+
try {
|
|
189
|
+
const u = new URL(originalUrl);
|
|
190
|
+
forms.add(`//${u.host}${u.pathname}${u.search}`);
|
|
191
|
+
forms.add(`${u.pathname}${u.search}`);
|
|
192
|
+
} catch {
|
|
193
|
+
// Already a relative reference — the literal form is all there is.
|
|
194
|
+
}
|
|
195
|
+
for (const form of forms) {
|
|
196
|
+
if (form) {
|
|
197
|
+
byForm.set(form, href);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const forms = [...byForm.keys()].toSorted((a, b) => b.length - a.length);
|
|
202
|
+
if (forms.length > 0) {
|
|
203
|
+
const pattern = new RegExp(
|
|
204
|
+
forms.map((f) => f.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)).join("|"),
|
|
205
|
+
"g",
|
|
206
|
+
);
|
|
207
|
+
fontCss = fontCss.replaceAll(pattern, (m) => byForm.get(m) ?? m);
|
|
132
208
|
}
|
|
133
209
|
}
|
|
134
210
|
const fontsDir = join(outDir, "public", "assets");
|
|
@@ -151,6 +227,22 @@ export async function emitMultiPageProject({
|
|
|
151
227
|
await Bun.write(projectPath, `${JSON.stringify(projectJson, null, 2)}\n`);
|
|
152
228
|
files.push(projectPath);
|
|
153
229
|
|
|
230
|
+
/*
|
|
231
|
+
* Classes go here, at the last possible moment, and that placement is the whole design.
|
|
232
|
+
*
|
|
233
|
+
* `componentize` groups by structure and `ai-componentize` reads the template to choose a name —
|
|
234
|
+
* `product-card` over `component-div-0` — and the class names are the strongest signal either has.
|
|
235
|
+
* Stripping earlier would cost that; stripping later is impossible. So both passes see the source
|
|
236
|
+
* site's classes, and nothing downstream of this line does.
|
|
237
|
+
*/
|
|
238
|
+
let classesStripped = 0;
|
|
239
|
+
for (const compDoc of componentFiles.values()) {
|
|
240
|
+
classesStripped += stripClasses(compDoc);
|
|
241
|
+
}
|
|
242
|
+
for (const doc of finalPages.values()) {
|
|
243
|
+
classesStripped += stripClasses(doc);
|
|
244
|
+
}
|
|
245
|
+
|
|
154
246
|
// Write components
|
|
155
247
|
for (const [fileName, compDoc] of componentFiles) {
|
|
156
248
|
const compPath = join(outDir, "components", fileName);
|
|
@@ -193,10 +285,11 @@ export async function emitMultiPageProject({
|
|
|
193
285
|
...(((layoutDoc as Record<string, unknown>).$elements as JxElement[]) ?? []),
|
|
194
286
|
];
|
|
195
287
|
}
|
|
288
|
+
classesStripped += stripClasses(layoutDoc);
|
|
196
289
|
await Bun.write(layoutPath, `${JSON.stringify(layoutDoc, null, 2)}\n`);
|
|
197
290
|
files.push(layoutPath);
|
|
198
291
|
|
|
199
|
-
return { files };
|
|
292
|
+
return { classesStripped, files };
|
|
200
293
|
}
|
|
201
294
|
|
|
202
295
|
function extractStateDefaults(node: JxElement | string, out: Record<string, string>) {
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,19 @@ export { convertToJx } from "./to-jx.ts";
|
|
|
4
4
|
export { emitProject, emitMultiPageProject } from "./emit.ts";
|
|
5
5
|
export { captureStyles, captureStylesAtWidth, STYLE_ALLOWLIST } from "./style-capture.ts";
|
|
6
6
|
export { diffStyles, diffAllStyles, computeMediaDelta, kebabToCamel } from "./style-diff.ts";
|
|
7
|
-
export {
|
|
7
|
+
export { extractMedia } from "./media-extract.ts";
|
|
8
|
+
export {
|
|
9
|
+
DEFAULT_BREAKPOINT_POLICY,
|
|
10
|
+
analyzeMediaQueries,
|
|
11
|
+
skippedWidthQueries,
|
|
12
|
+
MAX_BREAKPOINTS,
|
|
13
|
+
MAX_BREAKPOINT_WIDTH,
|
|
14
|
+
MIN_BREAKPOINT_WIDTH,
|
|
15
|
+
breakpointName,
|
|
16
|
+
parseWidthQuery,
|
|
17
|
+
planBreakpoints,
|
|
18
|
+
} from "./breakpoint-plan.ts";
|
|
19
|
+
export { stripClasses } from "./strip-classes.ts";
|
|
8
20
|
export { applyStylesToTree } from "./apply-styles.ts";
|
|
9
21
|
export { collectAssets } from "./asset-collect.ts";
|
|
10
22
|
export { downloadAssets } from "./asset-download.ts";
|
|
@@ -29,10 +41,16 @@ export type {
|
|
|
29
41
|
ImportSiteResult,
|
|
30
42
|
} from "./run.ts";
|
|
31
43
|
export type { ToJxResult } from "./to-jx.ts";
|
|
32
|
-
export type { EmitOptions, MultiEmitOptions } from "./emit.ts";
|
|
44
|
+
export type { EmitOptions, EmitResult, MultiEmitOptions } from "./emit.ts";
|
|
33
45
|
export type { CapturedStyle, StyleCaptureResult } from "./style-capture.ts";
|
|
34
46
|
export type { DiffedStyle } from "./style-diff.ts";
|
|
35
|
-
export type {
|
|
47
|
+
export type { ExtractMediaOptions, MediaExtractionResult } from "./media-extract.ts";
|
|
48
|
+
export type {
|
|
49
|
+
Breakpoint,
|
|
50
|
+
BreakpointPlanResult,
|
|
51
|
+
BreakpointPolicy,
|
|
52
|
+
BreakpointRounding,
|
|
53
|
+
} from "./breakpoint-plan.ts";
|
|
36
54
|
export type {
|
|
37
55
|
DiscoveredAsset,
|
|
38
56
|
AssetCollectionResult,
|
package/src/media-extract.ts
CHANGED
|
@@ -1,110 +1,86 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Extract
|
|
3
|
-
* width to produce per-node
|
|
2
|
+
* Extract `@media` breakpoints from captured CSS and orchestrate style re-capture at each kept
|
|
3
|
+
* breakpoint width to produce per-node `$media` style deltas.
|
|
4
|
+
*
|
|
5
|
+
* The set of breakpoints a project keeps is decided in `breakpoint-plan.ts`, BEFORE anything is
|
|
6
|
+
* re-captured — each kept breakpoint costs a viewport change and a full style walk, so a limited
|
|
7
|
+
* import does less work rather than merely emitting less.
|
|
8
|
+
*
|
|
9
|
+
* A folded-away width is not sampled at all, and does not need to be. The computed style at a kept
|
|
10
|
+
* width is already the site's own cascade resolved AT that width, folded rules included; merging a
|
|
11
|
+
* separately-sampled delta on top of it could only disagree with the browser. The fold map exists
|
|
12
|
+
* so the import can SAY where a width went, not so its styles can be recovered.
|
|
4
13
|
*/
|
|
5
14
|
|
|
6
15
|
import type { Page } from "puppeteer-core";
|
|
7
16
|
import type { CapturedStyle } from "./style-capture.ts";
|
|
8
17
|
import { captureStylesAtWidth } from "./style-capture.ts";
|
|
9
18
|
import { computeMediaDelta } from "./style-diff.ts";
|
|
19
|
+
import { analyzeMediaQueries, planBreakpoints } from "./breakpoint-plan.ts";
|
|
20
|
+
import type { Breakpoint, BreakpointPolicy } from "./breakpoint-plan.ts";
|
|
10
21
|
import type { DiffedStyle } from "./style-diff.ts";
|
|
11
22
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
/* The query analysis is pure and lives with the planner; it is re-exported here because this is the
|
|
24
|
+
module a caller reaches for when it is thinking about media. */
|
|
25
|
+
export { analyzeMediaQueries, skippedWidthQueries } from "./breakpoint-plan.ts";
|
|
26
|
+
export type {
|
|
27
|
+
Breakpoint,
|
|
28
|
+
BreakpointPolicy,
|
|
29
|
+
BreakpointPlanResult,
|
|
30
|
+
BreakpointRounding,
|
|
31
|
+
} from "./breakpoint-plan.ts";
|
|
20
32
|
|
|
21
33
|
export interface MediaExtractionResult {
|
|
22
|
-
/** Breakpoints to add to project.json
|
|
34
|
+
/** Breakpoints to add to `project.json`'s `$media`. */
|
|
23
35
|
breakpoints: Record<string, string>;
|
|
24
|
-
/** Per-breakpoint style deltas keyed by breakpoint name */
|
|
36
|
+
/** Per-breakpoint style deltas keyed by breakpoint name. */
|
|
25
37
|
deltas: Record<string, DiffedStyle[]>;
|
|
26
38
|
}
|
|
27
39
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Generate a Jx breakpoint name from a width, e.g. 768 → "--768" */
|
|
45
|
-
function breakpointName(width: number): string {
|
|
46
|
-
return `--${width}`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Analyze discovered @media queries and produce a set of testable breakpoints. Only picks up simple
|
|
51
|
-
* min-width / max-width queries — complex queries (orientation, hover, prefers-*) are logged and
|
|
52
|
-
* skipped.
|
|
53
|
-
*/
|
|
54
|
-
export function analyzeMediaQueries(queries: string[]): Breakpoint[] {
|
|
55
|
-
const seen = new Set<number>();
|
|
56
|
-
const breakpoints: Breakpoint[] = [];
|
|
57
|
-
|
|
58
|
-
for (const query of queries) {
|
|
59
|
-
const parsed = parseWidthQuery(query);
|
|
60
|
-
if (!parsed) {
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
if (seen.has(parsed.width)) {
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
seen.add(parsed.width);
|
|
67
|
-
|
|
68
|
-
// For max-width: test AT that width (it's the upper bound)
|
|
69
|
-
// For min-width: test AT that width (it's the lower bound — styles kick in here)
|
|
70
|
-
breakpoints.push({
|
|
71
|
-
name: breakpointName(parsed.width),
|
|
72
|
-
query,
|
|
73
|
-
testWidth: parsed.width,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Sort by width ascending
|
|
78
|
-
breakpoints.sort((a, b) => a.testWidth - b.testWidth);
|
|
79
|
-
return breakpoints;
|
|
40
|
+
export interface ExtractMediaOptions {
|
|
41
|
+
/** The base viewport width to restore after extraction. */
|
|
42
|
+
originalWidth?: number;
|
|
43
|
+
/** Which of the discovered breakpoints to keep (default: the three-breakpoint limit). */
|
|
44
|
+
policy?: BreakpointPolicy | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* An already-decided plan, used verbatim instead of planning from this page's own queries.
|
|
47
|
+
*
|
|
48
|
+
* A crawl needs this. Planning per page would let page 2 keep a width page 1 folded away, so the
|
|
49
|
+
* project's `$media` would be the UNION of several plans — the very thing a limit is for — and
|
|
50
|
+
* the breakpoint names a node carries would depend on which page it came from. One plan, decided
|
|
51
|
+
* on the first page that declares any width, is what keeps the names meaning one thing.
|
|
52
|
+
*/
|
|
53
|
+
plan?: readonly Breakpoint[] | undefined;
|
|
80
54
|
}
|
|
81
55
|
|
|
82
56
|
/**
|
|
83
|
-
* Extract
|
|
57
|
+
* Extract `$media` style deltas by re-capturing at each KEPT breakpoint width.
|
|
84
58
|
*
|
|
85
|
-
* @param page - Puppeteer page (must still have the target page loaded)
|
|
86
|
-
* @param baseElements - Style capture from the base viewport
|
|
87
|
-
* @param uaDefaults - UA-default baselines per tagName
|
|
88
|
-
* @param mediaQueries -
|
|
89
|
-
* @param
|
|
59
|
+
* @param {Page} page - Puppeteer page (must still have the target page loaded)
|
|
60
|
+
* @param {CapturedStyle[]} baseElements - Style capture from the base viewport
|
|
61
|
+
* @param {Record<string, Record<string, string>>} uaDefaults - UA-default baselines per tagName
|
|
62
|
+
* @param {readonly string[]} mediaQueries - `@media` queries discovered in the page's stylesheets
|
|
63
|
+
* @param {ExtractMediaOptions} [options]
|
|
64
|
+
* @returns {Promise<MediaExtractionResult>}
|
|
90
65
|
*/
|
|
91
66
|
export async function extractMedia(
|
|
92
67
|
page: Page,
|
|
93
68
|
baseElements: CapturedStyle[],
|
|
94
69
|
uaDefaults: Record<string, Record<string, string>>,
|
|
95
|
-
mediaQueries: string[],
|
|
96
|
-
|
|
70
|
+
mediaQueries: readonly string[],
|
|
71
|
+
options: ExtractMediaOptions = {},
|
|
97
72
|
): Promise<MediaExtractionResult> {
|
|
98
|
-
const
|
|
73
|
+
const { originalWidth = 1440, plan, policy } = options;
|
|
74
|
+
const keep = plan ?? planBreakpoints(analyzeMediaQueries(mediaQueries), policy).keep;
|
|
99
75
|
|
|
100
|
-
if (
|
|
76
|
+
if (keep.length === 0) {
|
|
101
77
|
return { breakpoints: {}, deltas: {} };
|
|
102
78
|
}
|
|
103
79
|
|
|
104
80
|
const projectBreakpoints: Record<string, string> = {};
|
|
105
81
|
const allDeltas: Record<string, DiffedStyle[]> = {};
|
|
106
82
|
|
|
107
|
-
for (const bp of
|
|
83
|
+
for (const bp of keep) {
|
|
108
84
|
const bpElements = await captureStylesAtWidth(page, bp.testWidth);
|
|
109
85
|
const deltas = computeMediaDelta(baseElements, bpElements, uaDefaults);
|
|
110
86
|
|