Skip to content

fix(seo): prefer page bundle images and emit og:image dimensions - #2337

Merged
markdumay merged 18 commits into
mainfrom
fix/2330-og-image-metadata
Nov 6, 2026
Merged

markdumay merged 18 commits into
mainfrom
fix/2330-og-image-metadata

Conversation

@markdumay

Copy link
Copy Markdown
Collaborator

Closes #2330

Summary

  • Precedence. The preview image now resolves in this order: front-matter thumbnail, then page bundle *feature*, then {*cover*,*thumbnail*}, then schema.image.url, then opengraph.images[0]. Before, a site-level schema.image.url hid every bundle image.
  • Same processing for bundle images. Bundle images go through the same 1280x640 fill as front-matter thumbnails, with EXIF orientation applied.
  • Dimension metadata. og:image:width, og:image:height and og:image:type are emitted when they are known: from a processed image, or from schema.image.width/height when both are positive integers. When unknown, none of the three is emitted.
  • Remote thumbnails keep their current behavior and never get dimension tags.
  • Fallback for undecodable images. A 0-byte, truncated or Git LFS pointer image, or one whose name cannot form a URL, falls back without failing the build. A filename with a space, a non-ASCII letter, #, ? or % produces a URL that resolves.

Known new warning

Reading EXIF orientation calls .Meta. When Hugo cannot decode an image's metadata, it now logs Unable to decode metadata from image. This happens for a file whose bytes don't match its extension, or a JPEG whose EXIF block Hugo can't parse.

  • In a normal build the image is still processed.
  • Under --panicOnWarning the build still succeeds, and the image is left unprocessed.

The commit bodies of 55cdf4f, 753c0fb and a379271 record the details.

Tests

tests/templates/layouts/_partials/tests/og-image.html, the og-schema, og-schema-partial, og-schema-height, og-schema-units, og-fallback and image-warnings environments, and scripts/test-strict-images.cjs cover:

  • each precedence step and the emitted dimensions
  • broken and unusually named images
  • warm-cache rebuilds
  • remote thumbnails
  • an exact warning set compared against main
  • that every emitted og:image URL maps to a published file with matching PNG dimensions

main's templates fail these tests. pnpm test passes from a clean cache. On the exampleSite, og:image, twitter:image and JSON-LD are unchanged; pages only gain the dimension tags.

The combined branch with #2329 passes the full suite.

🤖 Generated with Claude Code

markdumay and others added 12 commits November 6, 2026 05:10
A page bundle image matching *feature*, then *cover* or *thumbnail*, now
wins over schema.image.url as the preview image, and is filled to a
1280x640 png like a front-matter thumbnail. An SVG bundle image without a
raster sibling keeps its own image.

head/opengraph.html emits og:image:width, og:image:height and
og:image:type only when they are known: for a processed image, for a
bundle image's own media type, and for schema.image, whose type comes
from its url's extension and whose dimensions are emitted only when it
declares both. A front-matter thumbnail that image-dimension.html
handles gets none, because that helper returns the dimensions it was
given whether or not it processed the image.

Closes #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A build whose schema.image declares a height but no width, and one whose
schema.image has dimensions but no url, show that neither emits a
dimension. Each og:image case also reads back the JSON-LD image url, which
must stay null on a page without any image.

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mages

A raster front-matter thumbnail that resolves to a resource, in the page
bundle or under assets, now goes to the image adapter itself, after
utilities/GetImage.html turns it upright by its EXIF orientation. It is
filled to 1280x640 like the raster sibling of an SVG and gets
og:image:width, og:image:height and og:image:type. A file under static
and a remote URL keep the image-dimension.html path without dimensions.

URL change for existing sites: a raster page resource given as a
relative front-matter thumbnail, such as "feature.png" in a page
bundle, rendered the site's base URL as its og:image. It now renders
the processed image, for example .../feature-1280x640.png. An assets
thumbnail keeps its URL: every og:image in the exampleSite build is the
same before and after, img/flowers.jpg included
(.../img/flowers-1280x640.png).

