Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
/*.xcodeproj
xcuserdata/
.swift-format
Package.resolved
8 changes: 6 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "FeedKit",
platforms: [
.macOS(.v12),
.macOS(.v13),
.iOS(.v15),
.watchOS(.v8),
.tvOS(.v15),
Expand Down Expand Up @@ -64,7 +64,11 @@ let package = Package(
.process("Resources/xml/Syndication.xml"),
.process("Resources/xml/iTunes.xml"),
.process("Resources/xml/YouTube.xml"),
.process("Resources/xml/GeoRSSSimple.xml")
.process("Resources/xml/GeoRSSSimple.xml"),
.process("Resources/xml/Podcast.xml"),
.process("Resources/xml/SourceMarkdown.xml"),
.process("Resources/xml/NetNewsWire.xml"),
.process("Resources/xml/CommentAPI.xml")
]
)
]
Expand Down
25 changes: 22 additions & 3 deletions Sources/FeedKit/FeedInitializable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,20 +124,39 @@ public extension FeedInitializable {
/// - Parameter data: The feed content as raw data.
/// - Throws: An error if parsing or decoding fails.
init(data: Data) throws {
self = try Self.decode(data: data)
try self.init(data: data, namespaceHandling: .lenient)
}

/// Initializes from data, controlling how elements using a conventional
/// namespace prefix (e.g. `content:`, `dc:`) that was never declared via
/// an `xmlns`/`xmlns:*` attribute are treated.
/// - Parameters:
/// - data: The feed content as raw data.
/// - namespaceHandling: `.lenient` (the default via `init(data:)`)
/// tolerates undeclared conventional prefixes, matching real-world
/// feeds. `.strict` requires a matching `xmlns` declaration and
/// otherwise treats the element as absent.
/// - Throws: An error if parsing or decoding fails.
init(data: Data, namespaceHandling: XMLNamespaceHandling) throws {
self = try Self.decode(data: data, namespaceHandling: namespaceHandling)
}
}

// MARK: - Private

extension FeedInitializable {
/// Helper method for decoding data into a model.
/// - Parameter data: The raw feed data.
/// - Parameters:
/// - data: The raw feed data.
/// - namespaceHandling: How to treat undeclared conventional namespace
/// prefixes.
/// - Returns: A parsed feed model conforming to `FeedInitializable`.
private static func decode(data: Data) throws -> Self {
private static func decode(data: Data, namespaceHandling: XMLNamespaceHandling) throws -> Self {
let decoder: XMLDecoder = .init()
let formatter: FeedDateFormatter = .init(spec: .permissive)
decoder.dateDecodingStrategy = .formatter(formatter)
decoder.namespaceMap = FeedNamespace.namespaceMap
decoder.namespaceHandling = namespaceHandling
return try decoder.decode(Self.self, from: data)
}
}
64 changes: 50 additions & 14 deletions Sources/FeedKit/FeedNamespace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import Foundation
/// Each case corresponds to a specific namespace that can be used in feed parsing
/// and handling. These namespaces provide additional information and functionality
/// for feeds beyond the core elements.
enum FeedNamespace: CaseIterable {
enum FeedNamespace: CaseIterable, Equatable {
/// Represents the Dublin Core metadata terms used for describing
/// resources in a standardized way.
case dublinCore
Expand Down Expand Up @@ -63,37 +63,48 @@ enum FeedNamespace: CaseIterable {
/// Represents the source namespace, used for Source-specific metadata
/// like markdown content.
case source
/// Represents the Well-Formed Web Comment API namespace, used for
/// comment-related URLs on an item.
case commentAPI

// MARK: Internal

/// The namespace prefix.
/// The bare namespace prefix, as used in element names (e.g. `"dc"`).
var prefix: String {
switch self {
case .dublinCore:
"xmlns:dc"
"dc"
case .itunes:
"xmlns:itunes"
"itunes"
case .syndication:
"xmlns:sy"
"sy"
case .media:
"xmlns:media"
"media"
case .content:
"xmlns:content"
"content"
case .georss:
"xmlns:georss"
"georss"
case .gml:
"xmlns:gml"
"gml"
case .youTube:
"xmlns:yt"
"yt"
case .atom:
"xmlns:atom"
"atom"
case .podcast:
"xmlns:podcast"
"podcast"
case .source:
"xmlns:source"
"source"
case .commentAPI:
"wfw"
}
}

/// The `xmlns:*` attribute name used to declare this namespace on a root
/// element (e.g. `"xmlns:dc"`).
var attributeName: String {
"xmlns:\(prefix)"
}

/// The URL associated with the namespace.
var url: String {
switch self {
Expand All @@ -119,7 +130,24 @@ enum FeedNamespace: CaseIterable {
"https://podcastindex.org/namespace/1.0"
case .source:
"http://source.scripting.com/"
case .commentAPI:
"http://wellformedweb.org/CommentAPI/"
}
}

/// Looks up the namespace whose URL matches the given value.
/// - Parameter url: The namespace URL to look up.
init?(url: String) {
guard let match = Self.allCases.first(where: { $0.url == url }) else {
return nil
}
self = match
}

/// A map of namespace URL to canonical prefix for every known namespace,
/// suitable for `XMLDecoder.namespaceMap`.
static var namespaceMap: [String: String] {
Dictionary(uniqueKeysWithValues: allCases.map { ($0.url, $0.prefix) })
}
}

Expand Down Expand Up @@ -166,6 +194,9 @@ extension FeedNamespace {

case .source:
feed.channel?.items?.contains(where: { $0.markdown != nil }) ?? false

case .commentAPI:
feed.channel?.items?.contains(where: { $0.commentAPI != nil }) ?? false
}
}

Expand All @@ -178,7 +209,12 @@ extension FeedNamespace {
feed.entries?.contains(where: { $0.youTube != nil }) ?? false
case .georss:
feed.entries?.contains(where: { $0.geoRSS != nil }) ?? false
default:
case .dublinCore:
feed.dublinCore != nil ||
feed.entries?.contains(where: { $0.dublinCore != nil }) ?? false
case .media:
feed.entries?.contains(where: { $0.media != nil }) ?? false
case .itunes, .syndication, .content, .gml, .atom, .podcast, .source, .commentAPI:
false
}
}
Expand Down
30 changes: 30 additions & 0 deletions Sources/FeedKit/Feeds/Atom/AtomFeed.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
// SOFTWARE.

import Foundation
import XMLKit

/// Data model for the XML DOM of the Atom Specification
/// See https://tools.ietf.org/html/rfc4287
Expand Down Expand Up @@ -275,3 +276,32 @@ extension AtomFeed: Codable {
// MARK: - FeedInitializable

extension AtomFeed: FeedInitializable {}

// MARK: - XMLDocumentConvertible

extension AtomFeed: XMLDocumentConvertible {
public func toXmlDocument() throws -> XMLKit.XMLDocument {
let encoder: XMLEncoder = .init()
encoder.dateEncodingStrategy = .formatter(FeedDateFormatter(spec: .rfc3339))

let document = try encoder.encode(value: self)
document.setRootName(name: "feed")
document.setRootAttribute(name: "xmlns", value: FeedNamespace.atom.url)

for namespace in FeedNamespace.allCases {
if namespace.shouldInclude(in: self) {
document.setRootAttribute(name: namespace.attributeName, value: namespace.url)
}
}

return document
}
}

// MARK: - XMLStringConvertible

extension AtomFeed: XMLStringConvertible {
public func toXMLString(formatted _: Bool, indentationLevel _: Int = 1) throws -> String {
try toXmlDocument().toXMLString(formatted: true)
}
}
2 changes: 1 addition & 1 deletion Sources/FeedKit/Feeds/RSS/RSSFeed.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ extension RSSFeed: XMLDocumentConvertible {

for namespace in FeedNamespace.allCases {
if namespace.shouldInclude(in: self) {
document.setRootAttribute(name: namespace.prefix, value: namespace.url)
document.setRootAttribute(name: namespace.attributeName, value: namespace.url)
}
}

Expand Down
13 changes: 12 additions & 1 deletion Sources/FeedKit/Feeds/RSS/RSSFeedItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ public struct RSSFeedItem {
iTunes: ITunes? = nil,
media: Media? = nil,
podcast: Podcast? = nil,
geoRSS: GeoRSSSimple? = nil
geoRSS: GeoRSSSimple? = nil,
commentAPI: CommentAPI? = nil
) {
self.title = title
self.link = link
Expand All @@ -70,6 +71,7 @@ public struct RSSFeedItem {
self.media = media
self.podcast = podcast
self.geoRSS = geoRSS
self.commentAPI = commentAPI
}

// MARK: Public
Expand Down Expand Up @@ -240,6 +242,12 @@ public struct RSSFeedItem {
public var podcast: Podcast?

public var geoRSS: GeoRSSSimple?

/// The Well-Formed Web Comment API module, commonly used by WordPress and
/// other blogging platforms to associate comment-related URLs with the
/// item.
/// See http://wellformedweb.org/CommentAPI/
public var commentAPI: CommentAPI?
}

// MARK: - Sendable
Expand Down Expand Up @@ -275,6 +283,7 @@ extension RSSFeedItem: Codable {
case media
case podcast
case geoRSS = "georss"
case commentAPI = "wfw"
}

public init(from decoder: any Decoder) throws {
Expand All @@ -297,6 +306,7 @@ extension RSSFeedItem: Codable {
media = try container.decodeIfPresent(Media.self, forKey: CodingKeys.media)
podcast = try container.decodeIfPresent(Podcast.self, forKey: CodingKeys.podcast)
geoRSS = try container.decodeIfPresent(GeoRSSSimple.self, forKey: CodingKeys.geoRSS)
commentAPI = try container.decodeIfPresent(CommentAPI.self, forKey: CodingKeys.commentAPI)
}

public func encode(to encoder: any Encoder) throws {
Expand All @@ -319,5 +329,6 @@ extension RSSFeedItem: Codable {
try container.encodeIfPresent(media, forKey: CodingKeys.media)
try container.encodeIfPresent(podcast, forKey: CodingKeys.podcast)
try container.encodeIfPresent(geoRSS, forKey: CodingKeys.geoRSS)
try container.encodeIfPresent(commentAPI, forKey: CodingKeys.commentAPI)
}
}
92 changes: 92 additions & 0 deletions Sources/FeedKit/Namespaces/CommentAPI/CommentAPI.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//
// CommentAPI.swift
//
// Copyright (c) 2016 - 2026 Nuno Dias
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import Foundation
import XMLKit

/// The Well-Formed Web Comment API module, commonly used by WordPress and
/// other blogging platforms to associate comment-related URLs with a feed
/// item.
/// See http://wellformedweb.org/CommentAPI/
public struct CommentAPI {
// MARK: Lifecycle

public init(comment: String? = nil, commentRss: String? = nil) {
self.comment = comment
self.commentRss = commentRss
}

// MARK: Public

/// The URL to use for posting a comment on the item via the CommentAPI
/// protocol.
///
/// Example:
/// <wfw:comment>http://example.com/wp-comments-post.php?p=1</wfw:comment>
public var comment: String?

/// The URL of the RSS feed of comments for the item.
///
/// Example:
/// <wfw:commentRss>http://example.com/2024/01/01/hello-world/feed/</wfw:commentRss>
public var commentRss: String?
}

// MARK: - XMLNamespaceCodable

extension CommentAPI: XMLNamespaceCodable {}

// MARK: - Sendable

extension CommentAPI: Sendable {}

// MARK: - Equatable

extension CommentAPI: Equatable {}

// MARK: - Hashable

extension CommentAPI: Hashable {}

// MARK: - Codable

extension CommentAPI: Codable {
private enum CodingKeys: String, CodingKey {
case comment = "wfw:comment"
case commentRss = "wfw:commentRss"
}

public init(from decoder: any Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)

comment = try container.decodeIfPresent(String.self, forKey: CodingKeys.comment)
commentRss = try container.decodeIfPresent(String.self, forKey: CodingKeys.commentRss)
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)

try container.encodeIfPresent(comment, forKey: CodingKeys.comment)
try container.encodeIfPresent(commentRss, forKey: CodingKeys.commentRss)
}
}
Loading
Loading