From bdd3820f27ed535654f4360891790fd578a06294 Mon Sep 17 00:00:00 2001 From: George Cushen Date: Sun, 17 May 2026 23:45:27 +0100 Subject: [PATCH] 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: .yaml / .yml / .json and .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[]. --- .../functions/resolve_gallery_items.html | 130 +++++++++++++++--- .../media/albums/gallery/04-cabin.jpg.yaml | 4 + .../media/albums/gallery/06-mountain.jpg.yaml | 5 + 3 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 test/assets/media/albums/gallery/04-cabin.jpg.yaml create mode 100644 test/assets/media/albums/gallery/06-mountain.jpg.yaml diff --git a/modules/blox/layouts/_partials/functions/resolve_gallery_items.html b/modules/blox/layouts/_partials/functions/resolve_gallery_items.html index b99a9370..ee5a219e 100644 --- a/modules/blox/layouts/_partials/functions/resolve_gallery_items.html +++ b/modules/blox/layouts/_partials/functions/resolve_gallery_items.html @@ -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 ".yaml" then ".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

) - 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: + .yaml/.yml/.json (full filename, Lightroom-style convention) + .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 }} diff --git a/test/assets/media/albums/gallery/04-cabin.jpg.yaml b/test/assets/media/albums/gallery/04-cabin.jpg.yaml new file mode 100644 index 00000000..7f56c391 --- /dev/null +++ b/test/assets/media/albums/gallery/04-cabin.jpg.yaml @@ -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" diff --git a/test/assets/media/albums/gallery/06-mountain.jpg.yaml b/test/assets/media/albums/gallery/06-mountain.jpg.yaml new file mode 100644 index 00000000..e64ccee0 --- /dev/null +++ b/test/assets/media/albums/gallery/06-mountain.jpg.yaml @@ -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]