Skip to content

Commit 134fe56

Browse files
committed
Add a helper function for rendering page breadcrumbs as HTML
rdar://163326857
1 parent 56ba4eb commit 134fe56

File tree

3 files changed

+130
-0
lines changed

3 files changed

+130
-0
lines changed

Sources/DocCHTML/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ See https://swift.org/LICENSE.txt for license information
1010
add_library(DocCHTML STATIC
1111
LinkProvider.swift
1212
MarkdownRenderer+Availability.swift
13+
MarkdownRenderer+Breadcrumbs.swift
1314
MarkdownRenderer.swift
1415
WordBreak.swift
1516
XMLNode+element.swift)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2025 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
#if canImport(FoundationXML)
12+
// FIXME: See if we can avoid depending on XMLNode/XMLParser to avoid needing to import FoundationXML
13+
package import FoundationXML
14+
package import FoundationEssentials
15+
#else
16+
package import Foundation
17+
#endif
18+
19+
package extension MarkdownRenderer {
20+
/// Creates an HTML element for the breadcrumbs leading up to the renderer's current page.
21+
func breadcrumbs(references: [URL], currentPageNames: LinkedElement.Names) -> XMLNode {
22+
// Breadcrumbs handle symbols differently than most elements in that everything uses a default style (no "code voice")
23+
func nameElements(for names: LinkedElement.Names) -> [XMLNode] {
24+
switch names {
25+
case .single(.conceptual(let name)), .single(.symbol(let name)):
26+
return [.text(name)]
27+
28+
case .languageSpecificSymbol(let namesByLanguageID):
29+
let names = RenderHelpers.sortedLanguageSpecificValues(namesByLanguageID)
30+
return switch goal {
31+
case .richness:
32+
if names.count == 1 {
33+
[.text(names.first!.value)]
34+
} else {
35+
names.map { language, name in
36+
// Wrap the name in a span so that it can be given a language specific "class" attribute.
37+
.element(named: "span", children: [.text(name)], attributes: ["class": "\(language.id)-only"])
38+
}
39+
}
40+
case .conciseness:
41+
// If the goal is conciseness, only display the primary language's name
42+
names.first.map { _, name in [.text(name)] } ?? []
43+
}
44+
}
45+
}
46+
47+
// Create links for each of the breadcrumbs
48+
var items: [XMLNode] = references.compactMap {
49+
linkProvider.element(for: $0).map { page in
50+
.element(named: "li", children: [
51+
.element(named: "a", children: nameElements(for: page.names), attributes: ["href": self.path(to: page.path)])
52+
])
53+
}
54+
}
55+
56+
// Add the name of the current page. It doesn't display as a link because it would refer to the current page.
57+
items.append(
58+
.element(named: "li", children: nameElements(for: currentPageNames))
59+
)
60+
let list = XMLNode.element(named: "ul", children: items)
61+
62+
return switch goal {
63+
case .conciseness: list // If the goal is conciseness, don't wrap the list in a `<nav>` HTML element with an "id".
64+
case .richness: .element(named: "nav", children: [list], attributes: ["id": "breadcrumbs"])
65+
}
66+
}
67+
}

Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,68 @@ import DocCHTML
2121
import Markdown
2222

2323
struct MarkdownRenderer_PageElementsTests {
24+
@Test(arguments: RenderGoal.allCases)
25+
func testRenderBreadcrumbs(goal: RenderGoal) {
26+
let elements = [
27+
LinkedElement(
28+
path: URL(string: "/documentation/ModuleName/index.html")!,
29+
names: .single(.symbol("ModuleName")),
30+
subheadings: .single(.symbol([.init(text: "ModuleName", kind: .identifier)])),
31+
abstract: nil
32+
),
33+
LinkedElement(
34+
path: URL(string: "/documentation/ModuleName/Something/index.html")!,
35+
names: .languageSpecificSymbol([
36+
.swift: "Something",
37+
.objectiveC: "TLASomething",
38+
]),
39+
subheadings: .languageSpecificSymbol([
40+
.swift: [
41+
.init(text: "class ", kind: .decorator),
42+
.init(text: "Something", kind: .identifier),
43+
],
44+
.objectiveC: [
45+
.init(text: "class ", kind: .decorator),
46+
.init(text: "TLASomething", kind: .identifier),
47+
],
48+
]),
49+
abstract: nil
50+
),
51+
]
52+
let breadcrumbs = makeRenderer(goal: goal, elementsToReturn: elements).breadcrumbs(references: elements.map { $0.path }, currentPageNames: .single(.conceptual("ThisPage")))
53+
switch goal {
54+
case .richness:
55+
breadcrumbs.assertMatches(prettyFormatted: true, expectedXMLString: """
56+
<nav id="breadcrumbs">
57+
<ul>
58+
<li>
59+
<a href="../../index.html">ModuleName</a>
60+
</li>
61+
<li>
62+
<a href="../index.html">
63+
<span class="swift-only">Something</span>
64+
<span class="occ-only">TLASomething</span>
65+
</a>
66+
</li>
67+
<li>ThisPage</li>
68+
</ul>
69+
</nav>
70+
""")
71+
case .conciseness:
72+
breadcrumbs.assertMatches(prettyFormatted: true, expectedXMLString: """
73+
<ul>
74+
<li>
75+
<a href="../../index.html">ModuleName</a>
76+
</li>
77+
<li>
78+
<a href="../index.html">Something</a>
79+
</li>
80+
<li>ThisPage</li>
81+
</ul>
82+
""")
83+
}
84+
}
85+
2486
@Test(arguments: RenderGoal.allCases)
2587
func testRenderAvailability(goal: RenderGoal) {
2688
let availability = makeRenderer(goal: goal).availability([

0 commit comments

Comments
 (0)