Skip to content

Commit 30a5e34

Browse files
authored
Add a helper function for rendering a Topics/SeeAlso section as HTML (#1382)
* Add a helper function for rendering a Topics/SeeAlso section as HTML rdar://163326857 * Add more code comments and internal documentation comments * Very slightly correct the test data
1 parent ba7a32b commit 30a5e34

File tree

3 files changed

+329
-1
lines changed

3 files changed

+329
-1
lines changed

Sources/DocCHTML/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ add_library(DocCHTML STATIC
1414
MarkdownRenderer+Declaration.swift
1515
MarkdownRenderer+Parameters.swift
1616
MarkdownRenderer+Returns.swift
17+
MarkdownRenderer+Topics.swift
1718
MarkdownRenderer.swift
1819
WordBreak.swift
1920
XMLNode+element.swift)
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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+
// TODO: Consider other HTML rendering options as a future improvement (rdar://165755530)
13+
package import FoundationXML
14+
package import FoundationEssentials
15+
#else
16+
package import Foundation
17+
#endif
18+
19+
package import Markdown
20+
package import DocCCommon
21+
22+
package extension MarkdownRenderer {
23+
/// Information about a task group that organizes other API into a hierarchy on this page.
24+
struct TaskGroupInfo {
25+
/// The title of this group of API
26+
package var title: String?
27+
/// Any additional free-form content that describes the group of API.
28+
package var content: [any Markup]
29+
/// A list of already resolved references that the renderer should display, in order, for this group.
30+
package var references: [URL]
31+
32+
package init(title: String?, content: [any Markup], references: [URL]) {
33+
self.title = title
34+
self.content = content
35+
self.references = references
36+
}
37+
}
38+
39+
/// Creates a grouped section with a given name, for example "topics" or "see also" that describes and organizes groups of related API.
40+
///
41+
/// If each language representation of the API has its own task groups, pass the task groups for each language representation.
42+
///
43+
/// If the API has the _same_ task groups in all language representations, only pass the task groups for one language.
44+
/// This produces a named section that doesn't hide any task groups for any of the languages (the same as if the symbol only had one language representation).
45+
func groupedSection(named sectionName: String, groups taskGroups: [SourceLanguage: [TaskGroupInfo]]) -> [XMLNode] {
46+
let taskGroups = RenderHelpers.sortedLanguageSpecificValues(taskGroups)
47+
48+
let items: [XMLElement] = if taskGroups.count == 1 {
49+
taskGroups.first!.value.flatMap { taskGroup in
50+
_singleTaskGroupElements(for: taskGroup)
51+
}
52+
} else {
53+
// TODO: As a future improvement we could diff the references and only mark them as language-specific if the group and reference doesn't appear in all languages.
54+
taskGroups.flatMap { language, taskGroups in
55+
let attribute = XMLNode.attribute(withName: "class", stringValue: "\(language.id)-only") as! XMLNode
56+
57+
let elements = taskGroups.flatMap { _singleTaskGroupElements(for: $0) }
58+
for element in elements {
59+
element.addAttribute(attribute)
60+
}
61+
return elements
62+
}
63+
}
64+
65+
return selfReferencingSection(named: sectionName, content: items)
66+
}
67+
68+
private func _singleTaskGroupElements(for taskGroup: TaskGroupInfo) -> [XMLElement] {
69+
let listItems = taskGroup.references.compactMap { reference in
70+
linkProvider.element(for: reference).map { _taskGroupItem(for: $0) }
71+
}
72+
// Don't return a title or abstract/discussion if this group has no links to display.
73+
guard !listItems.isEmpty else { return [] }
74+
75+
var items: [XMLElement] = []
76+
// Title
77+
if let title = taskGroup.title {
78+
items.append(selfReferencingHeading(level: 3, content: [.text(title)], plainTextTitle: title))
79+
}
80+
// Abstract/Discussion
81+
for markup in taskGroup.content {
82+
let rendered = visit(markup)
83+
if let element = rendered as? XMLElement {
84+
items.append(element)
85+
} else {
86+
// Wrap any inline content in an element. This is not expected to happen in practice
87+
items.append(.element(named: "p", children: [rendered]))
88+
}
89+
}
90+
// Links
91+
items.append(.element(named: "ul", children: listItems))
92+
93+
return items
94+
}
95+
96+
private func _taskGroupItem(for element: LinkedElement) -> XMLElement {
97+
var items: [XMLNode]
98+
switch element.subheadings {
99+
case .single(.conceptual(let title)):
100+
items = [.element(named: "p", children: [.text(title)])]
101+
102+
case .single(.symbol(let fragments)):
103+
items = switch goal {
104+
case .conciseness:
105+
[ .element(named: "code", children: [.text(fragments.map(\.text).joined())]) ]
106+
case .richness:
107+
[ _symbolSubheading(fragments, languageFilter: nil) ]
108+
}
109+
110+
case .languageSpecificSymbol(let fragmentsByLanguage):
111+
let fragmentsByLanguage = RenderHelpers.sortedLanguageSpecificValues(fragmentsByLanguage)
112+
items = if fragmentsByLanguage.count == 1 {
113+
[ _symbolSubheading(fragmentsByLanguage.first!.value, languageFilter: nil) ]
114+
} else if goal == .conciseness, let fragments = fragmentsByLanguage.first?.value {
115+
// On the rendered page, language specific symbol names _could_ be hidden through CSS but that wouldn't help the tool that reads the raw HTML.
116+
// So that tools don't need to filter out language specific names themselves, include only the primary language's subheading.
117+
[ _symbolSubheading(fragments, languageFilter: nil) ]
118+
} else {
119+
fragmentsByLanguage.map { language, fragments in
120+
_symbolSubheading(fragments, languageFilter: language)
121+
}
122+
}
123+
}
124+
125+
// Add the formatted abstract if the linked element has one.
126+
if let abstract = element.abstract {
127+
items.append(visit(abstract))
128+
}
129+
130+
return .element(named: "li", children: [
131+
// Wrap both the name and the abstract in an anchor so that the entire item is a link to that page.
132+
.element(named: "a", children: items, attributes: ["href": path(to: element.path)])
133+
])
134+
}
135+
136+
/// Transforms the symbol name fragments into a `<code>` HTML element that represents a symbol's subheading.
137+
///
138+
/// When the renderer has a ``RenderGoal/richness`` goal, it creates one `<span>` HTML element per fragment that could be styled differently through CSS:
139+
/// ```
140+
/// <code class="swift-only">
141+
/// <span class="decorator">class </span>
142+
/// <span class="identifier">Some<wbr/>Class</span>
143+
/// </code>
144+
/// ```
145+
///
146+
/// When the renderer has a ``RenderGoal/conciseness`` goal, it joins the fragment's text into a single string:
147+
/// ```
148+
/// <code>class SomeClass</code>
149+
/// ```
150+
private func _symbolSubheading(_ fragments: [LinkedElement.SymbolNameFragment], languageFilter: SourceLanguage?) -> XMLElement {
151+
switch goal {
152+
case .richness:
153+
.element(
154+
named: "code",
155+
children: fragments.map {
156+
.element(named: "span", children: wordBreak(symbolName: $0.text), attributes: ["class": $0.kind.rawValue])
157+
},
158+
attributes: languageFilter.map { ["class": "\($0.id)-only"] }
159+
)
160+
case .conciseness:
161+
.element(
162+
named: "code",
163+
children: [.text(fragments.map(\.text).joined())],
164+
attributes: languageFilter.map { ["class": "\($0.id)-only"] }
165+
)
166+
}
167+
}
168+
}

Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ struct MarkdownRenderer_PageElementsTests {
4242
.init(text: "Something", kind: .identifier),
4343
],
4444
.objectiveC: [
45-
.init(text: "class ", kind: .decorator),
45+
.init(text: "@interface ", kind: .decorator),
4646
.init(text: "TLASomething", kind: .identifier),
4747
],
4848
]),
@@ -482,6 +482,165 @@ struct MarkdownRenderer_PageElementsTests {
482482
}
483483
}
484484

