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.
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
- /** Image encode wrapper (positional: instance, format, quality, speed, animation). */
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
- pending = loadHtmlToImageModule({
231
- factory,
232
- moduleArg: precompiledModuleArg(wasm, label),
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)
@@ -1 +1,38 @@
1
- export declare const createCSS: (html: string) => Promise<string>;
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
- export const createCSS = async (html) => {
4
- const uno = await createGenerator({ presets: [presetUno()] });
5
- return uno.generate(html).then((v) => v.css);
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, "&quot;");
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;