feat(gallery): sidecar files, EXIF/IPTC, and page-bundle auto-discovery

Adds the metadata pipeline that makes the gallery block self-curating.
Per-image metadata now resolves through a four-tier waterfall, first
non-empty source wins:

  1. Frontmatter (items[].caption etc.)
  2. Sidecar file next to the image - tries six patterns:
     <name>.yaml / .yml / .json and <basename>.yaml / .yml / .json,
     looked up in both site assets and the page bundle (covers album
     mode and page-resource mode alike).
  3. Embedded IPTC/EXIF via Hugo's .Exif.Tags - caption from
     Caption-Abstract / ImageDescription / XPSubject / Description,
     credit from By-line / Artist / Creator. Wrapped in `try` so
     non-EXIF formats and bad metadata never break the build.
  4. Humanised filename - alt fallback only; captions stay empty
     rather than fabricated from filenames.

Also adds a third source mode - content.page_resources: true -
auto-discovers image resources attached to the current page bundle.
Drop-in for blog posts and project pages where photos live next to
index.md; sidecar lookup works inside the bundle too.

Fixes a leading-slash quirk: Resource.Name returns "/media/..." for
site assets but a bare path for page-bundle resources; the path is
now normalised so sidecar lookups land in either layout.

Test site adds two sidecar YAML files (04-cabin.jpg.yaml and
06-mountain.jpg.yaml) - the masonry album demo now picks up titles,
markdown captions, and links from those sidecars without listing any
items[].
This commit is contained in:
George Cushen committed 2026-05-17 23:45:27 +01:00
1 parent b67f09b8fd
commit bdd3820f27
3 files changed
+120 -19

No files matched your search

