@11ty/eleventy-img
Version:
Low level utility to perform build-time image transformations.
211 lines (168 loc) • 6.95 kB
JavaScript
const path = require("node:path");
const Util = require("./util.js");
const { imageAttributesToPosthtmlNode, getOutputDirectory, cleanTag, isIgnored, isOptional } = require("./image-attrs-to-posthtml-node.js");
const { getGlobalOptions } = require("./global-options.js");
const { eleventyImageOnRequestDuringServePlugin } = require("./on-request-during-serve-plugin.js");
const PLACEHOLDER_DATA_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=";
const ATTRS = {
ORIGINAL_SOURCE: "eleventy:internal_original_src",
};
function getSrcAttributeValue(sourceNode/*, rootTargetNode*/) {
// Debatable TODO: use rootTargetNode (if `picture`) to retrieve a potentially higher quality source from <source srcset>
return sourceNode.attrs?.src;
}
function assignAttributes(rootTargetNode, newNode) {
// only copy attributes if old and new tag name are the same (picture => picture, img => img)
if(rootTargetNode.tag !== newNode.tag) {
delete rootTargetNode.attrs;
}
if(!rootTargetNode.attrs) {
rootTargetNode.attrs = {};
}
// Copy all new attributes to target
if(newNode.attrs) {
Object.assign(rootTargetNode.attrs, newNode.attrs);
}
}
function getOutputLocations(originalSource, outputDirectoryFromAttribute, pageContext, options) {
let projectOutputDirectory = options.directories.output;
if(outputDirectoryFromAttribute) {
if(path.isAbsolute(outputDirectoryFromAttribute)) {
return {
outputDir: path.join(projectOutputDirectory, outputDirectoryFromAttribute),
urlPath: outputDirectoryFromAttribute,
};
}
return {
outputDir: path.join(projectOutputDirectory, pageContext.url, outputDirectoryFromAttribute),
urlPath: path.join(pageContext.url, outputDirectoryFromAttribute),
};
}
if(options.urlPath) {
// do nothing, user has specified directories in the plugin options.
return {};
}
if(path.isAbsolute(originalSource)) {
// if the path is an absolute one (relative to the content directory) write to a global output directory to avoid duplicate writes for identical source images.
return {
outputDir: path.join(projectOutputDirectory, "/img/"),
urlPath: "/img/",
};
}
// If original source is a relative one, this colocates images to the template output.
let dir = path.dirname(pageContext.outputPath);
// filename is included in url: ./dir/post.html => /dir/post.html
if(pageContext.outputPath.endsWith(pageContext.url)) {
// remove file name
let split = pageContext.url.split("/");
split[split.length - 1] = "";
return {
outputDir: dir,
urlPath: split.join("/"),
};
}
// filename is not included in url: ./dir/post/index.html => /dir/post/
return {
outputDir: dir,
urlPath: pageContext.url,
};
}
function transformTag(context, sourceNode, rootTargetNode, opts) {
let originalSource = getSrcAttributeValue(sourceNode, rootTargetNode);
if(!originalSource) {
return sourceNode;
}
let { inputPath } = context.page;
sourceNode.attrs.src = Util.normalizeImageSource({
input: opts.directories.input,
inputPath,
}, originalSource, {
isViaHtml: true, // this reference came from HTML, so we can decode the file name
});
if(sourceNode.attrs.src !== originalSource) {
sourceNode.attrs[ATTRS.ORIGINAL_SOURCE] = originalSource;
}
let outputDirectoryFromAttribute = getOutputDirectory(sourceNode);
let instanceOptions = getOutputLocations(originalSource, outputDirectoryFromAttribute, context.page, opts);
// returns promise
return imageAttributesToPosthtmlNode(sourceNode.attrs, instanceOptions, opts).then(newNode => {
// node.tag
// node.attrs
// node.content
assignAttributes(rootTargetNode, newNode);
rootTargetNode.tag = newNode.tag;
rootTargetNode.content = newNode.content;
}, (error) => {
if(isOptional(sourceNode) || !opts.failOnError) {
if(isOptional(sourceNode, "keep")) {
// replace with the original source value, no image transformation is taking place
if(sourceNode.attrs[ATTRS.ORIGINAL_SOURCE]) {
sourceNode.attrs.src = sourceNode.attrs[ATTRS.ORIGINAL_SOURCE];
}
// leave as-is, likely 404 when a user visits the page
} else if(isOptional(sourceNode, "placeholder")) {
// transparent png
sourceNode.attrs.src = PLACEHOLDER_DATA_URI;
} else if(isOptional(sourceNode)) {
delete sourceNode.attrs.src;
}
// optional or don’t fail on error
cleanTag(sourceNode);
return Promise.resolve();
}
return Promise.reject(error);
});
}
function eleventyImageTransformPlugin(eleventyConfig, options = {}) {
options = Object.assign({
extensions: "html",
transformOnRequest: process.env.ELEVENTY_RUN_MODE === "serve",
}, options);
if(options.transformOnRequest !== false) {
// Add the on-request plugin automatically (unless opt-out in this plugins options only)
eleventyConfig.addPlugin(eleventyImageOnRequestDuringServePlugin);
}
// Notably, global options are not shared automatically with the WebC `eleventyImagePlugin` above.
// Devs can pass in the same object to both if they want!
let opts = getGlobalOptions(eleventyConfig, options, "transform");
eleventyConfig.addJavaScriptFunction("__private_eleventyImageTransformConfigurationOptions", () => {
return opts;
});
function posthtmlPlugin(context) {
return async (tree) => {
let promises = [];
let match = tree.match;
tree.match({ tag: 'picture' }, pictureNode => {
match.call(pictureNode, { tag: 'img' }, imgNode => {
imgNode._insideOfPicture = true;
if(!isIgnored(imgNode) && !imgNode?.attrs?.src?.startsWith("data:")) {
promises.push(transformTag(context, imgNode, pictureNode, opts));
}
return imgNode;
});
return pictureNode;
});
tree.match({ tag: 'img' }, (imgNode) => {
if(imgNode._insideOfPicture) {
delete imgNode._insideOfPicture;
} else if(isIgnored(imgNode) || imgNode?.attrs?.src?.startsWith("data:")) {
cleanTag(imgNode);
} else {
promises.push(transformTag(context, imgNode, imgNode, opts));
}
return imgNode;
});
await Promise.all(promises);
return tree;
};
}
if(!eleventyConfig.htmlTransformer || !("addPosthtmlPlugin" in eleventyConfig.htmlTransformer)) {
throw new Error("[@11ty/eleventy-img] `eleventyImageTransformPlugin` is not compatible with this version of Eleventy. You will need to use v3.0.0 or newer.");
}
eleventyConfig.htmlTransformer.addPosthtmlPlugin(options.extensions, posthtmlPlugin, {
priority: -1, // we want this to go before <base> or inputpath to url
});
}
module.exports = {
eleventyImageTransformPlugin,
};