+
+ {/* Portal the lightbox to 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(
+ ,
+ document.body,
+ )}
+
+ );
+};
diff --git a/modules/blox/blox/gallery/manifest.json b/modules/blox/blox/gallery/manifest.json
new file mode 100644
index 00000000..c892d007
--- /dev/null
+++ b/modules/blox/blox/gallery/manifest.json
@@ -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"]
+}
diff --git a/modules/blox/blox/gallery/style.css b/modules/blox/blox/gallery/style.css
new file mode 100644
index 00000000..ed18ba68
--- /dev/null
+++ b/modules/blox/blox/gallery/style.css
@@ -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 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);
+}
diff --git a/modules/blox/layouts/_partials/blox/preact-wrapper.html b/modules/blox/layouts/_partials/blox/preact-wrapper.html
index b27df14a..91deb761 100644
--- a/modules/blox/layouts/_partials/blox/preact-wrapper.html
+++ b/modules/blox/layouts/_partials/blox/preact-wrapper.html
@@ -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 }}
diff --git a/modules/blox/layouts/_partials/functions/resolve_gallery_items.html b/modules/blox/layouts/_partials/functions/resolve_gallery_items.html
new file mode 100644
index 00000000..b99a9370
--- /dev/null
+++ b/modules/blox/layouts/_partials/functions/resolve_gallery_items.html
@@ -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
)
+ 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 }}
diff --git a/test/assets/media/albums/gallery/01-workspace.jpg b/test/assets/media/albums/gallery/01-workspace.jpg
new file mode 100644
index 00000000..b46b3ee4
Binary files /dev/null and b/test/assets/media/albums/gallery/01-workspace.jpg differ
diff --git a/test/assets/media/albums/gallery/02-forest.jpg b/test/assets/media/albums/gallery/02-forest.jpg
new file mode 100644
index 00000000..7ef143c9
Binary files /dev/null and b/test/assets/media/albums/gallery/02-forest.jpg differ
diff --git a/test/assets/media/albums/gallery/03-boat.jpg b/test/assets/media/albums/gallery/03-boat.jpg
new file mode 100644
index 00000000..a8af0f66
Binary files /dev/null and b/test/assets/media/albums/gallery/03-boat.jpg differ
diff --git a/test/assets/media/albums/gallery/04-cabin.jpg b/test/assets/media/albums/gallery/04-cabin.jpg
new file mode 100644
index 00000000..53b5da26
Binary files /dev/null and b/test/assets/media/albums/gallery/04-cabin.jpg differ
diff --git a/test/assets/media/albums/gallery/05-castle.jpg b/test/assets/media/albums/gallery/05-castle.jpg
new file mode 100644
index 00000000..784543a0
Binary files /dev/null and b/test/assets/media/albums/gallery/05-castle.jpg differ
diff --git a/test/assets/media/albums/gallery/06-mountain.jpg b/test/assets/media/albums/gallery/06-mountain.jpg
new file mode 100644
index 00000000..0c023143
Binary files /dev/null and b/test/assets/media/albums/gallery/06-mountain.jpg differ
diff --git a/test/assets/media/albums/gallery/07-architecture.jpg b/test/assets/media/albums/gallery/07-architecture.jpg
new file mode 100644
index 00000000..71674fd9
Binary files /dev/null and b/test/assets/media/albums/gallery/07-architecture.jpg differ
diff --git a/test/assets/media/albums/gallery/08-portrait.jpg b/test/assets/media/albums/gallery/08-portrait.jpg
new file mode 100644
index 00000000..081eb947
Binary files /dev/null and b/test/assets/media/albums/gallery/08-portrait.jpg differ
diff --git a/test/assets/media/albums/gallery/09-shoreline.jpg b/test/assets/media/albums/gallery/09-shoreline.jpg
new file mode 100644
index 00000000..555cd189
Binary files /dev/null and b/test/assets/media/albums/gallery/09-shoreline.jpg differ
diff --git a/test/config.yaml b/test/config.yaml
index 41fdf586..5c666aad 100644
--- a/test/config.yaml
+++ b/test/config.yaml
@@ -1,4 +1,4 @@
-title: Hugo Blox Test
+title: HugoBlox Test
baseURL: https://example.com
build:
buildStats:
@@ -14,11 +14,3 @@ markup:
_merge: deep
security:
_merge: deep
-params:
- header:
- navbar:
- enable: false
- extensions:
- cms:
- local_backend: true
- publish_mode:
diff --git a/test/data/pages/home.yaml b/test/data/pages/home.yaml
index 0c4a641f..2b9c0538 100644
--- a/test/data/pages/home.yaml
+++ b/test/data/pages/home.yaml
@@ -6,12 +6,12 @@ date: 2023-11-01
sections:
- block: hero
content:
- title: "**Build Anything** with Hugo Blox"
+ title: "**Build Anything** with HugoBlox"
text: |
- Create stunning websites with our Preact-powered components.
+ Create stunning websites with Hugo-powered components.
Fast, flexible, and future-proof.
announcement:
- text: "🎉 Introducing Preact Components"
+ text: "🎉 Introducing New Components"
link:
text: "Learn more"
url: "/linked"
@@ -90,10 +90,53 @@ sections:
height: lg
style: light
+ - block: gallery
+ content:
+ title: "Lab tour"
+ subtitle: "A curated walkthrough — explicit items with captions and credits."
+ items:
+ - src: "media/albums/gallery/01-workspace.jpg"
+ caption: "The **main workspace** where most of our research happens"
+ credit: "Photo: [Picsum](https://picsum.photos)"
+ alt: "Open-plan workspace with laptops"
+ - src: "media/albums/gallery/05-castle.jpg"
+ title: "Annual retreat"
+ caption: "*Edinburgh, 2024* — three days of deep work and walks"
+ credit: "Photo: [Picsum](https://picsum.photos)"
+ - src: "media/albums/gallery/03-boat.jpg"
+ caption: "Field trip — coastal data collection on the `Aurora-II`"
+ credit: "Photo: [Picsum](https://picsum.photos)"
+ link: "/research/aurora-ii/"
+ - src: "media/albums/gallery/07-architecture.jpg"
+ caption: "The new wing opens [in spring](/news/new-wing/)"
+ credit: "Photo: [Picsum](https://picsum.photos)"
+ design:
+ layout: grid
+ columns: 4
+ gap: md
+ aspect_ratio: square
+ caption_position: below
+ hover_zoom: true
+ lightbox: true
+
+ - block: gallery
+ content:
+ title: "Recent expeditions"
+ subtitle: "Auto-loaded from `assets/media/albums/gallery/` — captions stay empty when not provided (Slice 1 behaviour)."
+ album: "media/albums/gallery"
+ sort: asc
+ design:
+ layout: masonry
+ columns: 3
+ gap: md
+ caption_position: overlay
+ hover_zoom: true
+ lightbox: true
+
- block: cta-card
content:
title: "Ready to get started?"
- text: "Join 250,000+ users building with Hugo Blox"
+ text: "Join 150,000+ users building with HugoBlox"
button:
text: "Start Building"
url: "https://hugoblox.com"