Skip to content

Commit c47f599

Browse files
authored
[5.10] Replace spaces with dashes in links to articles (#796)
1 parent 7bfb34c commit c47f599

File tree

3 files changed

+121
-14
lines changed

3 files changed

+121
-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

@@ -386,3 +385,19 @@ private final class FallbackResolverBasedLinkResolver {
386385
return nil
387386
}
388387
}
388+
389+
/// Creates a more writable version of an articles file name for use in documentation links.
390+
///
391+
/// Compared to `urlReadablePath(_:)` this preserves letters in other written languages.
392+
private func linkName<S: StringProtocol>(filename: S) -> String {
393+
// It would be a nice enhancement to also remove punctuation from the filename to allow an article in a file named "One, two, & three!"
394+
// to be referenced with a link as `"One-two-three"` instead of `"One,-two-&-three!"` (rdar://120722917)
395+
return filename
396+
// Replace continuous whitespace and dashes
397+
.components(separatedBy: whitespaceAndDashes)
398+
.filter({ !$0.isEmpty })
399+
.joined(separator: "-")
400+
}
401+
402+
private let whitespaceAndDashes = CharacterSet.whitespaces
403+
.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
@@ -628,6 +628,7 @@ func urlReadablePath<S: StringProtocol>(_ path: S) -> String {
628628
}
629629

630630
private extension CharacterSet {
631+
// For fragments
631632
static let fragmentCharactersToRemove = CharacterSet.punctuationCharacters // Remove punctuation from fragments
632633
.union(CharacterSet(charactersIn: "`")) // Also consider back-ticks as punctuation. They are used as quotes around symbols or other code.
633634
.subtracting(CharacterSet(charactersIn: "-")) // Don't remove hyphens. They are used as a whitespace replacement.
@@ -654,3 +655,4 @@ func urlReadableFragment<S: StringProtocol>(_ fragment: S) -> String {
654655

655656
return fragment
656657
}
658+

Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1736,6 +1736,63 @@ 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: "Root.md", utf8Content: """
1743+
# Root
1744+
1745+
@Metadata {
1746+
@TechnologyRoot
1747+
}
1748+
1749+
This test needs an explicit root on 'release/5.10' but not on 'main'.
1750+
"""),
1751+
1752+
TextFile(name: "Hello-world.md", utf8Content: """
1753+
# Dash
1754+
1755+
No whitespace in the file name
1756+
"""),
1757+
1758+
TextFile(name: "Hello world.md", utf8Content: """
1759+
# Only space
1760+
1761+
This has the same reference as "Hello-world.md" and will raise a warning.
1762+
"""),
1763+
1764+
TextFile(name: "Hello world.md", utf8Content: """
1765+
# Multiple spaces
1766+
1767+
Each space is replaced with a dash in the reference, so this has a unique reference.
1768+
"""),
1769+
1770+
TextFile(name: "Hello, world!.md", utf8Content: """
1771+
# Space and punctuation
1772+
1773+
The punctuation is not removed from the reference, so this has a unique reference.
1774+
"""),
1775+
1776+
TextFile(name: "Hello. world?.md", utf8Content: """
1777+
# Space and different punctuation
1778+
1779+
The punctuation is not removed from the reference, so this has a unique reference.
1780+
"""),
1781+
])
1782+
])
1783+
let (_, _, context) = try loadBundle(from: tempURL)
1784+
1785+
XCTAssertEqual(context.problems.map(\.diagnostic.summary), ["Redeclaration of 'Hello world.md'; this file will be skipped"])
1786+
1787+
XCTAssertEqual(context.knownPages.map(\.absoluteString).sorted(), [
1788+
"doc://unit-test/documentation/Root", // since this catalog has an explicit technology root on the 'release/5.10' branch
1789+
"doc://unit-test/documentation/unit-test/Hello,-world!",
1790+
"doc://unit-test/documentation/unit-test/Hello--world",
1791+
"doc://unit-test/documentation/unit-test/Hello-world",
1792+
"doc://unit-test/documentation/unit-test/Hello.-world-",
1793+
])
1794+
}
1795+
17391796
func testSpecialCharactersInLinks() throws {
17401797
let originalSymbolGraph = Bundle.module.url(forResource: "TestBundle", withExtension: "docc", subdirectory: "Test Bundles")!.appendingPathComponent("mykit-iOS.symbols.json")
17411798

@@ -1751,7 +1808,15 @@ let expected = """
17511808
"""),
17521809

