wasm-html-to-image 1.0.2 → 1.0.5
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 +6 -0
- package/dist/browser-workers.js +20 -7
- package/dist/cli-options.d.ts +32 -0
- package/dist/cli-options.js +35 -0
- package/dist/cli.js +8 -0
- package/dist/core-orchestrate.d.ts +15 -0
- package/dist/core-orchestrate.js +36 -8
- package/dist/core.d.ts +26 -3
- package/dist/encode-args.js +2 -0
- package/dist/html-to-image-wasm.js +3 -1
- package/dist/html-to-image.wasm +0 -0
- package/dist/input.d.ts +1 -1
- package/dist/input.js +11 -1
- package/dist/loader.d.ts +30 -2
- package/dist/loader.js +172 -11
- package/dist/tailwind.d.ts +38 -1
- package/dist/tailwind.js +187 -3
- package/dist/wasm-binary.d.ts +34 -0
- package/dist/wasm-binary.js +210 -0
- package/dist/wasm-payload.js +8 -0
- package/dist/web-workers.js +342 -23
- package/llms.txt +3 -3
- package/package.json +1 -1
package/dist/html-to-image.wasm
CHANGED
|
Binary file
|
package/dist/input.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export declare function toBytes(data: Uint8Array | ArrayBuffer): Uint8Array;
|
|
|
13
13
|
* Whether `value` is image input.
|
|
14
14
|
*
|
|
15
15
|
* - Binary (`Uint8Array` / `ArrayBuffer`): magic-byte sniffing for
|
|
16
|
-
* PNG/JPEG/WebP/GIF/AVIF/BMP. Unknown magic throws.
|
|
16
|
+
* PNG/JPEG/WebP/GIF/AVIF/JXL/BMP. Unknown magic throws.
|
|
17
17
|
* - `string`: `data:image/` prefix means image input; anything else is HTML.
|
|
18
18
|
* - `string[]` (HTML vector) / `undefined`: not image input.
|
|
19
19
|
*/
|
package/dist/input.js
CHANGED
|
@@ -41,13 +41,23 @@ function sniffImageFormat(bytes) {
|
|
|
41
41
|
return "avif";
|
|
42
42
|
if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d)
|
|
43
43
|
return "bmp";
|
|
44
|
+
// JPEG XL: bare codestream (FF 0A) or ISO BMFF container signature box.
|
|
45
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0x0a)
|
|
46
|
+
return "jxl";
|
|
47
|
+
if (bytes.length >= 12 &&
|
|
48
|
+
bytes[0] === 0 &&
|
|
49
|
+
bytes[1] === 0 &&
|
|
50
|
+
bytes[2] === 0 &&
|
|
51
|
+
bytes[3] === 0x0c &&
|
|
52
|
+
ascii(bytes, 4, 8) === "JXL ")
|
|
53
|
+
return "jxl";
|
|
44
54
|
return null;
|
|
45
55
|
}
|
|
46
56
|
/**
|
|
47
57
|
* Whether `value` is image input.
|
|
48
58
|
*
|
|
49
59
|
* - Binary (`Uint8Array` / `ArrayBuffer`): magic-byte sniffing for
|
|
50
|
-
* PNG/JPEG/WebP/GIF/AVIF/BMP. Unknown magic throws.
|
|
60
|
+
* PNG/JPEG/WebP/GIF/AVIF/JXL/BMP. Unknown magic throws.
|
|
51
61
|
* - `string`: `data:image/` prefix means image input; anything else is HTML.
|
|
52
62
|
* - `string[]` (HTML vector) / `undefined`: not image input.
|
|
53
63
|
*/
|
package/dist/loader.d.ts
CHANGED
|
@@ -85,7 +85,19 @@ export interface HtmlToImageModule {
|
|
|
85
85
|
* Returns false on failure. Probed at runtime; PNG path is the fallback.
|
|
86
86
|
*/
|
|
87
87
|
satoru_render_to_converter?: (...args: unknown[]) => boolean;
|
|
88
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Animated (opacity / transform / color) HTML render into a converter's
|
|
90
|
+
* frames, additive: `(satoruInst, converterInst, htmls, width, height,
|
|
91
|
+
* options, durationMs, fps)`. Frames come from `@keyframes` + `animation` in
|
|
92
|
+
* the document (loop infinite); returns the number of frames appended
|
|
93
|
+
* (0 on failure).
|
|
94
|
+
*/
|
|
95
|
+
satoru_render_animation_to_converter?: (...args: unknown[]) => number;
|
|
96
|
+
/**
|
|
97
|
+
* Image encode wrapper (positional: instance, format, quality, speed,
|
|
98
|
+
* animation, animationLoop). `animationLoop` is the animated WebP loop
|
|
99
|
+
* count (0 = infinite, the default).
|
|
100
|
+
*/
|
|
89
101
|
converter_encode?: (...args: unknown[]) => unknown;
|
|
90
102
|
/**
|
|
91
103
|
* Unified Bitmap-direct call (`render_bitmap_to_encoded` exposed to JS,
|
|
@@ -115,6 +127,13 @@ export interface LoadOptions {
|
|
|
115
127
|
/** Bypass the process-wide cache (tests). */
|
|
116
128
|
noCache?: boolean;
|
|
117
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* A failure that is likely to disappear on a second attempt (the `.wasm`
|
|
132
|
+
* was read while it was still being written).
|
|
133
|
+
*/
|
|
134
|
+
export declare class TransientWasmError extends Error {
|
|
135
|
+
constructor(message: string);
|
|
136
|
+
}
|
|
118
137
|
/** Default glue URL: `./html-to-image.js` next to the compiled loader. */
|
|
119
138
|
export declare function defaultGlueUrl(): URL;
|
|
120
139
|
/**
|
|
@@ -134,6 +153,10 @@ export declare function importNode<T = unknown>(name: string): Promise<T>;
|
|
|
134
153
|
* Load and instantiate the unified module (cached per process, keyed by
|
|
135
154
|
* factory then moduleArg — see `moduleCache`).
|
|
136
155
|
* Works in Node (file URL import) and browsers (served URL import).
|
|
156
|
+
*
|
|
157
|
+
* Parallel calls with the same key share one instantiation (see
|
|
158
|
+
* {@link pendingLoads}); a failed load is not cached, so a later call
|
|
159
|
+
* retries.
|
|
137
160
|
*/
|
|
138
161
|
export declare function loadHtmlToImageModule(options?: LoadOptions): Promise<HtmlToImageModule>;
|
|
139
162
|
/** Drop all cached instances (tests / HMR). */
|
|
@@ -143,8 +166,13 @@ export declare function dropCachedModule(): void;
|
|
|
143
166
|
* Shared adapter for workerd / edge-light entry points (behavior unchanged:
|
|
144
167
|
* `WebAssembly.instantiate` + success-callback override, empty object return
|
|
145
168
|
* for emscripten). The `label` only selects the console-error prefix.
|
|
169
|
+
*
|
|
170
|
+
* `onFailure` (optional) is notified when the instantiation fails. The
|
|
171
|
+
* emscripten glue only offers a success callback, so without it a failed
|
|
172
|
+
* instantiation leaves the module promise pending forever; the caller uses
|
|
173
|
+
* this hook to turn the failure into a rejected promise.
|
|
146
174
|
*/
|
|
147
|
-
export declare function precompiledModuleArg(wasm: WebAssembly.Module, label: string): EmscriptenModuleArg;
|
|
175
|
+
export declare function precompiledModuleArg(wasm: WebAssembly.Module, label: string, onFailure?: (e: unknown) => void): EmscriptenModuleArg;
|
|
148
176
|
/**
|
|
149
177
|
* Load (and reuse) the unified module for a pre-compiled `WebAssembly.Module`.
|
|
150
178
|
* Per-`wasm` cache with failure eviction (a second call with a different
|
package/dist/loader.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* file URL, then invoking the factory. No `node:` builtins are used so the
|
|
20
20
|
* same file works in both runtimes.
|
|
21
21
|
*/
|
|
22
|
+
import { checkWasmBinary } from "./wasm-binary.js";
|
|
22
23
|
/**
|
|
23
24
|
* Process-wide module cache, keyed by factory then moduleArg: different
|
|
24
25
|
* factories, or different WASM payloads (e.g. workerd/edge-light calls
|
|
@@ -27,6 +28,37 @@
|
|
|
27
28
|
* single.ts default path, `*-workers` lazy vars) are unaffected.
|
|
28
29
|
*/
|
|
29
30
|
const moduleCache = new Map();
|
|
31
|
+
/**
|
|
32
|
+
* In-flight loads, keyed like {@link moduleCache} (factory then moduleArg).
|
|
33
|
+
*
|
|
34
|
+
* The resolved-module cache only helps *after* the first load finished, so
|
|
35
|
+
* N parallel calls (a worker pool starting up, a burst of requests) each
|
|
36
|
+
* instantiated their own copy of the module — a 12 MB binary plus its
|
|
37
|
+
* linear memory, N times over. Sharing the pending promise makes parallel
|
|
38
|
+
* initialization instantiate exactly once.
|
|
39
|
+
*/
|
|
40
|
+
const pendingLoads = new Map();
|
|
41
|
+
/**
|
|
42
|
+
* How many times a load is attempted before the error is reported.
|
|
43
|
+
*
|
|
44
|
+
* A truncated `.wasm` (read while a concurrent build was still writing it)
|
|
45
|
+
* is transient: re-reading the file a moment later usually yields the
|
|
46
|
+
* complete binary. Two attempts are enough to cover that window without
|
|
47
|
+
* hiding a real failure.
|
|
48
|
+
*/
|
|
49
|
+
const LOAD_ATTEMPTS = 2;
|
|
50
|
+
/** Delay before the retry of a transient (truncated binary) failure. */
|
|
51
|
+
const LOAD_RETRY_DELAY_MS = 150;
|
|
52
|
+
/**
|
|
53
|
+
* A failure that is likely to disappear on a second attempt (the `.wasm`
|
|
54
|
+
* was read while it was still being written).
|
|
55
|
+
*/
|
|
56
|
+
export class TransientWasmError extends Error {
|
|
57
|
+
constructor(message) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "TransientWasmError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
30
62
|
/** Default glue URL: `./html-to-image.js` next to the compiled loader. */
|
|
31
63
|
export function defaultGlueUrl() {
|
|
32
64
|
return new URL("./html-to-image.js", import.meta.url);
|
|
@@ -129,6 +161,10 @@ async function readWasmBinaryNode(glueDir) {
|
|
|
129
161
|
* Load and instantiate the unified module (cached per process, keyed by
|
|
130
162
|
* factory then moduleArg — see `moduleCache`).
|
|
131
163
|
* Works in Node (file URL import) and browsers (served URL import).
|
|
164
|
+
*
|
|
165
|
+
* Parallel calls with the same key share one instantiation (see
|
|
166
|
+
* {@link pendingLoads}); a failed load is not cached, so a later call
|
|
167
|
+
* retries.
|
|
132
168
|
*/
|
|
133
169
|
export async function loadHtmlToImageModule(options = {}) {
|
|
134
170
|
const cacheKeyFactory = options.factory ?? undefined;
|
|
@@ -137,7 +173,80 @@ export async function loadHtmlToImageModule(options = {}) {
|
|
|
137
173
|
const hit = moduleCache.get(cacheKeyFactory)?.get(cacheKeyModuleArg);
|
|
138
174
|
if (hit)
|
|
139
175
|
return hit;
|
|
176
|
+
// A load is already running for this key: share it instead of
|
|
177
|
+
// instantiating a second copy of the module.
|
|
178
|
+
const pending = pendingLoads.get(cacheKeyFactory)?.get(cacheKeyModuleArg);
|
|
179
|
+
if (pending)
|
|
180
|
+
return pending;
|
|
140
181
|
}
|
|
182
|
+
const promise = loadHtmlToImageModuleOnce(options, cacheKeyFactory, cacheKeyModuleArg);
|
|
183
|
+
if (!options.noCache) {
|
|
184
|
+
let inner = pendingLoads.get(cacheKeyFactory);
|
|
185
|
+
if (!inner) {
|
|
186
|
+
inner = new Map();
|
|
187
|
+
pendingLoads.set(cacheKeyFactory, inner);
|
|
188
|
+
}
|
|
189
|
+
inner.set(cacheKeyModuleArg, promise);
|
|
190
|
+
// A rejected load must not poison the cache: drop it so a later call
|
|
191
|
+
// retries (a truncated `.wasm` may be complete by then).
|
|
192
|
+
promise.catch(() => {
|
|
193
|
+
if (inner?.get(cacheKeyModuleArg) === promise) {
|
|
194
|
+
inner.delete(cacheKeyModuleArg);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return promise;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Load the module, retrying once when the failure is transient (the
|
|
202
|
+
* `.wasm` was read while it was still being written).
|
|
203
|
+
*/
|
|
204
|
+
async function loadHtmlToImageModuleOnce(options, cacheKeyFactory, cacheKeyModuleArg) {
|
|
205
|
+
for (let attempt = 1;; attempt++) {
|
|
206
|
+
try {
|
|
207
|
+
return await loadHtmlToImageModuleUncached(options, cacheKeyFactory, cacheKeyModuleArg);
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
if (attempt >= LOAD_ATTEMPTS || !isTransientWasmFailure(e)) {
|
|
211
|
+
throw e;
|
|
212
|
+
}
|
|
213
|
+
await new Promise((r) => setTimeout(r, LOAD_RETRY_DELAY_MS));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* True when the failure looks like a corrupt/truncated binary (retryable).
|
|
219
|
+
*
|
|
220
|
+
* The engine reports a truncated module as `CompileError`; the emscripten
|
|
221
|
+
* glue wraps it into `RuntimeError("Aborted(CompileError: ...)")`, so the
|
|
222
|
+
* message is inspected as well.
|
|
223
|
+
*/
|
|
224
|
+
function isTransientWasmFailure(e) {
|
|
225
|
+
if (e instanceof TransientWasmError)
|
|
226
|
+
return true;
|
|
227
|
+
if (typeof WebAssembly !== "undefined" && e instanceof WebAssembly.CompileError)
|
|
228
|
+
return true;
|
|
229
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
230
|
+
return /CompileError/.test(message);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Explain a load failure in terms of the artifact that is actually broken:
|
|
234
|
+
* a truncated `.wasm` (read while a build was writing it) or a
|
|
235
|
+
* glue/binary mismatch (the two come from different builds).
|
|
236
|
+
*/
|
|
237
|
+
function explainWasmLoadFailure(e, glueLabel) {
|
|
238
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
239
|
+
if (/Import #\d+/.test(message)) {
|
|
240
|
+
return new Error(`wasm-html-to-image: the WebAssembly binary and the JavaScript ` +
|
|
241
|
+
`glue do not match (${message}). The artifacts in ${glueLabel} ` +
|
|
242
|
+
`are probably from different builds - rebuild the glue and the ` +
|
|
243
|
+
`.wasm together.`, { cause: e });
|
|
244
|
+
}
|
|
245
|
+
if (e instanceof Error)
|
|
246
|
+
return e;
|
|
247
|
+
return new Error(message);
|
|
248
|
+
}
|
|
249
|
+
async function loadHtmlToImageModuleUncached(options, cacheKeyFactory, cacheKeyModuleArg) {
|
|
141
250
|
let factory = options.factory;
|
|
142
251
|
let glueLabel = "(pre-bundled factory)";
|
|
143
252
|
let glueDir = "";
|
|
@@ -173,16 +282,51 @@ export async function loadHtmlToImageModule(options = {}) {
|
|
|
173
282
|
// workerd with `instantiateWasm`, is left untouched).
|
|
174
283
|
let moduleArg = options.moduleArg;
|
|
175
284
|
if (!options.factory && glueDir && moduleArg?.wasmBinary === undefined) {
|
|
285
|
+
const wasmPath = glueDirToFsPath(glueDir) + "html-to-image.wasm";
|
|
176
286
|
const wasmBinary = await readWasmBinaryNode(glueDir);
|
|
177
|
-
if (wasmBinary)
|
|
287
|
+
if (wasmBinary) {
|
|
288
|
+
// A file that is still being written yields a truncated binary and
|
|
289
|
+
// a cryptic `section Data extends past end` deep inside the engine.
|
|
290
|
+
const check = checkWasmBinary(wasmBinary);
|
|
291
|
+
if (!check.ok) {
|
|
292
|
+
throw new TransientWasmError(`wasm-html-to-image: invalid WebAssembly binary at ${wasmPath}: ` +
|
|
293
|
+
`${check.reason} - the file was probably read while it was ` +
|
|
294
|
+
`still being written (concurrent build); retry once the ` +
|
|
295
|
+
`build finished.`);
|
|
296
|
+
}
|
|
178
297
|
moduleArg = { ...moduleArg, wasmBinary };
|
|
298
|
+
}
|
|
299
|
+
else if (isNodeRuntime()) {
|
|
300
|
+
throw new TransientWasmError(`wasm-html-to-image: cannot read the WebAssembly binary at ` +
|
|
301
|
+
`${wasmPath}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const provided = moduleArg?.wasmBinary;
|
|
305
|
+
const providedBytes = provided instanceof Uint8Array
|
|
306
|
+
? provided
|
|
307
|
+
: provided instanceof ArrayBuffer
|
|
308
|
+
? new Uint8Array(provided)
|
|
309
|
+
: undefined;
|
|
310
|
+
if (providedBytes) {
|
|
311
|
+
// Caller-supplied bytes (browser worker payload, edge runtimes) get
|
|
312
|
+
// the same structural check.
|
|
313
|
+
const check = checkWasmBinary(providedBytes);
|
|
314
|
+
if (!check.ok) {
|
|
315
|
+
throw new TransientWasmError(`wasm-html-to-image: invalid WebAssembly binary: ${check.reason}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
let module;
|
|
319
|
+
try {
|
|
320
|
+
module = await factory({
|
|
321
|
+
...moduleArg,
|
|
322
|
+
locateFile: options.locateFile ??
|
|
323
|
+
moduleArg?.locateFile ??
|
|
324
|
+
((path) => glueDir + path),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
catch (e) {
|
|
328
|
+
throw explainWasmLoadFailure(e, glueLabel);
|
|
179
329
|
}
|
|
180
|
-
const module = await factory({
|
|
181
|
-
...moduleArg,
|
|
182
|
-
locateFile: options.locateFile ??
|
|
183
|
-
moduleArg?.locateFile ??
|
|
184
|
-
((path) => glueDir + path),
|
|
185
|
-
});
|
|
186
330
|
if (!options.noCache) {
|
|
187
331
|
let inner = moduleCache.get(cacheKeyFactory);
|
|
188
332
|
if (!inner) {
|
|
@@ -196,14 +340,20 @@ export async function loadHtmlToImageModule(options = {}) {
|
|
|
196
340
|
/** Drop all cached instances (tests / HMR). */
|
|
197
341
|
export function dropCachedModule() {
|
|
198
342
|
moduleCache.clear();
|
|
343
|
+
pendingLoads.clear();
|
|
199
344
|
}
|
|
200
345
|
/**
|
|
201
346
|
* Build Emscripten module args for a pre-compiled `WebAssembly.Module`.
|
|
202
347
|
* Shared adapter for workerd / edge-light entry points (behavior unchanged:
|
|
203
348
|
* `WebAssembly.instantiate` + success-callback override, empty object return
|
|
204
349
|
* for emscripten). The `label` only selects the console-error prefix.
|
|
350
|
+
*
|
|
351
|
+
* `onFailure` (optional) is notified when the instantiation fails. The
|
|
352
|
+
* emscripten glue only offers a success callback, so without it a failed
|
|
353
|
+
* instantiation leaves the module promise pending forever; the caller uses
|
|
354
|
+
* this hook to turn the failure into a rejected promise.
|
|
205
355
|
*/
|
|
206
|
-
export function precompiledModuleArg(wasm, label) {
|
|
356
|
+
export function precompiledModuleArg(wasm, label, onFailure) {
|
|
207
357
|
return {
|
|
208
358
|
instantiateWasm: (imports, successCallback) => {
|
|
209
359
|
WebAssembly.instantiate(wasm, imports)
|
|
@@ -212,6 +362,7 @@ export function precompiledModuleArg(wasm, label) {
|
|
|
212
362
|
})
|
|
213
363
|
.catch((e) => {
|
|
214
364
|
console.error(`wasm-html-to-image [${label}]: Wasm instantiation failed:`, e);
|
|
365
|
+
onFailure?.(e);
|
|
215
366
|
});
|
|
216
367
|
return {}; // Return empty object as emscripten expects
|
|
217
368
|
},
|
|
@@ -227,10 +378,20 @@ export function precompiledModuleArg(wasm, label) {
|
|
|
227
378
|
export async function loadPrecompiledModule(factory, wasm, cache, label) {
|
|
228
379
|
let pending = cache.get(wasm);
|
|
229
380
|
if (!pending) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
381
|
+
// The glue only reports instantiation failures through the console,
|
|
382
|
+
// so the failure is mirrored into a rejected promise: otherwise the
|
|
383
|
+
// module promise never settles and the caller waits forever.
|
|
384
|
+
let reportFailure = () => { };
|
|
385
|
+
const failure = new Promise((_resolve, reject) => {
|
|
386
|
+
reportFailure = reject;
|
|
233
387
|
});
|
|
388
|
+
pending = Promise.race([
|
|
389
|
+
loadHtmlToImageModule({
|
|
390
|
+
factory,
|
|
391
|
+
moduleArg: precompiledModuleArg(wasm, label, reportFailure),
|
|
392
|
+
}),
|
|
393
|
+
failure,
|
|
394
|
+
]);
|
|
234
395
|
cache.set(wasm, pending);
|
|
235
396
|
pending.catch(() => {
|
|
236
397
|
if (cache.get(wasm) === pending)
|
package/dist/tailwind.d.ts
CHANGED
|
@@ -1 +1,38 @@
|
|
|
1
|
-
|
|
1
|
+
/** Options shared by {@link createCSS}, {@link tailwind} and {@link convertTw}. */
|
|
2
|
+
export interface TailwindOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Extra stylesheet that may use Tailwind v4 `@theme { ... }` and `@apply`.
|
|
5
|
+
* It is converted like the `<style>` blocks of the HTML and appended to the result.
|
|
6
|
+
*/
|
|
7
|
+
css?: string;
|
|
8
|
+
/** Called for problems that are skipped (unknown / unsupported `@apply` utilities). Defaults to `console.warn`. */
|
|
9
|
+
warn?: (message: string) => void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Rewrite Tailwind v4 style `tw="..."` attributes into `class` (merged with an existing `class`).
|
|
13
|
+
* Comments, `<script>` and `<style>` contents are left untouched.
|
|
14
|
+
*/
|
|
15
|
+
export declare const convertTw: (html: string) => string;
|
|
16
|
+
/**
|
|
17
|
+
* Generate the CSS for `html`: UnoCSS / Tailwind utilities for every class (and `tw`) token,
|
|
18
|
+
* plus Tailwind v4 `@theme` variables (`:root`) and `@apply` expansion for `<style>` blocks of
|
|
19
|
+
* `html` and for `options.css`.
|
|
20
|
+
*
|
|
21
|
+
* `tw="..."` attributes are not rewritten here: pass the HTML through {@link convertTw} (or use
|
|
22
|
+
* {@link tailwind}) so the elements actually carry the classes.
|
|
23
|
+
*/
|
|
24
|
+
export declare const createCSS: (html: string, options?: TailwindOptions) => Promise<string>;
|
|
25
|
+
/**
|
|
26
|
+
* One call for Tailwind v4 style markup: `tw="..."` becomes `class`, and the returned `css` holds the
|
|
27
|
+
* utilities, `@theme` variables and `@apply` expansion. Feed both to `render()`.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* const { html, css } = await tailwind('<div tw="flex gap-2 bg-brand">…</div>', {
|
|
31
|
+
* css: "@theme { --color-brand: #f00 }",
|
|
32
|
+
* });
|
|
33
|
+
* await render({ value: html, css, width: 600, format: "png" });
|
|
34
|
+
*/
|
|
35
|
+
export declare const tailwind: (html: string, options?: TailwindOptions) => Promise<{
|
|
36
|
+
html: string;
|
|
37
|
+
css: string;
|
|
38
|
+
}>;
|
package/dist/tailwind.js
CHANGED
|
@@ -1,6 +1,190 @@
|
|
|
1
1
|
import { createGenerator } from "@unocss/core";
|
|
2
2
|
import presetUno from "@unocss/preset-wind4";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
const warnDefault = (message) => console.warn(message);
|
|
4
|
+
// --- tw prop -> class ------------------------------------------------------
|
|
5
|
+
const PROTECTED_BLOCKS = /<!--[\s\S]*?-->|<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
|
|
6
|
+
const OPEN_TAG = /<([a-zA-Z][\w:-]*)((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'<>=`]+))?)*)\s*(\/?)>/g;
|
|
7
|
+
const ATTR = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>=`]+)))?/g;
|
|
8
|
+
const escapeAttr = (value) => value.replace(/"/g, """);
|
|
9
|
+
/**
|
|
10
|
+
* Rewrite Tailwind v4 style `tw="..."` attributes into `class` (merged with an existing `class`).
|
|
11
|
+
* Comments, `<script>` and `<style>` contents are left untouched.
|
|
12
|
+
*/
|
|
13
|
+
export const convertTw = (html) => {
|
|
14
|
+
const protectedParts = [];
|
|
15
|
+
const masked = html.replace(PROTECTED_BLOCKS, (block) => {
|
|
16
|
+
protectedParts.push(block);
|
|
17
|
+
return `\u0000${protectedParts.length - 1}\u0000`;
|
|
18
|
+
});
|
|
19
|
+
const converted = masked.replace(OPEN_TAG, (whole, name, attrs, selfClose) => {
|
|
20
|
+
if (!/\stw\b/i.test(attrs))
|
|
21
|
+
return whole;
|
|
22
|
+
let twValue = "";
|
|
23
|
+
let classValue;
|
|
24
|
+
const kept = [];
|
|
25
|
+
for (const m of attrs.matchAll(ATTR)) {
|
|
26
|
+
const attrName = m[1];
|
|
27
|
+
const value = m[2] ?? m[3] ?? m[4] ?? "";
|
|
28
|
+
const lower = attrName.toLowerCase();
|
|
29
|
+
if (lower === "tw") {
|
|
30
|
+
twValue = `${twValue} ${value}`.trim();
|
|
31
|
+
}
|
|
32
|
+
else if (lower === "class") {
|
|
33
|
+
classValue = `${classValue ?? ""} ${value}`.trim();
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
kept.push(m[0]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!twValue)
|
|
40
|
+
return whole;
|
|
41
|
+
const merged = `${classValue ?? ""} ${twValue}`.trim();
|
|
42
|
+
const rest = kept.length ? ` ${kept.join(" ")}` : "";
|
|
43
|
+
return `<${name}${rest} class="${escapeAttr(merged)}"${selfClose ? " /" : ""}>`;
|
|
44
|
+
});
|
|
45
|
+
return converted.replace(/\u0000(\d+)\u0000/g, (_, i) => protectedParts[Number(i)]);
|
|
46
|
+
};
|
|
47
|
+
// --- @theme / @apply -------------------------------------------------------
|
|
48
|
+
/** Index of the `}` matching the `{` at `open`, or -1. */
|
|
49
|
+
const matchBrace = (css, open) => {
|
|
50
|
+
let depth = 0;
|
|
51
|
+
for (let i = open; i < css.length; i++) {
|
|
52
|
+
const ch = css[i];
|
|
53
|
+
if (ch === "{")
|
|
54
|
+
depth++;
|
|
55
|
+
else if (ch === "}" && --depth === 0)
|
|
56
|
+
return i;
|
|
57
|
+
}
|
|
58
|
+
return -1;
|
|
59
|
+
};
|
|
60
|
+
/** Replace every `@theme { --x: v }` block by a `:root` rule carrying the same variables. */
|
|
61
|
+
const extractTheme = (css) => {
|
|
62
|
+
const variables = {};
|
|
63
|
+
let out = "";
|
|
64
|
+
let pos = 0;
|
|
65
|
+
const re = /@theme\b[^{;]*\{/g;
|
|
66
|
+
for (let m = re.exec(css); m; m = re.exec(css)) {
|
|
67
|
+
const open = m.index + m[0].length - 1;
|
|
68
|
+
const close = matchBrace(css, open);
|
|
69
|
+
if (close < 0)
|
|
70
|
+
break;
|
|
71
|
+
const body = css.slice(open + 1, close);
|
|
72
|
+
const decls = [];
|
|
73
|
+
for (const d of body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);?/g)) {
|
|
74
|
+
variables[d[1]] = d[2].trim();
|
|
75
|
+
decls.push(`${d[1]}:${d[2].trim()}`);
|
|
76
|
+
}
|
|
77
|
+
out += css.slice(pos, m.index);
|
|
78
|
+
out += decls.length ? `:root{${decls.join(";")}}` : "";
|
|
79
|
+
pos = close + 1;
|
|
80
|
+
re.lastIndex = pos;
|
|
81
|
+
}
|
|
82
|
+
return { css: out + css.slice(pos), variables };
|
|
83
|
+
};
|
|
84
|
+
/** `--color-brand-500` style theme variables as a UnoCSS `theme.colors` tree. */
|
|
85
|
+
const themeColors = (variables) => {
|
|
86
|
+
const colors = {};
|
|
87
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
88
|
+
const m = /^--color-([\w-]+)$/.exec(key);
|
|
89
|
+
if (!m)
|
|
90
|
+
continue;
|
|
91
|
+
const parts = m[1].split("-");
|
|
92
|
+
let node = colors;
|
|
93
|
+
for (const [i, part] of parts.entries()) {
|
|
94
|
+
const existing = node[part];
|
|
95
|
+
if (i === parts.length - 1) {
|
|
96
|
+
// `--color-brand` and `--color-brand-500` coexist as brand.DEFAULT + brand[500].
|
|
97
|
+
if (existing && typeof existing === "object")
|
|
98
|
+
existing.DEFAULT = value;
|
|
99
|
+
else
|
|
100
|
+
node[part] = value;
|
|
101
|
+
}
|
|
102
|
+
else if (existing && typeof existing === "object") {
|
|
103
|
+
node = existing;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const next = typeof existing === "string" ? { DEFAULT: existing } : {};
|
|
107
|
+
node[part] = next;
|
|
108
|
+
node = next;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return colors;
|
|
113
|
+
};
|
|
114
|
+
const APPLY = /@apply\s+([^;}]+?)\s*(;|(?=}))/g;
|
|
115
|
+
const applyTokens = (css) => {
|
|
116
|
+
const tokens = [];
|
|
117
|
+
for (const m of css.matchAll(APPLY))
|
|
118
|
+
tokens.push(...m[1].split(/\s+/));
|
|
119
|
+
return tokens.filter(Boolean);
|
|
120
|
+
};
|
|
121
|
+
const expandApply = async (css, uno, warn) => {
|
|
122
|
+
const cache = new Map();
|
|
123
|
+
for (const token of new Set(applyTokens(css))) {
|
|
124
|
+
const parsed = await uno.parseToken(token);
|
|
125
|
+
// Only plain utilities (`.token { ... }`) can be inlined into another rule.
|
|
126
|
+
const plain = parsed?.filter((u) => !u[3] && u[1] && u[1].replace(/\\/g, "") === `.${token}`);
|
|
127
|
+
if (!plain || plain.length === 0) {
|
|
128
|
+
warn(`@apply: cannot resolve utility "${token}"; ignored.`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
cache.set(token, plain.map((u) => u[2].trim().replace(/;+$/, "")).join(";"));
|
|
132
|
+
}
|
|
133
|
+
return css.replace(APPLY, (_, list, end) => {
|
|
134
|
+
const body = list
|
|
135
|
+
.split(/\s+/)
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.map((token) => cache.get(token))
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.join(";");
|
|
140
|
+
return body ? `${body}${end === ";" ? ";" : ""}` : "";
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
const STYLE_BLOCK = /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi;
|
|
144
|
+
const needsTailwindCss = (css) => /@theme\b|@apply\b/.test(css);
|
|
145
|
+
/**
|
|
146
|
+
* Generate the CSS for `html`: UnoCSS / Tailwind utilities for every class (and `tw`) token,
|
|
147
|
+
* plus Tailwind v4 `@theme` variables (`:root`) and `@apply` expansion for `<style>` blocks of
|
|
148
|
+
* `html` and for `options.css`.
|
|
149
|
+
*
|
|
150
|
+
* `tw="..."` attributes are not rewritten here: pass the HTML through {@link convertTw} (or use
|
|
151
|
+
* {@link tailwind}) so the elements actually carry the classes.
|
|
152
|
+
*/
|
|
153
|
+
export const createCSS = async (html, options = {}) => {
|
|
154
|
+
const warn = options.warn ?? warnDefault;
|
|
155
|
+
const userBlocks = [];
|
|
156
|
+
for (const m of html.matchAll(STYLE_BLOCK)) {
|
|
157
|
+
if (needsTailwindCss(m[1]))
|
|
158
|
+
userBlocks.push(m[1]);
|
|
159
|
+
}
|
|
160
|
+
if (options.css)
|
|
161
|
+
userBlocks.push(options.css);
|
|
162
|
+
// Theme tokens feed UnoCSS' theme (colors) so utilities such as `bg-brand` resolve.
|
|
163
|
+
const themed = userBlocks.map((css) => extractTheme(css));
|
|
164
|
+
const variables = Object.assign({}, ...themed.map((t) => t.variables));
|
|
165
|
+
const uno = await createGenerator({
|
|
166
|
+
presets: [presetUno()],
|
|
167
|
+
theme: { colors: themeColors(variables) },
|
|
168
|
+
});
|
|
169
|
+
const extraTokens = userBlocks.flatMap(applyTokens).join(" ");
|
|
170
|
+
const utilities = (await uno.generate(`${html}\n${extraTokens}`)).css;
|
|
171
|
+
const converted = [];
|
|
172
|
+
for (const t of themed) {
|
|
173
|
+
converted.push(await expandApply(t.css, uno, warn));
|
|
174
|
+
}
|
|
175
|
+
return [utilities, ...converted.filter((css) => css.trim())].join("\n");
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* One call for Tailwind v4 style markup: `tw="..."` becomes `class`, and the returned `css` holds the
|
|
179
|
+
* utilities, `@theme` variables and `@apply` expansion. Feed both to `render()`.
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* const { html, css } = await tailwind('<div tw="flex gap-2 bg-brand">…</div>', {
|
|
183
|
+
* css: "@theme { --color-brand: #f00 }",
|
|
184
|
+
* });
|
|
185
|
+
* await render({ value: html, css, width: 600, format: "png" });
|
|
186
|
+
*/
|
|
187
|
+
export const tailwind = async (html, options = {}) => {
|
|
188
|
+
const converted = convertTw(html);
|
|
189
|
+
return { html: converted, css: await createCSS(converted, options) };
|
|
6
190
|
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal WASM binary sanity check.
|
|
3
|
+
*
|
|
4
|
+
* The loader hands the `.wasm` bytes straight to the engine, so a file
|
|
5
|
+
* that is still being written (concurrent build) or that belongs to a
|
|
6
|
+
* different build than the JavaScript glue fails deep inside V8 with
|
|
7
|
+
* messages that do not point at the real cause:
|
|
8
|
+
*
|
|
9
|
+
* - `CompileError: section Data extends past end` — the file was read
|
|
10
|
+
* while it was still being written (truncated binary).
|
|
11
|
+
* - `Import #0 "env" is not an object` — the binary and the glue come
|
|
12
|
+
* from different builds (the import namespace does not match).
|
|
13
|
+
*
|
|
14
|
+
* {@link checkWasmBinary} detects the truncated case before the engine
|
|
15
|
+
* sees it and reports the import namespaces, so the loader can explain
|
|
16
|
+
* a mismatch instead of surfacing a cryptic engine error.
|
|
17
|
+
*/
|
|
18
|
+
/** Result of {@link checkWasmBinary}. */
|
|
19
|
+
export interface WasmBinaryCheck {
|
|
20
|
+
/** True when the bytes are a structurally complete WASM binary. */
|
|
21
|
+
ok: boolean;
|
|
22
|
+
/** Why the check failed (only set when `ok` is false). */
|
|
23
|
+
reason?: string;
|
|
24
|
+
/** Import namespaces (module names) the binary requires. */
|
|
25
|
+
namespaces: string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Check that `bytes` is a structurally complete WASM binary.
|
|
29
|
+
*
|
|
30
|
+
* Walks the section table: a truncated file (the classic symptom of
|
|
31
|
+
* reading a file that is still being written) shows up as a section
|
|
32
|
+
* whose declared length runs past the end of the buffer.
|
|
33
|
+
*/
|
|
34
|
+
export declare function checkWasmBinary(bytes: Uint8Array): WasmBinaryCheck;
|