@jxsuite/import 0.40.0 → 0.40.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/emit.ts CHANGED
@@ -1,9 +1,8 @@
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";
5
3
  import { stripClasses } from "./strip-classes.ts";
6
4
  import type { ComponentizeOptions, ComponentizeResult } from "./componentize.ts";
5
+ import type { ImportIo } from "./io.ts";
7
6
 
8
7
  /**
9
8
  * The `$media` key the base width lives under — a number of CSS pixels, not a query.
@@ -21,14 +20,22 @@ function mediaKeyWidth(key: string): number {
21
20
  }
22
21
 
23
22
  export interface EmitResult {
24
- /** Absolute paths of every file written. */
23
+ /** Project-relative, forward-slashed paths of every file written, in the order they were written. */
25
24
  files: string[];
26
25
  /** How many source-site class names were removed on the way out. */
27
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;
28
35
  }
29
36
 
30
37
  export interface EmitOptions {
31
- outDir: string;
38
+ io: ImportIo;
32
39
  title: string;
33
40
  document: JxElement;
34
41
  sourceUrl: string;
@@ -41,7 +48,8 @@ export interface EmitOptions {
41
48
  }
42
49
 
43
50
  export interface MultiEmitOptions {
44
- outDir: string;
51
+ /** Where the emitted files go. See `io.ts` — a disk is one implementation, not the requirement. */
52
+ io: ImportIo;
45
53
  title: string;
46
54
  sourceUrl: string;
47
55
  /** Map of route path (e.g. "pages/index.json") → Jx document. */
@@ -65,7 +73,7 @@ export interface MultiEmitOptions {
65
73
  }
66
74
 
67
75
  export async function emitProject({
68
- outDir,
76
+ io,
69
77
  title,
70
78
  document,
71
79
  sourceUrl,
@@ -74,7 +82,7 @@ export async function emitProject({
74
82
  componentizeOptions,
75
83
  }: EmitOptions): Promise<EmitResult> {
76
84
  return emitMultiPageProject({
77
- outDir,
85
+ io,
78
86
  title,
79
87
  sourceUrl,
80
88
  pages: new Map([["pages/index.json", document]]),
@@ -85,7 +93,7 @@ export async function emitProject({
85
93
  }
86
94
 
87
95
  export async function emitMultiPageProject({
88
- outDir,
96
+ io,
89
97
  title,
90
98
  pages,
91
99
  layout,
@@ -97,10 +105,12 @@ export async function emitMultiPageProject({
97
105
  fontRewriteMap,
98
106
  styleTokens,
99
107
  }: MultiEmitOptions): Promise<EmitResult> {
100
- await mkdir(join(outDir, "pages"), { recursive: true });
101
- await mkdir(join(outDir, "layouts"), { recursive: true });
102
- await mkdir(join(outDir, "components"), { recursive: true });
103
- await mkdir(join(outDir, "public"), { recursive: true });
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
+ }
104
114
 
105
115
  /*
106
116
  * Only keys the project schema declares (#228). `title` and `description` are page-level keys and
@@ -156,10 +166,16 @@ export async function emitMultiPageProject({
156
166
  tagName: comp.tagName,
157
167
  };
158
168
 
159
- const state: Record<string, string> = {};
160
- extractStateDefaults(comp.template, state);
169
+ /* Derived placeholders fill in for props the template interpolates but nobody declared;
170
+ a value the template DECLARED wins over them. This was an assignment over the spread, so
171
+ a component that carried its own `state` — the `active` index a tab group switches on —
172
+ lost it on the way out, and every interpolation referencing it went dead. */
173
+ const derived: Record<string, string> = {};
174
+ extractStateDefaults(comp.template, derived);
175
+ const declared = (comp.template.state ?? {}) as Record<string, unknown>;
176
+ const state = { ...derived, ...declared };
161
177
  if (Object.keys(state).length > 0) {
162
- compDoc.state = state;
178
+ compDoc.state = state as Record<string, string>;
163
179
  }
164
180
 
165
181
  componentFiles.set(fileName, compDoc);
@@ -207,10 +223,8 @@ export async function emitMultiPageProject({
207
223
  fontCss = fontCss.replaceAll(pattern, (m) => byForm.get(m) ?? m);
208
224
  }
209
225
  }
210
- const fontsDir = join(outDir, "public", "assets");
211
- await mkdir(fontsDir, { recursive: true });
212
- const fontsCssPath = join(fontsDir, "fonts.css");
213
- await Bun.write(fontsCssPath, fontCss);
226
+ const fontsCssPath = "public/assets/fonts.css";
227
+ await io.write(fontsCssPath, fontCss);
214
228
  files.push(fontsCssPath);
215
229
 
216
230
  // Add fonts.css to project head
@@ -223,9 +237,9 @@ export async function emitMultiPageProject({
223
237
  });
224
238
  }
225
239
 
226
- const projectPath = join(outDir, "project.json");
227
- await Bun.write(projectPath, `${JSON.stringify(projectJson, null, 2)}\n`);
228
- files.push(projectPath);
240
+ const projectText = `${JSON.stringify(projectJson, null, 2)}\n`;
241
+ await io.write("project.json", projectText);
242
+ files.push("project.json");
229
243
 
230
244
  /*
231
245
  * Classes go here, at the last possible moment, and that placement is the whole design.
@@ -245,8 +259,8 @@ export async function emitMultiPageProject({
245
259
 
246
260
  // Write components
247
261
  for (const [fileName, compDoc] of componentFiles) {
248
- const compPath = join(outDir, "components", fileName);
249
- await Bun.write(compPath, `${JSON.stringify(compDoc, null, 2)}\n`);
262
+ const compPath = `components/${fileName}`;
263
+ await io.write(compPath, `${JSON.stringify(compDoc, null, 2)}\n`);
250
264
  files.push(compPath);
251
265
  }
252
266
 
@@ -267,14 +281,12 @@ export async function emitMultiPageProject({
267
281
  ...((pageDoc.$elements as JxElement[]) ?? []),
268
282
  ] as JxElement[];
269
283
  }
270
- const pagePath = join(outDir, route);
271
- await mkdir(dirname(pagePath), { recursive: true });
272
- await Bun.write(pagePath, `${JSON.stringify(pageDoc, null, 2)}\n`);
273
- files.push(pagePath);
284
+ await io.write(route, `${JSON.stringify(pageDoc, null, 2)}\n`);
285
+ files.push(route);
274
286
  }
275
287
 
276
288
  // Write layout
277
- const layoutPath = join(outDir, "layouts", "base.json");
289
+ const layoutPath = "layouts/base.json";
278
290
  const layoutDoc: JxElement = layout ?? {
279
291
  tagName: "div",
280
292
  children: [{ tagName: "slot" }],
@@ -286,22 +298,35 @@ export async function emitMultiPageProject({
286
298
  ];
287
299
  }
288
300
  classesStripped += stripClasses(layoutDoc);
289
- await Bun.write(layoutPath, `${JSON.stringify(layoutDoc, null, 2)}\n`);
301
+ await io.write(layoutPath, `${JSON.stringify(layoutDoc, null, 2)}\n`);
290
302
  files.push(layoutPath);
291
303
 
292
- return { classesStripped, files };
304
+ return { classesStripped, files, projectJson: projectText };
305
+ }
306
+
307
+ /**
308
+ * The state key a whole-string `${state.x}` names, or null.
309
+ *
310
+ * The identifier check is the point. The capture used to be `([^}]+)`, so `${state.active !== 0}` —
311
+ * an expression, not a reference — minted a state entry literally called `active !== 0` beside the
312
+ * real one. Only a bare identifier is a key; anything else is an expression over state and has no
313
+ * default of its own to declare.
314
+ */
315
+ function stateKey(text: string): string | undefined {
316
+ const key = text.match(/^\$\{state\.([^}]+)\}$/)?.[1];
317
+ return key !== undefined && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : undefined;
293
318
  }
294
319
 
295
320
  function extractStateDefaults(node: JxElement | string, out: Record<string, string>) {
296
321
  if (typeof node === "string") {
297
- const key = node.match(/^\$\{state\.([^}]+)\}$/)?.[1];
322
+ const key = stateKey(node);
298
323
  if (key && !(key in out)) {
299
324
  out[key] = "";
300
325
  }
301
326
  return;
302
327
  }
303
328
  if (typeof node.textContent === "string") {
304
- const key = node.textContent.match(/^\$\{state\.([^}]+)\}$/)?.[1];
329
+ const key = stateKey(node.textContent);
305
330
  if (key && !(key in out)) {
306
331
  out[key] = "";
307
332
  }
@@ -309,7 +334,7 @@ function extractStateDefaults(node: JxElement | string, out: Record<string, stri
309
334
  if (node.attributes) {
310
335
  for (const v of Object.values(node.attributes)) {
311
336
  if (typeof v === "string") {
312
- const key = v.match(/^\$\{state\.([^}]+)\}$/)?.[1];
337
+ const key = stateKey(v);
313
338
  if (key && !(key in out)) {
314
339
  out[key] = "";
315
340
  }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Group an image's responsive derivatives back into one family, and pick the member to keep.
3
+ *
4
+ * A CMS publishes one photograph as a dozen files. WordPress alone emits `-300x200`, `-768x512`,
5
+ * `-1024x683`, `-1536x1025`, `-2048x1366` and `-scaled` beside the upload, and every one of them
6
+ * appears in the `srcset` of every `<img>` that uses it. The importer enqueued each candidate as an
7
+ * independent asset, so a clone of a 451-image site downloaded 2,446 files and 427 MB, and the
8
+ * emitted markup carried the whole derivative ladder forward.
9
+ *
10
+ * That is the wrong shape twice over. It is large, and it hands the compiler a `srcset` it did not
11
+ * build and cannot reason about — while `image-transform.ts` caps its own ladder at the source
12
+ * file's width, so an `<img src>` pointing at a `-300x200` derivative is a permanent quality
13
+ * ceiling rather than merely a wasted byte.
14
+ *
15
+ * So the importer keeps ONE member per family and lets the compiler regenerate sizes from it.
16
+ *
17
+ * Two rules make this safe on a real site:
18
+ *
19
+ * 1. **The key comes from the source URL, never from the written filename.** `sanitizeFilename`
20
+ * truncates a long name to 96 characters, which cuts the `-WxH` marker off the end — five
21
+ * resolutions of one image would land as five unrelated families.
22
+ * 2. **The winner is always a URL the page actually referenced.** 120 of the 451 families on the
23
+ * reference corpus have no undecorated member at all, because WordPress serves only
24
+ * `-scaled.jpg` once an upload passes its threshold. Synthesising the bare name and fetching it
25
+ * would 404 on those, and a failed download leaves the remote URL in the markup — the clone then
26
+ * serves images from the host it cloned. Taking the largest real candidate cannot fail that
27
+ * way.
28
+ */
29
+
30
+ import type { DiscoveredAsset } from "./asset-collect.ts";
31
+
32
+ /** Sources whose URLs are responsive variants of one image. A CSS background is not one. */
33
+ const FAMILY_SOURCES = new Set(["img-src", "img-srcset", "source-srcset", "picture-source"]);
34
+
35
+ /** `name-1024x683.jpg` → the `1024x683` marker, and where it starts. */
36
+ const DIMENSION_SUFFIX = /-(\d{1,5})x(\d{1,5})$/;
37
+
38
+ /** WordPress's marker for "the original was too big, this is the working copy". */
39
+ const SCALED_SUFFIX = /-scaled$/;
40
+
41
+ interface ParsedName {
42
+ /** The stem with any `-WxH` and `-scaled` markers removed. */
43
+ stem: string;
44
+ /** Pixel area of the `-WxH` marker, or null when there is none. */
45
+ area: number | null;
46
+ /** Whether the name carried `-scaled`. */
47
+ scaled: boolean;
48
+ }
49
+
50
+ function splitExtension(pathname: string): { base: string; ext: string } {
51
+ const slash = pathname.lastIndexOf("/");
52
+ const dot = pathname.lastIndexOf(".");
53
+ return dot > slash
54
+ ? { base: pathname.slice(0, dot), ext: pathname.slice(dot) }
55
+ : { base: pathname, ext: "" };
56
+ }
57
+
58
+ /**
59
+ * Strip the derivative markers from a pathname's final segment.
60
+ *
61
+ * Order matters: WordPress writes `photo-scaled-768x512.jpg`, so the dimensions come off first and
62
+ * `-scaled` second. Doing it the other way leaves `-768x512` welded to the stem and splits the
63
+ * family.
64
+ */
65
+ function parseName(pathname: string): ParsedName {
66
+ const { base } = splitExtension(pathname);
67
+ let stem = base;
68
+ let area: number | null = null;
69
+
70
+ const dimensions = stem.match(DIMENSION_SUFFIX);
71
+ if (dimensions) {
72
+ area = Number(dimensions[1]) * Number(dimensions[2]);
73
+ stem = stem.slice(0, dimensions.index);
74
+ }
75
+
76
+ const scaled = SCALED_SUFFIX.test(stem);
77
+ if (scaled) {
78
+ stem = stem.replace(SCALED_SUFFIX, "");
79
+ }
80
+
81
+ return { area, scaled, stem };
82
+ }
83
+
84
+ /**
85
+ * The identity two derivatives of one image share.
86
+ *
87
+ * Origin and directory are part of it — two sites can both serve `hero.jpg`, and WordPress's
88
+ * `/uploads/<year>/<month>/` layout means the same basename recurs legitimately. The query string
89
+ * is not: a cache-buster is not a different image. A URL that will not parse is its own family,
90
+ * which is the conservative answer (it is kept, not merged with something it may not match).
91
+ */
92
+ export function familyKey(url: string): string {
93
+ let parsed: URL;
94
+ try {
95
+ parsed = new URL(url);
96
+ } catch {
97
+ return url;
98
+ }
99
+ const { ext } = splitExtension(parsed.pathname);
100
+ const { stem } = parseName(parsed.pathname);
101
+ return `${parsed.origin}${stem}${ext.toLowerCase()}`;
102
+ }
103
+
104
+ /**
105
+ * Pick the member of a family to keep.
106
+ *
107
+ * Ranked: an undecorated name beats everything, because it is the upload itself; otherwise the
108
+ * largest `-WxH` wins, and a `-scaled` copy is preferred to a small explicit crop only when nothing
109
+ * else is left. Ties break on the URL string so a crawl is reproducible.
110
+ *
111
+ * @param {readonly string[]} candidates - Absolute URLs, all of one family, non-empty
112
+ * @returns {string}
113
+ */
114
+ export function chooseOriginal(candidates: readonly string[]): string {
115
+ const ranked = candidates.map((url) => {
116
+ let pathname: string;
117
+ try {
118
+ ({ pathname } = new URL(url));
119
+ } catch {
120
+ pathname = url;
121
+ }
122
+ const { area, scaled } = parseName(pathname);
123
+ // Undecorated first, then scaled-only, then explicit crops largest-first.
124
+ const tier = area === null ? (scaled ? 1 : 0) : 2;
125
+ return { area: area ?? 0, tier, url };
126
+ });
127
+
128
+ ranked.sort((a, b) => {
129
+ if (a.tier !== b.tier) {
130
+ return a.tier - b.tier;
131
+ }
132
+ if (a.area !== b.area) {
133
+ return b.area - a.area;
134
+ }
135
+ return a.url < b.url ? -1 : a.url > b.url ? 1 : 0;
136
+ });
137
+
138
+ return ranked[0]!.url;
139
+ }
140
+
141
+ export interface ImageFamilyPlan {
142
+ /** The assets to actually download — one per image family, plus every non-family asset. */
143
+ keep: DiscoveredAsset[];
144
+ /**
145
+ * Dropped URL → the URL kept in its place.
146
+ *
147
+ * The caller replays this over the download's `rewriteMap` so a reference to any derivative still
148
+ * resolves, to the one file that was written.
149
+ */
150
+ alias: Map<string, string>;
151
+ }
152
+
153
+ /**
154
+ * Reduce a discovered asset list to one member per image family.
155
+ *
156
+ * Only `<img>`/`<source>` URLs take part. A CSS background, a font and a favicon are each kept as
157
+ * found: a background is authored at one size, and merging two of them on a coincidental name match
158
+ * would silently swap an image the stylesheet asked for.
159
+ *
160
+ * @param {readonly DiscoveredAsset[]} assets
161
+ * @returns {ImageFamilyPlan}
162
+ */
163
+ export function planImageFamilies(assets: readonly DiscoveredAsset[]): ImageFamilyPlan {
164
+ const families = new Map<string, string[]>();
165
+ for (const asset of assets) {
166
+ if (!FAMILY_SOURCES.has(asset.source)) {
167
+ continue;
168
+ }
169
+ const key = familyKey(asset.url);
170
+ families.set(key, [...(families.get(key) ?? []), asset.url]);
171
+ }
172
+
173
+ const winners = new Map<string, string>();
174
+ for (const [key, urls] of families) {
175
+ winners.set(key, chooseOriginal(urls));
176
+ }
177
+
178
+ const alias = new Map<string, string>();
179
+ const keep: DiscoveredAsset[] = [];
180
+ const taken = new Set<string>();
181
+
182
+ for (const asset of assets) {
183
+ if (!FAMILY_SOURCES.has(asset.source)) {
184
+ keep.push(asset);
185
+ continue;
186
+ }
187
+ const winner = winners.get(familyKey(asset.url))!;
188
+ if (asset.url === winner) {
189
+ if (!taken.has(winner)) {
190
+ taken.add(winner);
191
+ keep.push(asset);
192
+ }
193
+ continue;
194
+ }
195
+ alias.set(asset.url, winner);
196
+ }
197
+
198
+ return { alias, keep };
199
+ }
200
+
201
+ /**
202
+ * Point every dropped derivative at the file its family actually wrote.
203
+ *
204
+ * The download only ever saw one member per family, so the map it returns has one entry per family.
205
+ * Markup still refers to whichever rung it was authored against, and an unresolved reference is not
206
+ * merely a broken image: `asset-rewrite.ts` leaves a URL it cannot resolve untouched, so the built
207
+ * clone would go on serving that image from the site it was cloned from.
208
+ *
209
+ * @param {Map<string, string>} rewriteMap - Download result, mutated in place
210
+ * @param {ReadonlyMap<string, string>} alias - Dropped URL to kept URL, from
211
+ * {@link planImageFamilies}
212
+ * @returns {number} How many references were reconnected
213
+ */
214
+ export function applyFamilyAliases(
215
+ rewriteMap: Map<string, string>,
216
+ alias: ReadonlyMap<string, string>,
217
+ ): number {
218
+ let reconnected = 0;
219
+ for (const [dropped, kept] of alias) {
220
+ const local = rewriteMap.get(kept);
221
+ if (local !== undefined) {
222
+ rewriteMap.set(dropped, local);
223
+ reconnected += 1;
224
+ }
225
+ }
226
+ return reconnected;
227
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,7 @@
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";
@@ -33,13 +36,16 @@ export {
33
36
  serveDirectory,
34
37
  routeToUrlPath,
35
38
  } from "./verify.ts";
36
- 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";
37
42
  export type {
38
- ImportSiteOptions,
43
+ ImportPipelineOptions,
44
+ ImportPipelineResult,
39
45
  ImportProgressEvent,
40
46
  ImportPhase,
41
- ImportSiteResult,
42
- } from "./run.ts";
47
+ } from "./pipeline.ts";
48
+ export type { ImportSiteOptions, ImportSiteResult } from "./run.ts";
43
49
  export type { ToJxResult } from "./to-jx.ts";
44
50
  export type { EmitOptions, EmitResult, MultiEmitOptions } from "./emit.ts";
45
51
  export type { CapturedStyle, StyleCaptureResult } from "./style-capture.ts";
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
+ }
@@ -12,11 +12,11 @@
12
12
  * so the import can SAY where a width went, not so its styles can be recovered.
13
13
  */
14
14
 
15
- import type { Page } from "puppeteer-core";
15
+ import type { ImportPage } from "./capture.ts";
16
16
  import type { CapturedStyle } from "./style-capture.ts";
17
17
  import { captureStylesAtWidth } from "./style-capture.ts";
18
18
  import { computeMediaDelta } from "./style-diff.ts";
19
- import { analyzeMediaQueries, planBreakpoints } from "./breakpoint-plan.ts";
19
+ import { analyzeMediaQueries, parseWidthQuery, planBreakpoints } from "./breakpoint-plan.ts";
20
20
  import type { Breakpoint, BreakpointPolicy } from "./breakpoint-plan.ts";
21
21
  import type { DiffedStyle } from "./style-diff.ts";
22
22
 
@@ -53,10 +53,70 @@ export interface ExtractMediaOptions {
53
53
  plan?: readonly Breakpoint[] | undefined;
54
54
  }
55
55
 
56
+ /**
57
+ * How far inside its own band the corroborating sample sits. Wide enough that a fluid element
58
+ * measures differently, narrow enough to stay clear of the neighbouring breakpoint.
59
+ */
60
+ const SECOND_SAMPLE_OFFSET = 40;
61
+
62
+ /**
63
+ * A second viewport width INSIDE the same media band.
64
+ *
65
+ * The direction is the whole point: for `(max-width: N)` the band runs downward, so the second
66
+ * sample is narrower; for `(min-width: N)` it runs upward and the sample must be wider, or it
67
+ * leaves the band and measures the rules this breakpoint exists to distinguish itself from.
68
+ */
69
+ export function secondSampleWidth(bp: Breakpoint): number {
70
+ const parsed = parseWidthQuery(bp.query);
71
+ const direction = parsed?.type === "min" ? 1 : -1;
72
+ return Math.max(1, bp.testWidth + direction * SECOND_SAMPLE_OFFSET);
73
+ }
74
+
75
+ /**
76
+ * Keep only the declarations two samples of the same band AGREE on.
77
+ *
78
+ * `getComputedStyle` returns used values, so `width` and `height` come back as the pixels the
79
+ * element happens to occupy - never the authored `100%` or `auto`. Diffing one sample against the
80
+ * base therefore reported a "change" for every fluid element at every breakpoint: two thirds of all
81
+ * responsive declarations in a real import were the viewport's own width written back as the
82
+ * element's, which does not merely waste space but PINS the layout and defeats the reflow the
83
+ * breakpoint existed for.
84
+ *
85
+ * Measuring twice inside the band separates the two cases without knowing which properties are
86
+ * geometry. An authored value is the same at both widths and survives; a resolved one moves with
87
+ * the viewport and is dropped. A property whose value legitimately differs across the band (a
88
+ * percentage-positioned element) is dropped too - that is the intended trade, since nothing in a
89
+ * computed-style capture can tell it from noise.
90
+ */
91
+ export function agreedDeltas(
92
+ primary: readonly DiffedStyle[],
93
+ corroborating: readonly DiffedStyle[],
94
+ ): DiffedStyle[] {
95
+ const second = new Map(corroborating.map((d) => [d.path.join(","), d.style]));
96
+ const agreed: DiffedStyle[] = [];
97
+
98
+ for (const delta of primary) {
99
+ const other = second.get(delta.path.join(","));
100
+ if (!other) {
101
+ continue;
102
+ }
103
+ const style: Record<string, string | number> = {};
104
+ for (const [prop, value] of Object.entries(delta.style)) {
105
+ if (other[prop] === value) {
106
+ style[prop] = value;
107
+ }
108
+ }
109
+ if (Object.keys(style).length > 0) {
110
+ agreed.push({ path: delta.path, style });
111
+ }
112
+ }
113
+ return agreed;
114
+ }
115
+
56
116
  /**
57
117
  * Extract `$media` style deltas by re-capturing at each KEPT breakpoint width.
58
118
  *
59
- * @param {Page} page - Puppeteer page (must still have the target page loaded)
119
+ * @param {ImportPage} page - Browser page (must still have the target page loaded)
60
120
  * @param {CapturedStyle[]} baseElements - Style capture from the base viewport
61
121
  * @param {Record<string, Record<string, string>>} uaDefaults - UA-default baselines per tagName
62
122
  * @param {readonly string[]} mediaQueries - `@media` queries discovered in the page's stylesheets
@@ -64,7 +124,7 @@ export interface ExtractMediaOptions {
64
124
  * @returns {Promise<MediaExtractionResult>}
65
125
  */
66
126
  export async function extractMedia(
67
- page: Page,
127
+ page: ImportPage,
68
128
  baseElements: CapturedStyle[],
69
129
  uaDefaults: Record<string, Record<string, string>>,
70
130
  mediaQueries: readonly string[],
@@ -81,8 +141,17 @@ export async function extractMedia(
81
141
  const allDeltas: Record<string, DiffedStyle[]> = {};
82
142
 
83
143
  for (const bp of keep) {
84
- const bpElements = await captureStylesAtWidth(page, bp.testWidth);
85
- const deltas = computeMediaDelta(baseElements, bpElements, uaDefaults);
144
+ const primary = computeMediaDelta(
145
+ baseElements,
146
+ await captureStylesAtWidth(page, bp.testWidth),
147
+ uaDefaults,
148
+ );
149
+ const corroborating = computeMediaDelta(
150
+ baseElements,
151
+ await captureStylesAtWidth(page, secondSampleWidth(bp)),
152
+ uaDefaults,
153
+ );
154
+ const deltas = agreedDeltas(primary, corroborating);
86
155
 
87
156
  if (deltas.length > 0) {
88
157
  projectBreakpoints[bp.name] = bp.query;