@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 +58 -0
- package/package.json +47 -0
- package/src/ai-componentize.ts +307 -0
- package/src/apply-styles.ts +103 -0
- package/src/asset-collect.ts +254 -0
- package/src/asset-download.ts +201 -0
- package/src/asset-rewrite.ts +143 -0
- package/src/capture.ts +159 -0
- package/src/cli.ts +153 -0
- package/src/componentize.ts +348 -0
- package/src/crawl.ts +391 -0
- package/src/css-tokens.ts +156 -0
- package/src/emit.ts +231 -0
- package/src/index.ts +53 -0
- package/src/layout-detect.ts +151 -0
- package/src/media-extract.ts +121 -0
- package/src/run.ts +528 -0
- package/src/screenshot-diff.ts +92 -0
- package/src/style-capture.ts +386 -0
- package/src/style-diff.ts +183 -0
- package/src/to-jx.ts +89 -0
- package/src/verify.ts +311 -0
package/src/capture.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { launch } from "puppeteer-core";
|
|
2
|
+
import type { Browser, Page } from "puppeteer-core";
|
|
3
|
+
|
|
4
|
+
export interface CaptureResult {
|
|
5
|
+
url: string;
|
|
6
|
+
title: string;
|
|
7
|
+
bodyHtml: string;
|
|
8
|
+
/** Discovered same-origin links for the crawler (Phase 3). */
|
|
9
|
+
links: string[];
|
|
10
|
+
/** The puppeteer Page, kept open for style capture (Phase 1). Caller must close. */
|
|
11
|
+
page: Page;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_CHROME_PATHS = [
|
|
15
|
+
"google-chrome-stable",
|
|
16
|
+
"google-chrome",
|
|
17
|
+
"chromium-browser",
|
|
18
|
+
"chromium",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function findChrome(executablePath?: string): string {
|
|
22
|
+
if (executablePath) {
|
|
23
|
+
return executablePath;
|
|
24
|
+
}
|
|
25
|
+
const env = process.env.CHROME_PATH;
|
|
26
|
+
if (env) {
|
|
27
|
+
return env;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const name of DEFAULT_CHROME_PATHS) {
|
|
31
|
+
const path = Bun.which(name);
|
|
32
|
+
if (path) {
|
|
33
|
+
return path;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
throw new Error(
|
|
37
|
+
"Could not find Chrome/Chromium. Set CHROME_PATH or install google-chrome-stable.",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let _browser: Browser | null = null;
|
|
42
|
+
|
|
43
|
+
export interface LaunchOptions {
|
|
44
|
+
/** Explicit browser binary. Wins over CHROME_PATH and PATH discovery. */
|
|
45
|
+
executablePath?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function launchBrowser(options?: LaunchOptions): Promise<Browser> {
|
|
49
|
+
if (_browser?.connected) {
|
|
50
|
+
return _browser;
|
|
51
|
+
}
|
|
52
|
+
_browser = await launch({
|
|
53
|
+
executablePath: findChrome(options?.executablePath),
|
|
54
|
+
headless: true,
|
|
55
|
+
args: ["--no-sandbox", "--disable-gpu"],
|
|
56
|
+
});
|
|
57
|
+
return _browser;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function closeBrowser(): Promise<void> {
|
|
61
|
+
if (_browser?.connected) {
|
|
62
|
+
await _browser.close();
|
|
63
|
+
_browser = null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CaptureOptions {
|
|
68
|
+
/** Scroll to bottom before capture to trigger lazy-loaded content (default: true). */
|
|
69
|
+
scrollToBottom?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Scroll the page to the bottom in steps to trigger lazy-loaded images and intersection-observer
|
|
74
|
+
* content, then scroll back to top. Settles between steps to let content render.
|
|
75
|
+
*/
|
|
76
|
+
async function scrollToRevealAll(page: Page): Promise<void> {
|
|
77
|
+
await page.evaluate(async () => {
|
|
78
|
+
const delay = (ms: number) =>
|
|
79
|
+
new Promise<void>((r) => {
|
|
80
|
+
setTimeout(r, ms);
|
|
81
|
+
});
|
|
82
|
+
const scrollHeight = () => document.body.scrollHeight;
|
|
83
|
+
const viewportHeight = window.innerHeight;
|
|
84
|
+
let lastHeight = 0;
|
|
85
|
+
let currentPosition = 0;
|
|
86
|
+
|
|
87
|
+
// Scroll down in viewport-sized steps
|
|
88
|
+
while (currentPosition < scrollHeight()) {
|
|
89
|
+
currentPosition += viewportHeight;
|
|
90
|
+
window.scrollTo(0, currentPosition);
|
|
91
|
+
await delay(100);
|
|
92
|
+
|
|
93
|
+
// If the page grew (infinite scroll), keep going but cap at 20 iterations
|
|
94
|
+
if (scrollHeight() > lastHeight) {
|
|
95
|
+
lastHeight = scrollHeight();
|
|
96
|
+
}
|
|
97
|
+
if (currentPosition > viewportHeight * 20) {
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Settle for lazy images that load on scroll
|
|
103
|
+
await delay(300);
|
|
104
|
+
|
|
105
|
+
// Scroll back to top for the reference screenshot
|
|
106
|
+
window.scrollTo(0, 0);
|
|
107
|
+
await delay(100);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Capture a page's DOM. Returns the page object still open — the caller is responsible for closing
|
|
113
|
+
* it (after style capture in Phase 1, or immediately).
|
|
114
|
+
*/
|
|
115
|
+
export async function capturePage(
|
|
116
|
+
url: string,
|
|
117
|
+
browser?: Browser,
|
|
118
|
+
options?: CaptureOptions,
|
|
119
|
+
): Promise<CaptureResult> {
|
|
120
|
+
const { scrollToBottom = true } = options ?? {};
|
|
121
|
+
const br = browser ?? (await launchBrowser());
|
|
122
|
+
const page: Page = await br.newPage();
|
|
123
|
+
await page.setViewport({ width: 1440, height: 900 });
|
|
124
|
+
|
|
125
|
+
await page.goto(url, { waitUntil: "networkidle0", timeout: 30_000 });
|
|
126
|
+
|
|
127
|
+
// R1: Scroll to bottom to trigger lazy-loaded content before capture
|
|
128
|
+
if (scrollToBottom) {
|
|
129
|
+
await scrollToRevealAll(page);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const result = await page.evaluate(() => {
|
|
133
|
+
// Strip scripts and noscript before capture
|
|
134
|
+
for (const el of document.querySelectorAll("script, noscript")) {
|
|
135
|
+
el.remove();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const links: string[] = [];
|
|
139
|
+
const { origin } = location;
|
|
140
|
+
for (const a of document.querySelectorAll("a[href]")) {
|
|
141
|
+
try {
|
|
142
|
+
const { href } = new URL((a as HTMLAnchorElement).href, location.href);
|
|
143
|
+
if (href.startsWith(origin) && !href.includes("#")) {
|
|
144
|
+
links.push(href);
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Skip invalid URLs
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
title: document.title,
|
|
153
|
+
bodyHtml: document.body.innerHTML,
|
|
154
|
+
links: [...new Set(links)],
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return { url, ...result, page };
|
|
159
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { importSite } from "./run.ts";
|
|
6
|
+
|
|
7
|
+
function usage(): never {
|
|
8
|
+
console.log(`Usage: jx-import <url> [options]
|
|
9
|
+
|
|
10
|
+
Clone a live website into a Jx project.
|
|
11
|
+
|
|
12
|
+
Options:
|
|
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 (Phase 4)
|
|
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 LLM to refine component names and props (Phase 4 AI pass)
|
|
26
|
+
--ai-model <model> Model for AI componentization (default: gpt-4o-mini)
|
|
27
|
+
--verify After import, build and screenshot-diff vs original (Phase 5)
|
|
28
|
+
--verify-threshold <n> Pixel diff threshold 0..1 (default: 0.15)
|
|
29
|
+
|
|
30
|
+
Environment:
|
|
31
|
+
OPENAI_API_KEY Required for --ai-components
|
|
32
|
+
OPENAI_BASE_URL Custom API base URL (default: https://api.openai.com/v1)
|
|
33
|
+
CHROME_PATH Explicit Chrome/Chromium binary
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
jx-import https://example.com
|
|
37
|
+
jx-import https://example.com --depth 1 --max-pages 10
|
|
38
|
+
jx-import https://example.com --no-crawl
|
|
39
|
+
jx-import https://example.com --out sites/my-clone --no-styles
|
|
40
|
+
jx-import https://example.com --verify
|
|
41
|
+
jx-import https://example.com --ai-components`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseIntArg(args: string[], flag: string, fallback: number): number {
|
|
46
|
+
const idx = args.indexOf(flag);
|
|
47
|
+
if (idx === -1 || !args[idx + 1]) {
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
const n = Math.trunc(Number(args[idx + 1]));
|
|
51
|
+
return Number.isNaN(n) ? fallback : n;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const args = process.argv.slice(2);
|
|
55
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
56
|
+
usage();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const [url] = args;
|
|
60
|
+
if (!url || (!url.startsWith("http://") && !url.startsWith("https://"))) {
|
|
61
|
+
console.error("Error: URL must start with http:// or https://");
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const noCrawl = args.includes("--no-crawl");
|
|
66
|
+
const noComponents = args.includes("--no-components");
|
|
67
|
+
const doAiComponents = args.includes("--ai-components");
|
|
68
|
+
const aiModelIdx = args.indexOf("--ai-model");
|
|
69
|
+
const aiModel = aiModelIdx !== -1 && args[aiModelIdx + 1] ? args[aiModelIdx + 1] : undefined;
|
|
70
|
+
const doVerify = args.includes("--verify");
|
|
71
|
+
const verifyThresholdIdx = args.indexOf("--verify-threshold");
|
|
72
|
+
const verifyThreshold =
|
|
73
|
+
verifyThresholdIdx !== -1 && args[verifyThresholdIdx + 1]
|
|
74
|
+
? Number(args[verifyThresholdIdx + 1]) || 0.15
|
|
75
|
+
: 0.15;
|
|
76
|
+
|
|
77
|
+
const maxDepth = noCrawl ? 0 : parseIntArg(args, "--depth", 2);
|
|
78
|
+
const maxPages = parseIntArg(args, "--max-pages", 25);
|
|
79
|
+
const maxNodesPerPage = parseIntArg(args, "--max-nodes-per-page", 5000);
|
|
80
|
+
const minInstances = parseIntArg(args, "--min-instances", 2);
|
|
81
|
+
const minDepth = parseIntArg(args, "--min-depth", 2);
|
|
82
|
+
|
|
83
|
+
let ai: false | { apiKey: string; baseUrl?: string | undefined; model?: string | undefined } =
|
|
84
|
+
false;
|
|
85
|
+
if (doAiComponents) {
|
|
86
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
87
|
+
if (!apiKey) {
|
|
88
|
+
console.error("Error: --ai-components requires OPENAI_API_KEY environment variable");
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
ai = { apiKey, baseUrl: process.env.OPENAI_BASE_URL, model: aiModel };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let outDir: string;
|
|
95
|
+
const outIdx = args.indexOf("--out");
|
|
96
|
+
const outArg = args[outIdx + 1];
|
|
97
|
+
if (outIdx !== -1 && outArg) {
|
|
98
|
+
outDir = resolve(outArg);
|
|
99
|
+
} else {
|
|
100
|
+
const hostname = new URL(url).hostname.replace(/^www\./, "");
|
|
101
|
+
outDir = resolve(homedir(), "jx-imports", hostname);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log(`Importing ${url} → ${outDir}`);
|
|
105
|
+
if (maxDepth > 0) {
|
|
106
|
+
console.log(` Crawl: depth=${maxDepth}, max-pages=${maxPages}, max-nodes=${maxNodesPerPage}\n`);
|
|
107
|
+
} else {
|
|
108
|
+
console.log(` Single page mode\n`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The interactive flow runs inside an async function rather than via top-level await so a test can
|
|
112
|
+
// Dynamic-import this entry and await the same sequence (see create/index.ts for the rationale).
|
|
113
|
+
async function main() {
|
|
114
|
+
try {
|
|
115
|
+
const result = await importSite(
|
|
116
|
+
{
|
|
117
|
+
url: url as string,
|
|
118
|
+
outDir,
|
|
119
|
+
maxDepth,
|
|
120
|
+
maxPages,
|
|
121
|
+
maxNodesPerPage,
|
|
122
|
+
styles: !args.includes("--no-styles"),
|
|
123
|
+
assets: !args.includes("--no-assets"),
|
|
124
|
+
scroll: !args.includes("--no-scroll"),
|
|
125
|
+
respectRobots: !args.includes("--no-robots"),
|
|
126
|
+
componentize: noComponents ? false : { minInstances, minDepth },
|
|
127
|
+
ai,
|
|
128
|
+
verify: doVerify ? { threshold: verifyThreshold } : false,
|
|
129
|
+
},
|
|
130
|
+
(e) => console.log(` ${e.message}`),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
console.log(`\n Imported ${result.pages.length} page${result.pages.length === 1 ? "" : "s"}:`);
|
|
134
|
+
for (const page of result.pages) {
|
|
135
|
+
console.log(` ${page.route} — "${page.title}" (${page.nodeCount} nodes)`);
|
|
136
|
+
}
|
|
137
|
+
if (result.verify) {
|
|
138
|
+
console.log(` Average fidelity: ${result.verify.averageFidelity}%`);
|
|
139
|
+
console.log(` Report: ${result.verify.reportDir}/report.json`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log(`\nDone! Open in Studio:`);
|
|
143
|
+
console.log(
|
|
144
|
+
` http://localhost:3000/packages/studio/index.html?project=${outDir}/project.json`,
|
|
145
|
+
);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error(`Error: ${error instanceof Error ? error.message : error}`);
|
|
148
|
+
process.exitCode = 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// oxlint-disable-next-line unicorn/prefer-top-level-await
|
|
153
|
+
export const ready = main();
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import type { JxElement } from "@jxsuite/schema/types";
|
|
2
|
+
|
|
3
|
+
export interface ComponentizeOptions {
|
|
4
|
+
minInstances?: number;
|
|
5
|
+
minDepth?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ExtractedComponent {
|
|
9
|
+
$id: string;
|
|
10
|
+
tagName: string;
|
|
11
|
+
template: JxElement;
|
|
12
|
+
instanceCount: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ComponentizeResult {
|
|
16
|
+
components: Map<string, ExtractedComponent>;
|
|
17
|
+
rewrittenPages: Map<string, JxElement>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SubtreeLocation {
|
|
21
|
+
pageRoute: string;
|
|
22
|
+
path: number[];
|
|
23
|
+
node: JxElement;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function nodeDepth(node: JxElement | string): number {
|
|
27
|
+
if (typeof node === "string") {
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
let max = 0;
|
|
31
|
+
if (Array.isArray(node.children)) {
|
|
32
|
+
for (const c of node.children) {
|
|
33
|
+
max = Math.max(max, nodeDepth(c as JxElement | string));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return 1 + max;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function structuralHash(node: JxElement | string): string {
|
|
40
|
+
if (typeof node === "string") {
|
|
41
|
+
return "#text";
|
|
42
|
+
}
|
|
43
|
+
const tag = node.tagName ?? "div";
|
|
44
|
+
const childHashes: string[] = [];
|
|
45
|
+
if (Array.isArray(node.children)) {
|
|
46
|
+
for (const child of node.children) {
|
|
47
|
+
childHashes.push(structuralHash(child as JxElement | string));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (node.textContent) {
|
|
51
|
+
childHashes.push("#text");
|
|
52
|
+
}
|
|
53
|
+
return `<${tag}>${childHashes.join(",")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function collectLeafValues(node: JxElement | string, prefix: string, out: Map<string, string>) {
|
|
57
|
+
if (typeof node === "string") {
|
|
58
|
+
out.set(`${prefix}.$text`, node);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (node.textContent) {
|
|
62
|
+
out.set(`${prefix}.textContent`, node.textContent as string);
|
|
63
|
+
}
|
|
64
|
+
if (node.attributes) {
|
|
65
|
+
for (const [k, v] of Object.entries(node.attributes)) {
|
|
66
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
|
|
67
|
+
out.set(`${prefix}.attr.${k}`, String(v));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(node.children)) {
|
|
72
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
73
|
+
collectLeafValues(node.children[i] as JxElement | string, `${prefix}.${i}`, out);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function leafPathToStateName(leafPath: string): string {
|
|
79
|
+
const parts = leafPath.split(".").slice(1);
|
|
80
|
+
const meaningful: string[] = [];
|
|
81
|
+
for (const p of parts) {
|
|
82
|
+
if (p === "$text" || p === "textContent") {
|
|
83
|
+
meaningful.push("text");
|
|
84
|
+
} else if (p === "attr") {
|
|
85
|
+
continue;
|
|
86
|
+
} else if (/^\d+$/.test(p)) {
|
|
87
|
+
continue;
|
|
88
|
+
} else {
|
|
89
|
+
meaningful.push(p);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (meaningful.length === 0) {
|
|
93
|
+
meaningful.push("value");
|
|
94
|
+
}
|
|
95
|
+
const raw = meaningful.join("_");
|
|
96
|
+
return raw.replaceAll(/[^a-zA-Z0-9_]/g, "");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function applyStateInterpolation(
|
|
100
|
+
node: JxElement | string,
|
|
101
|
+
prefix: string,
|
|
102
|
+
varyingPaths: Set<string>,
|
|
103
|
+
nameMap: Map<string, string>,
|
|
104
|
+
): JxElement | string {
|
|
105
|
+
if (typeof node === "string") {
|
|
106
|
+
const key = `${prefix}.$text`;
|
|
107
|
+
if (varyingPaths.has(key)) {
|
|
108
|
+
return `\${state.${nameMap.get(key)}}`;
|
|
109
|
+
}
|
|
110
|
+
return node;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const result: JxElement = { ...node };
|
|
114
|
+
|
|
115
|
+
if (node.textContent) {
|
|
116
|
+
const key = `${prefix}.textContent`;
|
|
117
|
+
if (varyingPaths.has(key)) {
|
|
118
|
+
result.textContent = `\${state.${nameMap.get(key)}}`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (node.attributes) {
|
|
123
|
+
result.attributes = { ...node.attributes };
|
|
124
|
+
for (const [k] of Object.entries(node.attributes)) {
|
|
125
|
+
const key = `${prefix}.attr.${k}`;
|
|
126
|
+
if (varyingPaths.has(key)) {
|
|
127
|
+
result.attributes[k] = `\${state.${nameMap.get(key)}}`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (Array.isArray(node.children)) {
|
|
133
|
+
result.children = node.children.map((child, i) =>
|
|
134
|
+
applyStateInterpolation(child as JxElement | string, `${prefix}.${i}`, varyingPaths, nameMap),
|
|
135
|
+
) as JxElement[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function toPascalCase(str: string): string {
|
|
142
|
+
return str
|
|
143
|
+
.split(/[-_\s]+/)
|
|
144
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase())
|
|
145
|
+
.join("");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function toKebabCase(base: string, index: number): string {
|
|
149
|
+
return `component-${base}-${index}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function setNodeAt(root: JxElement, path: number[], replacement: JxElement): JxElement {
|
|
153
|
+
if (path.length === 0) {
|
|
154
|
+
return replacement;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const result: JxElement = { ...root };
|
|
158
|
+
if (Array.isArray(root.children)) {
|
|
159
|
+
result.children = [...root.children] as JxElement[];
|
|
160
|
+
const head = path[0]!;
|
|
161
|
+
const rest = path.slice(1);
|
|
162
|
+
if (rest.length === 0) {
|
|
163
|
+
(result.children as JxElement[])[head] = replacement;
|
|
164
|
+
} else {
|
|
165
|
+
const child = result.children[head];
|
|
166
|
+
if (typeof child !== "string") {
|
|
167
|
+
(result.children as JxElement[])[head] = setNodeAt(child as JxElement, rest, replacement);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function collectSubtrees(
|
|
175
|
+
node: JxElement,
|
|
176
|
+
pageRoute: string,
|
|
177
|
+
path: number[],
|
|
178
|
+
minDepth: number,
|
|
179
|
+
out: SubtreeLocation[],
|
|
180
|
+
) {
|
|
181
|
+
// Skip root node (path.length === 0) — don't componentize entire pages
|
|
182
|
+
if (path.length > 0 && nodeDepth(node) >= minDepth) {
|
|
183
|
+
out.push({ pageRoute, path: [...path], node });
|
|
184
|
+
}
|
|
185
|
+
if (Array.isArray(node.children)) {
|
|
186
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
187
|
+
const child = node.children[i];
|
|
188
|
+
if (typeof child !== "string") {
|
|
189
|
+
collectSubtrees(child as JxElement, pageRoute, [...path, i], minDepth, out);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function componentize(
|
|
196
|
+
pages: Map<string, JxElement>,
|
|
197
|
+
options: ComponentizeOptions = {},
|
|
198
|
+
): ComponentizeResult {
|
|
199
|
+
const minInstances = options.minInstances ?? 2;
|
|
200
|
+
const minDepth = options.minDepth ?? 2;
|
|
201
|
+
|
|
202
|
+
const allSubtrees: SubtreeLocation[] = [];
|
|
203
|
+
for (const [route, doc] of pages) {
|
|
204
|
+
collectSubtrees(doc, route, [], minDepth, allSubtrees);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const byHash = new Map<string, SubtreeLocation[]>();
|
|
208
|
+
for (const loc of allSubtrees) {
|
|
209
|
+
const hash = structuralHash(loc.node);
|
|
210
|
+
if (!byHash.has(hash)) {
|
|
211
|
+
byHash.set(hash, []);
|
|
212
|
+
}
|
|
213
|
+
byHash.get(hash)!.push(loc);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const candidates = [...byHash.entries()]
|
|
217
|
+
.filter(([, locs]) => locs.length >= minInstances)
|
|
218
|
+
.toSorted((a, b) => {
|
|
219
|
+
const depthA = nodeDepth(a[1][0]!.node);
|
|
220
|
+
const depthB = nodeDepth(b[1][0]!.node);
|
|
221
|
+
if (depthB !== depthA) {
|
|
222
|
+
return depthB - depthA;
|
|
223
|
+
}
|
|
224
|
+
return b[1].length - a[1].length;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const components = new Map<string, ExtractedComponent>();
|
|
228
|
+
const claimed = new Set<string>();
|
|
229
|
+
const replacements: {
|
|
230
|
+
pageRoute: string;
|
|
231
|
+
path: number[];
|
|
232
|
+
tagName: string;
|
|
233
|
+
props: Record<string, string>;
|
|
234
|
+
}[] = [];
|
|
235
|
+
|
|
236
|
+
let compIndex = 0;
|
|
237
|
+
for (const [, locations] of candidates) {
|
|
238
|
+
const unclaimed = locations.filter((loc) => {
|
|
239
|
+
const locKey = `${loc.pageRoute}:${loc.path.join(".")}`;
|
|
240
|
+
for (const c of claimed) {
|
|
241
|
+
if (locKey.startsWith(c) || c.startsWith(locKey)) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (unclaimed.length < minInstances) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const allLeaves: Map<string, string>[] = unclaimed.map((loc) => {
|
|
253
|
+
const leaves = new Map<string, string>();
|
|
254
|
+
collectLeafValues(loc.node, "root", leaves);
|
|
255
|
+
return leaves;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const allKeys = new Set<string>();
|
|
259
|
+
for (const m of allLeaves) {
|
|
260
|
+
for (const k of m.keys()) {
|
|
261
|
+
allKeys.add(k);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const varyingPaths = new Set<string>();
|
|
266
|
+
for (const key of allKeys) {
|
|
267
|
+
const values = allLeaves.map((m) => m.get(key) ?? "");
|
|
268
|
+
if (new Set(values).size > 1) {
|
|
269
|
+
varyingPaths.add(key);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const nameMap = new Map<string, string>();
|
|
274
|
+
const usedNames = new Set<string>();
|
|
275
|
+
for (const vp of varyingPaths) {
|
|
276
|
+
let name = leafPathToStateName(vp);
|
|
277
|
+
if (usedNames.has(name)) {
|
|
278
|
+
let suffix = 2;
|
|
279
|
+
while (usedNames.has(`${name}${suffix}`)) {
|
|
280
|
+
suffix += 1;
|
|
281
|
+
}
|
|
282
|
+
name = `${name}${suffix}`;
|
|
283
|
+
}
|
|
284
|
+
usedNames.add(name);
|
|
285
|
+
nameMap.set(vp, name);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const baseTag = unclaimed[0]!.node.tagName ?? "div";
|
|
289
|
+
const kebabName = toKebabCase(baseTag, compIndex);
|
|
290
|
+
const pascalName = toPascalCase(kebabName);
|
|
291
|
+
compIndex += 1;
|
|
292
|
+
|
|
293
|
+
const state: Record<string, string> = {};
|
|
294
|
+
for (const [leafPath, stateName] of nameMap) {
|
|
295
|
+
state[stateName] = allLeaves[0]!.get(leafPath) ?? "";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const template = applyStateInterpolation(
|
|
299
|
+
unclaimed[0]!.node,
|
|
300
|
+
"root",
|
|
301
|
+
varyingPaths,
|
|
302
|
+
nameMap,
|
|
303
|
+
) as JxElement;
|
|
304
|
+
|
|
305
|
+
const component: ExtractedComponent = {
|
|
306
|
+
$id: pascalName,
|
|
307
|
+
tagName: kebabName,
|
|
308
|
+
template,
|
|
309
|
+
instanceCount: unclaimed.length,
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const fileName = `${kebabName}.json`;
|
|
313
|
+
components.set(fileName, component);
|
|
314
|
+
|
|
315
|
+
for (const loc of unclaimed) {
|
|
316
|
+
const locKey = `${loc.pageRoute}:${loc.path.join(".")}`;
|
|
317
|
+
claimed.add(locKey);
|
|
318
|
+
|
|
319
|
+
const props: Record<string, string> = {};
|
|
320
|
+
const leaves = new Map<string, string>();
|
|
321
|
+
collectLeafValues(loc.node, "root", leaves);
|
|
322
|
+
for (const [leafPath, stateName] of nameMap) {
|
|
323
|
+
props[stateName] = leaves.get(leafPath) ?? "";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
replacements.push({ pageRoute: loc.pageRoute, path: loc.path, tagName: kebabName, props });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const rewrittenPages = new Map<string, JxElement>();
|
|
331
|
+
for (const [route, doc] of pages) {
|
|
332
|
+
rewrittenPages.set(route, structuredClone(doc));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const sortedReplacements = replacements.toSorted((a, b) => b.path.length - a.path.length);
|
|
336
|
+
|
|
337
|
+
for (const rep of sortedReplacements) {
|
|
338
|
+
const page = rewrittenPages.get(rep.pageRoute)!;
|
|
339
|
+
const callSite: JxElement = { tagName: rep.tagName };
|
|
340
|
+
const hasNonDefaultProps = Object.keys(rep.props).length > 0;
|
|
341
|
+
if (hasNonDefaultProps) {
|
|
342
|
+
callSite.$props = rep.props;
|
|
343
|
+
}
|
|
344
|
+
rewrittenPages.set(rep.pageRoute, setNodeAt(page, rep.path, callSite));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { components, rewrittenPages };
|
|
348
|
+
}
|