Custom Feed Formats
Build your own feed renderer, using JSON Feed as an example.
Overview
Saga ships with a built-in atomFeed(title:author:baseURL:summary:image:dateKeyPath:) renderer, but you can create renderers for any feed format. This guide walks through building a JSON Feed renderer to show the pattern.
A feed renderer is a static method on Saga that returns a @Sendable (Context) -> String closure — the same signature used by the built-in Atom renderer. The closure receives a rendering context with the items and output path, and returns the feed content as a string.
Defining the feed structure
JSON Feed’s structure maps naturally to Swift’s Codable. Define structs for the feed and its entries:
struct JSONFeedOutput: Codable {
let version: String
let title: String
let homePageUrl: String
let feedUrl: String
let authors: [JSONFeedAuthor]?
let items: [JSONFeedItem]
}
struct JSONFeedAuthor: Codable {
let name: String
}
struct JSONFeedItem: Codable {
let id: String
let url: String
let title: String
let contentHtml: String
let datePublished: String
let authors: [JSONFeedAuthor]?
}
Using JSONEncoder with .convertToSnakeCase, these property names automatically become the snake_case keys that the JSON Feed spec requires (homePageUrl → home_page_url, contentHtml → content_html, etc).
Writing the renderer
The renderer is an extension on Saga that uses the existing AtomContext protocol — it provides items and outputPath, which is everything a feed needs:
import Foundation
import Saga
extension Saga {
static func jsonFeed<Context: AtomContext, M>(
title: String,
baseURL: URL,
author: String? = nil
) -> @Sendable (Context) -> String where Context.M == M {
return { context in
let dateFormatter = ISO8601DateFormatter()
let feedAuthor = author.map { [JSONFeedAuthor(name: $0)] }
let feed = JSONFeedOutput(
version: "https://jsonfeed.org/version/1.1",
title: title,
homePageUrl: baseURL.absoluteString,
feedUrl: baseURL.appendingPathComponent(context.outputPath.string).absoluteString,
authors: feedAuthor,
items: context.items.map { item in
JSONFeedItem(
id: baseURL.appendingPathComponent(item.url).absoluteString,
url: baseURL.appendingPathComponent(item.url).absoluteString,
title: item.title,
contentHtml: item.body,
datePublished: dateFormatter.string(from: item.date),
authors: feedAuthor
)
}
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.keyEncodingStrategy = .convertToSnakeCase
let data = try! encoder.encode(feed)
return String(data: data, encoding: .utf8) ?? ""
}
}
}
Using the renderer
Wire it into your pipeline with a listWriter, just like the Atom feed:
.register(
folder: "articles",
metadata: ArticleMetadata.self,
readers: [.parsleyMarkdownReader],
writers: [
.itemWriter(swim(renderArticle)),
.listWriter(swim(renderArticles)),
// Atom feed
.listWriter(Saga.atomFeed(
title: "My Site",
author: "Author Name",
baseURL: siteURL
), output: "feed.xml"),
// JSON Feed
.listWriter(Saga.jsonFeed(
title: "My Site",
baseURL: siteURL,
author: "Author Name"
), output: "feed.json"),
]
)
Applying the pattern to other formats
The same approach works for any feed format — RSS 2.0, microformats2 h-feed, or anything else:
- Define your output structure (structs, XML builder, or plain string interpolation)
- Write a static method on
Sagathat returns@Sendable (Context) -> String - Use
AtomContext(or another rendering context) to access items - Wire it up with
listWriterortagWriter
The key insight is that a “renderer” in Saga is just a function from context to string. There’s no special protocol to adopt or plugin system to hook into.