Skip to content

Commit e89095c

Browse files
authored
Allow writing doc links to subsections using special characters (#262) (#265)
* Allow writing doc links to subsections using special characters rdar://93458581 * Rename ValidatedURL initializers to indicate intended use at call site
1 parent f1669e3 commit e89095c

18 files changed

+192
-50
lines changed

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See https://swift.org/LICENSE.txt for license information
@@ -879,7 +879,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
879879
// At this point we consider all articles with an H1 containing link "documentation extension" - some links might not resolve in the final documentation hierarchy
880880
// and we will emit warnings for those later on when we finalize the bundle discovery phase.
881881
if let link = result.value.title?.child(at: 0) as? AnyLink,
882-
let url = link.destination.flatMap(ValidatedURL.init) {
882+
let url = link.destination.flatMap(ValidatedURL.init(parsingExact:)) {
883883
let reference = result.topicGraphNode.reference
884884

885885
let symbolPath = NodeURLGenerator.Path.documentation(path: url.components.path).stringValue

Sources/SwiftDocC/Infrastructure/DocumentationCurator.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See https://swift.org/LICENSE.txt for license information
@@ -54,7 +54,7 @@ struct DocumentationCurator {
5454
/// Tries to resolve a link in the current module/context.
5555
mutating func referenceFromLink(link: Link, resolved: ResolvedTopicReference, source: URL?) -> ResolvedTopicReference? {
5656
// Try a link to a topic
57-
guard let unresolved = link.destination.flatMap(ValidatedURL.init)?
57+
guard let unresolved = link.destination.flatMap(ValidatedURL.init(parsingAuthoredLink:))?
5858
.requiring(scheme: ResolvedTopicReference.urlScheme)
5959
.map(UnresolvedTopicReference.init(topicURL:)) else {
6060
// Emit a warning regarding the invalid link found in a task group.

Sources/SwiftDocC/Infrastructure/Symbol Link Resolution/AbsoluteSymbolLink.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public struct AbsoluteSymbolLink: CustomStringConvertible {
4646
// Begin by constructing a validated URL from the given string.
4747
// Normally symbol links would be validated with `init(symbolPath:)` but since this is expected
4848
// to be an absolute URL we parse it with `init(parsing:)` instead.
49-
guard let validatedURL = ValidatedURL(parsing: string)?.requiring(scheme: ResolvedTopicReference.urlScheme) else {
49+
guard let validatedURL = ValidatedURL(parsingExact: string)?.requiring(scheme: ResolvedTopicReference.urlScheme) else {
5050
return nil
5151
}
5252

Sources/SwiftDocC/Model/DocumentationNode.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public struct DocumentationNode {
104104
// so we can index all anchors found in the bundle for link resolution.
105105
if let heading = child as? Heading, heading.level > 1, heading.level < 4 {
106106
anchorSections.append(
107-
AnchorSection(reference: reference.withFragment(urlReadableFragment(heading.plainText)), title: heading.plainText)
107+
AnchorSection(reference: reference.withFragment(heading.plainText), title: heading.plainText)
108108
)
109109
}
110110
}

Sources/SwiftDocC/Model/Identifier.swift

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See https://swift.org/LICENSE.txt for license information
@@ -291,7 +291,7 @@ public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomString
291291
let newReference = ResolvedTopicReference(
292292
bundleIdentifier: bundleIdentifier,
293293
urlReadablePath: newPath,
294-
urlReadableFragment: reference.fragment,
294+
urlReadableFragment: reference.fragment.map(urlReadableFragment),
295295
sourceLanguages: sourceLanguages
296296
)
297297
return newReference
@@ -553,8 +553,11 @@ func urlReadablePath(_ path: String) -> String {
553553
}
554554

555555
private extension CharacterSet {
556-
static let invalidCharacterSet = CharacterSet(charactersIn: "'\"`")
557-
static let whitespaceAndDashes = CharacterSet(charactersIn: "-").union(.whitespaces)
556+
static let fragmentCharactersToRemove = CharacterSet.punctuationCharacters // Remove punctuation from fragments
557+
.union(CharacterSet(charactersIn: "`")) // Also consider back-ticks as punctuation. They are used as quotes around symbols or other code.
558+
.subtracting(CharacterSet(charactersIn: "-")) // Don't remove hyphens. They are used as a whitespace replacement.
559+
static let whitespaceAndDashes = CharacterSet.whitespaces
560+
.union(CharacterSet(charactersIn: "-–—")) // hyphen, en dash, em dash
558561
}
559562

560563
/// Creates a more readable version of a fragment by replacing characters that are not allowed in the fragment of a URL with hyphens.
@@ -572,7 +575,7 @@ func urlReadableFragment(_ fragment: String) -> String {
572575
.joined(separator: "-")
573576

574577
// Remove invalid characters
575-
fragment.unicodeScalars.removeAll(where: CharacterSet.invalidCharacterSet.contains)
578+
fragment.unicodeScalars.removeAll(where: CharacterSet.fragmentCharactersToRemove.contains)
576579

577580
return fragment
578581
}

Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See https://swift.org/LICENSE.txt for license information
@@ -95,7 +95,7 @@ struct RenderContentCompiler: MarkupVisitor {
9595
let externalLinkIdentifier = RenderReferenceIdentifier(forExternalLink: destination)
9696

9797
if linkReferences.keys.contains(externalLinkIdentifier.identifier) {
98-
// If we've already seen this link, return the existing reference with an overriden title.
98+
// If we've already seen this link, return the existing reference with an overridden title.
9999
return [RenderInlineContent.reference(identifier: externalLinkIdentifier,
100100
isActive: true,
101101
overridingTitle: plainTextLinkTitle.isEmpty ? nil : plainTextLinkTitle,
@@ -113,7 +113,7 @@ struct RenderContentCompiler: MarkupVisitor {
113113
}
114114
}
115115

116-
guard let unresolved = link.destination.flatMap(ValidatedURL.init)
116+
guard let unresolved = link.destination.flatMap(ValidatedURL.init(parsingAuthoredLink:))
117117
.map({ UnresolvedTopicReference(topicURL: $0) }),
118118
// Try to resolve in the local context
119119
case let .success(resolved) = context.resolve(.unresolved(unresolved), in: identifier) else {

Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -920,7 +920,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
920920
case let link as Link:
921921
if !allowExternalLinks {
922922
// For links require documentation scheme
923-
guard let _ = link.destination.flatMap(ValidatedURL.init)?.requiring(scheme: ResolvedTopicReference.urlScheme) else {
923+
guard let _ = link.destination.flatMap(ValidatedURL.init(parsingAuthoredLink:))?.requiring(scheme: ResolvedTopicReference.urlScheme) else {
924924
return nil
925925
}
926926
}

Sources/SwiftDocC/Semantics/ExternalLinks/ExternalMarkupReferenceWalker.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ struct ExternalMarkupReferenceWalker: MarkupVisitor {
3737
mutating func visitLink(_ link: Link) {
3838
// Only process documentation links to external bundles
3939
guard let destination = link.destination,
40-
let url = ValidatedURL(parsing: destination),
40+
let url = ValidatedURL(parsingExact: destination),
4141
url.components.scheme == ResolvedTopicReference.urlScheme,
4242
let bundleID = url.components.host,
4343
bundleID != bundle.identifier else {

Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See https://swift.org/LICENSE.txt for license information
@@ -96,7 +96,7 @@ struct MarkupReferenceResolver: MarkupRewriter {
9696
guard let destination = link.destination else {
9797
return link
9898
}
99-
guard let url = ValidatedURL(parsing: destination) else {
99+
guard let url = ValidatedURL(parsingAuthoredLink: destination) else {
100100
problems.append(invalidLinkDestinationProblem(destination: destination, source: source, range: link.range, severity: .warning))
101101
return link
102102
}
@@ -122,7 +122,7 @@ struct MarkupReferenceResolver: MarkupRewriter {
122122
}
123123

124124
// We don't require a scheme here as the link can be a relative one, e.g. ``SwiftUI/View``.
125-
let url = ValidatedURL(parsing: unresolvedDestination)?.requiring(scheme: ResolvedTopicReference.urlScheme) ?? ValidatedURL(symbolPath: unresolvedDestination)
125+
let url = ValidatedURL(parsingExact: unresolvedDestination)?.requiring(scheme: ResolvedTopicReference.urlScheme) ?? ValidatedURL(symbolPath: unresolvedDestination)
126126
let unresolved = TopicReference.unresolved(.init(topicURL: url))
127127
guard let resolvedURL = resolve(reference: unresolved, range: range, severity: .warning, fromSymbolLink: true) else {
128128
return unresolvedDestination

Sources/SwiftDocC/Utility/ValidatedURL.swift

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See https://swift.org/LICENSE.txt for license information
@@ -37,13 +37,40 @@ public struct ValidatedURL: Hashable, Equatable {
3737
/// > URL with the "someMethodWithFirstValue" scheme which is a valid link but which won't resolve to the intended symbol.
3838
/// >
3939
/// > When working with symbol destinations use ``init(symbolPath:)`` instead.
40-
init?(parsing string: String) {
40+
/// >
41+
/// > When working with authored documentation links use ``init(parsingAuthoredLink:)`` instead.
42+
init?(parsingExact string: String) {
4143
guard let components = URLComponents(string: string) else {
4244
return nil
4345
}
4446
self.components = components
4547
}
4648

49+
/// Creates a new RFC 3986 valid URL by using the given string URL and percent escaping the fragment component if necessary.
50+
///
51+
/// Will return `nil` when the given `string` is not a valid URL.
52+
/// - Parameter string: Source URL address as string.
53+
///
54+
/// If the parsed fragment component contains characters not allowed in the fragment of a URL, those characters will be percent encoded.
55+
///
56+
/// Use this to parse author provided documentation links that may contain links to on-page subsections. Escaping the fragment allows authors
57+
/// to write links to subsections using characters that wouldn't otherwise be allowed in a fragment of a URL.
58+
init?(parsingAuthoredLink string: String) {
59+
// Try to parse the string without escaping anything
60+
if let parsed = ValidatedURL(parsingExact: string) {
61+
self.components = parsed.components
62+
return
63+
}
64+
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
68+
}
69+
70+
components.percentEncodedFragment = String(string[fragmentSeparatorIndex...].dropFirst()).addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed)
71+
self.components = components
72+
}
73+
4774
/// Creates a new RFC 3986 valid URL from the given URL.
4875
///
4976
/// Will return `nil` when the given URL doesn't comply with RFC 3986.

0 commit comments

Comments
 (0)