@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/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
- outDir: string;
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
- outDir: string;
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
- outDir,
76
+ io,
42
77
  title,
43
78
  document,
44
79
  sourceUrl,
45
80
  breakpoints,
81
+ baseWidth,
46
82
  componentizeOptions,
47
- }: EmitOptions): Promise<{ files: string[] }> {
83
+ }: EmitOptions): Promise<EmitResult> {
48
84
  return emitMultiPageProject({
49
- outDir,
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
- outDir,
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<{ files: string[] }> {
71
- await mkdir(join(outDir, "pages"), { recursive: true });
72
- await mkdir(join(outDir, "layouts"), { recursive: true });
73
- await mkdir(join(outDir, "components"), { recursive: true });
74
- await mkdir(join(outDir, "public"), { recursive: true });
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
- projectJson.$media = breakpoints;
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.$style = styleTokens;
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
- // Rewrite absolute URLs in font-face rules to relative local paths
128
- const relativePath = localPath.startsWith("public/")
193
+ const local = localPath.startsWith("public/")
129
194
  ? localPath.slice("public/".length)
130
195
  : localPath;
131
- fontCss = fontCss.replaceAll(originalUrl, `/${relativePath}`);
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 fontsDir = join(outDir, "public", "assets");
135
- await mkdir(fontsDir, { recursive: true });
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 projectPath = join(outDir, "project.json");
151
- await Bun.write(projectPath, `${JSON.stringify(projectJson, null, 2)}\n`);
152
- files.push(projectPath);
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 = join(outDir, "components", fileName);
157
- await Bun.write(compPath, `${JSON.stringify(compDoc, null, 2)}\n`);
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
- const pagePath = join(outDir, route);
179
- await mkdir(dirname(pagePath), { recursive: true });
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 = join(outDir, "layouts", "base.json");
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
- await Bun.write(layoutPath, `${JSON.stringify(layoutDoc, null, 2)}\n`);
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, launchBrowser, closeBrowser } from "./capture.ts";
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 { analyzeMediaQueries, extractMedia } from "./media-extract.ts";
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, LaunchOptions } from "./capture.ts";
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
- ImportSiteOptions,
43
+ ImportPipelineOptions,
44
+ ImportPipelineResult,
27
45
  ImportProgressEvent,
28
46
  ImportPhase,
29
- ImportSiteResult,
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 { Breakpoint, MediaExtractionResult } from "./media-extract.ts";
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
+ }
@@ -1,110 +1,86 @@
1
1
  /**
2
- * Extract @media breakpoints from captured CSS and orchestrate style re-capture at each breakpoint
3
- * width to produce per-node $media style deltas.
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 { Page } from "puppeteer-core";
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
- export interface Breakpoint {
13
- /** Jx breakpoint name, e.g. "--md" */
14
- name: string;
15
- /** CSS media query string, e.g. "(max-width: 768px)" */
16
- query: string;
17
- /** The width in px to test this breakpoint at */
18
- testWidth: number;
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.$media */
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
- * Parse a media query string and extract a pixel width if it's a simple min-width or max-width
30
- * query. Returns null for complex/non-size queries.
31
- */
32
- function parseWidthQuery(query: string): { type: "min" | "max"; width: number } | null {
33
- const minMatch = query.match(/^\s*\(\s*min-width\s*:\s*([\d.]+)px\s*\)\s*$/);
34
- if (minMatch) {
35
- return { type: "min", width: Number(minMatch[1]) };
36
- }
37
- const maxMatch = query.match(/^\s*\(\s*max-width\s*:\s*([\d.]+)px\s*\)\s*$/);
38
- if (maxMatch) {
39
- return { type: "max", width: Number(maxMatch[1]) };
40
- }
41
- return null;
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 $media style deltas by re-capturing at each breakpoint width.
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 (1440px)
87
- * @param uaDefaults - UA-default baselines per tagName
88
- * @param mediaQueries - @media queries discovered in the page's stylesheets
89
- * @param originalWidth - The base viewport width to restore after extraction
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: Page,
67
+ page: ImportPage,
93
68
  baseElements: CapturedStyle[],
94
69
  uaDefaults: Record<string, Record<string, string>>,
95
- mediaQueries: string[],
96
- originalWidth = 1440,
70
+ mediaQueries: readonly string[],
71
+ options: ExtractMediaOptions = {},
97
72
  ): Promise<MediaExtractionResult> {
98
- const breakpoints = analyzeMediaQueries(mediaQueries);
73
+ const { originalWidth = 1440, plan, policy } = options;
74
+ const keep = plan ?? planBreakpoints(analyzeMediaQueries(mediaQueries), policy).keep;
99
75
 
100
- if (breakpoints.length === 0) {
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 breakpoints) {
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