Skip to content

Commit 077afe1

Browse files
authored
Include Mentioned In and Relationships sections in the HTML output (#1391)
1 parent 0f91352 commit 077afe1

File tree

4 files changed

+231
-12
lines changed

4 files changed

+231
-12
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+Discussion.swift
1616
MarkdownRenderer+Parameters.swift
17+
MarkdownRenderer+Relationships.swift
1718
MarkdownRenderer+Returns.swift
1819
MarkdownRenderer+Topics.swift
1920
MarkdownRenderer.swift
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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 DocCCommon
20+
21+
package extension MarkdownRenderer {
22+
/// Information about a task group that organizes other API into a hierarchy on this page.
23+
struct ListInfo {
24+
/// The title of this group of API
25+
package var title: String?
26+
/// A list of already resolved references that the renderer should display, in order, for this group.
27+
package var references: [URL]
28+
29+
package init(title: String?, references: [URL]) {
30+
self.title = title
31+
self.references = references
32+
}
33+
}
34+
35+
/// Creates a grouped section with a given name, for example "relationships" or "mentioned in" lists groups of related pages without further description.
36+
///
37+
/// If each language representation of the API has its own lists, pass the list for each language representation.
38+
///
39+
/// If the API has the _same_ lists in all language representations, only pass the lists for one language.
40+
/// This produces a named section that doesn't hide any lists for any of the languages (the same as if the symbol only had one language representation).
41+
func groupedListSection(named sectionName: String, groups lists: [SourceLanguage: [ListInfo]]) -> [XMLNode] {
42+
let lists = RenderHelpers.sortedLanguageSpecificValues(lists)
43+
44+
let items: [XMLElement] = if lists.count == 1 {
45+
lists.first!.value.flatMap { list in
46+
_singleListGroupElements(for: list)
47+
}
48+
} else {
49+
// 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.
50+
lists.flatMap { language, taskGroups in
51+
let attribute = XMLNode.attribute(withName: "class", stringValue: "\(language.id)-only") as! XMLNode
52+
53+
let elements = taskGroups.flatMap { _singleListGroupElements(for: $0) }
54+
for element in elements {
55+
element.addAttribute(attribute)
56+
}
57+
return elements
58+
}
59+
}
60+
61+
return selfReferencingSection(named: sectionName, content: items)
62+
}
63+
64+
private func _singleListGroupElements(for list: ListInfo) -> [XMLElement] {
65+
let listItems = list.references.compactMap { reference in
66+
linkProvider.element(for: reference).map { _listItem(for: $0) }
67+
}
68+
// Don't return a title or abstract/discussion if this group has no links to display.
69+
guard !listItems.isEmpty else { return [] }
70+
71+
var items: [XMLElement] = []
72+
// Title
73+
if let title = list.title {
74+
items.append(selfReferencingHeading(level: 3, content: [.text(title)], plainTextTitle: title))
75+
}
76+
// Links
77+
items.append(.element(named: "ul", children: listItems))
78+
79+
return items
80+
}
81+
82+
private func _listItem(for element: LinkedElement) -> XMLElement {
83+
var items: [XMLNode]
84+
switch element.names {
85+
case .single(.conceptual(let title)):
86+
items = [.text(title)]
87+
88+
case .single(.symbol(let title)):
89+
items = [ .element(named: "code", children: wordBreak(symbolName: title)) ]
90+
91+
case .languageSpecificSymbol(let titlesByLanguage):
92+
let titlesByLanguage = RenderHelpers.sortedLanguageSpecificValues(titlesByLanguage)
93+
items = if titlesByLanguage.count == 1 {
94+
[ .element(named: "code", children: wordBreak(symbolName: titlesByLanguage.first!.value)) ]
95+
} else {
96+
titlesByLanguage.map { language, title in
97+
.element(named: "code", children: wordBreak(symbolName: title), attributes: ["class": "\(language.id)-only"])
98+
}
99+
}
100+
}
101+
102+
return .element(named: "li", children: [
103+
.element(named: "a", children: items, attributes: ["href": path(to: element.path)])
104+
])
105+
}
106+
}

Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,13 +230,41 @@ struct HTMLRenderer {
230230
hero.addChild(paragraph)
231231
}
232232

233+
// Mentioned In
234+
if FeatureFlags.current.isMentionedInEnabled {
235+
articleElement.addChildren(
236+
renderer.groupedListSection(named: "Mentioned In", groups: [
237+
.swift: [.init(title: nil, references: context.articleSymbolMentions.articlesMentioning(reference).map(\.url))]
238+
])
239+
)
240+
}
241+
233242
// Discussion
234243
if let discussion = symbol.discussion {
235244
articleElement.addChildren(
236245
renderer.discussion(discussion.content, fallbackSectionName: symbol.kind.identifier.swiftSymbolCouldHaveChildren ? "Overview" : "Discussion")
237246
)
238247
}
239248

249+
// Relationships
250+
if let relationships = symbol.relationshipsVariants
251+
.values(goal: goal, by: { $0.groups.elementsEqual($1.groups, by: { $0 == $1 }) })
252+
.valuesByLanguage()
253+
{
254+
articleElement.addChildren(
255+
renderer.groupedListSection(named: "Relationships", groups: relationships.mapValues { section in
256+
section.groups.map {
257+
.init(title: $0.sectionTitle, references: $0.destinations.compactMap { topic in
258+
switch topic {
259+
case .resolved(.success(let reference)): reference.url
260+
case .unresolved, .resolved(.failure): nil
261+
}
262+
})
263+
}
264+
})
265+
)
266+
}
267+
240268
return RenderedPageInfo(
241269
content: goal == .richness ? main : articleElement,
242270
metadata: .init(
@@ -305,19 +333,36 @@ private extension Symbol {
305333
}
306334
}
307335

336+
private extension RelationshipsGroup {
337+
static func == (lhs: RelationshipsGroup, rhs: RelationshipsGroup) -> Bool {
338+
lhs.kind == rhs.kind && lhs.destinations == rhs.destinations // Everything else is derived from the `kind`
339+
}
340+
}
341+
308342
private enum VariantValues<Value> {
309343
case single(Value)
310344
case languageSpecific([SourceLanguage: Value])
311345
// This is necessary because of a shortcoming in the API design of `DocumentationDataVariants`.
312346
case empty
347+
348+
func valuesByLanguage() -> [SourceLanguage: Value]? {
349+
switch self {
350+
case .single(let value):
351+
[.swift: value] // The language doesn't matter when there's only one
352+
case .languageSpecific(let values):
353+
values
354+
case .empty:
355+
nil
356+
}
357+
}
313358
}
314359

315360
// Both `DocumentationDataVariants` and `VariantCollection` are really hard to work with correctly and neither offer a good API that both:
316361
// - Makes a clear distinction between when a value will always exist and when the "values" can be empty.
317362
// - Allows the caller to iterate over all the values.
318363
// TODO: Design and implement a better solution for representing language specific variations of a value (rdar://166211961)
319-
private extension DocumentationDataVariants where Variant: Equatable {
320-
func values(goal: RenderGoal) -> VariantValues<Variant> {
364+
private extension DocumentationDataVariants {
365+
func values(goal: RenderGoal, by areEquivalent: (Variant, Variant) -> Bool) -> VariantValues<Variant> {
321366
guard let primaryValue = firstValue else {
322367
return .empty
323368
}
@@ -335,7 +380,7 @@ private extension DocumentationDataVariants where Variant: Equatable {
335380
}
336381

337382
// Check if the variants has any language-specific values (that are _actually_ different from the primary value)
338-
if values.contains(where: { _, value in value != primaryValue }) {
383+
if values.contains(where: { _, value in !areEquivalent(value, primaryValue) }) {
339384
// There are multiple distinct values
340385
return .languageSpecific([SourceLanguage: Variant](
341386
values.map { trait, value in
@@ -348,3 +393,9 @@ private extension DocumentationDataVariants where Variant: Equatable {
348393
}
349394
}
350395
}
396+
397+
private extension DocumentationDataVariants where Variant: Equatable {
398+
func values(goal: RenderGoal) -> VariantValues<Variant> {
399+
values(goal: goal, by: ==)
400+
}
401+
}

Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
4646
.init(kind: .text, spelling: " ", preciseIdentifier: nil),
4747
.init(kind: .identifier, spelling: "SomeClass", preciseIdentifier: nil),
4848
]),
49+
makeSymbol(id: "some-protocol-id", kind: .class, pathComponents: ["SomeProtocol"], docComment: """
50+
Some in-source description of this protocol.
51+
""", declaration: [
52+
.init(kind: .keyword, spelling: "protocol", preciseIdentifier: nil),
53+
.init(kind: .text, spelling: " ", preciseIdentifier: nil),
54+
.init(kind: .identifier, spelling: "SomeProtocol", preciseIdentifier: nil),
55+
]),
4956
makeSymbol(
5057
id: "some-method-id", kind: .method, pathComponents: ["SomeClass", "someMethod(with:and:)"],
5158
docComment: """
@@ -100,7 +107,8 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
100107
]
101108
)
102109
], relationships: [
103-
.init(source: "some-method-id", target: "some-class-id", kind: .memberOf, targetFallback: nil)
110+
.init(source: "some-method-id", target: "some-class-id", kind: .memberOf, targetFallback: nil),
111+
.init(source: "some-class-id", target: "some-protocol-id", kind: .conformsTo, targetFallback: nil)
104112
])),
105113

106114
TextFile(name: "ModuleName.md", utf8Content: """
@@ -177,10 +185,12 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
177185
├─ index.html
178186
├─ somearticle/
179187
│ ╰─ index.html
180-
╰─ someclass/
181-
├─ index.html
182-
╰─ somemethod(with:and:)/
183-
╰─ index.html
188+
├─ someclass/
189+
│ ├─ index.html
190+
│ ╰─ somemethod(with:and:)/
191+
│ ╰─ index.html
192+
╰─ someprotocol/
193+
╰─ index.html
184194
""")
185195

186196
try assert(readHTML: fileSystem.contents(of: URL(fileURLWithPath: "/output-dir/documentation/modulename/index.html")), matches: """
@@ -190,7 +200,8 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
190200
<link rel="icon" href="/favicon.ico" />
191201
<title>ModuleName</title>
192202
<script>var baseUrl = "/"</script>
193-
<meta content="Some formatted description of this module" name="description"/></head>
203+
<meta content="Some formatted description of this module" name="description"/>
204+
</head>
194205
<body>
195206
<noscript>
196207
<article>
@@ -212,14 +223,30 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
212223
<link rel="icon" href="/favicon.ico" />
213224
<title>SomeClass</title>
214225
<script>var baseUrl = "/"</script>
215-
<meta content="Some in-source description of this class." name="description"/></head>
226+
<meta content="Some in-source description of this class." name="description"/>
227+
</head>
216228
<body>
217229
<noscript>
218230
<article>
219231
<section>
220232
<h1>SomeClass</h1>
221233
<p>Some in-source description of this class.</p>
222234
</section>
235+
<h2>Mentioned In</h2>
236+
<ul>
237+
<li>
238+
<a href="../somearticle/index.html">Some article</a>
239+
</li>
240+
</ul>
241+
<h2>Relationships</h2>
242+
<h3>Conforms To</h3>
243+
<ul>
244+
<li>
245+
<a href="../someprotocol/index.html">
246+
<code>SomeProtocol</code>
247+
</a>
248+
</li>
249+
</ul>
223250
</article>
224251
</noscript>
225252
<div id="app"></div>
@@ -234,7 +261,8 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
234261
<link rel="icon" href="/favicon.ico" />
235262
<title>someMethod(with:and:)</title>
236263
<script>var baseUrl = "/"</script>
237-
<meta content="Some in-source description of this method." name="description"/></head>
264+
<meta content="Some in-source description of this method." name="description"/>
265+
</head>
238266
<body>
239267
<noscript>
240268
<article>
@@ -258,7 +286,8 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
258286
<link rel="icon" href="/favicon.ico" />
259287
<title>Some article</title>
260288
<script>var baseUrl = "/"</script>
261-
<meta content="This is an formatted article." name="description"/></head>
289+
<meta content="This is an formatted article." name="description"/>
290+
</head>
262291
<body>
263292
<noscript>
264293
<article>
@@ -276,6 +305,38 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
276305
</body>
277306
</html>
278307
""")
308+
309+
try assert(readHTML: fileSystem.contents(of: URL(fileURLWithPath: "/output-dir/documentation/modulename/someprotocol/index.html")), matches: """
310+
<html>
311+
<head>
312+
<meta charset="utf-8" />
313+
<link rel="icon" href="/favicon.ico" />
314+
<title>SomeProtocol</title>
315+
<script>var baseUrl = "/"</script>
316+
<meta content="Some in-source description of this protocol." name="description"/>
317+
</head>
318+
<body>
319+
<noscript>
320+
<article>
321+
<section>
322+
<h1>SomeProtocol</h1>
323+
<p>Some in-source description of this protocol.</p>
324+
</section>
325+
<h2>Relationships</h2>
326+
<h3>Conforming Types</h3>
327+
<ul>
328+
<li>
329+
<a href="../someclass/index.html">
330+
<code>SomeClass</code>
331+
</a>
332+
</li>
333+
</ul>
334+
</article>
335+
</noscript>
336+
<div id="app"></div>
337+
</body>
338+
</html>
339+
""")
279340
}
280341

281342
}

0 commit comments

Comments
 (0)