@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/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
+ }