Topic

Advanced Usage

Tips and techniques for more complex Saga setups.

Item processors

Use an itemProcessor to modify items after they are read but before they are written. This is useful for transforming titles, adjusting dates, setting metadata, or any per-item logic.

func addExclamationToTitle(item: Item<EmptyMetadata>) async {
  // Do whatever you want with the Item - you can even use async functions and await them!
  item.title.append("!")
}

try await Saga(input: "content", output: "deploy")
  .register(
    readers: [.parsleyMarkdownReader],
    itemProcessor: addExclamationToTitle,
    writers: [.itemWriter(swim(renderItem))]
  )
  .run()

Template-driven pages

Create pages that are purely template-driven — no markdown file or Item needed.

Overview

Not every page on a website corresponds to a content file. Homepages, search pages, and 404 pages are often driven entirely by a template, sometimes pulling in items from other sections of the site. The createPage(_:using:) method lets you render these pages without needing a markdown file or Item.

Basic usage

Use createPage(_:using:) to render a template to a specific output path:

try await Saga(input: "content", output: "deploy")
  .register(
    folder: "articles",
    metadata: ArticleMetadata.self,
    readers: [.parsleyMarkdownReader],
    writers: [
      .itemWriter(swim(renderArticle)),
      .listWriter(swim(renderArticles)),
    ]
  )
  .createPage("index.html", using: swim(renderHome))
  .createPage("404.html", using: swim(render404))
  .run()

The renderer receives a PageRenderingContext with access to allItems (all items across all processing steps) and outputPath.

When to use createPage vs. register

Use createPage(_:using:) when:

  • The page has no corresponding content file (no markdown, no frontmatter)
  • The page is purely template-driven, possibly pulling in items from other steps
  • You want to render a page like a homepage, search page, sitemap, or 404 page

Use register when:

  • Content comes from files on disk or a programmatic data source
  • You need the full Item pipeline (readers, processors, filters, writers)

Custom processing steps

Register custom logic as part of Saga’s pipeline, running alongside the built-in steps.

Write-only steps

The most common use case is running custom code during the write phase, after all items have been read and sorted. Use register with a trailing closure:

try await Saga(input: "content", output: "deploy")
  .register(
    folder: "articles",
    metadata: ArticleMetadata.self,
    readers: [.parsleyMarkdownReader],
    writers: [.itemWriter(swim(renderArticle))]
  )
  .register { saga in
    let articles = saga.allItems.compactMap { $0 as? Item<ArticleMetadata> }
    for article in articles {
      let destination = (saga.outputPath + article.relativeDestination.parent()).string + ".png"
      // generate an image and write it to `destination`
    }
  }
  .run()

The closure receives the Saga instance with access to allItems, outputPath, and everything else you need.

Read and write steps

If your custom step needs to run code during both the read phase and the write phase, provide both closures:

.register(
  read: { saga in
    // runs during the read phase, before items are sorted
  },
  write: { saga in
    // runs during the write phase, after all readers have finished
  }
)

tip You can check the source of loopwerk.io for more inspiration.

Programmatic Items

Create items from APIs, databases, or any async data source — without files on disk.

Overview

Saga’s pipeline is traditionally file-driven: Readers parse files into Item instances. But sometimes your content doesn’t live on disk. You might want to pull data from a REST API, a database, or generate items in code.

The register(fetch:writers:) method lets you do exactly that. It takes an async closure that returns an array of items, and feeds them into the same writer pipeline as file-based items.

Creating items

Use the convenience initializer on Item to create items programmatically:

let item = Item(
  title: "My Article",
  body: "<p>Hello world</p>",
  date: Date(),
  metadata: EmptyMetadata()
)

By default, the item’s output path is derived from the slugified title: my-article/index.html. You can set a custom output path using the relativeDestination parameter:

import SagaPathKit

let item = Item(
  title: "My Article",
  body: "<p>Hello world</p>",
  date: Date(),
  relativeDestination: Path("blog/my-article/index.html"),
  metadata: EmptyMetadata()
)

The relativeDestination controls both where the file is written and what url returns, so set it to wherever you want the item to live in your site.

Fetching from an API

