diff --git a/.changeset/violet-vans-peel.md b/.changeset/violet-vans-peel.md new file mode 100644 index 000000000..1463a3b65 --- /dev/null +++ b/.changeset/violet-vans-peel.md @@ -0,0 +1,5 @@ +--- +'@responsive-image/vite-plugin': minor +--- + +Add LQIP support to vite-plugin diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index 4ce1b7bf6..23308fe60 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -3,6 +3,10 @@ import resizePlugin from './resize'; import exportPlugin from './export'; import servePlugin from './serve'; import lqipBlurhashPlugin from './lqip/blurhash'; +import lqipColorPlugin from './lqip/color'; +import lqipColorCssPlugin from './lqip/color-css'; +import lqipInlinePlugin from './lqip/inline'; +import lqipInlineCssPlugin from './lqip/inline-css'; import type { Options } from './types'; export type { Options, ImageLoaderChainedResult } from './types'; @@ -11,6 +15,10 @@ function setupPlugins(options?: Partial) { loaderPlugin(options), resizePlugin(options), lqipBlurhashPlugin(options), + lqipColorPlugin(options), + lqipColorCssPlugin(options), + lqipInlinePlugin(options), + lqipInlineCssPlugin(options), exportPlugin(options), servePlugin(options), ]; diff --git a/packages/vite-plugin/src/loader.ts b/packages/vite-plugin/src/loader.ts index 770f9bf7b..fba5fc919 100644 --- a/packages/vite-plugin/src/loader.ts +++ b/packages/vite-plugin/src/loader.ts @@ -1,5 +1,5 @@ import { createFilter } from '@rollup/pluginutils'; -import { Plugin } from 'vite'; +import type { Plugin } from 'vite'; import type { Options } from './types'; import { META_KEY, normalizeInput } from './utils'; diff --git a/packages/vite-plugin/src/lqip/blurhash.ts b/packages/vite-plugin/src/lqip/blurhash.ts index d11d0770b..338f8be7c 100644 --- a/packages/vite-plugin/src/lqip/blurhash.ts +++ b/packages/vite-plugin/src/lqip/blurhash.ts @@ -1,6 +1,6 @@ import { encode } from 'blurhash'; import type { Metadata } from 'sharp'; -import { Plugin } from 'vite'; +import type { Plugin } from 'vite'; import type { Options } from '../types'; import { META_KEY, getAspectRatio, getInput, getOptions } from '../utils'; diff --git a/packages/vite-plugin/src/lqip/color-css.ts b/packages/vite-plugin/src/lqip/color-css.ts new file mode 100644 index 000000000..6985d8f7c --- /dev/null +++ b/packages/vite-plugin/src/lqip/color-css.ts @@ -0,0 +1,50 @@ +import sharp from 'sharp'; +import type { Plugin } from 'vite'; +import type { Options } from '../types'; +import { getPathname, parseQuery, parseURL } from '../utils'; + +export const name = 'responsive-image/lqip/color-css'; + +export default function lqipColorCssPlugin( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + userOptions: Partial = {}, +): Plugin { + return { + name, + resolveId(source) { + const { _plugin } = parseQuery(parseURL(source).searchParams); + + if (_plugin !== name) { + return null; + } + + // return the same module id to make vite think this file exists and is a .css file + // we will load the actually existing file without .css in the load hook + return source; + }, + async load(id) { + const { className, _plugin } = parseQuery(parseURL(id).searchParams); + + if (_plugin !== name) { + return; + } + + if (typeof className !== 'string') { + throw new Error('Missing className'); + } + + const file = getPathname(id).replace(/\.css$/, ''); + const image = sharp(file); + const { dominant } = await image.stats(); + const colorHex = + dominant.r.toString(16) + + dominant.g.toString(16) + + dominant.b.toString(16); + const color = '#' + colorHex; + + const cssRule = `.${className} { background-color: ${color}; }`; + + return cssRule; + }, + }; +} diff --git a/packages/vite-plugin/src/lqip/color.ts b/packages/vite-plugin/src/lqip/color.ts new file mode 100644 index 000000000..ac7a632d8 --- /dev/null +++ b/packages/vite-plugin/src/lqip/color.ts @@ -0,0 +1,52 @@ +import type { Plugin } from 'vite'; +import type { Options } from '../types'; +import { + META_KEY, + generateLqipClassName, + getInput, + getOptions, + getPathname, +} from '../utils'; +import { name as colorCssPluginName } from './color-css'; + +export default function lqipColorPlugin( + userOptions: Partial = {}, +): Plugin { + return { + name: 'responsive-image/lqip/color', + async transform(code, id) { + const input = getInput(this, id); + + // Bail out if our loader didn't handle this module + if (!input) { + return; + } + + const options = getOptions(id, userOptions); + + if (options.lqip?.type !== 'color') { + return; + } + + const pathname = getPathname(id); + const className = generateLqipClassName(id); + const importCSS = `${ + pathname + }.css?_plugin=${colorCssPluginName}&className=${encodeURIComponent(className)}`; + + const result = { + ...input, + lqip: { type: 'color', class: className }, + imports: [...input.imports, importCSS], + }; + + return { + // Only the export plugin will actually return ESM code + code: '', + meta: { + [META_KEY]: result, + }, + }; + }, + }; +} diff --git a/packages/vite-plugin/src/lqip/inline-css.ts b/packages/vite-plugin/src/lqip/inline-css.ts new file mode 100644 index 000000000..a9ffd23b0 --- /dev/null +++ b/packages/vite-plugin/src/lqip/inline-css.ts @@ -0,0 +1,94 @@ +import sharp, { Metadata } from 'sharp'; +import type { Plugin } from 'vite'; +import type { Options } from '../types'; +import { + blurrySvg, + dataUri, + getAspectRatio, + getPathname, + parseQuery, + parseURL, +} from '../utils'; + +export const name = 'responsive-image/lqip/inline-css'; + +export default function lqipInlineCssPlugin( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + userOptions: Partial = {}, +): Plugin { + return { + name, + resolveId(source) { + const { _plugin } = parseQuery(parseURL(source).searchParams); + + if (_plugin !== name) { + return null; + } + + // return the same module id to make vite think this file exists and is a .css file + // we will load the actually existing file without .css in the load hook + return source; + }, + async load(id) { + const { className, targetPixels, _plugin } = parseQuery( + parseURL(id).searchParams, + ); + + if (_plugin !== name) { + return; + } + + if (typeof className !== 'string') { + throw new Error('Missing className'); + } + + if (typeof targetPixels !== 'string') { + throw new Error('Missing targetPixels'); + } + + const file = getPathname(id).replace(/\.css$/, ''); + const image = sharp(file); + const meta = await image.metadata(); + + if (meta.width === undefined || meta.height === undefined) { + throw new Error('Missing image meta data'); + } + + const { width, height } = await getLqipDimensions( + parseInt(targetPixels, 10), + meta, + ); + + const lqi = await image + .resize(width, height, { + withoutEnlargement: true, + fit: 'fill', + }) + .png(); + + const uri = dataUri( + blurrySvg( + dataUri(await lqi.toBuffer(), 'image/png'), + meta.width, + meta.height, + ), + 'image/svg+xml', + ); + + return `.${className} { background-image: url(${uri}); }`; + }, + }; +} + +async function getLqipDimensions( + targetPixels: number, + meta: Metadata, +): Promise<{ width: number; height: number }> { + const aspectRatio = getAspectRatio(meta) ?? 1; + + // taken from https://github.com/google/eleventy-high-performance-blog/blob/5ed39db7fd3f21ae82ac1a8e833bf283355bd3d0/_11ty/blurry-placeholder.js#L74-L92 + let bitmapHeight = targetPixels / aspectRatio; + bitmapHeight = Math.sqrt(bitmapHeight); + const bitmapWidth = targetPixels / bitmapHeight; + return { width: Math.round(bitmapWidth), height: Math.round(bitmapHeight) }; +} diff --git a/packages/vite-plugin/src/lqip/inline.ts b/packages/vite-plugin/src/lqip/inline.ts new file mode 100644 index 000000000..0a8348c71 --- /dev/null +++ b/packages/vite-plugin/src/lqip/inline.ts @@ -0,0 +1,53 @@ +import type { Plugin } from 'vite'; +import type { Options } from '../types'; +import { + META_KEY, + generateLqipClassName, + getInput, + getOptions, + getPathname, +} from '../utils'; +import { name as inlineCssPluginName } from './inline-css'; + +export default function lqipLinlinePlugin( + userOptions: Partial = {}, +): Plugin { + return { + name: 'responsive-image/lqip/inline', + async transform(code, id) { + const input = getInput(this, id); + + // Bail out if our loader didn't handle this module + if (!input) { + return; + } + + const options = getOptions(id, userOptions); + + if (options.lqip?.type !== 'inline') { + return; + } + + const pathname = getPathname(id); + const className = generateLqipClassName(id); + const targetPixels = options.lqip.targetPixels ?? 60; + const importCSS = `${ + pathname + }.css?_plugin=${inlineCssPluginName}&className=${encodeURIComponent(className)}&targetPixels=${targetPixels}`; + + const result = { + ...input, + lqip: { type: 'inline', class: className }, + imports: [...input.imports, importCSS], + }; + + return { + // Only the export plugin will actually return ESM code + code: '', + meta: { + [META_KEY]: result, + }, + }; + }, + }; +} diff --git a/packages/vite-plugin/src/resize.ts b/packages/vite-plugin/src/resize.ts index 0e01562ee..559091be2 100644 --- a/packages/vite-plugin/src/resize.ts +++ b/packages/vite-plugin/src/resize.ts @@ -1,7 +1,7 @@ import type { ImageType } from '@responsive-image/core'; import { ImageConfig } from 'imagetools-core'; import type { Metadata, Sharp } from 'sharp'; -import { Plugin } from 'vite'; +import type { Plugin } from 'vite'; import type { ImageLoaderChainedResult, LazyImageProcessingResult, diff --git a/packages/vite-plugin/src/utils.ts b/packages/vite-plugin/src/utils.ts index bef17eb71..6ac4bf119 100644 --- a/packages/vite-plugin/src/utils.ts +++ b/packages/vite-plugin/src/utils.ts @@ -27,6 +27,11 @@ export function parseURL(id: string) { return new URL(id, 'file://'); } +export function getPathname(id: string) { + const url = parseURL(id); + return decodeURIComponent(url.pathname); +} + export function parseQuery(query: string | URLSearchParams): Partial { const params = query instanceof URLSearchParams ? query : new URLSearchParams(query); @@ -99,12 +104,9 @@ export function normalizeInput( input: string | ImageLoaderChainedResult, ): ImageLoaderChainedResult { if (typeof input === 'string') { - const url = parseURL(input); - const pathname = decodeURIComponent(url.pathname); - return { images: [], - sharp: sharp(pathname), + sharp: sharp(getPathname(input)), imports: [], }; } diff --git a/packages/vite-plugin/tests/__snapshots__/index.test.ts.snap b/packages/vite-plugin/tests/__snapshots__/index.test.ts.snap index 93c6e9018..574ecb1f8 100644 --- a/packages/vite-plugin/tests/__snapshots__/index.test.ts.snap +++ b/packages/vite-plugin/tests/__snapshots__/index.test.ts.snap @@ -1,55 +1,109 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`LQIP > blurhash LQIP is supported 1`] = ` -"function r(i, a, g) { - const m = i.map((e) => e.width).reduce((e, t) => t >= a && e >= a ? t >= e ? e : t : t >= e ? t : e, 0); - return i.find( - (e) => e.width === m && e.format === g +"function o(t, a, g) { + const r = t.map((e) => e.width).reduce((e, i) => i >= a && e >= a ? i >= e ? e : i : i >= e ? i : e, 0); + return t.find( + (e) => e.width === r && e.format === g ); } -const o = /* @__PURE__ */ new Map(); -function s(i) { - return o.get(i); +const h = /* @__PURE__ */ new Map(); +function m(t) { + return h.get(t); } var n; -(n = s("env")) == null || n.deviceWidths; -const h = [{ url: "/images/image-100w.jpg", width: 100, format: "jpeg" }, { url: "/images/image-100w.webp", width: 100, format: "webp" }, { url: "/images/image-200w.jpg", width: 200, format: "jpeg" }, { url: "/images/image-200w.webp", width: 200, format: "webp" }], p = { +(n = m("env")) == null || n.deviceWidths; +const p = [{ url: "/image-100w.jpg", width: 100, format: "jpeg" }, { url: "/image-100w.webp", width: 100, format: "webp" }, { url: "/image-200w.jpg", width: 200, format: "jpeg" }, { url: "/image-200w.webp", width: 200, format: "webp" }], s = { imageTypes: ["jpeg", "webp"], availableWidths: [100, 200], lqip: { type: "blurhash", hash: "NYGkgjIp9b%1f6Iq~UaeV@WYWVs,%MoJWBWCjZbH", width: 6, height: 3 }, aspectRatio: 2, - imageUrlFor(i, a) { + imageUrlFor(t, a) { var g; - return (g = r(h, i, a ?? "jpeg")) == null ? void 0 : g.url; + return (g = o(p, t, a ?? "jpeg")) == null ? void 0 : g.url; + } +}; +console.log(s); +" +`; + +exports[`LQIP > color LQIP is supported 1`] = ` +"function o(t, n, a) { + const r = t.map((e) => e.width).reduce((e, i) => i >= n && e >= n ? i >= e ? e : i : i >= e ? i : e, 0); + return t.find( + (e) => e.width === r && e.format === a + ); +} +const c = /* @__PURE__ */ new Map(); +function m(t) { + return c.get(t); +} +var g; +(g = m("env")) == null || g.deviceWidths; +const p = [{ url: "/image-100w.jpg", width: 100, format: "jpeg" }, { url: "/image-100w.webp", width: 100, format: "webp" }, { url: "/image-200w.jpg", width: 200, format: "jpeg" }, { url: "/image-200w.webp", width: 200, format: "webp" }], s = { + imageTypes: ["jpeg", "webp"], + availableWidths: [100, 200], + lqip: { type: "color", class: "eri-dyn-0" }, + aspectRatio: 2, + imageUrlFor(t, n) { + var a; + return (a = o(p, t, n ?? "jpeg")) == null ? void 0 : a.url; } }; -console.log(p); +console.log(s); +" +`; + +exports[`LQIP > inline LQIP is supported 1`] = ` +"function o(t, n, a) { + const r = t.map((e) => e.width).reduce((e, i) => i >= n && e >= n ? i >= e ? e : i : i >= e ? i : e, 0); + return t.find( + (e) => e.width === r && e.format === a + ); +} +const m = /* @__PURE__ */ new Map(); +function c(t) { + return m.get(t); +} +var g; +(g = c("env")) == null || g.deviceWidths; +const p = [{ url: "/image-100w.jpg", width: 100, format: "jpeg" }, { url: "/image-100w.webp", width: 100, format: "webp" }, { url: "/image-200w.jpg", width: 200, format: "jpeg" }, { url: "/image-200w.webp", width: 200, format: "webp" }], s = { + imageTypes: ["jpeg", "webp"], + availableWidths: [100, 200], + lqip: { type: "inline", class: "eri-dyn-0" }, + aspectRatio: 2, + imageUrlFor(t, n) { + var a; + return (a = o(p, t, n ?? "jpeg")) == null ? void 0 : a.url; + } +}; +console.log(s); " `; exports[`custom loader options are supported 1`] = ` -"function s(n, e, g) { - const o = n.map((t) => t.width).reduce((t, i) => i >= e && t >= e ? i >= t ? t : i : i >= t ? i : t, 0); +"function a(n, e, g) { + const r = n.map((t) => t.width).reduce((t, i) => i >= e && t >= e ? i >= t ? t : i : i >= t ? i : t, 0); return n.find( - (t) => t.width === o && t.format === g + (t) => t.width === r && t.format === g ); } -const r = /* @__PURE__ */ new Map(); -function c(n) { - return r.get(n); +const c = /* @__PURE__ */ new Map(); +function s(n) { + return c.get(n); } -var a; -(a = c("env")) == null || a.deviceWidths; -const m = [{ url: "/images/test-100.png", width: 100, format: "png" }, { url: "/images/test-200.png", width: 200, format: "png" }], d = { +var o; +(o = s("env")) == null || o.deviceWidths; +const d = [{ url: "/test-100.png", width: 100, format: "png" }, { url: "/test-200.png", width: 200, format: "png" }], f = { imageTypes: ["png"], availableWidths: [100, 200], aspectRatio: 2, imageUrlFor(n, e) { var g; - return (g = s(m, n, e ?? "png")) == null ? void 0 : g.url; + return (g = a(d, n, e ?? "png")) == null ? void 0 : g.url; } }; -console.log(d); +console.log(f); " `; @@ -66,7 +120,7 @@ function m(t) { } var a; (a = m("env")) == null || a.deviceWidths; -const s = [{ url: "/images/image-100w.png", width: 100, format: "png" }, { url: "/images/image-200w.png", width: 200, format: "png" }], d = { +const s = [{ url: "/image-100w.png", width: 100, format: "png" }, { url: "/image-200w.png", width: 200, format: "png" }], d = { imageTypes: ["png"], availableWidths: [100, 200], aspectRatio: 2, @@ -80,28 +134,28 @@ console.log(d); `; exports[`filter > it operates on included assets 1`] = ` -"function r(i, a, m) { - const w = i.map((e) => e.width).reduce((e, g) => g >= a && e >= a ? g >= e ? e : g : g >= e ? g : e, 0); - return i.find( - (e) => e.width === w && e.format === m +"function m(t, w, g) { + const r = t.map((e) => e.width).reduce((e, i) => i >= w && e >= w ? i >= e ? e : i : i >= e ? i : e, 0); + return t.find( + (e) => e.width === r && e.format === g ); } const p = /* @__PURE__ */ new Map(); -function o(i) { - return p.get(i); +function o(t) { + return p.get(t); } -var t; -(t = o("env")) == null || t.deviceWidths; -const s = [{ url: "/images/image-640w.jpg", width: 640, format: "jpeg" }, { url: "/images/image-640w.webp", width: 640, format: "webp" }, { url: "/images/image-750w.jpg", width: 750, format: "jpeg" }, { url: "/images/image-750w.webp", width: 750, format: "webp" }, { url: "/images/image-828w.jpg", width: 828, format: "jpeg" }, { url: "/images/image-828w.webp", width: 828, format: "webp" }, { url: "/images/image-1080w.jpg", width: 1080, format: "jpeg" }, { url: "/images/image-1080w.webp", width: 1080, format: "webp" }, { url: "/images/image-1200w.jpg", width: 1200, format: "jpeg" }, { url: "/images/image-1200w.webp", width: 1200, format: "webp" }, { url: "/images/image-1920w.jpg", width: 1920, format: "jpeg" }, { url: "/images/image-1920w.webp", width: 1920, format: "webp" }, { url: "/images/image-2048w.jpg", width: 2048, format: "jpeg" }, { url: "/images/image-2048w.webp", width: 2048, format: "webp" }, { url: "/images/image-3840w.jpg", width: 3840, format: "jpeg" }, { url: "/images/image-3840w.webp", width: 3840, format: "webp" }], d = { +var a; +(a = o("env")) == null || a.deviceWidths; +const d = [{ url: "/image-640w.jpg", width: 640, format: "jpeg" }, { url: "/image-640w.webp", width: 640, format: "webp" }, { url: "/image-750w.jpg", width: 750, format: "jpeg" }, { url: "/image-750w.webp", width: 750, format: "webp" }, { url: "/image-828w.jpg", width: 828, format: "jpeg" }, { url: "/image-828w.webp", width: 828, format: "webp" }, { url: "/image-1080w.jpg", width: 1080, format: "jpeg" }, { url: "/image-1080w.webp", width: 1080, format: "webp" }, { url: "/image-1200w.jpg", width: 1200, format: "jpeg" }, { url: "/image-1200w.webp", width: 1200, format: "webp" }, { url: "/image-1920w.jpg", width: 1920, format: "jpeg" }, { url: "/image-1920w.webp", width: 1920, format: "webp" }, { url: "/image-2048w.jpg", width: 2048, format: "jpeg" }, { url: "/image-2048w.webp", width: 2048, format: "webp" }, { url: "/image-3840w.jpg", width: 3840, format: "jpeg" }, { url: "/image-3840w.webp", width: 3840, format: "webp" }], f = { imageTypes: ["jpeg", "webp"], availableWidths: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], aspectRatio: 2, - imageUrlFor(i, a) { - var m; - return (m = r(s, i, a ?? "jpeg")) == null ? void 0 : m.url; + imageUrlFor(t, w) { + var g; + return (g = m(d, t, w ?? "jpeg")) == null ? void 0 : g.url; } }; -console.log(d); +console.log(f); " `; @@ -118,7 +172,7 @@ function m(t) { } var a; (a = m("env")) == null || a.deviceWidths; -const s = [{ url: "/images/image-100w.png", width: 100, format: "png" }, { url: "/images/image-200w.png", width: 200, format: "png" }], d = { +const s = [{ url: "/image-100w.png", width: 100, format: "png" }, { url: "/image-200w.png", width: 200, format: "png" }], d = { imageTypes: ["png"], availableWidths: [100, 200], aspectRatio: 2, @@ -132,27 +186,27 @@ console.log(d); `; exports[`it produces expected output 1`] = ` -"function r(i, a, m) { - const w = i.map((e) => e.width).reduce((e, g) => g >= a && e >= a ? g >= e ? e : g : g >= e ? g : e, 0); - return i.find( - (e) => e.width === w && e.format === m +"function m(e, w, g) { + const r = e.map((t) => t.width).reduce((t, i) => i >= w && t >= w ? i >= t ? t : i : i >= t ? i : t, 0); + return e.find( + (t) => t.width === r && t.format === g ); } const n = /* @__PURE__ */ new Map(); -function p(i) { - return n.get(i); +function p(e) { + return n.get(e); } -var t; -(t = p("env")) == null || t.deviceWidths; -const o = [{ url: "/images/image-640w.png", width: 640, format: "png" }, { url: "/images/image-640w.webp", width: 640, format: "webp" }, { url: "/images/image-750w.png", width: 750, format: "png" }, { url: "/images/image-750w.webp", width: 750, format: "webp" }, { url: "/images/image-828w.png", width: 828, format: "png" }, { url: "/images/image-828w.webp", width: 828, format: "webp" }, { url: "/images/image-1080w.png", width: 1080, format: "png" }, { url: "/images/image-1080w.webp", width: 1080, format: "webp" }, { url: "/images/image-1200w.png", width: 1200, format: "png" }, { url: "/images/image-1200w.webp", width: 1200, format: "webp" }, { url: "/images/image-1920w.png", width: 1920, format: "png" }, { url: "/images/image-1920w.webp", width: 1920, format: "webp" }, { url: "/images/image-2048w.png", width: 2048, format: "png" }, { url: "/images/image-2048w.webp", width: 2048, format: "webp" }, { url: "/images/image-3840w.png", width: 3840, format: "png" }, { url: "/images/image-3840w.webp", width: 3840, format: "webp" }], s = { +var a; +(a = p("env")) == null || a.deviceWidths; +const o = [{ url: "/image-640w.png", width: 640, format: "png" }, { url: "/image-640w.webp", width: 640, format: "webp" }, { url: "/image-750w.png", width: 750, format: "png" }, { url: "/image-750w.webp", width: 750, format: "webp" }, { url: "/image-828w.png", width: 828, format: "png" }, { url: "/image-828w.webp", width: 828, format: "webp" }, { url: "/image-1080w.png", width: 1080, format: "png" }, { url: "/image-1080w.webp", width: 1080, format: "webp" }, { url: "/image-1200w.png", width: 1200, format: "png" }, { url: "/image-1200w.webp", width: 1200, format: "webp" }, { url: "/image-1920w.png", width: 1920, format: "png" }, { url: "/image-1920w.webp", width: 1920, format: "webp" }, { url: "/image-2048w.png", width: 2048, format: "png" }, { url: "/image-2048w.webp", width: 2048, format: "webp" }, { url: "/image-3840w.png", width: 3840, format: "png" }, { url: "/image-3840w.webp", width: 3840, format: "webp" }], d = { imageTypes: ["png", "webp"], availableWidths: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], aspectRatio: 2, - imageUrlFor(i, a) { - var m; - return (m = r(o, i, a ?? "png")) == null ? void 0 : m.url; + imageUrlFor(e, w) { + var g; + return (g = m(o, e, w ?? "png")) == null ? void 0 : g.url; } }; -console.log(s); +console.log(d); " `; diff --git a/packages/vite-plugin/tests/index.test.ts b/packages/vite-plugin/tests/index.test.ts index 363028632..2a468fd5e 100644 --- a/packages/vite-plugin/tests/index.test.ts +++ b/packages/vite-plugin/tests/index.test.ts @@ -48,27 +48,27 @@ test('it produces expected output', async () => { expect(assets.map((a) => a.fileName)).toEqual( [ - 'images/image-640w.png', - 'images/image-640w.webp', - 'images/image-750w.png', - 'images/image-750w.webp', - 'images/image-828w.png', - 'images/image-828w.webp', - 'images/image-1080w.png', - 'images/image-1080w.webp', - 'images/image-1200w.png', - 'images/image-1200w.webp', - 'images/image-1920w.png', - 'images/image-1920w.webp', - 'images/image-2048w.png', - 'images/image-2048w.webp', - 'images/image-3840w.png', - 'images/image-3840w.webp', + 'image-640w.png', + 'image-640w.webp', + 'image-750w.png', + 'image-750w.webp', + 'image-828w.png', + 'image-828w.webp', + 'image-1080w.png', + 'image-1080w.webp', + 'image-1200w.png', + 'image-1200w.webp', + 'image-1920w.png', + 'image-1920w.webp', + 'image-2048w.png', + 'image-2048w.webp', + 'image-3840w.png', + 'image-3840w.webp', ].sort(), ); expect( - assets.find((a) => a.fileName === 'images/image-640w.png')?.source, + assets.find((a) => a.fileName === 'image-640w.png')?.source, ).toMatchImageSnapshot(); }); @@ -83,8 +83,8 @@ test('custom loader options are supported', async () => { expect(source).toMatchSnapshot(); expect(assets.toSorted().map((a) => a.fileName)).toEqual([ - 'images/test-100.png', - 'images/test-200.png', + 'test-100.png', + 'test-200.png', ]); for (const image of assets) { @@ -100,8 +100,8 @@ test('custom query params are supported', async () => { expect(source).toMatchSnapshot(); expect(assets.map((a) => a.fileName)).toEqual([ - 'images/image-100w.png', - 'images/image-200w.png', + 'image-100w.png', + 'image-200w.png', ]); for (const image of assets) { @@ -120,8 +120,8 @@ test('imagetools params are supported', async () => { expect(source).toMatchSnapshot(); expect(assets.map((a) => a.fileName)).toEqual([ - 'images/image-100w.png', - 'images/image-200w.png', + 'image-100w.png', + 'image-200w.png', ]); for (const image of assets) { @@ -129,38 +129,54 @@ test('imagetools params are supported', async () => { } }); -describe('LQIP', function () { - // test('color LQIP is supported', async () => { - // const { stats } = await compiler( - // 'fixtures/image.jpg?responsive', - // _dirname, - // { - // lqip: { type: 'color' }, - // }, - // ); - - // expect(stats.modules).toBeDefined(); - // expect(stats.modules![0]?.modules).toHaveLength(3); - - // const output = stats.modules?.[0]?.modules?.[0]?.source; - // expect(sanitizeOutput(output)).toMatchSnapshot(); - // }); - - // test('inline LQIP is supported', async () => { - // const { stats } = await compiler( - // 'fixtures/image.jpg?responsive', - // _dirname, - // { - // lqip: { type: 'inline' }, - // }, - // ); - - // expect(stats.modules).toBeDefined(); - // expect(stats.modules![0]?.modules).toHaveLength(3); - - // const output = stats.modules?.[0]?.modules?.[0]?.source; - // expect(sanitizeOutput(output)).toMatchSnapshot(); - // }); +describe('LQIP', async () => { + test('color LQIP is supported', async () => { + const { source, assets } = await compile('image.jpg', { + include: '**/*.jpg', + w: [100, 200], + lqip: { type: 'color' }, + }); + + expect(assets.map((a) => a.fileName)).toEqual([ + 'image-100w.jpg', + 'image-100w.webp', + 'image-200w.jpg', + 'image-200w.webp', + 'style.css', + ]); + + expect(source).toMatchSnapshot(); + + const style = assets.find((a) => a.fileName === 'style.css'); + expect(style?.source).toMatchInlineSnapshot(` + ".eri-dyn-0{background-color:#584838} + " + `); + }); + + test('inline LQIP is supported', async () => { + const { source, assets } = await compile('image.jpg', { + include: '**/*.jpg', + w: [100, 200], + lqip: { type: 'inline' }, + }); + + expect(assets.map((a) => a.fileName)).toEqual([ + 'image-100w.jpg', + 'image-100w.webp', + 'image-200w.jpg', + 'image-200w.webp', + 'style.css', + ]); + + expect(source).toMatchSnapshot(); + + const style = assets.find((a) => a.fileName === 'style.css'); + expect(style?.source).toMatchInlineSnapshot(` + ".eri-dyn-0{background-image:url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMjAwIDEwMCI+CjxmaWx0ZXIgaWQ9ImIiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiI+PGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iLjUiPjwvZmVHYXVzc2lhbkJsdXI+PGZlQ29tcG9uZW50VHJhbnNmZXI+PGZlRnVuY0EgdHlwZT0iZGlzY3JldGUiIHRhYmxlVmFsdWVzPSIxIDEiPjwvZmVGdW5jQT48L2ZlQ29tcG9uZW50VHJhbnNmZXI+PC9maWx0ZXI+CjxpbWFnZSBmaWx0ZXI9InVybCgjYikiIHByZXNlcnZlQXNwZWN0UmF0aW89Im5vbmUiIGhlaWdodD0iMTAwJSIgd2lkdGg9IjEwMCUiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQUFOU1VoRVVnQUFBQXNBQUFBRkNBSUFBQUFjeElFQkFBQUFDWEJJV1hNQUFBc1RBQUFMRXdFQW1wd1lBQUFBdFVsRVFWUjRuQUdxQUZYL0FIaHlYSk9JYXFtZWZLeWpoYjJ4a3J5eWxzU3prN3l1a3JXcmo2aWdoS1NhZXdDYWUwNnBpVld3a0Z1MmwyREFvR2JQcVdmczFKN2p4WW5EbldDeWsxNmxoMVlBTnlvYVBTMGJTVFVmVVRzaFlVWW1iMDBualcxQW5IVkNkbFV3ZEZjMWNGTXpBREl3Sno4NkxrbERORlJNTzJoY1JWNVNRSnQvVUp4K1VtZGNTVjlWUWw5UlBRQTVOQ2RGUGl4UFJ6UldURGhlVWp0a1ZEM0puRldhZVVwT1JqZFRTVGhWU1RtTWVFV0J5d1lza1FBQUFBQkpSVTVFcmtKZ2dnPT0iPjwvaW1hZ2U+Cjwvc3ZnPg==)} + " + `); + }); test('blurhash LQIP is supported', async () => { const { source } = await compile('image.jpg', { diff --git a/packages/vite-plugin/tests/utils.ts b/packages/vite-plugin/tests/utils.ts index 94198bab7..13ac77c95 100644 --- a/packages/vite-plugin/tests/utils.ts +++ b/packages/vite-plugin/tests/utils.ts @@ -8,8 +8,6 @@ import type { OutputAsset, OutputChunk, RollupOutput } from 'rollup'; const _dirname = dirname(fileURLToPath(import.meta.url)); -const imageExtensions = ['.jps', '.jpeg', '.png', '.webp', '.avif']; - function entryFile(source: string): Plugin { let id: string; @@ -53,11 +51,6 @@ export async function compile( }, write: false, modulePreload: { polyfill: false }, - rollupOptions: { - output: { - assetFileNames: `images/[name].[ext]`, - }, - }, }, plugins: [entryFile(source), setupPlugins(options)], })) as RollupOutput | RollupOutput[]; @@ -75,9 +68,7 @@ export async function compile( } const assets = bundle.output - .filter((chunk): chunk is OutputAsset => - imageExtensions.some((ext) => chunk.fileName.endsWith(ext)), - ) + .filter((chunk): chunk is OutputAsset => chunk.type === 'asset') .toSorted((a, b) => a.fileName.localeCompare(b.fileName)); return {