@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.
- 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
package/src/style-capture.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ImportPage } from "./capture.ts";
|
|
10
|
+
import { STRIP_TAGS } from "./to-jx.ts";
|
|
10
11
|
|
|
11
12
|
export interface CapturedStyle {
|
|
12
13
|
/** Depth-first element-index path from <body>, e.g. [0, 2, 1]. */
|
|
@@ -164,173 +165,194 @@ const STYLE_ALLOWLIST: string[] = [
|
|
|
164
165
|
* round-trips per element.
|
|
165
166
|
*/
|
|
166
167
|
export async function captureStyles(page: ImportPage): Promise<StyleCaptureResult> {
|
|
167
|
-
return page.evaluate(
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
return page.evaluate(
|
|
169
|
+
(allowlist: string[], stripTags: string[]) => {
|
|
170
|
+
const stripped = new Set(stripTags);
|
|
171
|
+
const elements: { path: number[]; tagName: string; styles: Record<string, string> }[] = [];
|
|
172
|
+
const tagsSeen = new Set<string>();
|
|
170
173
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
// Depth-first walk matching htmlToJx traversal order (element children only,
|
|
175
|
+
// Skipping text/comment nodes — same as hast element indexing).
|
|
176
|
+
function walk(el: Element, path: number[]) {
|
|
177
|
+
const tag = el.tagName.toLowerCase();
|
|
178
|
+
tagsSeen.add(tag);
|
|
176
179
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
const cs = window.getComputedStyle(el);
|
|
181
|
+
const styles: Record<string, string> = {};
|
|
182
|
+
for (const prop of allowlist) {
|
|
183
|
+
const val = cs.getPropertyValue(prop);
|
|
184
|
+
if (val) {
|
|
185
|
+
styles[prop] = val;
|
|
186
|
+
}
|
|
183
187
|
}
|
|
184
|
-
|
|
185
|
-
elements.push({ path: [...path], tagName: tag, styles });
|
|
188
|
+
elements.push({ path: [...path], tagName: tag, styles });
|
|
186
189
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
childIdx
|
|
190
|
+
/* A stripped tag is not walked AND does not consume an index. `to-jx.ts` removes it from
|
|
191
|
+
the tree these paths are later resolved against, so counting it here would shift every
|
|
192
|
+
following sibling — and the whole subtree under it — onto a path that does not exist. */
|
|
193
|
+
let childIdx = 0;
|
|
194
|
+
for (const child of el.children) {
|
|
195
|
+
if (stripped.has(child.tagName.toLowerCase())) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
walk(child, [...path, childIdx]);
|
|
199
|
+
childIdx += 1;
|
|
200
|
+
}
|
|
191
201
|
}
|
|
192
|
-
}
|
|
193
202
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
203
|
+
// Walk from <body>'s children (htmlToJx receives body.innerHTML, not body itself)
|
|
204
|
+
let bodyChildIdx = 0;
|
|
205
|
+
for (const child of document.body.children) {
|
|
206
|
+
if (stripped.has(child.tagName.toLowerCase())) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
walk(child, [bodyChildIdx]);
|
|
210
|
+
bodyChildIdx += 1;
|
|
211
|
+
}
|
|
200
212
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
213
|
+
// Build UA-default baselines per unique tagName
|
|
214
|
+
const uaDefaults: Record<string, Record<string, string>> = {};
|
|
215
|
+
const sandbox = document.createElement("div");
|
|
216
|
+
/* The sandbox must not set an INHERITED property that is also on the allowlist: the probe
|
|
217
|
+
inherits it and the value becomes that tag's "UA default", so a real declaration equal to it
|
|
218
|
+
is dropped as a no-op by `diffStyles` and the mirror value is emitted on every other node.
|
|
219
|
+
`visibility:hidden` and `pointer-events:none` did exactly that — every genuine
|
|
220
|
+
`visibility:hidden` and `pointer-events:none` on the site was deleted, and `visibility:visible`
|
|
221
|
+
plus `pointerEvents:auto` were stamped onto ~5,300 nodes each, making them the two most
|
|
222
|
+
common declarations in the project. Offscreen positioning alone hides the probes. */
|
|
223
|
+
sandbox.style.cssText = "position:fixed;top:-9999px;left:-9999px;";
|
|
224
|
+
document.body.append(sandbox);
|
|
207
225
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
226
|
+
for (const tag of tagsSeen) {
|
|
227
|
+
try {
|
|
228
|
+
const probe = document.createElement(tag);
|
|
229
|
+
sandbox.append(probe);
|
|
230
|
+
const cs = window.getComputedStyle(probe);
|
|
231
|
+
const defaults: Record<string, string> = {};
|
|
232
|
+
for (const prop of allowlist) {
|
|
233
|
+
const val = cs.getPropertyValue(prop);
|
|
234
|
+
if (val) {
|
|
235
|
+
defaults[prop] = val;
|
|
236
|
+
}
|
|
218
237
|
}
|
|
238
|
+
uaDefaults[tag] = defaults;
|
|
239
|
+
probe.remove();
|
|
240
|
+
} catch {
|
|
241
|
+
// Skip exotic elements
|
|
219
242
|
}
|
|
220
|
-
uaDefaults[tag] = defaults;
|
|
221
|
-
probe.remove();
|
|
222
|
-
} catch {
|
|
223
|
-
// Skip exotic elements
|
|
224
243
|
}
|
|
225
|
-
|
|
226
|
-
sandbox.remove();
|
|
244
|
+
sandbox.remove();
|
|
227
245
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
246
|
+
// Discover @media queries from stylesheets (recurse into @layer, @supports, etc.)
|
|
247
|
+
const mediaQueries: string[] = [];
|
|
248
|
+
const mediaSeen = new Set<string>();
|
|
249
|
+
function collectMediaRules(rules: CSSRuleList) {
|
|
250
|
+
for (const rule of rules) {
|
|
251
|
+
if (rule instanceof CSSMediaRule) {
|
|
252
|
+
const q = rule.conditionText;
|
|
253
|
+
if (!mediaSeen.has(q)) {
|
|
254
|
+
mediaSeen.add(q);
|
|
255
|
+
mediaQueries.push(q);
|
|
256
|
+
}
|
|
257
|
+
collectMediaRules(rule.cssRules);
|
|
258
|
+
} else if ("cssRules" in rule) {
|
|
259
|
+
collectMediaRules((rule as CSSGroupingRule).cssRules);
|
|
238
260
|
}
|
|
239
|
-
collectMediaRules(rule.cssRules);
|
|
240
|
-
} else if ("cssRules" in rule) {
|
|
241
|
-
collectMediaRules((rule as CSSGroupingRule).cssRules);
|
|
242
261
|
}
|
|
243
262
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
263
|
+
for (const sheet of document.styleSheets) {
|
|
264
|
+
try {
|
|
265
|
+
collectMediaRules(sheet.cssRules);
|
|
266
|
+
} catch {
|
|
267
|
+
// Cross-origin stylesheet — cssRules inaccessible
|
|
268
|
+
}
|
|
250
269
|
}
|
|
251
|
-
}
|
|
252
270
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
271
|
+
// Extract CSS custom property declarations (R5 — design tokens)
|
|
272
|
+
const customProperties: Record<string, string> = {};
|
|
273
|
+
function collectCustomProps(rules: CSSRuleList) {
|
|
274
|
+
for (const rule of rules) {
|
|
275
|
+
if (rule instanceof CSSStyleRule) {
|
|
276
|
+
const { style } = rule;
|
|
277
|
+
for (const name of style) {
|
|
278
|
+
if (name.startsWith("--")) {
|
|
279
|
+
const val = style.getPropertyValue(name).trim();
|
|
280
|
+
if (val && !val.includes("var(")) {
|
|
281
|
+
customProperties[name] = val;
|
|
282
|
+
}
|
|
264
283
|
}
|
|
265
284
|
}
|
|
266
285
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
286
|
+
if ("cssRules" in rule) {
|
|
287
|
+
try {
|
|
288
|
+
collectCustomProps((rule as CSSGroupingRule).cssRules);
|
|
289
|
+
} catch {
|
|
290
|
+
/* Skip */
|
|
291
|
+
}
|
|
273
292
|
}
|
|
274
293
|
}
|
|
275
294
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
295
|
+
for (const sheet of document.styleSheets) {
|
|
296
|
+
try {
|
|
297
|
+
collectCustomProps(sheet.cssRules);
|
|
298
|
+
} catch {
|
|
299
|
+
/* Cross-origin */
|
|
300
|
+
}
|
|
282
301
|
}
|
|
283
|
-
}
|
|
284
302
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
303
|
+
// Capture <html> and <body> styles that won't be in the element tree
|
|
304
|
+
// (the Jx tree starts from body.children, not body itself)
|
|
305
|
+
const documentStyles: Record<string, string> = {};
|
|
288
306
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
307
|
+
const TRANSPARENT = new Set(["rgba(0, 0, 0, 0)", "transparent", ""]);
|
|
308
|
+
function isDefault(val: string, probeVal: string): boolean {
|
|
309
|
+
return !val || val === probeVal || TRANSPARENT.has(val);
|
|
310
|
+
}
|
|
293
311
|
|
|
294
|
-
|
|
295
|
-
|
|
312
|
+
const htmlCs = window.getComputedStyle(document.documentElement);
|
|
313
|
+
const bodyCs = window.getComputedStyle(document.body);
|
|
296
314
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
315
|
+
// Create a clean probe in an iframe to get true UA defaults
|
|
316
|
+
// (a probe appended to body would inherit body's own styles)
|
|
317
|
+
const iframe = document.createElement("iframe");
|
|
318
|
+
iframe.style.cssText =
|
|
319
|
+
"position:fixed;top:-9999px;left:-9999px;width:0;height:0;border:none;";
|
|
320
|
+
document.body.append(iframe);
|
|
321
|
+
const iframeDoc = iframe.contentDocument!;
|
|
322
|
+
const probeDiv = iframeDoc.createElement("div");
|
|
323
|
+
iframeDoc.body.append(probeDiv);
|
|
324
|
+
const probeCs = iframe.contentWindow!.getComputedStyle(probeDiv);
|
|
325
|
+
const probeBodyCs = iframe.contentWindow!.getComputedStyle(iframeDoc.body);
|
|
307
326
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
327
|
+
const DOC_PROPS = [
|
|
328
|
+
"background-color",
|
|
329
|
+
"background-image",
|
|
330
|
+
"color",
|
|
331
|
+
"font-family",
|
|
332
|
+
"font-size",
|
|
333
|
+
"line-height",
|
|
334
|
+
"letter-spacing",
|
|
335
|
+
];
|
|
336
|
+
for (const prop of DOC_PROPS) {
|
|
337
|
+
const bodyVal = bodyCs.getPropertyValue(prop);
|
|
338
|
+
const htmlVal = htmlCs.getPropertyValue(prop);
|
|
339
|
+
const probeDefault = probeCs.getPropertyValue(prop);
|
|
340
|
+
const probeBodyDefault = probeBodyCs.getPropertyValue(prop);
|
|
322
341
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
342
|
+
// Body takes priority, but skip transparent/default values
|
|
343
|
+
if (!isDefault(bodyVal, probeBodyDefault)) {
|
|
344
|
+
documentStyles[prop] = bodyVal;
|
|
345
|
+
} else if (!isDefault(htmlVal, probeDefault)) {
|
|
346
|
+
documentStyles[prop] = htmlVal;
|
|
347
|
+
}
|
|
328
348
|
}
|
|
329
|
-
|
|
330
|
-
iframe.remove();
|
|
349
|
+
iframe.remove();
|
|
331
350
|
|
|
332
|
-
|
|
333
|
-
|
|
351
|
+
return { elements, uaDefaults, mediaQueries, customProperties, documentStyles };
|
|
352
|
+
},
|
|
353
|
+
STYLE_ALLOWLIST,
|
|
354
|
+
[...STRIP_TAGS],
|
|
355
|
+
);
|
|
334
356
|
}
|
|
335
357
|
|
|
336
358
|
/**
|
|
@@ -354,36 +376,50 @@ export async function captureStylesAtWidth(
|
|
|
354
376
|
}),
|
|
355
377
|
);
|
|
356
378
|
|
|
357
|
-
return page.evaluate(
|
|
358
|
-
|
|
379
|
+
return page.evaluate(
|
|
380
|
+
(allowlist: string[], stripTags: string[]) => {
|
|
381
|
+
const stripped = new Set(stripTags);
|
|
382
|
+
const elements: { path: number[]; tagName: string; styles: Record<string, string> }[] = [];
|
|
359
383
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
384
|
+
function walk(el: Element, path: number[]) {
|
|
385
|
+
const tag = el.tagName.toLowerCase();
|
|
386
|
+
const cs = window.getComputedStyle(el);
|
|
387
|
+
const styles: Record<string, string> = {};
|
|
388
|
+
for (const prop of allowlist) {
|
|
389
|
+
const val = cs.getPropertyValue(prop);
|
|
390
|
+
if (val) {
|
|
391
|
+
styles[prop] = val;
|
|
392
|
+
}
|
|
368
393
|
}
|
|
369
|
-
|
|
370
|
-
elements.push({ path: [...path], tagName: tag, styles });
|
|
394
|
+
elements.push({ path: [...path], tagName: tag, styles });
|
|
371
395
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
childIdx
|
|
396
|
+
/* A stripped tag is not walked AND does not consume an index. `to-jx.ts` removes it from
|
|
397
|
+
the tree these paths are later resolved against, so counting it here would shift every
|
|
398
|
+
following sibling — and the whole subtree under it — onto a path that does not exist. */
|
|
399
|
+
let childIdx = 0;
|
|
400
|
+
for (const child of el.children) {
|
|
401
|
+
if (stripped.has(child.tagName.toLowerCase())) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
walk(child, [...path, childIdx]);
|
|
405
|
+
childIdx += 1;
|
|
406
|
+
}
|
|
376
407
|
}
|
|
377
|
-
}
|
|
378
408
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
409
|
+
let bodyChildIdx = 0;
|
|
410
|
+
for (const child of document.body.children) {
|
|
411
|
+
if (stripped.has(child.tagName.toLowerCase())) {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
walk(child, [bodyChildIdx]);
|
|
415
|
+
bodyChildIdx += 1;
|
|
416
|
+
}
|
|
384
417
|
|
|
385
|
-
|
|
386
|
-
|
|
418
|
+
return elements;
|
|
419
|
+
},
|
|
420
|
+
STYLE_ALLOWLIST,
|
|
421
|
+
[...STRIP_TAGS],
|
|
422
|
+
);
|
|
387
423
|
}
|
|
388
424
|
|
|
389
425
|
export { STYLE_ALLOWLIST };
|
package/src/style-diff.ts
CHANGED
|
@@ -166,11 +166,24 @@ export function computeMediaDelta(
|
|
|
166
166
|
const baseStyle = diffStyles(baseEl.styles, defaults);
|
|
167
167
|
const bpStyle = diffStyles(bpEl.styles, defaults);
|
|
168
168
|
|
|
169
|
-
|
|
169
|
+
/* The breakpoint's values BEFORE the UA-default and noise filters.
|
|
170
|
+
`diffStyles` drops a declaration that matches the tag's UA default, which is exactly what a
|
|
171
|
+
property REVERTING at a breakpoint looks like: `display: flex` going back to `block` leaves
|
|
172
|
+
nothing in `bpStyle`, because `block` is the UA default for a div. Iterating only `bpStyle`
|
|
173
|
+
therefore made every revert invisible - across the whole corpus there was not one
|
|
174
|
+
`display: block` delta, and a site's entire "stack on mobile" behaviour was discarded. The
|
|
175
|
+
raw value is what a revert has to be expressed WITH, since the filtered one is gone. */
|
|
176
|
+
const bpRaw: Record<string, string | number> = {};
|
|
177
|
+
for (const [prop, value] of Object.entries(bpEl.styles)) {
|
|
178
|
+
bpRaw[kebabToCamel(prop)] = maybeNumeric(value);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Every property either side declares, so a revert counts as a change rather than an absence.
|
|
170
182
|
const delta: Record<string, string | number> = {};
|
|
171
|
-
for (const
|
|
172
|
-
|
|
173
|
-
|
|
183
|
+
for (const prop of new Set([...Object.keys(baseStyle), ...Object.keys(bpStyle)])) {
|
|
184
|
+
const value = prop in bpStyle ? bpStyle[prop] : bpRaw[prop];
|
|
185
|
+
if (value !== undefined && baseStyle[prop] !== value) {
|
|
186
|
+
delta[prop] = value;
|
|
174
187
|
}
|
|
175
188
|
}
|
|
176
189
|
|
package/src/to-jx.ts
CHANGED
|
@@ -10,7 +10,33 @@ export interface ToJxResult {
|
|
|
10
10
|
collectedStyles: string[];
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Tags removed from the converted tree — and therefore tags the style capture must skip too.
|
|
15
|
+
*
|
|
16
|
+
* Exported because two passes have to agree about it. `style-capture.ts` walks the LIVE DOM to
|
|
17
|
+
* build each element's child-index path; `apply-styles.ts` looks those paths up in the STRIPPED
|
|
18
|
+
* tree. Anything dropped here but present during the walk shifts every following sibling by one,
|
|
19
|
+
* and the shift cascades through the subtree, so the lookup misses and the node gets no style at
|
|
20
|
+
* all. On a WordPress page carrying sixteen in-body `<style>` blocks and an `<iframe>`, that was
|
|
21
|
+
* 62.8% of elements unstyled.
|
|
22
|
+
*
|
|
23
|
+
* `style` is a member: it is stripped in `stripTags` by its own branch (its text is harvested into
|
|
24
|
+
* `collectedStyles` first), which is exactly the kind of second exit that made the sets diverge.
|
|
25
|
+
*
|
|
26
|
+
* The live DOM must NOT have these removed before capture — `getComputedStyle` is about to read the
|
|
27
|
+
* cascade that `<style>` and `<link>` define. Skipping them in the walk is the correct fix;
|
|
28
|
+
* removing them from the page is not.
|
|
29
|
+
*/
|
|
30
|
+
export const STRIP_TAGS = new Set([
|
|
31
|
+
"script",
|
|
32
|
+
"noscript",
|
|
33
|
+
"iframe",
|
|
34
|
+
"object",
|
|
35
|
+
"embed",
|
|
36
|
+
"link",
|
|
37
|
+
"meta",
|
|
38
|
+
"style",
|
|
39
|
+
]);
|
|
14
40
|
|
|
15
41
|
function countNodes(node: JxElement | string): number {
|
|
16
42
|
if (typeof node === "string") {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a widget's open/closed logic out of its attribute VALUES, without knowing the framework.
|
|
3
|
+
*
|
|
4
|
+
* An imported accordion arrives as inert text. The site drove it with a client framework, the
|
|
5
|
+
* capture strips scripts, and what survives is markup carrying directives nothing will ever
|
|
6
|
+
* execute: on the reference corpus, 174 `x-show` / `@click` / `x-collapse` attributes against 17
|
|
7
|
+
* surviving roots and no runtime, with 178 elements left permanently `hidden` over real content.
|
|
8
|
+
*
|
|
9
|
+
* The obvious detector is a signal list — `aria-expanded`, `aria-controls`, `role="tabpanel"`. It
|
|
10
|
+
* does not work. The corpus's accordions carry NO ARIA at all: their only markers are class names
|
|
11
|
+
* and framework directives, so a ranked ARIA list scores them zero. And enumerating the directives
|
|
12
|
+
* instead just moves the problem, because the next site brings a different vocabulary.
|
|
13
|
+
*
|
|
14
|
+
* So this reads the ALGEBRA rather than the vocabulary. Three shapes appear in the value of any
|
|
15
|
+
* attribute, whatever it is called:
|
|
16
|
+
*
|
|
17
|
+
* - **DECLARE** — an object literal of `ident: literal` pairs. The widget's state, named.
|
|
18
|
+
* - **ASSIGN** — `ident = (ident === K ? <closed> : K)`, or `ident = !ident`. A toggle.
|
|
19
|
+
* - **COMPARE** — `ident === K`, or a bare `ident`. A visibility predicate.
|
|
20
|
+
*
|
|
21
|
+
* A widget is recognised when those three agree with each other and with the repeated structure:
|
|
22
|
+
* one ident, declared once, whose assignment keys are in bijection with the rows that compare
|
|
23
|
+
* against them. That is true of Alpine, Vue, Petite-Vue and Livewire alike, and it never names any
|
|
24
|
+
* of them.
|
|
25
|
+
*
|
|
26
|
+
* **It fails closed, and its limits are real.** Bootstrap, jQuery, Webflow and Squarespace bind in
|
|
27
|
+
* JavaScript against selectors or opaque ids, and contribute nothing here — those need the ARIA
|
|
28
|
+
* rules, which they ship correctly. When the algebra does not close, nothing is rewritten.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** A JavaScript identifier, which is what every one of these shapes is built around. */
|
|
32
|
+
const IDENT = String.raw`[A-Za-z_$][A-Za-z0-9_$]*`;
|
|
33
|
+
|
|
34
|
+
/** The literals a row key may be. A key is compared and assigned, so it has to be primitive. */
|
|
35
|
+
const KEY = String.raw`-?\d+|'[^']*'|"[^"]*"`;
|
|
36
|
+
|
|
37
|
+
/** The values that mean "nothing is open" on the closing side of a toggle. */
|
|
38
|
+
const CLOSED = String.raw`null|undefined|-1|''|""|false`;
|
|
39
|
+
|
|
40
|
+
const DECLARE_RE = /^\s*\{\s*(.+?)\s*\}\s*$/s;
|
|
41
|
+
const DECLARE_PAIR_RE = new RegExp(String.raw`^\s*(${IDENT})\s*:\s*(.+?)\s*$`);
|
|
42
|
+
|
|
43
|
+
/** `ident = (ident === K ? null : K)` — exclusive toggle, parentheses optional. */
|
|
44
|
+
const ASSIGN_KEYED_RE = new RegExp(
|
|
45
|
+
String.raw`^\s*(${IDENT})\s*=\s*\(?\s*\1\s*===\s*(${KEY})\s*\?\s*(?:${CLOSED})\s*:\s*(${KEY})\s*\)?\s*;?\s*$`,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/** `ident = !ident` — a plain boolean disclosure. */
|
|
49
|
+
const ASSIGN_BOOL_RE = new RegExp(String.raw`^\s*(${IDENT})\s*=\s*!\s*\1\s*;?\s*$`);
|
|
50
|
+
|
|
51
|
+
/** `ident === K` — the predicate a row's body is shown under. */
|
|
52
|
+
const COMPARE_KEYED_RE = new RegExp(String.raw`^\s*(${IDENT})\s*===\s*(${KEY})\s*$`);
|
|
53
|
+
|
|
54
|
+
/** A bare `ident`, the boolean form of the same predicate. */
|
|
55
|
+
const COMPARE_BOOL_RE = new RegExp(String.raw`^\s*(${IDENT})\s*$`);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The one key a boolean widget has, so keyed and boolean forms share a shape.
|
|
59
|
+
*
|
|
60
|
+
* `#` cannot begin a bare numeric key, so this cannot collide with a real one.
|
|
61
|
+
*/
|
|
62
|
+
export const BOOLEAN_KEY = "#boolean";
|
|
63
|
+
|
|
64
|
+
export type ToggleRole =
|
|
65
|
+
| { kind: "declare"; idents: Map<string, string> }
|
|
66
|
+
| { kind: "assign"; ident: string; key: string }
|
|
67
|
+
| { kind: "compare"; ident: string; key: string };
|
|
68
|
+
|
|
69
|
+
/** Normalize a key literal so `0`, `'0'` and `"0"` are the same row. */
|
|
70
|
+
function normalizeKey(raw: string): string {
|
|
71
|
+
const quoted = raw.match(/^['"](.*)['"]$/);
|
|
72
|
+
return quoted ? quoted[1]! : raw.trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Classify one attribute value, or null when it is not part of a toggle.
|
|
77
|
+
*
|
|
78
|
+
* Order matters: DECLARE first, because `{ open: null }` matches nothing else; then the keyed
|
|
79
|
+
* forms, because a bare identifier is the loosest shape here.
|
|
80
|
+
*
|
|
81
|
+
* `knownIdents` is what makes the bare form safe, and it is not optional in spirit. A lone
|
|
82
|
+
* identifier is a valid predicate for a boolean widget (`x-show="show_more"`) and is ALSO what
|
|
83
|
+
* `id="x"`, `class="hero"` and `target="_blank"` look like. Read context-free it matches half the
|
|
84
|
+
* attributes on a page. So a bare identifier is only a predicate when it names state something
|
|
85
|
+
* already declared — which is the closure requirement the whole algebra rests on, applied at the
|
|
86
|
+
* one place it would otherwise leak.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} value - The raw attribute value
|
|
89
|
+
* @param {ReadonlySet<string>} [knownIdents] - Idents a DECLARE in scope has introduced
|
|
90
|
+
* @returns {ToggleRole | null}
|
|
91
|
+
*/
|
|
92
|
+
export function classifyToggleValue(
|
|
93
|
+
value: string,
|
|
94
|
+
knownIdents?: ReadonlySet<string>,
|
|
95
|
+
): ToggleRole | null {
|
|
96
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const declare = value.match(DECLARE_RE);
|
|
101
|
+
if (declare) {
|
|
102
|
+
const idents = new Map<string, string>();
|
|
103
|
+
for (const part of declare[1]!.split(",")) {
|
|
104
|
+
const pair = part.match(DECLARE_PAIR_RE);
|
|
105
|
+
if (!pair) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
idents.set(pair[1]!, pair[2]!);
|
|
109
|
+
}
|
|
110
|
+
return idents.size > 0 ? { idents, kind: "declare" } : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const keyedAssign = value.match(ASSIGN_KEYED_RE);
|
|
114
|
+
if (keyedAssign && normalizeKey(keyedAssign[2]!) === normalizeKey(keyedAssign[3]!)) {
|
|
115
|
+
return { ident: keyedAssign[1]!, key: normalizeKey(keyedAssign[2]!), kind: "assign" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const boolAssign = value.match(ASSIGN_BOOL_RE);
|
|
119
|
+
if (boolAssign) {
|
|
120
|
+
return { ident: boolAssign[1]!, key: BOOLEAN_KEY, kind: "assign" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const keyedCompare = value.match(COMPARE_KEYED_RE);
|
|
124
|
+
if (keyedCompare) {
|
|
125
|
+
return { ident: keyedCompare[1]!, key: normalizeKey(keyedCompare[2]!), kind: "compare" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const boolCompare = value.match(COMPARE_BOOL_RE);
|
|
129
|
+
if (boolCompare && knownIdents?.has(boolCompare[1]!)) {
|
|
130
|
+
return { ident: boolCompare[1]!, key: BOOLEAN_KEY, kind: "compare" };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The strongest role among an element's attribute values.
|
|
138
|
+
*
|
|
139
|
+
* An element carries several directives at once — the corpus's accordion body has `x-show`,
|
|
140
|
+
* `x-collapse.duration.250ms` and `hidden` together — and only one of them is the predicate. A
|
|
141
|
+
* declaration outranks a toggle, which outranks a predicate, because that is the order in which
|
|
142
|
+
* they identify the node: a root, then a control, then a panel.
|
|
143
|
+
*
|
|
144
|
+
* @param {Record<string, unknown> | undefined} attributes
|
|
145
|
+
* @param {ReadonlySet<string>} [knownIdents] - Idents a DECLARE in scope has introduced
|
|
146
|
+
* @returns {ToggleRole | null}
|
|
147
|
+
*/
|
|
148
|
+
export function roleOfElement(
|
|
149
|
+
attributes: Record<string, unknown> | undefined,
|
|
150
|
+
knownIdents?: ReadonlySet<string>,
|
|
151
|
+
): ToggleRole | null {
|
|
152
|
+
if (!attributes) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const rank = { assign: 1, compare: 2, declare: 0 };
|
|
156
|
+
let best: ToggleRole | null = null;
|
|
157
|
+
for (const value of Object.values(attributes)) {
|
|
158
|
+
if (typeof value !== "string") {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const role = classifyToggleValue(value, knownIdents);
|
|
162
|
+
if (role && (best === null || rank[role.kind] < rank[best.kind])) {
|
|
163
|
+
best = role;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return best;
|
|
167
|
+
}
|