@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/crawl.ts ADDED
@@ -0,0 +1,391 @@
1
+ import { launchBrowser, capturePage } from "./capture.ts";
2
+ import type { CaptureResult } from "./capture.ts";
3
+ import { convertToJx } from "./to-jx.ts";
4
+ import type { ToJxResult } from "./to-jx.ts";
5
+ import { captureStyles } from "./style-capture.ts";
6
+ import { diffAllStyles, kebabToCamel } from "./style-diff.ts";
7
+ import { extractMedia } from "./media-extract.ts";
8
+ import { applyStylesToTree } from "./apply-styles.ts";
9
+ import { collectAssets } from "./asset-collect.ts";
10
+ import { downloadAssets } from "./asset-download.ts";
11
+ import { rewriteAssetUrls } from "./asset-rewrite.ts";
12
+ import { applyTokens } from "./css-tokens.ts";
13
+
14
+ export interface CrawlOptions {
15
+ url: string;
16
+ outDir: string;
17
+ maxDepth: number;
18
+ maxPages: number;
19
+ maxNodesPerPage: number;
20
+ skipStyles: boolean;
21
+ skipAssets: boolean;
22
+ respectRobots: boolean;
23
+ /** Skip scroll-to-bottom before capture (default: false). */
24
+ noScroll?: boolean;
25
+ /** Capture a reference screenshot of each page before closing (for --verify). */
26
+ captureScreenshots?: boolean;
27
+ onProgress?: (msg: string) => void;
28
+ /** Abort the crawl between pages; the loop throws before capturing the next page. */
29
+ signal?: AbortSignal;
30
+ }
31
+
32
+ export interface CrawledPage {
33
+ url: string;
34
+ route: string;
35
+ title: string;
36
+ jx: ToJxResult;
37
+ depth: number;
38
+ links: string[];
39
+ /** Reference screenshot PNG buffer, present when captureScreenshots is true. */
40
+ screenshot?: Buffer | undefined;
41
+ }
42
+
43
+ export interface CrawlResult {
44
+ pages: CrawledPage[];
45
+ breakpoints: Record<string, string> | undefined;
46
+ skippedByRobots: string[];
47
+ skippedByNodeCap: string[];
48
+ /** Collected @font-face rules from all pages (R2). */
49
+ fontFaceRules: string[];
50
+ /** Font URL → local path rewrite map (R2). */
51
+ fontRewriteMap: Map<string, string>;
52
+ /** CSS custom property tokens merged across all pages (R5). */
53
+ styleTokens: Record<string, string> | undefined;
54
+ }
55
+
56
+ /**
57
+ * Normalize a URL for deduplication: strip hash, trailing slash, tracking params, sort remaining
58
+ * params.
59
+ */
60
+ export function normalizeUrl(raw: string): string {
61
+ try {
62
+ const u = new URL(raw);
63
+ u.hash = "";
64
+
65
+ const TRACKING_PARAMS = new Set([
66
+ "utm_source",
67
+ "utm_medium",
68
+ "utm_campaign",
69
+ "utm_term",
70
+ "utm_content",
71
+ "ref",
72
+ "fbclid",
73
+ "gclid",
74
+ "mc_cid",
75
+ "mc_eid",
76
+ ]);
77
+ for (const p of TRACKING_PARAMS) {
78
+ u.searchParams.delete(p);
79
+ }
80
+
81
+ u.searchParams.sort();
82
+
83
+ let path = u.pathname;
84
+ if (path.length > 1 && path.endsWith("/")) {
85
+ path = path.slice(0, -1);
86
+ }
87
+ u.pathname = path;
88
+
89
+ return u.toString();
90
+ } catch {
91
+ return raw;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Convert a URL path to a Jx page file path under pages/. / → pages/index.json /about →
97
+ * pages/about.json /blog/post → pages/blog/post.json
98
+ */
99
+ export function routeToFilePath(url: string, _baseOrigin?: string): string {
100
+ const u = new URL(url);
101
+ let path = u.pathname;
102
+
103
+ if (path.length > 1 && path.endsWith("/")) {
104
+ path = path.slice(0, -1);
105
+ }
106
+
107
+ if (path === "" || path === "/") {
108
+ return "pages/index.json";
109
+ }
110
+
111
+ // Remove leading slash, sanitize segments
112
+ const segments = path
113
+ .slice(1)
114
+ .split("/")
115
+ .map((s) => s.replaceAll(/[^a-zA-Z0-9._-]/g, "_"));
116
+
117
+ // If the last segment has an extension (like .html), strip it
118
+ const last = segments.at(-1)!;
119
+ const dotIdx = last.lastIndexOf(".");
120
+ if (dotIdx > 0) {
121
+ segments[segments.length - 1] = last.slice(0, dotIdx);
122
+ }
123
+
124
+ return `pages/${segments.join("/")}.json`;
125
+ }
126
+
127
+ /** Fetch and parse robots.txt for a given origin. Returns a set of disallowed path prefixes. */
128
+ export async function fetchRobotsTxt(origin: string): Promise<Set<string>> {
129
+ const disallowed = new Set<string>();
130
+ try {
131
+ const res = await fetch(`${origin}/robots.txt`);
132
+ if (!res.ok) {
133
+ return disallowed;
134
+ }
135
+
136
+ const text = await res.text();
137
+ let inUserAgent = false;
138
+ for (const line of text.split("\n")) {
139
+ const trimmed = line.trim();
140
+ if (trimmed.startsWith("#") || trimmed === "") {
141
+ continue;
142
+ }
143
+
144
+ if (trimmed.toLowerCase().startsWith("user-agent:")) {
145
+ const agent = trimmed.slice("user-agent:".length).trim();
146
+ inUserAgent = agent === "*";
147
+ } else if (inUserAgent && trimmed.toLowerCase().startsWith("disallow:")) {
148
+ const path = trimmed.slice("disallow:".length).trim();
149
+ if (path) {
150
+ disallowed.add(path);
151
+ }
152
+ }
153
+ }
154
+ } catch {
155
+ // Robots.txt unavailable — allow everything
156
+ }
157
+ return disallowed;
158
+ }
159
+
160
+ function isDisallowed(url: string, disallowedPaths: Set<string>): boolean {
161
+ const path = new URL(url).pathname;
162
+ for (const prefix of disallowedPaths) {
163
+ if (path.startsWith(prefix)) {
164
+ return true;
165
+ }
166
+ }
167
+ return false;
168
+ }
169
+
170
+ /** BFS crawl a site starting from `url`. Captures each page, converts to Jx, applies styles/assets. */
171
+ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
172
+ const {
173
+ url,
174
+ outDir,
175
+ maxDepth,
176
+ maxPages,
177
+ maxNodesPerPage,
178
+ skipStyles,
179
+ skipAssets,
180
+ respectRobots,
181
+ onProgress = console.log,
182
+ } = options;
183
+
184
+ const { origin } = new URL(url);
185
+ const browser = await launchBrowser();
186
+
187
+ // Fetch robots.txt
188
+ let disallowedPaths = new Set<string>();
189
+ if (respectRobots) {
190
+ onProgress(" Fetching robots.txt...");
191
+ disallowedPaths = await fetchRobotsTxt(origin);
192
+ if (disallowedPaths.size > 0) {
193
+ onProgress(` robots.txt: ${disallowedPaths.size} disallowed paths`);
194
+ }
195
+ }
196
+
197
+ const visited = new Set<string>();
198
+ const queue: { url: string; depth: number }[] = [{ url: normalizeUrl(url), depth: 0 }];
199
+ visited.add(normalizeUrl(url));
200
+
201
+ const pages: CrawledPage[] = [];
202
+ const skippedByRobots: string[] = [];
203
+ const skippedByNodeCap: string[] = [];
204
+ let mergedBreakpoints: Record<string, string> | undefined;
205
+ let mergedTokens: Record<string, string> | undefined;
206
+ const allFontFaceRules: string[] = [];
207
+ const fontRewriteMap = new Map<string, string>();
208
+ const seenFontRules = new Set<string>();
209
+
210
+ while (queue.length > 0 && pages.length < maxPages) {
211
+ if (options.signal?.aborted) {
212
+ throw new Error("Import aborted");
213
+ }
214
+ const entry = queue.shift()!;
215
+
216
+ if (respectRobots && isDisallowed(entry.url, disallowedPaths)) {
217
+ skippedByRobots.push(entry.url);
218
+ onProgress(` Skipped (robots.txt): ${entry.url}`);
219
+ continue;
220
+ }
221
+
222
+ onProgress(
223
+ ` [${pages.length + 1}/${maxPages}] Capturing ${entry.url} (depth ${entry.depth})...`,
224
+ );
225
+
226
+ let capture: CaptureResult;
227
+ try {
228
+ capture = await capturePage(entry.url, browser, { scrollToBottom: !options.noScroll });
229
+ } catch (error) {
230
+ onProgress(
231
+ ` ⚠ Failed to capture ${entry.url}: ${error instanceof Error ? error.message : error}`,
232
+ );
233
+ continue;
234
+ }
235
+
236
+ const jx = convertToJx(capture.bodyHtml);
237
+ onProgress(` Converted: ${jx.nodeCount} nodes`);
238
+
239
+ if (jx.nodeCount > maxNodesPerPage) {
240
+ onProgress(
241
+ ` ⚠ Page exceeds node cap (${jx.nodeCount} > ${maxNodesPerPage}), skipping styles/assets`,
242
+ );
243
+ skippedByNodeCap.push(entry.url);
244
+ let screenshot: Buffer | undefined;
245
+ if (options.captureScreenshots) {
246
+ screenshot = Buffer.from(await capture.page.screenshot({ type: "png" }));
247
+ }
248
+ await capture.page.close();
249
+
250
+ pages.push({
251
+ url: entry.url,
252
+ route: routeToFilePath(entry.url, origin),
253
+ title: capture.title,
254
+ jx,
255
+ depth: entry.depth,
256
+ links: capture.links,
257
+ screenshot,
258
+ });
259
+ continue;
260
+ }
261
+
262
+ // Style capture (Phase 1)
263
+ if (!skipStyles) {
264
+ try {
265
+ const styleResult = await captureStyles(capture.page);
266
+ const diffed = diffAllStyles(styleResult.elements, styleResult.uaDefaults);
267
+
268
+ // R5: Replace resolved values with var(--name) references
269
+ if (Object.keys(styleResult.customProperties).length > 0) {
270
+ const tokenResult = applyTokens(diffed, styleResult.customProperties);
271
+ if (tokenResult.replacements > 0) {
272
+ if (!mergedTokens) {
273
+ mergedTokens = {};
274
+ }
275
+ Object.assign(mergedTokens, tokenResult.tokens);
276
+ }
277
+ }
278
+
279
+ if (styleResult.mediaQueries.length > 0) {
280
+ const media = await extractMedia(
281
+ capture.page,
282
+ styleResult.elements,
283
+ styleResult.uaDefaults,
284
+ styleResult.mediaQueries,
285
+ );
286
+ const bpCount = Object.keys(media.breakpoints).length;
287
+ if (bpCount > 0) {
288
+ if (!mergedBreakpoints) {
289
+ mergedBreakpoints = {};
290
+ }
291
+ Object.assign(mergedBreakpoints, media.breakpoints);
292
+ applyStylesToTree(jx.document, diffed, media.deltas);
293
+ } else {
294
+ applyStylesToTree(jx.document, diffed);
295
+ }
296
+ } else {
297
+ applyStylesToTree(jx.document, diffed);
298
+ }
299
+
300
+ // Apply <html>/<body> computed styles to the root wrapper
301
+ if (Object.keys(styleResult.documentStyles).length > 0) {
302
+ if (!jx.document.style) {
303
+ jx.document.style = {};
304
+ }
305
+ for (const [prop, val] of Object.entries(styleResult.documentStyles)) {
306
+ jx.document.style[kebabToCamel(prop)] = val;
307
+ }
308
+ }
309
+ } catch (error) {
310
+ onProgress(` ⚠ Style capture failed: ${error instanceof Error ? error.message : error}`);
311
+ }
312
+ }
313
+
314
+ // Asset download (Phase 2)
315
+ if (!skipAssets) {
316
+ try {
317
+ const collected = await collectAssets(capture.page);
318
+
319
+ // Accumulate @font-face rules (R2) — dedupe across pages
320
+ for (const sheet of collected.stylesheets) {
321
+ for (const rule of sheet.fontFaceRules) {
322
+ if (!seenFontRules.has(rule)) {
323
+ seenFontRules.add(rule);
324
+ allFontFaceRules.push(rule);
325
+ }
326
+ }
327
+ }
328
+
329
+ if (collected.assets.length > 0) {
330
+ const downloaded = await downloadAssets(collected.assets, outDir, entry.url);
331
+ if (downloaded.rewriteMap.size > 0) {
332
+ rewriteAssetUrls(jx.document, downloaded.rewriteMap, entry.url);
333
+ // Collect font rewrites (R2)
334
+ for (const [originalUrl, localPath] of downloaded.rewriteMap) {
335
+ if (localPath.includes("/fonts/") || /\.(woff2?|ttf|otf|eot)$/i.test(localPath)) {
336
+ fontRewriteMap.set(originalUrl, localPath);
337
+ }
338
+ }
339
+ }
340
+ }
341
+ } catch (error) {
342
+ onProgress(
343
+ ` ⚠ Asset collection failed: ${error instanceof Error ? error.message : error}`,
344
+ );
345
+ }
346
+ }
347
+
348
+ let screenshot: Buffer | undefined;
349
+ if (options.captureScreenshots) {
350
+ screenshot = Buffer.from(await capture.page.screenshot({ type: "png" }));
351
+ }
352
+ await capture.page.close();
353
+
354
+ const route = routeToFilePath(entry.url, origin);
355
+ pages.push({
356
+ url: entry.url,
357
+ route,
358
+ title: capture.title,
359
+ jx,
360
+ depth: entry.depth,
361
+ links: capture.links,
362
+ screenshot,
363
+ });
364
+
365
+ // Enqueue discovered links (BFS)
366
+ if (entry.depth < maxDepth) {
367
+ for (const link of capture.links) {
368
+ const normalized = normalizeUrl(link);
369
+ if (!normalized.startsWith(origin)) {
370
+ continue;
371
+ }
372
+ if (visited.has(normalized)) {
373
+ continue;
374
+ }
375
+
376
+ visited.add(normalized);
377
+ queue.push({ url: normalized, depth: entry.depth + 1 });
378
+ }
379
+ }
380
+ }
381
+
382
+ return {
383
+ pages,
384
+ breakpoints: mergedBreakpoints,
385
+ skippedByRobots,
386
+ skippedByNodeCap,
387
+ fontFaceRules: allFontFaceRules,
388
+ fontRewriteMap,
389
+ styleTokens: mergedTokens,
390
+ };
391
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Extract CSS custom properties from retained stylesheets and replace resolved computed values with
3
+ * var(--name) references. Hoists token declarations for project.json.$style.
4
+ */
5
+
6
+ import type { DiffedStyle } from "./style-diff.ts";
7
+
8
+ export interface TokenExtractionResult {
9
+ /**
10
+ * Custom property declarations to hoist into project.json.$style (e.g. { "--brand-blue":
11
+ * "#3b82f6" }).
12
+ */
13
+ tokens: Record<string, string>;
14
+ /** Number of value replacements made across all diffed styles. */
15
+ replacements: number;
16
+ }
17
+
18
+ /**
19
+ * Normalize a CSS color value for comparison. Handles common variations: - rgb(r, g, b) vs rgb(r g
20
+ * b) - Trailing semicolons/whitespace
21
+ */
22
+ function normalizeValue(val: string): string {
23
+ return val
24
+ .trim()
25
+ .replace(/\s*;\s*$/, "")
26
+ .replaceAll(/,\s*/g, ", ")
27
+ .replaceAll(/\s+/g, " ");
28
+ }
29
+
30
+ /**
31
+ * Build a reverse map: resolved CSS value → var(--name). When multiple custom properties resolve to
32
+ * the same value, prefer shorter names (heuristic: shorter names are more likely to be semantic
33
+ * tokens).
34
+ */
35
+ function buildValueToVarMap(props: Map<string, string>): Map<string, string> {
36
+ const valueToVar = new Map<string, string>();
37
+
38
+ // Sort by name length ascending so shorter (more semantic) names win ties
39
+ const sorted = [...props.entries()].toSorted((a, b) => a[0].length - b[0].length);
40
+
41
+ for (const [name, value] of sorted) {
42
+ const normalized = normalizeValue(value);
43
+ if (!valueToVar.has(normalized)) {
44
+ valueToVar.set(normalized, `var(${name})`);
45
+ }
46
+ }
47
+
48
+ return valueToVar;
49
+ }
50
+
51
+ const TOKEN_ELIGIBLE_PROPS = new Set([
52
+ "color",
53
+ "backgroundColor",
54
+ "borderColor",
55
+ "borderTopColor",
56
+ "borderRightColor",
57
+ "borderBottomColor",
58
+ "borderLeftColor",
59
+ "outlineColor",
60
+ "textDecorationColor",
61
+ "fill",
62
+ "stroke",
63
+ "caretColor",
64
+ "columnRuleColor",
65
+ "boxShadow",
66
+ "textShadow",
67
+ "background",
68
+ "backgroundImage",
69
+ "borderTop",
70
+ "borderRight",
71
+ "borderBottom",
72
+ "borderLeft",
73
+ "border",
74
+ "fontFamily",
75
+ "fontSize",
76
+ "fontWeight",
77
+ "lineHeight",
78
+ "letterSpacing",
79
+ "gap",
80
+ "rowGap",
81
+ "columnGap",
82
+ "borderRadius",
83
+ "borderTopLeftRadius",
84
+ "borderTopRightRadius",
85
+ "borderBottomLeftRadius",
86
+ "borderBottomRightRadius",
87
+ "padding",
88
+ "paddingTop",
89
+ "paddingRight",
90
+ "paddingBottom",
91
+ "paddingLeft",
92
+ "margin",
93
+ "marginTop",
94
+ "marginRight",
95
+ "marginBottom",
96
+ "marginLeft",
97
+ "maxWidth",
98
+ "width",
99
+ ]);
100
+
101
+ /**
102
+ * Replace resolved computed values with var(--name) references in diffed styles. Also collects the
103
+ * set of custom properties actually used (for hoisting into project.json).
104
+ *
105
+ * Mutates the diffed styles in place for efficiency.
106
+ *
107
+ * @param customProperties - Map of CSS custom property name → resolved value, as extracted by
108
+ * captureStyles() in-browser.
109
+ */
110
+ export function applyTokens(
111
+ diffedStyles: DiffedStyle[],
112
+ customProperties: Record<string, string>,
113
+ ): TokenExtractionResult {
114
+ const props = new Map(Object.entries(customProperties));
115
+ if (props.size === 0) {
116
+ return { tokens: {}, replacements: 0 };
117
+ }
118
+
119
+ const valueToVar = buildValueToVarMap(props);
120
+ const usedTokens = new Map<string, string>();
121
+ let replacements = 0;
122
+
123
+ for (const el of diffedStyles) {
124
+ for (const [prop, value] of Object.entries(el.style)) {
125
+ if (!TOKEN_ELIGIBLE_PROPS.has(prop)) {
126
+ continue;
127
+ }
128
+ if (typeof value !== "string") {
129
+ continue;
130
+ }
131
+ // Already a var() reference
132
+ if (value.startsWith("var(")) {
133
+ continue;
134
+ }
135
+
136
+ const normalized = normalizeValue(value);
137
+ const varRef = valueToVar.get(normalized);
138
+ if (varRef) {
139
+ el.style[prop] = varRef;
140
+ replacements += 1;
141
+
142
+ // Track which token was used so we can hoist its declaration
143
+ const varName = varRef.slice(4, -1); // "var(--x)" → "--x"
144
+ usedTokens.set(varName, props.get(varName)!);
145
+ }
146
+ }
147
+ }
148
+
149
+ // Sort tokens alphabetically for stable output
150
+ const tokens: Record<string, string> = {};
151
+ for (const name of [...usedTokens.keys()].toSorted()) {
152
+ tokens[name] = usedTokens.get(name)!;
153
+ }
154
+
155
+ return { tokens, replacements };
156
+ }