17531810
TextFile(name: "article-with-😃-in-filename.md", utf8Content: """
1754-
# Article with 😃 emoji in file name
1811+
# Article with 😃 emoji in its filename
1812+
1813+
Abstract
1814+
1815+
### Hello world
1816+
"""),
1817+
1818+
TextFile(name: "Article: with - various! whitespace & punctuation. in, filename.md", utf8Content: """
1819+
# Article with various whitespace and punctuation in its filename
17551820
17561821
Abstract
17571822
@@ -1767,6 +1832,8 @@ let expected = """
17671832
- <doc:article-with-emoji-in-heading#Hello-🌍>
17681833
- <doc:article-with-😃-in-filename>
17691834
- <doc:article-with-😃-in-filename#Hello-world>
1835+
- <doc:Article:-with-various!-whitespace-&-punctuation.-in,-filename>
1836+
- <doc:Article:-with-various!-whitespace-&-punctuation.-in,-filename#Hello-world>
17701837
17711838
Now test the same links in topic curation.
17721839
@@ -1776,6 +1843,7 @@ let expected = """
17761843
17771844
- ``MyClass/myFunc🙂()``
17781845
- <doc:article-with-😃-in-filename>
1846+
- <doc:Article:-with-various!-whitespace-&-punctuation.-in,-filename>
17791847
"""),
17801848
])
17811849
let bundleURL = try testBundle.write(inside: createTemporaryDirectory())
@@ -1789,11 +1857,12 @@ let expected = """
17891857

17901858
let moduleSymbol = try XCTUnwrap(entity.semantic as? Symbol)
17911859
let topicSection = try XCTUnwrap(moduleSymbol.topics?.taskGroups.first)
1792-
1860+
17931861
// Verify that all the links in the topic section resolved
17941862
XCTAssertEqual(topicSection.links.map(\.destination), [
17951863
"doc://special-characters/documentation/MyKit/MyClass/myFunc_()",
17961864
"doc://special-characters/documentation/special-characters/article-with---in-filename",
1865+
"doc://special-characters/documentation/special-characters/Article:-with---various!-whitespace-&-punctuation.-in,-filename",
17971866
])
17981867

17991868
// Verify that all resolved link exist in the context.
@@ -1808,10 +1877,11 @@ let expected = """
18081877
let renderNode = translator.visit(moduleSymbol) as! RenderNode
18091878

18101879
// Verify that the resolved links rendered as links
1811-
XCTAssertEqual(renderNode.topicSections.first?.identifiers.count, 2)
1880+
XCTAssertEqual(renderNode.topicSections.first?.identifiers.count, 3)
18121881
XCTAssertEqual(renderNode.topicSections.first?.identifiers, [
18131882
"doc://special-characters/documentation/MyKit/MyClass/myFunc_()",
18141883
"doc://special-characters/documentation/special-characters/article-with---in-filename",
1884+
"doc://special-characters/documentation/special-characters/Article:-with---various!-whitespace-&-punctuation.-in,-filename",
18151885
])
18161886

18171887

@@ -1826,7 +1896,7 @@ let expected = """
18261896

18271897
XCTAssertEqual(lists.count, 1)
18281898
let list = try XCTUnwrap(lists.first)
1829-
XCTAssertEqual(list.items.count, 4, "Unexpected list items: \(list.items.map(\.content))")
1899+
XCTAssertEqual(list.items.count, 6, "Unexpected list items: \(list.items.map(\.content))")
18301900

18311901
func withContentAsReference(_ listItem: RenderBlockContent.ListItem?, verify: (RenderReferenceIdentifier, Bool, String?, [RenderInlineContent]?) -> Void) {
18321902
guard let listItem = listItem else {
@@ -1866,7 +1936,19 @@ let expected = """
18661936
XCTAssertEqual(overridingTitle, nil)
18671937
XCTAssertEqual(overridingTitleInlineContent, nil)
18681938
}
1869-
1939+
withContentAsReference(list.items.dropFirst(4).first) { identifier, isActive, overridingTitle, overridingTitleInlineContent in
1940+
XCTAssertEqual(identifier.identifier, "doc://special-characters/documentation/special-characters/Article:-with---various!-whitespace-&-punctuation.-in,-filename")
1941+
XCTAssertEqual(isActive, true)
1942+
XCTAssertEqual(overridingTitle, nil)
1943+
XCTAssertEqual(overridingTitleInlineContent, nil)
1944+
}
1945+
withContentAsReference(list.items.dropFirst(5).first) { identifier, isActive, overridingTitle, overridingTitleInlineContent in
1946+
XCTAssertEqual(identifier.identifier, "doc://special-characters/documentation/special-characters/Article:-with---various!-whitespace-&-punctuation.-in,-filename#Hello-world")
1947+
XCTAssertEqual(isActive, true)
1948+
XCTAssertEqual(overridingTitle, nil)
1949+
XCTAssertEqual(overridingTitleInlineContent, nil)
1950+
}
1951+
18701952
// Verify that the topic render references have titles with special characters when the original content contained special characters
18711953
XCTAssertEqual(
18721954
(renderNode.references["doc://special-characters/documentation/MyKit/MyClass/myFunc_()"] as? TopicRenderReference)?.title,
@@ -1878,12 +1960,20 @@ let expected = """
18781960
)
18791961
XCTAssertEqual(
18801962
(renderNode.references["doc://special-characters/documentation/special-characters/article-with---in-filename"] as? TopicRenderReference)?.title,
1881-
"Article with 😃 emoji in file name"
1963+
"Article with 😃 emoji in its filename"
18821964
)
18831965
XCTAssertEqual(
18841966
(renderNode.references["doc://special-characters/documentation/special-characters/article-with---in-filename#Hello-world"] as? TopicRenderReference)?.title,
18851967
"Hello world"
18861968
)
1969+
XCTAssertEqual(
1970+
(renderNode.references["doc://special-characters/documentation/special-characters/Article:-with---various!-whitespace-&-punctuation.-in,-filename"] as? TopicRenderReference)?.title,
1971+
"Article with various whitespace and punctuation in its filename"
1972+
)
1973+
XCTAssertEqual(
1974+
(renderNode.references["doc://special-characters/documentation/special-characters/Article:-with---various!-whitespace-&-punctuation.-in,-filename#Hello-world"] as? TopicRenderReference)?.title,
1975+
"Hello world"
1976+
)
18871977
}
18881978

18891979
func testNonOverloadCollisionFromExtension() throws {

0 commit comments

Comments
 (0)