Skip to content

Commit 8e6aaa3

Browse files
authored
Support linking to authored task groups / topic sections (#842)
* Support linking to topic sections rdar://78908451 * Fix typo in test data * Document how to link to headings and task groups * Add comment about preserving order of anchor sections
1 parent 2336534 commit 8e6aaa3

File tree

14 files changed

+331
-31
lines changed

14 files changed

+331
-31
lines changed

Sources/SwiftDocC/Model/DocumentationNode.swift

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,21 +107,42 @@ public struct DocumentationNode {
107107
} else if let discussionVariants = (semantic as? Symbol)?.discussionVariants {
108108
discussionSections = discussionVariants.allValues.map(\.variant)
109109
} else {
110-
return
110+
discussionSections = []
111+
}
112+
113+
anchorSections.removeAll()
114+
var seenAnchorTitles = Set<String>()
115+
116+
func addAnchorSection(title: String) {
117+
// To preserve the order of headings and task groups in the content, we use *both* a `Set` and
118+
// an `Array` to ensure unique titles and to accumulate the linkable anchor section elements.
119+
guard !title.isEmpty, !seenAnchorTitles.contains(title) else { return }
120+
seenAnchorTitles.insert(title)
121+
anchorSections.append(
122+
AnchorSection(reference: reference.withFragment(title), title: title)
123+
)
111124
}
112125

113126
for discussion in discussionSections {
114127
for child in discussion.content {
115-
// For any non-H1 Heading sections found in the topic's discussion
116-
// create an `AnchorSection` and add it to `anchorSections`
117-
// so we can index all anchors found in the bundle for link resolution.
118128
if let heading = child as? Heading, heading.level > 1 {
119-
anchorSections.append(
120-
AnchorSection(reference: reference.withFragment(heading.plainText), title: heading.plainText)
121-
)
129+
addAnchorSection(title: heading.plainText)
122130
}
123131
}
124132
}
133+
134+
let taskGroups: [TaskGroup]?
135+
if let article = semantic as? Article {
136+
taskGroups = article.topics?.taskGroups
137+
} else if let symbol = semantic as? Symbol {
138+
taskGroups = symbol.topics?.taskGroups
139+
} else {
140+
taskGroups = nil
141+
}
142+
143+
for taskGroup in taskGroups ?? [] {
144+
addAnchorSection(title: taskGroup.heading?.plainText ?? "Topics")
145+
}
125146
}
126147

127148
/// Initializes a documentation node with all its initial values.

Sources/docc/DocCDocumentation.docc/linking-to-symbols-and-other-content.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,13 @@ a colon (`:`), the name of the article, and a greater-than symbol
173173
<doc:GettingStarted>
174174
```
175175

176+
If the article's file name contains whitespace characters, replace each consecutive sequence of whitespace characters with a dash.
177+
For example, the link to an article with a file name "Getting Started.md" is
178+
179+
```
180+
<doc:Getting-Started>
181+
```
182+
176183
When DocC resolves the link, it uses the article's page title as the link's
177184
text, and the article's filename as the link's URL. Links to tutorials follow
178185
the same format, except you must add the `/tutorials/` prefix to the path:
@@ -186,6 +193,41 @@ symbol's path between the colon (`:`) and the terminating greater-than
186193
symbol (`>`).
187194
`<doc:Sloth/init(name:color:power:)>`
188195

196+
### Navigate to a Heading or Task Group
197+
198+
To add a link to heading or task group on another page, use a `<doc:>` link to the page and end the link with a hash (`#`) followed by the name of the heading.
199+
If the heading text contains whitespace or punctuation characters, replace each consecutive sequence of whitespace characters with a dash and optionally remove the punctuation characters.
200+
201+
For example, consider this level 3 heading with a handful of punctuation characters:
202+
203+
```
204+
### (1) "Example": Sloth's diet.
205+
```
206+
207+
A link to this heading can either include all the punctuation characters from the heading text or remove some or all of the punctuation characters.
208+
209+
```
210+
<doc:OtherPage#(1)-"Example":-Sloth's-diet.>
211+
<doc:OtherPage#1-Example-Sloths-diet>
212+
```
213+
214+
> Note:
215+
> Links to headings or task groups on symbol pages use `<doc:>` syntax.
216+
217+
To add a link to heading or task group on the current page, use a `<doc:>` link that starts with the name of the heading. If you prefer you can include the hash (`#`) prefix before the heading name. For example, both these links resolve to a heading named "Some heading title" on the current page:
218+
219+
```
220+
<doc:#Some-heading-title>
221+
<doc:Some-heading-title>
222+
```
223+
224+
If a task group is empty or none of its links resolve successfully, it's not possible to link to that task group because it will be omitted from the rendered page. Linking to generated per-symbol-kind task groups is not supported.
225+
226+
> Earlier Versions:
227+
> Before Swift-DocC 6.0, links to task groups isn't supported. The syntax above only works for links to general headings.
228+
>
229+
> Before Swift-DocC 5.9, links to same-page headings don't support a leading hash (`#`) character.
230+
189231
### Include web links
190232