The first of opengraph.images now gets a type from its extension, and
no dimensions.

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page bundle image now goes through utilities/GetImage.html before it
is filled to 1280x640, as a front-matter thumbnail already did, so a
JPEG with EXIF orientation 6 comes out upright instead of on its side.
Only a png, jpeg, gif, tiff, bmp or webp bundle image is passed to it,
the types GetImage.html supports. Any other keeps its own file, as it
did before 81cb16b, with its own media type and no dimensions. That
stops a HEIC bundle image from failing the build in Fill. A bundle AVIF
image, which 81cb16b filled, now also keeps its own file, as a
front-matter one does.

assets/helpers/image-media-type.html and the remote thumbnail check in
head/seo.html no longer parse the url, which failed the build on a
literal "%". The check now tests for the "http" prefix, in any letter
case, that makes utilities/GetResource.html fetch a url. A second
strict-images build serves two remote thumbnails and fails when one is
resolved as a resource.

The feature.webp fixture of thumbnails/featured was a PNG with a .webp
extension, whose metadata Hugo cannot read; GetImage.html asks for it.
It is now a WebP file with the same pixels.

Correction to 81cb16b and ef9fb88: their bodies say that a bundle
image, and a raster front-matter thumbnail that resolves to a resource,
are filled to 1280x640. With this change, both hold only for png, jpeg,
gif, tiff, bmp and webp images. A front-matter resource of another
type, such as AVIF or HEIC, keeps the image-dimension.html path.

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scripts/test-strict-images.cjs decodes the processed EXIF fixtures to
read four pixels. Hugo writes each of them as a non-interlaced 8-bit RGB
PNG, so the decoder now reads that kind only, and the script reports any
other as a failure instead of stopping with an exception. The gray and
palette branches, which no fixture reached, are gone.

Correction to 05677e0: its body says that a bundle AVIF image now keeps
its own file "as a front-matter one does". A front-matter AVIF resource
takes the image-dimension.html path instead; for one in a page bundle,
that path renders the site's base URL as its og:image.

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bundle image that Hugo cannot decode, such as an empty file, a
truncated JPEG or a Git LFS pointer, failed the build in Fill, and one
named "feature 50%.png" or "feature%zz.png" failed it when its URL was
built. origin/main builds all of these whenever schema.image.url is
set, as the theme's defaults set it, because it does not read the
bundle image then. head/seo.html now processes the image inside try. A
bundle image it cannot process keeps its own file and media type,
without dimensions. When Hugo cannot build its permalink either, the
preview image comes from schema.image.url, then opengraph.images. A
front-matter thumbnail that names a page resource or asset it cannot
process keeps the image-dimension.html path, as on origin/main.

Fill leaves an empty file in the image cache for an image it cannot
decode, and from the third build on it returns that file without an
error. So the fill now runs in head/seo.html, which turns away a result
with a width of 0, instead of in the image adapter, which returns only
a url. The png keeps the name the adapter gave it.

A front-matter thumbnail that names an AVIF or HEIC page resource no
longer goes to utilities/GetImage.html, which logged "Unsupported
image" for it. Both sources now share one list of the types
GetImage.html supports.

Known new warning: a page bundle image whose bytes do not match its
extension, such as PNG bytes in a .webp file, now logs Hugo's "Unable
to decode metadata from image" warning, both when it is matched by
name and when the front matter names it as the thumbnail. origin/main
logged no such warning for either. With Hugo v0.168.0 and v0.169.0,
--panicOnWarning does not stop the build on this warning; a build that
fails on any WARN line in its log does stop. Giving the file the
extension of its contents clears the warning.

Tests: bundle fixtures for each of these inputs run in every
test:templates build, and the strict-images build fails on any warning
they log. A new image-warnings build covers front-matter AVIF, HEIC and
undecodable page resources and the two mislabeled images, and
scripts/test-strict-images.cjs compares its warnings with the ones it
expects. The script now also fails when the remote thumbnail build
renders without its two remote cases.

