Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions eleventy.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@ export default (eleventyConfig) => {
eleventyConfig.addDataExtension('yml, yaml', load);
eleventyConfig.setQuietMode(true);

// eleventy-img is async-only as of v7, but the `image` shortcode is called
// from inside (synchronous) Nunjucks macros -- so gather image metadata
// up front, and generate the markup synchronously from that.
eleventyConfig.on('eleventy.before', images.cacheImageMetadata);

if (!process.env.NETLIFY) {
eleventyConfig.on('eleventy.before', () => {
delete process.env.IMAGE_CACHE_CHANGED;
Expand Down
22 changes: 11 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"devDependencies": {
"@11ty/eleventy": "^3.1.6",
"@11ty/eleventy-fetch": "^5.1.3",
"@11ty/eleventy-img": "^6.0.4",
"@11ty/eleventy-img": "^7.0.0",
"@11ty/eleventy-plugin-rss": "^3.0.0",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
"@11ty/is-land": "^5.0.1",
Expand All @@ -75,36 +75,36 @@
"@rollup/plugin-terser": "^1.0.0",
"@tommoor/remove-markdown": "^0.3.2",
"autoprefixer": "^10.5.4",
"chalk": "^5.6.2",
"chalk": "^6.0.0",
"chokidar-cli": "^3.0.0",
"core-js": "^3.49.0",
"date-fns": "^4.4.0",
"dotenv": "^17.4.2",
"doxray": "^0.11.0",
"eslint": "^10.7.0",
"eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jest": "^29.15.5",
"eslint-plugin-jest": "^29.16.0",
"eslint-plugin-simple-import-sort": "^14.0.0",
"fs-extra": "^11.3.6",
"globals": "^17.7.0",
"fs-extra": "^11.4.0",
"globals": "^17.9.0",
"jest": "^30.4.2",
"jest-environment-jsdom": "^30.4.1",
"js-yaml": "^5.2.1",
"js-yaml": "^5.2.3",
"lite-youtube-embed": "^0.3.4",
"lodash-es": "^4.18.1",
"markdown-it": "^14.3.0",
"markdown-it": "^15.0.0",
"markdown-it-anchor": "^9.2.1",
"markdown-it-footnote": "^4.0.0",
"markdown-it-mark": "^4.0.0",
"mockdate": "^3.0.5",
"netlify-plugin-11ty": "^1.5.0",
"node-fetch": "^3.3.2",
"npm-run-all": "^4.1.5",
"postcss": "^8.5.22",
"postcss": "^8.5.25",
"posthtml": "^0.16.7",
"prettier": "^3.9.6",
"rimraf": "^6.1.3",
"rollup": "^4.62.2",
"rollup": "^4.62.4",
"sanitize-html": "^2.17.6",
"sass-embedded": "^1.100.0",
"sassdoc": "^2.7.4",
Expand All @@ -117,5 +117,5 @@
"typescript": "^6.0.3",
"typogr": "^0.6.8"
},
"packageManager": "yarn@4.17.1"
"packageManager": "yarn@4.18.0"
}
73 changes: 55 additions & 18 deletions src/filters/images.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable no-sync, no-process-env */

import { basename, dirname, extname, join } from 'node:path';
import { globSync } from 'node:fs';
import { basename, dirname, extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';

import eleventyImg from '@11ty/eleventy-img';
Expand Down Expand Up @@ -52,6 +53,49 @@ if (useCache && !rebuildCache && fs.existsSync(CACHE_FILE)) {
imageCache = fs.readJsonSync(CACHE_FILE);
}

// Options are derived entirely from the source path,
// so image metadata can be cached by source path alone.
const imgOptionsFor = (src) => {
let outputDir = './_site/assets/images/';
let urlPath = '/assets/images/';
if (src.startsWith(IMG_SRC)) {
const dir = dirname(src.slice(IMG_SRC.length));
outputDir = `${outputDir}${dir}`;
urlPath = `${urlPath}${dir}`;
} else {
// eslint-disable-next-line no-console
console.warn(`Unexpected image source path: "${src}"`);
}
return { ...imgOptions, outputDir, urlPath };
};

// As of v7, eleventy-img is async-only (`statsSync` was removed).
// The `image` shortcode is called from inside Nunjucks macros,
// which can only be rendered synchronously, so we pre-compute the
// metadata for every source image before the build starts. This only
// reads image headers (no image processing), and takes under a second.
export const imageMetadata = new Map();
export const metadataKey = (src) => normalize(src);

export const cacheImageMetadata = async () => {
const files = globSync(`${IMG_SRC}**/*.{jpg,jpeg,png,gif,webp,avif,svg}`);
await Promise.all(
files.map(async (file) => {
const src = `./${metadataKey(file)}`;
try {
const metadata = await eleventyImg(src, {
...imgOptionsFor(src),
statsOnly: true,
});
imageMetadata.set(metadataKey(src), metadata);
} catch (error) {
// eslint-disable-next-line no-console
console.warn(`Unable to read image metadata for "${src}": ${error}`);
}
}),
);
};

/* @docs
label: image
category: responsive images
Expand Down Expand Up @@ -81,21 +125,7 @@ params:
Returns url to largest jpeg image instead of full HTML
*/
export const image = (src, alt, attrs, sizes, getUrl) => {
let outputDir = './_site/assets/images/';
let urlPath = '/assets/images/';
if (src.startsWith(IMG_SRC)) {
const dir = dirname(src.slice(IMG_SRC.length));
outputDir = `${outputDir}${dir}`;
urlPath = `${urlPath}${dir}`;
} else {
// eslint-disable-next-line no-console
console.warn(`Unexpected image source path: "${src}"`);
}
const opts = {
...imgOptions,
outputDir,
urlPath,
};
const opts = imgOptionsFor(src);
const imgSizes =
sizes && imgConfig.sizes[sizes]
? imgConfig.sizes[sizes]
Expand Down Expand Up @@ -136,11 +166,18 @@ export const image = (src, alt, attrs, sizes, getUrl) => {
}
}

const metadata = imageMetadata.get(metadataKey(src));
if (!metadata) {
throw new Error(
`Missing image metadata for "${src}". ` +
`Images must live in "${IMG_SRC}", ` +
`and \`cacheImageMetadata()\` must run before the build.`,
);
}

// generate images; this is async but we don’t wait
eleventyImg(src, opts);

const metadata = eleventyImg.statsSync(src, opts);

if (getUrl) {
const data = metadata.jpeg[metadata.jpeg.length - 1];
/* istanbul ignore if */
Expand Down
75 changes: 68 additions & 7 deletions test/js/images.test.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
/* eslint-disable no-sync */

import { jest } from '@jest/globals';

jest.unstable_mockModule('@11ty/eleventy-img', () => ({
default: jest.fn(),
}));

const eleventyImg = await import('@11ty/eleventy-img');
const { image } = await import('#filters/images');
const { cacheImageMetadata, image, imageMetadata } =
await import('#filters/images');

eleventyImg.default.generateHTML = jest.fn();
eleventyImg.default.statsSync = jest.fn().mockReturnValue({
jpeg: [{ url: '/assets/images/img-960w.webp' }],
});

const metadata = { jpeg: [{ url: '/assets/images/img-960w.webp' }] };

describe('image filters', () => {
describe('image', () => {
Expand All @@ -24,8 +22,14 @@ describe('image filters', () => {
global.console.warn = jest.fn();
});

beforeEach(() => {
imageMetadata.set('src/images/foo/img.jpg', metadata);
imageMetadata.set('foo/img.jpg', metadata);
});

afterAll(() => {
global.console.warn = warn;
imageMetadata.clear();
});

test('calls eleventy-img plugin with options', () => {
Expand All @@ -41,8 +45,10 @@ describe('image filters', () => {
expect(options.filenameFormat('hash', src, 480, 'webp')).toBe(
'img-480w.webp',
);
expect(eleventyImg.default.statsSync).toHaveBeenCalledTimes(1);
expect(eleventyImg.default.generateHTML).toHaveBeenCalledTimes(1);
expect(eleventyImg.default.generateHTML.mock.calls[0][0]).toEqual(
metadata,
);
expect(eleventyImg.default.generateHTML.mock.calls[0][1]).toEqual({
alt: 'alt text',
sizes: '(min-width: 45em) 50vw, 100vw',
Expand Down Expand Up @@ -70,10 +76,65 @@ describe('image filters', () => {
expect(url).toBe('/assets/images/img-960w.webp');
});

test('normalizes the metadata lookup', () => {
image('./src/images//foo/img.jpg', null, null, null, true);

expect(eleventyImg.default.generateHTML).not.toHaveBeenCalled();
});

test('warns if unexpected src prefix', () => {
image('foo/img.jpg');

expect(global.console.warn).toHaveBeenCalledTimes(1);
});

test('throws if metadata was not pre-computed', () => {
imageMetadata.clear();

expect(() => image(src)).toThrow('Missing image metadata');
});
});

describe('cacheImageMetadata', () => {
let warn;

beforeAll(() => {
warn = global.console.warn;
global.console.warn = jest.fn();
});

afterEach(() => {
imageMetadata.clear();
});

afterAll(() => {
global.console.warn = warn;
});

test('stores metadata for every source image', async () => {
eleventyImg.default.mockResolvedValue(metadata);

await cacheImageMetadata();

expect(imageMetadata.size).toBeGreaterThan(0);
// keys are normalized, relative to the project root
for (const key of imageMetadata.keys()) {
expect(key.startsWith('src/images/')).toBe(true);
}
expect(imageMetadata.values().next().value).toEqual(metadata);
expect(eleventyImg.default.mock.calls[0][1].statsOnly).toBe(true);
});

test('warns (and skips) images it cannot read', async () => {
eleventyImg.default.mockRejectedValue(new Error('nope'));

await cacheImageMetadata();

expect(imageMetadata.size).toBe(0);
expect(global.console.warn).toHaveBeenCalled();
expect(global.console.warn.mock.calls[0][0]).toContain(
'Unable to read image metadata',
);
});
});
});
Loading