485+
@Test(arguments: RenderGoal.allCases, ["Topics", "See Also"])
486+
func testRenderSingleLanguageGroupedSectionsWithMultiLanguageLinks(goal: RenderGoal, expectedGroupTitle: String) {
487+
let elements = [
488+
LinkedElement(
489+
path: URL(string: "/documentation/ModuleName/SomeClass/index.html")!,
490+
names: .languageSpecificSymbol([
491+
.swift: "SomeClass",
492+
.objectiveC: "TLASomeClass",
493+
]),
494+
subheadings: .languageSpecificSymbol([
495+
.swift: [
496+
.init(text: "class ", kind: .decorator),
497+
.init(text: "SomeClass", kind: .identifier),
498+
],
499+
.objectiveC: [
500+
.init(text: "@interface ", kind: .decorator),
501+
.init(text: "TLASomeClass", kind: .identifier),
502+
],
503+
]),
504+
abstract: parseMarkup(string: "Some _formatted_ description of this class").first as? Paragraph
505+
),
506+
LinkedElement(
507+
path: URL(string: "/documentation/ModuleName/SomeArticle/index.html")!,
508+
names: .single(.conceptual("Some Article")),
509+
subheadings: .single(.conceptual("Some Article")),
510+
abstract: parseMarkup(string: "Some **formatted** description of this _article_.").first as? Paragraph
511+
),
512+
LinkedElement(
513+
path: URL(string: "/documentation/ModuleName/SomeClass/someMethod(with:and:)/index.html")!,
514+
names: .languageSpecificSymbol([
515+
.swift: "someMethod(with:and:)",
516+
.objectiveC: "someMethodWithFirst:andSecond:",
517+
]),
518+
subheadings: .languageSpecificSymbol([
519+
.swift: [
520+
.init(text: "func ", kind: .decorator),
521+
.init(text: "someMethod", kind: .identifier),
522+
.init(text: "(", kind: .decorator),
523+
.init(text: "with", kind: .identifier),
524+
.init(text: ": Int, ", kind: .decorator),
525+
.init(text: "and", kind: .identifier),
526+
.init(text: ": String)", kind: .decorator),
527+
],
528+
.objectiveC: [
529+
.init(text: "- ", kind: .decorator),
530+
.init(text: "someMethodWithFirst:andSecond:", kind: .identifier),
531+
],
532+
]),
533+
abstract: nil
534+
),
535+
]
536+
537+
let renderer = makeRenderer(goal: goal, elementsToReturn: elements)
538+
let expectedSectionID = expectedGroupTitle.replacingOccurrences(of: " ", with: "-")
539+
let groupedSection = renderer.groupedSection(named: expectedGroupTitle, groups: [
540+
.swift: [
541+
.init(title: "Group title", content: parseMarkup(string: "Some description of this group"), references: [
542+
URL(string: "/documentation/ModuleName/SomeClass/index.html")!,
543+
URL(string: "/documentation/ModuleName/SomeArticle/index.html")!,
544+
URL(string: "/documentation/ModuleName/SomeClass/someMethod(with:and:)/index.html")!,
545+
])
546+
]
547+
])
548+
549+
switch goal {
550+
case .richness:
551+
groupedSection.assertMatches(prettyFormatted: true, expectedXMLString: """
552+
<section id="\(expectedSectionID)">
553+
<h2>
554+
<a href="#\(expectedSectionID)">\(expectedGroupTitle)</a>
555+
</h2>
556+
<h3 id="Group-title">
557+
<a href="#Group-title">Group title</a>
558+
</h3>
559+
<p>Some description of this group</p>
560+
<ul>
561+
<li>
562+
<a href="../../someclass/index.html">
563+
<code class="swift-only">
564+
<span class="decorator">class </span>
565+
<span class="identifier">Some<wbr/>
566+
Class</span>
567+
</code>
568+
<code class="occ-only">
569+
<span class="decorator">@interface </span>
570+
<span class="identifier">TLASome<wbr/>
571+
Class</span>
572+
</code>
573+
<p>Some <i>formatted</i>
574+
description of this class</p>
575+
</a>
576+
</li>
577+
<li>
578+
<a href="../../somearticle/index.html">
579+
<p>Some Article</p>
580+
<p>Some <b>formatted</b>
581+
description of this <i>article</i>
582+
.</p>
583+
</a>
584+
</li>
585+
<li>
586+
<a href="../../someclass/somemethod(with:and:)/index.html">
587+
<code class="swift-only">
588+
<span class="decorator">func </span>
589+
<span class="identifier">some<wbr/>
590+
Method</span>
591+
<span class="decorator">(</span>
592+
<span class="identifier">with</span>
593+
<span class="decorator">:<wbr/>
594+
Int, </span>
595+
<span class="identifier">and</span>
596+
<span class="decorator">:<wbr/>
597+
String)</span>
598+
</code>
599+
<code class="occ-only">
600+
<span class="decorator">- </span>
601+
<span class="identifier">some<wbr/>
602+
Method<wbr/>
603+
With<wbr/>
604+
First:<wbr/>
605+
and<wbr/>
606+
Second:</span>
607+
</code>
608+
</a>
609+
</li>
610+
</ul>
611+
</section>
612+
""")
613+
case .conciseness:
614+
groupedSection.assertMatches(prettyFormatted: true, expectedXMLString: """
615+
<h2>\(expectedGroupTitle)</h2>
616+
<h3>Group title</h3>
617+
<p>Some description of this group</p>
618+
<ul>
619+
<li>
620+
<a href="../../someclass/index.html">
621+
<code>class SomeClass</code>
622+
<p>Some <i>formatted</i>
623+
description of this class</p>
624+
</a>
625+
</li>
626+
<li>
627+
<a href="../../somearticle/index.html">
628+
<p>Some Article</p>
629+
<p>Some <b>formatted</b>
630+
description of this <i>article</i>
631+
.</p>
632+
</a>
633+
</li>
634+
<li>
635+
<a href="../../someclass/somemethod(with:and:)/index.html">
636+
<code>func someMethod(with: Int, and: String)</code>
637+
</a>
638+
</li>
639+
</ul>
640+
""")
641+
}
642+
}
643+
485644
// MARK: -
486645

487646
private func makeRenderer(

0 commit comments

Comments
 (0)