Correction to 81cb16b, ef9fb88 and 05677e0: their bodies say that a
bundle image, and a raster front-matter thumbnail that resolves to a
resource, are filled to 1280x640, by the image adapter in ef9fb88.
With this change, an image that Fill cannot decode is not filled, a
bundle image without a permalink does not win over schema.image.url,
and head/seo.html fills the image itself.

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…afely

Three defects in the preview image of 55cdf4f, found by a blind review.

A bundle image named with a space, a non-ASCII letter, a "#", a "?" or a
"%" got an og:image that named no published file, tagged 1280x640
image/png all the same. head/seo.html built the path of the processed png
from the permalink of the image, in which Hugo escapes a space or a
non-ASCII letter, so "feature 1.png" was published as
"feature%201-1280x640.png", while its url, once decoded, names
"feature 1-1280x640.png". Hugo cuts a permalink short at a "#" or "?";
path.Ext then returned "", replace put "-1280x640.png" between every
character, and the build published a tree of directories under
public/-1280x640.png/. The path now comes from the decoded permalink. For
an image named with a "#", "?" or "%", the file name comes from the name
of the image instead, and each of the three becomes a hyphen:
"feature#1.png" gives "feature-1-1280x640.png". A front-matter thumbnail
takes the same step.

A bundle image whose name starts with "http", such as "http-feature.png",
logged "Missing image ... Cannot find image resource", which origin/main
does not: utilities/GetResource.html takes any url that starts with
"http" for a remote one. head/seo.html now looks the image up as
"./http-feature.png", which Resources.Get finds as the resource of that
name on Hugo v0.153.1, v0.168.0 and v0.169.0.

A schema.image width or height that is no integer, such as
HUGO_PARAMS_SCHEMA_IMAGE_WIDTH=1280px, failed the build in int, where
origin/main exits 0, and a negative one was emitted as it was. The new
assets/helpers/positive-integer.html accepts an int, a float without a
fraction, or a string of decimal digits without a leading zero, and
returns 0 for anything else. The dimensions are emitted only when both
are positive.

Tests: bundle fixtures named with a space, a non-ASCII letter, a "#", a
valid percent escape, "http-feature.png" and "https_cover.png", and a
front-matter thumbnail named with a space, run in every test:templates
build. The og-image content adapter adds a bundle image named with a "?"
to the strict-images build except on Windows, since a Go module zip
refuses that file name. scripts/test-strict-images.cjs now decodes each
og:image on the host of the test site, checks that it names a file of the
size its tags give, and fails on any directory named like an image. A new
og-schema-units build sets a width of "1280px", and the helper is tested
directly.

Correction to 55cdf4f: its body says that with Hugo v0.168.0 and
v0.169.0, --panicOnWarning does not stop the build on Hugo's "Unable to
decode metadata from image" warning. For a bundle image that holds only
because, under that flag, Image.Meta returns the warning as an error as
well as logging it. utilities/GetImage.html reads .Meta, so the step
fails inside try, and the bundle image keeps its own file and media type,
unprocessed. Checked on v0.168.0 and v0.169.0. A front-matter thumbnail
with such bytes falls back to the image-dimension.html path, and where
that path logs a missing image, --panicOnWarning stops the build on that
warning, as it does on origin/main.

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A front-matter thumbnail that names the asset "img/asset#1.png" must be
processed to img/asset-1-1280x640.png, next to the asset. The name of a
global resource carries its directory, "/img/asset#1.png", so a step
that took the whole name rather than its base would publish the png
under img/img/. No earlier fixture had a "#", "?" or "%" in a name with
a directory, and that mutant of cb01268 passed every test.

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two paragraphs added in cb01268 were wrapped unevenly. The text is
unchanged.

Correction to cb01268: its body says that a bundle image named with a
space, a non-ASCII letter, a "#", a "?" or a "%" got an og:image that
named no published file. That holds for a space, a non-ASCII letter and
a valid percent escape such as "feature%25.png". For a "#" or a "?", the
og:image named a file in the tree of directories that the build
published under public/-1280x640.png/, so a server answered it, but with
a path built from the image's name one character at a time. A name with
an invalid percent escape, such as "feature 50%.png", still takes the
fallback. Its body also says that assets/helpers/positive-integer.html
accepts an int; it accepts a positive int, and returns 0 for zero and
for a negative one.

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mage

