diff --git a/Sources/Saga/Saga+AtomFeed.swift b/Sources/Saga/Saga+AtomFeed.swift index 4a1f742..25e94e6 100644 --- a/Sources/Saga/Saga+AtomFeed.swift +++ b/Sources/Saga/Saga+AtomFeed.swift @@ -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 field. Defaults to `\.lastModified`. /// - Returns: A function which takes a rendering context, and returns a string. @preconcurrency - static func atomFeed(title: String, author: String? = nil, baseURL: URL, summary: (@Sendable (Item) -> String?)? = nil, image: (@Sendable (Item) -> String?)? = nil, dateKeyPath: KeyPath, Date> = \.lastModified) -> (@Sendable (_ context: Context) -> String) { + static func atomFeed(title: String, author: String? = nil, baseURL: URL, itemTitle: (@Sendable (Item) -> String)? = nil, summary: (@Sendable (Item) -> String?)? = nil, image: (@Sendable (Item) -> String?)? = nil, dateKeyPath: KeyPath, Date> = \.lastModified) -> (@Sendable (_ context: Context) -> String) { nonisolated(unsafe) let RFC3339_DF = ISO8601DateFormatter() nonisolated(unsafe) let dateKeyPath = dateKeyPath @@ -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) { diff --git a/Sources/Saga/Saga.docc/Guides/CustomFeedFormats.md b/Sources/Saga/Saga.docc/Guides/CustomFeedFormats.md index 02b6f6d..d2dba9c 100644 --- a/Sources/Saga/Saga.docc/Guides/CustomFeedFormats.md +++ b/Sources/Saga/Saga.docc/Guides/CustomFeedFormats.md @@ -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. diff --git a/Tests/SagaTests/SagaTests.swift b/Tests/SagaTests/SagaTests.swift index 9169482..c830dda 100644 --- a/Tests/SagaTests/SagaTests.swift +++ b/Tests/SagaTests/SagaTests.swift @@ -48,6 +48,11 @@ struct TaggedMetadata: Metadata { let tags: [String] } +struct FeedContext: AtomContext { + let items: [Item] + let outputPath: Path +} + struct WrittenPage: Equatable { let destination: Path let content: String @@ -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: "

body

", 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("A _great_ 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("A great title")) + XCTAssertTrue(customFeed.contains("Test Site"), "The feed-level title is unaffected.") + } }