From 161b0acd7dd7ab3552ebd88422802a3c22f28663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 6 Nov 2025 10:41:37 +0100 Subject: [PATCH] Support decoding link summaries with absolute paths to external pages rdar://149470919 --- .../ExternalPathHierarchyResolver.swift | 2 +- .../LinkTargets/LinkDestinationSummary.swift | 32 +++++++++- .../ExternalReferenceResolverTests.swift | 58 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift index 7efffba881..44477ca526 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift @@ -192,7 +192,7 @@ extension LinkDestinationSummary { identifier: .init(referenceURL.absoluteString), titleVariants: titleVariants, abstractVariants: abstractVariants, - url: relativePresentationURL.absoluteString, + url: absolutePresentationURL?.absoluteString ?? relativePresentationURL.absoluteString, kind: kind, required: false, role: role, diff --git a/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift b/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift index e6407e9057..5c3af1ff6b 100644 --- a/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift +++ b/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift @@ -83,6 +83,11 @@ public struct LinkDestinationSummary: Codable, Equatable { /// The relative presentation URL for this element. public let relativePresentationURL: URL + /// The absolute presentation URL for this element, or `nil` if only the _relative_ presentation URL is known. + /// + /// - Note: The absolute presentation URL (if one exists) and the relative presentation URL will always have the same path and fragment components. + let absolutePresentationURL: URL? + /// The resolved topic reference URL to this element. public var referenceURL: URL @@ -359,6 +364,7 @@ public struct LinkDestinationSummary: Codable, Equatable { self.kind = kind self.language = language self.relativePresentationURL = relativePresentationURL + self.absolutePresentationURL = nil self.referenceURL = referenceURL self.title = title self.abstract = abstract @@ -763,7 +769,9 @@ extension LinkDestinationSummary { } catch { kind = try container.decode(DocumentationNode.Kind.self, forKey: .kind) } - relativePresentationURL = try container.decode(URL.self, forKey: .relativePresentationURL) + let decodedURL = try container.decode(URL.self, forKey: .relativePresentationURL) + (relativePresentationURL, absolutePresentationURL) = Self.checkIfDecodedURLWasAbsolute(decodedURL) + referenceURL = try container.decode(URL.self, forKey: .referenceURL) title = try container.decode(String.self, forKey: .title) abstract = try container.decodeIfPresent(Abstract.self, forKey: .abstract) @@ -808,6 +816,28 @@ extension LinkDestinationSummary { variants = try container.decodeIfPresent([Variant].self, forKey: .variants) ?? [] } + + private static func checkIfDecodedURLWasAbsolute(_ decodedURL: URL) -> (relative: URL, absolute: URL?) { + guard decodedURL.isAbsoluteWebURL, + var components = URLComponents(url: decodedURL, resolvingAgainstBaseURL: false) + else { + // If the decoded URL isn't an absolute web URL that's valid according to RFC 3986, then treat it as relative. + return (relative: decodedURL, absolute: nil) + } + + // Remove the scheme, user, port, and host to create a relative URL. + components.scheme = nil + components.user = nil + components.host = nil + components.port = nil + + guard let relativeURL = components.url else { + // If we can't create a relative URL that's valid according to RFC 3986, then treat the original as relative. + return (relative: decodedURL, absolute: nil) + } + + return (relative: relativeURL, absolute: decodedURL) + } } extension LinkDestinationSummary.Variant { diff --git a/Tests/SwiftDocCTests/Infrastructure/ExternalReferenceResolverTests.swift b/Tests/SwiftDocCTests/Infrastructure/ExternalReferenceResolverTests.swift index 803868d2ad..a5aae23123 100644 --- a/Tests/SwiftDocCTests/Infrastructure/ExternalReferenceResolverTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/ExternalReferenceResolverTests.swift @@ -1379,4 +1379,62 @@ class ExternalReferenceResolverTests: XCTestCase { XCTAssertEqual(externalLinkCount, 2, "Did not resolve the 2 expected external links.") } + func testExternalReferenceWithAbsolutePresentationURL() async throws { + class Resolver: ExternalDocumentationSource { + let bundleID: DocumentationBundle.Identifier = "com.example.test" + + func resolve(_ reference: TopicReference) -> TopicReferenceResolutionResult { + .success(ResolvedTopicReference(bundleID: bundleID, path: "/path/to/something", sourceLanguage: .swift)) + } + + var entityToReturn: LinkDestinationSummary + init(entityToReturn: LinkDestinationSummary) { + self.entityToReturn = entityToReturn + } + + func entity(with reference: ResolvedTopicReference) -> LinkResolver.ExternalEntity { + entityToReturn + } + } + + let catalog = Folder(name: "unit-test.docc", content: [ + TextFile(name: "Root.md", utf8Content: """ + # Root + + Link to an external page: + """), + ]) + + // Only decoded link summaries support absolute presentation URLs. + let externalEntity = try JSONDecoder().decode(LinkDestinationSummary.self, from: Data(""" + { + "path": "https://com.example/path/to/something", + "title": "Something", + "kind": "org.swift.docc.kind.article", + "referenceURL": "doc://com.example.test/path/to/something", + "language": "swift", + "availableLanguages": [ + "swift" + ] + } + """.utf8)) + XCTAssertEqual(externalEntity.relativePresentationURL.absoluteString, "/path/to/something") + XCTAssertEqual(externalEntity.absolutePresentationURL?.absoluteString, "https://com.example/path/to/something") + + let resolver = Resolver(entityToReturn: externalEntity) + + var configuration = DocumentationContext.Configuration() + configuration.externalDocumentationConfiguration.sources = [resolver.bundleID: resolver] + let (_, context) = try await loadBundle(catalog: catalog, configuration: configuration) + + XCTAssert(context.problems.isEmpty, "Unexpected problems: \(context.problems.map(\.diagnostic.summary))") + + // Check the curation on the root page + let rootNode = try context.entity(with: XCTUnwrap(context.soleRootModuleReference)) + let converter = DocumentationNodeConverter(context: context) + + let renderNode = converter.convert(rootNode) + let externalTopicReference = try XCTUnwrap(renderNode.references.values.first as? TopicRenderReference) + XCTAssertEqual(externalTopicReference.url, "https://com.example/path/to/something") + } }