From bab17c129d8b8d9a08efddfe2e938a860e2217f0 Mon Sep 17 00:00:00 2001 From: George Cushen Date: Tue, 19 Aug 2025 01:06:36 +0100 Subject: [PATCH] feat(images): add responsive srcset/sizes across Tailwind layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce reusable partial process_responsive_image.html (fit/fill/resize/responsive), using Hugo .Process, WebP by default, skip GIFs, respect Hugo imaging defaults Preserve original look: pre-fit/pre-fill to intended dimensions, then generate responsive variants; use fitted dims for width/height to avoid CLS single.html: featured image pre-fit by placement (720/1200/2560), responsive srcset, fetchpriority=high; author avatar uses 2x retina (32→16) article-grid.html: main image pre-fit 960×540 (fill/fit), responsive srcset; author avatar uses 2x retina (40→20) card.html: pre-fill 655×655, responsive srcset render-image.html: default display 760px with responsive srcset (matches prior layout) page_author_card.html: high‑res avatar pipeline with srcset (sizes=96px) testimonials.html: avatar uses 2x retina (96→48) CTA/resume banners: responsive srcset + sizes Fix issues: avoid using fallback dims for display Closes #1988 --- .../layouts/_markup/render-image.html | 17 +- .../_partials/blox/cta-image-paragraph.html | 24 ++- .../_partials/blox/resume-biography-3.html | 31 +++- .../_partials/blox/resume-biography.html | 41 +++-- .../layouts/_partials/blox/testimonials.html | 10 +- .../functions/process_responsive_image.html | 152 ++++++++++++++++++ .../layouts/_partials/page_author_card.html | 28 ++-- .../layouts/_partials/views/article-grid.html | 87 ++++++---- .../layouts/_partials/views/card.html | 18 ++- modules/blox-tailwind/layouts/single.html | 90 +++++++++-- 10 files changed, 414 insertions(+), 84 deletions(-) create mode 100644 modules/blox-tailwind/layouts/_partials/functions/process_responsive_image.html diff --git a/modules/blox-tailwind/layouts/_markup/render-image.html b/modules/blox-tailwind/layouts/_markup/render-image.html index 6d080c82..87f3ff51 100644 --- a/modules/blox-tailwind/layouts/_markup/render-image.html +++ b/modules/blox-tailwind/layouts/_markup/render-image.html @@ -25,7 +25,7 @@
-
+
{{- if $img -}} {{- $isSVG := eq $img.MediaType.SubType "svg" -}} {{- $isGIF := eq $img.MediaType.SubType "gif" -}} @@ -38,16 +38,15 @@ {{- with $height }} height="{{.}}"{{end}} {{- with $img_class }} class="{{.}}"{{end}} /> {{- else }} - {{- $img_lg := $img.Fit "1200x1200 webp" -}} - {{- $img_md := $img_lg.Fit "760x760 webp" -}}{{/* Match `.docs-article-container` max-width */}} - {{- $img_sm := $img_md.Fit "400x400 webp" -}} + {{/* Process responsive images - use 760px as default display size */}} + {{- $img_md := $img.Fit "760x760 webp" -}} + {{- $responsive := partial "functions/process_responsive_image.html" (dict "image" $img_md "mode" "responsive") -}} {{- $width := $width | default $img_md.Width -}} {{- $height := $height | default $img_md.Height -}} - {{ $alt }} + {{ if ne $image.MediaType.SubType "gif" }} + {{ $responsive := partial "functions/process_responsive_image.html" (dict + "image" $image + "mode" "fit" + "sizes" (slice 400 600 800 1200) + ) }} + {{$title | plainify}} + {{ else }} + {{$title | plainify}} + {{ end }} {{ else }} {{ errorf "%s uses the `cta-image-paragraph` blox which specifies a non-existent `image` at `assets/%s`. Please add the image to this folder and try again." $page.File.Path $image_path }} {{ end }} -
- {{$title | plainify}}

{{$title}}

{{$text}}

