feat(gallery): add gallery block with grid/masonry layouts and lightbox

First-class image gallery block. Two layouts ship today - grid (uniform
tiles, configurable columns 1-6, aspect-ratio presets) and masonry
(CSS-columns Pinterest packing). Click-to-zoom lightbox built in Preact,
portalled to <body> so it escapes the section's `isolation: isolate`
stacking context; keyboard navigation (← → Esc), touch swipe, and body
scroll-lock included.

Two source modes: explicit items[] for curated galleries with per-image
captions/credits/links, or album: <folder> for drop-in folder loading
sorted by filename. Remote URLs (Unsplash/Pexels/CDNs) are passed
through unprocessed; local images get the full responsive variants +
inline base64 LQIP placeholder for instant first-paint over the eventual
sharp image.

Captions, titles, and credits are rendered through Hugo's RenderString
at build time (display: inline) - users get full Markdown (bold/italic/
code/links) in any of those fields with zero JS markdown parser. Alt
text falls back to plainified caption → humanised filename so screen
readers always have something useful.

Caption positions: below (default - accessibility-first), overlay
(magazine gradient), hover (clean grid reveal), none (lightbox-only).
Block-scoped CSS in the co-located style.css is picked up by the
existing block-CSS auto-discovery.

Test site: two demos under home.yaml (curated grid + auto-loaded
masonry) with 9 sample Picsum images stored under
test/assets/media/albums/gallery/ at varied aspect ratios.
This commit is contained in:
George Cushen committed 2026-05-17 23:32:49 +01:00
1 parent 4de0ad0af9
commit b67f09b8fd
17 files changed
+725 -13

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
import {render} from "preact";
import {GalleryBlock} from "./component.jsx";
function renderGalleryBlocks() {
const blocks = document.querySelectorAll('[data-block-type="gallery"]');
blocks.forEach((block) => {
const propsData = block.dataset.props;
if (propsData) {
const props = JSON.parse(propsData);
render(<GalleryBlock {...props} />, block);
}
});
}
renderGalleryBlocks();
+431
View File
@@ -0,0 +1,431 @@
import {useState, useEffect, useRef, useCallback} from "preact/hooks";
import {createPortal} from "preact/compat";
/*
* Gallery block — Slice 1 (grid + masonry layouts, Preact lightbox).
*
* Hugo's resolve_gallery_items.html partial supplies a fully-processed `items`
* prop: each item has src/srcset/width/height/aspect_ratio/lqip plus
* alt/caption/credit/link/title. The component never touches image paths.
*/
const GRID_COLS = {
1: "grid-cols-1",
2: "grid-cols-1 sm:grid-cols-2",
3: "grid-cols-1 sm:grid-cols-2 md:grid-cols-3",
4: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4",
5: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5",
6: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6",
};
const MASONRY_COLS = {
1: "columns-1",
2: "columns-1 sm:columns-2",
3: "columns-1 sm:columns-2 md:columns-3",
4: "columns-2 sm:columns-3 md:columns-4",
5: "columns-2 sm:columns-3 md:columns-4 lg:columns-5",
6: "columns-2 sm:columns-3 md:columns-4 lg:columns-6",
};
const GAP_CLASS = {
sm: "gap-2",
md: "gap-4",
lg: "gap-6",
};
const MASONRY_GAP_CLASS = {
sm: "hbx-gallery-masonry-gap-sm",
md: "",
lg: "hbx-gallery-masonry-gap-lg",
};
const ASPECT_CLASS = {
square: "aspect-square",
landscape: "aspect-[4/3]",
portrait: "aspect-[3/4]",
wide: "aspect-[16/9]",
};
const SIZES_DEFAULT_GRID = "(min-width: 1024px) 25vw, (min-width: 640px) 33vw, 100vw";
const SIZES_DEFAULT_MASONRY = "(min-width: 1024px) 25vw, (min-width: 640px) 33vw, 100vw";
function renderInline(text) {
if (!text) return "";
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>");
}
function GridTile({item, onClick, aspectClass, captionPosition, hoverZoom, sizes, lightbox}) {
const [loaded, setLoaded] = useState(false);
const imgClass = loaded
? "absolute inset-0 w-full h-full object-cover transition-opacity duration-500 opacity-100"
: "absolute inset-0 w-full h-full object-cover transition-opacity duration-500 opacity-0";
const hoverImgClass = hoverZoom ? "transition-transform duration-500 group-hover:scale-105" : "";
const showOverlay = captionPosition === "overlay" && (item.caption || item.title);
const showHoverCaption = captionPosition === "hover" && (item.caption || item.title);
return (
<figure class={`hbx-gallery-tile group relative overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800 ${aspectClass}`}>
<button
type="button"
onClick={onClick}
class="absolute inset-0 w-full h-full block cursor-zoom-in focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label={lightbox ? `Open image: ${item.alt || item.caption || ""}` : item.alt || item.caption || "Image"}
>
<span
class="hbx-gallery-tile-lqip absolute inset-0 w-full h-full"
style={item.lqip ? `background-image: url("${item.lqip}")` : ""}
aria-hidden="true"
/>
<img
src={item.src}
srcset={item.srcset}
sizes={sizes}
width={item.width}
height={item.height}
alt={item.alt || ""}
loading="lazy"
decoding="async"
class={`${imgClass} ${hoverImgClass}`}
onLoad={() => setLoaded(true)}
/>
{showOverlay && (
<span class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 pt-12 text-left">
<span class="block text-sm font-semibold text-white" dangerouslySetInnerHTML={{__html: item.title || item.caption}} />
{item.title && item.caption && (
<span class="block text-xs text-gray-200 mt-1" dangerouslySetInnerHTML={{__html: item.caption}} />
)}
</span>
)}
{showHoverCaption && (
<span class="absolute inset-0 flex items-end p-4 bg-black/0 group-hover:bg-black/60 transition-colors duration-300">
<span class="block text-sm font-semibold text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300" dangerouslySetInnerHTML={{__html: item.caption || item.title}} />
</span>
)}
</button>
{captionPosition === "below" && (item.caption || item.title) && (
<figcaption class="pt-2 text-sm text-gray-600 dark:text-gray-400">
{item.title && (
<span class="font-semibold text-gray-900 dark:text-white" dangerouslySetInnerHTML={{__html: item.title}} />
)}
{item.title && item.caption && " — "}
{item.caption && <span dangerouslySetInnerHTML={{__html: item.caption}} />}
</figcaption>
)}
</figure>
);
}
function MasonryTile({item, onClick, captionPosition, hoverZoom, sizes, lightbox}) {
const [loaded, setLoaded] = useState(false);
const imgClass = loaded
? "w-full h-auto block transition-opacity duration-500 opacity-100"
: "w-full h-auto block transition-opacity duration-500 opacity-0";
const hoverImgClass = hoverZoom ? "transition-transform duration-500 group-hover:scale-105" : "";
// Use the precomputed aspect ratio to reserve space — prevents the column
// layout from reflowing as each image loads.
const aspectStyle = item.aspect_ratio ? `aspect-ratio: ${item.aspect_ratio}` : "";
const showOverlay = captionPosition === "overlay" && (item.caption || item.title);
const showHoverCaption = captionPosition === "hover" && (item.caption || item.title);
return (
<figure class="hbx-gallery-tile group relative">
<button
type="button"
onClick={onClick}
class="relative block w-full overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800 cursor-zoom-in focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label={lightbox ? `Open image: ${item.alt || item.caption || ""}` : item.alt || item.caption || "Image"}
style={aspectStyle}
>
<span
class="hbx-gallery-tile-lqip absolute inset-0 w-full h-full"
style={item.lqip ? `background-image: url("${item.lqip}")` : ""}
aria-hidden="true"
/>
<img
src={item.src}
srcset={item.srcset}
sizes={sizes}
width={item.width}
height={item.height}
alt={item.alt || ""}
loading="lazy"
decoding="async"
class={`${imgClass} ${hoverImgClass} absolute inset-0 w-full h-full object-cover`}
onLoad={() => setLoaded(true)}
/>
{showOverlay && (
<span class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 pt-12 text-left">
<span class="block text-sm font-semibold text-white" dangerouslySetInnerHTML={{__html: item.title || item.caption}} />
{item.title && item.caption && (
<span class="block text-xs text-gray-200 mt-1" dangerouslySetInnerHTML={{__html: item.caption}} />
)}
</span>
)}
{showHoverCaption && (
<span class="absolute inset-0 flex items-end p-4 bg-black/0 group-hover:bg-black/60 transition-colors duration-300">
<span class="block text-sm font-semibold text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300" dangerouslySetInnerHTML={{__html: item.caption || item.title}} />
</span>
)}
</button>
{captionPosition === "below" && (item.caption || item.title) && (
<figcaption class="pt-2 text-sm text-gray-600 dark:text-gray-400">
{item.title && (
<span class="font-semibold text-gray-900 dark:text-white" dangerouslySetInnerHTML={{__html: item.title}} />
)}
{item.title && item.caption && " — "}
{item.caption && <span dangerouslySetInnerHTML={{__html: item.caption}} />}
</figcaption>
)}
</figure>
);
}
function Lightbox({items, index, onClose, onPrev, onNext}) {
const item = items[index];
const touchStart = useRef({x: 0, y: 0, t: 0});
// Body scroll lock while the lightbox is open
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
// Keyboard navigation
useEffect(() => {
function onKey(e) {
if (e.key === "Escape") onClose();
else if (e.key === "ArrowLeft") onPrev();
else if (e.key === "ArrowRight") onNext();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, onPrev, onNext]);
function onTouchStart(e) {
const t = e.touches[0];
touchStart.current = {x: t.clientX, y: t.clientY, t: Date.now()};
}
function onTouchEnd(e) {
const t = e.changedTouches[0];
const dx = t.clientX - touchStart.current.x;
const dy = t.clientY - touchStart.current.y;
const dt = Date.now() - touchStart.current.t;
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) && dt < 600) {
if (dx < 0) onNext();
else onPrev();
}
}
if (!item) return null;
return (
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/95 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Image lightbox"
onClick={onClose}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
class="absolute top-4 right-4 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-white/50 transition-colors"
aria-label="Close"
>
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{items.length > 1 && (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onPrev();
}}
class="absolute left-4 top-1/2 -translate-y-1/2 z-10 hidden sm:flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-white/50 transition-colors"
aria-label="Previous image"
>
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNext();
}}
class="absolute right-4 top-1/2 -translate-y-1/2 z-10 hidden sm:flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-white/50 transition-colors"
aria-label="Next image"
>
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</>
)}
<figure class="relative flex flex-col items-center justify-center max-w-[95vw] max-h-[95vh]" onClick={(e) => e.stopPropagation()}>
<img
src={item.src}
srcset={item.srcset}
sizes="95vw"
alt={item.alt || ""}
width={item.width}
height={item.height}
class="max-w-[95vw] max-h-[80vh] w-auto h-auto object-contain rounded-lg shadow-2xl"
/>
{(item.title || item.caption || item.credit || item.link) && (
<figcaption class="hbx-gallery-lightbox-caption mt-4 text-center">
{item.title && (
<div class="text-base font-semibold" dangerouslySetInnerHTML={{__html: item.title}} />
)}
{item.caption && (
<div class="text-sm mt-1" dangerouslySetInnerHTML={{__html: item.caption}} />
)}
{(item.credit || item.link) && (
<div class="text-xs mt-2 text-gray-400">
{item.credit && <span dangerouslySetInnerHTML={{__html: item.credit}} />}
{item.credit && item.link && <span class="mx-2">·</span>}
{item.link && (
<a href={item.link} class="underline hover:no-underline">
Open page →
</a>
)}
</div>
)}
</figcaption>
)}
{items.length > 1 && (
<div class="absolute -top-10 left-0 text-xs text-gray-300 font-mono">
{index + 1} / {items.length}
</div>
)}
</figure>
</div>
);
}
export const GalleryBlock = ({content = {}, design = {}, items = []}) => {
const [lightboxIndex, setLightboxIndex] = useState(null);
const layout = design.layout || "grid";
const columns = Math.max(1, Math.min(6, design.columns || 3));
const gap = design.gap || "md";
const captionPosition = design.caption_position || "below";
const hoverZoom = design.hover_zoom !== false;
const lightboxEnabled = design.lightbox !== false;
const aspectRatio = design.aspect_ratio || "square";
const openLightbox = useCallback(
(i) => {
if (lightboxEnabled) {
setLightboxIndex(i);
} else if (items[i] && items[i].link) {
window.location.href = items[i].link;
}
},
[lightboxEnabled, items],
);
const closeLightbox = useCallback(() => setLightboxIndex(null), []);
const nextLightbox = useCallback(() => setLightboxIndex((i) => (i == null ? null : (i + 1) % items.length)), [items.length]);
const prevLightbox = useCallback(() => setLightboxIndex((i) => (i == null ? null : (i - 1 + items.length) % items.length)), [items.length]);
if (!items || items.length === 0) {
return (
<div class="py-12 px-4 text-center text-sm text-gray-500 dark:text-gray-400">
Gallery has no images. Add `items[]` or set `album` to a folder under `assets/`.
</div>
);
}
const sizes = layout === "masonry" ? SIZES_DEFAULT_MASONRY : SIZES_DEFAULT_GRID;
const aspectClass = ASPECT_CLASS[aspectRatio] || ASPECT_CLASS.square;
const gridClass = GRID_COLS[columns] || GRID_COLS[3];
const masonryClass = MASONRY_COLS[columns] || MASONRY_COLS[3];
const gapClass = GAP_CLASS[gap] || GAP_CLASS.md;
const masonryGapClass = MASONRY_GAP_CLASS[gap] || MASONRY_GAP_CLASS.md;
return (
<div class="py-12 sm:py-16 px-4 sm:px-6 lg:px-8">
<div class="max-w-7xl mx-auto">
{(content.title || content.subtitle) && (
<div class="text-center max-w-3xl mx-auto mb-10">
{content.title && (
<h2
class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white tracking-tight mb-3"
dangerouslySetInnerHTML={{__html: renderInline(content.title)}}
/>
)}
{content.subtitle && (
<p class="text-lg text-gray-600 dark:text-gray-400" dangerouslySetInnerHTML={{__html: renderInline(content.subtitle)}} />
)}
</div>
)}
{layout === "masonry" ? (
<div class={`hbx-gallery-masonry ${masonryClass} ${gapClass} ${masonryGapClass}`}>
{items.map((item, i) => (
<MasonryTile
key={i}
item={item}
onClick={() => openLightbox(i)}
captionPosition={captionPosition}
hoverZoom={hoverZoom}
sizes={sizes}
lightbox={lightboxEnabled}
/>
))}
</div>
) : (
<div class={`grid ${gridClass} ${gapClass}`}>
{items.map((item, i) => (
<GridTile
key={i}
item={item}
onClick={() => openLightbox(i)}
aspectClass={aspectClass}
captionPosition={captionPosition}
hoverZoom={hoverZoom}
sizes={sizes}
lightbox={lightboxEnabled}
/>
))}
</div>
)}
</div>
{/* Portal the lightbox to <body> so it escapes the page section's
`isolation: isolate` stacking context — without this, the next
section in document order paints over the lightbox. */}
{lightboxIndex !== null &&
typeof document !== "undefined" &&
createPortal(
<Lightbox items={items} index={lightboxIndex} onClose={closeLightbox} onPrev={prevLightbox} onNext={nextLightbox} />,
document.body,
)}
</div>
);
};
+15
View File
@@ -0,0 +1,15 @@
{
"id": "gallery",
"name": "Gallery",
"version": "1.0.0",
"license": "MIT",
"category": "media",
"intent": ["showcase", "inform"],
"verticals": ["academic-cv", "event", "agency", "dev-portfolio", "podcaster", "startup", "local-business"],
"tags": ["gallery", "photos", "images", "album", "masonry", "grid", "lightbox"],
"description": "Image gallery with grid and masonry layouts, click-to-zoom lightbox, responsive variants, and lazy-loaded LQIP placeholders. Source images from explicit items, an album folder, or remote URLs.",
"author": "Hugo Blox",
"homepage": "https://hugoblox.com/blocks/",
"repository": "https://github.com/HugoBlox/kit",
"keywords": ["hugo", "gallery", "photos", "lightbox", "masonry", "grid"]
}
+40
View File
@@ -0,0 +1,40 @@
/* Gallery block — masonry item spacing, LQIP backdrop, lightbox polish */
/* Masonry vertical spacing: each tile needs explicit margin-bottom because
Tailwind's `space-y-*` uses `:not(:last-child)` which doesn't behave inside
CSS multi-column layout. */
.hbx-gallery-masonry > .hbx-gallery-tile {
margin-bottom: 1rem;
break-inside: avoid;
}
.hbx-gallery-masonry-gap-sm > .hbx-gallery-tile {
margin-bottom: 0.5rem;
}
.hbx-gallery-masonry-gap-lg > .hbx-gallery-tile {
margin-bottom: 1.5rem;
}
/* LQIP placeholder — applied as a background-image so the real <img> can
fade in over it without a layout shift. */
.hbx-gallery-tile-lqip {
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
/* Lightbox caption — limit width so long captions wrap rather than spanning
the full viewport. */
.hbx-gallery-lightbox-caption {
max-width: min(72ch, 90vw);
}
/* Prevent backdrop-blurred caption from picking up site text colour in dark
mode (where body text is white on a black backdrop already). */
.hbx-gallery-lightbox-caption,
.hbx-gallery-lightbox-caption a {
color: rgb(243 244 246);
}
.hbx-gallery-lightbox-caption a:hover {
color: rgb(255 255 255);
}
@@ -208,6 +208,17 @@
{{ $props = merge $props (dict "icon_svgs" $icon_svgs) }}
{{ end }}
{{/* Gallery block: resolve items list with responsive variants + LQIP placeholders */}}
{{ if eq $block_type "gallery" }}
{{ $gallery := partial "functions/resolve_gallery_items.html" (dict
"content" $block.content
"Page" $page
) }}
{{ if $gallery.items }}
{{ $props = merge $props (dict "items" $gallery.items) }}
{{ end }}
{{ end }}
{{/* Convert props to JSON for client-side rendering */}}
{{ $propsJSON := $props | jsonify }}
@@ -0,0 +1,165 @@
{{/*
resolve_gallery_items — Resolves gallery block items into a list of fully-processed
image props ready for the Preact component. Handles two 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"
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.
Inputs:
.content: the block's content dict (items / album / sort / limit)
.Page: current page (for page-resource image lookup)
Returns: dict { items: [...] } or empty if nothing resolves.
*/}}
{{ $page := .Page }}
{{ $content := .content }}
{{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }}
{{ $allowed_exts := slice ".jpg" ".jpeg" ".png" ".webp" ".gif" ".avif" ".svg" }}
{{ $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 */}}
{{ $candidates := slice }}
{{/* Source A — explicit items */}}
{{ with $content.items }}
{{ range . }}
{{ $src := .src | default "" }}
{{ if $src }}
{{ $resolved := partial "functions/resolve_image_resource" (dict "path" $src "Page" $page) }}
{{ $candidates = $candidates | append (dict
"resource" $resolved.resource
"fallback_url" ($resolved.fallback_url | default "")
"meta" .
) }}
{{ end }}
{{ end }}
{{ end }}
{{/* Source B — album folder glob */}}
{{ with $content.album }}
{{ $album := strings.TrimSuffix "/" . }}
{{ $matches := resources.Match (printf "%s/*" $album) }}
{{ $sorted := sort $matches "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 }}
{{/* Apply limit */}}
{{ if gt $limit 0 }}
{{ $candidates = first $limit $candidates }}
{{ end }}
{{/* Process each candidate into final item props */}}
{{ $out := slice }}
{{ range $candidates }}
{{ $meta := .meta }}
{{ $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. */}}
{{ $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 "" }}
{{ $caption := $caption_raw }}
{{ if and $caption_raw $page }}
{{ $caption = $page.RenderString (dict "display" "inline") $caption_raw }}
{{ end }}
{{ $title := $title_raw }}
{{ if and $title_raw $page }}
{{ $title = $page.RenderString (dict "display" "inline") $title_raw }}
{{ end }}
{{ $credit := $credit_raw }}
{{ if and $credit_raw $page }}
{{ $credit = $page.RenderString (dict "display" "inline") $credit_raw }}
{{ end }}
{{ $entry := dict
"alt" $alt
"caption" $caption
"credit" $credit
"link" $link
"title" $title
}}
{{ if $resource }}
{{ $canProcess := and (reflect.IsImageResourceProcessable $resource) (ne $resource.MediaType.SubType "gif") }}
{{ if $canProcess }}
{{ $responsive := partial "functions/process_responsive_image.html" (dict
"image" $resource
"mode" "fit"
"sizes" (slice 480 768 1024 1366 1920)
) }}
{{ $entry = merge $entry (dict
"src" $responsive.fallback.RelPermalink
"srcset" $responsive.srcset
"width" $responsive.fallback.Width
"height" $responsive.fallback.Height
"aspect_ratio" (div (float $resource.Width) (float $resource.Height))
) }}
{{/* LQIP — 20px wide WebP, inlined as base64 for instant placeholder paint */}}
{{ $lqip_res := $resource.Resize "20x webp q30" }}
{{ $lqip_b64 := $lqip_res.Content | base64Encode }}
{{ $entry = merge $entry (dict "lqip" (printf "data:image/webp;base64,%s" $lqip_b64)) }}
{{ else }}
{{/* SVG / GIF — pass through unprocessed */}}
{{ $entry = merge $entry (dict
"src" $resource.RelPermalink
"width" $resource.Width
"height" $resource.Height
"aspect_ratio" (cond (gt $resource.Height 0) (div (float $resource.Width) (float $resource.Height)) 1.0)
) }}
{{ 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. */}}
{{ if not $entry.alt }}
{{ $fallback_alt := "" }}
{{ with $caption_raw }}
{{ $fallback_alt = plainify (printf "%s" $entry.caption) }}
{{ end }}
{{ if not $fallback_alt }}
{{ $base := path.Base $resource.Name }}
{{ $name := strings.TrimSuffix (path.Ext $base) $base }}
{{ $name = replace $name "-" " " }}
{{ $name = replace $name "_" " " }}
{{ $fallback_alt = humanize $name }}
{{ end }}
{{ $entry = merge $entry (dict "alt" $fallback_alt) }}
{{ end }}
{{ else if $fallback_url }}
{{ $entry = merge $entry (dict "src" $fallback_url) }}
{{ end }}
{{ if $entry.src }}
{{ $out = $out | append $entry }}
{{ end }}
{{ end }}
{{ return dict "items" $out }}