@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.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * In-browser asset URL discovery — runs inside page.evaluate().
3
+ *
4
+ * Finds images, background images, fonts (from @font-face), favicons, and srcset entries. Inline
5
+ * SVGs are kept as-is (no download needed). Returns absolute URLs for downloading.
6
+ */
7
+
8
+ import type { Page } from "puppeteer-core";
9
+
10
+ export interface DiscoveredAsset {
11
+ /** Absolute URL of the asset. */
12
+ url: string;
13
+ /** How it was found — guides download priority and rewrite strategy. */
14
+ source:
15
+ | "img-src"
16
+ | "img-srcset"
17
+ | "source-srcset"
18
+ | "picture-source"
19
+ | "video-poster"
20
+ | "css-background"
21
+ | "css-url"
22
+ | "font-face"
23
+ | "favicon"
24
+ | "og-image";
25
+ }
26
+
27
+ export interface CapturedStylesheet {
28
+ /** The stylesheet's href (null for inline <style> blocks). */
29
+ href: string | null;
30
+ /** Full cssText of the sheet. Null if cross-origin and cssRules threw. */
31
+ cssText: string | null;
32
+ /** Individual @font-face rule texts extracted from the sheet. */
33
+ fontFaceRules: string[];
34
+ }
35
+
36
+ export interface AssetCollectionResult {
37
+ assets: DiscoveredAsset[];
38
+ /** Inline SVGs found (kept inline, not downloaded). */
39
+ inlineSvgCount: number;
40
+ /** Retained source stylesheets (F0 foundation — enables font emission, breakpoint reading, etc). */
41
+ stylesheets: CapturedStylesheet[];
42
+ }
43
+
44
+ /**
45
+ * Collect all asset URLs from the live page DOM and stylesheets. Runs entirely in-browser via
46
+ * page.evaluate() — no per-element round-trips.
47
+ */
48
+ export async function collectAssets(page: Page): Promise<AssetCollectionResult> {
49
+ const result = await page.evaluate(() => {
50
+ const assets: { url: string; source: string }[] = [];
51
+ const seen = new Set<string>();
52
+ let inlineSvgCount = 0;
53
+
54
+ function add(url: string, source: string) {
55
+ if (!url || seen.has(url)) {
56
+ return;
57
+ }
58
+ try {
59
+ const abs = new URL(url, location.href).href;
60
+ // oxlint-disable-next-line no-script-url -- detection, not usage
61
+ if (abs.startsWith("data:") || abs.startsWith("blob:") || abs.startsWith("javascript:")) {
62
+ return;
63
+ }
64
+ if (seen.has(abs)) {
65
+ return;
66
+ }
67
+ seen.add(abs);
68
+ assets.push({ url: abs, source });
69
+ } catch {
70
+ // Invalid URL — skip
71
+ }
72
+ }
73
+
74
+ function parseSrcset(srcset: string): string[] {
75
+ return srcset
76
+ .split(",")
77
+ .map((entry) => entry.trim().split(/\s+/)[0] ?? "")
78
+ .filter(Boolean);
79
+ }
80
+
81
+ function extractCssUrls(cssText: string): string[] {
82
+ const urls: string[] = [];
83
+ const re = /url\(\s*(['"]?)(.+?)\1\s*\)/g;
84
+ let m;
85
+ while ((m = re.exec(cssText)) !== null) {
86
+ const u = m.at(2);
87
+ if (u && !u.startsWith("data:")) {
88
+ urls.push(u);
89
+ }
90
+ }
91
+ return urls;
92
+ }
93
+
94
+ // Img[src]
95
+ for (const el of document.querySelectorAll("img[src]")) {
96
+ add((el as HTMLImageElement).src, "img-src");
97
+ }
98
+
99
+ // Img[srcset] and source[srcset]
100
+ for (const el of document.querySelectorAll("img[srcset], source[srcset]")) {
101
+ const srcset = el.getAttribute("srcset") ?? "";
102
+ const source = el.tagName.toLowerCase() === "img" ? "img-srcset" : "source-srcset";
103
+ for (const u of parseSrcset(srcset)) {
104
+ add(u, source);
105
+ }
106
+ }
107
+
108
+ // Picture > source (type-based, not just srcset)
109
+ for (const el of document.querySelectorAll("picture > source[src]")) {
110
+ add((el as HTMLSourceElement).src, "picture-source");
111
+ }
112
+
113
+ // Video[poster]
114
+ for (const el of document.querySelectorAll("video[poster]")) {
115
+ add((el as HTMLVideoElement).poster, "video-poster");
116
+ }
117
+
118
+ // Inline style url() on any element
119
+ for (const el of document.querySelectorAll("[style]")) {
120
+ const style = el.getAttribute("style") ?? "";
121
+ for (const u of extractCssUrls(style)) {
122
+ add(u, "css-url");
123
+ }
124
+ }
125
+
126
+ // Computed background-image on all elements (catches CSS-applied bg images)
127
+ for (const el of document.querySelectorAll("*")) {
128
+ const cs = window.getComputedStyle(el);
129
+ const bg = cs.getPropertyValue("background-image");
130
+ if (bg && bg !== "none") {
131
+ for (const u of extractCssUrls(bg)) {
132
+ add(u, "css-background");
133
+ }
134
+ }
135
+ }
136
+
137
+ // @font-face from stylesheets + retain full source CSS (F0)
138
+ const stylesheets: { href: string | null; cssText: string | null; fontFaceRules: string[] }[] =
139
+ [];
140
+ for (const sheet of document.styleSheets) {
141
+ let rules: CSSRuleList | null = null;
142
+ let fullCssText: string | null = null;
143
+ const fontFaceRules: string[] = [];
144
+ const { href } = sheet;
145
+
146
+ try {
147
+ rules = sheet.cssRules;
148
+ // Build full cssText from all rules
149
+ const parts: string[] = [];
150
+ for (const rule of rules) {
151
+ parts.push(rule.cssText);
152
+ if (rule instanceof CSSFontFaceRule) {
153
+ fontFaceRules.push(rule.cssText);
154
+ const src = rule.style.getPropertyValue("src");
155
+ if (src) {
156
+ for (const u of extractCssUrls(src)) {
157
+ add(u, "font-face");
158
+ }
159
+ }
160
+ }
161
+ }
162
+ fullCssText = parts.join("\n");
163
+ } catch {
164
+ // Cross-origin sheet — cssRules inaccessible. Record href for browser-context refetch.
165
+ }
166
+
167
+ stylesheets.push({ href, cssText: fullCssText, fontFaceRules });
168
+ }
169
+
170
+ // Favicon and other link icons
171
+ for (const el of document.querySelectorAll(
172
+ 'link[rel~="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]',
173
+ )) {
174
+ const { href } = el as HTMLLinkElement;
175
+ if (href) {
176
+ add(href, "favicon");
177
+ }
178
+ }
179
+
180
+ // Open Graph images
181
+ for (const el of document.querySelectorAll('meta[property="og:image"]')) {
182
+ const { content } = el as HTMLMetaElement;
183
+ if (content) {
184
+ add(content, "og-image");
185
+ }
186
+ }
187
+
188
+ // Count inline SVGs (kept inline, not downloaded)
189
+ inlineSvgCount = document.querySelectorAll("svg").length;
190
+
191
+ return { assets, inlineSvgCount, stylesheets } as {
192
+ assets: { url: string; source: string }[];
193
+ inlineSvgCount: number;
194
+ stylesheets: { href: string | null; cssText: string | null; fontFaceRules: string[] }[];
195
+ };
196
+ });
197
+
198
+ // R2: For cross-origin sheets we couldn't access (cssText=null, has href), fetch them
199
+ // In-browser to get @font-face rules (e.g. Google Fonts loaded via <link>)
200
+ const inaccessibleSheets = result.stylesheets.filter((s) => s.cssText === null && s.href);
201
+ if (inaccessibleSheets.length > 0) {
202
+ const fetched = await page.evaluate(
203
+ async (hrefs: string[]) => {
204
+ const results: { href: string; cssText: string; fontFaceRules: string[] }[] = [];
205
+ for (const href of hrefs) {
206
+ try {
207
+ const res = await fetch(href);
208
+ if (res.ok) {
209
+ const text = await res.text();
210
+ // Parse @font-face rules from the raw CSS text
211
+ const fontFaceRules: string[] = [];
212
+ const re = /@font-face\s*\{[^}]*\}/g;
213
+ let match;
214
+ while ((match = re.exec(text)) !== null) {
215
+ fontFaceRules.push(match[0]);
216
+ }
217
+ results.push({ href, cssText: text, fontFaceRules });
218
+ }
219
+ } catch {
220
+ // Skip unreachable sheets
221
+ }
222
+ }
223
+ return results;
224
+ },
225
+ inaccessibleSheets.map((s) => s.href!),
226
+ );
227
+
228
+ // Merge fetched data back and discover font URLs
229
+ for (const fetched_sheet of fetched) {
230
+ const existing = result.stylesheets.find((s) => s.href === fetched_sheet.href);
231
+ if (existing) {
232
+ existing.cssText = fetched_sheet.cssText;
233
+ existing.fontFaceRules = fetched_sheet.fontFaceRules;
234
+ }
235
+
236
+ // Extract font URLs from the fetched @font-face rules
237
+ const urlRe = /url\(\s*(['"]?)(.+?)\1\s*\)/g;
238
+ for (const rule of fetched_sheet.fontFaceRules) {
239
+ let m;
240
+ while ((m = urlRe.exec(rule)) !== null) {
241
+ const fontUrl = m.at(2);
242
+ if (fontUrl && !fontUrl.startsWith("data:")) {
243
+ const abs = new URL(fontUrl, fetched_sheet.href).href;
244
+ if (!result.assets.some((a) => a.url === abs)) {
245
+ result.assets.push({ url: abs, source: "font-face" });
246
+ }
247
+ }
248
+ }
249
+ }
250
+ }
251
+ }
252
+
253
+ return result as AssetCollectionResult;
254
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Download discovered assets to public/assets/ and build a URL rewrite map.
3
+ *
4
+ * Skips tracking/analytics domains. Dedupes by URL. Preserves file extensions. Organizes into
5
+ * subdirectories by type: images/, fonts/, icons/.
6
+ */
7
+
8
+ import { join, extname } from "node:path";
9
+ import { mkdir } from "node:fs/promises";
10
+ import type { DiscoveredAsset } from "./asset-collect.ts";
11
+
12
+ export interface DownloadResult {
13
+ /**
14
+ * Maps original absolute URL → relative path from project root (e.g.
15
+ * "public/assets/images/hero.jpg").
16
+ */
17
+ rewriteMap: Map<string, string>;
18
+ /** URLs that failed to download. */
19
+ failed: string[];
20
+ /** URLs skipped (tracking, analytics, etc.). */
21
+ skipped: string[];
22
+ /** Total bytes downloaded. */
23
+ totalBytes: number;
24
+ }
25
+
26
+ const BLOCKED_DOMAINS = new Set([
27
+ "www.google-analytics.com",
28
+ "google-analytics.com",
29
+ "googletagmanager.com",
30
+ "www.googletagmanager.com",
31
+ "analytics.google.com",
32
+ "stats.g.doubleclick.net",
33
+ "www.facebook.com",
34
+ "connect.facebook.net",
35
+ "px.ads.linkedin.com",
36
+ "bat.bing.com",
37
+ "snap.licdn.com",
38
+ "tr.snapchat.com",
39
+ "cdn.segment.com",
40
+ "api.segment.io",
41
+ "static.hotjar.com",
42
+ "script.hotjar.com",
43
+ "plausible.io",
44
+ "cdn.amplitude.com",
45
+ ]);
46
+
47
+ const FONT_EXTENSIONS = new Set([".woff", ".woff2", ".ttf", ".otf", ".eot"]);
48
+ const IMAGE_EXTENSIONS = new Set([
49
+ ".jpg",
50
+ ".jpeg",
51
+ ".png",
52
+ ".gif",
53
+ ".webp",
54
+ ".avif",
55
+ ".svg",
56
+ ".ico",
57
+ ".bmp",
58
+ ".tiff",
59
+ ]);
60
+
61
+ function classifyAsset(
62
+ url: string,
63
+ source: DiscoveredAsset["source"],
64
+ ): "images" | "fonts" | "icons" | "other" {
65
+ if (source === "font-face") {
66
+ return "fonts";
67
+ }
68
+ if (source === "favicon") {
69
+ return "icons";
70
+ }
71
+
72
+ const [ext = ""] = extname(new URL(url).pathname).toLowerCase().split("?");
73
+ if (FONT_EXTENSIONS.has(ext)) {
74
+ return "fonts";
75
+ }
76
+ if (IMAGE_EXTENSIONS.has(ext)) {
77
+ return "images";
78
+ }
79
+ return "other";
80
+ }
81
+
82
+ function sanitizeFilename(url: string): string {
83
+ const parsed = new URL(url);
84
+ let { pathname } = parsed;
85
+
86
+ // Remove leading slash
87
+ if (pathname.startsWith("/")) {
88
+ pathname = pathname.slice(1);
89
+ }
90
+
91
+ // Use just the filename part, not the full path
92
+ const parts = pathname.split("/");
93
+ let filename = parts.at(-1) || "asset";
94
+
95
+ // Strip query params from extension
96
+ const qIdx = filename.indexOf("?");
97
+ if (qIdx !== -1) {
98
+ filename = filename.slice(0, qIdx);
99
+ }
100
+
101
+ // Sanitize: keep alphanumeric, hyphens, dots, underscores
102
+ filename = filename.replaceAll(/[^a-zA-Z0-9._-]/g, "_");
103
+
104
+ // Ensure it has an extension
105
+ if (!extname(filename)) {
106
+ filename += ".bin";
107
+ }
108
+
109
+ // Cap length
110
+ if (filename.length > 100) {
111
+ const ext = extname(filename);
112
+ filename = filename.slice(0, 96) + ext;
113
+ }
114
+
115
+ return filename;
116
+ }
117
+
118
+ function isBlocked(url: string): boolean {
119
+ try {
120
+ const { hostname } = new URL(url);
121
+ return BLOCKED_DOMAINS.has(hostname);
122
+ } catch {
123
+ return true;
124
+ }
125
+ }
126
+
127
+ export async function downloadAssets(
128
+ assets: DiscoveredAsset[],
129
+ outDir: string,
130
+ sourceUrl?: string,
131
+ ): Promise<DownloadResult> {
132
+ const rewriteMap = new Map<string, string>();
133
+ const failed: string[] = [];
134
+ const skipped: string[] = [];
135
+ let totalBytes = 0;
136
+
137
+ // Dedupe by URL
138
+ const unique = new Map<string, DiscoveredAsset>();
139
+ for (const asset of assets) {
140
+ if (!unique.has(asset.url)) {
141
+ unique.set(asset.url, asset);
142
+ }
143
+ }
144
+
145
+ // Create subdirectories
146
+ const assetsDir = join(outDir, "public", "assets");
147
+ await mkdir(join(assetsDir, "images"), { recursive: true });
148
+ await mkdir(join(assetsDir, "fonts"), { recursive: true });
149
+ await mkdir(join(assetsDir, "icons"), { recursive: true });
150
+ await mkdir(join(assetsDir, "other"), { recursive: true });
151
+
152
+ // Track filenames to avoid collisions
153
+ const usedNames = new Map<string, number>();
154
+
155
+ for (const [url, asset] of unique) {
156
+ if (isBlocked(url)) {
157
+ skipped.push(url);
158
+ continue;
159
+ }
160
+
161
+ const subdir = classifyAsset(url, asset.source);
162
+ let filename = sanitizeFilename(url);
163
+
164
+ // Dedupe filenames within subdirectory
165
+ const nameKey = `${subdir}/${filename}`;
166
+ const count = usedNames.get(nameKey) ?? 0;
167
+ if (count > 0) {
168
+ const ext = extname(filename);
169
+ const base = filename.slice(0, -ext.length || undefined);
170
+ filename = `${base}-${count}${ext}`;
171
+ }
172
+ usedNames.set(nameKey, count + 1);
173
+
174
+ const destPath = join(assetsDir, subdir, filename);
175
+ const relativePath = `public/assets/${subdir}/${filename}`;
176
+
177
+ try {
178
+ const headers: Record<string, string> = {
179
+ "User-Agent":
180
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
181
+ };
182
+ if (sourceUrl) {
183
+ headers["Referer"] = sourceUrl;
184
+ }
185
+ const response = await fetch(url, { headers, redirect: "follow" });
186
+ if (!response.ok) {
187
+ failed.push(url);
188
+ continue;
189
+ }
190
+
191
+ const buffer = await response.arrayBuffer();
192
+ totalBytes += buffer.byteLength;
193
+ await Bun.write(destPath, buffer);
194
+ rewriteMap.set(url, relativePath);
195
+ } catch {
196
+ failed.push(url);
197
+ }
198
+ }
199
+
200
+ return { rewriteMap, failed, skipped, totalBytes };
201
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Walk a Jx tree and rewrite absolute asset URLs to local relative paths using a rewrite map.
3
+ *
4
+ * Handles: - attributes.src, attributes.href, attributes.poster, attributes.srcset -
5
+ * style.backgroundImage (url() references) - $media nested style objects with backgroundImage
6
+ */
7
+
8
+ import type { JxElement, JxStyle } from "@jxsuite/schema/types";
9
+
10
+ /** Rewrite all asset URLs in a Jx tree in-place. Returns the count of rewrites performed. */
11
+ export function rewriteAssetUrls(
12
+ root: JxElement,
13
+ rewriteMap: Map<string, string>,
14
+ sourceUrl?: string,
15
+ ): number {
16
+ let count = 0;
17
+
18
+ function rewriteUrl(url: string): string | null {
19
+ const rewritten = rewriteMap.get(url);
20
+ if (rewritten) {
21
+ return rewritten;
22
+ }
23
+
24
+ // Try without trailing slash
25
+ const trimmed = url.endsWith("/") ? url.slice(0, -1) : `${url}/`;
26
+ const alt = rewriteMap.get(trimmed);
27
+ if (alt) {
28
+ return alt;
29
+ }
30
+
31
+ // Resolve relative/protocol-relative URLs against the source page URL
32
+ if (sourceUrl) {
33
+ try {
34
+ const resolved = new URL(url, sourceUrl).href;
35
+ if (resolved !== url) {
36
+ const fromResolved = rewriteMap.get(resolved);
37
+ if (fromResolved) {
38
+ return fromResolved;
39
+ }
40
+ }
41
+ } catch {
42
+ // Invalid URL — skip
43
+ }
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ function rewriteSrcset(srcset: string): string {
50
+ return srcset
51
+ .split(",")
52
+ .map((entry) => {
53
+ const parts = entry.trim().split(/\s+/);
54
+ const [url = ""] = parts;
55
+ const rewritten = rewriteUrl(url);
56
+ if (rewritten) {
57
+ count += 1;
58
+ parts[0] = rewritten;
59
+ }
60
+ return parts.join(" ");
61
+ })
62
+ .join(", ");
63
+ }
64
+
65
+ function rewriteCssUrls(value: string): string {
66
+ return value.replaceAll(/url\(\s*(['"]?)(.+?)\1\s*\)/g, (_match, quote, url: string) => {
67
+ const rewritten = rewriteUrl(url);
68
+ if (rewritten) {
69
+ count += 1;
70
+ return `url(${quote}${rewritten}${quote})`;
71
+ }
72
+ return _match;
73
+ });
74
+ }
75
+
76
+ function rewriteStyleObject(style: JxStyle): void {
77
+ for (const [key, value] of Object.entries(style)) {
78
+ if (typeof value === "string" && value.includes("url(")) {
79
+ (style as Record<string, unknown>)[key] = rewriteCssUrls(value);
80
+ } else if (typeof value === "object" && value !== null && key.startsWith("@")) {
81
+ rewriteStyleObject(value as JxStyle);
82
+ }
83
+ }
84
+ }
85
+
86
+ function walk(node: JxElement): void {
87
+ const attrs = node.attributes as Record<string, unknown> | undefined;
88
+
89
+ if (attrs) {
90
+ // Src attribute (img, video, audio, source, etc.)
91
+ if (typeof attrs.src === "string") {
92
+ const rewritten = rewriteUrl(attrs.src);
93
+ if (rewritten) {
94
+ attrs.src = rewritten;
95
+ count += 1;
96
+ }
97
+ }
98
+
99
+ // Href on non-anchor elements (could be link icon, etc.)
100
+ if (typeof attrs.href === "string") {
101
+ const tag = (node.tagName ?? "").toLowerCase();
102
+ if (tag !== "a") {
103
+ const rewritten = rewriteUrl(attrs.href);
104
+ if (rewritten) {
105
+ attrs.href = rewritten;
106
+ count += 1;
107
+ }
108
+ }
109
+ }
110
+
111
+ // Poster attribute (video)
112
+ if (typeof attrs.poster === "string") {
113
+ const rewritten = rewriteUrl(attrs.poster);
114
+ if (rewritten) {
115
+ attrs.poster = rewritten;
116
+ count += 1;
117
+ }
118
+ }
119
+
120
+ // Srcset attribute
121
+ if (typeof attrs.srcset === "string") {
122
+ attrs.srcset = rewriteSrcset(attrs.srcset);
123
+ }
124
+ }
125
+
126
+ // Style object — background-image url() references
127
+ if (node.style) {
128
+ rewriteStyleObject(node.style);
129
+ }
130
+
131
+ // Recurse
132
+ if (Array.isArray(node.children)) {
133
+ for (const child of node.children) {
134
+ if (typeof child !== "string") {
135
+ walk(child);
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ walk(root);
142
+ return count;
143
+ }