@jxsuite/import 0.40.9 → 0.40.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/import",
3
- "version": "0.40.9",
3
+ "version": "0.40.12",
4
4
  "description": "Clone a live website into a Jx project",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,17 +35,20 @@
35
35
  "import": "bun run src/cli.ts"
36
36
  },
37
37
  "dependencies": {
38
- "@jxsuite/markup": "^0.4.12",
39
- "@jxsuite/schema": "^3.0.0",
38
+ "@jxsuite/markup": "^1.0.0",
39
+ "@jxsuite/protocol": "^4.0.0",
40
+ "@jxsuite/schema": "^4.0.0",
40
41
  "pixelmatch": "^8.0.0",
41
42
  "pngjs": "^7.0.0",
42
43
  "puppeteer-core": "^25.12.0"
43
44
  },
44
45
  "devDependencies": {
46
+ "@happy-dom/global-registrator": "^21.0.3",
47
+ "@jxsuite/test-support": "^0.0.0",
45
48
  "@types/pngjs": "^6.0.5",
46
- "happy-dom": "^20.10.6"
49
+ "happy-dom": "^21.0.3"
47
50
  },
48
51
  "optionalDependencies": {
49
- "@jxsuite/compiler": "^5.1.2"
52
+ "@jxsuite/compiler": "^6.0.1"
50
53
  }
51
54
  }
@@ -9,7 +9,8 @@
9
9
  * Uses the OpenAI-compatible chat completions API (same as the eval harness).
10
10
  */
11
11
 
