@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 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.0",
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.8",
37
- "@jxsuite/schema": "^2.0.0",
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": "^3.0.0"
49
+ "@jxsuite/compiler": "^4.0.0"
48
50
  }
49
51
  }
@@ -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
- model?: string | undefined;
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.model || "gpt-4o-mini";
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
+ }