@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 CHANGED
@@ -1,9 +1,6 @@
1
1
  # @jxsuite/import
2
2
 
3
- Clone a live website into a Jx project: capture the rendered DOM with headless Chrome, diff
4
- computed styles against UA defaults, extract design tokens and responsive breakpoints, download
5
- assets, detect the shared layout across pages, componentize recurring subtrees (optionally refined
6
- by an OpenAI-compatible LLM), and emit a ready-to-edit Jx project.
3
+ Clone a live website into a Jx project: capture the rendered DOM with headless Chrome, diff computed styles against UA defaults, extract design tokens and responsive breakpoints, download assets, detect the shared layout across pages, componentize recurring subtrees (optionally refined by an OpenAI-compatible LLM), and emit a ready-to-edit Jx project.
7
4
 
8
5
  ## CLI
9
6
 
@@ -32,31 +29,15 @@ jx-import <url> [options]
32
29
 
33
30
  ### What `--verify` can fail on
34
31
 
35
- `--verify` builds the emitted project, serves it, screenshots every page and pixel-diffs each
36
- against a reference captured during the import. The run exits non-zero when any of three things is
37
- true: the project did not build cleanly, a page could not be rendered, or the average fidelity is
38
- below `--min-fidelity`.
32
+ `--verify` builds the emitted project, serves it, screenshots every page and pixel-diffs each against a reference captured during the import. The run exits non-zero when any of three things is true: the project did not build cleanly, a page could not be rendered, or the average fidelity is below `--min-fidelity`.
39
33
 
40
- `--verify-threshold` is **not** that bar. It is pixelmatch's per-pixel colour tolerance: it decides
41
- when two pixels count as the same colour, so it moves the score without ever deciding the outcome.
42
- `--min-fidelity` is the bar, and it defaults to a deliberately low `25`, a floor for "this is not a
43
- clone of anything" rather than a quality target. A faithful import of a complicated site still
44
- lands well under 100 for reasons no importer can fix (a rotating hero, a font rendering a hair
45
- differently).
34
+ `--verify-threshold` is **not** that bar. It is pixelmatch's per-pixel colour tolerance: it decides when two pixels count as the same colour, so it moves the score without ever deciding the outcome. `--min-fidelity` is the bar, and it defaults to a deliberately low `25`, a floor for "this is not a clone of anything" rather than a quality target. A faithful import of a complicated site still lands well under 100 for reasons no importer can fix (a rotating hero, a font rendering a hair differently).
46
35
 
47
- `verify/report.json` carries the whole picture: per-page fidelity, the console errors and failed or
48
- 404'd requests each rendered page produced, and any build errors. When a page scores badly, read
49
- `failedRequests` first, because a percentage cannot tell you that fifteen images 404'd and that
50
- list can.
36
+ `verify/report.json` carries the whole picture: per-page fidelity, the console errors and failed or 404'd requests each rendered page produced, and any build errors. When a page scores badly, read `failedRequests` first, because a percentage cannot tell you that fifteen images 404'd and that list can.
51
37
 
52
- **Comparing two runs.** The reference screenshot is captured fresh each time, so two runs of the
53
- same URL are not directly comparable. On a site with a rotating hero the spread across identical
54
- runs has been measured at around 4 points, which is wide enough to hide a small regression. Compare
55
- a fidelity number against another run of the same code, not against one from a different session.
38
+ **Comparing two runs.** The reference screenshot is captured fresh each time, so two runs of the same URL are not directly comparable. On a site with a rotating hero the spread across identical runs has been measured at around 4 points, which is wide enough to hide a small regression. Compare a fidelity number against another run of the same code, not against one from a different session.
56
39
 
