Skip to content

Commit ed5f428

Browse files
authored
Replace spaces with dashes in links to articles (#773)
* Replace spaces and punctuation with dashes in links to articles rdar://119577248 * Add test that file name punctuation warn about reference collisions * Update what punctuation is used in file names in new test * Use simpler punctuation in file name in test for older Swift versions * Only replace whitespaces with dashes (not punctuation) * Move linkName function to only file where it has a reason to be used
1 parent 3fe2154 commit ed5f428

File tree

3 files changed

+111
-14
lines changed

3 files changed

+111
-14
lines changed

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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,7 @@ func urlReadablePath<S: StringProtocol>(_ path: S) -> String {
643643
}
644644

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

670671
return fragment
671672
}
673+

Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1736,6 +1736,53 @@ let expected = """
17361736
XCTAssertEqual("/=(_:_:)", pageIdentifiersAndNames["/documentation/Operators/MyNumber/_=(_:_:)-3m4ko"])
17371737
}
17381738

1739+
func testFileNamesWithDifferentPunctuation() throws {
1740+
let tempURL = try createTempFolder(content: [
1741+
Folder(name: "unit-test.docc", content: [
1742+
TextFile(name: "Hello-world.md", utf8Content: """
1743+
# Dash
1744+
1745+
No whitespace in the file name
1746+
"""),
1747+
1748+
TextFile(name: "Hello world.md", utf8Content: """
1749+
# Only space
1750+
1751+
This has the same reference as "Hello-world.md" and will raise a warning.
1752+
"""),
1753+
1754+
TextFile(name: "Hello world.md", utf8Content: """
1755+
# Multiple spaces
1756+
1757+
Each space is replaced with a dash in the reference, so this has a unique reference.
1758+
"""),
1759+
1760+
TextFile(name: "Hello, world!.md", utf8Content: """
1761+
# Space and punctuation
1762+
1763+
The punctuation is not removed from the reference, so this has a unique reference.
1764+
"""),
1765+
1766+
TextFile(name: "Hello. world?.md", utf8Content: """
1767+
# Space and different punctuation
1768+
1769+
The punctuation is not removed from the reference, so this has a unique reference.
1770+
"""),
1771+
])
1772+
])
1773+
let (_, _, context) = try loadBundle(from: tempURL)
1774+
1775+
XCTAssertEqual(context.problems.map(\.diagnostic.summary), ["Redeclaration of 'Hello world.md'; this file will be skipped"])
1776+
1777+
XCTAssertEqual(context.knownPages.map(\.absoluteString).sorted(), [
1778+
"doc://unit-test/documentation/unit-test",
1779+
"doc://unit-test/documentation/unit-test/Hello,-world!",
1780+
"doc://unit-test/documentation/unit-test/Hello--world",
1781+
"doc://unit-test/documentation/unit-test/Hello-world",
1782+
"doc://unit-test/documentation/unit-test/Hello.-world-",
1783+
])
1784+
}
1785+
17391786
func testSpecialCharactersInLinks() throws {
17401787
let originalSymbolGraph = Bundle.module.url(forResource: "TestBundle", withExtension: "docc", subdirectory: "Test Bundles")!.appendingPathComponent("mykit-iOS.symbols.json")
17411788

@@ -1751,7 +1798,15 @@ let expected = """
17511798
"""),
17521799

17531800
TextFile(name: "article-with-😃-in-filename.md", utf8Content: """
1754-
# Article with 😃 emoji in file name
1801+
# Article with 😃 emoji in its filename
1802+
1803+
Abstract
1804+
1805+
### Hello world
1806+
"""),
1807+
1808+
TextFile(name: "Article: with - various! whitespace & punctuation. in, filename.md", utf8Content: """
1809+
# Article with various whitespace and punctuation in its filename
17551810
17561811
Abstract
17571812
@@ -1767,6 +1822,8 @@ let expected = """
17671822
- <doc:article-with-emoji-in-heading#Hello-🌍>
17681823
- <doc:article-with-😃-in-filename>
17691824
- <doc:article-with-😃-in-filename#Hello-world>
1825+
- <doc:Article:-with-various!-whitespace-&-punctuation.-in,-filename>
1826+
- <doc:Article:-with-various!-whitespace-&-punctuation.-in,-filename#Hello-world>
17701827
17711828
Now test the same links in topic curation.
17721829
@@ -1776,6 +1833,7 @@ let expected = """
17761833
17771834
- ``MyClass/myFunc🙂()``
17781835
- <doc:article-with-😃-in-filename>
1836+
- <doc:Article:-with-various!-whitespace-&-punctuation.-in,-filename>
17791837
"""),
17801838
])
17811839
let bundleURL = try testBundle.write(inside: createTemporaryDirectory())
@@ -1789,11 +1847,12 @@ let expected = """
17891847

