Skip to content

Commit

Permalink
Uses generateObject from Eleventy Image for <picture> HTML genera…
Browse files Browse the repository at this point in the history
…tion

- **Breaking**: The fallback `<img>` will have the smallest width file instead of medium width.
- These changes were made in order to add support for SVG in future
  • Loading branch information
saneef committed Dec 2, 2023
1 parent 9a96137 commit 47063d1
Show file tree
Hide file tree
Showing 21 changed files with 198 additions and 123 deletions.
6 changes: 3 additions & 3 deletions .eleventy.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
const img2picture = require("./lib/img2picture.js");

/** @typedef { import('./img2picture').Img2PictureOptions } Img2PictureOptions */
/** @typedef {import("./lib/img2picture").Img2PictureOptions} Img2PictureOptions */

module.exports = {
/**
* Plugin config function
*
* @param {object} eleventy The eleventy configuration object
* @param {Img2PictureOptions=} The options
* @param {object} eleventy The eleventy configuration object
* @param {Img2PictureOptions} [The] Options
*/
configFunction(eleventy, options) {
eleventy.addTransform("img2picture", img2picture(options));
Expand Down
122 changes: 55 additions & 67 deletions lib/img2picture.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,30 @@ const { isRemoteUrl, getPathFromUrl } = require("./utils/url");
const { generateWidths } = require("./utils/image");
const { isAllowedExtension } = require("./utils/file");

/** @typedef { import('@11ty/eleventy-img').ImageFormatWithAliases } ImageFormatWithAliases */
/** @typedef {import("@11ty/eleventy-img").ImageFormatWithAliases} ImageFormatWithAliases */

/**
* @typedef {object} Img2PictureOptions
* @property {string} eleventyInputDir
* @property {string} imagesOutputDir
* @property {string} urlPath
* @property {Array<string>=} extensions
* @property {Array<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 {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]
*/

/** @type {Img2PictureOptions} */
Expand All @@ -62,7 +62,12 @@ const defaultOptions = {
};

/**
* @typedef {(id: string, src: unknown, width: number, format: string) => string} filenameFormatFn
* @typedef {(
* id: string,
* src: unknown,
* width: number,
* format: string,
* ) => string} filenameFormatFn
*/

/**
Expand All @@ -77,29 +82,13 @@ const filenameFormatter = function (id, src, width, format) {
return `${name}-${id}-${width}w.${format}`;
};

/**
*
* Returns the last (assumed to be most compatible) format metadata
*
*
* @param {object} metadata The metadata
* @param {Img2PictureOptions} options The options
* @return {object} The the last from metadata
*/
const getFallbackFormat = (metadata, options) => {
const { formats = ["jpeg"] } = options;
const fallbackFormat = formats[formats.length - 1];

return metadata[fallbackFormat];
};

/**
* Generates `<picture>` element from Image metadata
*
* @param {object} metadata The metadata
* @param {object} attrs The attributes
* @param {Img2PictureOptions} options The options
* @return {string} The <picture> element
* @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;
Expand All @@ -109,9 +98,6 @@ function generatePicture(metadata, attrs, options) {
"data-img2picture-widths",
"data-img2picture-picture-class",
"src",
"width",
"height",
"sizes",
];

let pictureAttrs = {
Expand All @@ -130,34 +116,30 @@ function generatePicture(metadata, attrs, options) {

const imgAttrs = {
...removeObjectProperties(attrs, attributesToRemoveFromImg),
alt: attrs.alt || "",
sizes: attrs.sizes || sizes,
loading: attrs.loading || "lazy",
decoding: attrs.decoding || "async",
};

const fallbackFormat = getFallbackFormat(metadata, options);
// @ts-ignore
const { picture } = Image.generateObject(metadata, imgAttrs);

const medsrc = fallbackFormat[Math.floor(fallbackFormat.length / 2)];
const highsrc = fallbackFormat[fallbackFormat.length - 1];
return `<picture${objectToAttributes(pictureAttrs)}>${Object.values(metadata)
.map((imageFormat) => {
return `<source type="${imageFormat[0].sourceType}" srcset="${imageFormat
.map((entry) => entry.srcset)
.join(", ")}" sizes="${attrs.sizes || sizes}">`;
return `<picture ${objectToAttributes(pictureAttrs)}>${picture
.map((d) => {
const [[tag, obj]] = Object.entries(d);
return `<${tag} ${objectToAttributes(obj)}></${tag}>`;
})
.join("")}<img
src="${medsrc.url}"
width="${highsrc.width}"
height="${highsrc.height}"
${objectToAttributes(imgAttrs)}></picture>`;
.join("")}</picture>`;
}

/**
* Generate responsive image files, and return a `<picture>` element
* populated with generated file paths and sizes.
* 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
* @return {Promise} { description_of_the_return_value }
* @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 {
Expand Down Expand Up @@ -207,12 +189,12 @@ async function generateImage(attrs, options) {
}

/**
* Replaces `<img>` elements with `<picture>`
* with responsive sizes and formats
* Replaces `<img>` elements with `<picture>` with responsive sizes and formats
*
* @param {string} content The content
* @param {Img2PictureOptions} options The options
* @return {Promise<string>} HTML content with <img> replaced with <picture> elements
* @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;
Expand Down Expand Up @@ -247,6 +229,12 @@ async function replaceImages(content, options) {
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);
Expand All @@ -265,8 +253,8 @@ async function replaceImages(content, options) {
/**
* Initialise transformer function
*
* @param {Img2PictureOptions=} userOptions The options
* @return {(content: string) => Promise<string>} The transformer function
* @param {Img2PictureOptions} [userOptions] The options
* @returns {(content: string) => Promise<string>} The transformer function
*/
function img2picture(userOptions) {
/** @type {Img2PictureOptions} */
Expand Down
20 changes: 13 additions & 7 deletions lib/utils/object.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
// @ts-check
const { escapeAttribute } = require("entities/lib/escape.js");

/**
* Converts object to HTML attributes string
*
* @param {object} obj The object
* @return {string}
* @param {object} obj The object
* @returns {string}
*/
function objectToAttributes(obj) {
return Object.keys(obj).reduce((acc, key) => {
// Ignore empty class attribute
if (key === "class" && !obj[key]) return acc;
let value = obj[key];
if (key === "class" && !value) return acc;

return `${acc} ${key}="${obj[key]}"`;
if (key === "alt") {
value = escapeAttribute(value);
}

return `${acc} ${key}="${value}"`;
}, "");
}

/**
* Removes object properties.
*
* @param {object} obj The object
* @param {Array<string>} props The properties
* @return {object} Shallow cloned object without properties
* @param {object} obj The object
* @param {string[]} props The properties
* @returns {object} Shallow cloned object without properties
*/
function removeObjectProperties(obj, props = []) {
const copy = { ...obj };
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@
"@11ty/eleventy-img": "^3.1.8",
"cheerio": "^1.0.0-rc.12",
"debug": "^4.3.4",
"doctoc": "^2.2.1"
"entities": "^4.5.0"
},
"devDependencies": {
"ava": "^5.3.1",
"doctoc": "^2.2.1",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"eslint-config-xo-space": "^0.34.0",
Expand All @@ -43,7 +44,7 @@
"lint-staged": {
"*.js": "eslint --cache --fix",
"*.{js,md,json}": "prettier --write",
"*.md": "doctoc --title '## Table of Contents'"
"README.md": "doctoc --title '## Table of Contents'"
},
"ava": {
"failFast": false,
Expand Down
Loading

0 comments on commit 47063d1

Please sign in to comment.