Here’s a complete example that fetches music videos from the iTunes API:

import Foundation
import SagaPathKit
import Saga

struct MusicVideoMetadata: Metadata {
  let artworkUrl: String
  let previewUrl: String
}

struct ITunesResponse: Decodable {
  let results: [ITunesResult]
}

struct ITunesResult: Decodable {
  let trackCensoredName: String
  let artworkUrl100: String
  let previewUrl: String
  let releaseDate: String
}

func fetchVideos() async throws -> [Item<MusicVideoMetadata>] {
  let url = URL(string: "https://itunes.apple.com/search?term=the+beatles&media=musicVideo")!
  let (data, _) = try await URLSession.shared.data(from: url)
  let response = try JSONDecoder().decode(ITunesResponse.self, from: data)
  let dateFormatter = ISO8601DateFormatter()

  return response.results.map { result in
    let date = dateFormatter.date(from: result.releaseDate) ?? Date()
    let slug = result.trackCensoredName.slugified
    return Item(
      title: result.trackCensoredName,
      date: date,
      relativeDestination: Path("videos/\(slug)/index.html"),
      metadata: MusicVideoMetadata(
        artworkUrl: result.artworkUrl100,
        previewUrl: result.previewUrl
      )
    )
  }
}

Registering the fetch step

Use register(fetch:writers:) just like you would a file-based register call:

try await Saga(input: "content", output: "deploy")
  .register(
    metadata: MusicVideoMetadata.self,
    fetch: fetchVideos,
    writers: [
      .itemWriter(swim(renderVideo)),
      .listWriter(swim(renderVideoList), output: "videos/index.html"),
    ]
  )
  .run()

You can freely mix file-based and fetch-based steps. All items — regardless of how they were created — are available via allItems and passed to every writer’s allItems parameter.

Nested subfolder processing

When you have content organized into subfolders and want each subfolder processed independently — with its own scoped items array, previous/next navigation, and writers — append /** to the folder path:

try await Saga(input: "content", output: "deploy")
  .register(
    folder: "photos/**",
    metadata: PhotoMetadata.self,
    readers: [.parsleyMarkdownReader],
    writers: [
      .listWriter(swim(renderPhotoList)),
    ]
  )
  .run()

Given a directory layout like:

content/
  photos/
    vacation/
      photo1.md
      photo2.md
    birthday/
      photo3.md
      photo4.md

Saga creates a separate processing step for photos/vacation and photos/birthday. Each step sees only its own items, so a listWriter produces one index per subfolder and previous/next links stay within the subfolder.

Without the /** suffix, folder: "photos" would treat every Markdown file under photos/ as part of a single flat collection.

Post-processing output

Apply transforms to every file Saga writes, such as HTML minification.

Overview

Use postProcess(_:) to transform every file before it’s written. Multiple calls stack.

Basic usage

try await Saga(input: "content", output: "deploy")
  .register(
    metadata: EmptyMetadata.self,
    readers: [.parsleyMarkdownReader],
    writers: [.itemWriter(swim(renderPage))]
  )
  .postProcess { html, path in
    guard !isDev else { return html }
    return minifyHTML(html)
  }
  .run()

The transform receives the rendered content and the relative output path (e.g. articles/my-post/index.html). The path lets you selectively transform only certain files:

.postProcess { content, path in
  guard !isDev, path.extension == "html" else { return content }
  return minifyHTML(content)
}

Cache-busting with hashed()

Insert content-based hashes into asset filenames for cache-busting.

Overview

The hashed(_:) function takes a path like /static/output.css and returns /static/output-a1b2c3d4.css, where the hash is derived from the file’s contents. Saga automatically copies the hashed file to the output folder.

Basic usage

Call hashed(_:) from any renderer to produce fingerprinted asset URLs:

func renderPage(context: ItemRenderingContext<EmptyMetadata>) -> Node {
  html {
    head {
      link(href: hashed("/static/style.css"), rel: "stylesheet")
    }
    body {
      Node.raw(context.item.body)
    }
  }
}

note In dev mode (when using saga dev), hashed(_:) returns the path unchanged to keep filenames stable for auto-reload. See Getting Started with Saga for more on dev mode.