The header of the og:image assertions lists the overlays that set
schema.image or opengraph.images, and says that every other build sets
neither. cb01268 added og-schema-units, which sets both, without adding
it to that list. The paragraph is rewrapped as well.

Correction to e0fbae4: its body says that for a "#" or a "?", the
og:image path was built from the name of the image one character at a
time. It was built from the permalink of the image, which Hugo had cut
short at that character, so it held the directory as well:
"/og-image/hash-bundle/feature" for "feature#1.png".

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comments only. scripts/test-strict-images.cjs counted three pages for
which origin/main logs a missing image although this build processes
their thumbnail; it logs one for four: thumbnails/raster-bundle,
og-image/exif-thumbnail, og-image/space-thumbnail and
og-image/hash-asset. The comments in head/seo.html and in the og:image
assertions said that the processed png is published next to the image.
Its path is the decoded path of the permalink of the image, so a
baseURL with a path, such as "https://example.org/sub/", puts the png
under public/sub/, and "feature%2F.png" gives
"feature/feature-2F-1280x640.png", one directory down. The comments that
described Hugo's "Unable to decode metadata from image" warning as one
for bytes that do not match the extension now describe the wider case
below.

Correction to 55cdf4f, "Known new warning": the accepted warning is
not only for an image whose bytes do not match its extension. It covers
any page bundle image, and any front-matter thumbnail that names a page
resource, whose metadata Hugo cannot decode. utilities/GetImage.html
reads .Meta for the EXIF orientation of the image, and Hugo logs the
warning when it cannot decode that metadata. A JPEG with a corrupt EXIF
block logs it too, such as one whose Orientation tag has an invalid
TIFF type or whose first IFD claims more entries than the block holds.
Renaming the file does not clear the warning for a corrupt EXIF block:
such a JPEG logs it as .jpg and as .jpeg. In a normal build the image
is still processed to its 1280x640 png. Under --panicOnWarning, .Meta
returns the warning as an error: for a bundle image the build still
exits 0 on it, and og:image is the image's own file, unprocessed, while
a front-matter thumbnail takes the image-dimension.html path. Checked on Hugo v0.168.0
and v0.169.0. origin/main logs no such warning for these images.

Correction to 55cdf4f: its body says that a front-matter thumbnail
that names an AVIF or HEIC page resource no longer goes to
utilities/GetImage.html. It still reaches GetImage.html, through
image-dimension.html, image-set.html and the image adapter, and
GetImage.html logs "Missing image" for it, as it does on origin/main.
Only the "Unsupported image" warning, which 0bbe622 logged in addition,
is gone.

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comments only. scripts/test-strict-images.cjs and the og:image
assertions said that a JPEG with a corrupt EXIF block logs Hugo's
"Unable to decode metadata from image" warning. Not every corrupt EXIF
block does: a JPEG whose first IFD offset points past the end of the
block, or whose EXIF block has no TIFF header, is processed without
that warning on Hugo v0.168.0 and v0.169.0. Both comments now say that
a JPEG whose EXIF block Hugo cannot decode, such as one whose
Orientation tag has an invalid TIFF type, logs the same warning.

Correction to 753c0fb: its body says "A JPEG with a corrupt EXIF block
logs it too", and that renaming the file does not clear the warning
"for a corrupt EXIF block". Both hold only for a JPEG whose EXIF block
Hugo cannot decode, such as one whose Orientation tag has an invalid
TIFF type or whose first IFD claims more entries than the block holds;
such a JPEG logs the warning as .jpg and as .jpeg. A JPEG with another
kind of corrupt EXIF block may be processed without any warning.

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@netlify

netlify Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for gethinode-demo ready!

Name Link
🔨 Latest commit 773c6bc
🔍 Latest deploy log https://app.netlify.com/projects/gethinode-demo/deploys/6aede4242c6bdb2e0ddf4b5f
😎 Deploy Preview https://deploy-preview-2337--gethinode-demo.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

