satoru-render 0.0.17 → 0.0.19
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 +40 -1
- package/dist/bench.d.ts +1 -0
- package/dist/bench.js +70 -0
- package/dist/cli.js +55 -3
- package/dist/core.d.ts +4 -0
- package/dist/core.js +34 -2
- package/dist/jsdom.d.ts +41 -0
- package/dist/jsdom.js +99 -0
- package/dist/satoru-single.js +0 -0
- package/dist/satoru.js +1 -1
- package/dist/satoru.wasm +0 -0
- package/dist/web-workers.js +62 -41
- package/package.json +13 -1
package/README.md
CHANGED
|
@@ -220,17 +220,56 @@ const png = await render({
|
|
|
220
220
|
|
|
221
221
|
---
|
|
222
222
|
|
|
223
|
+
### 6. JSDOM Hydration (For Next.js / SPAs)
|
|
224
|
+
|
|
225
|
+
For complex client-side applications (like Next.js) that require full Javascript evaluation and DOM hydration before rendering, Satoru provides an optional `jsdom` helper.
|
|
226
|
+
|
|
227
|
+
_Note: `jsdom` must be installed separately in your project (`npm install jsdom`)._
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
import { render } from "satoru-render";
|
|
231
|
+
import { getHtml } from "satoru-render/jsdom";
|
|
232
|
+
|
|
233
|
+
// 1. Let JSDOM fetch the URL, execute scripts, and wait for network/hydration
|
|
234
|
+
const hydratedHtml = await getHtml({
|
|
235
|
+
src: "https://example.com/",
|
|
236
|
+
waitUntil: "networkidle", // Wait until Next.js finishes loading chunks
|
|
237
|
+
beforeParse: (window) => {
|
|
238
|
+
// Provide polyfills if the target site requires them
|
|
239
|
+
window.matchMedia = () => ({ matches: false, addListener: () => {} });
|
|
240
|
+
window.IntersectionObserver = class {
|
|
241
|
+
observe() {}
|
|
242
|
+
unobserve() {}
|
|
243
|
+
disconnect() {}
|
|
244
|
+
};
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// 2. Render the fully constructed DOM in Satoru (at native speed)
|
|
249
|
+
const pngBytes = await render({
|
|
250
|
+
value: hydratedHtml,
|
|
251
|
+
baseUrl: "https://example.com/",
|
|
252
|
+
width: 1200,
|
|
253
|
+
format: "png",
|
|
254
|
+
});
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
223
259
|
## 💻 CLI Tool
|
|
224
260
|
|
|
225
261
|
Convert files or URLs directly from your terminal.
|
|
226
262
|
|
|
227
263
|
```bash
|
|
228
|
-
# Local HTML to PNG
|
|
264
|
+
# Local HTML to PNG (JSDOM hydration enabled by default)
|
|
229
265
|
npx satoru-render input.html -o output.png
|
|
230
266
|
|
|
231
267
|
# URL to PDF with specific width
|
|
232
268
|
npx satoru-render https://example.com -o site.pdf -w 1280
|
|
233
269
|
|
|
270
|
+
# Convert without JSDOM hydration
|
|
271
|
+
npx satoru-render https://example.com --no-jsdom -o example.pdf
|
|
272
|
+
|
|
234
273
|
# WebP conversion with verbose logs
|
|
235
274
|
npx satoru-render input.html -f webp --verbose
|
|
236
275
|
```
|
package/dist/bench.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/bench.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Bench } from "tinybench";
|
|
2
|
+
import { Satoru } from "./node.js";
|
|
3
|
+
// @ts-ignore
|
|
4
|
+
import createSatoruModule from "../dist/satoru-single.js";
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const assetsDir = path.join(__dirname, "../../../assets");
|
|
10
|
+
async function runBenchmark() {
|
|
11
|
+
const satoru = await Satoru.create(createSatoruModule);
|
|
12
|
+
const bench = new Bench({ time: 1000 });
|
|
13
|
+
const files = [
|
|
14
|
+
"06-flexbox.html",
|
|
15
|
+
"09-grid.html",
|
|
16
|
+
"11-complex-text.html",
|
|
17
|
+
"15-showcase.html",
|
|
18
|
+
];
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
const filePath = path.join(assetsDir, file);
|
|
21
|
+
if (!fs.existsSync(filePath)) {
|
|
22
|
+
console.warn(`File not found: ${filePath}`);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const html = fs.readFileSync(filePath, "utf-8");
|
|
26
|
+
// Full render
|
|
27
|
+
bench.add(`Full Render: ${file}`, async () => {
|
|
28
|
+
await satoru.render({
|
|
29
|
+
value: html,
|
|
30
|
+
width: 1200,
|
|
31
|
+
format: "svg",
|
|
32
|
+
baseUrl: `file://${assetsDir}/`,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
// Core layout and render (no resource discovery if we assume resources are loaded)
|
|
36
|
+
// To make it fair, we'll initialize once and layout/render multiple times
|
|
37
|
+
// However, tinybench runs the function multiple times, so we need to be careful about state.
|
|
38
|
+
// For core, we'll do init inside to avoid state pollution, but the focus is on layout/render.
|
|
39
|
+
bench.add(`Core Layout+Render (SVG): ${file}`, async () => {
|
|
40
|
+
const inst = await satoru.initDocument({
|
|
41
|
+
html,
|
|
42
|
+
width: 1200,
|
|
43
|
+
baseUrl: `file://${assetsDir}/`,
|
|
44
|
+
});
|
|
45
|
+
await satoru.layoutDocument(inst, 1200);
|
|
46
|
+
await satoru.renderFromState(inst, {
|
|
47
|
+
width: 1200,
|
|
48
|
+
format: "svg",
|
|
49
|
+
});
|
|
50
|
+
await satoru.destroyInstance(inst);
|
|
51
|
+
});
|
|
52
|
+
bench.add(`Core Layout+Render (PNG): ${file}`, async () => {
|
|
53
|
+
const inst = await satoru.initDocument({
|
|
54
|
+
html,
|
|
55
|
+
width: 1200,
|
|
56
|
+
baseUrl: `file://${assetsDir}/`,
|
|
57
|
+
});
|
|
58
|
+
await satoru.layoutDocument(inst, 1200);
|
|
59
|
+
await satoru.renderFromState(inst, {
|
|
60
|
+
width: 1200,
|
|
61
|
+
format: "png",
|
|
62
|
+
});
|
|
63
|
+
await satoru.destroyInstance(inst);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
console.log("Running benchmarks...");
|
|
67
|
+
await bench.run();
|
|
68
|
+
console.table(bench.table());
|
|
69
|
+
}
|
|
70
|
+
runBenchmark().catch(console.error);
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ async function main() {
|
|
|
7
7
|
const options = {
|
|
8
8
|
width: 800,
|
|
9
9
|
format: "png",
|
|
10
|
+
jsdom: true, // Default to true
|
|
10
11
|
};
|
|
11
12
|
let input;
|
|
12
13
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -23,6 +24,9 @@ async function main() {
|
|
|
23
24
|
else if (arg === "-o" || arg === "--output") {
|
|
24
25
|
options.output = args[++i];
|
|
25
26
|
}
|
|
27
|
+
else if (arg === "--no-jsdom") {
|
|
28
|
+
options.jsdom = false;
|
|
29
|
+
}
|
|
26
30
|
else if (arg === "--verbose") {
|
|
27
31
|
options.verbose = true;
|
|
28
32
|
}
|
|
@@ -70,16 +74,63 @@ async function main() {
|
|
|
70
74
|
console.error(`[Satoru] ${LogLevel[level]}: ${message}`);
|
|
71
75
|
};
|
|
72
76
|
}
|
|
77
|
+
let finalHtml;
|
|
78
|
+
let baseUrl;
|
|
73
79
|
if (isUrl) {
|
|
74
|
-
|
|
80
|
+
baseUrl = input;
|
|
81
|
+
if (options.jsdom) {
|
|
82
|
+
try {
|
|
83
|
+
const { getHtml } = await import("./jsdom.js");
|
|
84
|
+
if (options.verbose)
|
|
85
|
+
console.error(`[Satoru] Hydrating URL via JSDOM: ${input}`);
|
|
86
|
+
finalHtml = await getHtml({
|
|
87
|
+
src: input,
|
|
88
|
+
waitUntil: "networkidle",
|
|
89
|
+
forwardConsole: options.verbose,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
if (options.verbose)
|
|
94
|
+
console.error(`[Satoru] JSDOM hydration failed or not available, falling back to direct URL fetch:`, err);
|
|
95
|
+
renderOptions.url = input;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
renderOptions.url = input;
|
|
100
|
+
}
|
|
75
101
|
}
|
|
76
102
|
else {
|
|
77
103
|
if (!fs.existsSync(input)) {
|
|
78
104
|
console.error(`Error: File not found: ${input}`);
|
|
79
105
|
process.exit(1);
|
|
80
106
|
}
|
|
81
|
-
|
|
82
|
-
|
|
107
|
+
const rawContent = fs.readFileSync(input, "utf-8");
|
|
108
|
+
baseUrl = path.dirname(path.resolve(input));
|
|
109
|
+
if (options.jsdom) {
|
|
110
|
+
try {
|
|
111
|
+
const { getHtml } = await import("./jsdom.js");
|
|
112
|
+
if (options.verbose)
|
|
113
|
+
console.error(`[Satoru] Hydrating file via JSDOM: ${input}`);
|
|
114
|
+
finalHtml = await getHtml({
|
|
115
|
+
src: rawContent,
|
|
116
|
+
baseUrl: `file://${baseUrl}/`,
|
|
117
|
+
waitUntil: "networkidle",
|
|
118
|
+
forwardConsole: options.verbose,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
if (options.verbose)
|
|
123
|
+
console.error(`[Satoru] JSDOM hydration failed or not available, falling back to raw file content:`, err);
|
|
124
|
+
finalHtml = rawContent;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
finalHtml = rawContent;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (finalHtml !== undefined) {
|
|
132
|
+
renderOptions.value = finalHtml;
|
|
133
|
+
renderOptions.baseUrl = baseUrl;
|
|
83
134
|
}
|
|
84
135
|
try {
|
|
85
136
|
const result = await satoru.render(renderOptions);
|
|
@@ -100,6 +151,7 @@ Options:
|
|
|
100
151
|
-w, --width <number> Viewport width (default: 800)
|
|
101
152
|
-h, --height <number> Viewport height (default: 0, auto-calculate)
|
|
102
153
|
-f, --format <format> Output format: svg, png, webp, pdf
|
|
154
|
+
--no-jsdom Disable JSDOM hydration (enabled by default)
|
|
103
155
|
--verbose Enable detailed logging
|
|
104
156
|
--help Show this help message
|
|
105
157
|
`);
|
package/dist/core.d.ts
CHANGED
|
@@ -7,7 +7,9 @@ export interface SatoruModule {
|
|
|
7
7
|
add_resource: (inst: any, url: string, type: number, data: Uint8Array) => void;
|
|
8
8
|
scan_css: (inst: any, css: string) => void;
|
|
9
9
|
load_font: (inst: any, name: string, data: Uint8Array) => void;
|
|
10
|
+
load_fallback_font: (inst: any, data: Uint8Array) => void;
|
|
10
11
|
load_image: (inst: any, name: string, url: string, width: number, height: number) => void;
|
|
12
|
+
load_image_pixels: (inst: any, name: string, width: number, height: number, pixels: Uint8Array, data_url: string) => void;
|
|
11
13
|
set_font_map: (inst: any, fontMap: Record<string, string>) => void;
|
|
12
14
|
set_log_level: (level: number) => void;
|
|
13
15
|
init_document: (inst: any, html: string, width: number, height: number) => void;
|
|
@@ -37,6 +39,7 @@ export interface RenderOptions {
|
|
|
37
39
|
name: string;
|
|
38
40
|
data: Uint8Array;
|
|
39
41
|
}[];
|
|
42
|
+
fallbackFonts?: Uint8Array[];
|
|
40
43
|
images?: {
|
|
41
44
|
name: string;
|
|
42
45
|
url: string;
|
|
@@ -69,6 +72,7 @@ export declare abstract class SatoruBase {
|
|
|
69
72
|
textToPaths?: boolean;
|
|
70
73
|
}): Promise<string | Uint8Array>;
|
|
71
74
|
destroyInstance(inst: any): Promise<void>;
|
|
75
|
+
loadFallbackFont(data: Uint8Array): Promise<void>;
|
|
72
76
|
protected abstract resolveDefaultResource(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | null>;
|
|
73
77
|
protected abstract fetchHtml(url: string, userAgent?: string): Promise<string>;
|
|
74
78
|
render(options: RenderOptions & {
|
package/dist/core.js
CHANGED
|
@@ -150,6 +150,16 @@ export class SatoruBase {
|
|
|
150
150
|
const mod = await this.getModule();
|
|
151
151
|
mod.destroy_instance(inst);
|
|
152
152
|
}
|
|
153
|
+
async loadFallbackFont(data) {
|
|
154
|
+
const mod = await this.getModule();
|
|
155
|
+
const inst = mod.create_instance();
|
|
156
|
+
try {
|
|
157
|
+
mod.load_fallback_font(inst, data);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
mod.destroy_instance(inst);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
153
163
|
async render(options) {
|
|
154
164
|
const mod = await this.getModule();
|
|
155
165
|
let { value, url, width, height = 0, format = "svg", fonts, images, css, baseUrl, logLevel, onLog, } = options;
|
|
@@ -181,6 +191,11 @@ export class SatoruBase {
|
|
|
181
191
|
mod.load_font(instancePtr, f.name, f.data);
|
|
182
192
|
}
|
|
183
193
|
}
|
|
194
|
+
if (options.fallbackFonts) {
|
|
195
|
+
for (const data of options.fallbackFonts) {
|
|
196
|
+
mod.load_fallback_font(instancePtr, data);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
184
199
|
if (images) {
|
|
185
200
|
for (const img of images) {
|
|
186
201
|
mod.load_image(instancePtr, img.name, img.url, img.width ?? 0, img.height ?? 0);
|
|
@@ -225,6 +240,23 @@ export class SatoruBase {
|
|
|
225
240
|
const uint8 = data instanceof Uint8Array
|
|
226
241
|
? data
|
|
227
242
|
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
243
|
+
if (r.type === "image" && typeof createImageBitmap !== "undefined" && typeof OffscreenCanvas !== "undefined") {
|
|
244
|
+
try {
|
|
245
|
+
const blob = new Blob([uint8.buffer]);
|
|
246
|
+
const bitmap = await createImageBitmap(blob);
|
|
247
|
+
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
248
|
+
const ctx = canvas.getContext("2d");
|
|
249
|
+
if (ctx) {
|
|
250
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
251
|
+
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
|
|
252
|
+
mod.load_image_pixels(instancePtr, r.url, bitmap.width, bitmap.height, new Uint8Array(imageData.data.buffer), r.url);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
// fallback to passing raw binary if decoding fails
|
|
258
|
+
}
|
|
259
|
+
}
|
|
228
260
|
let typeInt = 1;
|
|
229
261
|
if (r.type === "image")
|
|
230
262
|
typeInt = 2;
|
|
@@ -247,7 +279,7 @@ export class SatoruBase {
|
|
|
247
279
|
});
|
|
248
280
|
resolvedUrls.forEach((url) => {
|
|
249
281
|
const escapedUrl = url.replace(/[.*+?^${}()|[\]]/g, "\\$&");
|
|
250
|
-
const linkRegex = new RegExp(`<link[^>]*href\\s*=\\s*(["'])${escapedUrl}\\1[^>]*>`, "gi");
|
|
282
|
+
const linkRegex = new RegExp(`<link[^>]*href\\s*=\\s*(["']?)${escapedUrl}\\1[^>]*>`, "gi");
|
|
251
283
|
processedHtml = processedHtml.replace(linkRegex, "");
|
|
252
284
|
});
|
|
253
285
|
processedHtmls.push(processedHtml);
|
|
@@ -267,7 +299,7 @@ export class SatoruBase {
|
|
|
267
299
|
if (format === "svg") {
|
|
268
300
|
return new TextDecoder().decode(result);
|
|
269
301
|
}
|
|
270
|
-
return new Uint8Array(result);
|
|
302
|
+
return new Uint8Array(result.slice());
|
|
271
303
|
}
|
|
272
304
|
finally {
|
|
273
305
|
mod.destroy_instance(instancePtr);
|
package/dist/jsdom.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface HydrateOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Target URL or raw HTML string to render.
|
|
4
|
+
*/
|
|
5
|
+
src: string;
|
|
6
|
+
/**
|
|
7
|
+
* Base URL for resolving relative paths and fetching resources.
|
|
8
|
+
* Recommended when passing raw HTML.
|
|
9
|
+
*/
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Wait condition for hydration to complete.
|
|
13
|
+
* - number: Wait for specified milliseconds
|
|
14
|
+
* - function: Polling function that receives the window object and returns true when done
|
|
15
|
+
* - "networkidle": Waits until network requests settle (basic implementation)
|
|
16
|
+
*/
|
|
17
|
+
waitUntil?: number | ((window: any) => boolean | Promise<boolean>) | "networkidle";
|
|
18
|
+
/**
|
|
19
|
+
* Maximum time to wait in milliseconds. Default: 10000ms.
|
|
20
|
+
*/
|
|
21
|
+
timeout?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Callback to mock or inject missing browser APIs (e.g. matchMedia, IntersectionObserver)
|
|
24
|
+
* before the HTML is parsed and scripts are executed.
|
|
25
|
+
*/
|
|
26
|
+
beforeParse?: (window: any) => void | Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* If true, forwards console.log/error from JSDOM to the Node.js console.
|
|
29
|
+
*/
|
|
30
|
+
forwardConsole?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* If true, removes all <script> tags from the final HTML before returning.
|
|
33
|
+
* Default: true.
|
|
34
|
+
*/
|
|
35
|
+
removeScripts?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Hydrates an HTML string or URL using JSDOM and returns the final rendered HTML.
|
|
39
|
+
* Note: Requires 'jsdom' to be installed as a peer dependency.
|
|
40
|
+
*/
|
|
41
|
+
export declare function getHtml(options: HydrateOptions): Promise<string>;
|
package/dist/jsdom.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hydrates an HTML string or URL using JSDOM and returns the final rendered HTML.
|
|
3
|
+
* Note: Requires 'jsdom' to be installed as a peer dependency.
|
|
4
|
+
*/
|
|
5
|
+
export async function getHtml(options) {
|
|
6
|
+
let jsdomModule;
|
|
7
|
+
try {
|
|
8
|
+
jsdomModule = await import("jsdom");
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
throw new Error("[Satoru] 'jsdom' is required to use hydrateHtml. Please run: npm install jsdom");
|
|
12
|
+
}
|
|
13
|
+
const { JSDOM, VirtualConsole } = jsdomModule.default || jsdomModule;
|
|
14
|
+
const isUrl = /^https?:\/\//.test(options.src);
|
|
15
|
+
const finalBaseUrl = options.baseUrl || (isUrl ? options.src : "http://localhost/");
|
|
16
|
+
const virtualConsole = new VirtualConsole();
|
|
17
|
+
if (options.forwardConsole) {
|
|
18
|
+
virtualConsole.forwardTo(console);
|
|
19
|
+
}
|
|
20
|
+
const jsdomOptions = {
|
|
21
|
+
runScripts: "dangerously",
|
|
22
|
+
resources: "usable",
|
|
23
|
+
pretendToBeVisual: true,
|
|
24
|
+
url: finalBaseUrl,
|
|
25
|
+
virtualConsole,
|
|
26
|
+
beforeParse: (window) => {
|
|
27
|
+
// Provide basic polyfills often needed by modern frameworks
|
|
28
|
+
if (!window.fetch) {
|
|
29
|
+
window.fetch = async (url, opts) => {
|
|
30
|
+
const absoluteUrl = new URL(url, finalBaseUrl).href;
|
|
31
|
+
return await globalThis.fetch(absoluteUrl, opts);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
window.matchMedia = window.matchMedia || (() => ({
|
|
35
|
+
matches: false,
|
|
36
|
+
addListener: () => { },
|
|
37
|
+
removeListener: () => { },
|
|
38
|
+
addEventListener: () => { },
|
|
39
|
+
removeEventListener: () => { },
|
|
40
|
+
dispatchEvent: () => false,
|
|
41
|
+
}));
|
|
42
|
+
window.IntersectionObserver = window.IntersectionObserver || class {
|
|
43
|
+
observe() { }
|
|
44
|
+
unobserve() { }
|
|
45
|
+
disconnect() { }
|
|
46
|
+
};
|
|
47
|
+
window.ResizeObserver = window.ResizeObserver || class {
|
|
48
|
+
observe() { }
|
|
49
|
+
unobserve() { }
|
|
50
|
+
disconnect() { }
|
|
51
|
+
};
|
|
52
|
+
if (options.beforeParse) {
|
|
53
|
+
options.beforeParse(window);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
let dom;
|
|
58
|
+
if (isUrl) {
|
|
59
|
+
const { url, ...fromUrlOptions } = jsdomOptions;
|
|
60
|
+
dom = await JSDOM.fromURL(options.src, fromUrlOptions);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
dom = new JSDOM(options.src, jsdomOptions);
|
|
64
|
+
}
|
|
65
|
+
const timeoutMs = options.timeout || 10000;
|
|
66
|
+
const startTime = Date.now();
|
|
67
|
+
const checkWaitCondition = async () => {
|
|
68
|
+
if (Date.now() - startTime >= timeoutMs) {
|
|
69
|
+
return true; // Timeout reached
|
|
70
|
+
}
|
|
71
|
+
if (typeof options.waitUntil === "number") {
|
|
72
|
+
return Date.now() - startTime >= options.waitUntil;
|
|
73
|
+
}
|
|
74
|
+
if (options.waitUntil === "networkidle") {
|
|
75
|
+
// Wait at least 2 seconds for heavy sites like Zenn
|
|
76
|
+
return Date.now() - startTime >= 2000;
|
|
77
|
+
}
|
|
78
|
+
if (typeof options.waitUntil === "function") {
|
|
79
|
+
return await options.waitUntil(dom.window);
|
|
80
|
+
}
|
|
81
|
+
// Default: wait a short tick for basic sync scripts
|
|
82
|
+
return Date.now() - startTime >= 50;
|
|
83
|
+
};
|
|
84
|
+
// Polling loop
|
|
85
|
+
while (!(await checkWaitCondition())) {
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
87
|
+
}
|
|
88
|
+
const removeScripts = options.removeScripts ?? true;
|
|
89
|
+
if (removeScripts) {
|
|
90
|
+
const scripts = dom.window.document.querySelectorAll("script");
|
|
91
|
+
scripts.forEach((s) => s.remove());
|
|
92
|
+
}
|
|
93
|
+
const finalHtml = dom.serialize();
|
|
94
|
+
// Cleanup to prevent memory leaks
|
|
95
|
+
if (dom.window) {
|
|
96
|
+
dom.window.close();
|
|
97
|
+
}
|
|
98
|
+
return finalHtml;
|
|
99
|
+
}
|
package/dist/satoru-single.js
CHANGED
|
Binary file
|