@jxsuite/import 0.34.0

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 ADDED
@@ -0,0 +1,58 @@
1
+ # @jxsuite/import
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.
7
+
8
+ ## CLI
9
+
10
+ ```sh
11
+ jx-import <url> [options]
12
+
13
+ --out <dir> Output directory (default: ~/jx-imports/<hostname>)
14
+ --depth <n> Max crawl depth (default: 2, 0 = single page)
15
+ --max-pages <n> Max pages to capture (default: 25)
16
+ --max-nodes-per-page <n> Skip styles/assets for pages above this (default: 5000)
17
+ --no-styles Skip CSS capture
18
+ --no-assets Skip asset download
19
+ --no-crawl Single page only (equivalent to --depth 0)
20
+ --no-scroll Skip scroll-to-bottom (faster, may miss lazy content)
21
+ --no-robots Ignore robots.txt
22
+ --no-components Skip component extraction
23
+ --min-instances <n> Min recurring instances to extract a component (default: 2)
24
+ --min-depth <n> Min subtree depth to consider for componentization (default: 2)
25
+ --ai-components Use an LLM to refine component names and props
26
+ --ai-model <model> Model for AI componentization (default: gpt-4o-mini)
27
+ --verify After import, build and screenshot-diff vs the original
28
+ --verify-threshold <n> Pixel diff threshold 0..1 (default: 0.15)
29
+ ```
30
+
31
+ Environment: `CHROME_PATH` (explicit browser binary; otherwise `google-chrome-stable`,
32
+ `google-chrome`, `chromium-browser`, or `chromium` is discovered on PATH), `OPENAI_API_KEY` and
33
+ `OPENAI_BASE_URL` for `--ai-components`.
34
+
35
+ ## Programmatic API
36
+
37
+ The orchestrator behind the CLI and the Studio "Import" tab:
38
+
39
+ ```ts
40
+ import { importSite } from "@jxsuite/import/run";
41
+
42
+ const result = await importSite(
43
+ {
44
+ url: "https://example.com",
45
+ outDir: "/abs/path/to/project", // must be empty or absent
46
+ maxDepth: 1,
47
+ ai: { apiKey, baseUrl, model }, // optional LLM naming pass
48
+ signal: abortController.signal,
49
+ },
50
+ (e) => console.log(`[${e.phase}] ${e.message}`),
51
+ );
52
+ ```
53
+
54
+ Lower-level building blocks (capture, style diffing, crawling, layout detection, componentization,
55
+ emit, verify) are exported from the package root — see `src/index.ts`.
56
+
57
+ Verification (`verify` option / `--verify`) additionally needs `@jxsuite/compiler` (an optional
58
+ dependency) to build the emitted project before screenshot-diffing it.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@jxsuite/import",
3
+ "version": "0.34.0",
4
+ "description": "Clone a live website into a Jx project",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/jxsuite/jx.git",
9
+ "directory": "packages/import"
10
+ },
11
+ "bin": {
12
+ "jx-import": "./src/cli.ts"
13
+ },
14
+ "files": [
15
+ "src/"
16
+ ],
17
+ "type": "module",
18
+ "exports": {
19
+ ".": "./src/index.ts",
20
+ "./capture": "./src/capture.ts",
21
+ "./to-jx": "./src/to-jx.ts",
22
+ "./emit": "./src/emit.ts",
23
+ "./run": "./src/run.ts"
24
+ },
25
+ "publishConfig": {
26
+ "provenance": false
27
+ },
28
+ "scripts": {
29
+ "test": "bun test --isolate",
30
+ "test:coverage": "bun test --isolate --coverage",
31
+ "import": "bun run src/cli.ts"
32
+ },
33
+ "dependencies": {
34
+ "@jxsuite/parser": "workspace:^",
35
+ "@jxsuite/schema": "workspace:^",
36
+ "pixelmatch": "^7.2.0",
37
+ "pngjs": "^7.0.0",
38
+ "puppeteer-core": "^24.9.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/pngjs": "^6.0.5",
42
+ "happy-dom": "^20.10.6"
43
+ },
44
+ "optionalDependencies": {
45
+ "@jxsuite/compiler": "workspace:^"
46
+ }
47
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * AI-componentize — LLM-assisted refinement of heuristically extracted components.
3
+ *
4
+ * Takes the output of the heuristic `componentize()` pass and asks an LLM to: 1. Choose semantic
5
+ * component names (e.g. `product-card` not `component-div-0`) 2. Rename props to meaningful names
6
+ * (e.g. `title` not `text`) 3. Identify children that should be slots rather than interpolated
7
+ * props
8
+ *
9
+ * Uses the OpenAI-compatible chat completions API (same as the eval harness).
10
+ */
11
+
12
+ import type { JxElement } from "@jxsuite/schema/types";
13
+ import type { ExtractedComponent, ComponentizeResult } from "./componentize.ts";
14
+
15
+ export interface AiComponentizeOptions {
16
+ apiKey: string;
17
+ baseUrl?: string | undefined;
18
+ model?: string | undefined;
19
+ }
20
+
21
+ interface RenameResult {
22
+ componentName: string;
23
+ tagName: string;
24
+ props: Record<string, string>;
25
+ }
26
+
27
+ function buildPrompt(component: ExtractedComponent): string {
28
+ const stateEntries = Object.entries(component.template.state ?? {});
29
+ const propNames = stateEntries.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join("\n");
30
+
31
+ const treePreview = JSON.stringify(component.template, null, 2).slice(0, 2000);
32
+
33
+ return `You are a frontend component naming expert. Given a UI component extracted from a website, suggest better names.
34
+
35
+ Current auto-generated component:
36
+ - tagName: "${component.tagName}" (e.g. "component-div-0")
37
+ - $id: "${component.$id}"
38
+ - instanceCount: ${component.instanceCount}
39
+ - Current props (state):
40
+ ${propNames || " (none)"}
41
+
42
+ Component tree (truncated):
43
+ ${treePreview}
44
+
45
+ Return a JSON object with:
46
+ 1. "componentName" — a semantic kebab-case name for this component (e.g. "product-card", "hero-banner", "nav-link"). Must contain a hyphen.
47
+ 2. "tagName" — same as componentName (web component convention)
48
+ 3. "props" — an object mapping each CURRENT prop name to a BETTER name. Use camelCase. Choose descriptive names based on context (e.g. "text" → "title" if it's in an h2, "src" → "imageUrl" if it's an img src). If the current name is already good, keep it unchanged.
49
+
50
+ Rules:
51
+ - componentName must be kebab-case with at least one hyphen
52
+ - prop renames must be valid JS identifiers (camelCase, no spaces/special chars)
53
+ - Only rename props that exist in the current state — don't add or remove props
54
+ - If you can't determine a better name, keep the original
55
+
56
+ Respond with ONLY the JSON object, no markdown fences or explanation.`;
57
+ }
58
+
59
+ async function callLlm(prompt: string, opts: AiComponentizeOptions): Promise<RenameResult | null> {
60
+ const baseUrl = opts.baseUrl || "https://api.openai.com/v1";
61
+ const model = opts.model || "gpt-4o-mini";
62
+
63
+ const response = await fetch(`${baseUrl}/chat/completions`, {
64
+ method: "POST",
65
+ headers: {
66
+ "Content-Type": "application/json",
67
+ Authorization: `Bearer ${opts.apiKey}`,
68
+ },
69
+ body: JSON.stringify({
70
+ model,
71
+ messages: [{ role: "user", content: prompt }],
72
+ response_format: { type: "json_object" },
73
+ }),
74
+ });
75
+
76
+ if (!response.ok) {
77
+ return null;
78
+ }
79
+
80
+ const data = (await response.json()) as {
81
+ choices?: { message?: { content?: string } }[];
82
+ };
83
+
84
+ const content = data.choices?.[0]?.message?.content;
85
+ if (!content) {
86
+ return null;
87
+ }
88
+
89
+ try {
90
+ const parsed = JSON.parse(content) as Record<string, unknown>;
91
+ const { componentName } = parsed;
92
+ const { tagName } = parsed;
93
+ const { props } = parsed;
94
+
95
+ if (
96
+ typeof componentName !== "string" ||
97
+ !componentName.includes("-") ||
98
+ typeof tagName !== "string" ||
99
+ typeof props !== "object" ||
100
+ props === null
101
+ ) {
102
+ return null;
103
+ }
104
+
105
+ return {
106
+ componentName: componentName as string,
107
+ tagName: tagName as string,
108
+ props: props as Record<string, string>,
109
+ };
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ function renamePropsInTree(
116
+ node: JxElement | string,
117
+ renames: Record<string, string>,
118
+ ): JxElement | string {
119
+ if (typeof node === "string") {
120
+ let result = node;
121
+ for (const [oldName, newName] of Object.entries(renames)) {
122
+ if (oldName !== newName) {
123
+ result = result.replaceAll(`\${state.${oldName}}`, `\${state.${newName}}`);
124
+ }
125
+ }
126
+ return result;
127
+ }
128
+
129
+ const out: JxElement = { ...node };
130
+
131
+ if (typeof out.textContent === "string") {
132
+ for (const [oldName, newName] of Object.entries(renames)) {
133
+ if (oldName !== newName) {
134
+ out.textContent = (out.textContent as string).replaceAll(
135
+ `\${state.${oldName}}`,
136
+ `\${state.${newName}}`,
137
+ );
138
+ }
139
+ }
140
+ }
141
+
142
+ if (out.attributes) {
143
+ out.attributes = { ...out.attributes };
144
+ for (const [attrKey, attrVal] of Object.entries(out.attributes)) {
145
+ if (typeof attrVal === "string") {
146
+ let v = attrVal;
147
+ for (const [oldName, newName] of Object.entries(renames)) {
148
+ if (oldName !== newName) {
149
+ v = v.replaceAll(`\${state.${oldName}}`, `\${state.${newName}}`);
150
+ }
151
+ }
152
+ out.attributes[attrKey] = v;
153
+ }
154
+ }
155
+ }
156
+
157
+ if (Array.isArray(out.children)) {
158
+ out.children = out.children.map((c) =>
159
+ renamePropsInTree(c as JxElement | string, renames),
160
+ ) as JxElement[];
161
+ }
162
+
163
+ return out;
164
+ }
165
+
166
+ function renamePropsInState(
167
+ state: Record<string, unknown>,
168
+ renames: Record<string, string>,
169
+ ): Record<string, unknown> {
170
+ const out: Record<string, unknown> = {};
171
+ for (const [k, v] of Object.entries(state)) {
172
+ const newKey = renames[k] ?? k;
173
+ out[newKey] = v;
174
+ }
175
+ return out;
176
+ }
177
+
178
+ function toPascalCase(str: string): string {
179
+ return str
180
+ .split(/[-_\s]+/)
181
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase())
182
+ .join("");
183
+ }
184
+
185
+ export async function aiComponentize(
186
+ heuristicResult: ComponentizeResult,
187
+ opts: AiComponentizeOptions,
188
+ onProgress?: (msg: string) => void,
189
+ ): Promise<ComponentizeResult> {
190
+ const log = onProgress ?? (() => {});
191
+ const { components, rewrittenPages } = heuristicResult;
192
+
193
+ if (components.size === 0) {
194
+ log("No components to refine");
195
+ return heuristicResult;
196
+ }
197
+
198
+ const newComponents = new Map<string, ExtractedComponent>();
199
+ const tagRenames = new Map<string, string>();
200
+ const propRenamesByTag = new Map<string, Record<string, string>>();
201
+ const usedNames = new Set<string>();
202
+
203
+ for (const [fileName, component] of components) {
204
+ log(` Asking LLM about ${component.tagName} (${component.instanceCount} instances)...`);
205
+
206
+ const prompt = buildPrompt(component);
207
+ const result = await callLlm(prompt, opts);
208
+
209
+ if (!result) {
210
+ log(` LLM failed — keeping heuristic name "${component.tagName}"`);
211
+ newComponents.set(fileName, component);
212
+ continue;
213
+ }
214
+
215
+ let newTagName = result.tagName.toLowerCase();
216
+ if (!newTagName.includes("-")) {
217
+ newTagName = `x-${newTagName}`;
218
+ }
219
+
220
+ if (usedNames.has(newTagName)) {
221
+ let suffix = 2;
222
+ while (usedNames.has(`${newTagName}-${suffix}`)) {
223
+ suffix += 1;
224
+ }
225
+ newTagName = `${newTagName}-${suffix}`;
226
+ }
227
+ usedNames.add(newTagName);
228
+
229
+ const propRenames: Record<string, string> = {};
230
+ const existingProps = Object.keys(component.template.state ?? {});
231
+ for (const prop of existingProps) {
232
+ const renamed = result.props[prop];
233
+ propRenames[prop] =
234
+ typeof renamed === "string" && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(renamed) ? renamed : prop;
235
+ }
236
+
237
+ const newTemplate = renamePropsInTree(component.template, propRenames) as JxElement;
238
+ if (newTemplate.state) {
239
+ newTemplate.state = renamePropsInState(
240
+ newTemplate.state as Record<string, unknown>,
241
+ propRenames,
242
+ ) as Record<string, string>;
243
+ }
244
+
245
+ const newComponent: ExtractedComponent = {
246
+ $id: toPascalCase(newTagName),
247
+ tagName: newTagName,
248
+ template: newTemplate,
249
+ instanceCount: component.instanceCount,
250
+ };
251
+
252
+ const newFileName = `${newTagName}.json`;
253
+ newComponents.set(newFileName, newComponent);
254
+ tagRenames.set(component.tagName, newTagName);
255
+ propRenamesByTag.set(component.tagName, propRenames);
256
+
257
+ log(` ${component.tagName} → ${newTagName}`);
258
+ const changedProps = Object.entries(propRenames)
259
+ .filter(([a, b]) => a !== b)
260
+ .map(([a, b]) => `${a}→${b}`);
261
+ if (changedProps.length > 0) {
262
+ log(` Props: ${changedProps.join(", ")}`);
263
+ }
264
+ }
265
+
266
+ const newPages = new Map<string, JxElement>();
267
+ for (const [route, page] of rewrittenPages) {
268
+ const updated = rewriteCallSites(page, tagRenames, propRenamesByTag);
269
+ newPages.set(route, updated);
270
+ }
271
+
272
+ return { components: newComponents, rewrittenPages: newPages };
273
+ }
274
+
275
+ function rewriteCallSites(
276
+ node: JxElement,
277
+ tagRenames: Map<string, string>,
278
+ propRenamesByTag: Map<string, Record<string, string>>,
279
+ ): JxElement {
280
+ const result: JxElement = { ...node };
281
+
282
+ if (result.tagName && tagRenames.has(result.tagName)) {
283
+ const oldTag = result.tagName;
284
+ result.tagName = tagRenames.get(oldTag)!;
285
+
286
+ const propRenames = propRenamesByTag.get(oldTag);
287
+ if (propRenames && result.$props) {
288
+ const newProps: NonNullable<typeof result.$props> = {};
289
+ for (const [k, v] of Object.entries(result.$props)) {
290
+ const newKey = propRenames[k] ?? k;
291
+ newProps[newKey] = v;
292
+ }
293
+ result.$props = newProps;
294
+ }
295
+ }
296
+
297
+ if (Array.isArray(result.children)) {
298
+ result.children = result.children.map((child) => {
299
+ if (typeof child === "string") {
300
+ return child;
301
+ }
302
+ return rewriteCallSites(child as JxElement, tagRenames, propRenamesByTag);
303
+ }) as JxElement[];
304
+ }
305
+
306
+ return result;
307
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Apply captured/diffed styles onto Jx tree nodes by matching tree paths. Walks the Jx tree in the
3
+ * same depth-first element-index order as the browser DOM walk in style-capture.ts.
4
+ */
5
+
6
+ import type { JxElement, JxStyle } from "@jxsuite/schema/types";
7
+ import type { DiffedStyle } from "./style-diff.ts";
8
+
9
+ /** Build a lookup from path key → style object for fast application. */
10
+ function buildStyleMap(diffed: DiffedStyle[]): Map<string, Record<string, string | number>> {
11
+ const map = new Map<string, Record<string, string | number>>();
12
+ for (const d of diffed) {
13
+ map.set(d.path.join(","), d.style);
14
+ }
15
+ return map;
16
+ }
17
+
18
+ /**
19
+ * Apply base styles and $media deltas to all nodes in the Jx tree.
20
+ *
21
+ * The tree is walked in the same depth-first element-child order as the browser capture, so path
22
+ * indices align. String children (text nodes) are skipped in indexing, matching the browser's
23
+ * Element.children behavior.
24
+ */
25
+ export function applyStylesToTree(
26
+ root: JxElement,
27
+ baseStyles: DiffedStyle[],
28
+ mediaDeltas?: Record<string, DiffedStyle[]>,
29
+ ): void {
30
+ const baseMap = buildStyleMap(baseStyles);
31
+ const mediaMaps: Record<string, Map<string, Record<string, string | number>>> = {};
32
+ if (mediaDeltas) {
33
+ for (const [bpName, deltas] of Object.entries(mediaDeltas)) {
34
+ mediaMaps[bpName] = buildStyleMap(deltas);
35
+ }
36
+ }
37
+
38
+ // The root is a wrapper div (from to-jx.ts convertToJx), its children
39
+ // Correspond to body's direct children. Walk those children.
40
+ if (!root.children || !Array.isArray(root.children)) {
41
+ return;
42
+ }
43
+
44
+ let childIdx = 0;
45
+ for (const child of root.children) {
46
+ if (typeof child === "string") {
47
+ continue;
48
+ }
49
+ walkAndApply(child, [childIdx], baseMap, mediaMaps);
50
+ childIdx += 1;
51
+ }
52
+ }
53
+
54
+ function walkAndApply(
55
+ node: JxElement,
56
+ path: number[],
57
+ baseMap: Map<string, Record<string, string | number>>,
58
+ mediaMaps: Record<string, Map<string, Record<string, string | number>>>,
59
+ ): void {
60
+ const key = path.join(",");
61
+
62
+ // Apply base styles
63
+ const baseStyle = baseMap.get(key);
64
+ if (baseStyle) {
65
+ node.style = mergeStyle(node.style, baseStyle);
66
+ }
67
+
68
+ // Apply media-responsive deltas as @breakpointName nested objects
69
+ for (const [bpName, deltaMap] of Object.entries(mediaMaps)) {
70
+ const delta = deltaMap.get(key);
71
+ if (delta && Object.keys(delta).length > 0) {
72
+ if (!node.style) {
73
+ node.style = {};
74
+ }
75
+ (node.style as Record<string, unknown>)[`@${bpName}`] = delta;
76
+ }
77
+ }
78
+
79
+ // Recurse into element children (skip text nodes for indexing)
80
+ if (!node.children || !Array.isArray(node.children)) {
81
+ return;
82
+ }
83
+
84
+ let childIdx = 0;
85
+ for (const child of node.children) {
86
+ if (typeof child === "string") {
87
+ continue;
88
+ }
89
+ walkAndApply(child, [...path, childIdx], baseMap, mediaMaps);
90
+ childIdx += 1;
91
+ }
92
+ }
93
+
94
+ function mergeStyle(
95
+ existing: JxStyle | undefined,
96
+ incoming: Record<string, string | number>,
97
+ ): JxStyle {
98
+ const merged: JxStyle = existing ? { ...existing } : {};
99
+ for (const [prop, val] of Object.entries(incoming)) {
100
+ merged[prop] = val;
101
+ }
102
+ return merged;
103
+ }