@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.
@@ -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((allowlist: string[]) => {
168
- const elements: { path: number[]; tagName: string; styles: Record<string, string> }[] = [];
169
- const tagsSeen = new Set<string>();
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
- // Depth-first walk matching htmlToJx traversal order (element children only,
172
- // Skipping text/comment nodes — same as hast element indexing).
173
- function walk(el: Element, path: number[]) {
174
- const tag = el.tagName.toLowerCase();
175
- tagsSeen.add(tag);
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
- const cs = window.getComputedStyle(el);
178
- const styles: Record<string, string> = {};
179
- for (const prop of allowlist) {
180
- const val = cs.getPropertyValue(prop);
181
- if (val) {
182
- styles[prop] = val;
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
- let childIdx = 0;
188
- for (const child of el.children) {
189
- walk(child, [...path, childIdx]);
190
- childIdx += 1;
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
- // Walk from <body>'s children (htmlToJx receives body.innerHTML, not body itself)
195
- let bodyChildIdx = 0;
196
- for (const child of document.body.children) {
197
- walk(child, [bodyChildIdx]);
198
- bodyChildIdx += 1;
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
- // Build UA-default baselines per unique tagName
202
- const uaDefaults: Record<string, Record<string, string>> = {};
203
- const sandbox = document.createElement("div");
204
- sandbox.style.cssText =
205
- "position:fixed;top:-9999px;left:-9999px;visibility:hidden;pointer-events:none;";
206
- document.body.append(sandbox);
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
- for (const tag of tagsSeen) {
209
- try {
210
- const probe = document.createElement(tag);
211
- sandbox.append(probe);
212
- const cs = window.getComputedStyle(probe);
213
- const defaults: Record<string, string> = {};
214
- for (const prop of allowlist) {
215
- const val = cs.getPropertyValue(prop);
216
- if (val) {
217
- defaults[prop] = val;
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
- // Discover @media queries from stylesheets (recurse into @layer, @supports, etc.)
229
- const mediaQueries: string[] = [];
230
- const mediaSeen = new Set<string>();
231
- function collectMediaRules(rules: CSSRuleList) {
232
- for (const rule of rules) {
233
- if (rule instanceof CSSMediaRule) {
234
- const q = rule.conditionText;
235
- if (!mediaSeen.has(q)) {
236
- mediaSeen.add(q);
237
- mediaQueries.push(q);
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
- for (const sheet of document.styleSheets) {
246
- try {
247
- collectMediaRules(sheet.cssRules);
248
- } catch {
249
- // Cross-origin stylesheet — cssRules inaccessible
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
- // Extract CSS custom property declarations (R5 — design tokens)
254
- const customProperties: Record<string, string> = {};
255
- function collectCustomProps(rules: CSSRuleList) {
256
- for (const rule of rules) {
257
- if (rule instanceof CSSStyleRule) {
258
- const { style } = rule;
259
- for (const name of style) {
260
- if (name.startsWith("--")) {
261
- const val = style.getPropertyValue(name).trim();
262
- if (val && !val.includes("var(")) {
263
- customProperties[name] = val;
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
- if ("cssRules" in rule) {
269
- try {
270
- collectCustomProps((rule as CSSGroupingRule).cssRules);
271
- } catch {
272
- /* Skip */
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
- for (const sheet of document.styleSheets) {
278
- try {
279
- collectCustomProps(sheet.cssRules);
280
- } catch {
281
- /* Cross-origin */
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
- // Capture <html> and <body> styles that won't be in the element tree
286
- // (the Jx tree starts from body.children, not body itself)
287
- const documentStyles: Record<string, string> = {};
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
- const TRANSPARENT = new Set(["rgba(0, 0, 0, 0)", "transparent", ""]);
290
- function isDefault(val: string, probeVal: string): boolean {
291
- return !val || val === probeVal || TRANSPARENT.has(val);
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
- const htmlCs = window.getComputedStyle(document.documentElement);
295
- const bodyCs = window.getComputedStyle(document.body);
312
+ const htmlCs = window.getComputedStyle(document.documentElement);
313
+ const bodyCs = window.getComputedStyle(document.body);
296
314
 
297
- // Create a clean probe in an iframe to get true UA defaults
298
- // (a probe appended to body would inherit body's own styles)
299
- const iframe = document.createElement("iframe");
300
- iframe.style.cssText = "position:fixed;top:-9999px;left:-9999px;width:0;height:0;border:none;";
301
- document.body.append(iframe);
302
- const iframeDoc = iframe.contentDocument!;
303
- const probeDiv = iframeDoc.createElement("div");
304
- iframeDoc.body.append(probeDiv);
305
- const probeCs = iframe.contentWindow!.getComputedStyle(probeDiv);
306
- const probeBodyCs = iframe.contentWindow!.getComputedStyle(iframeDoc.body);
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
- const DOC_PROPS = [
309
- "background-color",
310
- "background-image",
311
- "color",
312
- "font-family",
313
- "font-size",
314
- "line-height",
315
- "letter-spacing",
316
- ];
317
- for (const prop of DOC_PROPS) {
318
- const bodyVal = bodyCs.getPropertyValue(prop);
319
- const htmlVal = htmlCs.getPropertyValue(prop);
320
- const probeDefault = probeCs.getPropertyValue(prop);
321
- const probeBodyDefault = probeBodyCs.getPropertyValue(prop);
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
- // Body takes priority, but skip transparent/default values
324
- if (!isDefault(bodyVal, probeBodyDefault)) {
325
- documentStyles[prop] = bodyVal;
326
- } else if (!isDefault(htmlVal, probeDefault)) {
327
- documentStyles[prop] = htmlVal;
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
- return { elements, uaDefaults, mediaQueries, customProperties, documentStyles };
333
- }, STYLE_ALLOWLIST);
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((allowlist: string[]) => {
358
- const elements: { path: number[]; tagName: string; styles: Record<string, string> }[] = [];
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
- function walk(el: Element, path: number[]) {
361
- const tag = el.tagName.toLowerCase();
362
- const cs = window.getComputedStyle(el);
363
- const styles: Record<string, string> = {};
364
- for (const prop of allowlist) {
365
- const val = cs.getPropertyValue(prop);
366
- if (val) {
367
- styles[prop] = val;
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
- let childIdx = 0;
373
- for (const child of el.children) {
374
- walk(child, [...path, childIdx]);
375
- childIdx += 1;
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
- let bodyChildIdx = 0;
380
- for (const child of document.body.children) {
381
- walk(child, [bodyChildIdx]);
382
- bodyChildIdx += 1;
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
- return elements;
386
- }, STYLE_ALLOWLIST);
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
- // Only keep properties that changed from base
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 [prop, val] of Object.entries(bpStyle)) {
172
- if (baseStyle[prop] !== val) {
173
- delta[prop] = val;
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
- const STRIP_TAGS = new Set(["script", "noscript", "iframe", "object", "embed", "link", "meta"]);
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
+ }