Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
19 changes: 10 additions & 9 deletions eleventy.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import rss from '@11ty/eleventy-plugin-rss';
import syntaxHighlight from '@11ty/eleventy-plugin-syntaxhighlight';
import fs from 'fs-extra';
import { load } from 'js-yaml';
import { concat, groupBy, merge } from 'lodash-es';

Expand Down Expand Up @@ -181,18 +180,20 @@ 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);

// image generation is started during render but not awaited, so wait for
// it here. This also writes the local image cache, since that may only
// happen once every image is known to have generated successfully.
eleventyConfig.on('eleventy.after', images.finishImages);

if (!process.env.NETLIFY) {
eleventyConfig.on('eleventy.before', () => {
delete process.env.IMAGE_CACHE_CHANGED;
});

eleventyConfig.on('eleventy.after', () => {
if (process.env.IMAGE_CACHE_CHANGED) {
// If the image cache has been updated, emit the new JSON file
// eslint-disable-next-line no-sync
fs.outputJsonSync(images.CACHE_FILE, images.imageCache, { spaces: 2 });
}
});
}

// settings
Expand Down
2 changes: 0 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export default [
sourceType: 'module',
ecmaVersion: 2022,
globals: {
...globals.es6,
...globals.node,
},
},
Expand Down Expand Up @@ -157,7 +156,6 @@ export default [
files: ['src/js/**/*.js', 'test/js/**/*.js'],
languageOptions: {
globals: {
...globals.es6,
...globals.browser,
},
},
Expand Down
2 changes: 1 addition & 1 deletion 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 Down
156 changes: 133 additions & 23 deletions src/filters/images.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* eslint-disable no-sync, no-process-env */

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

import eleventyImg from '@11ty/eleventy-img';
Expand All @@ -11,6 +13,9 @@ import { merge } from 'lodash-es';
import { fromTaxonomy } from '#filters/taxonomy.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const IMG_VERSION = createRequire(import.meta.url)(
'@11ty/eleventy-img/package.json',
).version;

/* @docs
label: Responsive Images
Expand Down Expand Up @@ -45,13 +50,115 @@ const rebuildCache = Boolean(
);
let cacheChanged = false;

export const CACHE_FILE = join(__dirname, 'image_cache.json');
export let imageCache = { html: {}, src: {} };
const CACHE_FILE = join(__dirname, 'image_cache.json');
let imageCache = { version: IMG_VERSION, html: {}, src: {} };
/* istanbul ignore next */
if (useCache && !rebuildCache && fs.existsSync(CACHE_FILE)) {
imageCache = fs.readJsonSync(CACHE_FILE);
const cached = fs.readJsonSync(CACHE_FILE);
// eleventy-img can change its generated markup between versions, so a
// cache written by a different version must not be reused -- otherwise
// stale markup survives an upgrade locally, and only differs once the
// production build (which never uses this cache) regenerates it.
if (cached.version === IMG_VERSION) {
imageCache = cached;
}
}

// 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();
// Images that exist, but could not be read (e.g. corrupt files).
// Tracked so that `image()` can report the underlying cause.
export const imageErrors = new Map();

// Content refers to the same file in several ways
// (e.g. `./src/images//projects/w3c.jpg`), so paths are normalized
// to a single canonical form before being used as keys or options.
const metadataKey = (src) => normalize(src);
const canonicalSrc = (src) => `./${metadataKey(src)}`;

// Matched case-insensitively: macOS would match an uppercase `.JPG`
// in a case-sensitive glob, but Netlify (Linux) would not.
const IMG_EXTENSIONS = new Set([
'.avif',
'.gif',
'.jpeg',
'.jpg',
'.png',
'.svg',
'.webp',
]);

// Image generation is started during render but never awaited, since
// templates are synchronous. Track the work so that failures can be
// reported (with the source that caused them) once the build is done,
// instead of surfacing as a bare unhandled rejection.
const pendingImages = [];
const failedImages = [];

export const finishImages = async () => {
await Promise.all(pendingImages.splice(0));
const failures = failedImages.splice(0);

// Persist the cache only for a build where every image generated. The
// cached markup is returned before an image is ever re-requested, so a
// cache written after a failure would serve markup for a file that was
// never written -- and the next build would report nothing at all.
// This has to happen here, rather than in a separate `eleventy.after`
// handler: those run in parallel, and would race this function.
/* istanbul ignore next */
if (useCache && process.env.IMAGE_CACHE_CHANGED && !failures.length) {
fs.outputJsonSync(CACHE_FILE, imageCache, { spaces: 2 });
}

if (failures.length) {
throw new Error(`Unable to generate images:\n ${failures.join('\n ')}`);
}
};

export const cacheImageMetadata = async () => {
imageMetadata.clear();
imageErrors.clear();
const files = globSync(`${IMG_SRC}**/*`).filter((file) =>
IMG_EXTENSIONS.has(extname(file).toLowerCase()),
);
await Promise.all(
files.map(async (file) => {
const src = canonicalSrc(file);
try {
const metadata = await eleventyImg(src, {
...imgOptionsFor(src),
statsOnly: true,
});
imageMetadata.set(metadataKey(src), metadata);
} catch (error) {
imageErrors.set(metadataKey(src), 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 @@ -80,22 +187,11 @@ params:
note: |
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,
};
export const image = (rawSrc, alt, attrs, sizes, getUrl) => {
// Normalize once, so that the options used to generate an image always
// match the options its cached metadata was computed with.
const src = canonicalSrc(rawSrc);
const opts = imgOptionsFor(src);
const imgSizes =
sizes && imgConfig.sizes[sizes]
? imgConfig.sizes[sizes]
Expand Down Expand Up @@ -136,10 +232,24 @@ export const image = (src, alt, attrs, sizes, getUrl) => {
}
}

// generate images; this is async but we don’t wait
eleventyImg(src, opts);
const metadata = imageMetadata.get(metadataKey(src));
if (!metadata) {
const error = imageErrors.get(metadataKey(src));
throw new Error(
error
? `Unable to process image "${src}": ${error}`
: `Missing image metadata for "${src}". ` +
`Images must live in "${IMG_SRC}", ` +
`and \`cacheImageMetadata()\` must run before the build.`,
);
}

const metadata = eleventyImg.statsSync(src, opts);
// generate images; this is async but we don’t wait
pendingImages.push(
eleventyImg(src, opts).catch((error) => {
failedImages.push(`${src}: ${error.message}`);
}),
);

if (getUrl) {
const data = metadata.jpeg[metadata.jpeg.length - 1];
Expand Down
Loading