Skip to content

Commit 186fb13

Browse files
authored
Fix rendering issue for links with special characters (#532)
* Fast path to look up resolved references by their absolute string rdar://85531439 * Fix bug where links with special characters used the link as title rdar://85531439 * Skip new test when running old link resolver implementation * Add more type annotations in test to accommodate other compiler versions * Expand the fallback parsing of authored documentation links
1 parent 1340fd2 commit 186fb13

File tree

8 files changed

+250
-15
lines changed

8 files changed

+250
-15
lines changed

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,9 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
10091009
return documentationCache[reference]
10101010
}
10111011

1012+
/// A lookup of resolved references based on the reference's absolute string.
1013+
private(set) var referenceIndex = [String: ResolvedTopicReference]()
1014+
10121015
private func nodeWithInitializedContent(reference: ResolvedTopicReference, matches: [DocumentationContext.SemanticResult<Article>]?) -> DocumentationNode {
10131016
precondition(documentationCache.keys.contains(reference))
10141017

@@ -2324,6 +2327,14 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
23242327
topicGraphGlobalAnalysis()
23252328

23262329
preResolveModuleNames()
2330+
2331+
referenceIndex.reserveCapacity(knownIdentifiers.count + nodeAnchorSections.count)
2332+
for reference in knownIdentifiers {
2333+
referenceIndex[reference.absoluteString] = reference
2334+
}
2335+
for reference in nodeAnchorSections.keys {
2336+
referenceIndex[reference.absoluteString] = reference
2337+
}
23272338
}
23282339

23292340
/// Given a list of topics that have been automatically curated, checks if a topic has been additionally manually curated

Sources/SwiftDocC/Infrastructure/DocumentationCurator.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ struct DocumentationCurator {
3838
}
3939