57
- Environment: `CHROME_PATH` (explicit browser binary; otherwise `google-chrome-stable`,
58
- `google-chrome`, `chromium-browser`, or `chromium` is discovered on PATH), `OPENAI_API_KEY` and
59
- `OPENAI_BASE_URL` for `--ai-components`.
40
+ Environment: `CHROME_PATH` (explicit browser binary; otherwise `google-chrome-stable`, `google-chrome`, `chromium-browser`, or `chromium` is discovered on PATH), `OPENAI_API_KEY` and `OPENAI_BASE_URL` for `--ai-components`.
60
41
 
61
42
  ## Programmatic API
62
43
 
@@ -77,9 +58,6 @@ const result = await importSite(
77
58
  );
78
59
  ```
79
60
 
80
- Lower-level building blocks (capture, style diffing, crawling, layout detection, componentization,
81
- emit, verify) are exported from the package root; `./capture`, `./to-jx`, `./emit`, `./run` and
82
- `./verify` are also addressable as subpaths. See `src/index.ts`.
61
+ Lower-level building blocks (capture, style diffing, crawling, layout detection, componentization, emit, verify) are exported from the package root; `./capture`, `./to-jx`, `./emit`, `./run` and `./verify` are also addressable as subpaths. See `src/index.ts`.
83
62
 
84
- Verification (`verify` option / `--verify`) additionally needs `@jxsuite/compiler` (an optional
85
- dependency) to build the emitted project before screenshot-diffing it.
63
+ Verification (`verify` option / `--verify`) additionally needs `@jxsuite/compiler` (an optional dependency) to build the emitted project before screenshot-diffing it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/import",
3
- "version": "0.40.1",
3
+ "version": "0.40.2",
4
4
  "description": "Clone a live website into a Jx project",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,8 +35,8 @@
35
35
  "import": "bun run src/cli.ts"
36
36
  },
37
37
  "dependencies": {
38
- "@jxsuite/markup": "^0.4.9",
39
- "@jxsuite/schema": "^2.1.0",
38
+ "@jxsuite/markup": "^0.4.10",
39
+ "@jxsuite/schema": "^2.2.0",
40
40
  "pixelmatch": "^7.2.0",
41
41
  "pngjs": "^7.0.0",
42
42
  "puppeteer-core": "^24.9.0"
@@ -46,6 +46,6 @@
46
46
  "happy-dom": "^20.10.6"
47
47
  },
48
48
  "optionalDependencies": {
49
- "@jxsuite/compiler": "^3.1.0"
49
+ "@jxsuite/compiler": "^4.0.0"
50
50
  }
51
51
  }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Turn a recognised framework accordion into native `<details name>`, preserving every node.
3
+ *
4
+ * The importer used to carry an accordion across as inert markup: the framework's directives
5
+ * survived as text, nothing was left to execute them, and the closed rows kept the `hidden`
6
+ * attribute and `display: none` the capture found them under. On the reference corpus that trapped
7
+ * 178 elements of real content behind widgets that could never open again.
8
+ *
9
+ * `<details name="…">` is the whole answer for this shape. Same-named details are mutually
10
+ * exclusive natively, which is what the source accordion's single scalar state meant, so the
11
+ * behaviour is preserved rather than approximated. It needs no runtime, no component state and no
12
+ * expressions — it prerenders, it is keyboard accessible, and there is nothing left to wire wrong.
13
+ *
14
+ * **The transform MOVES nodes; it never rebuilds them.** Every original element is reparented, not
15
+ * recreated, and {@link countLeaves} checks the leaf census across the rewrite. A mismatch abandons
16
+ * the whole widget and leaves the markup exactly as it was — losing a paragraph is far worse than
17
+ * failing to modernise a widget, so the invariant is enforced rather than trusted.
18
+ *
19
+ * Bound to no framework: everything structural comes from `toggle-algebra.ts`, which reads the
20
+ * open/closed logic out of attribute values without naming Alpine, Vue or anything else.
21
+ */
22
+
23
+ import type { JxElement } from "@jxsuite/schema/types";
24
+ import { roleOfElement } from "./toggle-algebra.ts";
25
+ import type { ToggleRole } from "./toggle-algebra.ts";
26
+
27
+ /**
28
+ * Attribute-name prefixes belonging to a client framework the clone does not ship.
29
+ *
30
+ * Left in place they are dead weight that nothing warns about: no attribute allowlist exists in the
31
+ * schema, and the compiler emits any string attribute verbatim, so a stale `x-show` travels all the
32
+ * way into the built site. `on*` is included only for STRING values, because a Jx handler is always
33
+ * an object and so cannot be hit by this.
34
+ */
35
+ const DEAD_DIRECTIVE_PREFIXES = ["x-", "@", ":", "v-", "wire:", "data-wp-", "hx-", "data-bs-"];
36
+
37
+ /** Attributes that conceal a node, and mean nothing once `<details>` owns the open state. */
38
+ const CONCEALING_ATTRIBUTES = new Set(["hidden", "inert", "aria-hidden"]);
39
+
40
+ /** Style declarations that were the widget's closed state rather than its design. */
41
+ const CONCEALING_STYLE = new Set(["display", "height", "maxHeight", "overflow", "visibility"]);
42
+
43
+ /** Names that must survive whatever else is stripped. */
44
+ const PROTECTED_ATTRIBUTES = new Set(["id", "role", "href", "src", "alt", "class", "title"]);
45
+
46
+ function isDeadDirective(name: string, value: unknown): boolean {
47
+ if (PROTECTED_ATTRIBUTES.has(name) || name.startsWith("aria-")) {
48
+ return false;
49
+ }
50
+ if (name.startsWith("on") && typeof value === "string") {
51
+ return true;
52
+ }
53
+ return DEAD_DIRECTIVE_PREFIXES.some((prefix) => name.startsWith(prefix));
54
+ }
55
+
56
+ /**
57
+ * A node's children as a plain array.
58
+ *
59
+ * `children` may also be a `$prototype: "Array"` repeater rather than a list, and a repeater has no
60
+ * literal rows to read. Treating one as empty is right for this pass: a widget whose rows are
61
+ * generated cannot have per-row toggle keys in its markup, so it could never close the algebra
62
+ * anyway, and returning nothing makes it fall through untouched.
63
+ */
64
+ function childrenOf(node: JxElement): (JxElement | string)[] {
65
+ return Array.isArray(node.children) ? (node.children as (JxElement | string)[]) : [];
66
+ }
67
+
68
+ /**
69
+ * The content census of a subtree: every text run and every media element it holds.
70
+ *
71
+ * This is what the rewrite is checked against. Counting nodes would not do — a rewrite legitimately
72
+ * adds a `<details>` and a `<summary>` — but the LEAVES are exactly what a reader loses if the
73
+ * transform goes wrong, so they must be conserved exactly.
74
+ *
75
+ * @param {JxElement | string} node
76
+ * @returns {string[]} One entry per leaf, sorted, so ordering changes do not read as loss
77
+ */
78
+ export function countLeaves(node: JxElement | string): string[] {
79
+ const leaves: string[] = [];
80
+ const visit = (current: JxElement | string): void => {
81
+ if (typeof current === "string") {
82
+ if (current.trim().length > 0) {
83
+ leaves.push(`t:${current.trim()}`);
84
+ }
85
+ return;
86
+ }
87
+ if (typeof current.textContent === "string" && current.textContent.trim().length > 0) {
88
+ leaves.push(`t:${current.textContent.trim()}`);
89
+ }
90
+ const source = (current.attributes as Record<string, unknown> | undefined)?.["src"];
91
+ if (typeof source === "string") {
92
+ leaves.push(`m:${source}`);
93
+ }
94
+ for (const child of childrenOf(current)) {
95
+ visit(child);
96
+ }
97
+ };
98
+ visit(node);
99
+ return leaves.toSorted();
100
+ }
101
+
102
+ /** Remove dead framework directives and concealment from one node, in place. */
103
+ function cleanNode(node: JxElement, unconceal: boolean): void {
104
+ const attributes = node.attributes as Record<string, unknown> | undefined;
105
+ if (attributes) {
106
+ for (const name of Object.keys(attributes)) {
107
+ if (isDeadDirective(name, attributes[name])) {
108
+ delete attributes[name];
109
+ } else if (unconceal && CONCEALING_ATTRIBUTES.has(name)) {
110
+ delete attributes[name];
111
+ }
112
+ }
113
+ }
114
+ if (unconceal && node.style) {
115
+ const style = node.style as Record<string, unknown>;
116
+ for (const property of Object.keys(style)) {
117
+ if (CONCEALING_STYLE.has(property)) {
118
+ delete style[property];
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ /** Strip directives from a whole subtree; only the row's own body is unconcealed. */
125
+ function cleanSubtree(node: JxElement | string, unconceal: boolean): void {
126
+ if (typeof node === "string") {
127
+ return;
128
+ }
129
+ cleanNode(node, unconceal);
130
+ for (const child of childrenOf(node)) {
131
+ cleanSubtree(child, false);
132
+ }
133
+ }
134
+
135
+ interface RowParts {
136
+ row: JxElement;
137
+ title: JxElement;
138
+ body: JxElement;
139
+ key: string;
140
+ }
141
+
142
+ /** The first descendant-or-self whose role matches, depth-first, narrowed to the kind asked for. */
143
+ function findRole<K extends ToggleRole["kind"]>(
144
+ node: JxElement,
145
+ idents: ReadonlySet<string>,
146
+ want: K,
147
+ ): { node: JxElement; role: Extract<ToggleRole, { kind: K }> } | null {
148
+ const role = roleOfElement(node.attributes as Record<string, unknown> | undefined, idents);
149
+ if (role?.kind === want) {
150
+ return { node, role: role as Extract<ToggleRole, { kind: K }> };
151
+ }
152
+ for (const child of childrenOf(node)) {
153
+ if (typeof child === "string") {
154
+ continue;
155
+ }
156
+ const found = findRole(child, idents, want);
157
+ if (found) {
158
+ return found;
159
+ }
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /**
165
+ * Read the rows out of a candidate root, or null when the algebra does not close.
166
+ *
167
+ * Closure is the safety property. Every row must contribute exactly one toggle and one predicate
168
+ * over the SAME declared ident, and their keys must agree and be unique across the widget. Anything
169
+ * looser — a stray predicate, two rows claiming one key, a toggle over an ident nobody declared —
170
+ * means this is not the widget it looks like, and nothing is rewritten.
171
+ */
172
+ export function readAccordionRows(root: JxElement, idents: ReadonlySet<string>): RowParts[] | null {
173
+ const rows: RowParts[] = [];
174
+ const seenKeys = new Set<string>();
175
+
176
+ for (const child of childrenOf(root)) {
177
+ if (typeof child === "string") {
178
+ if (child.trim().length > 0) {
179
+ return null;
180
+ }
181
+ continue;
182
+ }
183
+ const row = child as JxElement;
184
+ const toggle = findRole(row, idents, "assign");
185
+ const predicate = findRole(row, idents, "compare");
186
+ if (!toggle || !predicate) {
187
+ return null;
188
+ }
189
+ const { role: toggleRole } = toggle;
190
+ const { role: predicateRole } = predicate;
191
+ if (
192
+ toggleRole.ident !== predicateRole.ident ||
193
+ toggleRole.key !== predicateRole.key ||
194
+ !idents.has(toggleRole.ident) ||
195
+ seenKeys.has(toggleRole.key)
196
+ ) {
197
+ return null;
198
+ }
199
+ seenKeys.add(toggleRole.key);
200
+ rows.push({ body: predicate.node, key: toggleRole.key, row, title: toggle.node });
201
+ }
202
+
203
+ return rows.length >= 2 ? rows : null;
204
+ }
205
+
206
+ /** A stable, readable `name` so one widget's rows are mutually exclusive and another's are not. */
207
+ function groupName(ident: string, index: number): string {
208
+ const base = ident
209
+ .replaceAll(/[^a-zA-Z0-9]+/g, "-")
210
+ .replaceAll(/^-+|-+$/g, "")
211
+ .toLowerCase();
212
+ return `${base || "disclosure"}-${index}`;
213
+ }
214
+
215
+ /**
216
+ * Rewrite one candidate root's rows into `<details name>`, or return false to leave it alone.
217
+ *
218
+ * The row element itself becomes the `<details>` — keeping its attributes, classes and styles — so
219
+ * the site's own spacing and borders survive. Its title subtree moves inside a `<summary>` and its
220
+ * body subtree follows, both intact.
221
+ */
222
+ function rewriteRoot(
223
+ root: JxElement,
224
+ rows: readonly RowParts[],
225
+ idents: ReadonlySet<string>,
226
+ index: number,
227
+ ): boolean {
228
+ const before = countLeaves(root);
229
+ const name = groupName([...idents][0] ?? "disclosure", index);
230
+ const rewritten: JxElement[] = [];
231
+
232
+ for (const { row, title, body } of rows) {
233
+ cleanSubtree(row, false);
234
+ cleanNode(body, true);
235
+
236
+ const summary: JxElement = { children: [title] as JxElement[], tagName: "summary" };
237
+ const others = childrenOf(row).filter(
238
+ (child) => child !== title && child !== body,
239
+ ) as JxElement[];
240
+
241
+ rewritten.push({
242
+ ...row,
243
+ attributes: { ...(row.attributes as Record<string, string>), name },
244
+ children: [summary, body, ...others] as JxElement[],
245
+ tagName: "details",
246
+ });
247
+ }
248
+
249
+ const candidate: JxElement = { ...root, children: rewritten };
250
+
251
+ /* The invariant, enforced rather than trusted: if a single text run or image would go missing,
252
+ abandon the widget entirely. A stale accordion is a much smaller defect than a deleted
253
+ paragraph, so this failure mode is the safe one. */
254
+ if (countLeaves(candidate).join("") !== before.join("")) {
255
+ return false;
256
+ }
257
+
258
+ root.children = rewritten;
259
+ cleanNode(root, false);
260
+ return true;
261
+ }
262
+
263
+ export interface AccordionResult {
264
+ /** How many widgets were converted. */
265
+ converted: number;
266
+ /** How many rows those widgets held. */
267
+ rows: number;
268
+ }
269
+
270
+ /**
271
+ * Convert every recognisable accordion in a page tree to native `<details name>`.
272
+ *
273
+ * Runs on the per-page tree BEFORE layout detection, because that step replaces the pages it
274
+ * extracts a layout from — a pass placed after it would see a site's widgets in neither.
275
+ *
276
+ * @param {JxElement} root - The page tree, mutated in place
277
+ * @returns {AccordionResult}
278
+ */
279
+ export function applyAccordions(root: JxElement): AccordionResult {
280
+ const result: AccordionResult = { converted: 0, rows: 0 };
281
+ let index = 0;
282
+
283
+ const visit = (node: JxElement): void => {
284
+ const role = roleOfElement(node.attributes as Record<string, unknown> | undefined);
285
+ if (role?.kind === "declare") {
286
+ const idents = new Set(role.idents.keys());
287
+ const rows = readAccordionRows(node, idents);
288
+ if (rows && rewriteRoot(node, rows, idents, index)) {
289
+ index += 1;
290
+ result.converted += 1;
291
+ result.rows += rows.length;
292
+ return;
293
+ }
294
+ }
295
+ for (const child of childrenOf(node)) {
296
+ if (typeof child !== "string") {
297
+ visit(child);
298
+ }
299
+ }
300
+ };
301
+
302
+ visit(root);
303
+ return result;
304
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Turn a control that says it expands something into a real `<details>`.
3
+ *
4
+ * This is the reading that needs no framework knowledge at all: a site that ships correct
5
+ * accessibility markup has already told us which control opens which panel, in `aria-expanded` plus
6
+ * `aria-controls`, and that contract is identical across Bootstrap, jQuery, Webflow and
7
+ * hand-written markup. Where `toggle-algebra.ts` recognises the Alpine and Vue family by the shape
8
+ * of their expressions, this recognises everything that ships WAI-ARIA, which is most of the rest.
9
+ *
10
+ * The panel is concealed and the script that would reveal it is gone, so the content is on disk and
11
+ * unreachable — the single largest body of trapped text on the reference corpus.
12
+ *
13
+ * **A disclosure is not an overlay, and the difference is the flow.** `apply-popovers.ts` claims a
14
+ * concealed panel that is positioned, because being lifted out of the flow is what makes a thing
15
+ * draw ON TOP. A concealed panel still IN the flow is content that belongs where it sits and simply
16
+ * is not shown yet, which is exactly what `<details>` means. The two passes are disjoint by that
17
+ * test rather than by ordering luck.
18
+ *
19
+ * The rewrite MOVES both subtrees and conserves the leaf census, on the same terms as
20
+ * `apply-accordions.ts`: a stale widget is a far smaller defect than deleted content, so a rewrite
21
+ * that would lose a text run or an image is abandoned whole.
22
+ */
23
+
24
+ import type { JxElement } from "@jxsuite/schema/types";
25
+ import { countLeaves } from "./apply-accordions.ts";
26
+
27
+ /** Attributes that made the old control a control, and mean nothing once `<summary>` is one. */
28
+ const CONTROL_ATTRIBUTES = ["aria-expanded", "aria-controls", "role", "tabindex"];
29
+
30
+ /** A panel lifted out of the flow is an overlay, and belongs to the popover pass instead. */
31
+ const POSITIONED = new Set(["absolute", "fixed"]);
32
+
33
+ function attributesOf(node: JxElement): Record<string, unknown> | undefined {
34
+ return node.attributes as Record<string, unknown> | undefined;
35
+ }
36
+
37
+ function childrenOf(node: JxElement): (JxElement | string)[] {
38
+ return Array.isArray(node.children) ? (node.children as (JxElement | string)[]) : [];
39
+ }
40
+
41
+ function elementChildren(node: JxElement): JxElement[] {
42
+ return childrenOf(node).filter((child) => typeof child !== "string") as JxElement[];
43
+ }
44
+
45
+ /** Whether this control declares that it expands a named panel. */
46
+ export function isDisclosureInvoker(node: JxElement): boolean {
47
+ const attributes = attributesOf(node);
48
+ if (!attributes) {
49
+ return false;
50
+ }
51
+ const controls = attributes["aria-controls"];
52
+ return (
53
+ attributes["aria-expanded"] !== undefined && typeof controls === "string" && controls.length > 0
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Whether this panel is closed content in the flow, rather than an overlay or ordinary content.
59
+ *
60
+ * The corpus's panels carry no style at all: they are hidden by the `hidden` ATTRIBUTE, which is
61
+ * why a detector looking only at computed style would miss every one of them.
62
+ */
63
+ export function isDisclosurePanel(node: JxElement): boolean {
64
+ const style = node.style as Record<string, unknown> | undefined;
65
+ if (POSITIONED.has(String(style?.["position"] ?? ""))) {
66
+ return false;
67
+ }
68
+ const attributes = attributesOf(node);
69
+ return attributes?.["hidden"] !== undefined || style?.["display"] === "none";
70
+ }
71
+
72
+ /** Every element carrying an `id`, so `aria-controls` can be resolved. */
73
+ function indexById(root: JxElement): Map<string, JxElement> {
74
+ const index = new Map<string, JxElement>();
75
+ const visit = (node: JxElement): void => {
76
+ const id = attributesOf(node)?.["id"];
77
+ if (typeof id === "string" && !index.has(id)) {
78
+ index.set(id, node);
79
+ }
80
+ for (const child of elementChildren(node)) {
81
+ visit(child);
82
+ }
83
+ };
84
+ visit(root);
85
+ return index;
86
+ }
87
+
88
+ /**
89
+ * What an element actually holds, whether that is children or a collapsed `textContent`.
90
+ *
91
+ * `htmlToJx` folds a lone text child into `textContent` and emits no `children` array at all, so a
92
+ * control whose label is bare text — which is most of them — looks empty to a children-only reader.
93
+ * Unwrapping one through `childrenOf` alone silently drops the label, and the leaf census then
94
+ * abandons the whole rewrite. Correct, but the wrong outcome.
95
+ */
96
+ function contentOf(node: JxElement): (JxElement | string)[] {
97
+ const children = childrenOf(node);
98
+ if (children.length > 0) {
99
+ return children;
100
+ }
101
+ return typeof node.textContent === "string" && node.textContent.length > 0
102
+ ? [node.textContent]
103
+ : [];
104
+ }
105
+
106
+ /**
107
+ * Replace the invoker element with its own children, wherever it sits inside `node`.
108
+ *
109
+ * `<summary>` IS the control once the rewrite lands, so leaving the old one inside it would nest an
110
+ * interactive element in another. Unwrapping rather than deleting keeps the label text, which is
111
+ * the only part of the control a reader ever saw. Safe here because these controls carry no `href`:
112
+ * a real link would lose its destination and is refused before this point.
113
+ */
114
+ function unwrapInvoker(node: JxElement, invoker: JxElement): JxElement {
115
+ const children = childrenOf(node);
116
+ if (children.length === 0) {
117
+ return node;
118
+ }
119
+ const rebuilt: (JxElement | string)[] = [];
120
+ for (const child of children) {
121
+ if (child === invoker) {
122
+ rebuilt.push(...contentOf(child as JxElement));
123
+ } else if (typeof child === "string") {
124
+ rebuilt.push(child);
125
+ } else {
126
+ rebuilt.push(unwrapInvoker(child as JxElement, invoker));
127
+ }
128
+ }
129
+ return { ...node, children: rebuilt as JxElement[] };
130
+ }
131
+
132
+ export interface DisclosureResult {
133
+ /** How many control/panel pairs became a `<details>`. */
134
+ converted: number;
135
+ /** Controls left pointing at a panel another control had already claimed. */
136
+ duplicateInvokers: number;
137
+ }
138
+
139
+ /**
140
+ * Convert every ARIA-declared disclosure in a page tree into `<details>`.
141
+ *
142
+ * @param {JxElement} root - The page tree, mutated in place
143
+ * @returns {DisclosureResult}
144
+ */
145
+ export function applyDisclosures(root: JxElement): DisclosureResult {
146
+ const result: DisclosureResult = { converted: 0, duplicateInvokers: 0 };
147
+ const byId = indexById(root);
148
+ const claimedPanels = new Set<JxElement>();
149
+
150
+ /*
151
+ * Walk parents rather than invokers. The rewrite replaces TWO of a parent's children with one
152
+ * `<details>`, so the parent is the unit of work — and the corpus puts the panel BEFORE the
153
+ * control it belongs to ("… hidden extra copy … Read more"), so neither is reliably first.
154
+ */
155
+ const visit = (parent: JxElement): void => {
156
+ for (;;) {
157
+ const children = childrenOf(parent);
158
+ let applied = false;
159
+
160
+ for (let index = 0; index < children.length && !applied; index += 1) {
161
+ const branch = children[index];
162
+ if (typeof branch === "string") {
163
+ continue;
164
+ }
165
+
166
+ const invoker = findInvokerIn(branch as JxElement);
167
+ if (!invoker) {
168
+ continue;
169
+ }
170
+ const panelId = String(attributesOf(invoker)!["aria-controls"]);
171
+ const panel = byId.get(panelId);
172
+ if (!panel || !isDisclosurePanel(panel)) {
173
+ continue;
174
+ }
175
+ if (claimedPanels.has(panel)) {
176
+ /* A second control for a panel already inside a `<details>` is now a lie to a screen
177
+ reader: it reports a collapsed state it no longer governs. Strip what made it a
178
+ control and leave the text. */
179
+ const attributes = attributesOf(invoker)!;
180
+ for (const name of CONTROL_ATTRIBUTES) {
181
+ delete attributes[name];
182
+ }
183
+ result.duplicateInvokers += 1;
184
+ continue;
185
+ }
186
+
187
+ const panelIndex = children.indexOf(panel);
188
+ if (panelIndex === -1 || panelIndex === index) {
189
+ continue;
190
+ }
191
+
192
+ const before = countLeaves(parent);
193
+ const panelAttributes = { ...attributesOf(panel) };
194
+ delete panelAttributes["hidden"];
195
+ delete panelAttributes["aria-hidden"];
196
+
197
+ /* When the control IS the branch, there is no enclosing element to unwrap it out of, so
198
+ its own attributes would ride onto the `<summary>`. Stripping them here covers both
199
+ shapes: `<summary>` is the control now, and a leftover `aria-expanded` on it would report
200
+ a state the element no longer owns. */
201
+ const summaryBranch = unwrapInvoker(branch as JxElement, invoker);
202
+ const summaryAttributes = { ...attributesOf(summaryBranch) };
203
+ for (const name of CONTROL_ATTRIBUTES) {
204
+ delete summaryAttributes[name];
205
+ }
206
+ const summary: JxElement = {
207
+ ...summaryBranch,
208
+ ...(Object.keys(summaryAttributes).length > 0
209
+ ? { attributes: summaryAttributes as Record<string, string> }
210
+ : {}),
211
+ tagName: "summary",
212
+ };
213
+ if (Object.keys(summaryAttributes).length === 0) {
214
+ delete summary.attributes;
215
+ }
216
+ const details: JxElement = {
217
+ children: [summary, { ...panel, attributes: panelAttributes }] as JxElement[],
218
+ tagName: "details",
219
+ };
220
+
221
+ /* Placed where the FIRST of the pair sat, so the disclosure keeps the position the reader
222
+ already associated with it. `<details>` always renders its summary above its content, so
223
+ a panel that came first moves below the label it was always labelled by. */
224
+ const rebuilt = children.filter((child) => child !== branch && child !== panel) as (
225
+ | JxElement
226
+ | string
227
+ )[];
228
+ rebuilt.splice(Math.min(index, panelIndex), 0, details);
229
+
230
+ const candidate: JxElement = { ...parent, children: rebuilt as JxElement[] };
231
+ if (countLeaves(candidate).join(" ") !== before.join(" ")) {
232
+ continue;
233
+ }
234
+
235
+ parent.children = rebuilt as JxElement[];
236
+ claimedPanels.add(panel);
237
+ result.converted += 1;
238
+ applied = true;
239
+ }
240
+
241
+ if (!applied) {
242
+ break;
243
+ }
244
+ }
245
+
246
+ for (const child of elementChildren(parent)) {
247
+ visit(child);
248
+ }
249
+ };
250
+
251
+ visit(root);
252
+ return result;
253
+ }
254
+
255
+ /**
256
+ * The first disclosure control at or under `node`, but never one already inside a `<details>`.
257
+ *
258
+ * Without that second condition a converted disclosure's own summary would be offered back as a
259
+ * candidate on the next pass over the same parent, and the loop would not terminate.
260
+ */
261
+ function findInvokerIn(node: JxElement): JxElement | null {
262
+ if (String(node.tagName ?? "").toLowerCase() === "details") {
263
+ return null;
264
+ }
265
+ if (isDisclosureInvoker(node)) {
266
+ return node;
267
+ }
268
+ for (const child of elementChildren(node)) {
269
+ const found = findInvokerIn(child);
270
+ if (found) {
271
+ return found;
272
+ }
273
+ }
274
+ return null;
275
+ }