diff --git a/.gitignore b/.gitignore
index 2e0e537..b0d37a7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@
/*.xcodeproj
xcuserdata/
.swift-format
+Package.resolved
diff --git a/Package.swift b/Package.swift
index c69c20b..99a02aa 100644
--- a/Package.swift
+++ b/Package.swift
@@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "FeedKit",
platforms: [
- .macOS(.v12),
+ .macOS(.v13),
.iOS(.v15),
.watchOS(.v8),
.tvOS(.v15),
@@ -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")
]
)
]
diff --git a/Sources/FeedKit/FeedInitializable.swift b/Sources/FeedKit/FeedInitializable.swift
index 32d5ef8..3942051 100644
--- a/Sources/FeedKit/FeedInitializable.swift
+++ b/Sources/FeedKit/FeedInitializable.swift
@@ -124,7 +124,21 @@ 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)
}
}
@@ -132,12 +146,17 @@ public extension FeedInitializable {
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)
}
}
diff --git a/Sources/FeedKit/FeedNamespace.swift b/Sources/FeedKit/FeedNamespace.swift
index 2487df7..c088c5e 100644
--- a/Sources/FeedKit/FeedNamespace.swift
+++ b/Sources/FeedKit/FeedNamespace.swift
@@ -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
@@ -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 {
@@ -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) })
}
}
@@ -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
}
}
@@ -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
}
}
diff --git a/Sources/FeedKit/Feeds/Atom/AtomFeed.swift b/Sources/FeedKit/Feeds/Atom/AtomFeed.swift
index a48fb71..065f690 100644
--- a/Sources/FeedKit/Feeds/Atom/AtomFeed.swift
+++ b/Sources/FeedKit/Feeds/Atom/AtomFeed.swift
@@ -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
@@ -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)
+ }
+}
diff --git a/Sources/FeedKit/Feeds/RSS/RSSFeed.swift b/Sources/FeedKit/Feeds/RSS/RSSFeed.swift
index 768fcc3..a50c6cd 100644
--- a/Sources/FeedKit/Feeds/RSS/RSSFeed.swift
+++ b/Sources/FeedKit/Feeds/RSS/RSSFeed.swift
@@ -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)
}
}
diff --git a/Sources/FeedKit/Feeds/RSS/RSSFeedItem.swift b/Sources/FeedKit/Feeds/RSS/RSSFeedItem.swift
index acf0ae0..23c6f3f 100644
--- a/Sources/FeedKit/Feeds/RSS/RSSFeedItem.swift
+++ b/Sources/FeedKit/Feeds/RSS/RSSFeedItem.swift
@@ -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
@@ -70,6 +71,7 @@ public struct RSSFeedItem {
self.media = media
self.podcast = podcast
self.geoRSS = geoRSS
+ self.commentAPI = commentAPI
}
// MARK: Public
@@ -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
@@ -275,6 +283,7 @@ extension RSSFeedItem: Codable {
case media
case podcast
case geoRSS = "georss"
+ case commentAPI = "wfw"
}
public init(from decoder: any Decoder) throws {
@@ -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 {
@@ -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)
}
}
diff --git a/Sources/FeedKit/Namespaces/CommentAPI/CommentAPI.swift b/Sources/FeedKit/Namespaces/CommentAPI/CommentAPI.swift
new file mode 100644
index 0000000..3256f41
--- /dev/null
+++ b/Sources/FeedKit/Namespaces/CommentAPI/CommentAPI.swift
@@ -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:
+ /// http://example.com/wp-comments-post.php?p=1
+ public var comment: String?
+
+ /// The URL of the RSS feed of comments for the item.
+ ///
+ /// Example:
+ /// http://example.com/2024/01/01/hello-world/feed/
+ 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 = 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)
+ }
+}
diff --git a/Sources/XMLKit/XMLDecoder/XMLDecoder.swift b/Sources/XMLKit/XMLDecoder/XMLDecoder.swift
index 4b5a2ce..fecdfc9 100644
--- a/Sources/XMLKit/XMLDecoder/XMLDecoder.swift
+++ b/Sources/XMLKit/XMLDecoder/XMLDecoder.swift
@@ -34,6 +34,15 @@ public class XMLDecoder {
/// The strategy for decoding `Date` values from XML nodes.
public var dateDecodingStrategy: XMLDateDecodingStrategy = .deferredToDate
+ /// A map of namespace URI to canonical prefix, used to resolve elements
+ /// that use a differently-prefixed but equivalent namespace before
+ /// decoding. Leave empty to skip namespace canonicalization entirely.
+ public var namespaceMap: [String: String] = [:]
+
+ /// How to treat elements using a conventional namespace prefix that was
+ /// never declared via an `xmlns`/`xmlns:*` attribute.
+ public var namespaceHandling: XMLNamespaceHandling = .lenient
+
public func decode(_ type: T.Type, from data: Data) throws -> T {
let reader: XMLReader = .init(data: data)
let result = try reader.read().get()
@@ -42,6 +51,14 @@ public class XMLDecoder {
throw XMLError.unexpected(reason: "Unexpected parsing result. Root is nil.")
}
+ if !namespaceMap.isEmpty {
+ rootNode.canonicalizingNamespaces(
+ uriToPrefix: namespaceMap,
+ knownPrefixes: Set(namespaceMap.values),
+ handling: namespaceHandling
+ )
+ }
+
return try decode(type, from: rootNode)
}
diff --git a/Sources/XMLKit/XMLDecoder/XMLKeyedDecodingContainer.swift b/Sources/XMLKit/XMLDecoder/XMLKeyedDecodingContainer.swift
index d106f64..7cd73b6 100644
--- a/Sources/XMLKit/XMLDecoder/XMLKeyedDecodingContainer.swift
+++ b/Sources/XMLKit/XMLDecoder/XMLKeyedDecodingContainer.swift
@@ -164,6 +164,37 @@ class XMLKeyedDecodingContainer: KeyedDecodingContainerProtocol
return try decoder.decode(node: child, as: T.self)
}
+ /// Overrides the standard library's default `decodeIfPresent`, which
+ /// determines presence via `contains(_:)` alone. `contains(_:)` matches
+ /// by prefix as well as by exact name (`XMLNode.hasChild(for:)`), which is
+ /// necessary to detect namespace-group types like `DublinCore` (there is
+ /// no single wrapping `` element, just `dc:title`, `dc:creator`, etc.
+ /// as direct children). But that same prefix match would wrongly treat an
+ /// ordinary, non-namespaced key as present whenever a sibling element
+ /// merely happens to use that key's text as its own namespace prefix
+ /// (e.g. a `source:markdown` element's `source` prefix colliding with the
+ /// core, unprefixed RSS `` element). Since this override has the
+ /// concrete `T`, it can restrict prefix matching to genuine
+ /// `XMLNamespaceCodable` groups and require an exact name match for
+ /// everything else.
+ func decodeIfPresent(_ type: T.Type, forKey key: Key) throws -> T? {
+ if type is XMLNamespaceCodable.Type {
+ guard node.hasChild(for: key.stringValue) else {
+ return nil
+ }
+ } else {
+ guard node.child(for: key.stringValue) != nil else {
+ return nil
+ }
+ }
+
+ if try decodeNil(forKey: key) {
+ return nil
+ }
+
+ return try decode(type, forKey: key)
+ }
+
// MARK: -
func nestedContainer(keyedBy _: NestedKey.Type, forKey _: Key) throws -> KeyedDecodingContainer {
diff --git a/Sources/XMLKit/XMLNamespaceHandling.swift b/Sources/XMLKit/XMLNamespaceHandling.swift
new file mode 100644
index 0000000..4657705
--- /dev/null
+++ b/Sources/XMLKit/XMLNamespaceHandling.swift
@@ -0,0 +1,100 @@
+//
+// XMLNamespaceHandling.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
+
+/// Controls how elements using a namespace prefix that was never declared
+/// via an `xmlns`/`xmlns:*` attribute are treated during decoding.
+public enum XMLNamespaceHandling: Sendable {
+ /// Resolve elements by their declared namespace URI when possible. When a
+ /// prefix's namespace was never declared, fall back to matching it by its
+ /// literal prefix text, tolerating real-world feeds that use a
+ /// conventional prefix (e.g. `content:`, `dc:`) without declaring it.
+ case lenient
+
+ /// Resolve elements strictly by their declared namespace URI. Elements
+ /// using a conventional prefix without a matching `xmlns` declaration are
+ /// treated as if absent, rather than being matched by prefix text alone.
+ case strict
+}
+
+extension XMLNode {
+ /// Rewrites this node's subtree in place so elements whose namespace URI
+ /// resolves to a known namespace use that namespace's canonical prefix,
+ /// regardless of which prefix the source document actually declared it
+ /// with (e.g. `` bound to the content module URI becomes
+ /// ``, matching what `Content`'s `CodingKeys` expects).
+ ///
+ /// Only prefixed elements participate. Unprefixed elements are left
+ /// untouched, since they are either plain, non-namespaced content or
+ /// content in a document's default namespace (e.g. Atom's core elements),
+ /// neither of which should be rewritten.
+ ///
+ /// - Parameters:
+ /// - uriToPrefix: A map of namespace URI to canonical prefix.
+ /// - knownPrefixes: The canonical prefixes present in `uriToPrefix`,
+ /// used under `.strict` handling to recognize elements that look like
+ /// a known namespace but were never declared.
+ /// - handling: How to treat elements whose namespace was never declared.
+ func canonicalizingNamespaces(
+ uriToPrefix: [String: String],
+ knownPrefixes: Set,
+ handling: XMLNamespaceHandling
+ ) {
+ // Walked iteratively (rather than recursively) so arbitrarily deep or
+ // wide trees can never risk a stack overflow.
+ var pending: [XMLNode] = [self]
+ while let node = pending.popLast() {
+ if let prefix = node.prefix {
+ if let namespaceURI = node.namespaceURI, let canonicalPrefix = uriToPrefix[namespaceURI] {
+ if canonicalPrefix != prefix {
+ node.rename(toPrefix: canonicalPrefix)
+ }
+ } else if node.namespaceURI == nil, handling == .strict, knownPrefixes.contains(prefix) {
+ node.rename(toPrefix: "\(XMLNode.unresolvedPrefixMarker)\(prefix)")
+ }
+ }
+
+ if let children = node.children {
+ pending.append(contentsOf: children)
+ }
+ }
+ }
+
+ /// A prefix marker guaranteed not to match any namespaced `CodingKeys`,
+ /// used by `.strict` handling to hide elements with an undeclared,
+ /// conventionally-prefixed namespace from decoding.
+ private static let unresolvedPrefixMarker = "unresolved.namespace."
+
+ /// Rewrites `prefix` and the prefix portion of `name` to `newPrefix`.
+ private func rename(toPrefix newPrefix: String) {
+ let localName: String
+ if let colonIndex = name.firstIndex(of: ":") {
+ localName = String(name[name.index(after: colonIndex)...])
+ } else {
+ localName = name
+ }
+ prefix = newPrefix
+ name = "\(newPrefix):\(localName)"
+ }
+}
diff --git a/Sources/XMLKit/XMLNode.swift b/Sources/XMLKit/XMLNode.swift
index 146db94..6d7aad0 100644
--- a/Sources/XMLKit/XMLNode.swift
+++ b/Sources/XMLKit/XMLNode.swift
@@ -35,12 +35,14 @@ class XMLNode: Codable, Equatable, Hashable {
/// - children: Children for the node, if any.
init(
prefix: String? = nil,
+ namespaceURI: String? = nil,
name: String,
text: String? = nil,
isXhtml: Bool = false,
children: [XMLNode]? = nil
) {
self.prefix = prefix
+ self.namespaceURI = namespaceURI
self.name = name
self.text = text
self.isXhtml = isXhtml
@@ -53,6 +55,7 @@ class XMLNode: Codable, Equatable, Hashable {
let container: KeyedDecodingContainer = try decoder.container(keyedBy: XMLNode.CodingKeys.self)
prefix = try container.decodeIfPresent(String.self, forKey: XMLNode.CodingKeys.prefix)
+ namespaceURI = try container.decodeIfPresent(String.self, forKey: XMLNode.CodingKeys.namespaceURI)
name = try container.decode(String.self, forKey: XMLNode.CodingKeys.name)
text = try container.decodeIfPresent(String.self, forKey: XMLNode.CodingKeys.text)
isXhtml = try container.decode(Bool.self, forKey: XMLNode.CodingKeys.isXhtml)
@@ -67,6 +70,11 @@ class XMLNode: Codable, Equatable, Hashable {
var isXhtml: Bool = false
/// The namespace prefix
var prefix: String?
+ /// The resolved namespace URI for this node, if the prefix (or the
+ /// default namespace) was declared via an `xmlns`/`xmlns:*` attribute
+ /// in scope at this point in the document. `nil` when the element's
+ /// namespace was never declared.
+ var namespaceURI: String?
/// The name of the node.
var name: String
/// The text of the node, if present.
@@ -86,6 +94,7 @@ class XMLNode: Codable, Equatable, Hashable {
// Compare current node's properties
if
lhs.prefix != rhs.prefix ||
+ lhs.namespaceURI != rhs.namespaceURI ||
lhs.name != rhs.name ||
lhs.text != rhs.text ||
lhs.isXhtml != rhs.isXhtml
@@ -114,6 +123,7 @@ class XMLNode: Codable, Equatable, Hashable {
func hash(into hasher: inout Hasher) {
// Hash basic properties
hasher.combine(prefix)
+ hasher.combine(namespaceURI)
hasher.combine(name)
hasher.combine(text)
hasher.combine(isXhtml)
@@ -128,6 +138,7 @@ class XMLNode: Codable, Equatable, Hashable {
var container: KeyedEncodingContainer = encoder.container(keyedBy: XMLNode.CodingKeys.self)
try container.encodeIfPresent(prefix, forKey: XMLNode.CodingKeys.prefix)
+ try container.encodeIfPresent(namespaceURI, forKey: XMLNode.CodingKeys.namespaceURI)
try container.encode(name, forKey: XMLNode.CodingKeys.name)
try container.encodeIfPresent(text, forKey: XMLNode.CodingKeys.text)
try container.encode(isXhtml, forKey: XMLNode.CodingKeys.isXhtml)
@@ -187,6 +198,7 @@ class XMLNode: Codable, Equatable, Hashable {
private enum CodingKeys: CodingKey {
case prefix
+ case namespaceURI
case name
case text
case isXhtml
@@ -234,6 +246,7 @@ extension XMLNode: XMLStringConvertible {
xml += "\(formatted ? indent : "")\(name)>\(formatted ? "\n" : "")"
} else if let text {
// Element has text, close opening tag and add text
+ let text = isXhtml ? text : text.escapeCharacters()
xml += ">\(text)\(name)>\(formatted ? "\n" : "")"
} else {
@@ -257,7 +270,7 @@ extension XMLNode {
if let attributesNode = children?.first(where: { $0.name == "@attributes" }) {
// Append each attribute in the format: name="value".
for attribute in attributesNode.children ?? [] {
- result += " \(attribute.name)=\"\(attribute.text ?? "")\""
+ result += " \(attribute.name)=\"\((attribute.text ?? "").escapeCharacters())\""
}
}
return result
diff --git a/Sources/XMLKit/XMLReader.swift b/Sources/XMLKit/XMLReader.swift
index 4a2947d..757f7c0 100644
--- a/Sources/XMLKit/XMLReader.swift
+++ b/Sources/XMLKit/XMLReader.swift
@@ -55,6 +55,13 @@ class XMLReader: NSObject {
/// A boolean indicating whether the XML parsing process has completed.
/// Set to `true` when parsing is finished; otherwise, `false`.
var isComplete = false
+ /// A stack of namespace scopes (prefix -> URI, with `""` representing the
+ /// default namespace). Each frame is the fully merged scope in effect for
+ /// the corresponding element on `stack`, inherited from its parent and
+ /// augmented with any `xmlns`/`xmlns:*` declarations found on the element
+ /// itself. One frame is pushed per `didStartElement` call and popped per
+ /// `didEndElement` call, so the two stacks always stay aligned.
+ var namespaceScopes: [[String: String]] = [[:]]
/// Parses the XML data and returns a `Result` indicating success or failure.
/// - Returns: A `Result` with the parsed document on success, or an error.
@@ -107,6 +114,20 @@ extension XMLReader: XMLParserDelegate {
prefix = String(elementName[..
+
+
+ Test Blog
+
+ Hello World
+ http://example.com/2024/01/01/hello-world/
+ http://example.com/wp-comments-post.php?p=1
+ http://example.com/2024/01/01/hello-world/feed/
+
+
+
diff --git a/Tests/FeedKitTests/Resources/xml/NetNewsWire.xml b/Tests/FeedKitTests/Resources/xml/NetNewsWire.xml
new file mode 100644
index 0000000..2beb449
--- /dev/null
+++ b/Tests/FeedKitTests/Resources/xml/NetNewsWire.xml
@@ -0,0 +1,768 @@
+
+
+ NetNewsWire
+ https://netnewswire.blog/
+
+
+ en
+
+ Mon, 29 Jun 2026 17:16:35 -0700
+
+ How to Beta Test NetNewsWire
+ https://netnewswire.blog/2026/06/29/how-to-beta-test-netnewswire.html
+ Mon, 29 Jun 2026 17:16:35 -0700
+
+ http://NetNewsWire.micro.blog/2026/06/29/how-to-beta-test-netnewswire.html
+ <p>We very much appreciate the bug reports and feedback from NetNewsWire beta testers — and we want to make sure that everyone who might be interested in helping this way knows how to get started. It’s easy. 😀</p>
+<p>So we’ve written up a new <a href="https://netnewswire.com/help/beta-testing.html">How to Beta Test NetNewsWire</a> page. Want to help make NetNewsWire a better app? This is how!</p>
+
+ We very much appreciate the bug reports and feedback from NetNewsWire beta testers — and we want to make sure that everyone who might be interested in helping this way knows how to get started. It’s easy. 😀
+
+So we’ve written up a new [How to Beta Test NetNewsWire](https://netnewswire.com/help/beta-testing.html) page. Want to help make NetNewsWire a better app? This is how!
+
+
+
+
+ NetNewsWire 7.1 for iOS
+ https://netnewswire.blog/2026/06/26/netnewswire-for-ios.html
+ Fri, 26 Jun 2026 14:14:43 -0700
+
+ http://NetNewsWire.micro.blog/2026/06/26/netnewswire-for-ios.html
+ <p>NetNewsWire 7.1 for iOS, <a href="https://apps.apple.com/us/app/netnewswire-rss-reader/id1480640210">now available on the App Store</a>, has the same new features as the Mac version…</p>
+<ul>
+<li>The <a href="https://netnewswire.com/help/current-activity.html">Current Activity screen</a> shows what the app is doing</li>
+<li>The <a href="https://netnewswire.com/help/activity-log.html">Activity Log screen</a> shows what the app did</li>
+<li>The <a href="https://netnewswire.com/help/account-stats.html">Account Stats screen</a> shows various stats</li>
+<li>The <a href="https://netnewswire.com/help/dinosaurs.html">Dinosaurs screen</a> lists stale feeds</li>
+</ul>
+<p>…plus a bunch more changes, including some performance enhancements. See the <a href="https://github.com/Ranchero-Software/NetNewsWire/releases/tag/iOS-7.1">release notes</a> for the big list.</p>
+
+ NetNewsWire 7.1 for iOS, [now available on the App Store](https://apps.apple.com/us/app/netnewswire-rss-reader/id1480640210), has the same new features as the Mac version…
+
+- The [Current Activity screen](https://netnewswire.com/help/current-activity.html) shows what the app is doing
+- The [Activity Log screen](https://netnewswire.com/help/activity-log.html) shows what the app did
+- The [Account Stats screen](https://netnewswire.com/help/account-stats.html) shows various stats
+- The [Dinosaurs screen](https://netnewswire.com/help/dinosaurs.html) lists stale feeds
+
+…plus a bunch more changes, including some performance enhancements. See the [release notes](https://github.com/Ranchero-Software/NetNewsWire/releases/tag/iOS-7.1) for the big list.
+
+
+
+
+ NetNewsWire 7.1 for Mac
+ https://netnewswire.blog/2026/06/26/netnewswire-for-mac.html
+ Fri, 26 Jun 2026 10:11:38 -0700
+
+ http://NetNewsWire.micro.blog/2026/06/26/netnewswire-for-mac.html
+ <p>NetNewsWire 7.1 for Mac includes new features that help you understand what’s up with your feeds:</p>
+<ul>
+<li>The <a href="https://netnewswire.com/help/current-activity.html">Current Activity window</a> shows what the app is doing right now</li>
+<li>The <a href="https://netnewswire.com/help/activity-log.html">Activity Log window</a> shows what the app did recently</li>
+<li>The <a href="https://netnewswire.com/help/account-stats.html">Account Stats window</a> shows per-account article and status counts and database sizes, and it includes a Vacuum Databases button which can help with database size and performance</li>
+<li>The <a href="https://netnewswire.com/help/dinosaurs.html">Dinosaurs window</a> lists feeds that haven’t updated in n months (with a text field where you specify n)</li>
+</ul>
+<p>There are a bunch of other bug fixes and enhancements — <a href="https://github.com/Ranchero-Software/NetNewsWire/releases/tag/mac-7.1">see the release notes</a> for the full scoop.</p>
+<p>To update, do a Check for Updates in NetNewsWire or <a href="https://github.com/Ranchero-Software/NetNewsWire/releases/download/mac-7.1/NetNewsWire7.1.zip">download the app</a> directly.</p>
+<p>PS NetNewsWire 7.1 for iOS will be out as soon as it gets through App Store review. Soon!</p>
+<p>PPS Also: if you have feedback or questions, <a href="https://discourse.netnewswire.com/">check out our forum</a>.</p>
+
+ NetNewsWire 7.1 for Mac includes new features that help you understand what’s up with your feeds:
+
+- The [Current Activity window](https://netnewswire.com/help/current-activity.html) shows what the app is doing right now
+- The [Activity Log window](https://netnewswire.com/help/activity-log.html) shows what the app did recently
+- The [Account Stats window](https://netnewswire.com/help/account-stats.html) shows per-account article and status counts and database sizes, and it includes a Vacuum Databases button which can help with database size and performance
+- The [Dinosaurs window](https://netnewswire.com/help/dinosaurs.html) lists feeds that haven’t updated in n months (with a text field where you specify n)
+
+There are a bunch of other bug fixes and enhancements — [see the release notes](https://github.com/Ranchero-Software/NetNewsWire/releases/tag/mac-7.1) for the full scoop.
+
+To update, do a Check for Updates in NetNewsWire or [download the app](https://github.com/Ranchero-Software/NetNewsWire/releases/download/mac-7.1/NetNewsWire7.1.zip) directly.
+
+PS NetNewsWire 7.1 for iOS will be out as soon as it gets through App Store review. Soon!
+
+PPS Also: if you have feedback or questions, [check out our forum](https://discourse.netnewswire.com/).
+
+
+
+
+ What We’ve Been Doing This Past Year
+ https://netnewswire.blog/2026/06/15/what-weve-been-doing-this.html
+ Mon, 15 Jun 2026 12:17:56 -0700
+
+ http://NetNewsWire.micro.blog/2026/06/15/what-weve-been-doing-this.html
+ <p>On his blog, Brent writes about the <a href="https://inessential.com/2026/06/15/netnewswire-status.html">past year of NetNewsWire development</a>.</p>
+
+ On his blog, Brent writes about the [past year of NetNewsWire development](https://inessential.com/2026/06/15/netnewswire-status.html).
+
+
+
+
+ NetNewsWire Now Getting Feed Images from RSS
+ https://netnewswire.blog/2026/04/28/netnewswire-now-getting-feed-images.html
+ Tue, 28 Apr 2026 11:30:55 -0700
+
+ http://NetNewsWire.micro.blog/2026/04/28/netnewswire-now-getting-feed-images.html
+ <p>In NetNewsWire 7.0.5 we made a change to get the feed image from RSS via the <a href="https://cyber.harvard.edu/rss/rss.html#ltimagegtSubelementOfLtchannelgt">image element</a>.</p>
+<p>Weren’t we <em>already</em> doing this? Seems surprising that we weren’t!</p>
+<p>It’s because, historically, these images were often rectangular — but the app wants square images. These days, probably due to the influence of mobile apps, images tend to be square, which is great. It means we can use these.</p>
+<h4 id="note-to-feed-publishers">Note to feed publishers</h4>
+<p>We suggest checking your feeds to see if they are supplying an image URL. Check that…</p>
+<ul>
+<li>
+<p>You’re supplying a URL in the <code><image></code> element in the <code>channel</code></p>
+</li>
+<li>
+<p>The image URL doesn’t return 404 or something unexpected</p>
+</li>
+<li>
+<p>The image is square</p>
+</li>
+<li>
+<p>The image isn’t small: 128 pixels per side is probably a good minimum</p>
+</li>
+</ul>
+<p>Note, though, that due to NetNewsWire’s caching — because it’s trying to not hit servers too often — it’s difficult to debug this using NetNewsWire. Changes may take days to take appear in the app.</p>
+<p>But if you’ve done the above checks, then it’s good.</p>
+<h4 id="what-happens-when-there-is-no-feed-image">What happens when there is no feed image</h4>
+<p>NetNewsWire will continue to do what it has done for years — it will try to find a suitable feed image by downloading the home page and looking for <code>apple-touch-icon</code>, <code>twitter:image</code>, and <code>og:image</code> URLs. This uses more bandwidth, unfortunately, but it works.</p>
+<p>This is why it’s definitely better to supply a feed image. Don’t make the app go poking around!</p>
+<h4 id="example">Example</h4>
+<p>Here’s an example feed image from Dave Winer’s <a href="http://scripting.com/rss.xml">Scripting News RSS feed</a>:</p>
+<pre><code><image>
+ <title>Scripting News</title>
+ <url>https://imgs.scripting.com/2025/06/04/curly.png</url>
+ <link>http://scripting.com/</link>
+ <description>Scripting News gets an image because it's part of a network that uses them. 6/4/25 by DW</description>
+ </image>
+</code></pre>
+<p>The part that NetNewsWire looks at is the <code><url></code> part. (We don’t have a use currently for the other parts.)</p>
+<h4 id="ps">PS</h4>
+<p>In case you’re not a NetNewsWire user or aren’t sure what we’re talking about — here’s a screenshot showing a list of articles with feed images on the left:</p>
+<img src=https://cdn.uploads.micro.blog/6360/2026/feed-images.png alt="Feed images appear in a timeline in NetNewsWire." title="Feed images" border=0 width=256 height=337>
+
+ In NetNewsWire 7.0.5 we made a change to get the feed image from RSS via the [image element](https://cyber.harvard.edu/rss/rss.html#ltimagegtSubelementOfLtchannelgt).
+
+Weren’t we *already* doing this? Seems surprising that we weren’t!
+
+It’s because, historically, these images were often rectangular — but the app wants square images. These days, probably due to the influence of mobile apps, images tend to be square, which is great. It means we can use these.
+
+#### Note to feed publishers
+
+We suggest checking your feeds to see if they are supplying an image URL. Check that…
+
+* You’re supplying a URL in the `<image>` element in the `channel`
+
+* The image URL doesn’t return 404 or something unexpected
+
+* The image is square
+
+* The image isn’t small: 128 pixels per side is probably a good minimum
+
+Note, though, that due to NetNewsWire’s caching — because it’s trying to not hit servers too often — it’s difficult to debug this using NetNewsWire. Changes may take days to take appear in the app.
+
+But if you’ve done the above checks, then it’s good.
+
+#### What happens when there is no feed image
+
+NetNewsWire will continue to do what it has done for years — it will try to find a suitable feed image by downloading the home page and looking for `apple-touch-icon`, `twitter:image`, and `og:image` URLs. This uses more bandwidth, unfortunately, but it works.
+
+This is why it’s definitely better to supply a feed image. Don’t make the app go poking around!
+
+#### Example
+
+Here’s an example feed image from Dave Winer’s [Scripting News RSS feed](http://scripting.com/rss.xml):
+
+ <image>
+ <title>Scripting News</title>
+ <url>https://imgs.scripting.com/2025/06/04/curly.png</url>
+ <link>http://scripting.com/</link>
+ <description>Scripting News gets an image because it's part of a network that uses them. 6/4/25 by DW</description>
+ </image>
+
+The part that NetNewsWire looks at is the `<url>` part. (We don’t have a use currently for the other parts.)
+
+#### PS
+
+In case you’re not a NetNewsWire user or aren’t sure what we’re talking about — here’s a screenshot showing a list of articles with feed images on the left:
+
+<img src=https://cdn.uploads.micro.blog/6360/2026/feed-images.png alt="Feed images appear in a timeline in NetNewsWire." title="Feed images" border=0 width=256 height=337>
+
+
+
+
+ NetNewsWire 7.0.4 for iOS — runs on iOS 17 and up
+ https://netnewswire.blog/2026/04/06/netnewswire-for-ios-runs-on.html
+ Mon, 06 Apr 2026 09:05:54 -0700
+
+ http://NetNewsWire.micro.blog/2026/04/06/netnewswire-for-ios-runs-on.html
+ <p>NetNewsWire 7.0.4 is out — get your updates the normal way or <a href="https://apps.apple.com/us/app/netnewswire-rss-reader/id1480640210">download from the App Store</a>.</p>
+<p>There are two big changes in this release: it now runs on iOS 17 and up (it no longer requires iOS 26), and it includes the same new iCloud syncing enhancements that the <a href="https://netnewswire.blog/2026/04/03/netnewswire-for-mac-new-icloud.html">Mac version</a> has.</p>
+<p>See the new <a href="https://netnewswire.com/help/optimize-icloud.html">How to Optimize iCloud Syncing page</a> for details on the iCloud changes, and <a href="https://github.com/Ranchero-Software/NetNewsWire/releases/tag/iOS-7.0.4-7047">read the release notes</a> for details on all the changes.</p>
+<p>If you have questions or run into any issues, remember that you can always get help on the <a href="https://discourse.netnewswire.com/">NetNewsWire forum</a> and report bugs on the <a href="https://github.com/Ranchero-Software/NetNewsWire/issues">issues tracker</a>.</p>
+
+ NetNewsWire 7.0.4 is out — get your updates the normal way or [download from the App Store](https://apps.apple.com/us/app/netnewswire-rss-reader/id1480640210).
+
+There are two big changes in this release: it now runs on iOS 17 and up (it no longer requires iOS 26), and it includes the same new iCloud syncing enhancements that the [Mac version](https://netnewswire.blog/2026/04/03/netnewswire-for-mac-new-icloud.html) has.
+
+See the new [How to Optimize iCloud Syncing page](https://netnewswire.com/help/optimize-icloud.html) for details on the iCloud changes, and [read the release notes](https://github.com/Ranchero-Software/NetNewsWire/releases/tag/iOS-7.0.4-7047) for details on all the changes.
+
+If you have questions or run into any issues, remember that you can always get help on the [NetNewsWire forum](https://discourse.netnewswire.com/) and report bugs on the [issues tracker](https://github.com/Ranchero-Software/NetNewsWire/issues).
+
+
+
+
+ NetNewsWire 7.0.4 for Mac — new iCloud features
+ https://netnewswire.blog/2026/04/03/netnewswire-for-mac-new-icloud.html
+ Fri, 03 Apr 2026 11:38:50 -0700
+
+ http://NetNewsWire.micro.blog/2026/04/03/netnewswire-for-mac-new-icloud.html
+ <p>We’ve just released NetNewsWire 7.0.4 for Mac. Do a Check for Updates or <a href="https://github.com/Ranchero-Software/NetNewsWire/releases/download/mac-7.0.4/NetNewsWire7.0.4.zip">download it directly</a> to get the update.</p>
+<p>The big new changes are to iCloud syncing: there’s a new setting to <em>not</em> sync the content of unread articles, since that’s the biggest part of your iCloud database and what takes the longest to sync.</p>
+<p>Another change is a new iCloud Storage Stats window that tells you exactly what’s in your iCloud storage — and it includes a Clean Up button that will trim down your storage (and lead to faster syncs in the future, though not necessarily right at first).</p>
+<p>All of this and more is documented on the new <a href="https://netnewswire.com/help/optimize-icloud.html">How to Optimize iCloud Syncing</a> page.</p>
+<p>(Yes, these features are coming to iPhone and iPad too, as soon as the app gets through App Store review.)</p>
+<p>Also note, in case you missed it earlier, that NetNewsWire for Mac runs on macOS 15 and up. It no longer requires macOS 26. (Similarly: the upcoming iOS release will run on iOS 17 and up. We want as many people as possible to get these iCloud changes.)</p>
+<p>The iCloud changes aren’t the only changes — <a href="https://github.com/Ranchero-Software/NetNewsWire/releases/tag/mac-7.0.4">see the release notes</a> for the full scoop.</p>
+<p>As always, you can get help on the <a href="https://discourse.netnewswire.com/">NetNewsWire forum</a> and report bugs on the <a href="https://github.com/Ranchero-Software/NetNewsWire/issues">issues tracker</a>.</p>
+
+ We’ve just released NetNewsWire 7.0.4 for Mac. Do a Check for Updates or [download it directly](https://github.com/Ranchero-Software/NetNewsWire/releases/download/mac-7.0.4/NetNewsWire7.0.4.zip) to get the update.
+
+The big new changes are to iCloud syncing: there’s a new setting to *not* sync the content of unread articles, since that’s the biggest part of your iCloud database and what takes the longest to sync.
+
+Another change is a new iCloud Storage Stats window that tells you exactly what’s in your iCloud storage — and it includes a Clean Up button that will trim down your storage (and lead to faster syncs in the future, though not necessarily right at first).
+
+All of this and more is documented on the new [How to Optimize iCloud Syncing](https://netnewswire.com/help/optimize-icloud.html) page.
+
+(Yes, these features are coming to iPhone and iPad too, as soon as the app gets through App Store review.)
+
+Also note, in case you missed it earlier, that NetNewsWire for Mac runs on macOS 15 and up. It no longer requires macOS 26. (Similarly: the upcoming iOS release will run on iOS 17 and up. We want as many people as possible to get these iCloud changes.)
+
+The iCloud changes aren’t the only changes — [see the release notes](https://github.com/Ranchero-Software/NetNewsWire/releases/tag/mac-7.0.4) for the full scoop.
+
+As always, you can get help on the [NetNewsWire forum](https://discourse.netnewswire.com/) and report bugs on the [issues tracker](https://github.com/Ranchero-Software/NetNewsWire/issues).
+
+
+
+
+ Testing Help for NetNewsWire 7.0.4 — runs on iOS 17 and iOS 18
+ https://netnewswire.blog/2026/03/30/testing-help-for-netnewswire-runs.html
+ Mon, 30 Mar 2026 11:59:05 -0700
+
+ http://NetNewsWire.micro.blog/2026/03/30/testing-help-for-netnewswire-runs.html
+ <p>We just released a TestFlight build of NetNewsWire 7.0.4 (which includes iCloud syncing enhancements) that runs on iOS 17 and up. (It had required iOS 26.)</p>
+<p>If you have a device running one of these versions of iOS, we’d appreciate it if you would help test. Here’s where to <a href="https://netnewswire.com/test-ios.html">sign up for TestFlight for NetNewsWire</a>.</p>
+<p>Thanks!</p>
+<p>You can report bugs via TestFlight feedback, <a href="https://discourse.netnewswire.com/c/beta-testing/7">on the forum</a>, or <a href="https://github.com/Ranchero-Software/NetNewsWire/issues">on the bug tracker</a>. Whatever works for you is cool with us 😀</p>
+<p>Also: this page talks about the <a href="https://netnewswire.com/help/optimize-icloud.html">iCloud syncing enhancements in 7.0.4</a>.</p>
+
+ We just released a TestFlight build of NetNewsWire 7.0.4 (which includes iCloud syncing enhancements) that runs on iOS 17 and up. (It had required iOS 26.)
+
+If you have a device running one of these versions of iOS, we’d appreciate it if you would help test. Here’s where to [sign up for TestFlight for NetNewsWire](https://netnewswire.com/test-ios.html).
+
+Thanks!
+
+You can report bugs via TestFlight feedback, [on the forum](https://discourse.netnewswire.com/c/beta-testing/7), or [on the bug tracker](https://github.com/Ranchero-Software/NetNewsWire/issues). Whatever works for you is cool with us 😀
+
+Also: this page talks about the [iCloud syncing enhancements in 7.0.4](https://netnewswire.com/help/optimize-icloud.html).
+
+
+
+
+ NetNewsWire User Creates MCP Support Via AppleScript
+ https://netnewswire.blog/2026/03/21/netnewswire-user-creates-mcp-support.html
+ Sat, 21 Mar 2026 14:08:44 -0700
+
+ http://NetNewsWire.micro.blog/2026/03/21/netnewswire-user-creates-mcp-support.html
+ <p>Jelly <a href="https://discourse.netnewswire.com/t/nnw-mcp-support/199">writes on the NetNewsWire Discourse forum</a>:</p>
+<blockquote>
+<p>I’ve drafted a naive implementation of NNW MCP support through AppleScript. It supports listing feeds, getting/searching articles, subscribing new feeds…</p>
+<p>I can now schedule a daily task that lets Claude summarize that day’s new articles and order them based on my previous behaviors every morning.</p>
+</blockquote>
+<p>We love how AppleScript support is worth way more than the effort we put into it — users can keep creating cool things that we couldn’t have imagined back then.</p>
+<p>To be clear: we didn’t do anything in NetNewsWire to support AI, LLMs, or MCP, but, since the app is scriptable, you can do all kinds of useful things with it.</p>
+<p>Here’s the <a href="https://github.com/jellllly420/netnewswire-mcp">netnewswire-mcp repo</a> on GitHub.</p>
+
+ Jelly [writes on the NetNewsWire Discourse forum](https://discourse.netnewswire.com/t/nnw-mcp-support/199):
+
+> I’ve drafted a naive implementation of NNW MCP support through AppleScript. It supports listing feeds, getting/searching articles, subscribing new feeds…
+>
+> I can now schedule a daily task that lets Claude summarize that day’s new articles and order them based on my previous behaviors every morning.
+
+We love how AppleScript support is worth way more than the effort we put into it — users can keep creating cool things that we couldn’t have imagined back then.
+
+To be clear: we didn’t do anything in NetNewsWire to support AI, LLMs, or MCP, but, since the app is scriptable, you can do all kinds of useful things with it.
+
+Here’s the [netnewswire-mcp repo](https://github.com/jellllly420/netnewswire-mcp) on GitHub.
+
+
+
+
+ NetNewsWire 7.0.2: The Return of the Error Log
+ https://netnewswire.blog/2026/03/19/netnewswire-the-return-of-the.html
+ Thu, 19 Mar 2026 15:52:15 -0700
+
+ http://NetNewsWire.micro.blog/2026/03/19/netnewswire-the-return-of-the.html
+ <p>NetNewsWire of Yore had an Error Log window (on Mac) which could help you figure out what’s going on when a feed or account is acting weird — when it’s stopped updating, for instance.</p>
+<p>NetNewsWire 7.0.2 brings that feature back, and we’ve already used it to help fix a syncing authentication bug (bug fix will be in 7.0.3).</p>
+<p>Get your updates the usual way and enjoy the return of the Error Log!</p>
+<p>For more details, plus screenshots, see <a href="https://netnewswire.com/help/error-log.html">How to Use the Error Log</a>.</p>
+
+ NetNewsWire of Yore had an Error Log window (on Mac) which could help you figure out what’s going on when a feed or account is acting weird — when it’s stopped updating, for instance.
+
+NetNewsWire 7.0.2 brings that feature back, and we’ve already used it to help fix a syncing authentication bug (bug fix will be in 7.0.3).
+
+Get your updates the usual way and enjoy the return of the Error Log!
+
+For more details, plus screenshots, see [How to Use the Error Log](https://netnewswire.com/help/error-log.html).
+
+
+
+
+ NetNewsWire 7.0.1 for Mac runs on macOS 15
+ https://netnewswire.blog/2026/03/04/netnewswire-for-mac-runs-on.html
+ Wed, 04 Mar 2026 18:11:02 -0700
+
+ http://NetNewsWire.micro.blog/2026/03/04/netnewswire-for-mac-runs-on.html
+ <p>We’ve just released NetNewsWire 7.0.1 for Mac. To get it, do a check-for-updates or <a href="https://github.com/Ranchero-Software/NetNewsWire/releases/download/mac-7.0.1/NetNewsWire7.0.1.zip">download it directly</a>.</p>
+<p>The big new thing is that runs on macOS 15. 💥 It fixes several bugs as well: see the <a href="https://github.com/Ranchero-Software/NetNewsWire/releases/tag/mac-7.0.1">change notes on GitHub</a> for details.</p>
+<p>Thanks to everyone who helped test!</p>
+<p>PS NetNewsWire 7.0.1 for iOS in waiting for review. Hopefully it will be out in the next few days.</p>
+
+ We’ve just released NetNewsWire 7.0.1 for Mac. To get it, do a check-for-updates or [download it directly](https://github.com/Ranchero-Software/NetNewsWire/releases/download/mac-7.0.1/NetNewsWire7.0.1.zip).
+
+The big new thing is that runs on macOS 15. 💥 It fixes several bugs as well: see the [change notes on GitHub](https://github.com/Ranchero-Software/NetNewsWire/releases/tag/mac-7.0.1) for details.
+
+Thanks to everyone who helped test!
+
+PS NetNewsWire 7.0.1 for iOS in waiting for review. Hopefully it will be out in the next few days.
+
+
+
+
+ Testing help for NetNewsWire 7.0.1 beta needed — runs on macOS 15
+ https://netnewswire.blog/2026/02/27/testing-help-for-netnewswire-beta.html
+ Fri, 27 Feb 2026 14:34:27 -0700
+
+ http://NetNewsWire.micro.blog/2026/02/27/testing-help-for-netnewswire-beta.html
+ <p>NetNewsWire 7.0 required macOS 26, but now we’re working on making it compatible with macOS 15.</p>
+<p>This latest beta — either do a check-for-updates or <a href="https://github.com/Ranchero-Software/NetNewsWire/releases/download/mac-7.0.1b4/NetNewsWire7.0.1b4.zip">download it manually</a> to get it — runs on macOS 15.</p>
+<p>So far we’ve found just a couple cosmetic glitches, but we’d like to do a lot more testing than just a few quick looks around. We appreciate all the help!</p>
+<p>You can report bugs on the <a href="https://github.com/Ranchero-Software/NetNewsWire/issues">bug tracker</a> or on the <a href="https://discourse.netnewswire.com/t/netnewswire-7-0-1b4-runs-on-macos-15-testing-help-needed/">Discourse forum topic</a> for this beta.</p>
+<p>(If you’re reporting on the bug tracker, please be sure to specify that you’re running macOS 15, if you are, and specify which version of NetNewsWire. Thanks!)</p>
+
+ NetNewsWire 7.0 required macOS 26, but now we’re working on making it compatible with macOS 15.
+
+This latest beta — either do a check-for-updates or [download it manually](https://github.com/Ranchero-Software/NetNewsWire/releases/download/mac-7.0.1b4/NetNewsWire7.0.1b4.zip) to get it — runs on macOS 15.
+
+So far we’ve found just a couple cosmetic glitches, but we’d like to do a lot more testing than just a few quick looks around. We appreciate all the help!
+
+You can report bugs on the [bug tracker](https://github.com/Ranchero-Software/NetNewsWire/issues) or on the [Discourse forum topic](https://discourse.netnewswire.com/t/netnewswire-7-0-1b4-runs-on-macos-15-testing-help-needed/) for this beta.
+
+(If you’re reporting on the bug tracker, please be sure to specify that you’re running macOS 15, if you are, and specify which version of NetNewsWire. Thanks!)
+
+
+
+
+ NetNewsWire Turns 23
+ https://netnewswire.blog/2026/02/11/netnewswire-turns.html
+ Wed, 11 Feb 2026 10:35:12 -0700
+
+ http://NetNewsWire.micro.blog/2026/02/11/netnewswire-turns.html
+ <p>NetNewsWire 1.0 for Mac shipped 23 years ago today! 🎸🎩🕶️</p>
+<p>Here’s where things are on this particular February 11: we just shipped 7.0 for Mac and iOS, and now we’re working on NetNewsWire 7.0.1.</p>
+<p>After a big release, no matter how careful we are, there are often some regressions to fix and tweaks to make right away, so we’re working on those. Here’s the milestone with the <a href="https://github.com/Ranchero-Software/NetNewsWire/milestone/65">current to-do list</a>.</p>
+<p>Big picture: we still have a lot of bugs to fix, lots of tech debt to deal with, and lots of polish-needed areas of the app. With <a href="https://inessential.com/2025/05/24/retirement_and_netnewswire.html">Brent’s retirement last year</a> we’ve been able to go <em>way</em> faster on dealing with all this. We plan to keep up the pace.</p>
+<p>Here are our current plans:</p>
+<p>For <a href="https://github.com/Ranchero-Software/NetNewsWire/milestone/50">NetNewsWire 7.1</a> we’re focusing on syncing fixes and improvements.</p>
+<p><a href="https://github.com/Ranchero-Software/NetNewsWire/milestone/52">NetNewsWire 7.2</a> doesn’t have a focus yet. Could end up being UX fixes and polish, could be something else. Could be a potpourri, though we do prefer having a focus when possible.</p>
+<p>We don’t have a NetNewsWire 7.3 plan yet — that’s too far out. Depends on what actually happens with 7.1 and 7.2, and it depends on what Apple adds to our to-do list at WWDC this year. (Touchscreen Macs? Folding iPhones? Big new Swift features? Who knows!)</p>
+<p>Note that we do add and remove tickets from milestones at any time — none of this is set in stone, of course.</p>
+<p>It’s NetNewsWire’s birthday, but that’s a day to look forward, not to look back. The very best versions of NetNewsWire are still to come!</p>
+
+ NetNewsWire 1.0 for Mac shipped 23 years ago today! 🎸🎩🕶️
+
+Here’s where things are on this particular February 11: we just shipped 7.0 for Mac and iOS, and now we’re working on NetNewsWire 7.0.1.
+
+After a big release, no matter how careful we are, there are often some regressions to fix and tweaks to make right away, so we’re working on those. Here’s the milestone with the [current to-do list](https://github.com/Ranchero-Software/NetNewsWire/milestone/65).
+
+Big picture: we still have a lot of bugs to fix, lots of tech debt to deal with, and lots of polish-needed areas of the app. With [Brent’s retirement last year](https://inessential.com/2025/05/24/retirement_and_netnewswire.html) we’ve been able to go <em>way</em> faster on dealing with all this. We plan to keep up the pace.
+
+Here are our current plans:
+
+For [NetNewsWire 7.1](https://github.com/Ranchero-Software/NetNewsWire/milestone/50) we’re focusing on syncing fixes and improvements.
+
+[NetNewsWire 7.2](https://github.com/Ranchero-Software/NetNewsWire/milestone/52) doesn’t have a focus yet. Could end up being UX fixes and polish, could be something else. Could be a potpourri, though we do prefer having a focus when possible.
+
+We don’t have a NetNewsWire 7.3 plan yet — that’s too far out. Depends on what actually happens with 7.1 and 7.2, and it depends on what Apple adds to our to-do list at WWDC this year. (Touchscreen Macs? Folding iPhones? Big new Swift features? Who knows!)
+
+Note that we do add and remove tickets from milestones at any time — none of this is set in stone, of course.
+
+It’s NetNewsWire’s birthday, but that’s a day to look forward, not to look back. The very best versions of NetNewsWire are still to come!
+
+
+
+
+ NetNewsWire 7.0 for iOS
+ https://netnewswire.blog/2026/02/06/netnewswire-for-ios.html
+ Fri, 06 Feb 2026 12:42:44 -0700
+
+ http://NetNewsWire.micro.blog/2026/02/06/netnewswire-for-ios.html
+ <p>NetNewsWire 7 for iOS 26 and up is available now <a href="https://apps.apple.com/us/app/netnewswire-rss-reader/id1480640210">on the App Store</a>!</p>
+<p>This version adopts Liquid Glass — and we think it’s a better looking version of NetNewsWire. We think even people who aren’t fans of Liquid Glass will agree with us. 🐣</p>
+<p>Credit goes to <a href="https://stuartbreckenridge.net/">Stuart Breckenridge</a> for the design and implementation. Wonderful work! <a href="https://netnewswire.com/screenshots-ios-7.html">Check out the screenshots</a>.</p>
+<p>This version also fixes some small bugs and adds some small performance enhancements. (iOS developers might appreciate this bit: it adopts Swift structured concurrency.)</p>
+<p>But, again, the main thing is the updated UI. It’s cool!</p>
+<p>People who like details might enjoy this big list of UI changes from Stuart:</p>
+<ul>
+<li>[Sidebar] What was previously a UITableView is now a UICollectionView. This was needed in order to adopt modern styling across iPad and iPhone. iPad uses the .sidebar style, and iPhone uses .insetGrouped. This is similar to the behaviour you see in Mail.</li>
+<li>[Sidebar] The current refresh status is now located in the navigation bar as a subtitle, having previously been the footer</li>
+<li>[Sidebar] Toolbar buttons follow Liquid Glass standards</li>
+<li>[Sidebar (iPad)] Like the Mac refresh, the Feeds view floats and allows Timeline content to slide underneath</li>
+<li>[Sidebar] Smart Feeds and Account headers now adopt modern secondary styling</li>
+<li>[Sidebar (iPad)] Selected feeds have a modern capsule background and the text is bold</li>
+<li>[Sidebar] Folders have been entirely redesigned to match modern standards—they now have the same indentation as any other feed, but the enclosed feeds are indented further</li>
+<li>[Sidebar] Folders will highlight when Feeds are being dragged and dropped into them</li>
+<li>[Sidebar] Separators have been realigned</li>
+<li>[Sidebar] Unread counts are larger and are no longer backed by a filled capsule</li>
+<li>[Sidebar] Unread counts for folders are only displayed when the folder is closed</li>
+<li>[Sidebar] Swipe actions reveal icons</li>
+<li>[Sidebar (iPad)] Users can resize the sidebar (within reason)</li>
+<li>[Timeline] What was previously a UITableView is now a UICollectionView. This was needed in order to adopt modern cell styling—e.g., selected and swipe status—across iPad and iPhone.</li>
+<li>[Timeline] Navigation bar images have been removed</li>
+<li>[Timeline] Unread counts are now located in the navigation bar subtitle</li>
+<li>[Timeline] Adopts hierarchical text colours for titles and summaries</li>
+<li>[Timeline (iPad)] The search bar has been moved to the app-wide toolbar and behaves similar to the Mac search</li>
+<li>[Timeline (iPhone)] The search bar has been moved to the bottom toolbar</li>
+<li>[Timeline] The Timeline width is user adjustable (again, within reason)</li>
+<li>[Timeline] Timeline cells have been redesigned in Interface builder and now have the rounded corner selection style</li>
+<li>[Timeline] The Mark All as Read image (on both iPad and iPhone) has had alignment changes to make sure it sits in the middle of an englassified button</li>
+<li>[Article (iPad)] Articles can be read in three-pane view without hiding the Sidebar</li>
+<li>[Article (iPad)] The top toolbar inherits search capabilities</li>
+<li>[Article] The bottom toolbar buttons have been grouped in a 2-1-2 formation with the Next Unread button sitting in the throne seat</li>
+<li>[Sidebar, Timeline, Article] Visual state is restored on relaunch</li>
+<li>[Widgets] Home Screen widgets have been redesigned to make better use of horizontal space</li>
+<li>[Widgets] New Lock Screen widget with Today, Unread, Starred counts</li>
+<li>[About] Tending to the dark corner of the garden, the About view on iOS has been redesigned and inspired by the Credits from Vesper</li>
+</ul>
+
+ NetNewsWire 7 for iOS 26 and up is available now [on the App Store](https://apps.apple.com/us/app/netnewswire-rss-reader/id1480640210)!
+
+This version adopts Liquid Glass — and we think it’s a better looking version of NetNewsWire. We think even people who aren’t fans of Liquid Glass will agree with us. 🐣
+
+Credit goes to [Stuart Breckenridge](https://stuartbreckenridge.net/) for the design and implementation. Wonderful work! [Check out the screenshots](https://netnewswire.com/screenshots-ios-7.html).
+
+This version also fixes some small bugs and adds some small performance enhancements. (iOS developers might appreciate this bit: it adopts Swift structured concurrency.)
+
+But, again, the main thing is the updated UI. It’s cool!
+
+People who like details might enjoy this big list of UI changes from Stuart:
+
+- [Sidebar] What was previously a UITableView is now a UICollectionView. This was needed in order to adopt modern styling across iPad and iPhone. iPad uses the .sidebar style, and iPhone uses .insetGrouped. This is similar to the behaviour you see in Mail.
+- [Sidebar] The current refresh status is now located in the navigation bar as a subtitle, having previously been the footer
+- [Sidebar] Toolbar buttons follow Liquid Glass standards
+- [Sidebar (iPad)] Like the Mac refresh, the Feeds view floats and allows Timeline content to slide underneath
+- [Sidebar] Smart Feeds and Account headers now adopt modern secondary styling
+- [Sidebar (iPad)] Selected feeds have a modern capsule background and the text is bold
+- [Sidebar] Folders have been entirely redesigned to match modern standards—they now have the same indentation as any other feed, but the enclosed feeds are indented further
+- [Sidebar] Folders will highlight when Feeds are being dragged and dropped into them
+- [Sidebar] Separators have been realigned
+- [Sidebar] Unread counts are larger and are no longer backed by a filled capsule
+- [Sidebar] Unread counts for folders are only displayed when the folder is closed
+- [Sidebar] Swipe actions reveal icons
+- [Sidebar (iPad)] Users can resize the sidebar (within reason)
+- [Timeline] What was previously a UITableView is now a UICollectionView. This was needed in order to adopt modern cell styling—e.g., selected and swipe status—across iPad and iPhone.
+- [Timeline] Navigation bar images have been removed
+- [Timeline] Unread counts are now located in the navigation bar subtitle
+- [Timeline] Adopts hierarchical text colours for titles and summaries
+- [Timeline (iPad)] The search bar has been moved to the app-wide toolbar and behaves similar to the Mac search
+- [Timeline (iPhone)] The search bar has been moved to the bottom toolbar
+- [Timeline] The Timeline width is user adjustable (again, within reason)
+- [Timeline] Timeline cells have been redesigned in Interface builder and now have the rounded corner selection style
+- [Timeline] The Mark All as Read image (on both iPad and iPhone) has had alignment changes to make sure it sits in the middle of an englassified button
+- [Article (iPad)] Articles can be read in three-pane view without hiding the Sidebar
+- [Article (iPad)] The top toolbar inherits search capabilities
+- [Article] The bottom toolbar buttons have been grouped in a 2-1-2 formation with the Next Unread button sitting in the throne seat
+- [Sidebar, Timeline, Article] Visual state is restored on relaunch
+- [Widgets] Home Screen widgets have been redesigned to make better use of horizontal space
+- [Widgets] New Lock Screen widget with Today, Unread, Starred counts
+- [About] Tending to the dark corner of the garden, the About view on iOS has been redesigned and inspired by the Credits from Vesper
+
+
+
+
+
+ NetNewsWire 7 for Mac
+ https://netnewswire.blog/2026/01/27/netnewswire-for-mac.html
+ Tue, 27 Jan 2026 12:32:41 -0700
+
+ http://NetNewsWire.micro.blog/2026/01/27/netnewswire-for-mac.html
+ <p>NetNewsWire 7.0 for Mac is now shipping!</p>
+<p>The big change from 6.2.1 is that it adopts the Liquid Glass UI and it requires macOS 26.</p>
+<p>(Note to people who aren’t on macOS 26: we fixed a lot of bugs in 6.2 and 6.2.1 knowing that many people might skip, or at least delay, installing macOS 26. Also note that there’s a page where you can <a href="https://netnewswire.com/old-versions.html">get old versions of NetNewsWire</a>.)</p>
+<p>To get NetNewsWire 7: in the app, in the NetNewsWire menu, do <code>Check for Updates…</code> and it will update to the new version.</p>
+<p>If you’re not already running NetNewsWire, or prefer to update manually, you can <a href="https://github.com/Ranchero-Software/NetNewsWire/releases/download/mac-7.0/NetNewsWire7.0.zip">download NetNewsWire 7</a>.</p>
+<h4 id="feedback-and-support">Feedback and support</h4>
+<p>We recently switched from Slack to Discourse — we’ve got a <a href="https://discourse.netnewswire.com/">new forum</a> that doesn’t delete conversations. It’s nice!</p>
+<p>And, as always, you can report bugs and make feature requests on our <a href="https://github.com/Ranchero-Software/NetNewsWire/issues">bug tracker</a>.</p>
+<p>You don’t have to bookmark either of those two URLs — they’re available in NetNewsWire’s Help menu.</p>
+<h4 id="ps-ios-version-coming-soon">PS iOS version coming soon</h4>
+<p>We’re pretty close to being finished with the iPhone and iPad version. It too adopts the Liquid Glass UI. If you want in on the TestFlight — we appreciate help testing! — you can <a href="https://netnewswire.com/test-ios.html">sign up here</a>.</p>
+<h4 id="pps-screenshots">PPS Screenshots</h4>
+<p>Here are dark and light mode <a href="https://netnewswire.com/screenshots-mac-7.html">screenshots for NetNewsWire 7 for Mac</a>, which you’re free to use in any blog posts, social media posts, reviews, etc. (Or make your own.)</p>
+
+ NetNewsWire 7.0 for Mac is now shipping!
+
+The big change from 6.2.1 is that it adopts the Liquid Glass UI and it requires macOS 26.
+
+(Note to people who aren’t on macOS 26: we fixed a lot of bugs in 6.2 and 6.2.1 knowing that many people might skip, or at least delay, installing macOS 26. Also note that there’s a page where you can [get old versions of NetNewsWire](https://netnewswire.com/old-versions.html).)
+
+To get NetNewsWire 7: in the app, in the NetNewsWire menu, do `Check for Updates…` and it will update to the new version.
+
+If you’re not already running NetNewsWire, or prefer to update manually, you can [download NetNewsWire 7](https://github.com/Ranchero-Software/NetNewsWire/releases/download/mac-7.0/NetNewsWire7.0.zip).
+
+#### Feedback and support
+
+We recently switched from Slack to Discourse — we’ve got a [new forum](https://discourse.netnewswire.com/) that doesn’t delete conversations. It’s nice!
+
+And, as always, you can report bugs and make feature requests on our [bug tracker](https://github.com/Ranchero-Software/NetNewsWire/issues).
+
+You don’t have to bookmark either of those two URLs — they’re available in NetNewsWire’s Help menu.
+
+#### PS iOS version coming soon
+
+We’re pretty close to being finished with the iPhone and iPad version. It too adopts the Liquid Glass UI. If you want in on the TestFlight — we appreciate help testing! — you can [sign up here](https://netnewswire.com/test-ios.html).
+
+#### PPS Screenshots
+
+Here are dark and light mode [screenshots for NetNewsWire 7 for Mac](https://netnewswire.com/screenshots-mac-7.html), which you’re free to use in any blog posts, social media posts, reviews, etc. (Or make your own.)
+
+
+
+
+ Moving from Slack to Discourse
+ https://netnewswire.blog/2025/12/29/moving-from-slack-to-discourse.html
+ Mon, 29 Dec 2025 12:13:54 -0700
+
+ http://NetNewsWire.micro.blog/2025/12/29/moving-from-slack-to-discourse.html
+ <p>We’re dropping the Slack group as the NetNewsWire forum and switching to Discourse — <a href="https://discourse.netnewswire.com/">here’s the new forum</a>.</p>
+<p>Slack’s been pretty great for us, but it does have some limitations: conversations are automatically deleted and they’re not findable on the web in the first place.</p>
+<p>The switch to Discourse means conversations will be preserved and they will be able to benefit people for years to come. And we get to use an open web app that’s also open source. Which we like very much.</p>
+
+ We’re dropping the Slack group as the NetNewsWire forum and switching to Discourse — [here’s the new forum](https://discourse.netnewswire.com/).
+
+Slack’s been pretty great for us, but it does have some limitations: conversations are automatically deleted and they’re not findable on the web in the first place.
+
+The switch to Discourse means conversations will be preserved and they will be able to benefit people for years to come. And we get to use an open web app that’s also open source. Which we like very much.
+
+
+
+
+
+ NetNewsWire 6.2 for Mac and iOS
+ https://netnewswire.blog/2025/11/05/netnewswire-for-mac-and-ios.html
+ Wed, 05 Nov 2025 14:17:36 -0700
+
+ http://NetNewsWire.micro.blog/2025/11/05/netnewswire-for-mac-and-ios.html
+ <p>NetNewsWire 6.2 is on the App Store for iOS, and you can download the Mac version (or do NetNewsWire > Check for Updates… if you’re already running the app).</p>
+<p>Long-time Mac users will understand when we say that this is a Snow Leopard release — it fixes a bunch of bugs, makes some things faster, and adds only a couple features.</p>
+<p>See the <a href="https://github.com/Ranchero-Software/NetNewsWire/releases/tag/mac-6.2">release notes for Mac</a> and <a href="https://github.com/Ranchero-Software/NetNewsWire/releases/tag/iOS-6.2-6202">release notes for iOS</a> for more details. (They’re largely the same.)</p>
+<p>Note also that it doesn’t adopt Liquid Glass. We’ll be doing that in NetNewsWire 7, which we’re working on now. (See <a href="https://netnewswire.blog/2025/10/07/the-liquid-glass-plan.html">The Liquid Glass Plan</a>.)</p>
+<p>All that said — there is one new feature of potential interest: we’ve added support for Markdown in RSS feeds. When the parser encounters a <code>source:markdown</code> element, we save it in the database, and the app renders the Markdown as HTML and displays it in the article view.</p>
+<p>There aren’t many feeds that include Markdown yet, but we hope that changes! You can read more about it in Dave Winer’s <a href="http://scripting.com/2022/07/19/152235.html?title=devNotesForMarkdownInRss">Dev notes for Markdown in RSS</a>.</p>
+<p>While we’re not sure where this feature will take us, we have some thoughts. One is that RSS and Markdown were always destined to meet in this way, and it’s about time. :) Another is that this is a part of making posting/editing/replying over the web — as opposed to closed social networks — easier. This is part of making the web itself the social network.</p>
+<p>Anyway. It’s a foundation! We keep going.</p>
+
+ NetNewsWire 6.2 is on the App Store for iOS, and you can download the Mac version (or do NetNewsWire > Check for Updates… if you’re already running the app).
+
+Long-time Mac users will understand when we say that this is a Snow Leopard release — it fixes a bunch of bugs, makes some things faster, and adds only a couple features.
+
+See the [release notes for Mac](https://github.com/Ranchero-Software/NetNewsWire/releases/tag/mac-6.2) and [release notes for iOS](https://github.com/Ranchero-Software/NetNewsWire/releases/tag/iOS-6.2-6202) for more details. (They’re largely the same.)
+
+Note also that it doesn’t adopt Liquid Glass. We’ll be doing that in NetNewsWire 7, which we’re working on now. (See [The Liquid Glass Plan](https://netnewswire.blog/2025/10/07/the-liquid-glass-plan.html).)
+
+All that said — there is one new feature of potential interest: we’ve added support for Markdown in RSS feeds. When the parser encounters a `source:markdown` element, we save it in the database, and the app renders the Markdown as HTML and displays it in the article view.
+
+There aren’t many feeds that include Markdown yet, but we hope that changes! You can read more about it in Dave Winer’s [Dev notes for Markdown in RSS](http://scripting.com/2022/07/19/152235.html?title=devNotesForMarkdownInRss).
+
+While we’re not sure where this feature will take us, we have some thoughts. One is that RSS and Markdown were always destined to meet in this way, and it’s about time. :) Another is that this is a part of making posting/editing/replying over the web — as opposed to closed social networks — easier. This is part of making the web itself the social network.
+
+Anyway. It’s a foundation! We keep going.
+
+
+
+
+ The Liquid Glass Plan
+ https://netnewswire.blog/2025/10/07/the-liquid-glass-plan.html
+ Tue, 07 Oct 2025 20:38:42 -0700
+
+ http://NetNewsWire.micro.blog/2025/10/07/the-liquid-glass-plan.html
+ <p>We’re hearing from folks eager for the Liquid Glass update to NetNewsWire. The bad news is that it’s not coming this week or next (who knows when, really) — but the good news is that it is very much in progress.</p>
+<p>Here’s the plan:</p>
+<p><strong>NetNewsWire 6.2</strong> for Mac and iOS. This is a Snow-Leopard-style release — the idea is to fix a bunch of bugs knowing that a lot of people, particularly Mac users, may be slow to upgrade to the 26es, and we want them to have something solid to use while they put off upgrading — because the release after this one will require iOS and macOS 26.</p>
+<p><strong>NetNewsWire 7</strong> for Mac and iOS. This will be the Liquid Glass release, and, as noted above, it will require iOS and macOS 26.</p>
+<p>If you’d like more details, see our <a href="https://github.com/Ranchero-Software/NetNewsWire/milestone/62">milestone for NetNewsWire 6.2</a>, which is 67% complete at this writing. Also see our <a href="https://github.com/Ranchero-Software/NetNewsWire/milestone/63">NetNewsWire 7 milestone</a> and <a href="https://github.com/Ranchero-Software/NetNewsWire/milestones">list of milestones</a>.</p>
+<p>Please note that anything may change at any time! Particularly when it comes to which issues are on which milestones — things get moved around all the time.</p>
+<h3 id="screenshots">Screenshots</h3>
+<p>If you’d like a sneak peak of what NetNewsWire 7 will look like, check out these posts by Stuart Breckenridge, who’s done great work on our Liquid Glass adoption:</p>
+<p><a href="https://stuartbreckenridge.net/adopting-liquid-glass-part-ii-netnewswire-mac/">Adopting Liquid Glass, Part II (NetNewsWire Mac)</a></p>
+<p><a href="https://stuartbreckenridge.net/adopting-liquid-glass-part-iii-netnewswire-ios/">Adopting Liquid Glass, Part III (NetNewsWire iOS)</a></p>
+
+ We’re hearing from folks eager for the Liquid Glass update to NetNewsWire. The bad news is that it’s not coming this week or next (who knows when, really) — but the good news is that it is very much in progress.
+
+Here’s the plan:
+
+**NetNewsWire 6.2** for Mac and iOS. This is a Snow-Leopard-style release — the idea is to fix a bunch of bugs knowing that a lot of people, particularly Mac users, may be slow to upgrade to the 26es, and we want them to have something solid to use while they put off upgrading — because the release after this one will require iOS and macOS 26.
+
+**NetNewsWire 7** for Mac and iOS. This will be the Liquid Glass release, and, as noted above, it will require iOS and macOS 26.
+
+If you’d like more details, see our [milestone for NetNewsWire 6.2](https://github.com/Ranchero-Software/NetNewsWire/milestone/62), which is 67% complete at this writing. Also see our [NetNewsWire 7 milestone](https://github.com/Ranchero-Software/NetNewsWire/milestone/63) and [list of milestones](https://github.com/Ranchero-Software/NetNewsWire/milestones).
+
+Please note that anything may change at any time! Particularly when it comes to which issues are on which milestones — things get moved around all the time.
+
+### Screenshots
+
+If you’d like a sneak peak of what NetNewsWire 7 will look like, check out these posts by Stuart Breckenridge, who’s done great work on our Liquid Glass adoption:
+
+[Adopting Liquid Glass, Part II (NetNewsWire Mac)](https://stuartbreckenridge.net/adopting-liquid-glass-part-ii-netnewswire-mac/)
+
+[Adopting Liquid Glass, Part III (NetNewsWire iOS)](https://stuartbreckenridge.net/adopting-liquid-glass-part-iii-netnewswire-ios/)
+
+
+
+
+ New TestFlight Build Uploaded, Waiting for Review
+ https://netnewswire.blog/2025/08/11/new-testflight-build-uploaded-waiting.html
+ Mon, 11 Aug 2025 11:22:56 -0700
+
+ http://NetNewsWire.micro.blog/2025/08/11/new-testflight-build-uploaded-waiting.html
+ <p>We know some folks are waiting on a new TestFlight build for iOS, and we have good news — we’ve just uploaded 6.1.10 (6198), and it’s waiting on Apple review. Once reviewed it will be available on TestFlight.</p>
+<p><i>Update a few hours later…</i> The build is now available via TestFlight.</p>
+
+ We know some folks are waiting on a new TestFlight build for iOS, and we have good news — we’ve just uploaded 6.1.10 (6198), and it’s waiting on Apple review. Once reviewed it will be available on TestFlight.
+
+<i>Update a few hours later…</i> The build is now available via TestFlight.
+
+
+
+
+ TestFlight Build Expired (Again)
+ https://netnewswire.blog/2025/08/08/testflight-build-expired-again.html
+ Fri, 08 Aug 2025 17:37:14 -0700
+
+ http://NetNewsWire.micro.blog/2025/08/08/testflight-build-expired-again.html
+ <p>For people using NetNewsWire via TestFlight: the current TestFlight build has expired, and it will be a few days before a new build has been uploaded and reviewed by Apple.</p>
+<p>However, you can <a href="https://apps.apple.com/us/app/netnewswire-rss-reader/id1480640210">install the current build from the App Store</a>. It’s exact same build as the current TestFlight build that expired. It installs over your TestFlight build without affecting your data — it’s safe to do.</p>
+
+ For people using NetNewsWire via TestFlight: the current TestFlight build has expired, and it will be a few days before a new build has been uploaded and reviewed by Apple.
+
+However, you can [install the current build from the App Store](https://apps.apple.com/us/app/netnewswire-rss-reader/id1480640210). It’s exact same build as the current TestFlight build that expired. It installs over your TestFlight build without affecting your data — it’s safe to do.
+
+
+
+
+ TestFlight Build Expired, New One Coming
+ https://netnewswire.blog/2025/04/22/testflight-build-expired-new-one.html
+ Tue, 22 Apr 2025 08:54:04 -0700
+
+ http://NetNewsWire.micro.blog/2025/04/22/testflight-build-expired-new-one.html
+ <p>Note for people using the TestFlight builds — the latest build expired today (sorry about that!), but a new one (6.1.9) is waiting for App Store review. As soon as it gets through the process, you’ll have a new TestFlight build.</p>
+<p>If you don’t want to wait, you can of course switch to the <a href="https://apps.apple.com/us/app/netnewswire-rss-reader/id1480640210">App Store build</a> instead without losing any data.</p>
+
+ Note for people using the TestFlight builds — the latest build expired today (sorry about that!), but a new one (6.1.9) is waiting for App Store review. As soon as it gets through the process, you’ll have a new TestFlight build.
+
+If you don’t want to wait, you can of course switch to the [App Store build](https://apps.apple.com/us/app/netnewswire-rss-reader/id1480640210) instead without losing any data.
+
+
+
+
+ NetNewsWire 6.1.7 for Mac
+ https://netnewswire.blog/2024/12/16/netnewswire-for-mac.html
+ Mon, 16 Dec 2024 09:35:23 -0700
+
+ http://NetNewsWire.micro.blog/2024/12/16/netnewswire-for-mac.html
+ <p>NetNewsWire 6.1.7 for Mac is available through the standard ways — check for updates or <a href="https://github.com/Ranchero-Software/NetNewsWire/releases/download/mac-6.1.7-release/NetNewsWire6.1.7.zip">download it directly</a>.</p>
+<p>Changes:</p>
+<ul>
+<li>Fix bug clearing refresh progress</li>
+<li>Fix bandwidth bugs with downloading web pages to find feed icons and favicons</li>
+<li>Update default theme with enhancements by John Gruber</li>
+<li>Space out requests made to openrss.org</li>
+<li>Send user-agent with platform, version, and build to openrss.org (and only to that site)</li>
+</ul>
+<p>Note — we are working on an iOS release with the same changes that have been going into the Mac releases. If you’re willing to help test, you can <a href="https://testflight.apple.com/join/wpedPMRR">sign up for the TestFlight program</a>.</p>
+
+ NetNewsWire 6.1.7 for Mac is available through the standard ways — check for updates or [download it directly](https://github.com/Ranchero-Software/NetNewsWire/releases/download/mac-6.1.7-release/NetNewsWire6.1.7.zip).
+
+Changes:
+
+* Fix bug clearing refresh progress
+* Fix bandwidth bugs with downloading web pages to find feed icons and favicons
+* Update default theme with enhancements by John Gruber
+* Space out requests made to openrss.org
+* Send user-agent with platform, version, and build to openrss.org (and only to that site)
+
+Note — we are working on an iOS release with the same changes that have been going into the Mac releases. If you’re willing to help test, you can [sign up for the TestFlight program](https://testflight.apple.com/join/wpedPMRR).
+
+
+
+
+ NetNewsWire 6.1.5 for iOS TestFlight
+ https://netnewswire.blog/2024/12/07/netnewswire-for-ios.html
+ Sat, 07 Dec 2024 13:59:46 -0700
+
+ http://NetNewsWire.micro.blog/2024/12/07/netnewswire-for-ios.html
+ <p>NetNewsWire 6.1.5 for iOS — which has the same bandwidth-use fixes as the <a href="https://netnewswire.blog/2024/12/05/netnewswire-for-mac.html">recent Mac release</a> — is available for testing now via TestFlight.</p>
+<p>If you’re willing to help, please <a href="https://testflight.apple.com/join/wpedPMRR">sign up for the NetNewsWire TestFlight program</a>. The team appreciates your help! 🍕🎸🕶️</p>
+
+ NetNewsWire 6.1.5 for iOS — which has the same bandwidth-use fixes as the [recent Mac release](https://netnewswire.blog/2024/12/05/netnewswire-for-mac.html) — is available for testing now via TestFlight.
+
+If you’re willing to help, please [sign up for the NetNewsWire TestFlight program](https://testflight.apple.com/join/wpedPMRR). The team appreciates your help! 🍕🎸🕶️
+
+
+
+
+ NetNewsWire 6.1.6 for Mac
+ https://netnewswire.blog/2024/12/05/netnewswire-for-mac.html
+ Thu, 05 Dec 2024 13:51:45 -0700
+
+ http://NetNewsWire.micro.blog/2024/12/05/netnewswire-for-mac.html
+ <p>NetNewsWire 6.1.6 for Mac is out — do a check for updates or <a href="https://github.com/Ranchero-Software/NetNewsWire/releases/download/mac-6.1.6/NetNewsWire6.1.6.zip">download it directly</a>.</p>
+<p>The theme of this release is using less bandwidth (and, as a consequence, less battery). It fixes a <a href="https://inessential.com/2024/08/03/netnewswire_and_conditional_get_issues.html">conditional GET issue</a> and it now pays attention to Cache-Control response headers and 429 response codes.</p>
+<p>There are also a few other small bug fixes:</p>
+<ul>
+<li>Restore toolbar button to show and hide the sidebar</li>
+<li>Add keyboard shortcuts for Copy Article URL and Copy External URL menu items</li>
+<li>Fix a few AppleScript support bugs</li>
+</ul>
+<p>Note: this version requires macOS 13 and up.</p>
+<p>Also note: the equivalent update for iOS is in testing — if you’d like to help, you can <a href="https://testflight.apple.com/join/wpedPMRR">join the TestFlight program</a>.</p>
+
+ NetNewsWire 6.1.6 for Mac is out — do a check for updates or [download it directly](https://github.com/Ranchero-Software/NetNewsWire/releases/download/mac-6.1.6/NetNewsWire6.1.6.zip).
+
+The theme of this release is using less bandwidth (and, as a consequence, less battery). It fixes a [conditional GET issue](https://inessential.com/2024/08/03/netnewswire_and_conditional_get_issues.html) and it now pays attention to Cache-Control response headers and 429 response codes.
+
+There are also a few other small bug fixes:
+
+* Restore toolbar button to show and hide the sidebar
+* Add keyboard shortcuts for Copy Article URL and Copy External URL menu items
+* Fix a few AppleScript support bugs
+
+Note: this version requires macOS 13 and up.
+
+Also note: the equivalent update for iOS is in testing — if you’d like to help, you can [join the TestFlight program](https://testflight.apple.com/join/wpedPMRR).
+
+
+
+
+ Conditional GET Issues
+ https://netnewswire.blog/2024/08/03/conditional-get-issues.html
+ Sat, 03 Aug 2024 14:56:24 -0700
+
+ http://NetNewsWire.micro.blog/2024/08/03/conditional-get-issues.html
+ <p>On his blog, Brent writes about <a href="https://inessential.com/2024/08/03/netnewswire_and_conditional_get_issues.html">NetNewsWire and Conditional GET issues</a>.</p>
+
+ On his blog, Brent writes about [NetNewsWire and Conditional GET issues](https://inessential.com/2024/08/03/netnewswire_and_conditional_get_issues.html).
+
+
+
+
+
diff --git a/Tests/FeedKitTests/Resources/xml/SourceMarkdown.xml b/Tests/FeedKitTests/Resources/xml/SourceMarkdown.xml
new file mode 100644
index 0000000..27a07e9
--- /dev/null
+++ b/Tests/FeedKitTests/Resources/xml/SourceMarkdown.xml
@@ -0,0 +1,14 @@
+
+
+
+ Test Blog
+ https://example.com/blog
+ A test blog feed with source:markdown elements
+
+ Hello World
+ https://example.com/blog/hello-world
+ <p>Hello, <strong>world</strong>!</p>
+ Hello, **world**!
+
+
+
diff --git a/Tests/FeedKitTests/Tests/AtomFeedEncodingTests.swift b/Tests/FeedKitTests/Tests/AtomFeedEncodingTests.swift
new file mode 100644
index 0000000..569c1a7
--- /dev/null
+++ b/Tests/FeedKitTests/Tests/AtomFeedEncodingTests.swift
@@ -0,0 +1,117 @@
+//
+// AtomFeedEncodingTests.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.
+
+@testable import FeedKit
+import Testing
+
+@Suite("Atom Encoding")
+struct AtomFeedEncodingTests: FeedKitTestable {
+ @Test("The default Atom namespace is always declared on the root element")
+ func declaresDefaultAtomNamespace() throws {
+ // Given
+ let feed = AtomFeed(title: .init(text: "Test Feed"))
+
+ // When
+ let xml = try feed.toXMLString(formatted: true)
+
+ // Then
+ #expect(xml.contains(#""#))
+ }
+
+ @Test("xmlns:media is declared when an entry has media content, and omitted otherwise")
+ func declaresMediaNamespaceWhenPresent() throws {
+ // Given
+ let feedWithMedia = AtomFeed(
+ title: .init(text: "Test Feed"),
+ entries: [
+ .init(
+ title: "Entry 1",
+ media: .init(contents: [.init(attributes: .init(url: "http://example.com/video.mp4"))])
+ )
+ ]
+ )
+ let feedWithoutMedia = AtomFeed(title: .init(text: "Test Feed"))
+
+ // When
+ let xmlWithMedia = try feedWithMedia.toXMLString(formatted: true)
+ let xmlWithoutMedia = try feedWithoutMedia.toXMLString(formatted: true)
+
+ // Then
+ #expect(xmlWithMedia.contains(#"xmlns:media="http://search.yahoo.com/mrss/""#))
+ #expect(!xmlWithoutMedia.contains("xmlns:media"))
+ }
+
+ @Test("xmlns:dc is declared when Dublin Core metadata is present, and omitted otherwise")
+ func declaresDublinCoreNamespaceWhenPresent() throws {
+ // Given
+ let feedWithDublinCore = AtomFeed(
+ title: .init(text: "Test Feed"),
+ dublinCore: .init(creator: "Jane Doe")
+ )
+ let feedWithoutDublinCore = AtomFeed(title: .init(text: "Test Feed"))
+
+ // When
+ let xmlWithDublinCore = try feedWithDublinCore.toXMLString(formatted: true)
+ let xmlWithoutDublinCore = try feedWithoutDublinCore.toXMLString(formatted: true)
+
+ // Then
+ #expect(xmlWithDublinCore.contains(#"xmlns:dc="http://purl.org/dc/elements/1.1/""#))
+ #expect(!xmlWithoutDublinCore.contains("xmlns:dc"))
+ }
+
+ @Test("xmlns:yt is declared when an entry has YouTube metadata, and omitted otherwise")
+ func declaresYouTubeNamespaceWhenPresent() throws {
+ // Given
+ let feedWithYouTube = AtomFeed(
+ title: .init(text: "Test Feed"),
+ entries: [.init(title: "Entry 1", youTube: .init(videoID: "abc123"))]
+ )
+ let feedWithoutYouTube = AtomFeed(title: .init(text: "Test Feed"))
+
+ // When
+ let xmlWithYouTube = try feedWithYouTube.toXMLString(formatted: true)
+ let xmlWithoutYouTube = try feedWithoutYouTube.toXMLString(formatted: true)
+
+ // Then
+ #expect(xmlWithYouTube.contains(#"xmlns:yt="http://www.youtube.com/xml/schemas/2015""#))
+ #expect(!xmlWithoutYouTube.contains("xmlns:yt"))
+ }
+
+ @Test("xmlns:georss is declared when an entry has GeoRSS data, and omitted otherwise")
+ func declaresGeoRSSNamespaceWhenPresent() throws {
+ // Given
+ let feedWithGeoRSS = AtomFeed(
+ title: .init(text: "Test Feed"),
+ entries: [.init(title: "Entry 1", geoRSS: .init(point: .init(position: (latitude: 45, longitude: -5))))]
+ )
+ let feedWithoutGeoRSS = AtomFeed(title: .init(text: "Test Feed"))
+
+ // When
+ let xmlWithGeoRSS = try feedWithGeoRSS.toXMLString(formatted: true)
+ let xmlWithoutGeoRSS = try feedWithoutGeoRSS.toXMLString(formatted: true)
+
+ // Then
+ #expect(xmlWithGeoRSS.contains(#"xmlns:georss="http://www.georss.org/georss""#))
+ #expect(!xmlWithoutGeoRSS.contains("xmlns:georss"))
+ }
+}
diff --git a/Tests/FeedKitTests/Tests/AtomTests.swift b/Tests/FeedKitTests/Tests/AtomTests.swift
index 8655d8a..10eb0e4 100644
--- a/Tests/FeedKitTests/Tests/AtomTests.swift
+++ b/Tests/FeedKitTests/Tests/AtomTests.swift
@@ -51,4 +51,24 @@ struct AtomTests: FeedKitTestable {
// Then
#expect(expected == actual)
}
+
+ @Test("A declared, non-conventional prefix bound to the Atom namespace URI still decodes an atom:link inside RSS")
+ func atomLinkInRSSWithAlternatePrefix() throws {
+ // Given
+ let xml = """
+
+
+
+ Test Channel
+
+
+
+ """
+
+ // When
+ let feed = try RSSFeed(string: xml)
+
+ // Then
+ #expect(feed.channel?.atom?.links?.first?.attributes?.href == "http://example.com/feed")
+ }
}
diff --git a/Tests/FeedKitTests/Tests/CommentAPITests + Mocks.swift b/Tests/FeedKitTests/Tests/CommentAPITests + Mocks.swift
new file mode 100644
index 0000000..949c5d6
--- /dev/null
+++ b/Tests/FeedKitTests/Tests/CommentAPITests + Mocks.swift
@@ -0,0 +1,44 @@
+//
+// CommentAPITests + Mocks.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.
+
+@testable import FeedKit
+
+extension CommentAPITests {
+ var mock: RSSFeed {
+ .init(
+ channel: .init(
+ title: "Test Blog",
+ items: [
+ .init(
+ title: "Hello World",
+ link: "http://example.com/2024/01/01/hello-world/",
+ commentAPI: .init(
+ comment: "http://example.com/wp-comments-post.php?p=1",
+ commentRss: "http://example.com/2024/01/01/hello-world/feed/"
+ )
+ )
+ ]
+ )
+ )
+ }
+}
diff --git a/Tests/FeedKitTests/Tests/CommentAPITests.swift b/Tests/FeedKitTests/Tests/CommentAPITests.swift
new file mode 100644
index 0000000..07ebee4
--- /dev/null
+++ b/Tests/FeedKitTests/Tests/CommentAPITests.swift
@@ -0,0 +1,64 @@
+//
+// CommentAPITests.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.
+
+@testable import FeedKit
+import Testing
+
+@Suite("Comment API")
+struct CommentAPITests: FeedKitTestable {
+ @Test
+ func commentAPI() throws {
+ // Given
+ let data = data(resource: "CommentAPI", withExtension: "xml")
+ let expected: RSSFeed = mock
+
+ // When
+ let actual = try RSSFeed(data: data)
+
+ // Then
+ #expect(expected == actual)
+ }
+
+ @Test("A declared, non-conventional prefix bound to the Comment API namespace URI still decodes")
+ func commentAPIWithAlternatePrefix() throws {
+ // Given
+ let xml = """
+
+
+
+ Test Blog
+
+ Test Item
+ http://example.com/feed/comments/1234
+
+
+
+ """
+
+ // When
+ let feed = try RSSFeed(string: xml)
+
+ // Then
+ #expect(feed.channel?.items?.first?.commentAPI?.commentRss == "http://example.com/feed/comments/1234")
+ }
+}
diff --git a/Tests/FeedKitTests/Tests/ContentTests.swift b/Tests/FeedKitTests/Tests/ContentTests.swift
index c254c47..7242ee2 100644
--- a/Tests/FeedKitTests/Tests/ContentTests.swift
+++ b/Tests/FeedKitTests/Tests/ContentTests.swift
@@ -21,8 +21,10 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
+import Foundation
@testable import FeedKit
import Testing
+import XMLKit
@Suite("Content")
struct ContentTests: FeedKitTestable {
@@ -38,4 +40,53 @@ struct ContentTests: FeedKitTestable {
// Then
#expect(expected == actual)
}
+
+ @Test("A declared, non-conventional prefix bound to the content namespace URI still decodes")
+ func contentWithAlternatePrefix() throws {
+ // Given
+ let xml = """
+
+
+
+ Test Channel
+
+ Test Item
+ Full content
+
+
+
+ """
+
+ // When
+ let feed = try RSSFeed(string: xml)
+
+ // Then
+ #expect(feed.channel?.items?.first?.content?.encoded == "Full content")
+ }
+
+ @Test("An undeclared, conventionally-prefixed content: element decodes under .lenient but not under .strict")
+ func contentUndeclaredPrefixIsLenientByDefault() throws {
+ // Given
+ let xml = """
+
+
+
+ Test Channel
+
+ Test Item
+ Full content
+
+
+
+ """
+ let data = try #require(xml.data(using: .utf8))
+
+ // When
+ let lenientFeed = try RSSFeed(data: data, namespaceHandling: .lenient)
+ let strictFeed = try RSSFeed(data: data, namespaceHandling: .strict)
+
+ // Then
+ #expect(lenientFeed.channel?.items?.first?.content?.encoded == "Full content")
+ #expect(strictFeed.channel?.items?.first?.content == nil)
+ }
}
diff --git a/Tests/FeedKitTests/Tests/DublinCoreTests.swift b/Tests/FeedKitTests/Tests/DublinCoreTests.swift
index fa3cc21..35050dc 100644
--- a/Tests/FeedKitTests/Tests/DublinCoreTests.swift
+++ b/Tests/FeedKitTests/Tests/DublinCoreTests.swift
@@ -21,8 +21,10 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
+import Foundation
@testable import FeedKit
import Testing
+import XMLKit
@Suite("Dublin Core")
struct DublinCoreTests: FeedKitTestable {
@@ -51,4 +53,53 @@ struct DublinCoreTests: FeedKitTestable {
// Then
#expect(expected == actual)
}
+
+ @Test("A declared, non-conventional prefix bound to the Dublin Core namespace URI still decodes")
+ func dublinCoreWithAlternatePrefix() throws {
+ // Given
+ let xml = """
+
+
+
+ Test Channel
+
+ Test Item
+ Jane Doe
+
+
+
+ """
+
+ // When
+ let feed = try RSSFeed(string: xml)
+
+ // Then
+ #expect(feed.channel?.items?.first?.dublinCore?.creator == "Jane Doe")
+ }
+
+ @Test("An undeclared, conventionally-prefixed dc: element decodes under .lenient but not under .strict")
+ func dublinCoreUndeclaredPrefixIsLenientByDefault() throws {
+ // Given
+ let xml = """
+
+
+
+ Test Channel
+
+ Test Item
+ Jane Doe
+
+
+
+ """
+ let data = try #require(xml.data(using: .utf8))
+
+ // When
+ let lenientFeed = try RSSFeed(data: data, namespaceHandling: .lenient)
+ let strictFeed = try RSSFeed(data: data, namespaceHandling: .strict)
+
+ // Then
+ #expect(lenientFeed.channel?.items?.first?.dublinCore?.creator == "Jane Doe")
+ #expect(strictFeed.channel?.items?.first?.dublinCore == nil)
+ }
}
diff --git a/Tests/FeedKitTests/Tests/FeedNamespaceTests.swift b/Tests/FeedKitTests/Tests/FeedNamespaceTests.swift
new file mode 100644
index 0000000..a73498f
--- /dev/null
+++ b/Tests/FeedKitTests/Tests/FeedNamespaceTests.swift
@@ -0,0 +1,114 @@
+//
+// FeedNamespaceTests.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.
+
+@testable import FeedKit
+import Testing
+
+@Suite("FeedNamespace")
+struct FeedNamespaceTests {
+ @Test("Every namespace's attributeName is xmlns: followed by its bare prefix")
+ func attributeNameMatchesPrefix() {
+ for namespace in FeedNamespace.allCases {
+ #expect(namespace.attributeName == "xmlns:\(namespace.prefix)")
+ }
+ }
+
+ @Test("init?(url:) resolves every known namespace's URL back to itself")
+ func urlLookupResolvesKnownNamespaces() {
+ for namespace in FeedNamespace.allCases {
+ #expect(FeedNamespace(url: namespace.url) == namespace)
+ }
+ }
+
+ @Test("init?(url:) returns nil for an unknown URL")
+ func urlLookupReturnsNilForUnknownNamespace() {
+ #expect(FeedNamespace(url: "http://example.com/not-a-namespace") == nil)
+ }
+
+ @Test("namespaceMap contains every namespace's URL mapped to its bare prefix")
+ func namespaceMapContainsAllCases() {
+ let map = FeedNamespace.namespaceMap
+ #expect(map.count == FeedNamespace.allCases.count)
+ for namespace in FeedNamespace.allCases {
+ #expect(map[namespace.url] == namespace.prefix)
+ }
+ }
+
+ @Test("shouldInclude(in: RSSFeed) reflects channel- and item-level namespaced content")
+ func shouldIncludeInRSSFeed() {
+ let feed = RSSFeed(
+ channel: .init(
+ items: [.init(markdown: "**hi**", dublinCore: .init(creator: "Jane"))],
+ dublinCore: .init(title: "Channel DC"),
+ atom: .init(links: [.init(attributes: .init(href: "http://example.com/feed"))])
+ )
+ )
+
+ #expect(FeedNamespace.dublinCore.shouldInclude(in: feed))
+ #expect(FeedNamespace.atom.shouldInclude(in: feed))
+ #expect(FeedNamespace.source.shouldInclude(in: feed))
+ #expect(!FeedNamespace.media.shouldInclude(in: feed))
+ #expect(!FeedNamespace.podcast.shouldInclude(in: feed))
+ }
+
+ @Test("shouldInclude(in: AtomFeed) includes dc and media, matching AtomFeed/AtomFeedEntry's actual fields")
+ func shouldIncludeInAtomFeedCoversDublinCoreAndMedia() {
+ let feedWithChannelLevelDublinCore = AtomFeed(dublinCore: .init(creator: "Jane"))
+ let feedWithEntryLevelDublinCore = AtomFeed(entries: [.init(dublinCore: .init(creator: "Jane"))])
+ let feedWithMedia = AtomFeed(entries: [
+ .init(media: .init(contents: [.init(attributes: .init(url: "http://example.com/video.mp4"))]))
+ ])
+ let plainFeed = AtomFeed()
+
+ #expect(FeedNamespace.dublinCore.shouldInclude(in: feedWithChannelLevelDublinCore))
+ #expect(FeedNamespace.dublinCore.shouldInclude(in: feedWithEntryLevelDublinCore))
+ #expect(FeedNamespace.media.shouldInclude(in: feedWithMedia))
+ #expect(!FeedNamespace.dublinCore.shouldInclude(in: plainFeed))
+ #expect(!FeedNamespace.media.shouldInclude(in: plainFeed))
+ }
+
+ @Test(
+ "shouldInclude(in: AtomFeed) is false for namespaces AtomFeed/AtomFeedEntry have no field for",
+ arguments: [
+ FeedNamespace.itunes,
+ FeedNamespace.syndication,
+ FeedNamespace.content,
+ FeedNamespace.gml,
+ FeedNamespace.atom,
+ FeedNamespace.podcast,
+ FeedNamespace.source
+ ]
+ )
+ func shouldIncludeInAtomFeedIsFalseForUnsupportedNamespaces(namespace: FeedNamespace) {
+ let feed = AtomFeed(
+ entries: [.init(
+ media: .init(contents: [.init(attributes: .init(url: "http://example.com/video.mp4"))]),
+ youTube: .init(videoID: "abc123"),
+ geoRSS: .init(point: .init(position: (latitude: 45, longitude: -5)))
+ )],
+ dublinCore: .init(creator: "Jane")
+ )
+
+ #expect(!namespace.shouldInclude(in: feed))
+ }
+}
diff --git a/Tests/FeedKitTests/Tests/GeoRSSSimpleTests.swift b/Tests/FeedKitTests/Tests/GeoRSSSimpleTests.swift
index a509bca..c93bc99 100644
--- a/Tests/FeedKitTests/Tests/GeoRSSSimpleTests.swift
+++ b/Tests/FeedKitTests/Tests/GeoRSSSimpleTests.swift
@@ -38,4 +38,26 @@ struct GeoRSSSimpleTests: FeedKitTestable {
// Then
#expect(expected == actual)
}
+
+ @Test("A declared, non-conventional prefix bound to the GeoRSS namespace URI still decodes")
+ func geoRSSSimpleWithAlternatePrefix() throws {
+ // Given
+ let xml = """
+
+
+ Test Feed
+
+ Entry 1
+ 45 -5
+
+
+ """
+
+ // When
+ let feed = try AtomFeed(string: xml)
+
+ // Then
+ #expect(feed.entries?.first?.geoRSS?.point?.position?.latitude == 45)
+ #expect(feed.entries?.first?.geoRSS?.point?.position?.longitude == -5)
+ }
}
diff --git a/Tests/FeedKitTests/Tests/MediaTests.swift b/Tests/FeedKitTests/Tests/MediaTests.swift
index 5ea7d7d..191b0f9 100644
--- a/Tests/FeedKitTests/Tests/MediaTests.swift
+++ b/Tests/FeedKitTests/Tests/MediaTests.swift
@@ -21,8 +21,10 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
+import Foundation
@testable import FeedKit
import Testing
+import XMLKit
@Suite("Media")
struct MediaTests: FeedKitTestable {
@@ -38,4 +40,53 @@ struct MediaTests: FeedKitTestable {
// Then
#expect(expected == actual)
}
+
+ @Test("A declared, non-conventional prefix bound to the Media (MRSS) namespace URI still decodes")
+ func mediaWithAlternatePrefix() throws {
+ // Given
+ let xml = """
+
+
+
+ Test Channel
+
+ Test Item
+
+
+
+
+ """
+
+ // When
+ let feed = try RSSFeed(string: xml)
+
+ // Then
+ #expect(feed.channel?.items?.first?.media?.contents?.first?.attributes?.url == "http://example.com/video.mp4")
+ }
+
+ @Test("An undeclared, conventionally-prefixed media: element decodes under .lenient but not under .strict")
+ func mediaUndeclaredPrefixIsLenientByDefault() throws {
+ // Given
+ let xml = """
+
+
+
+ Test Channel
+
+ Test Item
+
+
+
+
+ """
+ let data = try #require(xml.data(using: .utf8))
+
+ // When
+ let lenientFeed = try RSSFeed(data: data, namespaceHandling: .lenient)
+ let strictFeed = try RSSFeed(data: data, namespaceHandling: .strict)
+
+ // Then
+ #expect(lenientFeed.channel?.items?.first?.media?.contents?.first?.attributes?.url == "http://example.com/video.mp4")
+ #expect(strictFeed.channel?.items?.first?.media == nil)
+ }
}
diff --git a/Tests/FeedKitTests/Tests/NetNewsWireTests.swift b/Tests/FeedKitTests/Tests/NetNewsWireTests.swift
new file mode 100644
index 0000000..aaf1f4b
--- /dev/null
+++ b/Tests/FeedKitTests/Tests/NetNewsWireTests.swift
@@ -0,0 +1,63 @@
+//
+// NetNewsWireTests.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.
+
+@testable import FeedKit
+import Testing
+
+/// A real-world regression fixture: a frozen snapshot of
+/// https://netnewswire.blog/feed.xml, an RSS feed that declares
+/// `xmlns:source="http://source.scripting.com/"` and uses `source:markdown`
+/// on every item.
+@Suite("NetNewsWire")
+struct NetNewsWireTests: FeedKitTestable {
+ @Test
+ func netNewsWire() throws {
+ // Given
+ let data = data(resource: "NetNewsWire", withExtension: "xml")
+
+ // When
+ let feed = try RSSFeed(data: data)
+
+ // Then
+ #expect(feed.channel?.title == "NetNewsWire")
+ #expect(feed.channel?.link == "https://netnewswire.blog/")
+
+ let items = try #require(feed.channel?.items)
+ #expect(items.count == 25)
+
+ // Every item on this feed carries a source:markdown element; this is a
+ // broad regression check that the source namespace resolves across the
+ // whole document, not just for a single hand-picked item.
+ #expect(items.allSatisfy { $0.markdown != nil })
+
+ let first = try #require(items.first)
+ #expect(first.title == "How to Beta Test NetNewsWire")
+ #expect(first.link == "https://netnewswire.blog/2026/06/29/how-to-beta-test-netnewswire.html")
+ #expect(first.guid?.text == "http://NetNewsWire.micro.blog/2026/06/29/how-to-beta-test-netnewswire.html")
+ #expect(first.markdown == """
+ We very much appreciate the bug reports and feedback from NetNewsWire beta testers —\u{00A0}and we want to make sure that everyone who might be interested in helping this way knows how to get started. It’s easy. 😀
+
+ So we’ve written up a new [How to Beta Test NetNewsWire](https://netnewswire.com/help/beta-testing.html) page. Want to help make NetNewsWire a better app? This is how!
+ """)
+ }
+}
diff --git a/Tests/FeedKitTests/Tests/PodcastTests + Mocks.swift b/Tests/FeedKitTests/Tests/PodcastTests + Mocks.swift
new file mode 100644
index 0000000..8d867a7
--- /dev/null
+++ b/Tests/FeedKitTests/Tests/PodcastTests + Mocks.swift
@@ -0,0 +1,70 @@
+//
+// PodcastTests + Mocks.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.
+
+@testable import FeedKit
+
+extension PodcastTests {
+ var rssMock: RSSFeed {
+ .init(
+ channel: .init(
+ title: "Test Podcast",
+ link: "https://example.com/podcast",
+ description: "A test podcast feed with podcast namespace elements",
+ items: [
+ .init(
+ title: "Episode 1: Introduction",
+ description: "This is the first episode of our test podcast.",
+ podcast: .init(
+ transcripts: [
+ .init(attributes: .init(
+ url: "https://example.com/episode1/transcript.txt",
+ type: "text/plain",
+ language: "en"
+ )),
+ .init(attributes: .init(
+ url: "https://example.com/episode1/transcript.vtt",
+ type: "text/vtt",
+ language: "en",
+ rel: "captions"
+ ))
+ ]
+ )
+ ),
+ .init(
+ title: "Episode 2: Advanced Topics",
+ description: "This episode covers advanced topics.",
+ podcast: .init(
+ transcripts: [
+ .init(attributes: .init(
+ url: "https://example.com/episode2/transcript.txt",
+ type: "text/plain",
+ language: "en"
+ ))
+ ]
+ )
+ )
+ ]
+ )
+ )
+ }
+}
diff --git a/Tests/FeedKitTests/Tests/PodcastTests.swift b/Tests/FeedKitTests/Tests/PodcastTests.swift
new file mode 100644
index 0000000..af40425
--- /dev/null
+++ b/Tests/FeedKitTests/Tests/PodcastTests.swift
@@ -0,0 +1,41 @@
+//
+// PodcastTests.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.
+
+@testable import FeedKit
+import Testing
+
+@Suite("Podcast")
+struct PodcastTests: FeedKitTestable {
+ @Test
+ func podcast() throws {
+ // Given
+ let data = data(resource: "Podcast", withExtension: "xml")
+ let expected: RSSFeed = rssMock
+
+ // When
+ let actual = try RSSFeed(data: data)
+
+ // Then
+ #expect(expected == actual)
+ }
+}
diff --git a/Tests/FeedKitTests/Tests/RSSFeedEncodingTests.swift b/Tests/FeedKitTests/Tests/RSSFeedEncodingTests.swift
new file mode 100644
index 0000000..b2ca5ef
--- /dev/null
+++ b/Tests/FeedKitTests/Tests/RSSFeedEncodingTests.swift
@@ -0,0 +1,78 @@
+//
+// RSSFeedEncodingTests.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.
+
+@testable import FeedKit
+import Testing
+
+@Suite("RSS Encoding")
+struct RSSFeedEncodingTests: FeedKitTestable {
+ @Test("Ampersands in a media:content url attribute are escaped as &")
+ func escapesAmpersandInMediaContentURL() throws {
+ // Given
+ let feed = RSSFeed(
+ channel: .init(
+ title: "Test Channel",
+ items: [
+ .init(
+ title: "Test Item",
+ media: .init(
+ contents: [
+ .init(attributes: .init(url: "http://example.com/video?a=1&b=2"))
+ ]
+ )
+ )
+ ]
+ )
+ )
+
+ // When
+ let xml = try feed.toXMLString(formatted: true)
+
+ // Then
+ #expect(xml.contains(#"url="http://example.com/video?a=1&b=2""#))
+ #expect(!xml.contains(#"url="http://example.com/video?a=1&b=2""#))
+ }
+
+ @Test("Ampersands in element text (e.g. link) are escaped as &")
+ func escapesAmpersandInElementText() throws {
+ // Given
+ let feed = RSSFeed(
+ channel: .init(
+ title: "Test Channel",
+ items: [
+ .init(
+ title: "Test Item",
+ link: "http://example.com/article?a=1&b=2"
+ )
+ ]
+ )
+ )
+
+ // When
+ let xml = try feed.toXMLString(formatted: true)
+
+ // Then
+ #expect(xml.contains("http://example.com/article?a=1&b=2"))
+ #expect(!xml.contains("http://example.com/article?a=1&b=2"))
+ }
+}
diff --git a/Tests/FeedKitTests/Tests/SourceMarkdownTests.swift b/Tests/FeedKitTests/Tests/SourceMarkdownTests.swift
new file mode 100644
index 0000000..821739e
--- /dev/null
+++ b/Tests/FeedKitTests/Tests/SourceMarkdownTests.swift
@@ -0,0 +1,55 @@
+//
+// SourceMarkdownTests.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.
+
+@testable import FeedKit
+import Testing
+
+@Suite("Source Markdown")
+struct SourceMarkdownTests: FeedKitTestable {
+ @Test
+ func sourceMarkdown() throws {
+ // Given
+ let data = data(resource: "SourceMarkdown", withExtension: "xml")
+ let expected = RSSFeed(
+ channel: .init(
+ title: "Test Blog",
+ link: "https://example.com/blog",
+ description: "A test blog feed with source:markdown elements",
+ items: [
+ .init(
+ title: "Hello World",
+ link: "https://example.com/blog/hello-world",
+ description: "
Hello, world!
",
+ markdown: "Hello, **world**!"
+ )
+ ]
+ )
+ )
+
+ // When
+ let actual = try RSSFeed(data: data)
+
+ // Then
+ #expect(expected == actual)
+ }
+}
diff --git a/Tests/FeedKitTests/Tests/SyndicationTests.swift b/Tests/FeedKitTests/Tests/SyndicationTests.swift
index cf422b7..e1c4c30 100644
--- a/Tests/FeedKitTests/Tests/SyndicationTests.swift
+++ b/Tests/FeedKitTests/Tests/SyndicationTests.swift
@@ -38,4 +38,24 @@ struct SyndicationTests: FeedKitTestable {
// Then
#expect(expected == actual)
}
+
+ @Test("A declared, non-conventional prefix bound to the Syndication namespace URI still decodes")
+ func syndicationWithAlternatePrefix() throws {
+ // Given
+ let xml = """
+
+
+
+ Test Channel
+ daily
+
+
+ """
+
+ // When
+ let feed = try RSSFeed(string: xml)
+
+ // Then
+ #expect(feed.channel?.syndication?.updatePeriod == .daily)
+ }
}
diff --git a/Tests/FeedKitTests/Tests/YouTubeTests.swift b/Tests/FeedKitTests/Tests/YouTubeTests.swift
index 75b4290..b63b09c 100644
--- a/Tests/FeedKitTests/Tests/YouTubeTests.swift
+++ b/Tests/FeedKitTests/Tests/YouTubeTests.swift
@@ -38,4 +38,25 @@ struct YouTubeTests: FeedKitTestable {
// Then
#expect(expected == actual)
}
+
+ @Test("A declared, non-conventional prefix bound to the YouTube namespace URI still decodes")
+ func youTubeWithAlternatePrefix() throws {
+ // Given
+ let xml = """
+
+
+ Test Feed
+
+ Entry 1
+ abc123
+
+
+ """
+
+ // When
+ let feed = try AtomFeed(string: xml)
+
+ // Then
+ #expect(feed.entries?.first?.youTube?.videoID == "abc123")
+ }
}
diff --git a/Tests/FeedKitTests/Tests/iTunesTests.swift b/Tests/FeedKitTests/Tests/iTunesTests.swift
index 4a99ed2..5fae4d9 100644
--- a/Tests/FeedKitTests/Tests/iTunesTests.swift
+++ b/Tests/FeedKitTests/Tests/iTunesTests.swift
@@ -37,4 +37,24 @@ struct iTunesTests: FeedKitTestable {
#expect(expected == actual)
}
+
+ @Test("A declared, non-conventional prefix bound to the iTunes namespace URI still decodes")
+ func itunesWithAlternatePrefix() throws {
+ // Given
+ let xml = """
+
+
+
+ Test Channel
+ Jane Doe
+
+
+ """
+
+ // When
+ let feed = try RSSFeed(string: xml)
+
+ // Then
+ #expect(feed.channel?.iTunes?.author == "Jane Doe")
+ }
}
diff --git a/Tests/XMLKitTests/Tests/SampleTests + Mocks.swift b/Tests/XMLKitTests/Tests/SampleTests + Mocks.swift
index 600280e..9c76b6c 100644
--- a/Tests/XMLKitTests/Tests/SampleTests + Mocks.swift
+++ b/Tests/XMLKitTests/Tests/SampleTests + Mocks.swift
@@ -358,11 +358,13 @@ extension SampleTests {
),
.init(
prefix: "ns",
+ namespaceURI: "http://example.ns/namespace",
name: "ns:title",
text: "This title is a sample namespace element."
),
.init(
prefix: "ns",
+ namespaceURI: "http://example.ns/namespace",
name: "ns:description",
text: "This description is a sample namespace element."
)