4040
// Optimization for absolute links.
41-
if let cached = context.documentationCacheBasedLinkResolver.referenceFor(absoluteSymbolPath: destination, parent: resolved) {
41+
if let cached = context.referenceIndex[destination] {
4242
return cached
4343
}
4444

Sources/SwiftDocC/Model/Identifier.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,7 @@ public struct ResourceReference: Hashable {
623623
/// If this step is not performed, the disallowed characters are instead percent escape encoded instead which is less readable.
624624
/// For example, a path like `"hello world/example project"` is converted to `"hello-world/example-project"`
625625
/// instead of `"hello%20world/example%20project"`.
626-
func urlReadablePath(_ path: String) -> String {
626+
func urlReadablePath<S: StringProtocol>(_ path: S) -> String {
627627
return path.components(separatedBy: .urlPathNotAllowed).joined(separator: "-")
628628
}
629629

@@ -639,7 +639,7 @@ private extension CharacterSet {
639639
///
640640
/// If this step is not performed, the disallowed characters are instead percent escape encoded, which is less readable.
641641
/// For example, a fragment like `"#hello world"` is converted to `"#hello-world"` instead of `"#hello%20world"`.
642-
func urlReadableFragment(_ fragment: String) -> String {
642+
func urlReadableFragment<S: StringProtocol>(_ fragment: S) -> String {
643643
var fragment = fragment
644644
// Trim leading/trailing whitespace
645645
.trimmingCharacters(in: .whitespaces)

Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,23 @@ struct RenderContentCompiler: MarkupVisitor {
175175
useOverriding = false
176176
} else if let overridingTitle = overridingTitle,
177177
overridingTitle.hasPrefix(ResolvedTopicReference.urlScheme + ":"),
178-
destination.hasPrefix(ResolvedTopicReference.urlScheme + "://"),
179-
destination.hasSuffix(overridingTitle.dropFirst((ResolvedTopicReference.urlScheme + ":").count)) { // If the link is a transformed doc link, we don't use overriding info
180-
useOverriding = false
178+
destination.hasPrefix(ResolvedTopicReference.urlScheme + "://")
179+
{
180+
// The overriding title looks like a documentation link. Escape it like a resolved reference string to compare it with the destination.
181+
let withoutScheme = overridingTitle.dropFirst((ResolvedTopicReference.urlScheme + ":").count)
182+
if destination.hasSuffix(withoutScheme) {
183+
useOverriding = false
184+
} else {
185+
let escapedTitle: String
186+
if let fragmentIndex = withoutScheme.firstIndex(of: "#") {
187+
let escapedFragment = withoutScheme[fragmentIndex...].dropFirst().addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) ?? ""
188+
escapedTitle = "\(urlReadablePath(withoutScheme[..<fragmentIndex]))#\(escapedFragment)"
189+
} else {
190+
escapedTitle = urlReadablePath(withoutScheme)
191+
}
192+
193+
useOverriding = !destination.hasSuffix(escapedTitle) // If the link is a transformed doc link, we don't use overriding info
194+
}
181195
} else {
182196
useOverriding = true
183197
}
@@ -192,6 +206,11 @@ struct RenderContentCompiler: MarkupVisitor {
192206
}
193207

194208
mutating func resolveTopicReference(_ destination: String) -> ResolvedTopicReference? {
209+
if let cached = context.referenceIndex[destination] {
210+
collectedTopicReferences.append(cached)
211+
return cached
212+
}
213+
195214
guard let validatedURL = ValidatedURL(parsingAuthoredLink: destination) else {
196215
return nil
197216
}
@@ -215,7 +234,7 @@ struct RenderContentCompiler: MarkupVisitor {
215234
}
216235

217236
func resolveSymbolReference(destination: String) -> ResolvedTopicReference? {
218-
if let cached = context.documentationCacheBasedLinkResolver.referenceFor(absoluteSymbolPath: destination, parent: identifier) {
237+
if let cached = context.referenceIndex[destination] {
219238
return cached
220239
}
221240

Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -534,8 +534,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
534534

535535
let action: RenderInlineContent
536536
// We expect, at this point of the rendering, this API to be called with valid URLs, otherwise crash.
537-
let unresolved = UnresolvedTopicReference(topicURL: ValidatedURL(link)!)
538-
if case let .success(resolved) = context.resolve(.unresolved(unresolved), in: bundle.rootReference) {
537+
if let resolved = context.referenceIndex[link.absoluteString] {
539538
action = RenderInlineContent.reference(identifier: RenderReferenceIdentifier(resolved.absoluteString),
540539
isActive: true,
541540
overridingTitle: overridingTitle,
@@ -545,7 +544,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
545544
// This is an external link
546545
let externalLinkIdentifier = RenderReferenceIdentifier(forExternalLink: link.absoluteString)
547546
if linkReferences.keys.contains(externalLinkIdentifier.identifier) {
548-
// If we've already seen this link, return the existing reference with an overriden title.
547+
// If we've already seen this link, return the existing reference with an overridden title.
549548
action = RenderInlineContent.reference(identifier: externalLinkIdentifier,
550549
isActive: true,
551550
overridingTitle: overridingTitle,

Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,17 @@ struct MarkupReferenceResolver: MarkupRewriter {
113113
return link
114114
}
115115
var link = link
116+
let wasAutoLink = link.isAutolink
116117
link.destination = resolvedURL.absoluteString
118+
if wasAutoLink {
119+
link.replaceChildrenInRange(0..<link.childCount, with: [Text(resolvedURL.absoluteString)])
120+
assert(link.isAutolink)
121+
}
117122
return link
118123
}
119124

120125
mutating func resolveAbsoluteSymbolLink(unresolvedDestination: String, elementRange range: SourceRange?) -> ResolvedTopicReference? {
121-
if let cached = context.documentationCacheBasedLinkResolver.referenceFor(absoluteSymbolPath: unresolvedDestination, parent: rootReference) {
126+
if let cached = context.referenceIndex[unresolvedDestination] {
122127
guard context.topicGraph.isLinkable(cached) == true else {
123128
problems.append(disabledLinkDestinationProblem(reference: cached, source: source, range: range, severity: .warning))
124129
return nil

Sources/SwiftDocC/Utility/ValidatedURL.swift

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,57 @@ public struct ValidatedURL: Hashable, Equatable {
6262
return
6363
}
6464

65-
// If the string doesn't contain a fragment and the string couldn't be parsed with `ValidatedURL(parsing:)` above, then consider it invalid.
66-
guard let fragmentSeparatorIndex = string.firstIndex(of: "#"), var components = URLComponents(string: String(string[..<fragmentSeparatorIndex])) else {
67-
return nil
65+
// If the `URLComponents(string:)` parsing in `init(parsingExact:)` failed try a fallback that attempts to individually
66+
// percent encode each component.
67+
//
68+
// This fallback parsing tries to determine the substrings of the authored link that correspond to the scheme, bundle
69+
// identifier, path, and fragment of a documentation link or symbol link. It is not meant to work with general links.
70+
//
71+
// By identifying the subranges they can each be individually percent encoded with the characters that are allowed for
72+
// that component. This allows authored links to contain characters that wouldn't otherwise be valid in a general URL.
73+
//
74+
// Assigning the percent encoded values to `URLComponents/percentEncodedHost`, URLComponents/percentEncodedPath`, and
75+
// URLComponents/percentEncodedFragment` allow for the creation of a `URLComponents` value with special characters.
76+
var components = URLComponents()
77+
var remainder = string[...]
78+
79+
// See if the link is a documentation link and try to split out the scheme and bundle identifier. If the link isn't a
80+
// documentation link it's assumed that it's a symbol link that start with the path component.
81+
// Other general URLs should have been successfully parsed with `URLComponents(string:)` in `init(parsingExact:)` above.
82+
if remainder.hasPrefix("\(ResolvedTopicReference.urlScheme):") {
83+
// The authored link is a doc link
84+
components.scheme = ResolvedTopicReference.urlScheme
85+
remainder = remainder.dropFirst("\(ResolvedTopicReference.urlScheme):".count)
86+
87+
if remainder.hasPrefix("//") {
88+
// The authored link includes a bundle ID
89+
guard let startOfPath = remainder.dropFirst(2).firstIndex(of: "/") else {
90+
// The link started with "doc://" but didn't contain another "/" to start of the path.
91+
return nil
92+
}
93+
components.percentEncodedHost = String(remainder[..<startOfPath]).addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
94+
remainder = remainder[startOfPath...]
95+
}
96+
}
97+
98+
// This either is the start of a symbol link or the remainder of a doc link after the scheme and bundle ID was parsed.
99+
// This means that the remainder of the string is a path with an optional fragment. No other URL components are supported
100+
// by documentation links and symbol links.
101+
if let fragmentSeparatorIndex = remainder.firstIndex(of: "#") {
102+
// Encode the path substring and fragment substring separately
103+
guard let path = String(remainder[..<fragmentSeparatorIndex]).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
104+
return nil
105+
}
106+
components.percentEncodedPath = path
107+
components.percentEncodedFragment = String(remainder[fragmentSeparatorIndex...].dropFirst()).addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed)
108+
} else {
109+
// Since the link didn't include a fragment, the rest of the string is the path.
110+
guard let path = String(remainder).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
111+
return nil
112+
}
113+
components.percentEncodedPath = path
68114
}
69115

70-
components.percentEncodedFragment = String(string[fragmentSeparatorIndex...].dropFirst()).addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed)
71116
self.components = components
72117
}
73118

0 commit comments

Comments
 (0)