Skip to content

Commit 2a1a7bd

Browse files
d-ronnqvistpatshaughnessyheckj
authored
Add a helper function for rendering a parameters section as HTML (#1377)
* Add a helper function for rendering a parameters section as HTML rdar://163326857 * Apply wording suggestions in documentation comments Co-authored-by: Pat Shaughnessy <pat_shaughnessy@apple.com> Co-authored-by: Joseph Heck <j_heck@apple.com> * Add TODO comment about a debug assertion for the parameter name order --------- Co-authored-by: Pat Shaughnessy <pat_shaughnessy@apple.com> Co-authored-by: Joseph Heck <j_heck@apple.com>
1 parent 9806c58 commit 2a1a7bd

File tree

4 files changed

+337
-2
lines changed

4 files changed

+337
-2
lines changed

Sources/DocCHTML/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ add_library(DocCHTML STATIC
1111
LinkProvider.swift
1212
MarkdownRenderer+Availability.swift
1313
MarkdownRenderer+Breadcrumbs.swift
14+
MarkdownRenderer+Parameters.swift
1415
MarkdownRenderer.swift
1516
WordBreak.swift
1617
XMLNode+element.swift)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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+
#else
15+
package import Foundation
16+
#endif
17+
18+
package import Markdown
19+
package import DocCCommon
20+
21+
package extension MarkdownRenderer {
22+
/// Information about a specific parameter for a piece of API.
23+
struct ParameterInfo {
24+
/// The name of the parameter.
25+
package var name: String
26+
/// The markdown content that describes the parameter.
27+
package var content: [any Markup]
28+
29+
package init(name: String, content: [any Markup]) {
30+
self.name = name
31+
self.content = content
32+
}
33+
}
34+
35+
/// Creates a "parameters" section that describes all the parameters for a symbol.
36+
///
37+
/// If each language representation of the API has their own language-specific parameters, pass each language representation's parameter information.
38+
///
39+
/// If the API has the _same_ parameters in all language representations, only pass the parameters for one language.
40+
/// This produces a "parameters" section that doesn't hide any parameters for any of the languages (same as if the symbol only had one language representation)
41+
func parameters(_ info: [SourceLanguage: [ParameterInfo]]) -> [XMLNode] {
42+
let info = RenderHelpers.sortedLanguageSpecificValues(info)
43+
guard info.contains(where: { _, parameters in !parameters.isEmpty }) else {
44+
// Don't create a section if there are no parameters to describe.
45+
return []
46+
}
47+
48+
let items: [XMLElement] = switch info.count {
49+
case 1:
50+
[_singleLanguageParameters(info.first!.value)]
51+
52+
case 2:
53+
[_dualLanguageParameters(primary: info.first!, secondary: info.last!)]
54+
55+
default:
56+
// In practice DocC only encounters one or two different languages. If there would be a third one,
57+
// produce correct looking pages that may include duplicated markup by not trying to share parameters across languages.
58+
info.map { language, info in
59+
.element(
60+
named: "dl",
61+
children: _singleLanguageParameterItems(info),
62+
attributes: ["class": "\(language.id)-only"]
63+
)
64+
}
65+
}
66+
67+
return selfReferencingSection(named: "Parameters", content: items)
68+
}
69+
70+
private func _singleLanguageParameters(_ parameterInfo: [ParameterInfo]) -> XMLElement {
71+
.element(named: "dl", children: _singleLanguageParameterItems(parameterInfo))
72+
}
73+
74+
private func _singleLanguageParameterItems(_ parameterInfo: [ParameterInfo]) -> [XMLElement] {
75+
// When there's only a single language representation, create a list of `<dt>` and `<dd>` HTML elements ("terms" and "definitions" in a "description list" (`<dl> HTML element`)
76+
var items: [XMLElement] = []
77+
items.reserveCapacity(parameterInfo.count * 2)
78+
for parameter in parameterInfo {
79+
// name
80+
items.append(
81+
.element(named: "dt", children: [
82+
.element(named: "code", children: [.text(parameter.name)])
83+
])
84+
)
85+
// description
86+
items.append(
87+
.element(named: "dd", children: parameter.content.map { visit($0) })
88+
)
89+
}
90+
91+
return items
92+
}
93+
94+
private func _dualLanguageParameters(
95+
primary: (key: SourceLanguage, value: [ParameterInfo]),
96+
secondary: (key: SourceLanguage, value: [ParameterInfo])
97+
) -> XMLElement {
98+
// "Shadow" the parameters with more descriptive tuple labels
99+
let primary = (language: primary.key, parameters: primary.value)
100+
let secondary = (language: secondary.key, parameters: secondary.value)
101+
102+
// When there are exactly two language representations, which is very common,
103+
// avoid duplication and only create `<dt>` and `<dd>` HTML elements _once_ if the parameter exist in both language representations.
104+
105+
// Start by rendering the primary language's parameter, then update that list with information about language-specific parameters.
106+
var items = _singleLanguageParameterItems(primary.parameters)
107+
108+
// Find all the inserted and deleted parameters.
109+
// This assumes that parameters appear in the same _order_ in each language representation, which is true in practice.
110+
// If that assumption is wrong, it will produce correct looking results but some repeated markup.
111+
// TODO: Consider adding a debug assertion that verifies the order and a test that verifies the output of out-of-order parameter names.
112+
let differences = secondary.parameters.difference(from: primary.parameters, by: { $0.name == $1.name })
113+
114+
// Track which parameters _only_ exist in the primary language in order to insert the secondary languages's _unique_ parameters in the right locations.
115+
var primaryOnlyIndices = Set<Int>()
116+
117+
// Add a "class" attribute to the parameters that only exist in the secondary language representation.
118+
// Through CSS, the rendered page can show and hide HTML elements that only apply to a specific language representation.
119+
for case let .remove(offset, _, _) in differences.removals {
120+
// This item only exists in the primary parameters
121+
primaryOnlyIndices.insert(offset)
122+
let index = offset * 2
123+
// Mark those items as only being applying to the first language
124+
items[index ].addAttributes(["class": "\(primary.language.id)-only"])
125+
items[index + 1].addAttributes(["class": "\(primary.language.id)-only"])
126+
}
127+
128+
// Insert parameter that only exists in the secondary language representation.
129+
for case let .insert(offset, parameter, _) in differences.insertions {
130+
// Account for any primary-only parameters that appear before this (times 2 because each parameter has a `<dt>` and `<dd>` HTML element)
131+
let index = (offset + primaryOnlyIndices.count(where: { $0 < offset })) * 2
132+
items.insert(contentsOf: [
133+
// Name
134+
.element(named: "dt", children: [
135+
.element(named: "code", children: [.text(parameter.name)])
136+
], attributes: ["class": "\(secondary.language.id)-only"]),
137+
// Description
138+
.element(named: "dd", children: parameter.content.map { visit($0) }, attributes: ["class": "\(secondary.language.id)-only"])
139+
], at: index)
140+
}
141+
142+
return .element(named: "dl", children: items)
143+
}
144+
}

Sources/DocCHTML/MarkdownRenderer.swift

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ package struct MarkdownRenderer<Provider: LinkProvider> {
9191
)
9292
}
9393

