From 6800bc8db4386c2f43e25cd242bdb56aef4474b0 Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:27:05 +0100 Subject: [PATCH 1/6] External Render Node Support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created an `ExternalRenderNode` structure that represents a pseudo render node out of an externally resolved value. This structure contains the minimal information needed to hold a value that can be passed to the Navigator Index. Fixes rdar://146123573. Co-authored-by: Sofía Rodríguez --- .../LinkResolver+NavigatorIndex.swift | 85 ++++++++++++++ .../Indexing/ExternalRenderNodeTests.swift | 111 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift create mode 100644 Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift new file mode 100644 index 000000000..3f79b9e98 --- /dev/null +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift @@ -0,0 +1,85 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2025 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import SymbolKit + +/// A rendering-friendly representation of a external node. +package struct ExternalRenderNode { + /// Underlying external entity backing this external node. + private var externalEntity: LinkResolver.ExternalEntity + + /// The bundle identifier for this external node. + private var bundleIdentifier: DocumentationBundle.Identifier + + init(externalEntity: LinkResolver.ExternalEntity, bundleIdentifier: DocumentationBundle.Identifier) { + self.externalEntity = externalEntity + self.bundleIdentifier = bundleIdentifier + } + + /// The identifier of the external render node. + package var identifier: ResolvedTopicReference { + ResolvedTopicReference( + bundleID: bundleIdentifier, + path: externalEntity.topicRenderReference.url, + sourceLanguages: externalEntity.sourceLanguages + ) + } + + /// The kind of this documentation node. + var kind: RenderNode.Kind { + externalEntity.topicRenderReference.kind + } + + /// The symbol kind of this documentation node. + var symbolKind: SymbolGraph.Symbol.KindIdentifier? { + // Symbol kind information is not available for external entities + return nil + } + + /// The additional "role" assigned to the symbol, if any + /// + /// This value is `nil` if the referenced page is not a symbol. + var role: String? { + externalEntity.topicRenderReference.role + } + + /// The variants of the title. + var titleVariants: VariantCollection { + externalEntity.topicRenderReference.titleVariants + } + + /// The variants of the abbreviated declaration of the symbol to display in navigation. + var navigatorTitleVariants: VariantCollection<[DeclarationRenderSection.Token]?> { + externalEntity.topicRenderReference.navigatorTitleVariants + } + + /// The variants of the abbreviated declaration of the symbol to display in links. + var fragmentsVariants: VariantCollection<[DeclarationRenderSection.Token]?> { + externalEntity.topicRenderReference.fragmentsVariants + } + + /// Author provided images that represent this page. + var images: [TopicImage] { + externalEntity.topicRenderReference.images + } + + /// The identifier of the external reference. + var externalIdentifier: RenderReferenceIdentifier { + externalEntity.topicRenderReference.identifier + } + + /// List of variants of the same external node for various languages. + var variants: [RenderNode.Variant]? { + externalEntity.sourceLanguages.map { + RenderNode.Variant(traits: [.interfaceLanguage($0.id)], paths: [externalEntity.topicRenderReference.url]) + } + } +} \ No newline at end of file diff --git a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift new file mode 100644 index 000000000..f3c560949 --- /dev/null +++ b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift @@ -0,0 +1,111 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2025 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import XCTest +@_spi(ExternalLinks) @testable import SwiftDocC + +class ExternalRenderNodeTests: XCTestCase { + func generateExternalResover() -> TestMultiResultExternalReferenceResolver { + let externalResolver = TestMultiResultExternalReferenceResolver() + externalResolver.bundleID = "com.test.external" + externalResolver.entitiesToReturn["/path/to/external/swiftArticle"] = .success( + .init( + referencePath: "/path/to/external/swiftArticle", + title: "SwiftArticle", + kind: .article, + language: .swift + ) + ) + externalResolver.entitiesToReturn["/path/to/external/objCArticle"] = .success( + .init( + referencePath: "/path/to/external/objCArticle", + title: "ObjCArticle", + kind: .article, + language: .objectiveC + ) + ) + externalResolver.entitiesToReturn["/path/to/external/swiftSymbol"] = .success( + .init( + referencePath: "/path/to/external/swiftSymbol", + title: "SwiftSymbol", + kind: .class, + language: .swift + ) + ) + externalResolver.entitiesToReturn["/path/to/external/objCSymbol"] = .success( + .init( + referencePath: "/path/to/external/objCSymbol", + title: "ObjCSymbol", + kind: .function, + language: .objectiveC + ) + ) + return externalResolver + } + + func testExternalRenderNode() throws { + + let externalResolver = generateExternalResover() + let (_, bundle, context) = try testBundleAndContext( + copying: "MixedLanguageFramework", + externalResolvers: [externalResolver.bundleID: externalResolver] + ) { url in + let mixedLanguageFrameworkExtension = """ + # ``MixedLanguageFramework`` + + This symbol has a Swift and Objective-C variant. + + ## Topics + + ### External Reference + + - + - + - + - + """ + try mixedLanguageFrameworkExtension.write(to: url.appendingPathComponent("/MixedLanguageFramework.md"), atomically: true, encoding: .utf8) + } + + var externalRenderNodes = [ExternalRenderNode]() + for externalLink in context.externalCache { + externalRenderNodes.append( + ExternalRenderNode(externalEntity: externalLink.value, bundleIdentifier: bundle.id) + ) + } + externalRenderNodes.sort(by: \.titleVariants.defaultValue) + XCTAssertEqual(externalRenderNodes.count, 4) + + XCTAssertEqual(externalRenderNodes[0].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/objCArticle") + XCTAssertEqual(externalRenderNodes[0].kind, .article) + XCTAssertEqual(externalRenderNodes[0].symbolKind, nil) + XCTAssertEqual(externalRenderNodes[0].role, "article") + XCTAssertEqual(externalRenderNodes[0].externalIdentifier.identifier, "doc://com.test.external/path/to/external/objCArticle") + + XCTAssertEqual(externalRenderNodes[1].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/objCSymbol") + XCTAssertEqual(externalRenderNodes[1].kind, .symbol) + XCTAssertEqual(externalRenderNodes[1].symbolKind, nil) + XCTAssertEqual(externalRenderNodes[1].role, "symbol") + XCTAssertEqual(externalRenderNodes[1].externalIdentifier.identifier, "doc://com.test.external/path/to/external/objCSymbol") + + XCTAssertEqual(externalRenderNodes[2].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/swiftArticle") + XCTAssertEqual(externalRenderNodes[2].kind, .article) + XCTAssertEqual(externalRenderNodes[2].symbolKind, nil) + XCTAssertEqual(externalRenderNodes[2].role, "article") + XCTAssertEqual(externalRenderNodes[2].externalIdentifier.identifier, "doc://com.test.external/path/to/external/swiftArticle") + + XCTAssertEqual(externalRenderNodes[3].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/swiftSymbol") + XCTAssertEqual(externalRenderNodes[3].kind, .symbol) + XCTAssertEqual(externalRenderNodes[3].symbolKind, nil) + XCTAssertEqual(externalRenderNodes[3].role, "symbol") + XCTAssertEqual(externalRenderNodes[3].externalIdentifier.identifier, "doc://com.test.external/path/to/external/swiftSymbol") + } +} From 4aad541abed2ef46864a8199f0c93492a8765df1 Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:28:47 +0100 Subject: [PATCH 2/6] External Render Node representation for the navigator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structures `NavigatorExternalRenderNode` and `ExternalRenderNodeMetadataRepresentation` are meant to hold the information needed to add an external render node in the navigator index. Each of these contains the information of a external render node for a specific language variant, since things like the node title change depending on the selected variant. Fields which cannot be populated due to insufficient information have been documented. These can have varying effect on the resulting navigation node, particularly the navigator title. Fixes rdar://146123573. Co-authored-by: Sofía Rodríguez --- .../LinkResolver+NavigatorIndex.swift | 63 +++++++++++++++++++ .../Indexing/ExternalRenderNodeTests.swift | 45 +++++++++++++ 2 files changed, 108 insertions(+) diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift index 3f79b9e98..c23a850f7 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift @@ -82,4 +82,67 @@ package struct ExternalRenderNode { RenderNode.Variant(traits: [.interfaceLanguage($0.id)], paths: [externalEntity.topicRenderReference.url]) } } +} + +/// A language specific representation of an external render node value for building a navigator index. +struct NavigatorExternalRenderNode: NavigatorIndexableRenderNodeRepresentation { + var identifier: ResolvedTopicReference + var externalIdentifier: RenderReferenceIdentifier + var kind: RenderNode.Kind + var metadata: ExternalRenderNodeMetadataRepresentation + + // Values that don't affect how the node is rendered in the sidebar. + // These are needed to conform to the navigator indexable protocol. + var references: [String : any RenderReference] = [:] + var sections: [any RenderSection] = [] + var topicSections: [TaskGroupRenderSection] = [] + var defaultImplementationsSections: [TaskGroupRenderSection] = [] + + init(renderNode: ExternalRenderNode, trait: RenderNode.Variant.Trait? = nil) { + // Compute the source language of the node based on the trait to know which variant to apply. + let traitLanguage = if case .interfaceLanguage(let id) = trait { + SourceLanguage(id: id) + } else { + renderNode.identifier.sourceLanguage + } + let traits = trait.map { [$0] } ?? [] + + self.identifier = renderNode.identifier.withSourceLanguages(Set(arrayLiteral: traitLanguage)) + self.kind = renderNode.kind + self.externalIdentifier = renderNode.externalIdentifier + + self.metadata = ExternalRenderNodeMetadataRepresentation( + title: renderNode.titleVariants.value(for: traits), + navigatorTitle: renderNode.navigatorTitleVariants.value(for: traits), + externalID: renderNode.externalIdentifier.identifier, + role: renderNode.role, + symbolKind: renderNode.symbolKind?.identifier, + images: renderNode.images + ) + } +} + +/// A language specific representation of a render metadata value for building an external navigator index. +struct ExternalRenderNodeMetadataRepresentation: NavigatorIndexableRenderMetadataRepresentation { + var title: String? + var navigatorTitle: [DeclarationRenderSection.Token]? + var externalID: String? + var role: String? + var symbolKind: String? + var images: [TopicImage] + + // Values that we have insufficient information to derive. + // These are needed to conform to the navigator indexable metadata protocol. + // + // The fragments that we get as part of the external link are the full declaration fragments. + // These are too verbose for the navigator, so instead of using them, we rely on the title, navigator title and symbol kind instead. + // + // The role heading is used to identify Property Lists. + // The value being missing is used for computing the final navigator title. + // + // The platforms are used for generating the availability index, + // but doesn't affect how the node is rendered in the sidebar. + var fragments: [DeclarationRenderSection.Token]? = nil + var roleHeading: String? = nil + var platforms: [AvailabilityRenderItem]? = nil } \ No newline at end of file diff --git a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift index f3c560949..c07a3be85 100644 --- a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift +++ b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift @@ -108,4 +108,49 @@ class ExternalRenderNodeTests: XCTestCase { XCTAssertEqual(externalRenderNodes[3].role, "symbol") XCTAssertEqual(externalRenderNodes[3].externalIdentifier.identifier, "doc://com.test.external/path/to/external/swiftSymbol") } + + func testExternalRenderNodeVariantRepresentation() throws { + let renderReferenceIdentifier = RenderReferenceIdentifier(forExternalLink: "doc://com.test.external/path/to/external/symbol") + + // Variants for the title + let swiftTitle = "Swift Symbol" + let occTitle = "Occ Symbol" + + // Variants for the navigator title + let navigatorTitle: [DeclarationRenderSection.Token] = [.init(text: "symbol", kind: .identifier)] + let occNavigatorTitle: [DeclarationRenderSection.Token] = [.init(text: "occ_symbol", kind: .identifier)] + + // Variants for the fragments + let fragments: [DeclarationRenderSection.Token] = [.init(text: "func", kind: .keyword), .init(text: "symbol", kind: .identifier)] + let occFragments: [DeclarationRenderSection.Token] = [.init(text: "func", kind: .keyword), .init(text: "occ_symbol", kind: .identifier)] + + let externalEntity = LinkResolver.ExternalEntity( + topicRenderReference: .init( + identifier: renderReferenceIdentifier, + titleVariants: .init(defaultValue: swiftTitle, objectiveCValue: occTitle), + abstractVariants: .init(defaultValue: []), + url: "/example/path/to/external/symbol", + kind: .symbol, + fragmentsVariants: .init(defaultValue: fragments, objectiveCValue: occFragments), + navigatorTitleVariants: .init(defaultValue: navigatorTitle, objectiveCValue: occNavigatorTitle) + ), + renderReferenceDependencies: .init(), + sourceLanguages: [SourceLanguage(name: "swift"), SourceLanguage(name: "objc")]) + let externalRenderNode = ExternalRenderNode( + externalEntity: externalEntity, + bundleIdentifier: "com.test.external" + ) + + let swiftNavigatorExternalRenderNode = try XCTUnwrap( + NavigatorExternalRenderNode(renderNode: externalRenderNode) + ) + XCTAssertEqual(swiftNavigatorExternalRenderNode.metadata.title, swiftTitle) + XCTAssertEqual(swiftNavigatorExternalRenderNode.metadata.navigatorTitle, navigatorTitle) + + let objcNavigatorExternalRenderNode = try XCTUnwrap( + NavigatorExternalRenderNode(renderNode: externalRenderNode, trait: .interfaceLanguage("objc")) + ) + XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.title, occTitle) + XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.navigatorTitle, occNavigatorTitle) + } } From e8cd14ad2944bdeb57bac8a611cf3b6e5941e112 Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:30:52 +0100 Subject: [PATCH 3/6] Index external render nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This method mimics the index method for render nodes [1] by indexing the main node representation, and the Objective-C representation. [1] https://github.com/swiftlang/swift-docc/blob/38ea39df14d0193f52900dbe54b7ae2be0abd856/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift\#L636 Fixes rdar://146123573. Co-authored-by: Sofía Rodríguez --- .../Indexing/Navigator/NavigatorIndex.swift | 22 +++++++++++++++++++ .../Action/Actions/Convert/Indexer.swift | 16 ++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift b/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift index 53b4e9982..aed5c37b8 100644 --- a/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift +++ b/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift @@ -630,6 +630,28 @@ extension NavigatorIndex { } } + /// Index a single render `ExternalRenderNode`. + /// - Parameter renderNode: The render node to be indexed. + package func index(renderNode: ExternalRenderNode, ignoringLanguage: Bool = false) throws { + let navigatorRenderNode = NavigatorExternalRenderNode(renderNode: renderNode) + _ = try index(navigatorRenderNode, traits: nil) + guard renderNode.identifier.sourceLanguage != .objectiveC else { + return + } + // Check if the render node has an Objective-C representation + guard let objCVariantTrait = renderNode.variants?.flatMap(\.traits).first(where: { trait in + switch trait { + case .interfaceLanguage(let language): + return InterfaceLanguage.from(string: language) == .objc + } + }) else { + return + } + // If this external render node has a variant, we create a "view" into its Objective-C specific data and index that. + let objVariantView = NavigatorExternalRenderNode(renderNode: renderNode, trait: objCVariantTrait) + _ = try index(objVariantView, traits: [objCVariantTrait]) + } + /// Index a single render `RenderNode`. /// - Parameter renderNode: The render node to be indexed. /// - Parameter ignoringLanguage: Whether language variants should be ignored when indexing this render node. diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/Indexer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/Indexer.swift index 1097395d8..5a721a8e4 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/Indexer.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/Indexer.swift @@ -62,6 +62,22 @@ extension ConvertAction { }) } + /// Indexes the given external render node and collects any encountered problems. + /// - Parameter renderNode: A ``ExternalRenderNode`` value. + func index(_ renderNode: ExternalRenderNode) { + // Synchronously index the render node. + indexBuilder.sync({ + do { + try $0.index(renderNode: renderNode) + nodeCount += 1 + } catch { + self.problems.append(error.problem(source: renderNode.identifier.url, + severity: .warning, + summaryPrefix: "External render node indexing process failed")) + } + }) + } + /// Finalizes the index and writes it on disk. /// - Returns: Returns a list of problems if any were encountered during indexing. func finalize(emitJSON: Bool, emitLMDB: Bool) -> [Problem] { From 2ba8bb01ca74af9f86e8288825d5c1548bc1ee81 Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:34:42 +0100 Subject: [PATCH 4/6] Consume external render nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During the convert process create the external render nodes from the context external cache and consume them to get them added into the navigator index. Fixes rdar://146123573. Co-authored-by: Sofía Rodríguez --- .../ConvertActionConverter.swift | 11 +++- .../ConvertOutputConsumer.swift | 9 ++++ .../Convert/ConvertFileWritingConsumer.swift | 7 ++- .../DocumentationConverterTests.swift | 6 ++- ...recatedDiagnosticsDigestWarningTests.swift | 3 +- .../Indexing/ExternalRenderNodeTests.swift | 54 +++++++++++++++++++ .../TestRenderNodeOutputConsumer.swift | 3 +- 7 files changed, 88 insertions(+), 5 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift index 2d348d425..17a5db0a7 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift @@ -34,7 +34,7 @@ package enum ConvertActionConverter { package static func convert( bundle: DocumentationBundle, context: DocumentationContext, - outputConsumer: some ConvertOutputConsumer, + outputConsumer: some ConvertOutputConsumer & ExternalNodeConsumer, sourceRepository: SourceRepository?, emitDigest: Bool, documentationCoverageOptions: DocumentationCoverageOptions @@ -104,6 +104,15 @@ package enum ConvertActionConverter { let resultsSyncQueue = DispatchQueue(label: "Convert Serial Queue", qos: .unspecified, attributes: []) let resultsGroup = DispatchGroup() + // Consume external links and add them into the sidebar. + for externalLink in context.externalCache { + // Here we're associating the external node with the **current** bundle's bundle ID. + // This is needed because nodes are only considered children if the parent and child's bundle ID match. + // Otherwise, the node will be considered as a separate root node and displayed separately. + let externalRenderNode = ExternalRenderNode(externalEntity: externalLink.value, bundleIdentifier: bundle.id) + try outputConsumer.consume(externalRenderNode: externalRenderNode) + } + let renderSignpostHandle = signposter.beginInterval("Render", id: signposter.makeSignpostID(), "Render \(context.knownPages.count) pages") var conversionProblems: [Problem] = context.knownPages.concurrentPerform { identifier, results in diff --git a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift index afe7a8272..830404dda 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift @@ -105,3 +105,12 @@ package struct _Deprecated: _DeprecatedConsumeP try consumer.consume(problems: problems) } } + +/// A consumer for nodes generated from external references. +/// +/// Types that conform to this protocol manage what to do with external references, for example index them. +package protocol ExternalNodeConsumer { + /// Consumes a external render node that was generated during a conversion. + /// > Warning: This method might be called concurrently. + func consume(externalRenderNode: ExternalRenderNode) throws +} diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift index eb1e43f6d..9d0370dda 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift @@ -11,7 +11,7 @@ import Foundation import SwiftDocC -struct ConvertFileWritingConsumer: ConvertOutputConsumer { +struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer { var targetFolder: URL var bundleRootFolder: URL? var fileManager: any FileManagerProtocol @@ -68,6 +68,11 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer { indexer?.index(renderNode) } + func consume(externalRenderNode: ExternalRenderNode) throws { + // Index the external node, if indexing is enabled. + indexer?.index(externalRenderNode) + } + func consume(assetsInBundle bundle: DocumentationBundle) throws { func copyAsset(_ asset: DataAsset, to destinationFolder: URL) throws { for sourceURL in asset.variants.values where !sourceURL.isAbsoluteWebURL { diff --git a/Tests/SwiftDocCTests/Converter/DocumentationConverterTests.swift b/Tests/SwiftDocCTests/Converter/DocumentationConverterTests.swift index 668730547..cf021a05d 100644 --- a/Tests/SwiftDocCTests/Converter/DocumentationConverterTests.swift +++ b/Tests/SwiftDocCTests/Converter/DocumentationConverterTests.swift @@ -17,7 +17,8 @@ import XCTest @available(*, deprecated, message: "This deprecated API will be removed after 6.2 is released") class DocumentationConverterTests: XCTestCase { /// An empty implementation of `ConvertOutputConsumer` that purposefully does nothing. - struct EmptyConvertOutputConsumer: ConvertOutputConsumer { + struct EmptyConvertOutputConsumer: ConvertOutputConsumer, ExternalNodeConsumer { + // Conformance to ConvertOutputConsumer func consume(renderNode: RenderNode) throws { } func consume(problems: [Problem]) throws { } func consume(assetsInBundle bundle: DocumentationBundle) throws {} @@ -26,6 +27,9 @@ class DocumentationConverterTests: XCTestCase { func consume(assets: [RenderReferenceType: [any RenderReference]]) throws {} func consume(benchmarks: Benchmark) throws {} func consume(documentationCoverageInfo: [CoverageDataEntry]) throws {} + + // Conformance to ExternalNodeConsumer + func consume(externalRenderNode: SwiftDocC.ExternalRenderNode) throws { } } func testThrowsErrorOnConvertingNoBundles() throws { diff --git a/Tests/SwiftDocCTests/DeprecatedDiagnosticsDigestWarningTests.swift b/Tests/SwiftDocCTests/DeprecatedDiagnosticsDigestWarningTests.swift index 6a39627b4..aaecda11e 100644 --- a/Tests/SwiftDocCTests/DeprecatedDiagnosticsDigestWarningTests.swift +++ b/Tests/SwiftDocCTests/DeprecatedDiagnosticsDigestWarningTests.swift @@ -70,7 +70,7 @@ class DeprecatedDiagnosticsDigestWarningTests: XCTestCase { } } -private class TestOutputConsumer: ConvertOutputConsumer { +private class TestOutputConsumer: ConvertOutputConsumer, ExternalNodeConsumer { var problems: [Problem] = [] func consume(problems: [Problem]) throws { @@ -87,4 +87,5 @@ private class TestOutputConsumer: ConvertOutputConsumer { func consume(renderReferenceStore: RenderReferenceStore) throws { } func consume(buildMetadata: BuildMetadata) throws { } func consume(linkResolutionInformation: SerializableLinkResolutionInformation) throws { } + func consume(externalRenderNode: ExternalRenderNode) throws { } } diff --git a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift index c07a3be85..2c4a6fc92 100644 --- a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift +++ b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift @@ -153,4 +153,58 @@ class ExternalRenderNodeTests: XCTestCase { XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.title, occTitle) XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.navigatorTitle, occNavigatorTitle) } + + func testNavigatorWithExternalNodes() throws { + let externalResolver = generateExternalResover() + let (_, bundle, context) = try testBundleAndContext( + copying: "MixedLanguageFramework", + externalResolvers: [externalResolver.bundleID: externalResolver] + ) { url in + let mixedLanguageFrameworkExtension = """ + # ``MixedLanguageFramework`` + + This symbol has a Swift and Objective-C variant. + + ## Topics + + ### External Reference + + - + - + - + - + """ + try mixedLanguageFrameworkExtension.write(to: url.appendingPathComponent("/MixedLanguageFramework.md"), atomically: true, encoding: .utf8) + } + let renderContext = RenderContext(documentationContext: context, bundle: bundle) + let converter = DocumentationContextConverter(bundle: bundle, context: context, renderContext: renderContext) + let targetURL = try createTemporaryDirectory() + let builder = NavigatorIndex.Builder(outputURL: targetURL, bundleIdentifier: bundle.id.rawValue, sortRootChildrenByName: true, groupByLanguage: true) + builder.setup() + for externalLink in context.externalCache { + let externalRenderNode = ExternalRenderNode(externalEntity: externalLink.value, bundleIdentifier: bundle.id) + try builder.index(renderNode: externalRenderNode) + } + for identifier in context.knownPages { + let entity = try context.entity(with: identifier) + let renderNode = try XCTUnwrap(converter.renderNode(for: entity)) + try builder.index(renderNode: renderNode) + } + builder.finalize() + let renderIndex = try RenderIndex.fromURL(targetURL.appendingPathComponent("index.json")) + + // Verify that there are no uncurated external links at the top level + let swiftTopLevelExternalNodes = renderIndex.interfaceLanguages["swift"]?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] + let occTopLevelExternalNodes = renderIndex.interfaceLanguages["occ"]?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] + XCTAssertEqual(swiftTopLevelExternalNodes.count, 0) + XCTAssertEqual(occTopLevelExternalNodes.count, 0) + + // Verify that the curated external links are part of the index. + let swiftExternalNodes = renderIndex.interfaceLanguages["swift"]?.first { $0.path == "/documentation/mixedlanguageframework" }?.children?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] + let occExternalNodes = renderIndex.interfaceLanguages["occ"]?.first { $0.path == "/documentation/mixedlanguageframework" }?.children?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] + XCTAssertEqual(swiftExternalNodes.count, 2) + XCTAssertEqual(occExternalNodes.count, 2) + XCTAssertEqual(swiftExternalNodes.map { $0.title }, ["SwiftArticle", "SwiftSymbol"]) + XCTAssertEqual(occExternalNodes.map { $0.title }, ["ObjCArticle", "ObjCSymbol"]) + } } diff --git a/Tests/SwiftDocCTests/TestRenderNodeOutputConsumer.swift b/Tests/SwiftDocCTests/TestRenderNodeOutputConsumer.swift index d1a108888..4281998cc 100644 --- a/Tests/SwiftDocCTests/TestRenderNodeOutputConsumer.swift +++ b/Tests/SwiftDocCTests/TestRenderNodeOutputConsumer.swift @@ -12,7 +12,7 @@ import Foundation @testable import SwiftDocC import XCTest -class TestRenderNodeOutputConsumer: ConvertOutputConsumer { +class TestRenderNodeOutputConsumer: ConvertOutputConsumer, ExternalNodeConsumer { var renderNodes = Synchronized<[RenderNode]>([]) func consume(renderNode: RenderNode) throws { @@ -30,6 +30,7 @@ class TestRenderNodeOutputConsumer: ConvertOutputConsumer { func consume(renderReferenceStore: RenderReferenceStore) throws { } func consume(buildMetadata: BuildMetadata) throws { } func consume(linkResolutionInformation: SerializableLinkResolutionInformation) throws { } + func consume(externalRenderNode: ExternalRenderNode) throws { } } extension TestRenderNodeOutputConsumer { From 5ecb12a505f8cfcbbe873d56759101438e2cb590 Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:39:42 +0100 Subject: [PATCH 5/6] Only add external nodes to index if manually curated in Topics section The NavigationIndex code has some logic about what to do with fallouts (i.e. nodes which are not curated anywhere). It places them at the top level of the navigator, so that they are not lost. This works well for render nodes, but for external render nodes, we don't want them to persist at the top level if they are not referenced anywhere. We'd like them to be excluded if not referenced. This can happen due to the fact that we consume **all** successfully resolved external entities for indexing, which includes external references in other sections of the page such as the Overview section, the abstract and the See also section. Fixes rdar://146123573. --- .../Indexing/Navigator/NavigatorIndex.swift | 15 +++-- .../Indexing/Navigator/NavigatorItem.swift | 11 +++- .../Indexing/ExternalRenderNodeTests.swift | 60 ++++++++++++++++++- 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift b/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift index aed5c37b8..63a427ff8 100644 --- a/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift +++ b/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift @@ -634,7 +634,7 @@ extension NavigatorIndex { /// - Parameter renderNode: The render node to be indexed. package func index(renderNode: ExternalRenderNode, ignoringLanguage: Bool = false) throws { let navigatorRenderNode = NavigatorExternalRenderNode(renderNode: renderNode) - _ = try index(navigatorRenderNode, traits: nil) + _ = try index(navigatorRenderNode, traits: nil, isExternal: true) guard renderNode.identifier.sourceLanguage != .objectiveC else { return } @@ -649,7 +649,7 @@ extension NavigatorIndex { } // If this external render node has a variant, we create a "view" into its Objective-C specific data and index that. let objVariantView = NavigatorExternalRenderNode(renderNode: renderNode, trait: objCVariantTrait) - _ = try index(objVariantView, traits: [objCVariantTrait]) + _ = try index(objVariantView, traits: [objCVariantTrait], isExternal: true) } /// Index a single render `RenderNode`. @@ -706,7 +706,7 @@ extension NavigatorIndex { } // The private index implementation which indexes a given render node representation - private func index(_ renderNode: any NavigatorIndexableRenderNodeRepresentation, traits: [RenderNode.Variant.Trait]?) throws -> InterfaceLanguage? { + private func index(_ renderNode: any NavigatorIndexableRenderNodeRepresentation, traits: [RenderNode.Variant.Trait]?, isExternal external: Bool = false) throws -> InterfaceLanguage? { guard let navigatorIndex else { throw Error.navigatorIndexIsNil } @@ -803,7 +803,8 @@ extension NavigatorIndex { title: title, platformMask: platformID, availabilityID: UInt64(availabilityID), - icon: renderNode.icon + icon: renderNode.icon, + isExternal: external ) navigationItem.path = identifierPath @@ -834,7 +835,8 @@ extension NavigatorIndex { languageID: language.mask, title: title, platformMask: platformID, - availabilityID: UInt64(Self.availabilityIDWithNoAvailabilities) + availabilityID: UInt64(Self.availabilityIDWithNoAvailabilities), + isExternal: external ) groupItem.path = identifier.path + "#" + fragment @@ -998,7 +1000,8 @@ extension NavigatorIndex { // curation, then they should not be in the navigator. In addition, treat unknown // page types as symbol nodes on the assumption that an unknown page type is a // symbol kind added in a future version of Swift-DocC. - if let node = identifierToNode[nodeID], PageType(rawValue: node.item.pageType)?.isSymbolKind == false { + // Finally, don't add external references to the root; if they are not referenced within the navigation tree, they should be dropped altogether. + if let node = identifierToNode[nodeID], PageType(rawValue: node.item.pageType)?.isSymbolKind == false , !node.item.isExternal { // If an uncurated page has been curated in another language, don't add it to the top-level. if curatedReferences.contains(where: { curatedNodeID in diff --git a/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift b/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift index 78d281dd0..32192682f 100644 --- a/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift +++ b/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift @@ -49,6 +49,11 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString var icon: RenderReferenceIdentifier? = nil + /// Whether the item has originated from an external reference. + /// + /// Used for determining whether stray navigation items should remain part of the final navigator. + var isExternal: Bool = false + /** Initialize a `NavigatorItem` with the given data. @@ -61,7 +66,7 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString - path: The path to load the content. - icon: A reference to a custom image for this navigator item. */ - init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, path: String, icon: RenderReferenceIdentifier? = nil) { + init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, path: String, icon: RenderReferenceIdentifier? = nil, isExternal: Bool = false) { self.pageType = pageType self.languageID = languageID self.title = title @@ -69,6 +74,7 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString self.availabilityID = availabilityID self.path = path self.icon = icon + self.isExternal = isExternal } /** @@ -82,13 +88,14 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString - availabilityID: The identifier of the availability information of the page. - icon: A reference to a custom image for this navigator item. */ - public init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, icon: RenderReferenceIdentifier? = nil) { + public init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, icon: RenderReferenceIdentifier? = nil, isExternal: Bool = false) { self.pageType = pageType self.languageID = languageID self.title = title self.platformMask = platformMask self.availabilityID = availabilityID self.icon = icon + self.isExternal = isExternal } // MARK: - Serialization and Deserialization diff --git a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift index 2c4a6fc92..78b10a9bc 100644 --- a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift +++ b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift @@ -206,5 +206,63 @@ class ExternalRenderNodeTests: XCTestCase { XCTAssertEqual(occExternalNodes.count, 2) XCTAssertEqual(swiftExternalNodes.map { $0.title }, ["SwiftArticle", "SwiftSymbol"]) XCTAssertEqual(occExternalNodes.map { $0.title }, ["ObjCArticle", "ObjCSymbol"]) - } + } + + func testNavigatorWithExternalNodesOnlyAddsCuratedNodesToNavigator() throws { + let externalResolver = generateExternalResover() + + let (_, bundle, context) = try testBundleAndContext( + copying: "MixedLanguageFramework", + externalResolvers: [externalResolver.bundleID: externalResolver] + ) { url in + let mixedLanguageFrameworkExtension = """ + # ``MixedLanguageFramework`` + + This symbol has a Swift and Objective-C variant. + + It also has an external reference which is not curated in the Topics section: + + + + ## Topics + + ### External Reference + + - + - + """ + try mixedLanguageFrameworkExtension.write(to: url.appendingPathComponent("/MixedLanguageFramework.md"), atomically: true, encoding: .utf8) + } + let renderContext = RenderContext(documentationContext: context, bundle: bundle) + let converter = DocumentationContextConverter(bundle: bundle, context: context, renderContext: renderContext) + let targetURL = try createTemporaryDirectory() + let builder = NavigatorIndex.Builder(outputURL: targetURL, bundleIdentifier: bundle.id.rawValue, sortRootChildrenByName: true, groupByLanguage: true) + builder.setup() + for externalLink in context.externalCache { + let externalRenderNode = ExternalRenderNode(externalEntity: externalLink.value, bundleIdentifier: bundle.id) + try builder.index(renderNode: externalRenderNode) + } + for identifier in context.knownPages { + let entity = try context.entity(with: identifier) + let renderNode = try XCTUnwrap(converter.renderNode(for: entity)) + try builder.index(renderNode: renderNode) + } + builder.finalize() + let renderIndex = try RenderIndex.fromURL(targetURL.appendingPathComponent("index.json")) + + + // Verify that there are no uncurated external links at the top level + let swiftTopLevelExternalNodes = renderIndex.interfaceLanguages["swift"]?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] + let occTopLevelExternalNodes = renderIndex.interfaceLanguages["occ"]?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] + XCTAssertEqual(swiftTopLevelExternalNodes.count, 0) + XCTAssertEqual(occTopLevelExternalNodes.count, 0) + + // Verify that the curated external links are part of the index. + let swiftExternalNodes = renderIndex.interfaceLanguages["swift"]?.first { $0.path == "/documentation/mixedlanguageframework" }?.children?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] + let occExternalNodes = renderIndex.interfaceLanguages["occ"]?.first { $0.path == "/documentation/mixedlanguageframework" }?.children?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] + XCTAssertEqual(swiftExternalNodes.count, 1) + XCTAssertEqual(occExternalNodes.count, 1) + XCTAssertEqual(swiftExternalNodes.map { $0.title }, ["SwiftArticle"]) + XCTAssertEqual(occExternalNodes.map { $0.title }, ["ObjCSymbol"]) + } } From 8d6607d9fbb16231c22d3096f536910929ee4a6e Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:42:55 +0100 Subject: [PATCH 6/6] Propagate isExternal value to RenderIndex Now that the `isExternal` property is populated as part of the `NavigatorItem`s, we can now use that value to propagate whether the node `isExternal` in the final `RenderIndex`, something which we couldn't support before. External nodes are now marked as "external": true in `index.json` as expected. Fixes rdar://146123573. --- .../Indexing/RenderIndexJSON/RenderIndex.swift | 17 +++++++++++++---- .../Indexing/ExternalRenderNodeTests.swift | 12 ++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift b/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift index 060c204b8..5140e35fd 100644 --- a/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift +++ b/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift @@ -86,7 +86,15 @@ public struct RenderIndex: Codable, Equatable { /// - Parameter named: The name of the new root node public mutating func insertRoot(named: String) { for (languageID, nodes) in interfaceLanguages { - let root = Node(title: named, path: "/documentation", pageType: .framework, isDeprecated: false, children: nodes, icon: nil) + let root = Node( + title: named, + path: "/documentation", + pageType: .framework, + isDeprecated: false, + isExternal: false, + children: nodes, + icon: nil + ) interfaceLanguages[languageID] = [root] } } @@ -236,6 +244,7 @@ extension RenderIndex { path: String, pageType: NavigatorIndex.PageType?, isDeprecated: Bool, + isExternal: Bool, children: [Node], icon: RenderReferenceIdentifier? ) { @@ -243,11 +252,10 @@ extension RenderIndex { self.children = children.isEmpty ? nil : children self.isDeprecated = isDeprecated + self.isExternal = isExternal - // Currently Swift-DocC doesn't support resolving links to external DocC archives + // Currently Swift-DocC doesn't support marking a node as beta in the navigation index // so we default to `false` here. - self.isExternal = false - self.isBeta = false self.icon = icon @@ -318,6 +326,7 @@ extension RenderIndex.Node { path: node.item.path, pageType: NavigatorIndex.PageType(rawValue: node.item.pageType), isDeprecated: isDeprecated, + isExternal: node.item.isExternal, children: node.children.map { RenderIndex.Node.fromNavigatorTreeNode($0, in: navigatorIndex, with: builder) }, diff --git a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift index 78b10a9bc..43243e342 100644 --- a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift +++ b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift @@ -204,8 +204,10 @@ class ExternalRenderNodeTests: XCTestCase { let occExternalNodes = renderIndex.interfaceLanguages["occ"]?.first { $0.path == "/documentation/mixedlanguageframework" }?.children?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] XCTAssertEqual(swiftExternalNodes.count, 2) XCTAssertEqual(occExternalNodes.count, 2) - XCTAssertEqual(swiftExternalNodes.map { $0.title }, ["SwiftArticle", "SwiftSymbol"]) - XCTAssertEqual(occExternalNodes.map { $0.title }, ["ObjCArticle", "ObjCSymbol"]) + XCTAssertEqual(swiftExternalNodes.map(\.title), ["SwiftArticle", "SwiftSymbol"]) + XCTAssertEqual(occExternalNodes.map(\.title), ["ObjCArticle", "ObjCSymbol"]) + XCTAssert(swiftExternalNodes.allSatisfy(\.isExternal)) + XCTAssert(occExternalNodes.allSatisfy(\.isExternal)) } func testNavigatorWithExternalNodesOnlyAddsCuratedNodesToNavigator() throws { @@ -262,7 +264,9 @@ class ExternalRenderNodeTests: XCTestCase { let occExternalNodes = renderIndex.interfaceLanguages["occ"]?.first { $0.path == "/documentation/mixedlanguageframework" }?.children?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] XCTAssertEqual(swiftExternalNodes.count, 1) XCTAssertEqual(occExternalNodes.count, 1) - XCTAssertEqual(swiftExternalNodes.map { $0.title }, ["SwiftArticle"]) - XCTAssertEqual(occExternalNodes.map { $0.title }, ["ObjCSymbol"]) + XCTAssertEqual(swiftExternalNodes.map(\.title), ["SwiftArticle"]) + XCTAssertEqual(occExternalNodes.map(\.title), ["ObjCSymbol"]) + XCTAssert(swiftExternalNodes.allSatisfy(\.isExternal)) + XCTAssert(occExternalNodes.allSatisfy(\.isExternal)) } }