17901848
let moduleSymbol = try XCTUnwrap(entity.semantic as? Symbol)
17911849
let topicSection = try XCTUnwrap(moduleSymbol.topics?.taskGroups.first)
1792-
1850+
17931851
// Verify that all the links in the topic section resolved
17941852
XCTAssertEqual(topicSection.links.map(\.destination), [
17951853
"doc://special-characters/documentation/MyKit/MyClass/myFunc_()",
17961854
"doc://special-characters/documentation/special-characters/article-with---in-filename",
1855+
"doc://special-characters/documentation/special-characters/Article:-with---various!-whitespace-&-punctuation.-in,-filename",
17971856
])
17981857

17991858
// Verify that all resolved link exist in the context.
@@ -1808,10 +1867,11 @@ let expected = """
18081867
let renderNode = translator.visit(moduleSymbol) as! RenderNode
18091868

18101869
// Verify that the resolved links rendered as links
1811-
XCTAssertEqual(renderNode.topicSections.first?.identifiers.count, 2)
1870+
XCTAssertEqual(renderNode.topicSections.first?.identifiers.count, 3)
18121871
XCTAssertEqual(renderNode.topicSections.first?.identifiers, [
18131872
"doc://special-characters/documentation/MyKit/MyClass/myFunc_()",
18141873
"doc://special-characters/documentation/special-characters/article-with---in-filename",
1874+
"doc://special-characters/documentation/special-characters/Article:-with---various!-whitespace-&-punctuation.-in,-filename",
18151875
])
18161876

18171877

@@ -1826,7 +1886,7 @@ let expected = """
18261886

18271887
XCTAssertEqual(lists.count, 1)
18281888
let list = try XCTUnwrap(lists.first)
1829-
XCTAssertEqual(list.items.count, 4, "Unexpected list items: \(list.items.map(\.content))")
1889+
XCTAssertEqual(list.items.count, 6, "Unexpected list items: \(list.items.map(\.content))")
18301890

18311891
func withContentAsReference(_ listItem: RenderBlockContent.ListItem?, verify: (RenderReferenceIdentifier, Bool, String?, [RenderInlineContent]?) -> Void) {
18321892
guard let listItem = listItem else {
@@ -1866,7 +1926,19 @@ let expected = """
18661926
XCTAssertEqual(overridingTitle, nil)
18671927
XCTAssertEqual(overridingTitleInlineContent, nil)
18681928
}
1869-
1929+
withContentAsReference(list.items.dropFirst(4).first) { identifier, isActive, overridingTitle, overridingTitleInlineContent in
1930+
XCTAssertEqual(identifier.identifier, "doc://special-characters/documentation/special-characters/Article:-with---various!-whitespace-&-punctuation.-in,-filename")
1931+
XCTAssertEqual(isActive, true)
1932+
XCTAssertEqual(overridingTitle, nil)
1933+
XCTAssertEqual(overridingTitleInlineContent, nil)
1934+
}
1935+
withContentAsReference(list.items.dropFirst(5).first) { identifier, isActive, overridingTitle, overridingTitleInlineContent in
1936+
XCTAssertEqual(identifier.identifier, "doc://special-characters/documentation/special-characters/Article:-with---various!-whitespace-&-punctuation.-in,-filename#Hello-world")
1937+
XCTAssertEqual(isActive, true)
1938+
XCTAssertEqual(overridingTitle, nil)
1939+
XCTAssertEqual(overridingTitleInlineContent, nil)
1940+
}
1941+
18701942
// Verify that the topic render references have titles with special characters when the original content contained special characters
18711943
XCTAssertEqual(
18721944
(renderNode.references["doc://special-characters/documentation/MyKit/MyClass/myFunc_()"] as? TopicRenderReference)?.title,
@@ -1878,12 +1950,20 @@ let expected = """
18781950
)
18791951
XCTAssertEqual(
18801952
(renderNode.references["doc://special-characters/documentation/special-characters/article-with---in-filename"] as? TopicRenderReference)?.title,
1881-
"Article with 😃 emoji in file name"
1953+
"Article with 😃 emoji in its filename"
18821954
)
18831955
XCTAssertEqual(
18841956
(renderNode.references["doc://special-characters/documentation/special-characters/article-with---in-filename#Hello-world"] as? TopicRenderReference)?.title,
18851957
"Hello world"
18861958
)
1959+
XCTAssertEqual(
1960+
(renderNode.references["doc://special-characters/documentation/special-characters/Article:-with---various!-whitespace-&-punctuation.-in,-filename"] as? TopicRenderReference)?.title,
1961+
"Article with various whitespace and punctuation in its filename"
1962+
)
1963+
XCTAssertEqual(
1964+
(renderNode.references["doc://special-characters/documentation/special-characters/Article:-with---various!-whitespace-&-punctuation.-in,-filename#Hello-world"] as? TopicRenderReference)?.title,
1965+
"Hello world"
1966+
)
18871967
}
18881968

18891969
func testNonOverloadCollisionFromExtension() throws {

0 commit comments

Comments
 (0)