perf: Hugo image processing — auto-convert all images to WebP with srcset

Add layouts/partials/img.html helper that resizes and converts any Hugo
page-resource image to WebP, emitting a responsive srcset at multiple
widths. Wire it up to every image rendering site:

- post-card.html: thumbnail (600w/1000w) + avatar (64px WebP)
- post-card-large.html: featured card background (800w/1200w)
- posts/single.html: banner (1200w/1800w, eager) + gallery (800w/1200w)
- author-card.html: avatar (96px WebP, 2× retina)

Result: banner.png 7.9 MB → 496 KB WebP at 1800w (−94 %).
Hugo caches processed images in resources/_gen/ across builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 19:12:06 +02:00
parent 9af45979f1
commit 36767c3d4d
5 changed files with 58 additions and 26 deletions
+10 -12
View File
@@ -3,18 +3,16 @@
*/ -}}
<div class="flex items-center gap-4 py-6 border-t border-b border-zinc my-8">
{{- $avatarRes := .Resources.GetMatch "avatar.*" -}}
{{- $avatarSrc := "" -}}
{{- with $avatarRes }}{{ $avatarSrc = .RelPermalink }}{{ else }}{{ with $.Params.avatar }}{{ $avatarSrc = . }}{{ end }}{{ end -}}
{{- with $avatarSrc -}}
<a href="{{ $.RelPermalink }}" class="flex-shrink-0">
<img
src="{{ . }}"
alt="{{ $.Params.name | default $.Title }}"
class="w-12 h-12 object-cover border border-zinc hover:border-heat transition-colors"
loading="lazy"
>
</a>
{{- end -}}
{{- if $avatarRes -}}
{{- $av := $avatarRes.Resize "96x webp" -}}
<a href="{{ $.RelPermalink }}" class="flex-shrink-0">
<img src="{{ $av.RelPermalink }}" alt="{{ $.Params.name | default $.Title }}" class="w-12 h-12 object-cover border border-zinc hover:border-heat transition-colors" width="{{ $av.Width }}" height="{{ $av.Height }}" loading="lazy" decoding="async">
</a>
{{- else -}}{{- with .Params.avatar -}}
<a href="{{ $.RelPermalink }}" class="flex-shrink-0">
<img src="{{ . }}" alt="{{ $.Params.name | default $.Title }}" class="w-12 h-12 object-cover border border-zinc hover:border-heat transition-colors" loading="lazy" decoding="async">
</a>
{{- end -}}{{- end -}}
<div class="min-w-0">
<a href="{{ .RelPermalink }}" class="label text-heat hover:text-frost transition-colors block mb-1">
{{ .Params.name | default .Title }}
+38
View File
@@ -0,0 +1,38 @@
{{- /*
Renders a Hugo image resource as an optimised WebP <img> with responsive srcset.
Parameters:
res — Hugo image resource (required)
widths — slice of pixel widths to generate, e.g. (slice 600 1200)
default: (slice 800 1600)
sizes — value for the <img sizes> attribute
default: "100vw"
class — CSS classes applied to <img>
alt — alt text (default "")
loading — "lazy" or "eager" (default "lazy")
*/ -}}
{{- $res := .res -}}
{{- $widths := .widths | default (slice 800 1600) -}}
{{- $sizes := .sizes | default "100vw" -}}
{{- $class := .class | default "" -}}
{{- $alt := .alt | default "" -}}
{{- $loading := .loading | default "lazy" -}}
{{- $entries := slice -}}
{{- $primary := $res -}}
{{- range $i, $w := $widths -}}
{{- $img := $res.Resize (printf "%dx webp" $w) -}}
{{- $entries = $entries | append (printf "%s %dw" $img.RelPermalink $w) -}}
{{- if eq $i 0 -}}{{- $primary = $img -}}{{- end -}}
{{- end -}}
<img
class="{{ $class }}"
src="{{ $primary.RelPermalink }}"
srcset="{{ delimit $entries ", " }}"
sizes="{{ $sizes }}"
alt="{{ $alt }}"
width="{{ $primary.Width }}"
height="{{ $primary.Height }}"
loading="{{ $loading }}"
decoding="async"
>
+1 -1
View File
@@ -10,7 +10,7 @@
{{- if not $thumb -}}{{- $thumb = $post.Resources.GetMatch "01.png" -}}{{- end -}}
<div class="card-media w-full h-full absolute inset-0">
{{- if $thumb -}}
<img class="w-full h-full object-cover" src="{{ $thumb.RelPermalink }}" alt="{{ $post.Title }}" loading="lazy" decoding="async">
{{- partial "img.html" (dict "res" $thumb "widths" (slice 800 1200) "sizes" "(max-width: 768px) 100vw, 50vw" "class" "w-full h-full object-cover" "alt" $post.Title) -}}
{{- else -}}{{- with $post.Params.banner -}}
{{- partial "media.html" (dict "media" . "class" "w-full h-full object-cover") -}}
{{- else -}}
+7 -11
View File
@@ -10,7 +10,7 @@
{{- if not $thumb -}}{{- $thumb = $post.Resources.GetMatch "01.png" -}}{{- end -}}
<div class="card-media aspect-editorial">
{{- if $thumb -}}
<img class="w-full h-full object-cover" src="{{ $thumb.RelPermalink }}" alt="{{ $post.Title }}" loading="lazy" decoding="async">
{{- partial "img.html" (dict "res" $thumb "widths" (slice 600 1000) "sizes" "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" "class" "w-full h-full object-cover" "alt" $post.Title) -}}
{{- else -}}{{- with $post.Params.banner -}}
{{- partial "media.html" (dict "media" . "class" "w-full h-full object-cover") -}}
{{- else -}}
@@ -40,16 +40,12 @@
{{- $authorPage := site.GetPage (printf "authors/%s" .) -}}
{{- with $authorPage -}}
{{- $avRes := .Resources.GetMatch "avatar.*" -}}
{{- $avSrc := "" -}}
{{- with $avRes }}{{ $avSrc = .RelPermalink }}{{ else }}{{ with $.Params.avatar }}{{ $avSrc = . }}{{ end }}{{ end -}}
{{- with $avSrc -}}
<img
src="{{ . }}"
alt="{{ $authorPage.Params.name | default $authorPage.Title }}"
class="w-5 h-5 object-cover border border-smoke flex-shrink-0"
loading="lazy"
>
{{- end -}}
{{- if $avRes -}}
{{- $av := $avRes.Resize "64x webp" -}}
<img src="{{ $av.RelPermalink }}" alt="{{ $authorPage.Params.name | default $authorPage.Title }}" class="w-5 h-5 object-cover border border-smoke flex-shrink-0" width="{{ $av.Width }}" height="{{ $av.Height }}" loading="lazy" decoding="async">
{{- else -}}{{- with .Params.avatar -}}
<img src="{{ . }}" alt="{{ $authorPage.Params.name | default $authorPage.Title }}" class="w-5 h-5 object-cover border border-smoke flex-shrink-0" loading="lazy" decoding="async">
{{- end -}}{{- end -}}
<span class="label text-fog">{{ .Params.name | default .Title }}</span>
<span class="text-smoke" aria-hidden="true">/</span>
{{- end -}}
+2 -2
View File
@@ -73,7 +73,7 @@
{{- if not $bannerImg -}}{{- $bannerImg = .Resources.GetMatch "01.png" -}}{{- end -}}
{{- if $bannerImg -}}
<div class="w-full overflow-hidden mb-16 md:mb-24" style="max-height: 75vh">
<img class="w-full object-cover" src="{{ $bannerImg.RelPermalink }}" alt="{{ .Title }}" loading="eager" decoding="async">
{{- partial "img.html" (dict "res" $bannerImg "widths" (slice 1200 1800) "sizes" "100vw" "class" "w-full object-cover" "alt" .Title "loading" "eager") -}}
</div>
{{- else -}}{{- with .Params.banner -}}
<div class="w-full overflow-hidden mb-16 md:mb-24" style="max-height: 75vh">
@@ -116,7 +116,7 @@
{{- if $video -}}
<video class="w-full" src="{{ $video.RelPermalink }}" poster="{{ .RelPermalink }}" autoplay loop muted playsinline></video>
{{- else -}}
<img class="w-full" src="{{ .RelPermalink }}" alt="" loading="lazy" decoding="async">
{{- partial "img.html" (dict "res" . "widths" (slice 800 1200) "sizes" "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" "class" "w-full") -}}
{{- end -}}
</div>
</figure>