diff --git a/modules/blox-tailwind/layouts/_partials/blox/resume-biography-3.html b/modules/blox-tailwind/layouts/_partials/blox/resume-biography-3.html index 9aa9b7c9..d2e28da6 100644 --- a/modules/blox-tailwind/layouts/_partials/blox/resume-biography-3.html +++ b/modules/blox-tailwind/layouts/_partials/blox/resume-biography-3.html @@ -35,9 +35,31 @@
{{ $img := "" }} - {{with $block.design.banner.filename}} - {{- $img = resources.Get (path.Join "media" .) -}} - {{ if $img }} +{{with $block.design.banner.filename}} +{{- $img = resources.Get (path.Join "media" .) -}} +{{ if $img }} + {{ if ne $img.MediaType.SubType "gif" }} + {{ $responsive := partial "functions/process_responsive_image.html" (dict + "image" $img + "mode" "fill" + "aspect_ratio" "16:9" + "sizes" (slice 768 1024 1366 1920 2560) + ) }} +
+
+
+ +
+
+
+ {{ else }} + {{/* Handle GIF without processing */}} {{- $img = $img.Process "webp" -}}
@@ -50,7 +72,8 @@
{{ end }} - {{ end }} +{{ end }} +{{ end }}
{{ if $avatar }} diff --git a/modules/blox-tailwind/layouts/_partials/blox/resume-biography.html b/modules/blox-tailwind/layouts/_partials/blox/resume-biography.html index c3ea123c..0bd53d1a 100644 --- a/modules/blox-tailwind/layouts/_partials/blox/resume-biography.html +++ b/modules/blox-tailwind/layouts/_partials/blox/resume-biography.html @@ -34,17 +34,40 @@ {{with $block.design.banner.filename}} {{- $img = resources.Get (path.Join "media" .) -}} {{ if $img }} - {{- $img = $img.Process "webp" -}} -
-
-
- + {{ if ne $img.MediaType.SubType "gif" }} + {{ $responsive := partial "functions/process_responsive_image.html" (dict + "image" $img + "mode" "fill" + "aspect_ratio" "16:9" + "sizes" (slice 768 1024 1366 1920 2560) + ) }} +
+
+
+ +
-
+ {{ else }} + {{/* Handle GIF without processing */}} + {{- $img = $img.Process "webp" -}} +
+
+
+ +
+
+
+ {{ end }} {{ end }} {{ end }} diff --git a/modules/blox-tailwind/layouts/_partials/blox/testimonials.html b/modules/blox-tailwind/layouts/_partials/blox/testimonials.html index 04a7e9a4..01f583cd 100644 --- a/modules/blox-tailwind/layouts/_partials/blox/testimonials.html +++ b/modules/blox-tailwind/layouts/_partials/blox/testimonials.html @@ -33,8 +33,14 @@
{{ $image := resources.GetMatch (path.Join "media" .image) }} {{ with $image }} - {{ $image = $image.Process "Fill 75x75 center webp" }} - {{$item.name | plainify}} + {{/* Serve a single 2x retina source for a 48px display */}} + {{ $avatar_96 := $image.Process "Fill 96x96 Center webp" }} + {{$item.name | plainify}} {{ end }}
{{.name}}
diff --git a/modules/blox-tailwind/layouts/_partials/functions/process_responsive_image.html b/modules/blox-tailwind/layouts/_partials/functions/process_responsive_image.html new file mode 100644 index 00000000..218c390e --- /dev/null +++ b/modules/blox-tailwind/layouts/_partials/functions/process_responsive_image.html @@ -0,0 +1,152 @@ +{{/* +Responsive Image Processor for Hugo Blox Builder (HBB) +Uses Hugo's .Process method with configurable quality and responsive breakpoints + +Parameters: +- image: Hugo image resource (required) +- sizes: Array of widths for responsive breakpoints (optional, defaults to comprehensive set) +- mode: Processing mode - "fit", "fill", "resize", or "responsive" (optional, defaults to "fit") + - "fit": Maintains original aspect ratio, scales to fit within width (ignores aspect_ratio param) + - "fill": Crops to exact dimensions, requires aspect_ratio for height calculation + - "resize": Same as fit, maintains aspect ratio + - "responsive": Creates responsive variants from already-processed image (no further processing) +- anchor: Anchor point for cropping when using fill (optional, defaults to "smart") +- aspect_ratio: Target aspect ratio for fill mode only (optional, format "width:height") +- quality: Image quality override (optional, uses Hugo config by default) + +Returns: Map with processed images and srcset string +- .srcset: Complete srcset attribute value +- .fallback: Default/smallest image for src attribute +- .sizes: Array of processed image resources +*/}} + +{{ $image := .image }} +{{ $sizes := .sizes | default (slice 320 480 768 1024 1366 1920 2560) }} +{{ $mode := .mode | default "fit" }} +{{ $anchor := .anchor | default "smart" }} +{{ $aspect_ratio := .aspect_ratio | default "" }} +{{ $quality := .quality }} + +{{/* Initialize return variables */}} +{{ $processed_images := slice }} +{{ $srcset_parts := slice }} +{{ $fallback_image := "" }} + +{{/* Check if image supports processing - only allow known processable formats */}} +{{ $can_process := false }} +{{ if $image.MediaType }} + {{ $format := $image.MediaType.SubType }} + {{ $isJPEG := eq $format "jpeg" }} + {{ $isPNG := eq $format "png" }} + {{ $isWebP := eq $format "webp" }} + {{ $can_process = or $isJPEG $isPNG $isWebP }} +{{ else }} + {{/* Try detecting by file extension if MediaType not available */}} + {{ $ext := path.Ext $image.RelPermalink | lower }} + {{ $isJPEG := or (eq $ext ".jpg") (eq $ext ".jpeg") }} + {{ $isPNG := eq $ext ".png" }} + {{ $isWebP := eq $ext ".webp" }} + {{ $can_process = or $isJPEG $isPNG $isWebP }} +{{ end }} + +{{ if $can_process }} + {{/* Handle responsive mode - for already processed images */}} + {{ if eq $mode "responsive" }} + {{/* Convert to WebP if needed */}} + {{ $base_image := $image }} + {{ if ne $image.MediaType.SubType "gif" }}{{ $base_image = $image.Process "webp" }}{{ end }} + + {{/* Create responsive variants by resizing down from the processed image */}} + {{ range $width := $sizes }} + {{/* Only create smaller variants */}} + {{ if and (le $width $base_image.Width) (gt $width 0) }} + {{ $resized := $base_image.Resize (printf "%dx webp" $width) }} + {{ $processed_images = $processed_images | append $resized }} + + {{/* Add to srcset */}} + {{ $srcset_part := printf "%s %dw" $resized.RelPermalink $resized.Width }} + {{ $srcset_parts = $srcset_parts | append $srcset_part }} + + {{/* Set fallback to smallest image */}} + {{ if not $fallback_image }} + {{ $fallback_image = $resized }} + {{ end }} + {{ end }} + {{ end }} + + {{/* Always include the original size as the largest option */}} + {{ $srcset_part := printf "%s %dw" $base_image.RelPermalink $base_image.Width }} + {{ $srcset_parts = $srcset_parts | append $srcset_part }} + {{ $processed_images = $processed_images | append $base_image }} + {{ if not $fallback_image }}{{ $fallback_image = $base_image }}{{ end }} + + {{ else }} + {{/* Process images for each breakpoint */}} + {{ range $width := $sizes }} + {{/* Only process if the source image is larger than target width */}} + {{ if ge $image.Width $width }} + {{ $process_params := "" }} + {{/* Build processing parameters based on mode */}} + {{- if eq $mode "resize" -}} + {{- if $quality -}} + {{- $process_params = printf "resize %dx webp q%d" $width $quality -}} + {{- else -}} + {{- $process_params = printf "resize %dx webp" $width -}} + {{- end -}} + {{- else if eq $mode "fill" -}} + {{- if $aspect_ratio -}} + {{- $ratio_parts := split $aspect_ratio ":" -}} + {{- $ratio_width := int (index $ratio_parts 0) -}} + {{- $ratio_height := int (index $ratio_parts 1) -}} + {{- $height := div (mul $width $ratio_height) $ratio_width -}} + {{- if $quality -}} + {{- $process_params = printf "fill %dx%d webp q%d %s" $width $height $quality $anchor -}} + {{- else -}} + {{- $process_params = printf "fill %dx%d webp %s" $width $height $anchor -}} + {{- end -}} + {{- else -}} + {{/* Fill mode without aspect ratio - use resize instead to maintain original proportions */}} + {{- if $quality -}} + {{- $process_params = printf "resize %dx webp q%d" $width $quality -}} + {{- else -}} + {{- $process_params = printf "resize %dx webp" $width -}} + {{- end -}} + {{- end -}} + {{- else -}} + {{/* Default to fit - always maintain original aspect ratio */}} + {{/* Fit mode should not force an aspect ratio, it should fit within bounds */}} + {{- if $quality -}} + {{- $process_params = printf "resize %dx webp q%d" $width $quality -}} + {{- else -}} + {{- $process_params = printf "resize %dx webp" $width -}} + {{- end -}} + {{- end -}} + + {{/* Process the image */}} + {{- $processed := $image.Process $process_params -}} + {{- $processed_images = $processed_images | append $processed -}} + + {{/* Add to srcset */}} + {{- $srcset_part := printf "%s %dw" $processed.RelPermalink $processed.Width -}} + {{- $srcset_parts = $srcset_parts | append $srcset_part -}} + + {{/* Set fallback to smallest image */}} + {{- if not $fallback_image -}} + {{- $fallback_image = $processed -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{ end }} +{{- else -}} + {{/* Handle unsupported formats (SVG, GIF) */}} + {{- $srcset_parts = $srcset_parts | append (printf "%s %dw" $image.RelPermalink $image.Width) -}} + {{- $fallback_image = $image -}} + {{- $processed_images = $processed_images | append $image -}} +{{- end -}} + +{{/* Return processed data */}} +{{ return (dict + "srcset" (delimit $srcset_parts ", ") + "fallback" $fallback_image + "sizes" $processed_images +) }} diff --git a/modules/blox-tailwind/layouts/_partials/page_author_card.html b/modules/blox-tailwind/layouts/_partials/page_author_card.html index 19b7214c..d0c9df05 100644 --- a/modules/blox-tailwind/layouts/_partials/page_author_card.html +++ b/modules/blox-tailwind/layouts/_partials/page_author_card.html @@ -15,15 +15,25 @@
{{ if $avatar }} - {{ $authorImage := $avatar.Fill "384x384 Center" }} - {{$author_page.Title}} + {{/* Use utility for high-res avatar processing (smart for Retina displays) */}} + {{ $responsive := partial "functions/process_responsive_image.html" (dict + "image" $avatar + "mode" "fill" + "aspect_ratio" "1:1" + "anchor" "Center" + "sizes" (slice 96 192 288 384) + ) }} + + {{$author_page.Title}} {{ end }}
diff --git a/modules/blox-tailwind/layouts/_partials/views/article-grid.html b/modules/blox-tailwind/layouts/_partials/views/article-grid.html index 3bc76fb1..72aa115d 100644 --- a/modules/blox-tailwind/layouts/_partials/views/article-grid.html +++ b/modules/blox-tailwind/layouts/_partials/views/article-grid.html @@ -14,27 +14,56 @@
{{ with $resource }} - {{ $image := "" }} - {{if $fill_image}} - {{ $image = .Fill (printf "960x540 %s" $anchor) }} - {{else}} - {{ $image = .Fit (printf "960x540 %s" $anchor) }} - {{end}} - {{ if ne $image.MediaType.SubType "gif" }}{{ $image = $image.Process "webp" }}{{ end }} -
+ {{/* Skip GIF processing */}} + {{ if ne .MediaType.SubType "gif" }} + {{/* Process original image first, then create responsive variants */}} + {{ $original_image := "" }} + {{if $fill_image}} + {{ $original_image = .Fill (printf "960x540 %s" $anchor) }} + {{else}} + {{ $original_image = .Fit (printf "960x540 %s" $anchor) }} + {{end}} + + {{ $responsive := partial "functions/process_responsive_image.html" (dict + "image" $original_image + "mode" "responsive" + "sizes" (slice 480 768 960 1200 1920) + ) }} + + {{ else }} + {{/* Handle GIF without processing */}} +
+ - {{ $item.Title | plainify }} -
+ {{ $item.Title | plainify }} + +
+ {{ end }} {{end}}
@@ -70,16 +99,16 @@
{{ $avatar := (.Resources.ByType "image").GetMatch "*avatar*" }} {{ if $avatar }} - {{ $authorImage := $avatar.Process "Fill 100x100 Center webp" }} - avatar + {{/* Use a single 2x retina source for a 20px display */}} + {{ $avatar_40 := $avatar.Process "Fill 40x40 Center webp" }} + avatar {{ else }} {{ warnf "An image named `avatar` was not found in the `%s` folder" $value.Path }} {{ end }} diff --git a/modules/blox-tailwind/layouts/_partials/views/card.html b/modules/blox-tailwind/layouts/_partials/views/card.html index 5b823962..75fd1a66 100644 --- a/modules/blox-tailwind/layouts/_partials/views/card.html +++ b/modules/blox-tailwind/layouts/_partials/views/card.html @@ -14,9 +14,21 @@
{{ with $resource }} - {{ $image := .Fill (printf "655x655 %s" $anchor) }} - {{ if ne $image.MediaType.SubType "gif" }}{{ $image = $image.Process "webp" }}{{ end }} - {{ $item.Title | plainify }} + {{ $original_image := .Fill (printf "655x655 %s" $anchor) }} + {{ $responsive := partial "functions/process_responsive_image.html" (dict + "image" $original_image + "mode" "responsive" + "sizes" (slice 192 384 480 655) + ) }} + + {{ $item.Title | plainify }} {{end}}
diff --git a/modules/blox-tailwind/layouts/single.html b/modules/blox-tailwind/layouts/single.html index 26add2ca..aea099f9 100644 --- a/modules/blox-tailwind/layouts/single.html +++ b/modules/blox-tailwind/layouts/single.html @@ -48,8 +48,14 @@ {{ end }}
{{ with $avatar }} - {{ $avatar = $avatar.Process "Fill 100x100 Center webp" }} - {{ $author_page.Title }} + {{/* Simple 2x retina image for small avatar */}} + {{ $avatar_32 := $avatar.Process "Fill 32x32 Center webp" }} + {{ $author_page.Title }} {{ end }}
{{ $author_page.Title }}
@@ -116,26 +122,78 @@ {{/* Determine image placement. */}} {{ $placement := .Params.image.placement | default 1 }}{{/* Default to full column width. */}} - {{/* Fit image to container's max width */}} + {{/* Configure responsive processing based on placement */}} {{ $image_container := "" }} + {{ $responsive_sizes := slice }} + {{ $sizes_attr := "" }} {{ if eq $placement 2}} - {{ $image_container = "container" }} - {{ $image = $featured.Fit "1200x2500" }} + {{ $image_container = "container" }} + {{ $responsive_sizes = slice 480 768 1024 1200 1600 }} + {{ $sizes_attr = "(max-width: 480px) 100vw, (max-width: 768px) 90vw, (max-width: 1200px) 80vw, 1200px" }} {{else if eq $placement 3}} - {{ $image_container = "container-fluid" }} - {{ $image := $featured.Fit "2560x2560" }} + {{ $image_container = "container-fluid" }} + {{ $responsive_sizes = slice 768 1024 1366 1920 2560 }} + {{ $sizes_attr = "(max-width: 768px) 100vw, (max-width: 1366px) 90vw, (max-width: 1920px) 80vw, 2560px" }} {{else}} - {{ $image_container = "article-container" }} - {{ $image = $featured.Fit "720x2500" }} + {{ $image_container = "article-container" }} + {{ $responsive_sizes = slice 320 480 640 720 960 }} + {{ $sizes_attr = "(max-width: 480px) 100vw, (max-width: 640px) 90vw, (max-width: 720px) 80vw, 720px" }} {{end}} - {{ if ne $image.MediaType.SubType "gif" }}{{ $image = $image.Process "webp" }}{{ end }} - {{/* Featured image */}} -
-
- {{ with $.Params.image.alt_text }}{{.}}{{ end }} - {{ with $.Params.image.caption }}{{ . | markdownify | emojify }}{{ end }} + + {{/* Process featured image using utility with placement-specific constraints */}} + {{ if ne $featured.MediaType.SubType "gif" }} + {{/* Pre-fit image to placement constraints, then apply responsive processing */}} + {{ $fitted_image := $featured }} + {{ if eq $placement 2}} + {{ $fitted_image = $featured.Fit "1200x2500" }} + {{else if eq $placement 3}} + {{ $fitted_image = $featured.Fit "2560x2560" }} + {{else}} + {{ $fitted_image = $featured.Fit "720x2500" }} + {{end}} + + {{ $responsive := partial "functions/process_responsive_image.html" (dict + "image" $fitted_image + "mode" "responsive" + "sizes" $responsive_sizes + ) }} + + {{/* Featured image - use fitted image dimensions for container, responsive for srcset */}} +
+
+ {{ with $.Params.image.alt_text }}{{.}}{{ end }} + {{ with $.Params.image.caption }}{{ . | markdownify | emojify }}{{ end }} +
-
+ {{ else }} + {{/* Handle GIF - apply same placement constraints without responsive processing */}} + {{ $image := $featured }} + {{ if eq $placement 2}} + {{ $image = $featured.Fit "1200x2500" }} + {{else if eq $placement 3}} + {{ $image = $featured.Fit "2560x2560" }} + {{else}} + {{ $image = $featured.Fit "720x2500" }} + {{end}} +
+
+ {{ with $.Params.image.alt_text }}{{.}}{{ end }} + {{ with $.Params.image.caption }}{{ . | markdownify | emojify }}{{ end }} +
+
+ {{ end }} {{end}} {{/* EVENT / PUBLICATION METADATA */}}