On Windows CI (windows-latest, Node 22 and 24, Hugo v0.169.0) the
image-warnings check of scripts/test-strict-images.cjs failed.
utilities/LogMsg.html ends a "Missing image" warning with the path of
the page file, which Hugo gives with the separator of the operating
system, so the build logged og-image\avif-thumbnail\index.md where
expectedWarnings has og-image/avif-thumbnail/index.md. normalizeWarning
now turns the backslashes of that path into slashes and leaves the rest
of the line as it is, so a different message, page or number of
warnings still fails the comparison. Fed the image-warnings output in
the log of CI job 127884185018, the script before this change reports
the five "Missing image" lines as different, and with it all eight
lines match. A fixed Windows line is also checked on every platform, so
a run on Linux or macOS fails if the normalization goes.

In the same job the first build of the script passed on Windows,
including the check that decodes each og:image and finds its file, the
check for directories named like an image and the size checks, which
build their paths with path.join. They need no change. The remote
thumbnail build failed there for a reason in mod-utils, which this
commit leaves alone.

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows CI the remote thumbnail build of scripts/test-strict-images.cjs
stopped in utilities/GetResource.html, which passed a remote url it could
not fetch to resources.GetMatch: "CreateFile
...\assets\https:\127.0.0.1\img: The filename, directory name, or volume
label syntax is incorrect" (job 127884185018). From mod-utils v6.18.2,
GetResource.html returns nil for a url that starts with http:// or
https://, in any letter case, when it cannot fetch it, and passes any
other url to GetMatch as before. GetResource.html is the only template
that v6.18.2 changes.

The bump is limited to mod-utils, in go.mod and go.sum and, as with the
last mod-utils bump, in exampleSite/go.mod and exampleSite/go.sum. On
macOS the three builds of scripts/test-strict-images.cjs log the same
warnings and render the same assertion tables with v6.18.1 and v6.18.2,
and the remote thumbnail build still passes both of its cases.

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@markdumay
markdumay disabled auto-merge November 6, 2026 13:15
markdumay and others added 2 commits November 6, 2026 14:32
…ension

assets/helpers/image-dimension.html skips its fileExists check under
/static for a url, but tested for one with a case-sensitive
hasPrefix "http://" or "https://". An src such as
"HTTP://127.0.0.1:52260/img/remote.png" therefore reached
fileExists "/static/HTTP:/127.0.0.1:52260/img/remote.png". That was
already so on main, where b72c8bf added the check. On macOS and Linux
fileExists returns false for that path; on Windows it fails the build:
the remote thumbnail build of scripts/test-strict-images.cjs stopped
with "error calling fileExists: CreateFile
...\static\HTTP:\127.0.0.1:52260\img\remote.png: The filename,
directory name, or volume label syntax is incorrect" (job 127913073443).
The check now compares on lower $src, and nothing else changes.

A trace in image-dimension.html during the remote thumbnail build shows
fileExists called for "HTTP://127.0.0.1:<port>/img/remote.png" before
this change and not after; for the lower-case url it is called in
neither. The output on macOS is the same either way, so only a Windows
build can show the failure itself.

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
assets/helpers/image-dimension.html takes an src that starts with
http:// or https://, in any letter case, for a url. The remote thumbnail
build had an upper-case case only for http://, so a check that lowered
the src for one prefix only would still pass it. A third page, whose
thumbnail starts with HTTPS://, covers the other prefix, and
scripts/test-strict-images.cjs now wants all three remote cases to
pass. On macOS the three pass with or without that fix; only a Windows
build, where fileExists fails on a path with "HTTPS:" in it, can tell.

Refs #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@markdumay
markdumay enabled auto-merge November 6, 2026 13:56
@markdumay
markdumay merged commit 8bb5671 into main Nov 6, 2026
17 checks passed
@markdumay

Copy link
Copy Markdown
Collaborator Author

🎉 This PR is included in version 3.52.1 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Emit og:image dimensions and prefer page bundle images over the site default

1 participant