@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/emit.ts
CHANGED
|
@@ -1,22 +1,55 @@
|
|
|
1
|
-
import { join, dirname } from "node:path";
|
|
2
|
-
import { mkdir } from "node:fs/promises";
|
|
3
1
|
import type { JxElement } from "@jxsuite/schema/types";
|
|
4
2
|
import { componentize } from "./componentize.ts";
|
|
3
|
+
import { stripClasses } from "./strip-classes.ts";
|
|
5
4
|
import type { ComponentizeOptions, ComponentizeResult } from "./componentize.ts";
|
|
5
|
+
import type { ImportIo } from "./io.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `$media` key the base width lives under — a number of CSS pixels, not a query.
|
|
9
|
+
*
|
|
10
|
+
* Studio's canvas reads it to size the default artboard (`utils/canvas-media.ts`), and an import
|
|
11
|
+
* never wrote one, so every imported project's base canvas fell back to 320px while its narrowest
|
|
12
|
+
* real breakpoint was three times that. Mirrors `settings/contexts-section.ts`'s `BASE_KEY`.
|
|
13
|
+
*/
|
|
14
|
+
const BASE_MEDIA_KEY = "--";
|
|
15
|
+
|
|
16
|
+
/** The width a `--<width>` breakpoint key names, for sorting. Non-numeric keys sort first. */
|
|
17
|
+
function mediaKeyWidth(key: string): number {
|
|
18
|
+
const width = Number(key.replace(/^--/, ""));
|
|
19
|
+
return Number.isFinite(width) ? width : -1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface EmitResult {
|
|
23
|
+
/** Project-relative, forward-slashed paths of every file written, in the order they were written. */
|
|
24
|
+
files: string[];
|
|
25
|
+
/** How many source-site class names were removed on the way out. */
|
|
26
|
+
classesStripped: number;
|
|
27
|
+
/**
|
|
28
|
+
* The exact `project.json` text that was written.
|
|
29
|
+
*
|
|
30
|
+
* Returned rather than left to be read back, because a caller with no filesystem cannot read it
|
|
31
|
+
* back at all: Jx Cloud commits the emitted file set to git and needs the configuration to name
|
|
32
|
+
* the project, and re-deriving it here would be a second writer on the same bytes.
|
|
33
|
+
*/
|
|
34
|
+
projectJson: string;
|
|
35
|
+
}
|
|
6
36
|
|
|
7
37
|
export interface EmitOptions {
|
|
8
|
-
|
|
38
|
+
io: ImportIo;
|
|
9
39
|
title: string;
|
|
10
40
|
document: JxElement;
|
|
11
41
|
sourceUrl: string;
|
|
12
42
|
/** $media breakpoints to seed into project.json (Phase 1). */
|
|
13
43
|
breakpoints?: Record<string, string>;
|
|
44
|
+
/** The capture viewport width, written as the `$media` base entry. */
|
|
45
|
+
baseWidth?: number;
|
|
14
46
|
/** Phase 4: componentization options. Pass false to skip. */
|
|
15
47
|
componentizeOptions?: ComponentizeOptions | false;
|
|
16
48
|
}
|
|
17
49
|
|
|
18
50
|
export interface MultiEmitOptions {
|
|
19
|
-
|
|
51
|
+
/** Where the emitted files go. See `io.ts` — a disk is one implementation, not the requirement. */
|
|
52
|
+
io: ImportIo;
|
|
20
53
|
title: string;
|
|
21
54
|
sourceUrl: string;
|
|
22
55
|
/** Map of route path (e.g. "pages/index.json") → Jx document. */
|
|
@@ -25,6 +58,8 @@ export interface MultiEmitOptions {
|
|
|
25
58
|
layout?: JxElement | undefined;
|
|
26
59
|
/** $media breakpoints to seed into project.json. */
|
|
27
60
|
breakpoints?: Record<string, string> | undefined;
|
|
61
|
+
/** The capture viewport width, written as the `$media` base entry. */
|
|
62
|
+
baseWidth?: number | undefined;
|
|
28
63
|
/** Phase 4: componentization options. Pass false to skip. */
|
|
29
64
|
componentizeOptions?: ComponentizeOptions | false | undefined;
|
|
30
65
|
/** Pre-computed componentization result (from AI pass). Overrides componentizeOptions. */
|
|
@@ -38,55 +73,77 @@ export interface MultiEmitOptions {
|
|
|
38
73
|
}
|
|
39
74
|
|
|
40
75
|
export async function emitProject({
|
|
41
|
-
|
|
76
|
+
io,
|
|
42
77
|
title,
|
|
43
78
|
document,
|
|
44
79
|
sourceUrl,
|
|
45
80
|
breakpoints,
|
|
81
|
+
baseWidth,
|
|
46
82
|
componentizeOptions,
|
|
47
|
-
}: EmitOptions): Promise<
|
|
83
|
+
}: EmitOptions): Promise<EmitResult> {
|
|
48
84
|
return emitMultiPageProject({
|
|
49
|
-
|
|
85
|
+
io,
|
|
50
86
|
title,
|
|
51
87
|
sourceUrl,
|
|
52
88
|
pages: new Map([["pages/index.json", document]]),
|
|
53
89
|
breakpoints,
|
|
90
|
+
baseWidth,
|
|
54
91
|
componentizeOptions,
|
|
55
92
|
});
|
|
56
93
|
}
|
|
57
94
|
|
|
58
95
|
export async function emitMultiPageProject({
|
|
59
|
-
|
|
96
|
+
io,
|
|
60
97
|
title,
|
|
61
|
-
sourceUrl,
|
|
62
98
|
pages,
|
|
63
99
|
layout,
|
|
64
100
|
breakpoints,
|
|
101
|
+
baseWidth,
|
|
65
102
|
componentizeOptions,
|
|
66
103
|
precomputedComponents,
|
|
67
104
|
fontFaceRules,
|
|
68
105
|
fontRewriteMap,
|
|
69
106
|
styleTokens,
|
|
70
|
-
}: MultiEmitOptions): Promise<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
107
|
+
}: MultiEmitOptions): Promise<EmitResult> {
|
|
108
|
+
/* Seeded even when nothing lands in them, so a host that opened the project at the seed event
|
|
109
|
+
sees the shape it expects rather than a directory that grows one folder at a time. A sink with
|
|
110
|
+
no directories declines by not implementing it. */
|
|
111
|
+
for (const dir of ["pages", "layouts", "components", "public"]) {
|
|
112
|
+
await io.mkdir?.(dir);
|
|
113
|
+
}
|
|
75
114
|
|
|
115
|
+
/*
|
|
116
|
+
* Only keys the project schema declares (#228). `title` and `description` are page-level keys and
|
|
117
|
+
* `$style` is not a key at all, and all three made every imported project fail `jx validate` —
|
|
118
|
+
* and, because Studio's Contexts editor validates the WHOLE configuration before saving, made an
|
|
119
|
+
* imported project's base width uneditable, reporting `(root): must NOT have unevaluated
|
|
120
|
+
* properties` three times while naming nothing.
|
|
121
|
+
*
|
|
122
|
+
* The source URL has no schema-legal home and does not get one. `$head`'s description meta is the
|
|
123
|
+
* nearest candidate and the wrong one: it is the site's own user-facing description, and
|
|
124
|
+
* "Imported from …" is provenance, not a description of the site. The importer reports it instead.
|
|
125
|
+
*/
|
|
76
126
|
const projectJson: Record<string, unknown> = {
|
|
77
127
|
name: title || "Imported Site",
|
|
78
|
-
title: title || "Imported Site",
|
|
79
|
-
description: `Imported from ${sourceUrl}`,
|
|
80
128
|
imports: {},
|
|
81
129
|
images: { optimize: false },
|
|
82
130
|
};
|
|
83
131
|
|
|
132
|
+
if (baseWidth !== undefined) {
|
|
133
|
+
projectJson.$media = { [BASE_MEDIA_KEY]: `${Math.round(baseWidth)}px` };
|
|
134
|
+
}
|
|
84
135
|
if (breakpoints && Object.keys(breakpoints).length > 0) {
|
|
85
|
-
|
|
136
|
+
/* Ascending, and the base first. `$media` is a map, so its order IS the order Studio's Contexts
|
|
137
|
+
list and the pane's size switcher offer these in; a crawl merged them in page-visit order,
|
|
138
|
+
which is not an order at all. */
|
|
139
|
+
const sorted = Object.entries(breakpoints).toSorted(
|
|
140
|
+
([a], [b]) => mediaKeyWidth(a) - mediaKeyWidth(b),
|
|
141
|
+
);
|
|
142
|
+
projectJson.$media = { ...(projectJson.$media as object), ...Object.fromEntries(sorted) };
|
|
86
143
|
}
|
|
87
144
|
|
|
88
145
|
if (styleTokens && Object.keys(styleTokens).length > 0) {
|
|
89
|
-
projectJson
|
|
146
|
+
projectJson.style = styleTokens;
|
|
90
147
|
}
|
|
91
148
|
|
|
92
149
|
const files: string[] = [];
|
|
@@ -123,18 +180,45 @@ export async function emitMultiPageProject({
|
|
|
123
180
|
if (fontFaceRules && fontFaceRules.length > 0) {
|
|
124
181
|
let fontCss = fontFaceRules.join("\n\n");
|
|
125
182
|
if (fontRewriteMap) {
|
|
183
|
+
// A @font-face rule carries the url() form its AUTHOR wrote — usually root-relative
|
|
184
|
+
// ("/wp-content/.../lato.woff2"), sometimes protocol-relative. The rewrite map is keyed by the
|
|
185
|
+
// Absolute URL the downloader resolved, so matching on that alone rewrote nothing: every
|
|
186
|
+
// Downloaded font stayed unreferenced and the page fell back to system fonts.
|
|
187
|
+
//
|
|
188
|
+
// Replaced in ONE pass, longest form first. Replacing form by form re-scans text this loop
|
|
189
|
+
// Already rewrote, and a bare pathname ("/a.woff2") then matches inside the local path it
|
|
190
|
+
// Just produced ("/assets/fonts/a.woff2" to "/assets/fonts/assets/fonts/a.woff2").
|
|
191
|
+
const byForm = new Map<string, string>();
|
|
126
192
|
for (const [originalUrl, localPath] of fontRewriteMap) {
|
|
127
|
-
|
|
128
|
-
const relativePath = localPath.startsWith("public/")
|
|
193
|
+
const local = localPath.startsWith("public/")
|
|
129
194
|
? localPath.slice("public/".length)
|
|
130
195
|
: localPath;
|
|
131
|
-
|
|
196
|
+
const href = local.startsWith("/") ? local : `/${local}`;
|
|
197
|
+
const forms = new Set([originalUrl]);
|
|
198
|
+
try {
|
|
199
|
+
const u = new URL(originalUrl);
|
|
200
|
+
forms.add(`//${u.host}${u.pathname}${u.search}`);
|
|
201
|
+
forms.add(`${u.pathname}${u.search}`);
|
|
202
|
+
} catch {
|
|
203
|
+
// Already a relative reference — the literal form is all there is.
|
|
204
|
+
}
|
|
205
|
+
for (const form of forms) {
|
|
206
|
+
if (form) {
|
|
207
|
+
byForm.set(form, href);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const forms = [...byForm.keys()].toSorted((a, b) => b.length - a.length);
|
|
212
|
+
if (forms.length > 0) {
|
|
213
|
+
const pattern = new RegExp(
|
|
214
|
+
forms.map((f) => f.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)).join("|"),
|
|
215
|
+
"g",
|
|
216
|
+
);
|
|
217
|
+
fontCss = fontCss.replaceAll(pattern, (m) => byForm.get(m) ?? m);
|
|
132
218
|
}
|
|
133
219
|
}
|
|
134
|
-
const
|
|
135
|
-
await
|
|
136
|
-
const fontsCssPath = join(fontsDir, "fonts.css");
|
|
137
|
-
await Bun.write(fontsCssPath, fontCss);
|
|
220
|
+
const fontsCssPath = "public/assets/fonts.css";
|
|
221
|
+
await io.write(fontsCssPath, fontCss);
|
|
138
222
|
files.push(fontsCssPath);
|
|
139
223
|
|
|
140
224
|
// Add fonts.css to project head
|
|
@@ -147,14 +231,30 @@ export async function emitMultiPageProject({
|
|
|
147
231
|
});
|
|
148
232
|
}
|
|
149
233
|
|
|
150
|
-
const
|
|
151
|
-
await
|
|
152
|
-
files.push(
|
|
234
|
+
const projectText = `${JSON.stringify(projectJson, null, 2)}\n`;
|
|
235
|
+
await io.write("project.json", projectText);
|
|
236
|
+
files.push("project.json");
|
|
237
|
+
|
|
238
|
+
/*
|
|
239
|
+
* Classes go here, at the last possible moment, and that placement is the whole design.
|
|
240
|
+
*
|
|
241
|
+
* `componentize` groups by structure and `ai-componentize` reads the template to choose a name —
|
|
242
|
+
* `product-card` over `component-div-0` — and the class names are the strongest signal either has.
|
|
243
|
+
* Stripping earlier would cost that; stripping later is impossible. So both passes see the source
|
|
244
|
+
* site's classes, and nothing downstream of this line does.
|
|
245
|
+
*/
|
|
246
|
+
let classesStripped = 0;
|
|
247
|
+
for (const compDoc of componentFiles.values()) {
|
|
248
|
+
classesStripped += stripClasses(compDoc);
|
|
249
|
+
}
|
|
250
|
+
for (const doc of finalPages.values()) {
|
|
251
|
+
classesStripped += stripClasses(doc);
|
|
252
|
+
}
|
|
153
253
|
|
|
154
254
|
// Write components
|
|
155
255
|
for (const [fileName, compDoc] of componentFiles) {
|
|
156
|
-
const compPath =
|
|
157
|
-
await
|
|
256
|
+
const compPath = `components/${fileName}`;
|
|
257
|
+
await io.write(compPath, `${JSON.stringify(compDoc, null, 2)}\n`);
|
|
158
258
|
files.push(compPath);
|
|
159
259
|
}
|
|
160
260
|
|
|
@@ -175,14 +275,12 @@ export async function emitMultiPageProject({
|
|
|
175
275
|
...((pageDoc.$elements as JxElement[]) ?? []),
|
|
176
276
|
] as JxElement[];
|
|
177
277
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
await Bun.write(pagePath, `${JSON.stringify(pageDoc, null, 2)}\n`);
|
|
181
|
-
files.push(pagePath);
|
|
278
|
+
await io.write(route, `${JSON.stringify(pageDoc, null, 2)}\n`);
|
|
279
|
+
files.push(route);
|
|
182
280
|
}
|
|
183
281
|
|
|
184
282
|
// Write layout
|
|
185
|
-
const layoutPath =
|
|
283
|
+
const layoutPath = "layouts/base.json";
|
|
186
284
|
const layoutDoc: JxElement = layout ?? {
|
|
187
285
|
tagName: "div",
|
|
188
286
|
children: [{ tagName: "slot" }],
|
|
@@ -193,10 +291,11 @@ export async function emitMultiPageProject({
|
|
|
193
291
|
...(((layoutDoc as Record<string, unknown>).$elements as JxElement[]) ?? []),
|
|
194
292
|
];
|
|
195
293
|
}
|
|
196
|
-
|
|
294
|
+
classesStripped += stripClasses(layoutDoc);
|
|
295
|
+
await io.write(layoutPath, `${JSON.stringify(layoutDoc, null, 2)}\n`);
|
|
197
296
|
files.push(layoutPath);
|
|
198
297
|
|
|
199
|
-
return { files };
|
|
298
|
+
return { classesStripped, files, projectJson: projectText };
|
|
200
299
|
}
|
|
201
300
|
|
|
202
301
|
function extractStateDefaults(node: JxElement | string, out: Record<string, string>) {
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
export { capturePage
|
|
1
|
+
export { capturePage } from "./capture.ts";
|
|
2
|
+
export { launchBrowser, closeBrowser } from "./browser-local.ts";
|
|
3
|
+
export { createLocalIo } from "./io.ts";
|
|
4
|
+
export { runImportPipeline } from "./pipeline.ts";
|
|
2
5
|
export { importSite } from "./run.ts";
|
|
3
6
|
export { convertToJx } from "./to-jx.ts";
|
|
4
7
|
export { emitProject, emitMultiPageProject } from "./emit.ts";
|
|
5
8
|
export { captureStyles, captureStylesAtWidth, STYLE_ALLOWLIST } from "./style-capture.ts";
|
|
6
9
|
export { diffStyles, diffAllStyles, computeMediaDelta, kebabToCamel } from "./style-diff.ts";
|
|
7
|
-
export {
|
|
10
|
+
export { extractMedia } from "./media-extract.ts";
|
|
11
|
+
export {
|
|
12
|
+
DEFAULT_BREAKPOINT_POLICY,
|
|
13
|
+
analyzeMediaQueries,
|
|
14
|
+
skippedWidthQueries,
|
|
15
|
+
MAX_BREAKPOINTS,
|
|
16
|
+
MAX_BREAKPOINT_WIDTH,
|
|
17
|
+
MIN_BREAKPOINT_WIDTH,
|
|
18
|
+
breakpointName,
|
|
19
|
+
parseWidthQuery,
|
|
20
|
+
planBreakpoints,
|
|
21
|
+
} from "./breakpoint-plan.ts";
|
|
22
|
+
export { stripClasses } from "./strip-classes.ts";
|
|
8
23
|
export { applyStylesToTree } from "./apply-styles.ts";
|
|
9
24
|
export { collectAssets } from "./asset-collect.ts";
|
|
10
25
|
export { downloadAssets } from "./asset-download.ts";
|
|
@@ -21,18 +36,27 @@ export {
|
|
|
21
36
|
serveDirectory,
|
|
22
37
|
routeToUrlPath,
|
|
23
38
|
} from "./verify.ts";
|
|
24
|
-
export type { CaptureResult,
|
|
39
|
+
export type { CaptureResult, ImportBrowser, ImportPage } from "./capture.ts";
|
|
40
|
+
export type { LaunchOptions } from "./browser-local.ts";
|
|
41
|
+
export type { ImportIo } from "./io.ts";
|
|
25
42
|
export type {
|
|
26
|
-
|
|
43
|
+
ImportPipelineOptions,
|
|
44
|
+
ImportPipelineResult,
|
|
27
45
|
ImportProgressEvent,
|
|
28
46
|
ImportPhase,
|
|
29
|
-
|
|
30
|
-
} from "./run.ts";
|
|
47
|
+
} from "./pipeline.ts";
|
|
48
|
+
export type { ImportSiteOptions, ImportSiteResult } from "./run.ts";
|
|
31
49
|
export type { ToJxResult } from "./to-jx.ts";
|
|
32
|
-
export type { EmitOptions, MultiEmitOptions } from "./emit.ts";
|
|
50
|
+
export type { EmitOptions, EmitResult, MultiEmitOptions } from "./emit.ts";
|
|
33
51
|
export type { CapturedStyle, StyleCaptureResult } from "./style-capture.ts";
|
|
34
52
|
export type { DiffedStyle } from "./style-diff.ts";
|
|
35
|
-
export type {
|
|
53
|
+
export type { ExtractMediaOptions, MediaExtractionResult } from "./media-extract.ts";
|
|
54
|
+
export type {
|
|
55
|
+
Breakpoint,
|
|
56
|
+
BreakpointPlanResult,
|
|
57
|
+
BreakpointPolicy,
|
|
58
|
+
BreakpointRounding,
|
|
59
|
+
} from "./breakpoint-plan.ts";
|
|
36
60
|
export type {
|
|
37
61
|
DiscoveredAsset,
|
|
38
62
|
AssetCollectionResult,
|
package/src/io.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sink every file an import produces goes through.
|
|
3
|
+
*
|
|
4
|
+
* The pipeline used to call `Bun.write(join(outDir, …))` at six sites, which made "run an import"
|
|
5
|
+
* and "have a writable filesystem" the same requirement. Jx Cloud is a Cloudflare Worker: it has
|
|
6
|
+
* neither `Bun` nor a disk, and commits the emitted project straight to git. So the destination is
|
|
7
|
+
* a parameter now — `createLocalIo` is the disk implementation and the only one that imports
|
|
8
|
+
* `node:fs`, and a Worker supplies its own without the pipeline knowing the difference.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdir } from "node:fs/promises";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export interface ImportIo {
|
|
15
|
+
/**
|
|
16
|
+
* Write one file.
|
|
17
|
+
*
|
|
18
|
+
* `relPath` is PROJECT-RELATIVE and forward-slashed — `"pages/blog/post.json"`, never an absolute
|
|
19
|
+
* path and never a backslash. A sink that has no filesystem uses it as a key verbatim; a sink
|
|
20
|
+
* that does joins it onto its own root.
|
|
21
|
+
*/
|
|
22
|
+
write: (relPath: string, data: string | Uint8Array) => Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Create an empty directory, if directories are a thing this sink has.
|
|
25
|
+
*
|
|
26
|
+
* Optional because they usually are not: a git tree, an object store and a zip all record only
|
|
27
|
+
* the files, and an empty directory is unrepresentable in every one of them. The local import
|
|
28
|
+
* still seeds `pages/`, `layouts/`, `components/` and `public/` up front so a host can open the
|
|
29
|
+
* project the moment the run starts, which is what this exists for.
|
|
30
|
+
*/
|
|
31
|
+
mkdir?: (relPath: string) => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Split a project-relative path into segments, so a sink's own separator is the one used. */
|
|
35
|
+
function toSegments(relPath: string): string[] {
|
|
36
|
+
return relPath.split("/").filter((segment) => segment !== "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The disk sink: `outDir` plus the relative path, with parent directories created on demand. */
|
|
40
|
+
export function createLocalIo(outDir: string): ImportIo {
|
|
41
|
+
const resolve = (relPath: string): string => join(outDir, ...toSegments(relPath));
|
|
42
|
+
return {
|
|
43
|
+
async write(relPath, data) {
|
|
44
|
+
const path = resolve(relPath);
|
|
45
|
+
await mkdir(dirname(path), { recursive: true });
|
|
46
|
+
await Bun.write(path, data);
|
|
47
|
+
},
|
|
48
|
+
async mkdir(relPath) {
|
|
49
|
+
await mkdir(resolve(relPath), { recursive: true });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
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
|
-
import type {
|
|
15
|
+
import type { ImportPage } from "./capture.ts";
|
|
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 -
|
|
86
|
-
* @param baseElements - Style capture from the base viewport
|
|
87
|
-
* @param uaDefaults - UA-default baselines per tagName
|
|
88
|
-
* @param mediaQueries -
|
|
89
|
-
* @param
|
|
59
|
+
* @param {ImportPage} page - Browser 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
|
-
page:
|
|
67
|
+
page: ImportPage,
|
|
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
|
|