Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Sources/Saga/Saga+AtomFeed.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ public extension Saga {
/// - title: The title of the feed, usually your site title. Example: Loopwerk.io.
/// - author: The author of the articles.
/// - baseURL: The base URL of your website, for example https://www.loopwerk.io.
/// - itemTitle: An optional function which takes an `Item` and returns the title to use for its entry. Defaults to the item's `title`. Useful when feed titles should differ from the parsed title, for example when applying an SEO title policy or stripping Markdown emphasis markers.
/// - summary: An optional function which takes an `Item` and returns its summary.
/// - image: An optional function which takes an `Item` and returns its image URL (absolute or relative to baseURL).
/// - dateKeyPath: A keypath to the date property to use for the <updated> field. Defaults to `\.lastModified`.
/// - Returns: A function which takes a rendering context, and returns a string.
@preconcurrency
static func atomFeed<Context: AtomContext>(title: String, author: String? = nil, baseURL: URL, summary: (@Sendable (Item<Context.M>) -> String?)? = nil, image: (@Sendable (Item<Context.M>) -> String?)? = nil, dateKeyPath: KeyPath<Item<Context.M>, Date> = \.lastModified) -> (@Sendable (_ context: Context) -> String) {
static func atomFeed<Context: AtomContext>(title: String, author: String? = nil, baseURL: URL, itemTitle: (@Sendable (Item<Context.M>) -> String)? = nil, summary: (@Sendable (Item<Context.M>) -> String?)? = nil, image: (@Sendable (Item<Context.M>) -> String?)? = nil, dateKeyPath: KeyPath<Item<Context.M>, Date> = \.lastModified) -> (@Sendable (_ context: Context) -> String) {
nonisolated(unsafe) let RFC3339_DF = ISO8601DateFormatter()
nonisolated(unsafe) let dateKeyPath = dateKeyPath

Expand Down Expand Up @@ -69,7 +70,7 @@ public extension Saga {
idElement.stringValue = baseURL.appendingPathComponent(item.url).absoluteString

entryElement.addChild(idElement)
entryElement.addChild(XMLElement(name: "title", stringValue: item.title))
entryElement.addChild(XMLElement(name: "title", stringValue: itemTitle?(item) ?? item.title))
entryElement.addChild(XMLElement(name: "updated", stringValue: RFC3339_DF.string(from: item[keyPath: dateKeyPath])))

if let summary, let summaryString = summary(item) {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Saga/Saga.docc/Guides/CustomFeedFormats.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Build your own feed renderer, using JSON Feed as an example.

## Overview

Saga ships with a built-in ``Saga/atomFeed(title:author:baseURL:summary:image:dateKeyPath:)`` renderer, but you can create renderers for any feed format. This guide walks you through building a [JSON Feed](https://www.jsonfeed.org) renderer to show the pattern.
Saga ships with a built-in ``Saga/atomFeed(title:author:baseURL:itemTitle:summary:image:dateKeyPath:)`` renderer, but you can create renderers for any feed format. This guide walks you through building a [JSON Feed](https://www.jsonfeed.org) renderer to show the pattern.

A renderer is a function 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.

Expand Down
30 changes: 30 additions & 0 deletions Tests/SagaTests/SagaTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ struct TaggedMetadata: Metadata {
let tags: [String]
}

struct FeedContext: AtomContext {
let items: [Item<EmptyMetadata>]
let outputPath: Path
}

struct WrittenPage: Equatable {
let destination: Path
let content: String
Expand Down Expand Up @@ -905,4 +910,29 @@ final class SagaTests: XCTestCase, @unchecked Sendable {
XCTAssertEqual(artList?.content, "art:1")
XCTAssertEqual(articlesList?.content, "articles:1")
}

func testAtomFeedEntryTitlesUseItemTitleOverride() throws {
let context = FeedContext(
items: [
Item(title: "A _great_ title", body: "<p>body</p>", date: Date(timeIntervalSince1970: 1_704_106_800), metadata: EmptyMetadata()),
],
outputPath: "feed.xml"
)

// Default behavior is unchanged: the entry title is the item's title.
let defaultFeed = Saga.atomFeed(
title: "Test Site",
baseURL: URL(string: "https://example.com")!
)(context)
XCTAssertTrue(defaultFeed.contains("<title>A _great_ title</title>"))

// itemTitle lets a site apply its own title policy per entry.
let customFeed = Saga.atomFeed(
title: "Test Site",
baseURL: URL(string: "https://example.com")!,
itemTitle: { $0.title.replacingOccurrences(of: "_", with: "") }
)(context)
XCTAssertTrue(customFeed.contains("<title>A great title</title>"))
XCTAssertTrue(customFeed.contains("<title>Test Site</title>"), "The feed-level title is unaffected.")
}
}
Loading