@jxsuite/import 0.40.8 → 0.40.10
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 +6 -4
- package/src/ai-componentize.ts +49 -71
- package/src/apply-accordions.ts +32 -25
- package/src/apply-disclosures.ts +30 -40
- package/src/apply-popovers.ts +24 -29
- package/src/apply-styles.ts +6 -5
- package/src/asset-collect.ts +17 -20
- package/src/asset-download.ts +3 -3
- package/src/asset-rewrite.ts +28 -28
- package/src/breakpoint-plan.ts +7 -2
- package/src/capture.ts +11 -5
- package/src/cli.ts +4 -3
- package/src/componentize.ts +32 -37
- package/src/crawl.ts +2 -2
- package/src/css-tokens.ts +2 -1
- package/src/derived-geometry.ts +13 -14
- package/src/emit.ts +37 -22
- package/src/layout-detect.ts +10 -11
- package/src/pipeline.ts +1 -1
- package/src/run.ts +23 -19
- package/src/strip-classes.ts +6 -5
- package/src/style-capture.ts +5 -0
- package/src/style-diff.ts +1 -4
- package/src/to-jx.ts +13 -9
- package/src/toggle-algebra.ts +7 -4
- package/src/verify.ts +53 -40
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/import",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.10",
|
|
4
4
|
"description": "Clone a live website into a Jx project",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,16 +36,18 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@jxsuite/markup": "^0.4.12",
|
|
39
|
+
"@jxsuite/protocol": "^2.6.1",
|
|
39
40
|
"@jxsuite/schema": "^3.0.0",
|
|
40
|
-
"pixelmatch": "^
|
|
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",
|
|
45
47
|
"@types/pngjs": "^6.0.5",
|
|
46
|
-
"happy-dom": "^
|
|
48
|
+
"happy-dom": "^21.0.3"
|
|
47
49
|
},
|
|
48
50
|
"optionalDependencies": {
|
|
49
|
-
"@jxsuite/compiler": "^5.1.
|
|
51
|
+
"@jxsuite/compiler": "^5.1.3"
|
|
50
52
|
}
|
|
51
53
|
}
|
package/src/ai-componentize.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* Uses the OpenAI-compatible chat completions API (same as the eval harness).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
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
|
-
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const {
|
|
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
|
-
|
|
111
|
+
!isString(componentName) ||
|
|
107
112
|
!componentName.includes("-") ||
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
props === null
|
|
113
|
+
!isString(tagName) ||
|
|
114
|
+
!isNonNullObject(props)
|
|
111
115
|
) {
|
|
112
116
|
return null;
|
|
113
117
|
}
|
|
114
118
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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 (
|
|
156
|
-
|
|
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
|
-
|
|
170
|
-
)
|
|
157
|
+
isString(c) ? renameStateRefs(c, renames) : renamePropsInTree(c, renames),
|
|
158
|
+
);
|
|
171
159
|
}
|
|
172
160
|
|
|
173
161
|
return out;
|
|
174
162
|
}
|
|
175
163
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
renames
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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 (
|
|
290
|
+
if (isString(child)) {
|
|
313
291
|
return child;
|
|
314
292
|
}
|
|
315
|
-
return rewriteCallSites(child
|
|
316
|
-
})
|
|
293
|
+
return rewriteCallSites(child, tagRenames, propRenamesByTag);
|
|
294
|
+
});
|
|
317
295
|
}
|
|
318
296
|
|
|
319
297
|
return result;
|
package/src/apply-accordions.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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") &&
|
|
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) ?
|
|
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 (
|
|
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 (
|
|
88
|
+
if (isString(current.textContent) && current.textContent.trim().length > 0) {
|
|
88
89
|
leaves.push(`t:${current.textContent.trim()}`);
|
|
89
90
|
}
|
|
90
|
-
const source =
|
|
91
|
-
if (
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
149
|
-
if (role
|
|
150
|
-
return { node, role
|
|
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 (
|
|
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 (
|
|
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
|
|
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]
|
|
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: { ...
|
|
244
|
-
children: [summary, body, ...others]
|
|
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
|
|
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 (
|
|
303
|
+
if (!isString(child)) {
|
|
297
304
|
visit(child);
|
|
298
305
|
}
|
|
299
306
|
}
|
package/src/apply-disclosures.ts
CHANGED
|
@@ -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) ?
|
|
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)
|
|
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 =
|
|
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
|
|
59
|
+
const { style } = node;
|
|
65
60
|
if (POSITIONED.has(String(style?.["position"] ?? ""))) {
|
|
66
61
|
return false;
|
|
67
62
|
}
|
|
68
|
-
|
|
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 =
|
|
77
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
118
|
+
rebuilt.push(unwrapInvoker(child, invoker));
|
|
127
119
|
}
|
|
128
120
|
}
|
|
129
|
-
return { ...node, children: rebuilt
|
|
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
|
-
|
|
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
|
|
159
|
+
const invoker = findInvokerIn(branch);
|
|
167
160
|
if (!invoker) {
|
|
168
161
|
continue;
|
|
169
162
|
}
|
|
170
|
-
const panelId = String(
|
|
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 =
|
|
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 = { ...
|
|
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
|
|
202
|
-
const summaryAttributes = { ...
|
|
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
|
-
|
|
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 }]
|
|
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)
|
|
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
|
|
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
|
|
225
|
+
parent.children = rebuilt;
|
|
236
226
|
claimedPanels.add(panel);
|
|
237
227
|
result.converted += 1;
|
|
238
228
|
applied = true;
|