@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.
@@ -5,7 +5,7 @@
5
5
  * SVGs are kept as-is (no download needed). Returns absolute URLs for downloading.
6
6
  */
7
7
 
8
- import type { Page } from "puppeteer-core";
8
+ import type { ImportPage } from "./capture.ts";
9
9
  import { SRCSET_SEPARATOR } from "./srcset.ts";
10
10
 
11
11
  export interface DiscoveredAsset {
@@ -46,7 +46,7 @@ export interface AssetCollectionResult {
46
46
  * Collect all asset URLs from the live page DOM and stylesheets. Runs entirely in-browser via
47
47
  * page.evaluate() — no per-element round-trips.
48
48
  */
49
- export async function collectAssets(page: Page): Promise<AssetCollectionResult> {
49
+ export async function collectAssets(page: ImportPage): Promise<AssetCollectionResult> {
50
50
  /*
51
51
  * The separator crosses into the page as a SOURCE STRING: an evaluated body is serialised and
52
52
  * re-parsed in the browser, so it cannot close over a value declared out here.
@@ -5,9 +5,9 @@
5
5
  * subdirectories by type: images/, fonts/, icons/.
6
6
  */
7
7
 
8
- import { join, extname } from "node:path";
9
- import { mkdir } from "node:fs/promises";
8
+ import { extname } from "node:path";
10
9
  import type { DiscoveredAsset } from "./asset-collect.ts";
10
+ import type { ImportIo } from "./io.ts";
11
11
 
12
12
  export interface DownloadResult {
13
13
  /**
@@ -127,7 +127,7 @@ function isBlocked(url: string): boolean {
127
127
 
128
128
  export async function downloadAssets(
129
129
  assets: DiscoveredAsset[],
130
- outDir: string,
130
+ io: ImportIo,
131
131
  sourceUrl?: string,
132
132
  ): Promise<DownloadResult> {
133
133
  const rewriteMap = new Map<string, string>();
@@ -143,12 +143,11 @@ export async function downloadAssets(
143
143
  }
144
144
  }
145
145
 
146
- // Create subdirectories
147
- const assetsDir = join(outDir, "public", "assets");
148
- await mkdir(join(assetsDir, "images"), { recursive: true });
149
- await mkdir(join(assetsDir, "fonts"), { recursive: true });
150
- await mkdir(join(assetsDir, "icons"), { recursive: true });
151
- await mkdir(join(assetsDir, "other"), { recursive: true });
146
+ // Create subdirectories (a sink with no directories declines — see ImportIo.mkdir)
147
+ const assetsDir = "public/assets";
148
+ for (const subdir of ["images", "fonts", "icons", "other"]) {
149
+ await io.mkdir?.(`${assetsDir}/${subdir}`);
150
+ }
152
151
 
153
152
  // Track filenames to avoid collisions
154
153
  const usedNames = new Map<string, number>();
@@ -172,7 +171,7 @@ export async function downloadAssets(
172
171
  }
173
172
  usedNames.set(nameKey, count + 1);
174
173
 
175
- const destPath = join(assetsDir, subdir, filename);
174
+ const destPath = `${assetsDir}/${subdir}/${filename}`;
176
175
  // The site-absolute path the built asset is served from. `public/` is the compiler's static
177
176
  // ROOT — its contents land at dist/<path>, so the "public/" segment must not survive into a
178
177
  // Reference, and the leading slash must be there or a reference from /components/x.js or a
@@ -195,7 +194,7 @@ export async function downloadAssets(
195
194
 
196
195
  const buffer = await response.arrayBuffer();
197
196
  totalBytes += buffer.byteLength;
198
- await Bun.write(destPath, buffer);
197
+ await io.write(destPath, new Uint8Array(buffer));
199
198
  rewriteMap.set(url, relativePath);
200
199
  } catch {
201
200
  failed.push(url);
@@ -52,20 +52,34 @@ export function rewriteAssetUrls(
52
52
  * the importer would fetch one set of URLs and rewrite another — the attribute would be
53
53
  * reassembled around fragments nothing had downloaded.
54
54
  */
55
- function rewriteSrcset(srcset: string): string {
56
- return srcset
55
+ function rewriteSrcset(srcset: string): { srcset: string; collapsedTo: string | null } {
56
+ const locals = new Set<string>();
57
+ let unresolved = false;
58
+
59
+ const rewritten = srcset
57
60
  .split(SRCSET_SEPARATOR)
58
61
  .map((entry) => {
59
62
  const parts = entry.trim().split(/\s+/);
60
63
  const [url = ""] = parts;
61
- const rewritten = rewriteUrl(url);
62
- if (rewritten) {
64
+ const local = rewriteUrl(url);
65
+ if (local) {
63
66
  count += 1;
64
- parts[0] = rewritten;
67
+ parts[0] = local;
68
+ locals.add(local);
69
+ } else {
70
+ unresolved = true;
65
71
  }
66
72
  return parts.join(" ");
67
73
  })
68
74
  .join(", ");
75
+
76
+ /* Every candidate now points at the ONE file the family kept, so the attribute has become a
77
+ list of the same path repeated with different width descriptors — which lies to the browser
78
+ about what it can choose. Collapse it, and let the compiler build a real `srcset` from the
79
+ original it owns. A candidate that failed to resolve means the family is only partly local,
80
+ so the attribute stays as it is rather than silently narrowing the set. */
81
+ const collapsedTo = !unresolved && locals.size === 1 ? [...locals][0]! : null;
82
+ return { collapsedTo, srcset: rewritten };
69
83
  }
70
84
 
71
85
  function rewriteCssUrls(value: string): string {
@@ -125,7 +139,17 @@ export function rewriteAssetUrls(
125
139
 
126
140
  // Srcset attribute
127
141
  if (typeof attrs.srcset === "string") {
128
- attrs.srcset = rewriteSrcset(attrs.srcset);
142
+ const result = rewriteSrcset(attrs.srcset);
143
+ if (result.collapsedTo === null) {
144
+ attrs.srcset = result.srcset;
145
+ } else {
146
+ delete attrs.srcset;
147
+ /* `sizes` describes a layout that no longer exists once the candidate list is gone, and
148
+ the compiler honours an author `sizes` over its own container measurement — so a stale
149
+ one would misdescribe the imported layout rather than merely be redundant. */
150
+ delete attrs.sizes;
151
+ attrs.src = result.collapsedTo;
152
+ }
129
153
  }
130
154
  }
131
155
 
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Launching a real Chrome on this machine — the one module in the import that loads
3
+ * `puppeteer-core` as a VALUE.
4
+ *
5
+ * It is separate from `capture.ts` for a bundling reason rather than a tidiness one.
6
+ * `puppeteer-core` cannot run in workerd at all (it reaches for `child_process`, `net` and a binary
7
+ * on disk), and a Worker bundler follows value imports transitively — so a single `import { launch
8
+ * }` anywhere in `pipeline.ts`'s graph would pull the whole of it in and fail the deploy. Keeping
9
+ * the launch here, and having every phase take a browser it was HANDED, is what lets Jx Cloud run
10
+ * the same pipeline against a `@cloudflare/puppeteer` session.
11
+ */
12
+
13
+ import { launch } from "puppeteer-core";
14
+ import type { Browser } from "puppeteer-core";
15
+
16
+ const DEFAULT_CHROME_PATHS = [
17
+ "google-chrome-stable",
18
+ "google-chrome",
19
+ "chromium-browser",
20
+ "chromium",
21
+ ];
22
+
23
+ function findChrome(executablePath?: string): string {
24
+ if (executablePath) {
25
+ return executablePath;
26
+ }
27
+ const env = process.env.CHROME_PATH;
28
+ if (env) {
29
+ return env;
30
+ }
31
+
32
+ for (const name of DEFAULT_CHROME_PATHS) {
33
+ const path = Bun.which(name);
34
+ if (path) {
35
+ return path;
36
+ }
37
+ }
38
+ throw new Error(
39
+ "Could not find Chrome/Chromium. Set CHROME_PATH or install google-chrome-stable.",
40
+ );
41
+ }
42
+
43
+ let _browser: Browser | null = null;
44
+
45
+ export interface LaunchOptions {
46
+ /** Explicit browser binary. Wins over CHROME_PATH and PATH discovery. */
47
+ executablePath?: string;
48
+ }
49
+
50
+ export async function launchBrowser(options?: LaunchOptions): Promise<Browser> {
51
+ if (_browser?.connected) {
52
+ return _browser;
53
+ }
54
+ _browser = await launch({
55
+ executablePath: findChrome(options?.executablePath),
56
+ headless: true,
57
+ args: ["--no-sandbox", "--disable-gpu"],
58
+ });
59
+ return _browser;
60
+ }
61
+
62
+ export async function closeBrowser(): Promise<void> {
63
+ if (_browser?.connected) {
64
+ await _browser.close();
65
+ _browser = null;
66
+ }
67
+ }
package/src/capture.ts CHANGED
@@ -1,5 +1,44 @@
1
- import { launch } from "puppeteer-core";
2
- import type { Browser, Page } from "puppeteer-core";
1
+ /**
2
+ * Page capture, and the browser surface the whole import is written against.
3
+ *
4
+ * That surface is declared STRUCTURALLY here rather than imported from `puppeteer-core`, and the
5
+ * reason is bundling, not taste: `puppeteer-core` is unloadable in workerd, and a Worker bundler
6
+ * follows value imports transitively, so a single one anywhere in `pipeline.ts`'s graph would fail
7
+ * the deploy. Naming only `newPage` — and, on a page, the five calls the phases actually make —
8
+ * lets the same code drive a local Chrome (`browser-local.ts`) or a `@cloudflare/puppeteer`
9
+ * session, both of which provide exactly this and more.
10
+ */
11
+
12
+ /**
13
+ * What `evaluate` hands the in-page function.
14
+ *
15
+ * A homomorphic mapped type rather than plain `Params`, because puppeteer's own `evaluate` declares
16
+ * its parameters through one (it unwraps `JSHandle`s there). Two generic signatures only relate
17
+ * when they are shaped alike, and with a bare tuple puppeteer's `Page` stops being assignable to
18
+ * `ImportPage` — which is the one thing this interface has to be true of.
19
+ */
20
+ export type PageArgs<Params extends unknown[]> = { [K in keyof Params]: Params[K] };
21
+
22
+ export interface ImportPage {
23
+ goto: (
24
+ url: string,
25
+ options?: {
26
+ waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
27
+ timeout?: number;
28
+ },
29
+ ) => Promise<unknown>;
30
+ setViewport: (viewport: { width: number; height: number }) => Promise<unknown>;
31
+ evaluate: <Params extends unknown[], R>(
32
+ fn: (...params: PageArgs<Params>) => R,
33
+ ...args: Params
34
+ ) => Promise<Awaited<R>>;
35
+ screenshot: (options?: { fullPage?: boolean; type?: "png" }) => Promise<Uint8Array>;
36
+ close: () => Promise<unknown>;
37
+ }
38
+
39
+ export interface ImportBrowser {
40
+ newPage: () => Promise<ImportPage>;
41
+ }
3
42
 
4
43
  export interface CaptureResult {
5
44
  url: string;
@@ -7,61 +46,8 @@ export interface CaptureResult {
7
46
  bodyHtml: string;
8
47
  /** Discovered same-origin links for the crawler (Phase 3). */
9
48
  links: string[];
10
- /** The puppeteer Page, kept open for style capture (Phase 1). Caller must close. */
11
- page: Page;
12
- }
13
-
14
- const DEFAULT_CHROME_PATHS = [
15
- "google-chrome-stable",
16
- "google-chrome",
17
- "chromium-browser",
18
- "chromium",
19
- ];
20
-
21
- function findChrome(executablePath?: string): string {
22
- if (executablePath) {
23
- return executablePath;
24
- }
25
- const env = process.env.CHROME_PATH;
26
- if (env) {
27
- return env;
28
- }
29
-
30
- for (const name of DEFAULT_CHROME_PATHS) {
31
- const path = Bun.which(name);
32
- if (path) {
33
- return path;
34
- }
35
- }
36
- throw new Error(
37
- "Could not find Chrome/Chromium. Set CHROME_PATH or install google-chrome-stable.",
38
- );
39
- }
40
-
41
- let _browser: Browser | null = null;
42
-
43
- export interface LaunchOptions {
44
- /** Explicit browser binary. Wins over CHROME_PATH and PATH discovery. */
45
- executablePath?: string;
46
- }
47
-
48
- export async function launchBrowser(options?: LaunchOptions): Promise<Browser> {
49
- if (_browser?.connected) {
50
- return _browser;
51
- }
52
- _browser = await launch({
53
- executablePath: findChrome(options?.executablePath),
54
- headless: true,
55
- args: ["--no-sandbox", "--disable-gpu"],
56
- });
57
- return _browser;
58
- }
59
-
60
- export async function closeBrowser(): Promise<void> {
61
- if (_browser?.connected) {
62
- await _browser.close();
63
- _browser = null;
64
- }
49
+ /** The page, kept open for style capture (Phase 1). Caller must close. */
50
+ page: ImportPage;
65
51
  }
66
52
 
67
53
  export interface CaptureOptions {
@@ -73,7 +59,7 @@ export interface CaptureOptions {
73
59
  * Scroll the page to the bottom in steps to trigger lazy-loaded images and intersection-observer
74
60
  * content, then scroll back to top. Settles between steps to let content render.
75
61
  */
76
- async function scrollToRevealAll(page: Page): Promise<void> {
62
+ async function scrollToRevealAll(page: ImportPage): Promise<void> {
77
63
  await page.evaluate(async () => {
78
64
  const delay = (ms: number) =>
79
65
  new Promise<void>((r) => {
@@ -111,15 +97,18 @@ async function scrollToRevealAll(page: Page): Promise<void> {
111
97
  /**
112
98
  * Capture a page's DOM. Returns the page object still open — the caller is responsible for closing
113
99
  * it (after style capture in Phase 1, or immediately).
100
+ *
101
+ * The browser is a parameter and has no default. It used to fall back to launching a local Chrome,
102
+ * and that fallback was the value import of `puppeteer-core` that kept this module — and everything
103
+ * that reaches it — out of a Worker.
114
104
  */
115
105
  export async function capturePage(
116
106
  url: string,
117
- browser?: Browser,
107
+ browser: ImportBrowser,
118
108
  options?: CaptureOptions,
119
109
  ): Promise<CaptureResult> {
120
110
  const { scrollToBottom = true } = options ?? {};
121
- const br = browser ?? (await launchBrowser());
122
- const page: Page = await br.newPage();
111
+ const page = await browser.newPage();
123
112
  await page.setViewport({ width: 1440, height: 900 });
124
113
 
125
114
  await page.goto(url, { waitUntil: "networkidle0", timeout: 30_000 });
@@ -185,6 +185,39 @@ function setNodeAt(root: JxElement, path: number[], replacement: JxElement): JxE
185
185
  return result;
186
186
  }
187
187
 
188
+ /**
189
+ * Tags that must never become a component ROOT, because the element itself carries the behaviour.
190
+ *
191
+ * A component's template root takes the component's own tag name, so promoting one of these
192
+ * replaces it with a custom element and the browser semantics go with it: `<details>` stops being a
193
+ * disclosure, `<summary>` stops being its label, `<dialog>` stops being a dialog, and a table or
194
+ * list part is torn out of the parent structure that gives it meaning. The children of one of these
195
+ * are still fair game — only the root is refused.
196
+ *
197
+ * This matters most for the widgets `apply-accordions.ts` converts: rewriting an accordion into
198
+ * `<details name>` makes its rows structurally identical, which is exactly the shape componentize
199
+ * looks for, so without this guard the pass would hand back a working disclosure and componentize
200
+ * would immediately turn it into an inert custom element.
201
+ */
202
+ const NEVER_A_COMPONENT_ROOT = new Set([
203
+ "details",
204
+ "summary",
205
+ "dialog",
206
+ "table",
207
+ "thead",
208
+ "tbody",
209
+ "tfoot",
210
+ "tr",
211
+ "td",
212
+ "th",
213
+ "li",
214
+ "dt",
215
+ "dd",
216
+ "option",
217
+ "optgroup",
218
+ "figcaption",
219
+ ]);
220
+
188
221
  function collectSubtrees(
189
222
  node: JxElement,
190
223
  pageRoute: string,
@@ -193,7 +226,8 @@ function collectSubtrees(
193
226
  out: SubtreeLocation[],
194
227
  ) {
195
228
  // Skip root node (path.length === 0) — don't componentize entire pages
196
- if (path.length > 0 && nodeDepth(node) >= minDepth) {
229
+ const tag = String(node.tagName ?? "").toLowerCase();
230
+ if (path.length > 0 && nodeDepth(node) >= minDepth && !NEVER_A_COMPONENT_ROOT.has(tag)) {
197
231
  out.push({ pageRoute, path: [...path], node });
198
232
  }
199
233
  if (Array.isArray(node.children)) {
package/src/crawl.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { launchBrowser, capturePage } from "./capture.ts";
2
- import type { CaptureResult } from "./capture.ts";
1
+ import { capturePage } from "./capture.ts";
2
+ import type { CaptureResult, ImportBrowser } from "./capture.ts";
3
+ import type { ImportIo } from "./io.ts";
3
4
  import { convertToJx } from "./to-jx.ts";
4
5
  import type { ToJxResult } from "./to-jx.ts";
5
6
  import { captureStyles } from "./style-capture.ts";
@@ -14,12 +15,16 @@ import type { Breakpoint, BreakpointPolicy } from "./breakpoint-plan.ts";
14
15
  import { applyStylesToTree } from "./apply-styles.ts";
15
16
  import { collectAssets } from "./asset-collect.ts";
16
17
  import { downloadAssets } from "./asset-download.ts";
18
+ import { applyFamilyAliases, planImageFamilies } from "./image-family.ts";
17
19
  import { rewriteAssetUrls } from "./asset-rewrite.ts";
18
20
  import { applyTokens } from "./css-tokens.ts";
19
21
 
20
22
  export interface CrawlOptions {
21
23
  url: string;
22
- outDir: string;
24
+ /** The browser to capture with. The crawl never launches one — see `browser-local.ts`. */
25
+ browser: ImportBrowser;
26
+ /** Where downloaded assets go. */
27
+ io: ImportIo;
23
28
  maxDepth: number;
24
29
  maxPages: number;
25
30
  maxNodesPerPage: number;
@@ -52,8 +57,8 @@ export interface CrawledPage {
52
57
  jx: ToJxResult;
53
58
  depth: number;
54
59
  links: string[];
55
- /** Reference screenshot PNG buffer, present when captureScreenshots is true. */
56
- screenshot?: Buffer | undefined;
60
+ /** Reference screenshot PNG bytes, present when captureScreenshots is true. */
61
+ screenshot?: Uint8Array | undefined;
57
62
  }
58
63
 
59
64
  export interface CrawlResult {
@@ -187,7 +192,8 @@ function isDisallowed(url: string, disallowedPaths: Set<string>): boolean {
187
192
  export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
188
193
  const {
189
194
  url,
190
- outDir,
195
+ browser,
196
+ io,
191
197
  maxDepth,
192
198
  maxPages,
193
199
  maxNodesPerPage,
@@ -199,7 +205,6 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
199
205
  } = options;
200
206
 
201
207
  const { origin } = new URL(url);
202
- const browser = await launchBrowser();
203
208
 
204
209
  // Fetch robots.txt
205
210
  let disallowedPaths = new Set<string>();
@@ -261,11 +266,9 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
261
266
  ` ⚠ Page exceeds node cap (${jx.nodeCount} > ${maxNodesPerPage}), skipping styles/assets`,
262
267
  );
263
268
  skippedByNodeCap.push(entry.url);
264
- let screenshot: Buffer | undefined;
269
+ let screenshot: Uint8Array | undefined;
265
270
  if (options.captureScreenshots) {
266
- screenshot = Buffer.from(
267
- await capture.page.screenshot({ fullPage: fullPageScreenshots, type: "png" }),
268
- );
271
+ screenshot = await capture.page.screenshot({ fullPage: fullPageScreenshots, type: "png" });
269
272
  }
270
273
  await capture.page.close();
271
274
 
@@ -325,7 +328,7 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
325
328
  mergedBreakpoints = {};
326
329
  }
327
330
  Object.assign(mergedBreakpoints, media.breakpoints);
328
- applyStylesToTree(jx.document, diffed, media.deltas);
331
+ applyStylesToTree(jx.document, diffed, media.deltas, media.breakpoints);
329
332
  } else {
330
333
  applyStylesToTree(jx.document, diffed);
331
334
  }
@@ -363,7 +366,12 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
363
366
  }
364
367
 
365
368
  if (collected.assets.length > 0) {
366
- const downloaded = await downloadAssets(collected.assets, outDir, entry.url);
369
+ /* One member per responsive family reaches the network; every dropped derivative is
370
+ aliased onto the file that WAS written, so a reference to any rung of the ladder still
371
+ resolves. The compiler regenerates the sizes from the original it now owns. */
372
+ const families = planImageFamilies(collected.assets);
373
+ const downloaded = await downloadAssets(families.keep, io, entry.url);
374
+ applyFamilyAliases(downloaded.rewriteMap, families.alias);
367
375
  if (downloaded.rewriteMap.size > 0) {
368
376
  rewriteAssetUrls(jx.document, downloaded.rewriteMap, entry.url);
369
377
  // Collect font rewrites (R2)
@@ -381,11 +389,9 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
381
389
  }
382
390
  }
383
391
 
384
- let screenshot: Buffer | undefined;
392
+ let screenshot: Uint8Array | undefined;
385
393
  if (options.captureScreenshots) {
386
- screenshot = Buffer.from(
387
- await capture.page.screenshot({ fullPage: fullPageScreenshots, type: "png" }),
388
- );
394
+ screenshot = await capture.page.screenshot({ fullPage: fullPageScreenshots, type: "png" });
389
395
  }
390
396
  await capture.page.close();
391
397
 
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Drop the `width` and `height` a browser MEASURED, keeping the ones a site actually authored.
3
+ *
4
+ * `getComputedStyle` returns used values, so `width` comes back as the pixels an element happens to
5
+ * occupy rather than the `100%` or `auto` that produced them. Written into a document as an
6
+ * unconditional style, that pins the layout: a full-width section captured at a 1440px viewport
7
+ * becomes `width: 1440px` and never fills anything else again, which is visible the moment a canvas
8
+ * is dragged wider than the width the import was taken at. On the reference corpus this was 814
9
+ * elements pinned at 1000px or more, and `width` and `height` were the two most common declarations
10
+ * in the entire project.
11
+ *
12
+ * `media-extract.ts` already removes these from BREAKPOINT deltas by sampling each band twice and
13
+ * keeping only what both samples agree on. That technique cannot rescue the base, for a reason
14
+ * worth writing down: a second sample must stay inside its own media band or it measures different
15
+ * rules, and the base band is bounded below by the site's widest `min-width` breakpoint. A
16
+ * container with `max-width: 1390px` measures 1390 at every width in that band, so it agrees with
17
+ * itself and survives — while being exactly the fluid element the reader will notice.
18
+ *
19
+ * So the base needs a different question, and the useful one is structural rather than numerical:
20
+ * **would this element have got that width on its own?** A block-level box in normal flow fills its
21
+ * containing block by definition, and a `max-width` or `flex-basis` that narrows it is captured
22
+ * separately and survives — so its measured `width` states a fact the layout already implies, and
23
+ * removing it restores the fluidity without losing the constraint. A replaced element, an
24
+ * out-of-flow box and a shrink-to-fit inline box are all sized by something the layout does NOT
25
+ * imply, so their measurements are kept.
26
+ */
27
+
28
+ import type { JxElement } from "@jxsuite/schema/types";
29
+
30
+ /** Elements sized by their own content or attributes rather than by their container. */
31
+ const REPLACED = new Set([
32
+ "img",
33
+ "svg",
34
+ "video",
35
+ "canvas",
36
+ "iframe",
37
+ "object",
38
+ "embed",
39
+ "input",
40
+ "select",
41
+ "textarea",
42
+ "progress",
43
+ "meter",
44
+ ]);
45
+
46
+ /** A box taken out of the flow is positioned deliberately, so its measurements may be intentional. */
47
+ const OUT_OF_FLOW = new Set(["absolute", "fixed", "sticky"]);
48
+
49
+ /** A bare pixel length, which is the only shape a measurement takes. */
50
+ const PIXEL_LENGTH = /^-?[\d.]+px$/;
51
+
52
+ function isMeasured(value: unknown): boolean {
53
+ return typeof value === "string" && PIXEL_LENGTH.test(value);
54
+ }
55
+
56
+ /**
57
+ * Whether this node holds anything that gives it a height of its own.
58
+ *
59
+ * The distinction matters only for `height`. A box with content is as tall as that content, and
60
+ * pinning it breaks the moment text reflows at a narrower width — the single most damaging way a
61
+ * clone can fail. An EMPTY box is different: a spacer's entire purpose is its height, and there is
62
+ * nothing else in the document that would reproduce it.
63
+ */
64
+ function hasContent(node: JxElement): boolean {
65
+ if (typeof node.textContent === "string" && node.textContent.trim().length > 0) {
66
+ return true;
67
+ }
68
+ const children = Array.isArray(node.children) ? node.children : [];
69
+ return children.some((child) =>
70
+ typeof child === "string" ? child.trim().length > 0 : child !== undefined,
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Remove measured geometry from a style object, in place.
76
+ *
77
+ * @param {JxElement} node - The element the style belongs to; its tag and content decide the answer
78
+ * @param {Record<string, unknown>} style - The style object to filter, mutated
79
+ * @param {Record<string, unknown>} [context] - Where `position` and `display` are read from, when
80
+ * they are not in `style` itself. A breakpoint delta carries only what CHANGED, so it usually
81
+ * states neither, and judging it on its own would strip an absolutely-positioned box's width on
82
+ * the grounds that it looked like an in-flow one.
83
+ * @returns {number} How many declarations were removed
84
+ */
85
+ export function dropDerivedGeometry(
86
+ node: JxElement,
87
+ style: Record<string, unknown>,
88
+ context: Record<string, unknown> = style,
89
+ ): number {
90
+ const tag = String(node.tagName ?? "").toLowerCase();
91
+ if (REPLACED.has(tag)) {
92
+ return 0;
93
+ }
94
+ const position = style["position"] ?? context["position"];
95
+ if (OUT_OF_FLOW.has(String(position ?? ""))) {
96
+ return 0;
97
+ }
98
+ /* A shrink-to-fit box is sized by its content in the inline direction, so its width is not
99
+ implied by its container the way a block-level box's is. */
100
+ const display = style["display"] ?? context["display"];
101
+ if (String(display ?? "").startsWith("inline")) {
102
+ return 0;
103
+ }
104
+
105
+ let dropped = 0;
106
+ if (isMeasured(style["width"])) {
107
+ delete style["width"];
108
+ dropped += 1;
109
+ }
110
+ if (isMeasured(style["height"]) && hasContent(node)) {
111
+ delete style["height"];
112
+ dropped += 1;
113
+ }
114
+ return dropped;
115
+ }