Building Photo Galleries
Use nested processing steps to create photo galleries with per-album navigation.
Overview
Photo galleries typically have a two-level structure: albums containing photos. Saga’s nested: parameter creates a separate processing scope per subfolder, giving each album its own items array and previous/next navigation.
Simple gallery (images only)
When albums are just folders of images with no metadata file:
content/
photos/
vacation/
beach.jpg
sunset.jpg
birthday/
cake.jpg
group.jpg
Then set up your nested Saga pipeline like so:
struct PhotoMetadata: Metadata {}
try await Saga(input: "content", output: "deploy")
.register(
folder: "photos",
writers: [
.listWriter(swim(renderAlbums)),
],
nested: { nested in
nested.register(
metadata: PhotoMetadata.self,
readers: [.imageReader],
writers: [
.listWriter(swim(renderAlbum)),
.itemWriter(swim(renderPhoto)),
]
)
}
)
.run()
In the outer renderAlbums template, context.items is the list of albums. For each album you can use children(as:) to access the nested items:
func renderAlbums(context: ItemsRenderingContext<EmptyMetadata>) -> Node {
context.items.map { album in
let photos = album.children(as: PhotoMetadata.self)
return a(href: album.url) {
h2 { album.title }
p { "\(photos.count) photos" }
}
}
}
Gallery with album metadata
For albums with an index.md containing metadata, use different readers for the outer and nested registrations:
content/
photos/
vacation/
index.md
beach.jpg
sunset.jpg
birthday/
index.md
cake.jpg
group.jpg
Each index.md contains album-level frontmatter:
---
date: 2024-06-15
---
# Summer Vacation
Photos from our trip to the coast.
Your pipeline looks like this:
struct AlbumMetadata: Metadata {
// Add fields as needed, e.g. coverImage, location
}
try await Saga(input: "content", output: "deploy")
.register(
folder: "photos",
metadata: AlbumMetadata.self,
readers: [.parsleyMarkdownReader],
writers: [
.listWriter(swim(renderAlbums)),
.itemWriter(swim(renderAlbum)),
],
nested: { nested in
nested.register(
metadata: PhotoMetadata.self,
readers: [.imageReader],
writers: [
.itemWriter(swim(renderPhoto)),
]
)
}
)
.run()
Parent/child relationships are wired automatically. Access them with typed accessors:
func renderAlbum(context: ItemRenderingContext<AlbumMetadata>) -> Node {
let photos = context.item.children(as: PhotoMetadata.self)
return baseLayout(title: context.item.title) {
h1 { context.item.title }
div(class: "photo-grid") {
photos.map { photo in
a(href: photo.url) {
img(alt: photo.title, loading: "lazy", src: photo.relativeSource.lastComponent)
}
}
}
}
}
Photo detail pages with navigation
Each photo page gets previous/next links scoped within its album, and can navigate back to its parent:
func renderPhoto(context: ItemRenderingContext<PhotoMetadata>) -> Node {
let album = context.item.parent(as: AlbumMetadata.self)
return baseLayout(title: context.item.title) {
div(class: "photo-nav") {
if let previous = context.previous {
a(href: previous.url) { "Previous" }
}
a(href: album.url) { "Back to album" }
if let next = context.next {
a(href: next.url) { "Next" }
}
}
img(alt: context.item.title, src: context.item.relativeSource.lastComponent)
}
}
Tip Check the Example project for a complete, runnable version of this pattern.