12
- import type { JxElement } from "@jxsuite/schema/types";
12
+ import { isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
13
+ import type { JsonObject, JsonValue, JxElement } from "@jxsuite/schema/types";
13
14
  import type { ExtractedComponent, ComponentizeResult } from "./componentize.ts";
14
15
 
15
16
  export interface AiComponentizeOptions {
@@ -31,7 +32,8 @@ export interface AiComponentizeOptions {
31
32
  interface RenameResult {
32
33
  componentName: string;
33
34
  tagName: string;
34
- props: Record<string, string>;
35
+ /** The model's proposed renames, unvalidated: a caller keeps only the string values it accepts. */
36
+ props: Record<string, JsonValue>;
35
37
  }
36
38
 
37
39
  function buildPrompt(component: ExtractedComponent): string {
@@ -87,102 +89,81 @@ async function callLlm(prompt: string, opts: AiComponentizeOptions): Promise<Ren
87
89
  return null;
88
90
  }
89
91
 
90
- const data = (await response.json()) as {
91
- choices?: { message?: { content?: string } }[];
92
- };
93
-
94
- const content = data.choices?.[0]?.message?.content;
95
- if (!content) {
92
+ // The completion body is foreign JSON: walk `choices[0].message.content` one guard at a time.
93
+ const data: unknown = await response.json();
94
+ const choices = isJsonObject(data) ? data.choices : undefined;
95
+ const choice = Array.isArray(choices) ? choices[0] : undefined;
96
+ const message = isJsonObject(choice) ? choice.message : undefined;
97
+ const content = isJsonObject(message) ? message.content : undefined;
98
+ if (!isString(content) || !content) {
96
99
  return null;
97
100
  }
98
101
 
99
102
  try {
100
- const parsed = JSON.parse(content) as Record<string, unknown>;
101
- const { componentName } = parsed;
102
- const { tagName } = parsed;
103
- const { props } = parsed;
103
+ // A reply that is not an object has none of the three fields, and fails the check below.
104
+ const parsed: unknown = JSON.parse(content);
105
+ const fields: JsonObject = isJsonObject(parsed) ? parsed : {};
106
+ const { componentName } = fields;
107
+ const { tagName } = fields;
108
+ const { props } = fields;
104
109
 
105
110
  if (
106
- typeof componentName !== "string" ||
111
+ !isString(componentName) ||
107
112
  !componentName.includes("-") ||
108
- typeof tagName !== "string" ||
109
- typeof props !== "object" ||
110
- props === null
113
+ !isString(tagName) ||
114
+ !isNonNullObject(props)
111
115
  ) {
112
116
  return null;
113
117
  }
114
118
 
115
- return {
116
- componentName: componentName as string,
117
- tagName: tagName as string,
118
- props: props as Record<string, string>,
119
- };
119
+ /* `Object.entries` reads an array's indices as keys, so an array `props` keeps the by-index
120
+ lookup it always had while the result gains one honest dictionary type. */
121
+ return { componentName, tagName, props: Object.fromEntries(Object.entries(props)) };
120
122
  } catch {
121
123
  return null;
122
124
  }
123
125
  }
124
126
 
125
- function renamePropsInTree(
126
- node: JxElement | string,
127
- renames: Record<string, string>,
128
- ): JxElement | string {
129
- if (typeof node === "string") {
130
- let result = node;
131
- for (const [oldName, newName] of Object.entries(renames)) {
132
- if (oldName !== newName) {
133
- result = result.replaceAll(`\${state.${oldName}}`, `\${state.${newName}}`);
134
- }
127
+ /** Rewrites every `${state.<old>}` reference in one template string to its renamed prop. */
128
+ function renameStateRefs(text: string, renames: Record<string, string>): string {
129
+ let result = text;
130
+ for (const [oldName, newName] of Object.entries(renames)) {
131
+ if (oldName !== newName) {
132
+ result = result.replaceAll(`\${state.${oldName}}`, `\${state.${newName}}`);
135
133
  }
136
- return result;
137
134
  }
135
+ return result;
136
+ }
138
137
 
138
+ function renamePropsInTree(node: JxElement, renames: Record<string, string>): JxElement {
139
139
  const out: JxElement = { ...node };
140
140
 
141
- if (typeof out.textContent === "string") {
142
- for (const [oldName, newName] of Object.entries(renames)) {
143
- if (oldName !== newName) {
144
- out.textContent = (out.textContent as string).replaceAll(
145
- `\${state.${oldName}}`,
146
- `\${state.${newName}}`,
147
- );
148
- }
149
- }
141
+ const { textContent } = out;
142
+ if (isString(textContent)) {
143
+ out.textContent = renameStateRefs(textContent, renames);
150
144
  }
151
145
 
152
146
  if (out.attributes) {
153
147
  out.attributes = { ...out.attributes };
154
148
  for (const [attrKey, attrVal] of Object.entries(out.attributes)) {
155
- if (typeof attrVal === "string") {
156
- let v = attrVal;
157
- for (const [oldName, newName] of Object.entries(renames)) {
158
- if (oldName !== newName) {
159
- v = v.replaceAll(`\${state.${oldName}}`, `\${state.${newName}}`);
160
- }
161
- }
162
- out.attributes[attrKey] = v;
149
+ if (isString(attrVal)) {
150
+ out.attributes[attrKey] = renameStateRefs(attrVal, renames);
163
151
  }
164
152
  }
165
153
  }
166
154
 
167
155
  if (Array.isArray(out.children)) {
168
156
  out.children = out.children.map((c) =>
169
- renamePropsInTree(c as JxElement | string, renames),
170
- ) as JxElement[];
157
+ isString(c) ? renameStateRefs(c, renames) : renamePropsInTree(c, renames),
158
+ );
171
159
  }
172
160
 
173
161
  return out;
174
162
  }
175
163
 
176
- function renamePropsInState(
177
- state: Record<string, unknown>,
178
- renames: Record<string, string>,
179
- ): Record<string, unknown> {
180
- const out: Record<string, unknown> = {};
181
- for (const [k, v] of Object.entries(state)) {
182
- const newKey = renames[k] ?? k;
183
- out[newKey] = v;
184
- }
185
- return out;
164
+ /** Re-keys a state map by the prop renames, keeping each entry's value and its position. */
165
+ function renamePropsInState<V>(state: Record<string, V>, renames: Record<string, string>) {
166
+ return Object.fromEntries(Object.entries(state).map(([k, v]) => [renames[k] ?? k, v]));
186
167
  }
187
168
 
188
169
  function toPascalCase(str: string): string {
@@ -241,15 +222,12 @@ export async function aiComponentize(
241
222
  for (const prop of existingProps) {
242
223
  const renamed = result.props[prop];
243
224
  propRenames[prop] =
244
- typeof renamed === "string" && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(renamed) ? renamed : prop;
225
+ isString(renamed) && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(renamed) ? renamed : prop;
245
226
  }
246
227
 
247
- const newTemplate = renamePropsInTree(component.template, propRenames) as JxElement;
228
+ const newTemplate = renamePropsInTree(component.template, propRenames);
248
229
  if (newTemplate.state) {
249
- newTemplate.state = renamePropsInState(
250
- newTemplate.state as Record<string, unknown>,
251
- propRenames,
252
- ) as Record<string, string>;
230
+ newTemplate.state = renamePropsInState(newTemplate.state, propRenames);
253
231
  }
254
232
 
255
233
  const newComponent: ExtractedComponent = {
@@ -291,7 +269,7 @@ function rewriteCallSites(
291
269
 
292
270
  /* Import builds every node from parsed HTML, so a tag here is always a literal — the narrow is
293
271
  the type saying so rather than a cast pretending the other shape cannot arrive. */
294
- const literalTag = typeof result.tagName === "string" ? result.tagName : null;
272
+ const literalTag = isString(result.tagName) ? result.tagName : null;
295
273
  if (literalTag && tagRenames.has(literalTag)) {
296
274
  const oldTag = literalTag;
297
275
  result.tagName = tagRenames.get(oldTag)!;
@@ -309,11 +287,11 @@ function rewriteCallSites(
309
287
 
310
288
  if (Array.isArray(result.children)) {
311
289
  result.children = result.children.map((child) => {
312
- if (typeof child === "string") {
290
+ if (isString(child)) {
313
291
  return child;
314
292
  }
315
- return rewriteCallSites(child as JxElement, tagRenames, propRenamesByTag);
316
- }) as JxElement[];
293
+ return rewriteCallSites(child, tagRenames, propRenamesByTag);
294
+ });
317
295
  }
318
296
 
319
297
  return result;
@@ -20,7 +20,8 @@
20
20
  * open/closed logic out of attribute values without naming Alpine, Vue or anything else.
21
21
  */
22
22
 
23
- import type { JxElement } from "@jxsuite/schema/types";
23
+ import { isString } from "@jxsuite/schema/guards";
24
+ import type { JxAttributeValue, JxElement } from "@jxsuite/schema/types";
24
25
  import { roleOfElement } from "./toggle-algebra.ts";
25
26
  import type { ToggleRole } from "./toggle-algebra.ts";
26
27
 
@@ -43,11 +44,11 @@ const CONCEALING_STYLE = new Set(["display", "height", "maxHeight", "overflow",
43
44
  /** Names that must survive whatever else is stripped. */
44
45
  const PROTECTED_ATTRIBUTES = new Set(["id", "role", "href", "src", "alt", "class", "title"]);
45
46
 
46
- function isDeadDirective(name: string, value: unknown): boolean {
47
+ function isDeadDirective(name: string, value: JxAttributeValue | undefined): boolean {
47
48
  if (PROTECTED_ATTRIBUTES.has(name) || name.startsWith("aria-")) {
48
49
  return false;
49
50
  }
50
- if (name.startsWith("on") && typeof value === "string") {
51
+ if (name.startsWith("on") && isString(value)) {
51
52
  return true;
52
53
  }
53
54
  return DEAD_DIRECTIVE_PREFIXES.some((prefix) => name.startsWith(prefix));
@@ -62,7 +63,7 @@ function isDeadDirective(name: string, value: unknown): boolean {
62
63
  * anyway, and returning nothing makes it fall through untouched.
63
64
  */
64
65
  function childrenOf(node: JxElement): (JxElement | string)[] {
65
- return Array.isArray(node.children) ? (node.children as (JxElement | string)[]) : [];
66
+ return Array.isArray(node.children) ? node.children : [];
66
67
  }
67
68
 
68
69
  /**
@@ -78,17 +79,17 @@ function childrenOf(node: JxElement): (JxElement | string)[] {
78
79
  export function countLeaves(node: JxElement | string): string[] {
79
80
  const leaves: string[] = [];
80
81
  const visit = (current: JxElement | string): void => {
81
- if (typeof current === "string") {
82
+ if (isString(current)) {
82
83
  if (current.trim().length > 0) {
83
84
  leaves.push(`t:${current.trim()}`);
84
85
  }
85
86
  return;
86
87
  }
87
- if (typeof current.textContent === "string" && current.textContent.trim().length > 0) {
88
+ if (isString(current.textContent) && current.textContent.trim().length > 0) {
88
89
  leaves.push(`t:${current.textContent.trim()}`);
89
90
  }
90
- const source = (current.attributes as Record<string, unknown> | undefined)?.["src"];
91
- if (typeof source === "string") {
91
+ const source = current.attributes?.["src"];
92
+ if (isString(source)) {
92
93
  leaves.push(`m:${source}`);
93
94
  }
94
95
  for (const child of childrenOf(current)) {
@@ -101,7 +102,7 @@ export function countLeaves(node: JxElement | string): string[] {
101
102
 
102
103
  /** Remove dead framework directives and concealment from one node, in place. */
103
104
  function cleanNode(node: JxElement, unconceal: boolean): void {
104
- const attributes = node.attributes as Record<string, unknown> | undefined;
105
+ const { attributes } = node;
105
106
  if (attributes) {
106
107
  for (const name of Object.keys(attributes)) {
107
108
  if (isDeadDirective(name, attributes[name])) {
@@ -112,7 +113,7 @@ function cleanNode(node: JxElement, unconceal: boolean): void {
112
113
  }
113
114
  }
114
115
  if (unconceal && node.style) {
115
- const style = node.style as Record<string, unknown>;
116
+ const { style } = node;
116
117
  for (const property of Object.keys(style)) {
117
118
  if (CONCEALING_STYLE.has(property)) {
118
119
  delete style[property];
@@ -123,7 +124,7 @@ function cleanNode(node: JxElement, unconceal: boolean): void {
123
124
 
124
125
  /** Strip directives from a whole subtree; only the row's own body is unconcealed. */
125
126
  function cleanSubtree(node: JxElement | string, unconceal: boolean): void {
126
- if (typeof node === "string") {
127
+ if (isString(node)) {
127
128
  return;
128
129
  }
129
130
  cleanNode(node, unconceal);
@@ -139,18 +140,26 @@ interface RowParts {
139
140
  key: string;
140
141
  }
141
142
 
143
+ /** Whether a role is of the kind asked for, narrowing it to that member of the union. */
144
+ function isRoleOfKind<K extends ToggleRole["kind"]>(
145
+ role: ToggleRole | null,
146
+ want: K,
147
+ ): role is Extract<ToggleRole, { kind: K }> {
148
+ return role?.kind === want;
149
+ }
150
+
142
151
  /** The first descendant-or-self whose role matches, depth-first, narrowed to the kind asked for. */
143
152
  function findRole<K extends ToggleRole["kind"]>(
144
153
  node: JxElement,
145
154
  idents: ReadonlySet<string>,
146
155
  want: K,
147
156
  ): { 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 }> };
157
+ const role = roleOfElement(node.attributes, idents);
158
+ if (isRoleOfKind(role, want)) {
159
+ return { node, role };
151
160
  }
152
161
  for (const child of childrenOf(node)) {
153
- if (typeof child === "string") {
162
+ if (isString(child)) {
154
163
  continue;
155
164
  }
156
165
  const found = findRole(child, idents, want);
@@ -174,13 +183,13 @@ export function readAccordionRows(root: JxElement, idents: ReadonlySet<string>):
174
183
  const seenKeys = new Set<string>();
175
184
 
176
185
  for (const child of childrenOf(root)) {
177
- if (typeof child === "string") {
186
+ if (isString(child)) {
178
187
  if (child.trim().length > 0) {
179
188
  return null;
180
189
  }
181
190
  continue;
182
191
  }
183
- const row = child as JxElement;
192
+ const row = child;
184
193
  const toggle = findRole(row, idents, "assign");
185
194
  const predicate = findRole(row, idents, "compare");
186
195
  if (!toggle || !predicate) {
@@ -233,15 +242,13 @@ function rewriteRoot(
233
242
  cleanSubtree(row, false);
234
243
  cleanNode(body, true);
235
244
 
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[];
245
+ const summary: JxElement = { children: [title], tagName: "summary" };
246
+ const others = childrenOf(row).filter((child) => child !== title && child !== body);
240
247
 
241
248
  rewritten.push({
242
249
  ...row,
243
- attributes: { ...(row.attributes as Record<string, string>), name },
244
- children: [summary, body, ...others] as JxElement[],
250
+ attributes: { ...row.attributes, name },
251
+ children: [summary, body, ...others],
245
252
  tagName: "details",
246
253
  });
247
254
  }
@@ -281,7 +288,7 @@ export function applyAccordions(root: JxElement): AccordionResult {
281
288
  let index = 0;
282
289
 
283
290
  const visit = (node: JxElement): void => {
284
- const role = roleOfElement(node.attributes as Record<string, unknown> | undefined);
291
+ const role = roleOfElement(node.attributes);
285
292
  if (role?.kind === "declare") {
286
293
  const idents = new Set(role.idents.keys());
287
294
  const rows = readAccordionRows(node, idents);
@@ -293,7 +300,7 @@ export function applyAccordions(root: JxElement): AccordionResult {
293
300
  }
294
301
  }
295
302
  for (const child of childrenOf(node)) {
296
- if (typeof child !== "string") {
303
+ if (!isString(child)) {
297
304
  visit(child);
298
305
  }
299
306
  }
@@ -21,6 +21,7 @@
21
21
  * that would lose a text run or an image is abandoned whole.
22
22
  */
23
23
 
24
+ import { isString } from "@jxsuite/schema/guards";
24
25
  import type { JxElement } from "@jxsuite/schema/types";
25
26
  import { countLeaves } from "./apply-accordions.ts";
26
27
 
@@ -30,28 +31,22 @@ const CONTROL_ATTRIBUTES = ["aria-expanded", "aria-controls", "role", "tabindex"
30
31
  /** A panel lifted out of the flow is an overlay, and belongs to the popover pass instead. */
31
32
  const POSITIONED = new Set(["absolute", "fixed"]);
32
33
 
33
- function attributesOf(node: JxElement): Record<string, unknown> | undefined {
34
- return node.attributes as Record<string, unknown> | undefined;
35
- }
36
-
37
34
  function childrenOf(node: JxElement): (JxElement | string)[] {
38
- return Array.isArray(node.children) ? (node.children as (JxElement | string)[]) : [];
35
+ return Array.isArray(node.children) ? node.children : [];
39
36
  }
40
37
 
41
38
  function elementChildren(node: JxElement): JxElement[] {
42
- return childrenOf(node).filter((child) => typeof child !== "string") as JxElement[];
39
+ return childrenOf(node).filter((child): child is JxElement => !isString(child));
43
40
  }
44
41
 
45
42
  /** Whether this control declares that it expands a named panel. */
46
43
  export function isDisclosureInvoker(node: JxElement): boolean {
47
- const attributes = attributesOf(node);
44
+ const { attributes } = node;
48
45
  if (!attributes) {
49
46
  return false;
50
47
  }
51
48
  const controls = attributes["aria-controls"];
52
- return (
53
- attributes["aria-expanded"] !== undefined && typeof controls === "string" && controls.length > 0
54
- );
49
+ return attributes["aria-expanded"] !== undefined && isString(controls) && controls.length > 0;
55
50
  }
56
51
 
57
52
  /**
@@ -61,20 +56,19 @@ export function isDisclosureInvoker(node: JxElement): boolean {
61
56
  * why a detector looking only at computed style would miss every one of them.
62
57
  */
63
58
  export function isDisclosurePanel(node: JxElement): boolean {
64
- const style = node.style as Record<string, unknown> | undefined;
59
+ const { style } = node;
65
60
  if (POSITIONED.has(String(style?.["position"] ?? ""))) {
66
61
  return false;
67
62
  }
68
- const attributes = attributesOf(node);
69
- return attributes?.["hidden"] !== undefined || style?.["display"] === "none";
63
+ return node.attributes?.["hidden"] !== undefined || style?.["display"] === "none";
70
64
  }
71
65
 
72
66
  /** Every element carrying an `id`, so `aria-controls` can be resolved. */
73
67
  function indexById(root: JxElement): Map<string, JxElement> {
74
68
  const index = new Map<string, JxElement>();
75
69
  const visit = (node: JxElement): void => {
76
- const id = attributesOf(node)?.["id"];
77
- if (typeof id === "string" && !index.has(id)) {
70
+ const id = node.attributes?.["id"];
71
+ if (isString(id) && !index.has(id)) {
78
72
  index.set(id, node);
79
73
  }
80
74
  for (const child of elementChildren(node)) {
@@ -98,9 +92,7 @@ function contentOf(node: JxElement): (JxElement | string)[] {
98
92
  if (children.length > 0) {
99
93
  return children;
100
94
  }
101
- return typeof node.textContent === "string" && node.textContent.length > 0
102
- ? [node.textContent]
103
- : [];
95
+ return isString(node.textContent) && node.textContent.length > 0 ? [node.textContent] : [];
104
96
  }
105
97
 
106
98
  /**
@@ -118,15 +110,15 @@ function unwrapInvoker(node: JxElement, invoker: JxElement): JxElement {
118
110
  }
119
111
  const rebuilt: (JxElement | string)[] = [];
120
112
  for (const child of children) {
121
- if (child === invoker) {
122
- rebuilt.push(...contentOf(child as JxElement));
123
- } else if (typeof child === "string") {
113
+ if (isString(child)) {
124
114
  rebuilt.push(child);
115
+ } else if (child === invoker) {
116
+ rebuilt.push(...contentOf(child));
125
117
  } else {
126
- rebuilt.push(unwrapInvoker(child as JxElement, invoker));
118
+ rebuilt.push(unwrapInvoker(child, invoker));
127
119
  }
128
120
  }
129
- return { ...node, children: rebuilt as JxElement[] };
121
+ return { ...node, children: rebuilt };
130
122
  }
131
123
 
132
124
  export interface DisclosureResult {
@@ -159,15 +151,16 @@ export function applyDisclosures(root: JxElement): DisclosureResult {
159
151
 
160
152
  for (let index = 0; index < children.length && !applied; index += 1) {
161
153
  const branch = children[index];
162
- if (typeof branch === "string") {
154
+ // The loop bound keeps `index` in range, so `branch` is never the out-of-bounds `undefined`.
155
+ if (branch === undefined || isString(branch)) {
163
156
  continue;
164
157
  }
165
158
 
166
- const invoker = findInvokerIn(branch as JxElement);
159
+ const invoker = findInvokerIn(branch);
167
160
  if (!invoker) {
168
161
  continue;
169
162
  }
170
- const panelId = String(attributesOf(invoker)!["aria-controls"]);
163
+ const panelId = String(invoker.attributes!["aria-controls"]);
171
164
  const panel = byId.get(panelId);
172
165
  if (!panel || !isDisclosurePanel(panel)) {
173
166
  continue;
@@ -176,7 +169,7 @@ export function applyDisclosures(root: JxElement): DisclosureResult {
176
169
  /* A second control for a panel already inside a `<details>` is now a lie to a screen
177
170
  reader: it reports a collapsed state it no longer governs. Strip what made it a
178
171
  control and leave the text. */
179
- const attributes = attributesOf(invoker)!;
172
+ const attributes = invoker.attributes!;
180
173
  for (const name of CONTROL_ATTRIBUTES) {
181
174
  delete attributes[name];
182
175
  }
@@ -190,7 +183,7 @@ export function applyDisclosures(root: JxElement): DisclosureResult {
190
183
  }
191
184
 
192
185
  const before = countLeaves(parent);
193
- const panelAttributes = { ...attributesOf(panel) };
186
+ const panelAttributes = { ...panel.attributes };
194
187
  delete panelAttributes["hidden"];
195
188
  delete panelAttributes["aria-hidden"];
196
189
 
@@ -198,41 +191,38 @@ export function applyDisclosures(root: JxElement): DisclosureResult {
198
191
  its own attributes would ride onto the `<summary>`. Stripping them here covers both
199
192
  shapes: `<summary>` is the control now, and a leftover `aria-expanded` on it would report
200
193
  a state the element no longer owns. */
201
- const summaryBranch = unwrapInvoker(branch as JxElement, invoker);
202
- const summaryAttributes = { ...attributesOf(summaryBranch) };
194
+ const summaryBranch = unwrapInvoker(branch, invoker);
195
+ const summaryAttributes = { ...summaryBranch.attributes };
203
196
  for (const name of CONTROL_ATTRIBUTES) {
204
197
  delete summaryAttributes[name];
205
198
  }
199
+ /* An `attributes` key the branch carried keeps its position, and one written only to be
200
+ deleted leaves no trace, so key order matches the branch it came from. */
206
201
  const summary: JxElement = {
207
202
  ...summaryBranch,
208
- ...(Object.keys(summaryAttributes).length > 0
209
- ? { attributes: summaryAttributes as Record<string, string> }
210
- : {}),
203
+ attributes: summaryAttributes,
211
204
  tagName: "summary",
212
205
  };
213
206
  if (Object.keys(summaryAttributes).length === 0) {
214
207
  delete summary.attributes;
215
208
  }
216
209
  const details: JxElement = {
217
- children: [summary, { ...panel, attributes: panelAttributes }] as JxElement[],
210
+ children: [summary, { ...panel, attributes: panelAttributes }],
218
211
  tagName: "details",
219
212
  };
220
213
 
221
214
  /* Placed where the FIRST of the pair sat, so the disclosure keeps the position the reader
222
215
  already associated with it. `<details>` always renders its summary above its content, so
223
216
  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
- )[];
217
+ const rebuilt = children.filter((child) => child !== branch && child !== panel);
228
218
  rebuilt.splice(Math.min(index, panelIndex), 0, details);
229
219
 
230
- const candidate: JxElement = { ...parent, children: rebuilt as JxElement[] };
220
+ const candidate: JxElement = { ...parent, children: rebuilt };
231
221
  if (countLeaves(candidate).join(" ") !== before.join(" ")) {
232
222
  continue;
233
223
  }
234
224
 
235
- parent.children = rebuilt as JxElement[];
225
+ parent.children = rebuilt;
236
226
  claimedPanels.add(panel);
237
227
  result.converted += 1;
238
228
  applied = true;