191233
To include a regular web link, add a set of brackets (`[]`) and

Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift

Lines changed: 136 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import XCTest
1212
import SymbolKit
1313
@testable import SwiftDocC
1414
import Markdown
15-
import SwiftDocCTestUtilities
15+
@_spi(FileManagerProtocol) import SwiftDocCTestUtilities
1616

1717
func diffDescription(lhs: String, rhs: String) -> String {
1818
let leftLines = lhs.components(separatedBy: .newlines)
@@ -905,7 +905,7 @@ class DocumentationContextTests: XCTestCase {
905905
XCTAssertEqual(myProtocolSymbol.topics?.taskGroups.first?.heading?.detachedFromParent.debugDescription(),
906906
"""
907907
Heading level: 3
908-
└─ Text "Task Group Excercising Symbol Links"
908+
└─ Text "Task Group Exercising Symbol Links"
909909
""")
910910
XCTAssertEqual(myProtocolSymbol.topics?.taskGroups.first?.links.count, 3)
911911
XCTAssertEqual(myProtocolSymbol.topics?.taskGroups.first?.links[0].destination, "doc://com.example.documentation/documentation/MyKit/MyClass")
@@ -2986,7 +2986,7 @@ let expected = """
29862986
XCTAssertEqual(taskGroup.links.count, 16)
29872987

29882988
XCTAssertEqual(node.anchorSections.first?.title, "Overview")
2989-
for (index, anchor) in node.anchorSections.dropFirst().enumerated() {
2989+
for (index, anchor) in node.anchorSections.dropFirst().dropLast().enumerated() {
29902990
XCTAssertEqual(taskGroup.links.dropFirst(index * 2 + 0).first?.destination, anchor.reference.absoluteString)
29912991
XCTAssertEqual(taskGroup.links.dropFirst(index * 2 + 1).first?.destination, anchor.reference.absoluteString)
29922992
}
@@ -2995,11 +2995,141 @@ let expected = """
29952995
XCTAssertEqual(node.anchorSections.dropFirst(2).first?.reference.absoluteString, "doc://com.test.docc/documentation/article#Apostrophe-firsts-second")
29962996
XCTAssertEqual(node.anchorSections.dropFirst(3).first?.reference.absoluteString, "doc://com.test.docc/documentation/article#Prime-firsts-second")
29972997

2998-
XCTAssertEqual(node.anchorSections.dropLast(2).last?.reference.absoluteString, "doc://com.test.docc/documentation/article#Em-dash-first-second")
2999-
XCTAssertEqual(node.anchorSections.dropLast().last?.reference.absoluteString, "doc://com.test.docc/documentation/article#Triple-hyphen-first-second")
3000-
XCTAssertEqual(node.anchorSections.last?.reference.absoluteString, "doc://com.test.docc/documentation/article#Emoji-%F0%9F%92%BB")
2998+
XCTAssertEqual(node.anchorSections.dropLast(3).last?.reference.absoluteString, "doc://com.test.docc/documentation/article#Em-dash-first-second")
2999+
XCTAssertEqual(node.anchorSections.dropLast(2).last?.reference.absoluteString, "doc://com.test.docc/documentation/article#Triple-hyphen-first-second")
3000+
XCTAssertEqual(node.anchorSections.dropLast().last?.reference.absoluteString, "doc://com.test.docc/documentation/article#Emoji-%F0%9F%92%BB")
30013001
}
30023002

3003+
func testResolvingLinksToTopicSections() throws {
3004+
let fileSystem = try TestFileSystem(folders: [
3005+
Folder(name: "unit-test.docc", content: [
3006+
JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(moduleName: "ModuleName")),
3007+
3008+
TextFile(name: "ModuleName.md", utf8Content: """
3009+
# ``ModuleName``
3010+
3011+
A symbol with two topic section
3012+
3013+
## Topics
3014+
3015+
### One
3016+
3017+
- <doc:First>
3018+
3019+
### Two
3020+
3021+
- <doc:Second>
3022+
"""),
3023+
3024+
TextFile(name: "First.md", utf8Content: """
3025+
# The first article
3026+
3027+
An article with a top-level topic section
3028+
3029+
## Topics
3030+
3031+
- <doc:Third>
3032+
"""),
3033+
3034+
TextFile(name: "Second.md", utf8Content: """
3035+
# The second article
3036+
3037+
An article with a named topic section
3038+
3039+
## Topics
3040+
3041+
### Some topic section
3042+
3043+
- <doc:Third>
3044+
"""),
3045+
3046+
TextFile(name: "Third.md", utf8Content: """
3047+
# The third article
3048+
3049+
An article that links to the various topic sections
3050+
3051+
- <doc:ModuleName#One>
3052+
- <doc:ModuleName#Two>
3053+
- <doc:First#Topics>
3054+
- <doc:Second#Some-topic-section>
3055+
- <doc:Third#Another-topic-section>
3056+
- <doc:#Another-topic-section>
3057+
3058+
## Topics
3059+
3060+
### Another topic section
3061+
3062+
- <doc:Fourth>
3063+
"""),
3064+
3065+
TextFile(name: "Fourth.md", utf8Content: """
3066+
# The fourth article
3067+
3068+
An article that only exists to be linked to
3069+
"""),
3070+
])
3071+
])
3072+
3073+
let workspace = DocumentationWorkspace()
3074+
let context = try DocumentationContext(dataProvider: workspace)
3075+
try workspace.registerProvider(fileSystem)
3076+
3077+
XCTAssert(context.problems.isEmpty, "Unexpected problems: \(context.problems.map(\.diagnostic.summary).sorted())")
3078+
3079+
let reference = try XCTUnwrap(context.knownPages.first(where: { $0.lastPathComponent == "Third" }))
3080+
let entity = try context.entity(with: reference)
3081+
3082+
struct LinkAggregator: MarkupWalker {
3083+
var destinations: [String] = []
3084+
3085+
mutating func visitLink(_ link: Link) -> () {
3086+
if let destination = link.destination {
3087+
destinations.append(destination)
3088+
}
3089+
}
3090+
mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> () {
3091+
if let destination = symbolLink.destination {
3092+
destinations.append(destination)
3093+
}
3094+
}
3095+
}
3096+
3097+
// Verify that the links are resolved in the in-memory model
3098+
3099+
var linkAggregator = LinkAggregator()
3100+
let list = try XCTUnwrap((entity.semantic as? Article)?.discussion?.content.first as? UnorderedList)
3101+
linkAggregator.visit(list)
3102+
3103+
XCTAssertEqual(linkAggregator.destinations, [
3104+
"doc://unit-test/documentation/ModuleName#One",
3105+
"doc://unit-test/documentation/ModuleName#Two",
3106+
"doc://unit-test/documentation/unit-test/First#Topics",
3107+
"doc://unit-test/documentation/unit-test/Second#Some-topic-section",
3108+
"doc://unit-test/documentation/unit-test/Third#Another-topic-section",
3109+
"doc://unit-test/documentation/unit-test/Third#Another-topic-section",
3110+
])
3111+
3112+
// Verify that the links are resolved in the render model.
3113+
let bundle = try XCTUnwrap(context.registeredBundles.first)
3114+
let converter = DocumentationNodeConverter(bundle: bundle, context: context)
3115+
let renderNode = try converter.convert(entity, at: nil)
3116+
3117+
let overviewSection = try XCTUnwrap(renderNode.primaryContentSections.first as? ContentRenderSection)
3118+
guard case .unorderedList(let unorderedList) = overviewSection.content.dropFirst().first else {
3119+
XCTFail("The first element of the Overview section (after the heading) should be an unordered list")
3120+
return
3121+
}
3122+
3123+
XCTAssertEqual(unorderedList.items.map(\.content.firstParagraph.first), [
3124+
.reference(identifier: RenderReferenceIdentifier("doc://unit-test/documentation/ModuleName#One"), isActive: true, overridingTitle: nil, overridingTitleInlineContent: nil),
3125+
.reference(identifier: RenderReferenceIdentifier("doc://unit-test/documentation/ModuleName#Two"), isActive: true, overridingTitle: nil, overridingTitleInlineContent: nil),
3126+
.reference(identifier: RenderReferenceIdentifier("doc://unit-test/documentation/unit-test/First#Topics"), isActive: true, overridingTitle: nil, overridingTitleInlineContent: nil),
3127+
.reference(identifier: RenderReferenceIdentifier("doc://unit-test/documentation/unit-test/Second#Some-topic-section"), isActive: true, overridingTitle: nil, overridingTitleInlineContent: nil),
3128+
.reference(identifier: RenderReferenceIdentifier("doc://unit-test/documentation/unit-test/Third#Another-topic-section"), isActive: true, overridingTitle: nil, overridingTitleInlineContent: nil),
3129+
.reference(identifier: RenderReferenceIdentifier("doc://unit-test/documentation/unit-test/Third#Another-topic-section"), isActive: true, overridingTitle: nil, overridingTitleInlineContent: nil),
3130+
])
3131+
}
3132+
30033133
func testWarnOnMultipleMarkdownExtensions() throws {
30043134
let fileContent = """
30053135
# ``MyKit/MyClass/myFunction()``

Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,6 +1111,14 @@ class PathHierarchyTests: XCTestCase {
11111111
let articleID = try tree.find(path: "/Test-Bundle/Default-Code-Listing-Syntax", onlyFindSymbols: false)
11121112
XCTAssertNil(tree.lookup[articleID]!.symbol)
11131113
XCTAssertEqual(tree.lookup[articleID]!.name, "Default-Code-Listing-Syntax")
1114+
1115+
let modulePageTaskGroupID = try tree.find(path: "/MyKit#Extensions-to-other-frameworks", onlyFindSymbols: false)
1116+
XCTAssertNil(tree.lookup[modulePageTaskGroupID]!.symbol)
1117+
XCTAssertEqual(tree.lookup[modulePageTaskGroupID]!.name, "Extensions-to-other-frameworks")
1118+
1119+
let symbolPageTaskGroupID = try tree.find(path: "/MyKit/MyProtocol#Task-Group-Exercising-Symbol-Links", onlyFindSymbols: false)
1120+
XCTAssertNil(tree.lookup[symbolPageTaskGroupID]!.symbol)
1121+
XCTAssertEqual(tree.lookup[symbolPageTaskGroupID]!.name, "Task-Group-Exercising-Symbol-Links")
11141122
}
11151123

11161124
func testMixedLanguageFramework() throws {
@@ -1755,6 +1763,105 @@ class PathHierarchyTests: XCTestCase {
17551763
XCTAssertEqual(paths[memberID], "/ModuleName/ContainerName-qwwf/MemberName1")
17561764
}
17571765

1766+
func testLinkToTopicSection() throws {
1767+
let exampleDocumentation = Folder(name: "unit-test.docc", content: [
1768+
JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(
1769+
moduleName: "ModuleName",
1770+
symbols: [
1771+
("some-symbol-id", .swift, ["SymbolName"]),
1772+
],
1773+
relationships: []
1774+
)),
1775+
1776+
TextFile(name: "ModuleName.md", utf8Content: """
1777+
# ``ModuleName``
1778+
1779+
A module with some named topic sections
1780+
1781+
## Other level 2 heading
1782+
1783+
Some content
1784+
1785+
### Other level 3 heading
1786+
1787+
Some more content
1788+
1789+
## Topics
1790+
1791+
### My classes
1792+
1793+
- ``SymbolName``
1794+
1795+
### My articles
1796+
1797+
- <doc:Article>
1798+
"""),
1799+
1800+
TextFile(name: "Article.md", utf8Content: """
1801+
# Some Article
1802+
1803+
An article with a top-level topic section
1804+
1805+
## Topics
1806+
1807+
- ``SymbolName``
1808+
""")
1809+
])
1810+
1811+
let tempURL = try createTempFolder(content: [exampleDocumentation])
1812+
let (_, _, context) = try loadBundle(from: tempURL)
1813+
let tree = context.linkResolver.localResolver.pathHierarchy
1814+
1815+
print(tree.dump())
1816+
1817+
let moduleID = try tree.find(path: "/ModuleName", onlyFindSymbols: true)
1818+
// Relative link from the module to a topic section
1819+
do {
1820+
let topicSectionID = try tree.find(path: "#My-classes", parent: moduleID, onlyFindSymbols: false)
1821+
let node = try XCTUnwrap(tree.lookup[topicSectionID])
1822+
XCTAssertNil(node.symbol)
1823+
XCTAssertEqual(node.name, "My-classes")
1824+
}
1825+
1826+
// Absolute link to a topic section on the module page
1827+
do {
1828+
let topicSectionID = try tree.find(path: "/ModuleName#My-classes", parent: nil, onlyFindSymbols: false)
1829+
let node = try XCTUnwrap(tree.lookup[topicSectionID])
1830+
XCTAssertNil(node.symbol)
1831+
XCTAssertEqual(node.name, "My-classes")
1832+
}
1833+
1834+
// Absolute link to a heading on the module page
1835+
do {
1836+
let headingID = try tree.find(path: "/ModuleName#Other-level-2-heading", parent: nil, onlyFindSymbols: false)
1837+
let node = try XCTUnwrap(tree.lookup[headingID])
1838+
XCTAssertNil(node.symbol)
1839+
XCTAssertEqual(node.name, "Other-level-2-heading")
1840+
}
1841+
1842+
// Relative link to a heading on the module page
1843+
do {
1844+
let headingID = try tree.find(path: "#Other-level-3-heading", parent: moduleID, onlyFindSymbols: false)
1845+
let node = try XCTUnwrap(tree.lookup[headingID])
1846+
XCTAssertNil(node.symbol)
1847+
XCTAssertEqual(node.name, "Other-level-3-heading")
1848+
}
1849+
1850+
// Relative link to a top-level topic section on another page
1851+
do {
1852+
let topicSectionID = try tree.find(path: "Article#Topics", parent: moduleID, onlyFindSymbols: false)
1853+
let node = try XCTUnwrap(tree.lookup[topicSectionID])
1854+
XCTAssertNil(node.symbol)
1855+
XCTAssertEqual(node.name, "Topics")
1856+
}
1857+
1858+
let paths = tree.caseInsensitiveDisambiguatedPaths(includeDisambiguationForUnambiguousChildren: true)
1859+
XCTAssertEqual(paths.values.sorted(), [
1860+
"/ModuleName",
1861+
"/ModuleName/SymbolName",
1862+
], "The hierarchy only computes paths for symbols, not for headings or topic sections")
1863+
}
1864+
17581865
func testModuleAndCollidingTechnologyRootHasPathsForItsSymbols() throws {
17591866
let symbolID = "some-symbol-id"
17601867

0 commit comments

Comments
 (0)