Skip to content

Commit 7746893

Browse files
oleeskildclaude
andauthored
fix: probe images with a real decode before optimizing (#394)
The header-sniffing guard from #393 was not enough: the same garden failed again on a *truncated AVIF* renamed to .jpg. Its ftypavif header passes the magic-byte check, but sharp fails mid-decode ("bad seek"), and eleventy-img leaves internal per-format promise rejections permanently unhandled on decode failure — Eleventy's unhandledRejection handler then fails the build, regardless of the .catch we attach to the returned promise (verified empirically with the reporting garden's actual file). A valid header can never prove a decodable bitstream, so the picture transform now awaits isDecodableImage: header sniff as a cheap first filter, then an actual sharp decode (stats()), memoized per file mtime+size so each unique image is probed once per build. The probe resolves sharp through eleventy-img's own module resolution, so the answer always agrees with what the pipeline can do. Undecodable files keep their original <img> tag and log a warning naming the file. Fixture: a tiny AVIF truncated to 60%, which reproduces the exact "bad seek" failure in tests. Claude-Session: https://claude.ai/code/session_01TFUucReuWzHi8VCKMovneB Co-authored-by: Ole Eskild Steensen <6201338+oleeskild@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8a36acb commit 7746893

4 files changed

Lines changed: 106 additions & 8 deletions

File tree

.eleventy.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const {
3434
const { basesPlugin } = require("./src/helpers/basesPlugin");
3535

3636
const Image = require("@11ty/eleventy-img");
37-
const { isTransformableImage } = require("./src/helpers/imageFormat.js");
37+
const { isDecodableImage } = require("./src/helpers/imageFormat.js");
3838
function transformImage(src, cls, alt, sizes, widths = ["500", "700", "auto"]) {
3939
let options = {
4040
widths: widths,
@@ -552,7 +552,7 @@ module.exports = function(eleventyConfig) {
552552
}
553553

554554

555-
eleventyConfig.addTransform("picture", function(str) {
555+
eleventyConfig.addTransform("picture", async function(str) {
556556
if (!isMarkdownPage(this.page.inputPath)) {
557557
return str;
558558
}
@@ -563,10 +563,13 @@ module.exports = function(eleventyConfig) {
563563
for (const imageTag of parsed.querySelectorAll(".cm-s-obsidian img")) {
564564
const src = imageTag.getAttribute("src");
565565
if (src && src.startsWith("/") && !src.endsWith(".svg")) {
566-
// Files whose content sharp can't decode (e.g. HEIC renamed to
567-
// .jpg) keep their original <img> tag instead of a <picture>
568-
// pointing at optimized files that will never exist.
569-
if (!isTransformableImage("./src/site" + decodeURI(src))) {
566+
// Files sharp can't decode (e.g. HEIC or a truncated AVIF renamed
567+
// to .jpg) keep their original <img> tag instead of a <picture>
568+
// pointing at optimized files that will never exist. This must be
569+
// a real decode probe, not just a header check: feeding an
570+
// undecodable file to eleventy-img fails the whole build via
571+
// unhandled promise rejections in its internals.
572+
if (!(await isDecodableImage("./src/site" + decodeURI(src)))) {
570573
continue;
571574
}
572575
const cls = imageTag.classList.value;
179 Bytes
Loading

src/helpers/imageFormat.js

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
const fs = require("fs");
2+
const { createRequire } = require("module");
3+
4+
// Use the exact sharp instance eleventy-img uses, so "can we decode this?"
5+
// always agrees with what the optimization pipeline can actually do.
6+
let sharp;
7+
try {
8+
sharp = createRequire(require.resolve("@11ty/eleventy-img"))("sharp");
9+
} catch {
10+
sharp = require("sharp");
11+
}
212

313
/**
414
* Check whether a file's actual content is an image format the sharp-based
@@ -55,4 +65,54 @@ function isTransformableImage(filePath) {
5565
return false;
5666
}
5767

58-
module.exports = { isTransformableImage };
68+
// Probe results memoized per file version — decoding is the expensive part
69+
// and the same image is typically referenced from many pages.
70+
const decodableCache = new Map();
71+
72+
/**
73+
* Check whether sharp can actually decode a file, by decoding it.
74+
*
75+
* Header sniffing (isTransformableImage) is a fast first filter, but a
76+
* valid header proves nothing about the bitstream: a truncated AVIF still
77+
* says "ftypavif" yet fails mid-decode, and eleventy-img leaves internal
78+
* promise rejections unhandled on decode failure, which kills the whole
79+
* Eleventy build. Only files that pass a real decode may enter the
80+
* optimization pipeline.
81+
*/
82+
async function isDecodableImage(filePath) {
83+
if (!isTransformableImage(filePath)) {
84+
return false;
85+
}
86+
87+
let cacheKey;
88+
89+
try {
90+
const stat = fs.statSync(filePath);
91+
cacheKey = `${filePath}:${stat.mtimeMs}:${stat.size}`;
92+
} catch {
93+
return false;
94+
}
95+
96+
if (decodableCache.has(cacheKey)) {
97+
return decodableCache.get(cacheKey);
98+
}
99+
100+
const probe = sharp(filePath)
101+
.stats()
102+
.then(
103+
() => true,
104+
(err) => {
105+
console.warn(
106+
`[image] ${filePath} cannot be decoded and will not be optimized: ${err.message.split("\n")[0]}`,
107+
);
108+
109+
return false;
110+
},
111+
);
112+
113+
decodableCache.set(cacheKey, probe);
114+
115+
return probe;
116+
}
117+
118+
module.exports = { isTransformableImage, isDecodableImage };

src/helpers/imageFormat.test.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
22
import fs from "fs";
33
import os from "os";
44
import path from "path";
5-
import { isTransformableImage } from "./imageFormat.js";
5+
import { isTransformableImage, isDecodableImage } from "./imageFormat.js";
66

77
const writeTemp = (name, bytes) => {
88
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "img-format-"));
@@ -57,3 +57,38 @@ describe("isTransformableImage", () => {
5757
expect(isTransformableImage("/nonexistent/img.jpg")).toBe(false);
5858
});
5959
});
60+
61+
describe("isDecodableImage", () => {
62+
it("rejects a truncated AVIF that passes header sniffing", async () => {
63+
// Real-world case: a truncated AVIF renamed to .jpg has a valid
64+
// ftypavif header but cannot be decoded ("bad seek to ...").
65+
await expect(
66+
isDecodableImage(
67+
path.join(__dirname, "__fixtures__", "truncated.avif.jpg"),
68+
),
69+
).resolves.toBe(false);
70+
});
71+
72+
it("accepts a real decodable image", async () => {
73+
await expect(
74+
isDecodableImage("src/site/img/tree-1.svg").then(Boolean),
75+
).resolves.toBe(false); // svg is not in scope for the optimizer
76+
77+
await expect(
78+
isDecodableImage("src/site/img/user/A Assets/travolta.png"),
79+
).resolves.toBe(true);
80+
});
81+
82+
it("rejects HEIC content without invoking a decode", async () => {
83+
const heic = [0, 0, 0, 24, ...Buffer.from("ftypheic"), 0, 0, 0, 0];
84+
await expect(
85+
isDecodableImage(writeTemp("photo.jpg", heic)),
86+
).resolves.toBe(false);
87+
});
88+
89+
it("rejects a missing file", async () => {
90+
await expect(isDecodableImage("/nonexistent/img.jpg")).resolves.toBe(
91+
false,
92+
);
93+
});
94+
});

0 commit comments

Comments
 (0)