Skip to content

Commit 323675b

Browse files
committed
Merge branch 'main' into jed/doxygen-discussion-note
2 parents c4ca64a + ca120e1 commit 323675b

File tree

12 files changed

+459
-154
lines changed

12 files changed

+459
-154
lines changed

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,9 +1261,9 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
12611261
diagnosticEngine.emit(Problem(diagnostic: diagnostic))
12621262
continue
12631263
}
1264-
guard let url = ValidatedURL(parsingExact: destination) else {
1264+
guard let url = ValidatedURL(parsingAuthoredLink: destination) else {
12651265
let diagnostic = Diagnostic(source: documentationExtension.source, severity: .warning, range: link.range, identifier: "org.swift.docc.invalidLinkDestination", summary: """
1266-
\(destination.singleQuoted) is
1266+
\(destination.singleQuoted) is not a valid RFC 3986 URL.
12671267
""", explanation: nil, notes: [])
12681268
diagnosticEngine.emit(Problem(diagnostic: diagnostic))
12691269
continue

Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchyBasedLinkResolver.swift

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ final class PathHierarchyBasedLinkResolver {
106106
}
107107

108108
private func addTutorial(reference: ResolvedTopicReference, source: URL, landmarks: [Landmark]) {
109-
let tutorialID = pathHierarchy.addTutorial(name: urlReadablePath(source.deletingPathExtension().lastPathComponent))
109+
let tutorialID = pathHierarchy.addTutorial(name: linkName(filename: source.deletingPathExtension().lastPathComponent))
110110
resolvedReferenceMap[tutorialID] = reference
111111

112112
for landmark in landmarks {
@@ -119,7 +119,7 @@ final class PathHierarchyBasedLinkResolver {
119119
func addTechnology(_ technology: DocumentationContext.SemanticResult<Technology>) {
120120
let reference = technology.topicGraphNode.reference
121121

122-
let technologyID = pathHierarchy.addTutorialOverview(name: urlReadablePath(technology.source.deletingPathExtension().lastPathComponent))
122+
let technologyID = pathHierarchy.addTutorialOverview(name: linkName(filename: technology.source.deletingPathExtension().lastPathComponent))
123123
resolvedReferenceMap[technologyID] = reference
124124

125125
var anonymousVolumeID: ResolvedIdentifier?
@@ -149,21 +149,20 @@ final class PathHierarchyBasedLinkResolver {
149149

150150
/// Adds a technology root article and its headings to the path hierarchy.
151151
func addRootArticle(_ article: DocumentationContext.SemanticResult<Article>, anchorSections: [AnchorSection]) {
152-
let articleID = pathHierarchy.addTechnologyRoot(name: article.source.deletingPathExtension().lastPathComponent)
152+
let linkName = linkName(filename: article.source.deletingPathExtension().lastPathComponent)
153+
let articleID = pathHierarchy.addTechnologyRoot(name: linkName)
153154
resolvedReferenceMap[articleID] = article.topicGraphNode.reference
154155
addAnchors(anchorSections, to: articleID)
155156
}
156157

157158
/// Adds an article and its headings to the path hierarchy.
158159
func addArticle(_ article: DocumentationContext.SemanticResult<Article>, anchorSections: [AnchorSection]) {
159-
let articleID = pathHierarchy.addArticle(name: article.source.deletingPathExtension().lastPathComponent)
160-
resolvedReferenceMap[articleID] = article.topicGraphNode.reference
161-
addAnchors(anchorSections, to: articleID)
160+
addArticle(filename: article.source.deletingPathExtension().lastPathComponent, reference: article.topicGraphNode.reference, anchorSections: anchorSections)
162161
}
163162

164163
/// Adds an article and its headings to the path hierarchy.
165164
func addArticle(filename: String, reference: ResolvedTopicReference, anchorSections: [AnchorSection]) {
166-
let articleID = pathHierarchy.addArticle(name: filename)
165+
let articleID = pathHierarchy.addArticle(name: linkName(filename: filename))
167166
resolvedReferenceMap[articleID] = reference
168167
addAnchors(anchorSections, to: articleID)
169168
}
@@ -186,7 +185,7 @@ final class PathHierarchyBasedLinkResolver {
186185
/// Adds a task group on a given page to the documentation hierarchy.
187186
func addTaskGroup(named name: String, reference: ResolvedTopicReference, to parent: ResolvedTopicReference) {
188187
let parentID = resolvedReferenceMap[parent]!
189-
let taskGroupID = pathHierarchy.addNonSymbolChild(parent: parentID, name: urlReadablePath(name), kind: "taskGroup")
188+
let taskGroupID = pathHierarchy.addNonSymbolChild(parent: parentID, name: urlReadableFragment(name), kind: "taskGroup")
190189
resolvedReferenceMap[taskGroupID] = reference
191190
}
192191

@@ -272,3 +271,19 @@ final class PathHierarchyBasedLinkResolver {
272271
return result
273272
}
274273
}
274+
275+
/// Creates a more writable version of an articles file name for use in documentation links.
276+
///
277+
/// Compared to `urlReadablePath(_:)` this preserves letters in other written languages.
278+
private func linkName<S: StringProtocol>(filename: S) -> String {
279+
// It would be a nice enhancement to also remove punctuation from the filename to allow an article in a file named "One, two, & three!"
280+
// to be referenced with a link as `"One-two-three"` instead of `"One,-two-&-three!"` (rdar://120722917)
281+
return filename
282+
// Replace continuous whitespace and dashes
283+
.components(separatedBy: whitespaceAndDashes)
284+
.filter({ !$0.isEmpty })
285+
.joined(separator: "-")
286+
}
287+
288+
private let whitespaceAndDashes = CharacterSet.whitespaces
289+
.union(CharacterSet(charactersIn: "-–—")) // hyphen, en dash, em dash

Sources/SwiftDocC/Model/Identifier.swift

Lines changed: 13 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,14 @@ extension TopicReferenceResolutionErrorInfo {
141141
/// > its data.
142142
public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomStringConvertible {
143143
typealias ReferenceBundleIdentifier = String
144-
typealias ReferenceKey = String
144+
private struct ReferenceKey: Hashable {
145+
var path: String
146+
var fragment: String?
147+
var sourceLanguages: Set<SourceLanguage>
148+
}
145149

146150
/// A synchronized reference cache to store resolved references.
147-
static var sharedPool = Synchronized([ReferenceBundleIdentifier: [ReferenceKey: ResolvedTopicReference]]())
151+
private static var sharedPool = Synchronized([ReferenceBundleIdentifier: [ReferenceKey: ResolvedTopicReference]]())
148152

149153
/// Clears cached references belonging to the bundle with the given identifier.
150154
/// - Parameter bundleIdentifier: The identifier of the bundle to which the method should clear belonging references.
@@ -219,11 +223,7 @@ public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomString
219223
private init(bundleIdentifier: String, urlReadablePath: String, urlReadableFragment: String? = nil, sourceLanguages: Set<SourceLanguage>) {
220224
precondition(!sourceLanguages.isEmpty, "ResolvedTopicReference.sourceLanguages cannot be empty")
221225
// Check for a cached instance of the reference
222-
let key = Self.cacheKey(
223-
urlReadablePath: urlReadablePath,
224-
urlReadableFragment: urlReadableFragment,
225-
sourceLanguages: sourceLanguages
226-
)
226+
let key = ReferenceKey(path: urlReadablePath, fragment: urlReadableFragment, sourceLanguages: sourceLanguages)
227227
let cached = Self.sharedPool.sync { $0[bundleIdentifier]?[key] }
228228
if let resolved = cached {
229229
self = resolved
@@ -244,20 +244,6 @@ public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomString
244244
}
245245
}
246246

247-
private static func cacheKey(
248-
urlReadablePath path: String,
249-
urlReadableFragment fragment: String?,
250-
sourceLanguages: Set<SourceLanguage>
251-
) -> String {
252-
let sourceLanguagesString = sourceLanguages.map(\.id).sorted().joined(separator: "-")
253-
254-
if let fragment = fragment {
255-
return "\(path):\(fragment):\(sourceLanguagesString)"
256-
} else {
257-
return "\(path):\(sourceLanguagesString)"
258-
}
259-
}
260-
261247
/// The topic URL as you would write in a link.
262248
public var url: URL {
263249
return _storage.url
@@ -495,22 +481,10 @@ public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomString
495481
self.absoluteString = self.url.absoluteString
496482
}
497483
}
498-
}
499-
500-
typealias ResolvedTopicReferenceCacheKey = String
501-
502-
extension ResolvedTopicReference {
503-
/// Returns a unique cache ID for a pair of unresolved and parent references.
504-
static func cacheIdentifier(_ reference: UnresolvedTopicReference, fromSymbolLink: Bool, in parent: ResolvedTopicReference?) -> ResolvedTopicReferenceCacheKey {
505-
let isSymbolLink = fromSymbolLink ? ":symbol" : ""
506-
if let parent = parent {
507-
// Create a cache id in the parent context
508-
return "\(reference.topicURL.absoluteString):\(parent.bundleIdentifier):\(parent.path):\(parent.sourceLanguage.id)\(isSymbolLink)"
509-
} else {
510-
// A cache ID for an external reference
511-
assert(reference.topicURL.components.host != nil)
512-
return reference.topicURL.absoluteString.appending(isSymbolLink)
513-
}
484+
485+
// For testing the caching
486+
static func _numberOfCachedReferences(bundleID: ReferenceBundleIdentifier) -> Int? {
487+
return Self.sharedPool.sync { $0[bundleID]?.count }
514488
}
515489
}
516490

@@ -643,6 +617,7 @@ func urlReadablePath<S: StringProtocol>(_ path: S) -> String {
643617
}
644618

645619
private extension CharacterSet {
620+
// For fragments
646621
static let fragmentCharactersToRemove = CharacterSet.punctuationCharacters // Remove punctuation from fragments
647622
.union(CharacterSet(charactersIn: "`")) // Also consider back-ticks as punctuation. They are used as quotes around symbols or other code.
648623
.subtracting(CharacterSet(charactersIn: "-")) // Don't remove hyphens. They are used as a whitespace replacement.
@@ -669,3 +644,4 @@ func urlReadableFragment<S: StringProtocol>(_ fragment: S) -> String {
669644

670645
return fragment
671646
}
647+

Sources/SwiftDocC/Model/Rendering/DocumentationContentRenderer.swift

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -494,12 +494,7 @@ public class DocumentationContentRenderer {
494494

495495
// For external links, verify they've resolved successfully and return `nil` otherwise.
496496
if linkHost != reference.bundleIdentifier {
497-
let externalReference = ResolvedTopicReference(
498-
bundleIdentifier: linkHost,
499-
path: destination.path,
500-
sourceLanguages: node.availableSourceLanguages
501-
)
502-
if documentationContext.externallyResolvedSymbols.contains(externalReference) {
497+
if let url = ValidatedURL(destination), case .success(let externalReference) = documentationContext.externallyResolvedLinks[url] {
503498
return externalReference
504499
}
505500
return nil

Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,8 @@ public struct RenderNodeTranslator: SemanticVisitor {
598598

599599
public mutating func visitArticle(_ article: Article) -> RenderTree? {
600600
var node = RenderNode(identifier: identifier, kind: .article)
601-
var contentCompiler = RenderContentCompiler(context: context, bundle: bundle, identifier: identifier)
601+
// Contains symbol references declared in the Topics section.
602+
var topicSectionContentCompiler = RenderContentCompiler(context: context, bundle: bundle, identifier: identifier)
602603

603604
node.metadata.title = article.title!.plainText
604605

@@ -674,7 +675,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
674675
allowExternalLinks: false,
675676
allowedTraits: allowedTraits,
676677
availableTraits: documentationNode.availableVariantTraits,
677-
contentCompiler: &contentCompiler
678+
contentCompiler: &topicSectionContentCompiler
678679
)
679680
)
680681
}
@@ -685,7 +686,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
685686
sections.append(
686687
contentsOf: renderAutomaticTaskGroupsSection(
687688
article.automaticTaskGroups.filter { $0.renderPositionPreference == .top },
688-
contentCompiler: &contentCompiler
689+
contentCompiler: &topicSectionContentCompiler
689690
)
690691
)
691692
}
@@ -714,7 +715,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
714715
}
715716

716717
// Collect all child topic references.
717-
contentCompiler.collectedTopicReferences.append(contentsOf: groups.flatMap(\.references))
718+
topicSectionContentCompiler.collectedTopicReferences.append(contentsOf: groups.flatMap(\.references))
718719
// Add the final groups to the node.
719720
sections.append(contentsOf: groups.map(TaskGroupRenderSection.init(taskGroup:)))
720721
}
@@ -725,7 +726,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
725726
sections.append(
726727
contentsOf: renderAutomaticTaskGroupsSection(
727728
article.automaticTaskGroups.filter { $0.renderPositionPreference == .bottom },
728-
contentCompiler: &contentCompiler
729+
contentCompiler: &topicSectionContentCompiler
729730
)
730731
)
731732
}
@@ -736,11 +737,30 @@ public struct RenderNodeTranslator: SemanticVisitor {
736737
node.topicSectionsStyle = topicsSectionStyle(for: documentationNode)
737738

738739
if shouldCreateAutomaticRoleHeading(for: documentationNode) {
739-
if node.topicSections.isEmpty {
740-
// Set an eyebrow for articles
740+
741+
let role = DocumentationContentRenderer.roleForArticle(article, nodeKind: documentationNode.kind)
742+
node.metadata.role = role.rawValue
743+
744+
switch role {
745+
case .article:
746+
// If there are no links to other nodes from the article,
747+
// set the eyebrow for articles.
741748
node.metadata.roleHeading = "Article"
749+
case .collectionGroup:
750+
// If the article links to other nodes, set the eyebrow for
751+
// API Collections if any linked node is a symbol.
752+
//
753+
// If none of the linked nodes are symbols (it's a plain collection),
754+
// don't display anything as the eyebrow title.
755+
let curatesSymbols = topicSectionContentCompiler.collectedTopicReferences.contains { topicReference in
756+
context.topicGraph.nodeWithReference(topicReference)?.kind.isSymbol ?? false
757+
}
758+
if curatesSymbols {
759+
node.metadata.roleHeading = "API Collection"
760+
}
761+
default:
762+
break
742763
}
743-
node.metadata.role = DocumentationContentRenderer.roleForArticle(article, nodeKind: documentationNode.kind).rawValue
744764
}
745765

746766
if let pageImages = documentationNode.metadata?.pageImages {
@@ -780,7 +800,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
780800
allowExternalLinks: true,
781801
allowedTraits: allowedTraits,
782802
availableTraits: documentationNode.availableVariantTraits,
783-
contentCompiler: &contentCompiler
803+
contentCompiler: &topicSectionContentCompiler
784804
)
785805
)
786806
}
@@ -794,7 +814,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
794814
renderContext: renderContext,
795815
renderer: contentRenderer
796816
) {
797-
contentCompiler.collectedTopicReferences.append(contentsOf: seeAlso.references)
817+
topicSectionContentCompiler.collectedTopicReferences.append(contentsOf: seeAlso.references)
798818
seeAlsoSections.append(TaskGroupRenderSection(taskGroup: seeAlso))
799819
}
800820

@@ -845,7 +865,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
845865
node.metadata.roleHeading = titleHeading.heading
846866
}
847867

848-
collectedTopicReferences.append(contentsOf: contentCompiler.collectedTopicReferences)
868+
collectedTopicReferences.append(contentsOf: topicSectionContentCompiler.collectedTopicReferences)
849869
node.references = createTopicRenderReferences()
850870

851871
addReferences(imageReferences, to: &node)
@@ -854,7 +874,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
854874
addReferences(downloadReferences, to: &node)
855875
// See Also can contain external links, we need to separately transfer
856876
// link references from the content compiler
857-
addReferences(contentCompiler.linkReferences, to: &node)
877+
addReferences(topicSectionContentCompiler.linkReferences, to: &node)
858878

859879
return node
860880
}

Tests/SwiftDocCTests/Converter/RenderNodeCodableTests.swift

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -146,21 +146,14 @@ class RenderNodeCodableTests: XCTestCase {
146146
subdirectory: "Test Resources"
147147
)!
148148

149-
let uniqueBundleIdentifier = #function
149+
let bundleID = #function
150150

151-
let renderNodeWithUniqueBundleID = try String(
152-
contentsOf: exampleRenderNodeJSON
153-
)
154-
.replacingOccurrences(
155-
of: "org.swift.docc.example",
156-
with: uniqueBundleIdentifier
157-
)
151+
let renderNodeWithUniqueBundleID = try String(contentsOf: exampleRenderNodeJSON)
152+
.replacingOccurrences(of: "org.swift.docc.example", with: bundleID)
158153

159154
_ = try JSONDecoder().decode(RenderNode.self, from: Data(renderNodeWithUniqueBundleID.utf8))
160155

161-
ResolvedTopicReference.sharedPool.sync { sharedPool in
162-
XCTAssertNil(sharedPool[uniqueBundleIdentifier])
163-
}
156+
XCTAssertNil(ResolvedTopicReference._numberOfCachedReferences(bundleID: bundleID))
164157
}
165158

166159
func testDecodeRenderNodeWithoutTopicSectionStyle() throws {

Tests/SwiftDocCTests/Indexing/NavigatorIndexTests.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,9 +260,7 @@ Root
260260
atomically: true
261261
)
262262

263-
ResolvedTopicReference.sharedPool.sync { sharedPool in
264-
XCTAssertNil(sharedPool[uniqueTestBundleIdentifier])
265-
}
263+
XCTAssertNil(ResolvedTopicReference._numberOfCachedReferences(bundleID: uniqueTestBundleIdentifier))
266264
}
267265

268266

0 commit comments

Comments
 (0)