Skip to content

Commit 65aaf92

Browse files
Support external links in the navigator (#1247)
* External Render Node Support 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 <[email protected]> * External Render Node representation for the navigator 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 <[email protected]> * Index external render nodes 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 <[email protected]> * Consume external render nodes 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 <[email protected]> * 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. * 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. --------- Co-authored-by: Sofía Rodríguez <[email protected]>
1 parent c7660bb commit 65aaf92

File tree

12 files changed

+521
-15
lines changed

12 files changed

+521
-15
lines changed

Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,28 @@ extension NavigatorIndex {
630630
}
631631
}
632632

633+
/// Index a single render `ExternalRenderNode`.
634+
/// - Parameter renderNode: The render node to be indexed.
635+
package func index(renderNode: ExternalRenderNode, ignoringLanguage: Bool = false) throws {
636+
let navigatorRenderNode = NavigatorExternalRenderNode(renderNode: renderNode)
637+
_ = try index(navigatorRenderNode, traits: nil, isExternal: true)
638+
guard renderNode.identifier.sourceLanguage != .objectiveC else {
639+
return
640+
}
641+
// Check if the render node has an Objective-C representation
642+
guard let objCVariantTrait = renderNode.variants?.flatMap(\.traits).first(where: { trait in
643+
switch trait {
644+
case .interfaceLanguage(let language):
645+
return InterfaceLanguage.from(string: language) == .objc
646+
}
647+
}) else {
648+
return
649+
}
650+
// If this external render node has a variant, we create a "view" into its Objective-C specific data and index that.
651+
let objVariantView = NavigatorExternalRenderNode(renderNode: renderNode, trait: objCVariantTrait)
652+
_ = try index(objVariantView, traits: [objCVariantTrait], isExternal: true)
653+
}
654+
633655
/// Index a single render `RenderNode`.
634656
/// - Parameter renderNode: The render node to be indexed.
635657
/// - Parameter ignoringLanguage: Whether language variants should be ignored when indexing this render node.
@@ -684,7 +706,7 @@ extension NavigatorIndex {
684706
}
685707