94-
/// Transforms a markdown heading into a`<h[1...6]>` HTML element whose content is wrapped in an `<a>` element that references the heading itself.
94+
/// Transforms a markdown heading into a`<h[1...6]>` HTML element whose content is wrapped in an `<a>` HTML element that references the heading itself.
9595
///
9696
/// As part of transforming the heading, the renderer also transforms all of the its content recursively.
9797
/// For example, the renderer transforms this markdown
@@ -107,11 +107,14 @@ package struct MarkdownRenderer<Provider: LinkProvider> {
107107
/// </h1>
108108
/// ```
109109
///
110-
/// - Note: When the renderer has a ``RenderGoal/conciseness`` goal, it doesn't wrap the headings content in an anchor.
110+
/// - Note: When the renderer has a ``RenderGoal/conciseness`` goal, it doesn't wrap the heading's content in an anchor.
111111
package func visit(_ heading: Heading) -> XMLNode {
112112
selfReferencingHeading(level: heading.level, content: visit(heading.children), plainTextTitle: heading.plainText)
113113
}
114114

115+
/// Returns a `<h[1...6]>` HTML element whose content is wrapped in an `<a>` HTML element that references the heading itself.
116+
///
117+
/// - Note: When the renderer has a ``RenderGoal/conciseness`` goal, it doesn't wrap the heading's content in an anchor.
115118
func selfReferencingHeading(level: Int, content: [XMLNode], plainTextTitle: @autoclosure () -> String) -> XMLElement {
116119
switch goal {
117120
case .conciseness:
@@ -131,6 +134,34 @@ package struct MarkdownRenderer<Provider: LinkProvider> {
131134
}
132135
}
133136

137+
/// Returns a "section" with a level-2 heading that references the section it's in.
138+
///
139+
/// When the renderer has a ``RenderGoal/richness`` goal, the returned section is a`<section>` HTML element.
140+
/// The first child of that `<section>` HTML element is an `<h2>` HTML element that wraps a `<a>` HTML element that references the section.
141+
/// After that `<h2>` HTML element, the section contains the already transformed `content` nodes representing the rest of its HTML content.
142+
///
143+
/// When the renderer has a ``RenderGoal/conciseness`` goal, it returns a plain `<h2>` element followed by the already transformed `content` nodes.
144+
func selfReferencingSection(named sectionName: String, content: [XMLNode]) -> [XMLNode] {
145+
guard !content.isEmpty else { return [] }
146+
147+
switch goal {
148+
case .richness:
149+
let id = urlReadableFragment(sectionName)
150+
151+
return [.element(
152+
named: "section",
153+
children: [
154+
.element(named: "h2", children: [
155+
.element(named: "a", children: [.text(sectionName)], attributes: ["href": "#\(id)"])
156+
])
157+
] + content,
158+
attributes: ["id": id]
159+
)]
160+
case .conciseness:
161+
return [.element(named: "h2", children: [.text(sectionName)]) as XMLNode] + content
162+
}
163+
}
164+
134165
/// Transforms a markdown emphasis into a`<i>` HTML element.
135166
func visit(_ emphasis: Emphasis) -> XMLNode {
136167
.element(named: "i", children: visit(emphasis.children))

Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,165 @@ struct MarkdownRenderer_PageElementsTests {
110110
}
111111
}
112112

113+
@Test(arguments: RenderGoal.allCases)
114+
func testRenderSingleLanguageParameters(goal: RenderGoal) {
115+
let parameters = makeRenderer(goal: goal).parameters([
116+
.swift: [
117+
.init(name: "First", content: parseMarkup(string: "Some _formatted_ description with `code`")),
118+
.init(name: "Second", content: parseMarkup(string: """
119+
Some **other** _formatted_ description
120+
121+
That spans two paragraphs
122+
""")),
123+
]
124+
])
125+
126+
switch goal {
127+
case .richness:
128+
parameters.assertMatches(prettyFormatted: true, expectedXMLString: """
129+
<section id="Parameters">
130+
<h2>
131+
<a href="#Parameters">Parameters</a>
132+
</h2>
133+
<dl>
134+
<dt>
135+
<code>First</code>
136+
</dt>
137+
<dd>
138+
<p>
139+
Some <i>formatted</i> description with <code>code</code>
140+
</p>
141+
</dd>
142+
<dt>
143+
<code>Second</code>
144+
</dt>
145+
<dd>
146+
<p>
147+
Some <b>other</b> <i>formatted</i> description</p>
148+
<p>That spans two paragraphs</p>
149+
</dd>
150+
</dl>
151+
</section>
152+
""")
153+
case .conciseness:
154+
parameters.assertMatches(prettyFormatted: true, expectedXMLString: """
155+
<h2>Parameters</h2>
156+
<dl>
157+
<dt>
158+
<code>First</code>
159+
</dt>
160+
<dd>
161+
<p>Some <i>formatted</i>description with <code>code</code>
162+
</p>
163+
</dd>
164+
<dt>
165+
<code>Second</code>
166+
</dt>
167+
<dd>
168+
<p>
169+
Some <b>other</b> <i>formatted</i> description</p>
170+
<p>That spans two paragraphs</p>
171+
</dd>
172+
</dl>
173+
""")
174+
}
175+
}
176+
177+
@Test
178+
func testRenderLanguageSpecificParameters() {
179+
let parameters = makeRenderer(goal: .richness).parameters([
180+
.swift: [
181+
.init(name: "FirstCommon", content: parseMarkup(string: "Available in both languages")),
182+
.init(name: "SwiftOnly", content: parseMarkup(string: "Only available in Swift")),
183+
.init(name: "SecondCommon", content: parseMarkup(string: "Also available in both languages")),
184+
],
185+
.objectiveC: [
186+
.init(name: "FirstCommon", content: parseMarkup(string: "Available in both languages")),
187+
.init(name: "SecondCommon", content: parseMarkup(string: "Also available in both languages")),
188+
.init(name: "ObjectiveCOnly", content: parseMarkup(string: "Only available in Objective-C")),
189+
],
190+
])
191+
parameters.assertMatches(prettyFormatted: true, expectedXMLString: """
192+
<section id="Parameters">
193+
<h2>
194+
<a href="#Parameters">Parameters</a>
195+
</h2>
196+
<dl>
197+
<dt>
198+
<code>FirstCommon</code>
199+
</dt>
200+
<dd>
201+
<p>Available in both languages</p>
202+
</dd>
203+
<dt class="swift-only">
204+
<code>SwiftOnly</code>
205+
</dt>
206+
<dd class="swift-only">
207+
<p>Only available in Swift</p>
208+
</dd>
209+
<dt>
210+
<code>SecondCommon</code>
211+
</dt>
212+
<dd>
213+
<p>Also available in both languages</p>
214+
</dd>
215+
<dt class="occ-only">
216+
<code>ObjectiveCOnly</code>
217+
</dt>
218+
<dd class="occ-only">
219+
<p>Only available in Objective-C</p>
220+
</dd>
221+
</dl>
222+
</section>
223+
""")
224+
}
225+
226+
@Test
227+
func testRenderManyLanguageSpecificParameters() {
228+
let parameters = makeRenderer(goal: .richness).parameters([
229+
.swift: [
230+
.init(name: "First", content: parseMarkup(string: "Some description")),
231+
],
232+
.objectiveC: [
233+
.init(name: "Second", content: parseMarkup(string: "Some description")),
234+
],
235+
.data: [
236+
.init(name: "Third", content: parseMarkup(string: "Some description")),
237+
],
238+
])
239+
parameters.assertMatches(prettyFormatted: true, expectedXMLString: """
240+
<section id="Parameters">
241+
<h2>
242+
<a href="#Parameters">Parameters</a>
243+
</h2>
244+
<dl class="swift-only">
245+
<dt>
246+
<code>First</code>
247+
</dt>
248+
<dd>
249+
<p>Some description</p>
250+
</dd>
251+
</dl>
252+
<dl class="data-only">
253+
<dt>
254+
<code>Third</code>
255+
</dt>
256+
<dd>
257+
<p>Some description</p>
258+
</dd>
259+
</dl>
260+
<dl class="occ-only">
261+
<dt>
262+
<code>Second</code>
263+
</dt>
264+
<dd>
265+
<p>Some description</p>
266+
</dd>
267+
</dl>
268+
</section>
269+
""")
270+
}
271+
113272
// MARK: -
114273

115274
private func makeRenderer(

0 commit comments

Comments
 (0)