eleventy-plugin-img2picture
Version:
Eleventy plugin to replace <img> using <picture> with resized and optimized images.
293 lines (256 loc) • 8.06 kB
JavaScript
// @ts-check
const cheerio = require("cheerio");
const Image = require("@11ty/eleventy-img");
const path = require("path");
const debug = require("debug")("img2picture");
const {
removeObjectProperties,
objectToHTMLAttributes,
} = require("./utils/object");
const { parseStringToNumbers } = require("./utils/number");
const { isRemoteUrl, getPathFromUrl } = require("./utils/url");
const { generateWidths } = require("./utils/image");
const { isAllowedExtension } = require("./utils/file");
/** @typedef {import("@11ty/eleventy-img").ImageFormatWithAliases} ImageFormatWithAliases */
/**
* @typedef {object} Img2PictureOptions
* @property {string} eleventyInputDir
* @property {string} imagesOutputDir
* @property {string} urlPath
* @property {string[]} [extensions]
* @property {ImageFormatWithAliases[]} [formats]
* @property {string} [sizes]
* @property {number} [minWidth]
* @property {number} [maxWidth]
* @property {number} [widthStep]
* @property {boolean} [hoistImgClass]
* @property {string} [pictureClass]
* @property {filenameFormatFn} [filenameFormat]
* @property {boolean} [fetchRemote]
* @property {boolean} [dryRun]
* @property {object} [sharpOptions]
* @property {object} [cacheOptions]
* @property {object} [sharpWebpOptions]
* @property {object} [sharpPngOptions]
* @property {object} [sharpJpegOptions]
* @property {object} [sharpAvifOptions]
* @property {boolean | "size"} [svgShortCircuit]
* @property {"br"} [svgCompressionSize]
*/
/** @type {Img2PictureOptions} */
const defaultOptions = {
eleventyInputDir: ".",
imagesOutputDir: "_site",
urlPath: "",
extensions: ["jpg", "png", "jpeg", "svg"],
formats: ["avif", "webp", "svg", "jpeg"],
sizes: "100vw",
minWidth: 150,
maxWidth: 1500,
widthStep: 150,
hoistImgClass: false,
pictureClass: "",
fetchRemote: false,
dryRun: false,
cacheOptions: {},
sharpOptions: {},
sharpWebpOptions: {},
sharpPngOptions: {},
sharpJpegOptions: {},
sharpAvifOptions: {},
svgShortCircuit: "size",
};
/**
* @typedef {(
* id: string,
* src: unknown,
* width: number,
* format: string,
* ) => string} filenameFormatFn
*/
/**
* Default function to generate image filenames
*
* @type {filenameFormatFn}
*/
const filenameFormatter = function (id, src, width, format) {
const extension = path.extname(/** @type {string} */ (src));
const name = path.basename(/** @type {string} */ (src), extension);
return `${name}-${id}-${width}w.${format}`;
};
/**
* Generates `<picture>` element from Image metadata
*
* @param {object} metadata The metadata
* @param {object} attrs The attributes
* @param {Img2PictureOptions} options The options
* @returns {string} The <picture> element
*/
function generatePicture(metadata, attrs, options) {
const { sizes, hoistImgClass, pictureClass } = options;
let attributesToRemoveFromImg = [
"data-img2picture-ignore",
"data-img2picture-widths",
"data-img2picture-picture-class",
"src",
];
let pictureAttrs = {
class: attrs["data-img2picture-picture-class"] || pictureClass || "",
};
if (hoistImgClass) {
const imgClass = attrs.class || "";
attributesToRemoveFromImg = [...attributesToRemoveFromImg, "class"];
pictureAttrs = {
...pictureAttrs,
class: (pictureAttrs.class + " " + imgClass).trim(),
};
}
const imgAttrs = {
...removeObjectProperties(attrs, attributesToRemoveFromImg),
alt: attrs.alt || "",
sizes: attrs.sizes || sizes,
loading: attrs.loading || "lazy",
decoding: attrs.decoding || "async",
};
const tagsObject = /** @type {object} */ (
Image.generateObject(metadata, imgAttrs)
);
// When `svgShortCircuit=true` only `<img>` will be there.
if (tagsObject.img) {
return tagObjectToHTML(tagsObject);
}
// '@children' is internally used in Eleventy Image
// https://github.com/11ty/eleventy-img/blob/092ad7a03caa08e2704690e88255da182a3acb1d/src/generate-html.js#L5
const children = tagsObject?.picture?.["@children"];
return `<picture ${objectToHTMLAttributes(pictureAttrs)}>${children
.map(tagObjectToHTML)
.join("")}</picture>`;
}
function tagObjectToHTML(object) {
const [[tag, obj]] = Object.entries(object);
return `<${tag} ${objectToHTMLAttributes(obj)}></${tag}>`;
}
/**
* Generate responsive image files, and return a `<picture>` element populated
* with generated file paths and sizes.
*
* @param {Record<string, string>} attrs The attributes
* @param {Img2PictureOptions} options The options
* @returns {Promise<string>} The picture tag as string
*/
async function generateImage(attrs, options) {
const {
cacheOptions,
dryRun,
eleventyInputDir,
filenameFormat,
formats,
imagesOutputDir,
maxWidth = 1500,
minWidth = 150,
sharpAvifOptions,
sharpJpegOptions,
sharpOptions,
sharpPngOptions,
sharpWebpOptions,
urlPath,
widthStep = 150,
svgShortCircuit,
svgCompressionSize,
} = options;
const { src, "data-img2picture-widths": imgAttrWidths } = attrs;
const widths = imgAttrWidths
? parseStringToNumbers(imgAttrWidths)
: generateWidths(minWidth, maxWidth, widthStep);
const filePath = isRemoteUrl(src) ? src : path.join(eleventyInputDir, src);
const filenameFormatFn =
typeof filenameFormat === "function" ? filenameFormat : filenameFormatter;
// eslint-disable-next-line new-cap
const metadata = await Image(filePath, {
formats,
filenameFormat: filenameFormatFn,
widths,
urlPath,
outputDir: imagesOutputDir,
sharpOptions,
sharpWebpOptions,
sharpPngOptions,
sharpJpegOptions,
sharpAvifOptions,
dryRun,
cacheOptions,
// @ts-ignore
svgShortCircuit,
svgCompressionSize,
});
return generatePicture(metadata, attrs, options);
}
/**
* Replaces `<img>` elements with `<picture>` with responsive sizes and formats
*
* @param {string} content The content
* @param {Img2PictureOptions} options The options
* @returns {Promise<string>} HTML content with <img> replaced with <picture>
* elements
*/
async function replaceImages(content, options) {
const { extensions, fetchRemote } = options;
const $ = cheerio.load(content);
const images = $("img")
.not("picture img") // Ignore images wrapped in <picture>
.not("[data-img2picture-ignore]") // Ignore excluded images
.filter((i, el) => {
const src = $(el).attr("src");
if (src && extensions) {
// Exclude remote URLs when fetchRemote=false
const pathFromUrl = getPathFromUrl(src);
if (pathFromUrl) {
// Is remote URL
if (fetchRemote) {
return isAllowedExtension(pathFromUrl, extensions);
}
return false;
}
// Exclude paths with extensions other than provided
return isAllowedExtension(src, extensions);
}
return false;
});
const promises = [];
for (let i = 0; i < images.length; i++) {
const img = images[i];
const attrs = $(img).attr();
if (attrs) {
if (attrs.alt === undefined) {
console.warn(
`WARN: Missing 'alt' attribute on <img src="${attrs.src}" … />`,
);
}
debug(`Optimizing: ${attrs.src}`);
promises[i] = generateImage(attrs, options);
}
}
const pictures = await Promise.all(promises);
pictures.forEach((picture, i) => {
$(images[i]).replaceWith(picture);
});
return $.html();
}
/**
* Initialise transformer function
*
* @param {Img2PictureOptions} [userOptions] The options
* @returns {(content: string) => Promise<string>} The transformer function
*/
function img2picture(userOptions) {
/** @type {Img2PictureOptions} */
const options = { ...defaultOptions, ...(userOptions || {}) };
return async function (content, outputPath) {
if (outputPath && outputPath.endsWith(".html")) {
return replaceImages(content, options);
}
return content;
};
}
module.exports = img2picture;
module.exports.filenameFormatter = filenameFormatter;