@jxsuite/import 0.40.0 → 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 +8 -30
- package/package.json +6 -4
- package/src/ai-componentize.ts +12 -2
- package/src/apply-accordions.ts +304 -0
- package/src/apply-disclosures.ts +275 -0
- package/src/apply-popovers.ts +298 -0
- package/src/apply-styles.ts +60 -11
- package/src/asset-collect.ts +2 -2
- package/src/asset-download.ts +10 -11
- package/src/asset-rewrite.ts +30 -6
- package/src/browser-local.ts +67 -0
- package/src/capture.ts +50 -61
- package/src/componentize.ts +35 -1
- package/src/crawl.ts +23 -17
- package/src/derived-geometry.ts +115 -0
- package/src/emit.ts +59 -34
- package/src/image-family.ts +227 -0
- package/src/index.ts +11 -5
- package/src/io.ts +52 -0
- package/src/media-extract.ts +75 -6
- package/src/pipeline.ts +673 -0
- package/src/run.ts +93 -503
- package/src/style-capture.ts +201 -162
- package/src/style-diff.ts +17 -4
- package/src/to-jx.ts +27 -1
- package/src/toggle-algebra.ts +167 -0
- package/src/verify.ts +1 -1
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.
|
|
3
|
+
"version": "0.40.2",
|
|
4
4
|
"description": "Clone a live website into a Jx project",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
"./capture": "./src/capture.ts",
|
|
21
21
|
"./to-jx": "./src/to-jx.ts",
|
|
22
22
|
"./emit": "./src/emit.ts",
|
|
23
|
+
"./io": "./src/io.ts",
|
|
24
|
+
"./pipeline": "./src/pipeline.ts",
|
|
23
25
|
"./run": "./src/run.ts",
|
|
24
26
|
"./verify": "./src/verify.ts",
|
|
25
27
|
"./breakpoint-plan": "./src/breakpoint-plan.ts"
|
|
@@ -33,8 +35,8 @@
|
|
|
33
35
|
"import": "bun run src/cli.ts"
|
|
34
36
|
},
|
|
35
37
|
"dependencies": {
|
|
36
|
-
"@jxsuite/markup": "^0.4.
|
|
37
|
-
"@jxsuite/schema": "^2.
|
|
38
|
+
"@jxsuite/markup": "^0.4.10",
|
|
39
|
+
"@jxsuite/schema": "^2.2.0",
|
|
38
40
|
"pixelmatch": "^7.2.0",
|
|
39
41
|
"pngjs": "^7.0.0",
|
|
40
42
|
"puppeteer-core": "^24.9.0"
|
|
@@ -44,6 +46,6 @@
|
|
|
44
46
|
"happy-dom": "^20.10.6"
|
|
45
47
|
},
|
|
46
48
|
"optionalDependencies": {
|
|
47
|
-
"@jxsuite/compiler": "^
|
|
49
|
+
"@jxsuite/compiler": "^4.0.0"
|
|
48
50
|
}
|
|
49
51
|
}
|
package/src/ai-componentize.ts
CHANGED
|
@@ -15,7 +15,17 @@ import type { ExtractedComponent, ComponentizeResult } from "./componentize.ts";
|
|
|
15
15
|
export interface AiComponentizeOptions {
|
|
16
16
|
apiKey: string;
|
|
17
17
|
baseUrl?: string | undefined;
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The model id, and it has no default on purpose.
|
|
20
|
+
*
|
|
21
|
+
* `"gpt-4o-mini"` used to be one, which was harmless while the only caller was the OSS CLI
|
|
22
|
+
* pointing at OpenAI and wrong the moment a backend brokered anything else: Workers AI answers a
|
|
23
|
+
* model it does not know with a 404, `callLlm` reads that as "the LLM failed", and every
|
|
24
|
+
* component silently keeps its heuristic `component-div-0` name. A required id makes the caller
|
|
25
|
+
* that knows which provider it is talking to say so. The CLI and the OSS server still default to
|
|
26
|
+
* `gpt-4o-mini` — at their own call site, in `run.ts`.
|
|
27
|
+
*/
|
|
28
|
+
model: string;
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
interface RenameResult {
|
|
@@ -58,7 +68,7 @@ Respond with ONLY the JSON object, no markdown fences or explanation.`;
|
|
|
58
68
|
|
|
59
69
|
async function callLlm(prompt: string, opts: AiComponentizeOptions): Promise<RenameResult | null> {
|
|
60
70
|
const baseUrl = opts.baseUrl || "https://api.openai.com/v1";
|
|
61
|
-
const model = opts
|
|
71
|
+
const { model } = opts;
|
|
62
72
|
|
|
63
73
|
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
64
74
|
method: "POST",
|
|
@@ -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
|
+
}
|