Skip to content

Commit

Permalink
Merge pull request #642 from simonihmig/vite-lqip
Browse files Browse the repository at this point in the history
Add LQIP support to vite-plugin
  • Loading branch information
simonihmig authored Sep 7, 2024
2 parents 60c98b0 + 7ca5b5c commit c2987f1
Show file tree
Hide file tree
Showing 13 changed files with 448 additions and 123 deletions.
5 changes: 5 additions & 0 deletions .changeset/violet-vans-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@responsive-image/vite-plugin': minor
---

Add LQIP support to vite-plugin
8 changes: 8 additions & 0 deletions packages/vite-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -11,6 +15,10 @@ function setupPlugins(options?: Partial<Options>) {
loaderPlugin(options),
resizePlugin(options),
lqipBlurhashPlugin(options),
lqipColorPlugin(options),
lqipColorCssPlugin(options),
lqipInlinePlugin(options),
lqipInlineCssPlugin(options),
exportPlugin(options),
servePlugin(options),
];
Expand Down
2 changes: 1 addition & 1 deletion packages/vite-plugin/src/loader.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
2 changes: 1 addition & 1 deletion packages/vite-plugin/src/lqip/blurhash.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
50 changes: 50 additions & 0 deletions packages/vite-plugin/src/lqip/color-css.ts
Original file line number Diff line number Diff line change
@@ -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<Options> = {},
): 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;
},
};
}
52 changes: 52 additions & 0 deletions packages/vite-plugin/src/lqip/color.ts
Original file line number Diff line number Diff line change
@@ -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<Options> = {},
): 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,
},
};
},
};
}
94 changes: 94 additions & 0 deletions packages/vite-plugin/src/lqip/inline-css.ts
Original file line number Diff line number Diff line change
@@ -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<Options> = {},
): 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) };
}
53 changes: 53 additions & 0 deletions packages/vite-plugin/src/lqip/inline.ts
Original file line number Diff line number Diff line change
@@ -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<Options> = {},
): 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,
},
};
},
};
}
2 changes: 1 addition & 1 deletion packages/vite-plugin/src/resize.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
10 changes: 6 additions & 4 deletions packages/vite-plugin/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Options> {
const params =
query instanceof URLSearchParams ? query : new URLSearchParams(query);
Expand Down Expand Up @@ -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: [],
};
}
Expand Down
Loading

0 comments on commit c2987f1

Please sign in to comment.