Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,13 @@ updates:
update-types:
- 'minor'
- 'patch'
ignore:
- dependency-name: '@babel/core'
update-types:
- 'version-update:semver-major'
- dependency-name: '@babel/preset-env'
update-types:
- 'version-update:semver-major'
- dependency-name: 'typescript'
update-types:
- 'version-update:semver-major'
9 changes: 9 additions & 0 deletions eleventy.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,15 @@ 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 and fail the build if any image errored
eleventyConfig.on('eleventy.after', images.finishImages);

if (!process.env.NETLIFY) {
eleventyConfig.on('eleventy.before', () => {
delete process.env.IMAGE_CACHE_CHANGED;
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
24 changes: 12 additions & 12 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",
"core-js": "^3.50.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.26",
"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"
}
142 changes: 120 additions & 22 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 @@ -46,12 +51,102 @@ const rebuildCache = Boolean(
let cacheChanged = false;

export const CACHE_FILE = join(__dirname, 'image_cache.json');
export let imageCache = { html: {}, src: {} };
export 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));
if (failedImages.length) {
const failures = failedImages.splice(0).join('\n ');
throw new Error(`Unable to generate images:\n ${failures}`);
}
};

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 +175,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 +220,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