Skip to content

Commit 8beb703

Browse files
authored
Index external entities after local entities (#1328)
The navigation indexing code skips all further nodes after it has already indexed a node with a given identifier [1]. This can result in an odd interaction when there is an external link which is resolved to a path that the bundle serves itself (or more colloquially, a catalog has an external link to itself). Since external entities are indexed before local entities, they were always be preferred over local entities, resulting in local entities mistakenly being dropped from the navigator altogether. The resulting navigator is incorrect and missing nodes, due to external entities not having hierarchy information. This issue was introduced when support for external links in the navigator was introduced. This commit updates `ConvertActionConverter` to always index local entities first. If any external entities clash with local entities, they will be resolved as the local entity (including its hierarchy) in the navigator. Fixes rdar://163018922. [1]: https://github.com/swiftlang/swift-docc/blob/b27288dd99b0e2715ed1a2d5720cd0f23118c030/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift#L711-L713 [2]: 65aaf92
1 parent ae6a083 commit 8beb703

File tree

2 files changed

+168
-11
lines changed

2 files changed

+168
-11
lines changed

Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,7 @@ package enum ConvertActionConverter {
100100

101101
let resultsSyncQueue = DispatchQueue(label: "Convert Serial Queue", qos: .unspecified, attributes: [])
102102
let resultsGroup = DispatchGroup()
103-
104-
// Consume external links and add them into the sidebar.
105-
for externalLink in context.externalCache {
106-
// Here we're associating the external node with the **current** bundle's bundle ID.
107-
// This is needed because nodes are only considered children if the parent and child's bundle ID match.
108-
// Otherwise, the node will be considered as a separate root node and displayed separately.
109-
let externalRenderNode = ExternalRenderNode(externalEntity: externalLink.value, bundleIdentifier: context.inputs.id)
110-
try outputConsumer.consume(externalRenderNode: externalRenderNode)
111-
}
112-
103+
113104
let renderSignpostHandle = signposter.beginInterval("Render", id: signposter.makeSignpostID(), "Render \(context.knownPages.count) pages")
114105

115106
var conversionProblems: [Problem] = context.knownPages.concurrentPerform { identifier, results in
@@ -172,7 +163,27 @@ package enum ConvertActionConverter {
172163
signposter.endInterval("Render", renderSignpostHandle)
173164

174165
guard !Task.isCancelled else { return [] }
175-
166+
167+
// Consumes all external links and adds them into the sidebar.
168+
// This consumes all external links referenced across all content, and indexes them so they're available for reference in the navigator.
169+
// This is not ideal as it means that links outside of the Topics section can impact the content of the navigator.
170+
// TODO: It would be more correct to only index external links which have been curated as part of the Topics section.
171+
//
172+
// This has to run after all local nodes have been indexed because we're associating the external node with the **local** documentation's identifier,
173+
// which makes it possible for there be clashes between local and external render nodes.
174+
// When there are duplicate nodes, only the first one will be indexed,
175+
// so in order to prefer local entities whenever there are any clashes, we have to index external nodes second.
176+
// TODO: External render nodes should be associated with the correct documentation identifier.
177+
try signposter.withIntervalSignpost("Index external links", id: signposter.makeSignpostID()) {
178+
for externalLink in context.externalCache {
179+
// Here we're associating the external node with the **local** documentation's identifier.
180+
// This is needed because nodes are only considered children if the parent and child's identifier match.
181+
// Otherwise, the node will be considered as a separate root node and displayed separately.
182+
let externalRenderNode = ExternalRenderNode(externalEntity: externalLink.value, bundleIdentifier: context.inputs.id)
183+
try outputConsumer.consume(externalRenderNode: externalRenderNode)
184+
}
185+
}
186+
176187
// Write various metadata
177188
if emitDigest {
178189
signposter.withIntervalSignpost("Emit digest", id: signposter.makeSignpostID()) {

Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,4 +507,150 @@ class ExternalRenderNodeTests: XCTestCase {
507507
XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.title, objcTitle)
508508
XCTAssertTrue(objcNavigatorExternalRenderNode.metadata.isBeta)
509509
}
510+
511+
func testExternalLinksInContentDontAffectNavigatorIndex() async throws {
512+
let externalResolver = ExternalReferenceResolverTests.TestExternalReferenceResolver()
513+
externalResolver.expectedReferencePath = "/documentation/testbundle/sampleclass"
514+
515+
let catalog = Folder(name: "unit-test.docc", content: [
516+
TextFile(name: "Article.md", utf8Content: """
517+
# Article
518+
519+
This is an internal article with an external link <doc://\(externalResolver.bundleID)/documentation/TestBundle/SampleClass> which clashes with the curated local link.
520+
521+
External links in content should not affect the navigator.
522+
523+
## Topics
524+
525+
- ``SampleClass``
526+
"""),
527+
TextFile(name: "SampleClass.md", utf8Content: """
528+
# ``SampleClass``
529+
530+
This extends the documentation for this symbol.
531+
532+
## Topics
533+
534+
- <doc:ChildArticleA>
535+
- <doc:ChildArticleB>
536+
"""),
537+
TextFile(name: "ChildArticleA.md", utf8Content: """
538+
# ChildArticleA
539+
540+
A child article.
541+
"""),
542+
TextFile(name: "ChildArticleB.md", utf8Content: """
543+
# ChildArticleB
544+
545+
A child article.
546+
"""),
547+
// Symbol graph with a class that matches an external link path
548+
JSONFile(name: "TestBundle.symbols.json", content: makeSymbolGraph(moduleName: "TestBundle", symbols: [
549+
makeSymbol(id: "some-symbol-id", language: .swift, kind: .class, pathComponents: ["SampleClass"])
550+
])),
551+
])
552+
553+
var configuration = DocumentationContext.Configuration()
554+
configuration.externalDocumentationConfiguration.sources[externalResolver.bundleID] = externalResolver
555+
let (bundle, context) = try await loadBundle(catalog: catalog, configuration: configuration)
556+
XCTAssert(context.problems.isEmpty, "Unexpectedly found problems: \(context.problems.map(\.diagnostic.summary))")
557+
558+
let renderIndexFolder = try createTemporaryDirectory()
559+
let indexBuilder = NavigatorIndex.Builder(outputURL: renderIndexFolder, bundleIdentifier: bundle.id.rawValue, sortRootChildrenByName: true, groupByLanguage: true)
560+
indexBuilder.setup()
561+
let outputConsumer = TestExternalRenderNodeOutputConsumer(indexBuilder: indexBuilder)
562+
563+
let problems = try ConvertActionConverter.convert(
564+
context: context,
565+
outputConsumer: outputConsumer,
566+
sourceRepository: nil,
567+
emitDigest: false,
568+
documentationCoverageOptions: .noCoverage
569+
)
570+
XCTAssert(problems.isEmpty, "Unexpectedly found problems: \(DiagnosticConsoleWriter.formattedDescription(for: problems))")
571+
indexBuilder.finalize(emitJSONRepresentation: true, emitLMDBRepresentation: false)
572+
573+
XCTAssertEqual(
574+
try RenderIndex.fromURL(renderIndexFolder.appendingPathComponent("index.json", isDirectory: false)),
575+
try RenderIndex.fromString(
576+
"""
577+
{
578+
"includedArchiveIdentifiers": [
579+
"unit-test"
580+
],
581+
"interfaceLanguages": {
582+
"swift": [
583+
{
584+
"children": [
585+
{
586+
"title": "Articles",
587+
"type": "groupMarker"
588+
},
589+
{
590+
"children": [
591+
{
592+
"children": [
593+
{
594+
"path": "/documentation/unit-test/childarticlea",
595+
"title": "ChildArticleA",
596+
"type": "article"
597+
},
598+
{
599+
"path": "/documentation/unit-test/childarticleb",
600+
"title": "ChildArticleB",
601+
"type": "article"
602+
}
603+
],
604+
"path": "/documentation/testbundle/sampleclass",
605+
"title": "SampleClass",
606+
"type": "class"
607+
}
608+
],
609+
"path": "/documentation/unit-test/article",
610+
"title": "Article",
611+
"type": "symbol"
612+
}
613+
],
614+
"path": "/documentation/testbundle",
615+
"title": "TestBundle",
616+
"type": "module"
617+
}
618+
]
619+
},
620+
"schemaVersion": {
621+
"major": 0,
622+
"minor": 1,
623+
"patch": 2
624+
}
625+
}
626+
"""
627+
)
628+
)
629+
}
630+
}
631+
632+
private class TestExternalRenderNodeOutputConsumer: ConvertOutputConsumer, ExternalNodeConsumer {
633+
let indexBuilder: Synchronized<NavigatorIndex.Builder>!
634+
635+
init(indexBuilder: NavigatorIndex.Builder) {
636+
self.indexBuilder = Synchronized<NavigatorIndex.Builder>(indexBuilder)
637+
}
638+
639+
func consume(externalRenderNode: ExternalRenderNode) throws {
640+
try self.indexBuilder.sync { try $0.index(renderNode: externalRenderNode) }
641+
}
642+
643+
func consume(renderNode: RenderNode) throws {
644+
try self.indexBuilder.sync { try $0.index(renderNode: renderNode) }
645+
}
646+
647+
func consume(assetsInBundle bundle: DocumentationBundle) throws { }
648+
func consume(linkableElementSummaries: [LinkDestinationSummary]) throws { }
649+
func consume(indexingRecords: [IndexingRecord]) throws { }
650+
func consume(assets: [RenderReferenceType: [any RenderReference]]) throws { }
651+
func consume(benchmarks: Benchmark) throws { }
652+
func consume(documentationCoverageInfo: [CoverageDataEntry]) throws { }
653+
func consume(renderReferenceStore: RenderReferenceStore) throws { }
654+
func consume(buildMetadata: BuildMetadata) throws { }
655+
func consume(linkResolutionInformation: SerializableLinkResolutionInformation) throws { }
510656
}

0 commit comments

Comments
 (0)