diff --git a/src/animated-cache.js b/src/animated-cache.js new file mode 100644 index 0000000000000000000000000000000000000000..3ab3e8466866d99c20959638523c00cd6d4ca819 --- /dev/null +++ b/src/animated-cache.js @@ -0,0 +1,240 @@ +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import path from 'node:path'; + +const CACHE_DIR = path.join(process.cwd(), 'node_modules/.cache/enhanced-img-animated'); + +/** + * @param {string} filepath + * @returns {string} + */ +function file_hash(filepath) { + const content = readFileSync(filepath); + return createHash('sha256').update(content).digest('hex'); +} + +// ─── Animated WebP cache ───────────────────────────────────────────────────── + +/** + * @typedef {{ width: number, height: number, pages: number, loop: number }} AnimatedMeta + * @returns {{ webp_buf: Buffer, meta: AnimatedMeta } | null} + */ +export function read_animated_cache(filepath) { + try { + const dir = path.join(CACHE_DIR, 'animated', file_hash(filepath)); + const webp_path = path.join(dir, 'output.webp'); + const meta_path = path.join(dir, 'meta.json'); + if (!existsSync(webp_path) || !existsSync(meta_path)) return null; + return { + webp_buf: readFileSync(webp_path), + meta: JSON.parse(readFileSync(meta_path, 'utf8')) + }; + } catch { + return null; + } +} + +/** + * @param {string} filepath + * @param {Buffer} webp_buf + * @param {AnimatedMeta} meta + */ +export function write_animated_cache(filepath, webp_buf, meta) { + try { + const dir = path.join(CACHE_DIR, 'animated', file_hash(filepath)); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'output.webp'), webp_buf); + writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(meta)); + } catch { /* non-fatal */ } +} + +// ─── MP4 / video cache ─────────────────────────────────────────────────────── + +/** + * @typedef {{ width: number, height: number }} VideoMeta + * @returns {{ webm_buf: Buffer, meta: VideoMeta } | null} + */ +export function read_video_cache(filepath) { + try { + const dir = path.join(CACHE_DIR, 'video', file_hash(filepath)); + const webm_path = path.join(dir, 'output.webm'); + const meta_path = path.join(dir, 'meta.json'); + if (!existsSync(webm_path) || !existsSync(meta_path)) return null; + return { + webm_buf: readFileSync(webm_path), + meta: JSON.parse(readFileSync(meta_path, 'utf8')) + }; + } catch { + return null; + } +} + +/** + * @param {string} filepath + * @param {Buffer} webm_buf + * @param {VideoMeta} meta + */ +export function write_video_cache(filepath, webm_buf, meta) { + try { + const dir = path.join(CACHE_DIR, 'video', file_hash(filepath)); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'output.webm'), webm_buf); + writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(meta)); + } catch { /* non-fatal */ } +} + +// ─── General imagetools cache ───────────────────────────────────────────────── +// Each cache entry stores the emitted asset buffers plus the module code template +// with __VITE_ASSET__ refs replaced by stable placeholder keys ($REF[key]). +// On cache hit the buffers are re-emitted (producing fresh ref IDs) and the +// placeholders are substituted with the new IDs before the module is returned. + +/** + * @typedef {{ key: string, name: string, source: Buffer }} CachedAsset + * @typedef {{ assets: CachedAsset[], template: string }} ImgCacheEntry + */ + +/** + * @param {string} filepath + * @param {string} query raw query string (everything after '?') + * @returns {ImgCacheEntry | null} + */ +export function read_img_cache(filepath, query) { + try { + const hash = createHash('sha256') + .update(file_hash(filepath)) + .update('\0') + .update(normalise_query(query)) + .digest('hex'); + const dir = path.join(CACHE_DIR, 'img', hash); + const index_path = path.join(dir, 'index.json'); + if (!existsSync(index_path)) return null; + const index = JSON.parse(readFileSync(index_path, 'utf8')); + const assets = index.assets.map((/** @type {any} */ a) => ({ + key: a.key, + name: a.name, + source: readFileSync(path.join(dir, a.file)) + })); + return { assets, template: index.template }; + } catch { + return null; + } +} + +/** + * @param {string} filepath + * @param {string} query + * @param {ImgCacheEntry} entry + */ +export function write_img_cache(filepath, query, entry) { + try { + const hash = createHash('sha256') + .update(file_hash(filepath)) + .update('\0') + .update(normalise_query(query)) + .digest('hex'); + const dir = path.join(CACHE_DIR, 'img', hash); + mkdirSync(dir, { recursive: true }); + const index_assets = entry.assets.map((a, i) => { + const file = `asset_${i}_${a.name}`; + writeFileSync(path.join(dir, file), a.source); + return { key: a.key, name: a.name, file }; + }); + writeFileSync(path.join(dir, 'index.json'), JSON.stringify({ + assets: index_assets, + template: entry.template + })); + } catch { /* non-fatal */ } +} + +/** + * Normalises a query string so key order doesn't matter for cache keying. + * @param {string} query + * @returns {string} + */ +function normalise_query(query) { + const p = new URLSearchParams(query); + return [...p.entries()].sort().map(([k, v]) => `${k}=${v}`).join('&'); +} + +/** + * Wraps a Vite plugin's load hook with the image cache. + * Intercepts emitFile calls during the original load to capture asset buffers, + * then writes them to cache. On subsequent builds, re-emits cached buffers and + * reconstructs the module code. + * + * @param {import('vite').Plugin} plugin the imagetools plugin instance + * @returns {import('vite').Plugin} + */ +export function with_img_cache(plugin) { + const original_hook = plugin.load; + if (!original_hook) return plugin; + const original_fn = typeof original_hook === 'object' ? original_hook.handler : original_hook; + + /** + * @this {import('vite').Rollup.PluginContext} + * @param {string} id + */ + async function cached_load(id) { + // Skip cache entirely in dev/watch mode — emitFile semantics differ there. + if (this.meta?.watchMode) return original_fn.call(this, id); + + const q = id.indexOf('?'); + if (q < 0) return original_fn.call(this, id); + const query = id.slice(q + 1); + const params = new URLSearchParams(query); + if (!params.has('enhanced')) return original_fn.call(this, id); + + const filepath = id.slice(0, q); + + const cached = read_img_cache(filepath, query); + if (cached) { + /** @type {Record} */ + const new_refs = {}; + for (const asset of cached.assets) { + new_refs[asset.key] = this.emitFile({ type: 'asset', name: asset.name, source: asset.source }); + } + return cached.template.replace(/\$REF\[([^\]]+)\]/g, (_, k) => `__VITE_ASSET__${new_refs[k]}__`); + } + + // Cache miss — call original, intercept emitFile to capture assets. + /** @type {CachedAsset[]} */ + const emitted = []; + const original_emit = this.emitFile.bind(this); + + const proxy_ctx = new Proxy(this, { + get(target, prop) { + if (prop === 'emitFile') { + return (/** @type {any} */ opts) => { + const ref = original_emit(opts); + if (opts.type === 'asset' && opts.source) { + emitted.push({ key: ref, name: opts.name ?? '', source: Buffer.from(opts.source) }); + } + return ref; + }; + } + return /** @type {any} */ (target)[prop]; + } + }); + + const result = await original_fn.call(proxy_ctx, id); + + if (result && emitted.length > 0) { + let template = typeof result === 'string' ? result : result.code; + for (const asset of emitted) { + template = template.replaceAll(`__VITE_ASSET__${asset.key}__`, `$REF[${asset.key}]`); + } + write_img_cache(filepath, query, { assets: emitted, template }); + } + + return result; + } + + if (typeof original_hook === 'object') { + plugin.load = { ...original_hook, handler: cached_load }; + } else { + plugin.load = cached_load; + } + + return plugin; +} diff --git a/src/index.js b/src/index.js index 013b9405436dd61d02d947a49c56ae1d2cad9f57..2f9205521c0bd01646f00cd051f158360322d4de 100644 --- a/src/index.js +++ b/src/index.js @@ -1,17 +1,131 @@ import process from 'node:process'; +import path from 'node:path'; +import sharp from 'sharp'; import { imagetools } from 'vite-imagetools'; import { image_plugin } from './vite-plugin.js'; +import { read_animated_cache, write_animated_cache, read_video_cache, write_video_cache, with_img_cache } from './animated-cache.js'; +import { video_dimensions, mp4_to_webm } from './video-convert.js'; /** * @returns {import('vite').Plugin[]} */ export function enhancedImages() { - const imagetools_instance = imagetools_plugin(); + const imagetools_instance = with_img_cache(imagetools_plugin()); + const animated_instance = animated_image_plugin(); return !process.versions.webcontainer - ? [image_plugin(imagetools_instance), imagetools_instance] + ? [animated_instance, image_plugin(imagetools_instance), imagetools_instance] : []; } +/** + * Vite plugin (enforce: 'pre') that intercepts animated GIF/WebP imports that + * carry ?enhanced before imagetools can strip their frames. Produces optimised + * animated WebP and emits them as build assets. In dev mode the original file + * is served unmodified. + * @returns {import('vite').Plugin} + */ +function animated_image_plugin() { + /** @type {import('vite').ResolvedConfig} */ + let vite_config; + + return { + name: 'vite-plugin-enhanced-img-animated', + enforce: /** @type {'pre'} */ ('pre'), + configResolved(config) { + vite_config = config; + }, + async load(id) { + const q = id.indexOf('?'); + const params = new URLSearchParams(q >= 0 ? id.slice(q + 1) : ''); + if (!params.has('enhanced')) return null; + + const filepath = q >= 0 ? id.slice(0, q) : id; + const ext = path.extname(filepath).toLowerCase(); + + // ── MP4 → WebM + MP4 (for ) ────────────────────── + if (ext === '.mp4') { + const stem = path.basename(filepath, ext); + + if (vite_config.command === 'serve') { + const rel = '/' + path.relative(vite_config.root, filepath).replace(/\\/g, '/'); + const { width, height } = await video_dimensions(filepath); + return ( + `export default { sources: { webm: ${JSON.stringify(rel)} },` + + ` video: { src: ${JSON.stringify(rel)}, w: ${width}, h: ${height} } }` + ); + } + + let cached_v = read_video_cache(filepath); + let webm_buf, vid_w, vid_h; + if (cached_v) { + ({ webm_buf, meta: { width: vid_w, height: vid_h } } = cached_v); + } else { + const dims = await video_dimensions(filepath); + vid_w = dims.width; + vid_h = dims.height; + webm_buf = await mp4_to_webm(filepath); + write_video_cache(filepath, webm_buf, { width: vid_w, height: vid_h }); + } + + const webm_ref = this.emitFile({ type: 'asset', name: `${stem}.webm`, source: webm_buf }); + + return [ + `export default {`, + ` sources: { webm: "__VITE_ASSET__${webm_ref}__" },`, + ` video: { src: "__VITE_ASSET__${webm_ref}__", w: ${vid_w}, h: ${vid_h} }`, + `}` + ].join('\n'); + } + + if (ext !== '.gif' && ext !== '.webp') return null; + + const sharp_meta = await sharp(filepath, { animated: true }).metadata(); + if (!sharp_meta.pages || sharp_meta.pages <= 1) return null; // not animated; let imagetools handle it + + const stem = path.basename(filepath, ext); + + if (vite_config.command === 'serve') { + // Dev: serve the original file directly — it is already animated. + const rel = '/' + path.relative(vite_config.root, filepath).replace(/\\/g, '/'); + const w = sharp_meta.width ?? 0; + const h = sharp_meta.pageHeight ?? sharp_meta.height ?? 0; + return ( + `export default { sources: { ${JSON.stringify(ext.slice(1))}: ${JSON.stringify(rel)} },` + + ` img: { src: ${JSON.stringify(rel)}, w: ${w}, h: ${h} } }` + ); + } + + // Build: check cache first, convert only on miss. + let cached = read_animated_cache(filepath); + let webp_buf, width, height; + if (cached) { + ({ webp_buf, meta: { width, height } } = cached); + } else { + webp_buf = await sharp(filepath, { animated: true }) + .webp({ effort: 6, loop: sharp_meta.loop ?? 0 }) + .toBuffer(); + width = sharp_meta.width ?? 0; + height = sharp_meta.pageHeight ?? sharp_meta.height ?? 0; + write_animated_cache(filepath, webp_buf, { + width, + height, + pages: sharp_meta.pages, + loop: sharp_meta.loop ?? 0 + }); + } + + const webp_ref = this.emitFile({ type: 'asset', name: `${stem}.webp`, source: webp_buf }); + + return [ + `export default {`, + ` sources: { webp: "__VITE_ASSET__${webp_ref}__" },`, + ` img: { src: "__VITE_ASSET__${webp_ref}__", w: ${width}, h: ${height} }`, + `}` + ].join('\n'); + } + }; +} + /** * @param {import('sharp').Metadata} meta * @returns {string} diff --git a/src/video-convert.js b/src/video-convert.js new file mode 100644 index 0000000000000000000000000000000000000000..0f2ce0d75e71eee14dea3efc82cb7a7851a9f7d8 --- /dev/null +++ b/src/video-convert.js @@ -0,0 +1,61 @@ +import process from 'node:process'; +import { execFile } from 'node:child_process'; +import { readFileSync, unlinkSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * @param {string} cmd + * @param {string[]} args + * @param {number} [timeout] + * @returns {Promise} + */ +function run(cmd, args, timeout = 120000) { + return new Promise((resolve, reject) => { + execFile(cmd, args, { timeout }, (err, stdout, stderr) => { + if (err) reject(new Error(`${cmd} failed: ${(stderr || '').trim().split('\n').pop()}`)); + else resolve(stdout.trim()); + }); + }); +} + +/** @param {string} ext */ +function tmp_path(ext) { + return path.join(os.tmpdir(), `enhanced_img_${process.hrtime.bigint()}.${ext}`); +} + +/** + * Returns pixel dimensions of the first video stream via ffprobe. + * @param {string} filepath + * @returns {Promise<{ width: number, height: number }>} + */ +export async function video_dimensions(filepath) { + const out = await run('ffprobe', [ + '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=width,height', + '-of', 'csv=p=0', + filepath + ], 10000); + const [w, h] = out.split(',').map(Number); + return { width: w || 0, height: h || 0 }; +} + +/** + * Converts an MP4 to a VP9+Opus WebM buffer (constant-quality, audio preserved). + * @param {string} filepath + * @returns {Promise} + */ +export async function mp4_to_webm(filepath) { + const out = tmp_path('webm'); + await run('ffmpeg', [ + '-i', filepath, + '-c:v', 'libvpx-vp9', + '-b:v', '0', '-crf', '33', + '-c:a', 'libopus', '-b:a', '128k', + '-y', out + ]); + const buf = readFileSync(out); + try { unlinkSync(out); } catch { /* ignore */ } + return buf; +} diff --git a/src/vite-plugin.js b/src/vite-plugin.js index c8ccb26b3f17cb69eb1e725bab1cde02c88c36cf..08392ceb8d2788917c1557ce81161982d97ea60e 100644 --- a/src/vite-plugin.js +++ b/src/vite-plugin.js @@ -1,6 +1,8 @@ /** @import { AST } from 'svelte/compiler' */ import { existsSync } from 'node:fs'; import path from 'node:path'; +import { read_animated_cache, write_animated_cache, read_video_cache, write_video_cache, read_img_cache, write_img_cache } from './animated-cache.js'; +import { video_dimensions, mp4_to_webm } from './video-convert.js'; import MagicString from 'magic-string'; import sharp from 'sharp'; import { parse } from 'svelte-parse-markup'; @@ -8,6 +10,97 @@ import { walk } from 'zimmerframe'; // TODO: expose this in vite-imagetools rather than duplicating it const OPTIMIZABLE = /^[^?]+\.(avif|heif|gif|jpeg|jpg|png|tiff|webp)(\?.*)?$/; +// Formats that may carry animation — checked before handing off to imagetools. +const ANIMATED_EXT = /\.(gif|webp)$/i; +// Video MIME types (use video/* instead of image/* in elements). +const VIDEO_FORMATS = new Set(['webm', 'mp4']); + +/** + * Process an animated GIF/WebP into optimised animated WebP assets. + * In dev mode returns the original file path so the browser plays it directly. + * @param {string} filepath Absolute path to the source file (no query params). + * @param {import('sharp').Metadata} meta Sharp metadata (animated: true). + * @param {import('vite').Rollup.PluginContext} plugin_context + * @param {import('vite').ResolvedConfig} vite_config + * @returns {Promise} + */ +async function process_animated(filepath, meta, plugin_context, vite_config) { + const width = meta.width ?? 0; + const height = meta.pageHeight ?? meta.height ?? 0; + const ext = path.extname(filepath); + const stem = path.basename(filepath, ext); + + if (vite_config.command === 'serve') { + // Dev: serve the original file — already animated, no optimisation needed. + const rel = '/' + path.relative(vite_config.root, filepath).replace(/\\/g, '/'); + return /** @type {any} */ ({ + sources: { [ext.slice(1)]: rel }, + img: { src: rel, w: width, h: height } + }); + } + + // Build: check cache first, convert only on miss. + let cached_anim = read_animated_cache(filepath); + let webp_buf; + if (cached_anim) { + ({ webp_buf } = cached_anim); + } else { + webp_buf = await sharp(filepath, { animated: true }) + .webp({ effort: 6, loop: meta.loop ?? 0 }) + .toBuffer(); + write_animated_cache(filepath, webp_buf, { + width, + height, + pages: meta.pages ?? 1, + loop: meta.loop ?? 0 + }); + } + + const webp_ref = plugin_context.emitFile({ type: 'asset', name: `${stem}.webp`, source: webp_buf }); + + return /** @type {any} */ ({ + sources: { webp: `__VITE_ASSET__${webp_ref}__` }, + img: { src: `__VITE_ASSET__${webp_ref}__`, w: width, h: height } + }); +} + +/** + * Process an MP4 source into optimised WebM + MP4 assets. + * In dev mode returns the original file so the browser can play it directly. + * @param {string} filepath + * @param {import('vite').Rollup.PluginContext} plugin_context + * @param {import('vite').ResolvedConfig} vite_config + * @returns {Promise<{ sources: Record, video: { src: string, w: number, h: number } }>} + */ +async function process_video(filepath, plugin_context, vite_config) { + const ext = path.extname(filepath); + const stem = path.basename(filepath, ext); + + if (vite_config.command === 'serve') { + const rel = '/' + path.relative(vite_config.root, filepath).replace(/\\/g, '/'); + const { width, height } = await video_dimensions(filepath); + return { sources: { webm: rel }, video: { src: rel, w: width, h: height } }; + } + + let cached_v = read_video_cache(filepath); + let webm_buf, width, height; + if (cached_v) { + ({ webm_buf, meta: { width, height } } = cached_v); + } else { + const dims = await video_dimensions(filepath); + width = dims.width; + height = dims.height; + webm_buf = await mp4_to_webm(filepath); + write_video_cache(filepath, webm_buf, { width, height }); + } + + const webm_ref = plugin_context.emitFile({ type: 'asset', name: `${stem}.webm`, source: webm_buf }); + + return { + sources: { webm: `__VITE_ASSET__${webm_ref}__` }, + video: { src: `__VITE_ASSET__${webm_ref}__`, w: width, h: height } + }; +} /** * Creates the Svelte image plugin. @@ -38,7 +131,7 @@ export function image_plugin(imagetools_plugin) { transform: { order: 'pre', // puts it before vite-plugin-svelte:compile filter: { - code: / 1) { + const image = await process_animated(filepath, anim_meta, plugin_context, vite_config); + s.update(node.start, node.end, img_to_picture(content, node, image)); + return; + } + } + if (OPTIMIZABLE.test(url)) { - const image = await process_id(resolved_id, plugin_context, imagetools_plugin); + const image = await process_id(resolved_id, plugin_context, imagetools_plugin, vite_config); s.update(node.start, node.end, img_to_picture(content, node, image)); } else { const metadata = await sharp(resolved_id).metadata(); @@ -132,20 +238,58 @@ export function image_plugin(imagetools_plugin) { } /** - * @type {Array>} + * Handles a static or dynamic element. + * @param {import('svelte/compiler').AST.RegularElement} node + * @param {AST.Text | AST.ExpressionTag} src_attribute + */ + async function update_video_element(node, src_attribute) { + if (src_attribute.type === 'ExpressionTag') { + const start = + 'end' in src_attribute.expression + ? src_attribute.expression.end + : src_attribute.expression.range?.[0]; + const end = + 'start' in src_attribute.expression + ? src_attribute.expression.start + : src_attribute.expression.range?.[1]; + if (typeof start !== 'number' || typeof end !== 'number') { + throw new Error('ExpressionTag has no range'); + } + const src_var_name = content.substring(start, end).trim(); + s.update(node.start, node.end, dynamic_video_to_video(content, node, src_var_name)); + return; + } + + const original_url = src_attribute.raw.trim(); + const resolved_id = (await plugin_context.resolve(original_url, filename))?.id; + if (!resolved_id) { + throw new Error(`Could not locate ${original_url} for `); + } + const filepath = resolved_id.includes('?') + ? resolved_id.slice(0, resolved_id.indexOf('?')) + : resolved_id; + const video = await process_video(filepath, plugin_context, vite_config); + s.update(node.start, node.end, video_to_video(content, node, video)); + } + + /** + * @type {Array>} */ const pending_ast_updates = []; walk(/** @type {import('svelte/compiler').AST.TemplateNode} */ (ast), null, { RegularElement(node, { next }) { if ('name' in node && node.name === 'enhanced:img') { - // Compare node tag match const src = get_attr_value(node, 'src'); - if (!src || typeof src === 'boolean') return; - pending_ast_updates.push(update_element(node, src)); + return; + } + if ('name' in node && node.name === 'enhanced:video') { + const src = get_attr_value(node, 'src'); + if (!src || typeof src === 'boolean') return; + pending_ast_updates.push(update_video_element(node, src)); return; } @@ -172,7 +316,9 @@ export function image_plugin(imagetools_plugin) { if (ast.css) { const css = content.substring(ast.css.start, ast.css.end); - const modified = css.replaceAll('enhanced\\:img', 'img'); + const modified = css + .replaceAll('enhanced\\:img', 'img') + .replaceAll('enhanced\\:video', 'video'); if (modified !== css) { s.update(ast.css.start, ast.css.end, modified); } @@ -194,18 +340,70 @@ export function image_plugin(imagetools_plugin) { * @param {import('vite').Plugin} imagetools_plugin * @returns {Promise} */ -async function process_id(resolved_id, plugin_context, imagetools_plugin) { +async function process_id(resolved_id, plugin_context, imagetools_plugin, vite_config) { if (!imagetools_plugin.load) { throw new Error('Invalid instance of vite-imagetools. Could not find load method.'); } const hook = imagetools_plugin.load; const handler = typeof hook === 'object' ? hook.handler : hook; - const module_info = await handler.call(plugin_context, resolved_id); + + // Cache keyed by file content + query params (build only). + const q = resolved_id.indexOf('?'); + const filepath = q >= 0 ? resolved_id.slice(0, q) : resolved_id; + const query = q >= 0 ? resolved_id.slice(q + 1) : ''; + + if (vite_config?.command === 'serve') { + const module_info = await handler.call(plugin_context, resolved_id); + if (!module_info) throw new Error(`Could not load ${resolved_id}`); + const code = typeof module_info === 'string' ? module_info : module_info.code; + return parse_object(code.replace('export default', '').replace(/;$/, '').trim()); + } + + const cached = read_img_cache(filepath, query); + if (cached) { + /** @type {Record} */ + const new_refs = {}; + for (const asset of cached.assets) { + new_refs[asset.key] = plugin_context.emitFile({ type: 'asset', name: asset.name, source: asset.source }); + } + const code = cached.template.replace(/\$REF\[([^\]]+)\]/g, (_, k) => `__VITE_ASSET__${new_refs[k]}__`); + return parse_object(code.replace('export default', '').replace(/;$/, '').trim()); + } + + // Cache miss — intercept emitFile to capture assets for caching. + /** @type {{ key: string, name: string, source: Buffer }[]} */ + const emitted = []; + const original_emit = plugin_context.emitFile.bind(plugin_context); + const proxy_ctx = new Proxy(plugin_context, { + get(target, prop) { + if (prop === 'emitFile') { + return (/** @type {any} */ opts) => { + const ref = original_emit(opts); + if (opts.type === 'asset' && opts.source) { + emitted.push({ key: ref, name: opts.name ?? '', source: Buffer.from(opts.source) }); + } + return ref; + }; + } + return /** @type {any} */ (target)[prop]; + } + }); + + const module_info = await handler.call(proxy_ctx, resolved_id); if (!module_info) { throw new Error(`Could not load ${resolved_id}`); } - const code = typeof module_info === 'string' ? module_info : module_info.code; - return parse_object(code.replace('export default', '').replace(/;$/, '').trim()); + const raw_code = typeof module_info === 'string' ? module_info : module_info.code; + + if (emitted.length > 0) { + let template = raw_code; + for (const asset of emitted) { + template = template.replaceAll(`__VITE_ASSET__${asset.key}__`, `$REF[${asset.key}]`); + } + write_img_cache(filepath, query, { assets: emitted, template }); + } + + return parse_object(raw_code.replace('export default', '').replace(/;$/, '').trim()); } /** @@ -345,6 +543,59 @@ function to_value(src) { return src.startsWith('__VITE_ASSET__') ? `{"${src}"}` : `"${src}"`; } +/** + * Generates a static