I've been working on a new docs site for https://replicate.com/ and we're using Remix as a front-end framework and MDX for all of our technical writing.
We co-locate all of our high-res image assets in the repository itself, and were looking for a way to, at dev and build time:
- Convert and resize images on-demand (e.g., to next-gen formats like
.webp
) - Generate LQIP assets
- Extract metadata from the underlying image asset, such as width and height.
The most recent of version of Remix works as a Vite plugin, which means you as the developer have access to the wonderful ecosystem of Vite at your disposal when building your site.
We discovered an awesome library which handles most of the aforementioned functionality: vite-imagetools
.
In so many words, it allows you to specify a whole host of "directives" when importing an image asset, most of which correspond neatly to Sharp – the Node-based image handling library – transformations.
So, say you import an image like this 👇
import dreambooth from '/app/assets/docs/dreambooth.png?format=webp&as=metadata';
dreambooth
is now a Javascript object with a bunch of goodies on it:
{
width: 1024,
height: 1024,
src: "...",
// etcetera
}
While vite-imagetools
didn't come out of the box with an LQIP transformation, it does have a wonderful API for extending the core functionality of the library.
Specifically, you can specify additional transforms like this, and even specify default sets of directives based on whatever criteria you desire. For us, we created a special ?optimize
directive which converts to webp, grabs the width/height, and generates a LQIP.
const placeholderTransform: TransformFactory = (config) => {
return async (image) => {
if (!("lqip" in config)) return image;
/** @ts-ignore it's a string */
const href = await createPlaceholder(image.options.input.file);
setMetadata(image, "lqip", href);
return image;
};
};
// Pass this object when initializing the plugin in your `vite.config.ts`.
export const imageToolsConfig: Partial<VitePluginOptions> = {
extendTransforms: (builtins) => {
return [placeholderTransform, ...builtins];
},
defaultDirectives: (url) => {
if (url.searchParams.has("optimize")) {
return new URLSearchParams({
as: "meta:height;width;lqip;src",
lqip: "true",
format: "webp",
});
}
return new URLSearchParams({});
},
};
The implementation of createPlaceholder
was lifted verbatim from a great GitHub issue on this topic. The real magic in the snippet above, though, is the setMetadata
function exposed by vite-imagetools
which allows you to set arbitrary additional pieces of metadata on the resolved image object.
import sharp from "sharp";
import {
setMetadata,
type TransformFactory,
type VitePluginOptions,
} from "vite-imagetools";
const PLACEHOLDER_WIDTH = 16;
async function resizeImage(
inputFile: string,
options: { width: number; height?: number; quality?: number }
) {
if (!inputFile) {
throw new Error("Input file is required");
}
let sharpInstance = sharp(inputFile).toFormat("webp").blur(3);
sharpInstance = sharpInstance.resize(options.width, options.height);
return sharpInstance.toBuffer();
}
/**
* @param {string} inputFile
*/
export async function getImageMetadata(inputFile: any) {
if (!inputFile) {
throw new Error("Input file is required");
}
return sharp(inputFile).metadata();
}
// sharp only supports a very specific list of image formats,
// no point depending on a complete mime type database
/**
* @param {string | undefined} format
*/
export function getMimeType(format?: keyof sharp.FormatEnum) {
switch (format) {
case "jpeg":
case "png":
case "webp":
case "avif":
case "tiff":
case "gif":
return `image/${format}`;
case "svg":
return "image/svg+xml";
}
return "";
}
/**
* @param {string} inputFile
*/
export async function createPlaceholder(inputFile: sharp.Sharp) {
if (!inputFile) {
throw new Error("Input file is required");
}
const [{ format }, blurData] = await Promise.all([
getImageMetadata(inputFile),
// @ts-ignore
resizeImage(inputFile, { width: PLACEHOLDER_WIDTH }),
]);
const blur64 = blurData.toString("base64");
const mime = getMimeType(format);
const href = `data:${mime};base64,${blur64}`;
return href;
}
The end result here is that now, when you import your image module in code, you have an additional lqip
field on the resolve object.
import dreambooth from '/app/assets/docs/dreambooth.png?optimize';
{
width: 1024,
height: 1024,
src: "...",
lqip: "huge-base64-string",
// etcetera
}
With this, you can pass all of this data to a React component and do things like:
- set the
lqip
as a background image, and fade the image on top - set an aspect ratio that's a function of the
width
andheight
Happy hacking!