From d6bda7ce2fc3d735af3403ad5ffadb30beee5783 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bojan=20Stefanovi=C4=87?=
<5675392+bojanstef@users.noreply.github.com>
Date: Sat, 13 Jun 2026 02:06:54 -0400
Subject: [PATCH] Prefer a page-specific feed over a site-wide
feed in
Find Feed (#5299)
findFeedsInHTMLPage returned early with the feed whenever one existed,
discarding every body-discovered feed. For a /blog request on a site whose
advertises a site-wide feed (e.g. relay.fm's all-network feed) while the
page links its own feed in the body ("Subscribe via RSS"), the page-specific
feed was dropped before bestFeed could consider it.
Before falling back to the feed, validate any body-linked candidate
whose URL is on the same host and nested under the requested page's path, and
prefer it. Host comparison is www- and case-insensitive (via
localeForLowercasing); root requests (no path) keep the existing -feed
behavior, so whole-site Find Feed is unchanged. A unit test covers the
path/host matching.
Diagnosed by tracing the discovery path at runtime: both feeds were discovered,
but the body feed was discarded by the didFindFeedInHTMLHead early return before
bestFeed ran. Written with LLM assistance and reviewed to meet the project's
quality bar; clean module build with no new warnings, swiftlint --strict clean,
tests pass.
Generated-by: claude-opus-4-8
Co-Authored-By: Claude Opus 4.8
---
.../Sources/FeedFinder/FeedFinder.swift | 43 +++++++++++++++++++
.../FeedFinderTests/FeedFinderTests.swift | 31 +++++++++++++
2 files changed, 74 insertions(+)
diff --git a/Modules/FeedFinder/Sources/FeedFinder/FeedFinder.swift b/Modules/FeedFinder/Sources/FeedFinder/FeedFinder.swift
index 74731d2b72..bc02dd7fe1 100644
--- a/Modules/FeedFinder/Sources/FeedFinder/FeedFinder.swift
+++ b/Modules/FeedFinder/Sources/FeedFinder/FeedFinder.swift
@@ -144,6 +144,32 @@ public final class FeedFinder {
}
}
+extension FeedFinder {
+
+ /// True when `feedURLString` is on the same host as `pageURLString` and its
+ /// path equals or is nested under the page's path. The page must have a
+ /// non-root path — a root request (e.g. example.com/) gets no on-path
+ /// preference, preserving the existing -feed behavior for whole sites.
+ static func feedURLString(_ feedURLString: String, isUnderRequestedPageURLString pageURLString: String) -> Bool {
+ guard let feedURL = URL(string: feedURLString), let pageURL = URL(string: pageURLString),
+ let feedHost = feedURL.host(), let pageHost = pageURL.host() else {
+ return false
+ }
+ func normalizedHost(_ host: String) -> String {
+ let lowered = host.lowercased(with: localeForLowercasing)
+ return lowered.hasPrefix("www.") ? String(lowered.dropFirst("www.".count)) : lowered
+ }
+ guard normalizedHost(feedHost) == normalizedHost(pageHost) else {
+ return false
+ }
+ let pagePath = pageURL.path.hasSuffix("/") ? String(pageURL.path.dropLast()) : pageURL.path
+ guard pagePath.count > 1 else {
+ return false
+ }
+ return feedURL.path == pagePath || feedURL.path.hasPrefix(pagePath + "/")
+ }
+}
+
private extension FeedFinder {
static func addFeedSpecifier(_ feedSpecifier: FeedSpecifier, feedSpecifiers: inout [String: FeedSpecifier]) {
@@ -180,6 +206,23 @@ private extension FeedFinder {
}
}
+ // A body-linked feed whose URL lives *under* the requested page's path
+ // (e.g. /blog/feed for a /blog request) is more specific than a
+ // site-wide feed declared in (e.g. a network master feed). Prefer
+ // it: validate the on-path body candidates and, if any are real feeds,
+ // return those instead of the feed. Without this, any feed
+ // unconditionally suppressed every body feed, so a page-specific feed was
+ // discarded before it could be considered. (#5299)
+ let onPathToDownload = feedSpecifiersToDownload.filter {
+ Self.feedURLString($0.urlString, isUnderRequestedPageURLString: urlString)
+ }
+ if !onPathToDownload.isEmpty {
+ let onPathFeeds = await downloadFeedSpecifiers(onPathToDownload, feedSpecifiers: [:])
+ if !onPathFeeds.isEmpty {
+ return (onPathFeeds, .candidates)
+ }
+ }
+
if didFindFeedInHTMLHead {
return (Set(feedSpecifiers.values), .htmlHead)
} else if feedSpecifiersToDownload.isEmpty {
diff --git a/Modules/FeedFinder/Tests/FeedFinderTests/FeedFinderTests.swift b/Modules/FeedFinder/Tests/FeedFinderTests/FeedFinderTests.swift
index 8d43fc7e87..bc4e826472 100644
--- a/Modules/FeedFinder/Tests/FeedFinderTests/FeedFinderTests.swift
+++ b/Modules/FeedFinder/Tests/FeedFinderTests/FeedFinderTests.swift
@@ -15,4 +15,35 @@ final class FeedFinderTests: XCTestCase {
let feedFinder = FeedFinder()
XCTAssertNotNil(feedFinder)
}
+
+ // Covers #5299: a page-specific feed linked in the body (e.g. /blog/feed)
+ // must be recognized as "under" the requested page path so it can be
+ // preferred over a site-wide feed.
+ func testFeedURLIsUnderRequestedPagePath() {
+ func isUnder(_ feed: String, _ page: String) -> Bool {
+ FeedFinder.feedURLString(feed, isUnderRequestedPageURLString: page)
+ }
+
+ // The bug case: /blog/feed is under the requested /blog page…
+ XCTAssertTrue(isUnder("https://www.relay.fm/blog/feed", "https://www.relay.fm/blog"))
+ // …while the site-wide feed at a different path is not on-path.
+ XCTAssertFalse(isUnder("http://relay.fm/master/feed", "https://www.relay.fm/blog"))
+
+ // Host comparison is www- and case-insensitive.
+ XCTAssertTrue(isUnder("https://relay.fm/blog/feed", "https://www.relay.fm/blog"))
+ XCTAssertTrue(isUnder("https://WWW.Relay.FM/blog/feed", "https://www.relay.fm/blog"))
+
+ // A different host is never on-path.
+ XCTAssertFalse(isUnder("https://example.com/blog/feed", "https://www.relay.fm/blog"))
+
+ // A root request gets no on-path preference (whole-site Find Feed unchanged).
+ XCTAssertFalse(isUnder("https://relay.fm/feed", "https://relay.fm/"))
+ XCTAssertFalse(isUnder("https://relay.fm/feed", "https://relay.fm"))
+
+ // A sibling that merely shares a path prefix is not nested under it.
+ XCTAssertFalse(isUnder("https://relay.fm/blogger/feed", "https://relay.fm/blog"))
+
+ // An exact path match counts as on-path.
+ XCTAssertTrue(isUnder("https://relay.fm/blog", "https://relay.fm/blog"))
+ }
}