@jxsuite/import 0.40.1 → 0.40.3

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.
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Rewrite a captured dropdown, mega-menu or drawer into a real HTML popover.
3
+ *
4
+ * The importer had no concept of an overlay, and the failure was quiet rather than loud: the markup
5
+ * all survived — `aria-haspopup`, `aria-controls`, `data-toggle-panel` and the panel itself — but
6
+ * the panel was captured in its CLOSED state and that state was written down as an unconditional
7
+ * style. The reference corpus emitted its navigation submenu as `{ display: grid, opacity: 0,
8
+ * position: absolute, zIndex: 10 }`, with nothing anywhere left to change the opacity. The menu
9
+ * could never appear again.
10
+ *
11
+ * The target is the platform's own popover, which `@jxsuite/schema` already models. Two of its
12
+ * rules are the two defects that have actually shipped in this repo, so they are obeyed here by
13
+ * construction rather than checked afterwards:
14
+ *
15
+ * 1. **A popover's base rule may not set `display` or `visibility`.** The UA sheet closes a popover
16
+ * with `[popover]:not(:popover-open) { display: none }`, and that rule is UA-origin, so any
17
+ * author `display` beats it and the panel is laid out whether open or not. The captured
18
+ * declarations move into `:popover-open`, which is where they were always describing.
19
+ * 2. **Only `<button>` and `<input>` can invoke one.** `popovertarget` comes from an IDL mixin
20
+ * included into those two interfaces and nothing else; on an `<a>` it parses and does nothing.
21
+ * An anchor that is really a button (`href="#"`) is converted into one. An anchor that actually
22
+ * navigates is left alone, because turning a link into a button would break the link.
23
+ *
24
+ * `opacity` is handled here rather than by `popoverDisplayRepair`, which lifts only `display` and
25
+ * `visibility`. The corpus's mega-menu is concealed with `opacity: 0` alone, so a repair that
26
+ * ignored it would produce a popover that opens into nothing.
27
+ */
28
+
29
+ import type { JxElement } from "@jxsuite/schema/types";
30
+ import { POPOVER_DEFAULT_MODE, POPOVER_INVOKER_TAGS } from "@jxsuite/schema/overlays";
31
+
32
+ /** Attributes naming the panel an invoker controls, in the order they are trusted. */
33
+ const TARGET_ATTRIBUTES = ["aria-controls", "data-toggle-panel", "data-target", "href"];
34
+
35
+ /** Attributes that mark a control as opening something, none of which is required on its own. */
36
+ const INVOKER_MARKERS = ["aria-haspopup", "aria-expanded", "data-toggle-panel", "data-target"];
37
+
38
+ /** Declarations that mean "this panel is currently closed" rather than "this is its design". */
39
+ const CONCEALING = new Set(["display", "visibility", "opacity"]);
40
+
41
+ /** A concealed panel is only a popover if it is also lifted out of the flow. */
42
+ const POSITIONED = new Set(["absolute", "fixed"]);
43
+
44
+ function styleOf(node: JxElement): Record<string, unknown> | undefined {
45
+ return node.style as Record<string, unknown> | undefined;
46
+ }
47
+
48
+ function attributesOf(node: JxElement): Record<string, unknown> | undefined {
49
+ return node.attributes as Record<string, unknown> | undefined;
50
+ }
51
+
52
+ function childrenOf(node: JxElement): JxElement[] {
53
+ return Array.isArray(node.children)
54
+ ? (node.children.filter((child) => typeof child !== "string") as JxElement[])
55
+ : [];
56
+ }
57
+
58
+ /** Whether a style hides the node outright, and by which declaration. */
59
+ function concealedBy(style: Record<string, unknown> | undefined): string[] {
60
+ if (!style) {
61
+ return [];
62
+ }
63
+ const found: string[] = [];
64
+ if (style["display"] === "none") {
65
+ found.push("display");
66
+ }
67
+ if (style["visibility"] === "hidden" || style["visibility"] === "collapse") {
68
+ found.push("visibility");
69
+ }
70
+ const { opacity } = style;
71
+ if (opacity === 0 || opacity === "0") {
72
+ found.push("opacity");
73
+ }
74
+ return found;
75
+ }
76
+
77
+ /**
78
+ * Whether this node looks like an overlay panel rather than ordinary hidden content.
79
+ *
80
+ * Concealment alone is not enough and must not be: a responsive alternate, a decorative SVG
81
+ * definition and a closed accordion row are all hidden too, and staples any of them permanently
82
+ * over the page if mistaken for a popover. Being lifted out of the flow is what distinguishes a
83
+ * thing drawn ON TOP from a thing not drawn at all — with `role="dialog"` and `inert` accepted as
84
+ * explicit statements of the same intent.
85
+ */
86
+ export function looksLikePanel(node: JxElement): boolean {
87
+ const style = styleOf(node);
88
+ if (concealedBy(style).length === 0) {
89
+ return false;
90
+ }
91
+ const attributes = attributesOf(node);
92
+ if (attributes?.["role"] === "dialog" || attributes?.["inert"] !== undefined) {
93
+ return true;
94
+ }
95
+ return POSITIONED.has(String(style?.["position"] ?? ""));
96
+ }
97
+
98
+ /** The id an invoker names, from whichever attribute carries it. */
99
+ export function targetIdOf(node: JxElement): string | null {
100
+ const attributes = attributesOf(node);
101
+ if (!attributes) {
102
+ return null;
103
+ }
104
+ for (const name of TARGET_ATTRIBUTES) {
105
+ const raw = attributes[name];
106
+ if (typeof raw !== "string" || raw.length === 0) {
107
+ continue;
108
+ }
109
+ const id = raw.startsWith("#") ? raw.slice(1) : name === "href" ? "" : raw;
110
+ if (id.length > 0 && /^[A-Za-z][\w:.-]*$/.test(id)) {
111
+ return id;
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** Whether this element is a control that opens something. */
118
+ function looksLikeInvoker(node: JxElement): boolean {
119
+ const tag = String(node.tagName ?? "").toLowerCase();
120
+ if (tag !== "button" && tag !== "input" && tag !== "a") {
121
+ return false;
122
+ }
123
+ const attributes = attributesOf(node);
124
+ if (!attributes) {
125
+ return false;
126
+ }
127
+ return (
128
+ INVOKER_MARKERS.some((name) => attributes[name] !== undefined) || targetIdOf(node) !== null
129
+ );
130
+ }
131
+
132
+ /** Every element in the tree that carries an `id`, so a control can be paired with its panel. */
133
+ function indexById(root: JxElement): Map<string, JxElement> {
134
+ const index = new Map<string, JxElement>();
135
+ const visit = (node: JxElement): void => {
136
+ const id = attributesOf(node)?.["id"];
137
+ if (typeof id === "string" && !index.has(id)) {
138
+ index.set(id, node);
139
+ }
140
+ for (const child of childrenOf(node)) {
141
+ visit(child);
142
+ }
143
+ };
144
+ visit(root);
145
+ return index;
146
+ }
147
+
148
+ /**
149
+ * The panel an invoker opens, when no id names it.
150
+ *
151
+ * Takes the ancestry INCLUDING the invoker, so the invoker's own next sibling is considered first
152
+ * and each ancestor's after it.
153
+ *
154
+ * The corpus's mega-menu is the case this exists for: the trigger sits inside a `<span>` and the
155
+ * panel is that span's next sibling, so the panel is the trigger's UNCLE. Walking up from the
156
+ * invoker and checking each ancestor's next sibling finds it, where "the invoker's own next
157
+ * sibling" finds nothing on any of the site's four dropdowns.
158
+ */
159
+ function panelBesideAncestor(ancestry: readonly JxElement[]): JxElement | null {
160
+ for (let depth = ancestry.length - 1; depth >= 0; depth -= 1) {
161
+ const node = ancestry[depth]!;
162
+ const parent = ancestry[depth - 1];
163
+ if (!parent) {
164
+ break;
165
+ }
166
+ const siblings = childrenOf(parent);
167
+ const next = siblings[siblings.indexOf(node) + 1];
168
+ if (next && looksLikePanel(next)) {
169
+ return next;
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /**
176
+ * The value a concealed property takes when the panel is OPEN.
177
+ *
178
+ * `opacity: 0` and `visibility: hidden` each have an exact inverse, so the open rule can state it
179
+ * rather than rely on a default — which is what makes a transition possible and what stops the
180
+ * schema's `no-open-rule` warning firing on a panel that is genuinely fine.
181
+ *
182
+ * `display: none` has NO inverse. The captured value erased whatever the panel used when open, and
183
+ * writing `block` there would be inventing a fact: the element's own default is the honest answer,
184
+ * so nothing is written and the warning is accepted.
185
+ */
186
+ function openValueFor(property: string, closed: unknown): string | null {
187
+ if (property === "opacity" && (closed === 0 || closed === "0")) {
188
+ return "1";
189
+ }
190
+ if (property === "visibility" && (closed === "hidden" || closed === "collapse")) {
191
+ return "visible";
192
+ }
193
+ return null;
194
+ }
195
+
196
+ /** Move a panel's closed-state declarations into `:popover-open`, where they describe the open one. */
197
+ function liftConcealment(node: JxElement): void {
198
+ const style = styleOf(node);
199
+ if (!style) {
200
+ return;
201
+ }
202
+ const open = (style[":popover-open"] as Record<string, unknown> | undefined) ?? {};
203
+ for (const property of Object.keys(style)) {
204
+ if (!CONCEALING.has(property)) {
205
+ continue;
206
+ }
207
+ const value = style[property];
208
+ const isConcealment = concealedBy({ [property]: value }).length > 0;
209
+ /* A concealment is replaced by its inverse where it has one; anything else was the panel's
210
+ design and is carried across unchanged, because the base rule is no longer where it belongs. */
211
+ const openValue = isConcealment ? openValueFor(property, value) : value;
212
+ if (openValue !== null) {
213
+ (open as Record<string, unknown>)[property] = openValue;
214
+ }
215
+ delete style[property];
216
+ }
217
+ if (Object.keys(open).length > 0) {
218
+ (style as Record<string, unknown>)[":popover-open"] = open;
219
+ }
220
+ }
221
+
222
+ export interface PopoverResult {
223
+ /** How many invoker/panel pairs were converted. */
224
+ converted: number;
225
+ /** Anchors left alone because converting them would have broken a real link. */
226
+ skippedNavigatingLinks: number;
227
+ }
228
+
229
+ /**
230
+ * Convert every recognisable overlay in a page tree into a native popover.
231
+ *
232
+ * @param {JxElement} root - The page tree, mutated in place
233
+ * @returns {PopoverResult}
234
+ */
235
+ export function applyPopovers(root: JxElement): PopoverResult {
236
+ const result: PopoverResult = { converted: 0, skippedNavigatingLinks: 0 };
237
+ const byId = indexById(root);
238
+ const claimed = new Set<JxElement>();
239
+ let generated = 0;
240
+
241
+ const visit = (node: JxElement, ancestry: JxElement[]): void => {
242
+ if (looksLikeInvoker(node)) {
243
+ const id = targetIdOf(node);
244
+ const panel = (id === null ? null : byId.get(id)) ?? panelBesideAncestor([...ancestry, node]);
245
+
246
+ if (panel && panel !== node && !claimed.has(panel) && looksLikePanel(panel)) {
247
+ const tag = String(node.tagName ?? "").toLowerCase();
248
+ const attributes = (node.attributes ??= {}) as Record<string, unknown>;
249
+
250
+ /* `popovertarget` exists only on button and input. An anchor that is really a button
251
+ becomes one; an anchor that navigates keeps its job, because a dead link is a worse
252
+ defect than an unconverted menu. */
253
+ if (!POPOVER_INVOKER_TAGS.has(tag)) {
254
+ const { href } = attributes;
255
+ const navigates = typeof href === "string" && href.length > 0 && href !== "#";
256
+ if (navigates) {
257
+ result.skippedNavigatingLinks += 1;
258
+ for (const child of childrenOf(node)) {
259
+ visit(child, [...ancestry, node]);
260
+ }
261
+ return;
262
+ }
263
+ node.tagName = "button";
264
+ attributes["type"] = "button";
265
+ delete attributes["href"];
266
+ }
267
+
268
+ const panelAttributes = (panel.attributes ??= {}) as Record<string, unknown>;
269
+ let { id: panelId } = panelAttributes;
270
+ if (typeof panelId !== "string" || panelId.length === 0) {
271
+ generated += 1;
272
+ panelId = `jx-popover-${generated}`;
273
+ panelAttributes["id"] = panelId;
274
+ }
275
+
276
+ panelAttributes["popover"] = POPOVER_DEFAULT_MODE;
277
+ delete panelAttributes["inert"];
278
+ delete panelAttributes["aria-hidden"];
279
+ liftConcealment(panel);
280
+
281
+ attributes["popovertarget"] = panelId;
282
+ /* The browser maintains `aria-expanded` on a popover invoker itself; a captured literal
283
+ would be a second, stale writer of the same fact. */
284
+ delete attributes["aria-expanded"];
285
+
286
+ claimed.add(panel);
287
+ result.converted += 1;
288
+ }
289
+ }
290
+
291
+ for (const child of childrenOf(node)) {
292
+ visit(child, [...ancestry, node]);
293
+ }
294
+ };
295
+
296
+ visit(root, []);
297
+ return result;
298
+ }
@@ -5,6 +5,8 @@
5
5
 
6
6
  import type { JxElement, JxStyle } from "@jxsuite/schema/types";
7
7
  import type { DiffedStyle } from "./style-diff.ts";
8
+ import { parseWidthQuery } from "./breakpoint-plan.ts";
9
+ import { dropDerivedGeometry } from "./derived-geometry.ts";
8
10
 
9
11
  /** Build a lookup from path key → style object for fast application. */
10
12
  function buildStyleMap(diffed: DiffedStyle[]): Map<string, Record<string, string | number>> {
@@ -15,6 +17,43 @@ function buildStyleMap(diffed: DiffedStyle[]): Map<string, Record<string, string
15
17
  return map;
16
18
  }
17
19
 
20
+ /**
21
+ * The order breakpoint keys must be WRITTEN in, so the cascade resolves the way the source site
22
+ * did.
23
+ *
24
+ * A node's `@--name` keys become `@media` blocks at equal specificity, and equal specificity means
25
+ * the LAST matching rule wins. So the order is not cosmetic: with `--767` written before `--1024`,
26
+ * a 500px viewport matches both and takes the 1024 rule, which is the wrong one. Breakpoints are
27
+ * planned and iterated ascending, so every multi-breakpoint node came out inverted.
28
+ *
29
+ * Correct order is per direction. `max-width` narrows downward, so the NARROWER query must come
30
+ * last to win at narrow viewports: descending. `min-width` widens upward, so the WIDER query must
31
+ * come last: ascending. A query neither shape (or one the planner could not read) keeps its
32
+ * relative order at the end, where it cannot displace a rule that was understood.
33
+ *
34
+ * This is deliberately NOT the order `$media` itself is written in — `emit.ts` sorts that map
35
+ * ascending for Studio's Contexts list, and a lookup table has no cascade to get wrong.
36
+ */
37
+ export function orderBreakpointKeys(
38
+ names: readonly string[],
39
+ queries: Record<string, string> | undefined,
40
+ ): string[] {
41
+ const rank = (name: string): { band: number; width: number } => {
42
+ const parsed = queries?.[name] === undefined ? null : parseWidthQuery(queries[name]!);
43
+ if (!parsed) {
44
+ return { band: 2, width: 0 };
45
+ }
46
+ return parsed.type === "max"
47
+ ? { band: 0, width: -parsed.width }
48
+ : { band: 1, width: parsed.width };
49
+ };
50
+ return names.toSorted((a, b) => {
51
+ const ra = rank(a);
52
+ const rb = rank(b);
53
+ return ra.band === rb.band ? ra.width - rb.width : ra.band - rb.band;
54
+ });
55
+ }
56
+
18
57
  /**
19
58
  * Apply base styles and $media deltas to all nodes in the Jx tree.
20
59
  *
@@ -26,12 +65,13 @@ export function applyStylesToTree(
26
65
  root: JxElement,
27
66
  baseStyles: DiffedStyle[],
28
67
  mediaDeltas?: Record<string, DiffedStyle[]>,
68
+ breakpointQueries?: Record<string, string>,
29
69
  ): void {
30
70
  const baseMap = buildStyleMap(baseStyles);
31
- const mediaMaps: Record<string, Map<string, Record<string, string | number>>> = {};
71
+ const mediaMaps: [string, Map<string, Record<string, string | number>>][] = [];
32
72
  if (mediaDeltas) {
33
- for (const [bpName, deltas] of Object.entries(mediaDeltas)) {
34
- mediaMaps[bpName] = buildStyleMap(deltas);
73
+ for (const bpName of orderBreakpointKeys(Object.keys(mediaDeltas), breakpointQueries)) {
74
+ mediaMaps.push([bpName, buildStyleMap(mediaDeltas[bpName]!)]);
35
75
  }
36
76
  }
37
77
 
@@ -55,24 +95,33 @@ function walkAndApply(
55
95
  node: JxElement,
56
96
  path: number[],
57
97
  baseMap: Map<string, Record<string, string | number>>,
58
- mediaMaps: Record<string, Map<string, Record<string, string | number>>>,
98
+ mediaMaps: readonly [string, Map<string, Record<string, string | number>>][],
59
99
  ): void {
60
100
  const key = path.join(",");
61
101
 
62
- // Apply base styles
102
+ /* Measured geometry is filtered against the NODE, because the question is structural: a
103
+ block-level box in normal flow fills its container, so its measured width states a fact the
104
+ layout already implies and pinning it is what stops the clone reflowing. The copy is per node
105
+ because the same captured style object is shared between every instance of a component. */
63
106
  const baseStyle = baseMap.get(key);
64
107
  if (baseStyle) {
65
- node.style = mergeStyle(node.style, baseStyle);
108
+ const filtered = { ...baseStyle };
109
+ dropDerivedGeometry(node, filtered);
110
+ node.style = mergeStyle(node.style, filtered);
66
111
  }
67
112
 
68
113
  // Apply media-responsive deltas as @breakpointName nested objects
69
- for (const [bpName, deltaMap] of Object.entries(mediaMaps)) {
114
+ for (const [bpName, deltaMap] of mediaMaps) {
70
115
  const delta = deltaMap.get(key);
71
- if (delta && Object.keys(delta).length > 0) {
72
- if (!node.style) {
73
- node.style = {};
116
+ if (delta) {
117
+ const filtered = { ...delta };
118
+ dropDerivedGeometry(node, filtered, (node.style ?? {}) as Record<string, unknown>);
119
+ if (Object.keys(filtered).length > 0) {
120
+ if (!node.style) {
121
+ node.style = {};
122
+ }
123
+ (node.style as Record<string, unknown>)[`@${bpName}`] = filtered;
74
124
  }
75
- (node.style as Record<string, unknown>)[`@${bpName}`] = delta;
76
125
  }
77
126
  }
78
127
 
@@ -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
 
@@ -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
@@ -15,6 +15,7 @@ import type { Breakpoint, BreakpointPolicy } from "./breakpoint-plan.ts";
15
15
  import { applyStylesToTree } from "./apply-styles.ts";
16
16
  import { collectAssets } from "./asset-collect.ts";
17
17
  import { downloadAssets } from "./asset-download.ts";
18
+ import { applyFamilyAliases, planImageFamilies } from "./image-family.ts";
18
19
  import { rewriteAssetUrls } from "./asset-rewrite.ts";
19
20
  import { applyTokens } from "./css-tokens.ts";
20
21
 
@@ -327,7 +328,7 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
327
328
  mergedBreakpoints = {};
328
329
  }
329
330
  Object.assign(mergedBreakpoints, media.breakpoints);
330
- applyStylesToTree(jx.document, diffed, media.deltas);
331
+ applyStylesToTree(jx.document, diffed, media.deltas, media.breakpoints);
331
332
  } else {
332
333
  applyStylesToTree(jx.document, diffed);
333
334
  }
@@ -365,7 +366,12 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
365
366
  }
366
367
 
367
368
  if (collected.assets.length > 0) {
368
- const downloaded = await downloadAssets(collected.assets, io, 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);
369
375
  if (downloaded.rewriteMap.size > 0) {
370
376
  rewriteAssetUrls(jx.document, downloaded.rewriteMap, entry.url);
371
377
  // Collect font rewrites (R2)
@@ -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
+ }