686708
// The private index implementation which indexes a given render node representation
687-
private func index(_ renderNode: any NavigatorIndexableRenderNodeRepresentation, traits: [RenderNode.Variant.Trait]?) throws -> InterfaceLanguage? {
709+
private func index(_ renderNode: any NavigatorIndexableRenderNodeRepresentation, traits: [RenderNode.Variant.Trait]?, isExternal external: Bool = false) throws -> InterfaceLanguage? {
688710
guard let navigatorIndex else {
689711
throw Error.navigatorIndexIsNil
690712
}
@@ -781,7 +803,8 @@ extension NavigatorIndex {
781803
title: title,
782804
platformMask: platformID,
783805
availabilityID: UInt64(availabilityID),
784-
icon: renderNode.icon
806+
icon: renderNode.icon,
807+
isExternal: external
785808
)
786809
navigationItem.path = identifierPath
787810

@@ -812,7 +835,8 @@ extension NavigatorIndex {
812835
languageID: language.mask,
813836
title: title,
814837
platformMask: platformID,
815-
availabilityID: UInt64(Self.availabilityIDWithNoAvailabilities)
838+
availabilityID: UInt64(Self.availabilityIDWithNoAvailabilities),
839+
isExternal: external
816840
)
817841

818842
groupItem.path = identifier.path + "#" + fragment
@@ -976,7 +1000,8 @@ extension NavigatorIndex {
9761000
// curation, then they should not be in the navigator. In addition, treat unknown
9771001
// page types as symbol nodes on the assumption that an unknown page type is a
9781002
// symbol kind added in a future version of Swift-DocC.
979-
if let node = identifierToNode[nodeID], PageType(rawValue: node.item.pageType)?.isSymbolKind == false {
1003+
// Finally, don't add external references to the root; if they are not referenced within the navigation tree, they should be dropped altogether.
1004+
if let node = identifierToNode[nodeID], PageType(rawValue: node.item.pageType)?.isSymbolKind == false , !node.item.isExternal {
9801005

9811006
// If an uncurated page has been curated in another language, don't add it to the top-level.
9821007
if curatedReferences.contains(where: { curatedNodeID in

Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString
4949

5050
var icon: RenderReferenceIdentifier? = nil
5151

52+
/// Whether the item has originated from an external reference.
53+
///
54+
/// Used for determining whether stray navigation items should remain part of the final navigator.
55+
var isExternal: Bool = false
56+
5257
/**
5358
Initialize a `NavigatorItem` with the given data.
5459

@@ -61,14 +66,15 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString
6166
- path: The path to load the content.
6267
- icon: A reference to a custom image for this navigator item.
6368
*/
64-
init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, path: String, icon: RenderReferenceIdentifier? = nil) {
69+
init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, path: String, icon: RenderReferenceIdentifier? = nil, isExternal: Bool = false) {
6570
self.pageType = pageType
6671
self.languageID = languageID
6772
self.title = title
6873
self.platformMask = platformMask
6974
self.availabilityID = availabilityID
7075
self.path = path
7176
self.icon = icon
77+
self.isExternal = isExternal
7278
}
7379

7480
/**
@@ -82,13 +88,14 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString
8288
- availabilityID: The identifier of the availability information of the page.
8389
- icon: A reference to a custom image for this navigator item.
8490
*/
85-
public init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, icon: RenderReferenceIdentifier? = nil) {
91+
public init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, icon: RenderReferenceIdentifier? = nil, isExternal: Bool = false) {
8692
self.pageType = pageType
8793
self.languageID = languageID
8894
self.title = title
8995
self.platformMask = platformMask
9096
self.availabilityID = availabilityID
9197
self.icon = icon
98+
self.isExternal = isExternal
9299
}
93100

94101
// MARK: - Serialization and Deserialization

Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,15 @@ public struct RenderIndex: Codable, Equatable {
8686
/// - Parameter named: The name of the new root node
8787
public mutating func insertRoot(named: String) {
8888
for (languageID, nodes) in interfaceLanguages {
89-
let root = Node(title: named, path: "/documentation", pageType: .framework, isDeprecated: false, children: nodes, icon: nil)
89+
let root = Node(
90+
title: named,
91+
path: "/documentation",
92+
pageType: .framework,
93+
isDeprecated: false,
94+
isExternal: false,
95+
children: nodes,
96+
icon: nil
97+
)
9098
interfaceLanguages[languageID] = [root]
9199
}
92100
}
@@ -236,18 +244,18 @@ extension RenderIndex {
236244
path: String,
237245
pageType: NavigatorIndex.PageType?,
238246
isDeprecated: Bool,
247+
isExternal: Bool,
239248
children: [Node],
240249
icon: RenderReferenceIdentifier?
241250
) {
242251
self.title = title
243252
self.children = children.isEmpty ? nil : children
244253

245254
self.isDeprecated = isDeprecated
255+
self.isExternal = isExternal
246256

247-
// Currently Swift-DocC doesn't support resolving links to external DocC archives
257+
// Currently Swift-DocC doesn't support marking a node as beta in the navigation index
248258
// so we default to `false` here.
249-
self.isExternal = false
250-
251259
self.isBeta = false
252260
self.icon = icon
253261

@@ -318,6 +326,7 @@ extension RenderIndex.Node {
318326
path: node.item.path,
319327
pageType: NavigatorIndex.PageType(rawValue: node.item.pageType),
320328
isDeprecated: isDeprecated,
329+
isExternal: node.item.isExternal,
321330
children: node.children.map {
322331
RenderIndex.Node.fromNavigatorTreeNode($0, in: navigatorIndex, with: builder)
323332
},

Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ package enum ConvertActionConverter {
3434
package static func convert(
3535
bundle: DocumentationBundle,
3636
context: DocumentationContext,
37-
outputConsumer: some ConvertOutputConsumer,
37+
outputConsumer: some ConvertOutputConsumer & ExternalNodeConsumer,
3838
sourceRepository: SourceRepository?,
3939
emitDigest: Bool,
4040
documentationCoverageOptions: DocumentationCoverageOptions
@@ -104,6 +104,15 @@ package enum ConvertActionConverter {
104104
let resultsSyncQueue = DispatchQueue(label: "Convert Serial Queue", qos: .unspecified, attributes: [])
105105
let resultsGroup = DispatchGroup()
106106

107+
// Consume external links and add them into the sidebar.
108+
for externalLink in context.externalCache {
109+
// Here we're associating the external node with the **current** bundle's bundle ID.
110+
// This is needed because nodes are only considered children if the parent and child's bundle ID match.
111+
// Otherwise, the node will be considered as a separate root node and displayed separately.
112+
let externalRenderNode = ExternalRenderNode(externalEntity: externalLink.value, bundleIdentifier: bundle.id)
113+
try outputConsumer.consume(externalRenderNode: externalRenderNode)
114+
}
115+
107116
let renderSignpostHandle = signposter.beginInterval("Render", id: signposter.makeSignpostID(), "Render \(context.knownPages.count) pages")
108117

109118
var conversionProblems: [Problem] = context.knownPages.concurrentPerform { identifier, results in

Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,12 @@ package struct _Deprecated<Consumer: ConvertOutputConsumer>: _DeprecatedConsumeP
105105
try consumer.consume(problems: problems)
106106
}
107107
}
108+
109+
/// A consumer for nodes generated from external references.
110+
///
111+
/// Types that conform to this protocol manage what to do with external references, for example index them.
112+
package protocol ExternalNodeConsumer {
113+
/// Consumes a external render node that was generated during a conversion.
114+
/// > Warning: This method might be called concurrently.
115+
func consume(externalRenderNode: ExternalRenderNode) throws
116+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2025 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
import SymbolKit
13+
14+
/// A rendering-friendly representation of a external node.
15+
package struct ExternalRenderNode {
16+
/// Underlying external entity backing this external node.
17+
private var externalEntity: LinkResolver.ExternalEntity
18+
19+
/// The bundle identifier for this external node.
20+
private var bundleIdentifier: DocumentationBundle.Identifier
21+
22+
init(externalEntity: LinkResolver.ExternalEntity, bundleIdentifier: DocumentationBundle.Identifier) {
23+
self.externalEntity = externalEntity
24+
self.bundleIdentifier = bundleIdentifier
25+
}
26+
27+
/// The identifier of the external render node.
28+
package var identifier: ResolvedTopicReference {
29+
ResolvedTopicReference(
30+
bundleID: bundleIdentifier,
31+
path: externalEntity.topicRenderReference.url,
32+
sourceLanguages: externalEntity.sourceLanguages
33+
)
34+
}
35+
36+
/// The kind of this documentation node.
37+
var kind: RenderNode.Kind {
38+
externalEntity.topicRenderReference.kind
39+
}
40+
41+
/// The symbol kind of this documentation node.
42+
var symbolKind: SymbolGraph.Symbol.KindIdentifier? {
43+
// Symbol kind information is not available for external entities
44+
return nil
45+
}
46+
47+
/// The additional "role" assigned to the symbol, if any
48+
///
49+
/// This value is `nil` if the referenced page is not a symbol.
50+
var role: String? {
51+
externalEntity.topicRenderReference.role
52+
}
53+
54+
/// The variants of the title.
55+
var titleVariants: VariantCollection<String> {
56+
externalEntity.topicRenderReference.titleVariants
57+
}
58+
59+
/// The variants of the abbreviated declaration of the symbol to display in navigation.
60+
var navigatorTitleVariants: VariantCollection<[DeclarationRenderSection.Token]?> {
61+
externalEntity.topicRenderReference.navigatorTitleVariants
62+
}
63+
64+
/// The variants of the abbreviated declaration of the symbol to display in links.
65+
var fragmentsVariants: VariantCollection<[DeclarationRenderSection.Token]?> {
66+
externalEntity.topicRenderReference.fragmentsVariants
67+
}
68+
69+
/// Author provided images that represent this page.
70+
var images: [TopicImage] {
71+
externalEntity.topicRenderReference.images
72+
}
73+
74+
/// The identifier of the external reference.
75+
var externalIdentifier: RenderReferenceIdentifier {
76+
externalEntity.topicRenderReference.identifier
77+
}
78+
79+
/// List of variants of the same external node for various languages.
80+
var variants: [RenderNode.Variant]? {
81+
externalEntity.sourceLanguages.map {
82+
RenderNode.Variant(traits: [.interfaceLanguage($0.id)], paths: [externalEntity.topicRenderReference.url])
83+
}
84+
}
85+
}
86+
87+
/// A language specific representation of an external render node value for building a navigator index.
88+
struct NavigatorExternalRenderNode: NavigatorIndexableRenderNodeRepresentation {
89+
var identifier: ResolvedTopicReference
90+
var externalIdentifier: RenderReferenceIdentifier
91+
var kind: RenderNode.Kind
92+
var metadata: ExternalRenderNodeMetadataRepresentation
93+
94+
// Values that don't affect how the node is rendered in the sidebar.
95+
// These are needed to conform to the navigator indexable protocol.
96+
var references: [String : any RenderReference] = [:]
97+
var sections: [any RenderSection] = []
98+
var topicSections: [TaskGroupRenderSection] = []
99+
var defaultImplementationsSections: [TaskGroupRenderSection] = []
100+
101+
init(renderNode: ExternalRenderNode, trait: RenderNode.Variant.Trait? = nil) {
102+
// Compute the source language of the node based on the trait to know which variant to apply.
103+
let traitLanguage = if case .interfaceLanguage(let id) = trait {
104+
SourceLanguage(id: id)
105+
} else {
106+
renderNode.identifier.sourceLanguage
107+
}
108+
let traits = trait.map { [$0] } ?? []
109+
110+
self.identifier = renderNode.identifier.withSourceLanguages(Set(arrayLiteral: traitLanguage))
111+
self.kind = renderNode.kind
112+
self.externalIdentifier = renderNode.externalIdentifier
113+
114+
self.metadata = ExternalRenderNodeMetadataRepresentation(
115+
title: renderNode.titleVariants.value(for: traits),
116+
navigatorTitle: renderNode.navigatorTitleVariants.value(for: traits),
117+
externalID: renderNode.externalIdentifier.identifier,
118+
role: renderNode.role,
119+
symbolKind: renderNode.symbolKind?.identifier,
120+
images: renderNode.images
121+
)
122+
}
123+
}
124+
125+
/// A language specific representation of a render metadata value for building an external navigator index.
126+
struct ExternalRenderNodeMetadataRepresentation: NavigatorIndexableRenderMetadataRepresentation {
127+
var title: String?
128+
var navigatorTitle: [DeclarationRenderSection.Token]?
129+
var externalID: String?
130+
var role: String?
131+
var symbolKind: String?
132+
var images: [TopicImage]
133+
134+
// Values that we have insufficient information to derive.
135+
// These are needed to conform to the navigator indexable metadata protocol.
136+
//
137+
// The fragments that we get as part of the external link are the full declaration fragments.
138+
// These are too verbose for the navigator, so instead of using them, we rely on the title, navigator title and symbol kind instead.
139+
//
140+
// The role heading is used to identify Property Lists.
141+
// The value being missing is used for computing the final navigator title.
142+
//
143+
// The platforms are used for generating the availability index,
144+
// but doesn't affect how the node is rendered in the sidebar.
145+
var fragments: [DeclarationRenderSection.Token]? = nil
146+
var roleHeading: String? = nil
147+
var platforms: [AvailabilityRenderItem]? = nil
148+
}

Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import Foundation
1212
import SwiftDocC
1313

14-
struct ConvertFileWritingConsumer: ConvertOutputConsumer {
14+
struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer {
1515
var targetFolder: URL
1616
var bundleRootFolder: URL?
1717
var fileManager: any FileManagerProtocol
@@ -68,6 +68,11 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer {
6868
indexer?.index(renderNode)
6969
}
7070

71+
func consume(externalRenderNode: ExternalRenderNode) throws {
72+
// Index the external node, if indexing is enabled.
73+
indexer?.index(externalRenderNode)
74+
}
75+
7176
func consume(assetsInBundle bundle: DocumentationBundle) throws {
7277
func copyAsset(_ asset: DataAsset, to destinationFolder: URL) throws {
7378
for sourceURL in asset.variants.values where !sourceURL.isAbsoluteWebURL {

Sources/SwiftDocCUtilities/Action/Actions/Convert/Indexer.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,22 @@ extension ConvertAction {
6262
})
6363
}
6464

65+
/// Indexes the given external render node and collects any encountered problems.
66+
/// - Parameter renderNode: A ``ExternalRenderNode`` value.
67+
func index(_ renderNode: ExternalRenderNode) {
68+
// Synchronously index the render node.
69+
indexBuilder.sync({
70+
do {
71+
try $0.index(renderNode: renderNode)
72+
nodeCount += 1
73+
} catch {
74+
self.problems.append(error.problem(source: renderNode.identifier.url,
75+
severity: .warning,
76+
summaryPrefix: "External render node indexing process failed"))
77+
}
78+
})
79+
}
80+
6581
/// Finalizes the index and writes it on disk.
6682
/// - Returns: Returns a list of problems if any were encountered during indexing.
6783
func finalize(emitJSON: Bool, emitLMDB: Bool) -> [Problem] {

0 commit comments

Comments
 (0)