From 35f3f3491f7a08866c13fae84503075e91f74f7e Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Tue, 7 Apr 2026 09:33:44 +0200 Subject: [PATCH] fix(router): image imports --- .changeset/neat-seas-give.md | 5 + .../src/buildtime/vite/image-jsx.ts | 191 ++++++++++++++---- .../src/buildtime/vite/image-jsx.unit.ts | 34 +++- 3 files changed, 186 insertions(+), 44 deletions(-) create mode 100644 .changeset/neat-seas-give.md diff --git a/.changeset/neat-seas-give.md b/.changeset/neat-seas-give.md new file mode 100644 index 00000000000..a24e71542e9 --- /dev/null +++ b/.changeset/neat-seas-give.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/router': patch +--- + +FIX: image imports `foo.png?jsx` are now more robust diff --git a/packages/qwik-router/src/buildtime/vite/image-jsx.ts b/packages/qwik-router/src/buildtime/vite/image-jsx.ts index 8e61331e74b..360de1f3902 100644 --- a/packages/qwik-router/src/buildtime/vite/image-jsx.ts +++ b/packages/qwik-router/src/buildtime/vite/image-jsx.ts @@ -2,14 +2,85 @@ import fs from 'node:fs'; import path from 'node:path'; import type { Config as SVGOConfig } from 'svgo'; import { optimize } from 'svgo'; -import type { PluginOption } from 'vite'; +import { normalizePath, type PluginOption } from 'vite'; import type { OutputFormat } from 'vite-imagetools'; import { parseId } from '../../../../qwik-vite/src/plugins/vite-utils'; import type { QwikRouterVitePluginOptions } from './types'; +const supportedExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.avif', '.tiff']; +const JSX_QUERY_PARAM = 'jsx'; +const INTERNAL_IMAGE_JSX_QUERY_PARAM = 'qwik-asset-jsx'; +const VIRTUAL_IMAGE_JSX_PREFIX = 'virtual:'; +const VIRTUAL_IMAGE_JSX_SUFFIX = '.qwik.jsx'; +const TO_IMG_ID = '@to-img.qwik.jsx'; +const VIRTUAL_TO_IMG_ID = 'virtual:to-img.qwik.jsx'; + +export function createVirtualImageJsxId(pathId: string, params: URLSearchParams) { + const query = new URLSearchParams(params); + query.delete(INTERNAL_IMAGE_JSX_QUERY_PARAM); + const queryString = query.toString(); + return `${VIRTUAL_IMAGE_JSX_PREFIX}${normalizePath(pathId)}${VIRTUAL_IMAGE_JSX_SUFFIX}${ + queryString ? `?${queryString}` : '' + }`; +} + +export function createImageJsxImportId(pathId: string, params: URLSearchParams) { + const query = new URLSearchParams(params); + query.set(INTERNAL_IMAGE_JSX_QUERY_PARAM, ''); + return `${normalizePath(pathId)}?${query.toString()}`; +} + +export function parseVirtualImageJsxId(id: string) { + const parsed = parseId(id); + if ( + !parsed.pathId.startsWith(VIRTUAL_IMAGE_JSX_PREFIX) || + !parsed.pathId.endsWith(VIRTUAL_IMAGE_JSX_SUFFIX) + ) { + return null; + } + + const pathId = parsed.pathId.slice( + VIRTUAL_IMAGE_JSX_PREFIX.length, + -VIRTUAL_IMAGE_JSX_SUFFIX.length + ); + + return { + ...parsed, + extension: path.extname(pathId).toLowerCase(), + pathId, + }; +} + +// Keep this up-to-date with jsxSplit changes +function getToImg() { + return ` + import { _jsxSplit, _getVarProps as v, _getConstProps as c} from '@qwik.dev/core'; + const decoding = "async"; + const loading = "lazy"; + export default (s, w, h) => + // Try to preserve const attrs by not spreading when props are not provided + p => p + ? _jsxSplit("img", {decoding, loading, ...v(p)}, {...c(p), height: h, srcSet: s, width: w }) + : _jsxSplit("img", null, {decoding, height: h, loading, srcSet: s, width: w}); + `; +} + +function createImageJsxModule(pathId: string, params: URLSearchParams) { + // We get the metadata via the vite-imagetools import + return ` + import { srcSet, width, height } from ${JSON.stringify(createImageJsxImportId(pathId, params))}; + import toImg from ${JSON.stringify(TO_IMG_ID)}; + + export default toImg(srcSet, width, height); + `; +} + +function createSvgJsxModule(attrs: Record) { + return `export default p => `; +} + /** @public */ export function imagePlugin(userOpts?: QwikRouterVitePluginOptions): PluginOption[] { - const supportedExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.avif', '.tiff']; return [ import('vite-imagetools') .then(({ imagetools }) => @@ -41,8 +112,10 @@ export function imagePlugin(userOpts?: QwikRouterVitePluginOptions): PluginOptio }; }, defaultDirectives: (url) => { - if (url.searchParams.has('jsx')) { - const { jsx: _, ...params } = Object.fromEntries(url.searchParams.entries()); + if (url.searchParams.has(JSX_QUERY_PARAM)) { + const params = Object.fromEntries(url.searchParams.entries()); + delete params[JSX_QUERY_PARAM]; + delete params[INTERNAL_IMAGE_JSX_QUERY_PARAM]; return new URLSearchParams({ format: 'webp', quality: '75', @@ -58,61 +131,93 @@ export function imagePlugin(userOpts?: QwikRouterVitePluginOptions): PluginOptio }) ) .catch((err) => { - console.error('Error loading vite-imagetools, image imports are not available', err); + console.error( + 'Error loading vite-imagetools, image imports ("foo.png?jsx") are not available', + err + ); return null; }) as PluginOption, { name: 'qwik-router-image-jsx', + resolveId: { + order: 'pre', + async handler(id, importer, options) { + if (parseVirtualImageJsxId(id)) { + return null; + } + if (id.endsWith(TO_IMG_ID)) { + return VIRTUAL_TO_IMG_ID; + } + + const { pathId, params } = parseId(id); + if (!params.has(JSX_QUERY_PARAM) || params.has(INTERNAL_IMAGE_JSX_QUERY_PARAM)) { + return null; + } + + const resolved = await this.resolve(pathId, importer, { + ...options, + skipSelf: true, + }); + const resolvedId = parseId((resolved ?? { id: pathId }).id).pathId; + const extension = path.extname(resolvedId).toLowerCase(); + + if (extension !== '.svg' && !supportedExtensions.includes(extension)) { + return null; + } + + return { + id: createVirtualImageJsxId(resolvedId, params), + moduleSideEffects: false, + }; + }, + }, load: { order: 'pre', handler: async (id) => { - const { params, pathId } = parseId(id); - const extension = path.extname(pathId).toLowerCase(); - if (extension === '.svg' && params.has('jsx')) { - const code = await fs.promises.readFile(pathId, 'utf-8'); - return { - code, - moduleSideEffects: false, - }; + if (id === VIRTUAL_TO_IMG_ID) { + return getToImg(); + } + const imageId = parseVirtualImageJsxId(id); + if (!imageId) { + return null; + } + + if (imageId.extension === '.svg') { + const code = await fs.promises.readFile(imageId.pathId, 'utf-8'); + return { code, moduleSideEffects: false }; } + + return { + code: 'export default undefined;', + moduleSideEffects: false, + }; }, }, - transform(code, id) { - id = id.toLowerCase(); - const { params, pathId } = parseId(id); - if (params.has('jsx')) { - const extension = path.extname(pathId).toLowerCase(); - - if (supportedExtensions.includes(extension)) { - if (!code.includes('srcSet')) { - this.error(`Image '${id}' could not be optimized to JSX`); - } - const index = code.indexOf('export default'); + transform: { + order: 'pre', + handler(code, id) { + const imageId = parseVirtualImageJsxId(id); + if (!imageId) { + return null; + } + + if (imageId.extension === '.svg') { + const { svgAttributes } = optimizeSvg({ code, path: imageId.pathId }, userOpts); return { - code: - code.slice(0, index) + - ` - import { _jsxSorted } from '@qwik.dev/core'; - const PROPS = {srcSet, width, height}; - export default function (props, key, _, dev) { - return _jsxSorted('img', {...{decoding: 'async', loading: 'lazy'}, ...props}, PROPS, undefined, 3, key, dev); - }`, + code: createSvgJsxModule(svgAttributes), map: null, }; - } else if (extension === '.svg') { - const { svgAttributes } = optimizeSvg({ code, path: pathId }, userOpts); + } + + if (supportedExtensions.includes(imageId.extension)) { return { - code: ` - import { _jsxSorted } from '@qwik.dev/core'; - const PROPS = ${JSON.stringify(svgAttributes)}; - export default function (props, key, _, dev) { - return _jsxSorted('svg', props, PROPS, undefined, 3, key, dev); - }`, + code: createImageJsxModule(imageId.pathId, imageId.params), map: null, }; } - } - return null; + + return null; + }, }, }, ]; @@ -141,7 +246,7 @@ export function optimizeSvg( if (plugin === 'prefixIds' || (typeof plugin === 'object' && plugin.name === 'prefixIds')) { console.warn( - `You are trying to use the preset-default SVGO plugin. This plugin is already included by default, you can customize it through the prefixIds option.` + `You are trying to use the prefixIds SVGO plugin. This plugin is already included by default, you can customize it through the prefixIds option.` ); return false; } diff --git a/packages/qwik-router/src/buildtime/vite/image-jsx.unit.ts b/packages/qwik-router/src/buildtime/vite/image-jsx.unit.ts index 3aaedb17af6..fc05194a1e4 100644 --- a/packages/qwik-router/src/buildtime/vite/image-jsx.unit.ts +++ b/packages/qwik-router/src/buildtime/vite/image-jsx.unit.ts @@ -1,5 +1,10 @@ import { assert, test } from 'vitest'; -import { optimizeSvg } from './image-jsx'; +import { + createImageJsxImportId, + createVirtualImageJsxId, + optimizeSvg, + parseVirtualImageJsxId, +} from './image-jsx'; const qwikLogoSvg = ``; @@ -12,6 +17,33 @@ test('optimize svg', () => { assert.isTrue(html.startsWith('')); }); +test('creates virtual image jsx ids', () => { + const id = createVirtualImageJsxId( + '/assets/hero.png', + new URLSearchParams('jsx&w=400&format=avif') + ); + const parsed = parseVirtualImageJsxId(id); + + assert.isNotNull(parsed); + assert.equal(parsed.pathId, '/assets/hero.png'); + assert.equal(parsed.extension, '.png'); + assert.isTrue(parsed.params.has('jsx')); + assert.equal(parsed.params.get('w'), '400'); + assert.equal(parsed.params.get('format'), 'avif'); +}); + +test('creates internal image metadata ids without re-virtualizing them', () => { + const id = createImageJsxImportId('/assets/hero.png', new URLSearchParams('jsx&w=400')); + const parsed = parseVirtualImageJsxId(id); + const params = new URLSearchParams(id.split('?')[1]); + + assert.isNull(parsed); + assert.equal(id.split('?')[0], '/assets/hero.png'); + assert.isTrue(params.has('jsx')); + assert.isTrue(params.has('qwik-asset-jsx')); + assert.equal(params.get('w'), '400'); +}); + const svgsFilesWithDefsTag = [ { content: `