@@ -1,21 +1,27 @@
{{/*
resolve_gallery_items — Resolves gallery block items into a list of fully-processed
image props ready for the Preact component. Handles two source modes:
image props ready for the Preact component. Three source modes:
1. content.items[] — explicit per-image dicts with optional caption/alt/credit/link
2. content.album — folder path relative to assets/, e.g. "media/albums/gallery"
1. content.items[] — explicit per-image dicts (curated)
2. content.album — folder under assets/, e.g. "media/albums/gallery"
3. content.page_resources — auto-discover image resources in the current page bundle
For each resolved image, produces a dict with:
{src, srcset, width, height, aspect_ratio, lqip, alt, caption, credit, link, title}
Slice 1 caption resolution: frontmatter only (items[].caption). Sidecar + EXIF/IPTC
resolution is intentionally deferred to Slice 2 — for now, missing captions stay
empty in the UI, with the humanised filename surfaced as the `alt` fallback so
screen readers and search engines have something useful.
Metadata waterfall — first non-empty value per field wins:
1. Frontmatter (items[].caption etc.)
2. Sidecar file next to the image (.yaml / .yml / .json)
— tried as "<full-name>.yaml" then "<basename-without-ext>.yaml"
— looked up in both site assets and the current page bundle
3. Embedded IPTC/EXIF via Hugo's .Exif.Tags
— caption from Caption-Abstract / ImageDescription / XPSubject / Description
— credit from By-line / Artist / Creator
4. Humanised filename (alt only — captions stay empty when no metadata exists)
Inputs:
.content: the block's content dict (items / album / sort / limit)
.Page: current page (for page-resource image lookup)
.content: the block's content dict
.Page: current page (used for page-resource lookup + RenderString markdown)
Returns: dict { items: [...] } or empty if nothing resolves.
*/}}
@@ -27,7 +33,7 @@
{{ $sort_order := lower ($content.sort | default "asc") }}
{{ $limit := $content.limit | default 0 }}
{{/* Build a unified list of {resource?, fallback_url?, meta} entries from either source mode */}}
{{/* Build a unified list of {resource?, fallback_url?, meta} entries */}}
{{ $candidates := slice }}
{{/* Source A — explicit items */}}
@@ -61,6 +67,23 @@
{{ end }}
{{ end }}
{{/* Source C — page bundle auto-discovery */}}
{{ if $content.page_resources }}
{{ with $page }}
{{ $imgs := .Resources.ByType "image" }}
{{ $sorted := sort $imgs "Name" $sort_order }}
{{ range $sorted }}
{{ if in $allowed_exts (lower (path.Ext .Name)) }}
{{ $candidates = $candidates | append (dict
"resource" .
"fallback_url" ""
"meta" dict
) }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{/* Apply limit */}}
{{ if gt $limit 0 }}
{{ $candidates = first $limit $candidates }}
@@ -73,17 +96,88 @@
{{ $resource := .resource }}
{{ $fallback_url := .fallback_url }}
{{/* Per-item text fields. Caption / title / credit are rendered through
Hugo's markdown pipeline at build time (inline mode — no wrapping <p>)
so users can put **bold**, *italic*, `code`, and [links](url) in any of
them without shipping a JS markdown parser. Alt stays as plain text
since it lives inside an HTML attribute. */}}
{{/* ---- Metadata waterfall ---- */}}
{{ $alt := index $meta "alt" | default "" }}
{{ $caption_raw := index $meta "caption" | default "" }}
{{ $title_raw := index $meta "title" | default "" }}
{{ $credit_raw := index $meta "credit" | default "" }}
{{ $link := index $meta "link" | default "" }}
{{/* Tier 2 — sidecar file lookup. Tries:
<name>.yaml/.yml/.json (full filename, Lightroom-style convention)
<basename>.yaml/.yml/.json (filename minus extension)
in both site assets and the page bundle. */}}
{{ $sidecar := false }}
{{ if $resource }}
{{/* Resource.Name comes back with a leading slash for site assets (e.g.
"/media/albums/gallery/01.jpg"); strip it so resources.Get treats the
path as relative to assets/. Page-bundle resource names have no
leading slash, so TrimPrefix is a no-op for those. */}}
{{ $name := strings.TrimPrefix "/" $resource.Name }}
{{ $basename := strings.TrimSuffix (path.Ext $name) $name }}
{{ $patterns := slice
(printf "%s.yaml" $name)
(printf "%s.yml" $name)
(printf "%s.json" $name)
(printf "%s.yaml" $basename)
(printf "%s.yml" $basename)
(printf "%s.json" $basename)
}}
{{ range $pattern := $patterns }}
{{ if not $sidecar }}
{{ $candidate := resources.Get $pattern }}
{{ if not $candidate }}{{ $candidate = resources.GetMatch $pattern }}{{ end }}
{{ if and (not $candidate) $page }}
{{ $candidate = $page.Resources.GetMatch $pattern }}
{{ end }}
{{ if $candidate }}
{{ $sidecar = $candidate }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ if $sidecar }}
{{ $data := transform.Unmarshal $sidecar }}
{{ if reflect.IsMap $data }}
{{ if not $caption_raw }}{{ $caption_raw = index $data "caption" | default "" }}{{ end }}
{{ if not $title_raw }}{{ $title_raw = index $data "title" | default "" }}{{ end }}
{{ if not $credit_raw }}{{ $credit_raw = index $data "credit" | default "" }}{{ end }}
{{ if not $alt }}{{ $alt = index $data "alt" | default "" }}{{ end }}
{{ if not $link }}{{ $link = index $data "link" | default "" }}{{ end }}
{{ end }}
{{ end }}
{{/* Tier 3 — embedded IPTC/EXIF. Lightroom/Capture One JPEG exports carry
Caption-Abstract + By-line + Copyright; this picks them up automatically. */}}
{{ $exif_tags := dict }}
{{ if $resource }}
{{ $exif_try := try $resource.Exif }}
{{ if and (not $exif_try.Err) $exif_try.Value }}
{{ $exif_tags = $exif_try.Value.Tags | default dict }}
{{ end }}
{{ end }}
{{ if $exif_tags }}
{{ if not $caption_raw }}
{{ range $key := (slice "Caption-Abstract" "ImageDescription" "XPSubject" "Description") }}
{{ if not $caption_raw }}
{{ $v := index $exif_tags $key }}
{{ if $v }}{{ $caption_raw = printf "%v" $v }}{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ if not $credit_raw }}
{{ range $key := (slice "By-line" "Artist" "Creator") }}
{{ if not $credit_raw }}
{{ $v := index $exif_tags $key }}
{{ if $v }}{{ $credit_raw = printf "%v" $v }}{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{/* ---- Inline-Markdown render of text fields (RenderString display:inline) ---- */}}
{{ $caption := $caption_raw }}
{{ if and $caption_raw $page }}
{{ $caption = $page.RenderString (dict "display" "inline") $caption_raw }}
@@ -135,10 +229,8 @@
) }}
{{ end }}
{{/* Alt fallback: plainified caption → humanised filename. Caption itself
is NOT filled from filename — empty captions stay empty in the UI
(Slice 1 behaviour). Plainify strips the HTML produced by RenderString
so alt stays attribute-safe. */}}
{{/* Tier 4 — alt fallback: plainified caption → humanised filename. Caption
itself stays empty when no metadata source has supplied one. */}}
{{ if not $entry.alt }}
{{ $fallback_alt := "" }}
{{ with $caption_raw }}
@@ -0,0 +1,4 @@
title: "The cabin"
caption: "*Winter quarters* — where the team writes up over the slow months"
credit: "Photo: [Picsum](https://picsum.photos)"
alt: "Snow-dusted wooden cabin among pine trees"
@@ -0,0 +1,5 @@
title: "Summit"
caption: "Final ridge of the **Highland traverse**, with the trig point in view"
credit: "Photo: [Picsum](https://picsum.photos)"
link: "/expeditions/highland-traverse/"
keywords: [highland, traverse, summit]