@jxsuite/import 0.40.1 → 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/README.md +8 -30
- package/package.json +4 -4
- package/src/apply-accordions.ts +304 -0
- package/src/apply-disclosures.ts +275 -0
- package/src/apply-popovers.ts +298 -0
- package/src/apply-styles.ts +60 -11
- package/src/asset-rewrite.ts +30 -6
- package/src/componentize.ts +35 -1
- package/src/crawl.ts +8 -2
- package/src/derived-geometry.ts +115 -0
- package/src/emit.ts +25 -6
- package/src/image-family.ts +227 -0
- package/src/media-extract.ts +72 -3
- package/src/pipeline.ts +70 -2
- package/src/style-capture.ts +195 -159
- package/src/style-diff.ts +17 -4
- package/src/to-jx.ts +27 -1
- package/src/toggle-algebra.ts +167 -0
|
@@ -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
|
+
}
|
package/src/apply-styles.ts
CHANGED
|
@@ -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:
|
|
71
|
+
const mediaMaps: [string, Map<string, Record<string, string | number>>][] = [];
|
|
32
72
|
if (mediaDeltas) {
|
|
33
|
-
for (const
|
|
34
|
-
mediaMaps[bpName
|
|
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:
|
|
98
|
+
mediaMaps: readonly [string, Map<string, Record<string, string | number>>][],
|
|
59
99
|
): void {
|
|
60
100
|
const key = path.join(",");
|
|
61
101
|
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
114
|
+
for (const [bpName, deltaMap] of mediaMaps) {
|
|
70
115
|
const delta = deltaMap.get(key);
|
|
71
|
-
if (delta
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
package/src/asset-rewrite.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
62
|
-
if (
|
|
64
|
+
const local = rewriteUrl(url);
|
|
65
|
+
if (local) {
|
|
63
66
|
count += 1;
|
|
64
|
-
parts[0] =
|
|
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
|
-
|
|
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
|
|
package/src/componentize.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|