Skip to content

Commit ba7a32b

Browse files
authored
Add a helper function for rendering symbol declarations as HTML (#1384)
* Add a helper function for rendering symbol declarations as HTML rdar://163326857 * Add code comments to describe why the concise declaration: - Only includes the primary language - Joins the declaration fragments into a single string
1 parent eab7829 commit ba7a32b

File tree

3 files changed

+232
-0
lines changed

3 files changed

+232
-0
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+Declaration.swift
1415
MarkdownRenderer+Parameters.swift
1516
MarkdownRenderer+Returns.swift
1617
MarkdownRenderer.swift
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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 DocCCommon
19+
package import SymbolKit
20+
21+
package extension MarkdownRenderer {
22+
23+
typealias DeclarationFragment = SymbolGraph.Symbol.DeclarationFragments.Fragment
24+
25+
/// Creates a`<pre><code>` HTML element hierarchy that represents the symbol's language-specific declarations.
26+
///
27+
/// When the renderer has a ``RenderGoal/richness`` goal, it creates a `<span>` element for each declaration fragment so that to enable syntax highlighting.
28+
///
29+
/// When the renderer has a ``RenderGoal/conciseness`` goal, it joins the different fragments into string.
30+
func declaration(_ fragmentsByLanguage: [SourceLanguage: [DeclarationFragment]]) -> XMLElement {
31+
let fragmentsByLanguage = RenderHelpers.sortedLanguageSpecificValues(fragmentsByLanguage)
32+
33+
guard goal == .richness else {
34+
// On the rendered page, language specific content _could_ be hidden through CSS but that wouldn't help the tool that reads the raw HTML.
35+
// So that tools don't need to filter out language specific content themselves, include only the primary language's (plain text) declaration.
36+
let plainTextDeclaration: [XMLNode] = fragmentsByLanguage.first.map { _, fragments in
37+
// The main purpose of individual HTML elements per declaration fragment would be syntax highlighting on the rendered page.
38+
// That structure likely won't be beneficial (and could even be detrimental) to the tool's ability to consume the declaration information.
39+
[.element(named: "code", children: [.text(fragments.map(\.spelling).joined())])]
40+
} ?? []
41+
return .element(named: "pre", children: plainTextDeclaration)
42+
}
43+
44+
let declarations: [XMLElement] = if fragmentsByLanguage.count == 1 {
45+
// If there's only a single language there's no need to mark anything as language specific.
46+
[XMLNode.element(named: "code", children: _declarationTokens(for: fragmentsByLanguage.first!.value))]
47+
} else {
48+
fragmentsByLanguage.map { language, fragments in
49+
XMLNode.element(named: "code", children: _declarationTokens(for: fragments), attributes: ["class": "\(language.id)-only"])
50+
}
51+
}
52+
return .element(named: "pre", children: declarations, attributes: ["id": "declaration"])
53+
}
54+
55+
private func _declarationTokens(for fragments: [DeclarationFragment]) -> [XMLNode] {
56+
// TODO: Pretty print declarations for Swift and Objective-C by placing attributes and parameters on their own lines (rdar://165918402)
57+
fragments.map { fragment in
58+
let elementClass = "token-\(fragment.kind.rawValue)"
59+
60+
if fragment.kind == .typeIdentifier,
61+
let symbolID = fragment.preciseIdentifier,
62+
let reference = linkProvider.pathForSymbolID(symbolID)
63+
{
64+
// If the token refers to a symbol that the `linkProvider` is aware of, make that fragment a link to that symbol.
65+
return .element(named: "a", children: [.text(fragment.spelling)], attributes: [
66+
"href": path(to: reference),
67+
"class": elementClass
68+
])
69+
} else if fragment.kind == .text {
70+
// ???: Does text also need a <span> element or can that be avoided?
71+
return .text(fragment.spelling)
72+
} else {
73+
// The declaration element is expected to scroll, so individual fragments don't need to contain explicit word breaks.
74+
return .element(named: "span", children: [.text(fragment.spelling)], attributes: ["class": elementClass])
75+
}
76+
}
77+
}
78+
}

Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,159 @@ struct MarkdownRenderer_PageElementsTests {
328328
""")
329329
}
330330
}
331+
332+
@Test(arguments: RenderGoal.allCases)
333+
func testRenderSwiftDeclaration(goal: RenderGoal) {
334+
let symbolPaths = [
335+
"first-parameter-symbol-id": URL(string: "/documentation/ModuleName/FirstParameterValue/index.html")!,
336+
"second-parameter-symbol-id": URL(string: "/documentation/ModuleName/SecondParameterValue/index.html")!,
337+
"return-value-symbol-id": URL(string: "/documentation/ModuleName/ReturnValue/index.html")!,
338+
]
339+
340+
let declaration = makeRenderer(goal: goal, pathsToReturn: symbolPaths).declaration([
341+
.swift: [
342+
.init(kind: .keyword, spelling: "func", preciseIdentifier: nil),
343+
.init(kind: .text, spelling: " ", preciseIdentifier: nil),
344+
.init(kind: .identifier, spelling: "doSomething", preciseIdentifier: nil),
345+
.init(kind: .text, spelling: "(", preciseIdentifier: nil),
346+
.init(kind: .externalParameter, spelling: "with", preciseIdentifier: nil),
347+
.init(kind: .text, spelling: " ", preciseIdentifier: nil),
348+
.init(kind: .internalParameter, spelling: "first", preciseIdentifier: nil),
349+
.init(kind: .text, spelling: ": ", preciseIdentifier: nil),
350+
.init(kind: .typeIdentifier, spelling: "FirstParameterValue", preciseIdentifier: "first-parameter-symbol-id"),
351+
.init(kind: .text, spelling: ", ", preciseIdentifier: nil),
352+
.init(kind: .externalParameter, spelling: "and", preciseIdentifier: nil),
353+
.init(kind: .text, spelling: " ", preciseIdentifier: nil),
354+
.init(kind: .internalParameter, spelling: "second", preciseIdentifier: nil),
355+
.init(kind: .text, spelling: ": ", preciseIdentifier: nil),
356+
.init(kind: .typeIdentifier, spelling: "SecondParameterValue", preciseIdentifier: "second-parameter-symbol-id"),
357+
.init(kind: .text, spelling: ") ", preciseIdentifier: nil),
358+
.init(kind: .keyword, spelling: "throws", preciseIdentifier: nil),
359+
.init(kind: .text, spelling: "-> ", preciseIdentifier: nil),
360+
.init(kind: .typeIdentifier, spelling: "ReturnValue", preciseIdentifier: "return-value-symbol-id"),
361+
]
362+
])
363+
switch goal {
364+
case .richness:
365+
declaration.assertMatches(prettyFormatted: true, expectedXMLString: """
366+
<pre id="declaration">
367+
<code>
368+
<span class="token-keyword">func</span>
369+
<span class="token-identifier">doSomething</span>
370+
(<span class="token-externalParam">with</span>
371+
<span class="token-internalParam">first</span>
372+
: <a class="token-typeIdentifier" href="../../firstparametervalue/index.html">FirstParameterValue</a>
373+
, <span class="token-externalParam">and</span>
374+
<span class="token-internalParam">second</span>
375+
: <a class="token-typeIdentifier" href="../../secondparametervalue/index.html">SecondParameterValue</a>
376+
) <span class="token-keyword">throws</span>
377+
-&gt; <a class="token-typeIdentifier" href="../../returnvalue/index.html">ReturnValue</a>
378+
</code>
379+
</pre>
380+
""")
381+
case .conciseness:
382+
declaration.assertMatches(prettyFormatted: true, expectedXMLString: """
383+
<pre>
384+
<code>func doSomething(with first: FirstParameterValue, and second: SecondParameterValue) throws-&gt; ReturnValue</code>
385+
</pre>
386+
""")
387+
}
388+
}
389+
390+
@Test(arguments: RenderGoal.allCases)
391+
func testRenderLanguageSpecificDeclarations(goal: RenderGoal) {
392+
let symbolPaths = [
393+
"first-parameter-symbol-id": URL(string: "/documentation/ModuleName/FirstParameterValue/index.html")!,
394+
"second-parameter-symbol-id": URL(string: "/documentation/ModuleName/SecondParameterValue/index.html")!,
395+
"return-value-symbol-id": URL(string: "/documentation/ModuleName/ReturnValue/index.html")!,
396+
"error-parameter-symbol-id": URL(string: "/documentation/Foundation/NSError/index.html")!,
397+
]
398+
399+
let declaration = makeRenderer(goal: goal, pathsToReturn: symbolPaths).declaration([
400+
.swift: [
401+
.init(kind: .keyword, spelling: "func", preciseIdentifier: nil),
402+
.init(kind: .text, spelling: " ", preciseIdentifier: nil),
403+
.init(kind: .identifier, spelling: "doSomething", preciseIdentifier: nil),
404+
.init(kind: .text, spelling: "(", preciseIdentifier: nil),
405+
.init(kind: .externalParameter, spelling: "with", preciseIdentifier: nil),
406+
.init(kind: .text, spelling: " ", preciseIdentifier: nil),
407+
.init(kind: .internalParameter, spelling: "first", preciseIdentifier: nil),
408+
.init(kind: .text, spelling: ": ", preciseIdentifier: nil),
409+
.init(kind: .typeIdentifier, spelling: "FirstParameterValue", preciseIdentifier: "first-parameter-symbol-id"),
410+
.init(kind: .text, spelling: ", ", preciseIdentifier: nil),
411+
.init(kind: .externalParameter, spelling: "and", preciseIdentifier: nil),
412+
.init(kind: .text, spelling: " ", preciseIdentifier: nil),
413+
.init(kind: .internalParameter, spelling: "second", preciseIdentifier: nil),
414+
.init(kind: .text, spelling: ": ", preciseIdentifier: nil),
415+
.init(kind: .typeIdentifier, spelling: "SecondParameterValue", preciseIdentifier: "second-parameter-symbol-id"),
416+
.init(kind: .text, spelling: ") ", preciseIdentifier: nil),
417+
.init(kind: .keyword, spelling: "throws", preciseIdentifier: nil),
418+
.init(kind: .text, spelling: "-> ", preciseIdentifier: nil),
419+
.init(kind: .typeIdentifier, spelling: "ReturnValue", preciseIdentifier: "return-value-symbol-id"),
420+
],
421+
422+
.objectiveC: [
423+
.init(kind: .text, spelling: "- (", preciseIdentifier: nil),
424+
.init(kind: .typeIdentifier, spelling: "ReturnValue", preciseIdentifier: "return-value-symbol-id"),
425+
.init(kind: .text, spelling: ") ", preciseIdentifier: nil),
426+
.init(kind: .identifier, spelling: "doSomethingWithFirst", preciseIdentifier: nil),
427+
.init(kind: .text, spelling: ": (", preciseIdentifier: nil),
428+
.init(kind: .typeIdentifier, spelling: "FirstParameterValue", preciseIdentifier: "first-parameter-symbol-id"),
429+
.init(kind: .text, spelling: ") ", preciseIdentifier: nil),
430+
.init(kind: .internalParameter, spelling: "first", preciseIdentifier: nil),
431+
.init(kind: .text, spelling: " ", preciseIdentifier: nil),
432+
.init(kind: .identifier, spelling: "andSecond", preciseIdentifier: nil),
433+
.init(kind: .text, spelling: ": (", preciseIdentifier: nil),
434+
.init(kind: .typeIdentifier, spelling: "SecondParameterValue", preciseIdentifier: "second-parameter-symbol-id"),
435+
.init(kind: .text, spelling: ") ", preciseIdentifier: nil),
436+
.init(kind: .internalParameter, spelling: "second", preciseIdentifier: nil),
437+
.init(kind: .text, spelling: " ", preciseIdentifier: nil),
438+
.init(kind: .identifier, spelling: "error", preciseIdentifier: nil),
439+
.init(kind: .text, spelling: ": (", preciseIdentifier: nil),
440+
.init(kind: .typeIdentifier, spelling: "NSError", preciseIdentifier: "error-parameter-symbol-id"),
441+
.init(kind: .text, spelling: " **) ", preciseIdentifier: nil),
442+
.init(kind: .internalParameter, spelling: "error", preciseIdentifier: nil),
443+
.init(kind: .text, spelling: ";", preciseIdentifier: nil),
444+
]
445+
])
446+
switch goal {
447+
case .richness:
448+
declaration.assertMatches(prettyFormatted: true, expectedXMLString: """
449+
<pre id="declaration">
450+
<code class="swift-only">
451+
<span class="token-keyword">func</span>
452+
<span class="token-identifier">doSomething</span>
453+
(<span class="token-externalParam">with</span>
454+
<span class="token-internalParam">first</span>
455+
: <a class="token-typeIdentifier" href="../../firstparametervalue/index.html">FirstParameterValue</a>
456+
, <span class="token-externalParam">and</span>
457+
<span class="token-internalParam">second</span>
458+
: <a class="token-typeIdentifier" href="../../secondparametervalue/index.html">SecondParameterValue</a>
459+
) <span class="token-keyword">throws</span>
460+
-&gt; <a class="token-typeIdentifier" href="../../returnvalue/index.html">ReturnValue</a>
461+
</code>
462+
<code class="occ-only">- (<a class="token-typeIdentifier" href="../../returnvalue/index.html">ReturnValue</a>
463+
) <span class="token-identifier">doSomethingWithFirst</span>
464+
: (<a class="token-typeIdentifier" href="../../firstparametervalue/index.html">FirstParameterValue</a>
465+
) <span class="token-internalParam">first</span>
466+
<span class="token-identifier">andSecond</span>
467+
: (<a class="token-typeIdentifier" href="../../secondparametervalue/index.html">SecondParameterValue</a>
468+
) <span class="token-internalParam">second</span>
469+
<span class="token-identifier">error</span>
470+
: (<a class="token-typeIdentifier" href="../../../foundation/nserror/index.html">NSError</a>
471+
**) <span class="token-internalParam">error</span>
472+
;</code>
473+
</pre>
474+
""")
475+
476+
case .conciseness:
477+
declaration.assertMatches(prettyFormatted: true, expectedXMLString: """
478+
<pre>
479+
<code>func doSomething(with first: FirstParameterValue, and second: SecondParameterValue) throws-&gt; ReturnValue</code>
480+
</pre>
481+
""")
482+
}
483+
}
331484

332485
// MARK: -
333486

0 commit comments

Comments
 (0)