diff --git a/Sources/DocCHTML/CMakeLists.txt b/Sources/DocCHTML/CMakeLists.txt
index 786cf5626..365226488 100644
--- a/Sources/DocCHTML/CMakeLists.txt
+++ b/Sources/DocCHTML/CMakeLists.txt
@@ -14,6 +14,7 @@ add_library(DocCHTML STATIC
MarkdownRenderer+Declaration.swift
MarkdownRenderer+Discussion.swift
MarkdownRenderer+Parameters.swift
+ MarkdownRenderer+Relationships.swift
MarkdownRenderer+Returns.swift
MarkdownRenderer+Topics.swift
MarkdownRenderer.swift
diff --git a/Sources/DocCHTML/MarkdownRenderer+Relationships.swift b/Sources/DocCHTML/MarkdownRenderer+Relationships.swift
new file mode 100644
index 000000000..1a86f8824
--- /dev/null
+++ b/Sources/DocCHTML/MarkdownRenderer+Relationships.swift
@@ -0,0 +1,106 @@
+/*
+ This source file is part of the Swift.org open source project
+
+ Copyright (c) 2025 Apple Inc. and the Swift project authors
+ Licensed under Apache License v2.0 with Runtime Library Exception
+
+ See https://swift.org/LICENSE.txt for license information
+ See https://swift.org/CONTRIBUTORS.txt for Swift project authors
+*/
+
+#if canImport(FoundationXML)
+// TODO: Consider other HTML rendering options as a future improvement (rdar://165755530)
+package import FoundationXML
+package import FoundationEssentials
+#else
+package import Foundation
+#endif
+
+package import DocCCommon
+
+package extension MarkdownRenderer {
+ /// Information about a task group that organizes other API into a hierarchy on this page.
+ struct ListInfo {
+ /// The title of this group of API
+ package var title: String?
+ /// A list of already resolved references that the renderer should display, in order, for this group.
+ package var references: [URL]
+
+ package init(title: String?, references: [URL]) {
+ self.title = title
+ self.references = references
+ }
+ }
+
+ /// Creates a grouped section with a given name, for example "relationships" or "mentioned in" lists groups of related pages without further description.
+ ///
+ /// If each language representation of the API has its own lists, pass the list for each language representation.
+ ///
+ /// If the API has the _same_ lists in all language representations, only pass the lists for one language.
+ /// 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).
+ func groupedListSection(named sectionName: String, groups lists: [SourceLanguage: [ListInfo]]) -> [XMLNode] {
+ let lists = RenderHelpers.sortedLanguageSpecificValues(lists)
+
+ let items: [XMLElement] = if lists.count == 1 {
+ lists.first!.value.flatMap { list in
+ _singleListGroupElements(for: list)
+ }
+ } else {
+ // 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.
+ lists.flatMap { language, taskGroups in
+ let attribute = XMLNode.attribute(withName: "class", stringValue: "\(language.id)-only") as! XMLNode
+
+ let elements = taskGroups.flatMap { _singleListGroupElements(for: $0) }
+ for element in elements {
+ element.addAttribute(attribute)
+ }
+ return elements
+ }
+ }
+
+ return selfReferencingSection(named: sectionName, content: items)
+ }
+
+ private func _singleListGroupElements(for list: ListInfo) -> [XMLElement] {
+ let listItems = list.references.compactMap { reference in
+ linkProvider.element(for: reference).map { _listItem(for: $0) }
+ }
+ // Don't return a title or abstract/discussion if this group has no links to display.
+ guard !listItems.isEmpty else { return [] }
+
+ var items: [XMLElement] = []
+ // Title
+ if let title = list.title {
+ items.append(selfReferencingHeading(level: 3, content: [.text(title)], plainTextTitle: title))
+ }
+ // Links
+ items.append(.element(named: "ul", children: listItems))
+
+ return items
+ }
+
+ private func _listItem(for element: LinkedElement) -> XMLElement {
+ var items: [XMLNode]
+ switch element.names {
+ case .single(.conceptual(let title)):
+ items = [.text(title)]
+
+ case .single(.symbol(let title)):
+ items = [ .element(named: "code", children: wordBreak(symbolName: title)) ]
+
+ case .languageSpecificSymbol(let titlesByLanguage):
+ let titlesByLanguage = RenderHelpers.sortedLanguageSpecificValues(titlesByLanguage)
+ items = if titlesByLanguage.count == 1 {
+ [ .element(named: "code", children: wordBreak(symbolName: titlesByLanguage.first!.value)) ]
+ } else {
+ titlesByLanguage.map { language, title in
+ .element(named: "code", children: wordBreak(symbolName: title), attributes: ["class": "\(language.id)-only"])
+ }
+ }
+ }
+
+ return .element(named: "li", children: [
+ .element(named: "a", children: items, attributes: ["href": path(to: element.path)])
+ ])
+ }
+}
diff --git a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift
index 59b05046c..e830202e3 100644
--- a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift
+++ b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift
@@ -230,6 +230,15 @@ struct HTMLRenderer {
hero.addChild(paragraph)
}
+ // Mentioned In
+ if FeatureFlags.current.isMentionedInEnabled {
+ articleElement.addChildren(
+ renderer.groupedListSection(named: "Mentioned In", groups: [
+ .swift: [.init(title: nil, references: context.articleSymbolMentions.articlesMentioning(reference).map(\.url))]
+ ])
+ )
+ }
+
// Discussion
if let discussion = symbol.discussion {
articleElement.addChildren(
@@ -237,6 +246,25 @@ struct HTMLRenderer {
)
}
+ // Relationships
+ if let relationships = symbol.relationshipsVariants
+ .values(goal: goal, by: { $0.groups.elementsEqual($1.groups, by: { $0 == $1 }) })
+ .valuesByLanguage()
+ {
+ articleElement.addChildren(
+ renderer.groupedListSection(named: "Relationships", groups: relationships.mapValues { section in
+ section.groups.map {
+ .init(title: $0.sectionTitle, references: $0.destinations.compactMap { topic in
+ switch topic {
+ case .resolved(.success(let reference)): reference.url
+ case .unresolved, .resolved(.failure): nil
+ }
+ })
+ }
+ })
+ )
+ }
+
return RenderedPageInfo(
content: goal == .richness ? main : articleElement,
metadata: .init(
@@ -305,19 +333,36 @@ private extension Symbol {
}
}
+private extension RelationshipsGroup {
+ static func == (lhs: RelationshipsGroup, rhs: RelationshipsGroup) -> Bool {
+ lhs.kind == rhs.kind && lhs.destinations == rhs.destinations // Everything else is derived from the `kind`
+ }
+}
+
private enum VariantValues {
case single(Value)
case languageSpecific([SourceLanguage: Value])
// This is necessary because of a shortcoming in the API design of `DocumentationDataVariants`.
case empty
+
+ func valuesByLanguage() -> [SourceLanguage: Value]? {
+ switch self {
+ case .single(let value):
+ [.swift: value] // The language doesn't matter when there's only one
+ case .languageSpecific(let values):
+ values
+ case .empty:
+ nil
+ }
+ }
}
// Both `DocumentationDataVariants` and `VariantCollection` are really hard to work with correctly and neither offer a good API that both:
// - Makes a clear distinction between when a value will always exist and when the "values" can be empty.
// - Allows the caller to iterate over all the values.
// TODO: Design and implement a better solution for representing language specific variations of a value (rdar://166211961)
-private extension DocumentationDataVariants where Variant: Equatable {
- func values(goal: RenderGoal) -> VariantValues {
+private extension DocumentationDataVariants {
+ func values(goal: RenderGoal, by areEquivalent: (Variant, Variant) -> Bool) -> VariantValues {
guard let primaryValue = firstValue else {
return .empty
}
@@ -335,7 +380,7 @@ private extension DocumentationDataVariants where Variant: Equatable {
}
// Check if the variants has any language-specific values (that are _actually_ different from the primary value)
- if values.contains(where: { _, value in value != primaryValue }) {
+ if values.contains(where: { _, value in !areEquivalent(value, primaryValue) }) {
// There are multiple distinct values
return .languageSpecific([SourceLanguage: Variant](
values.map { trait, value in
@@ -348,3 +393,9 @@ private extension DocumentationDataVariants where Variant: Equatable {
}
}
}
+
+private extension DocumentationDataVariants where Variant: Equatable {
+ func values(goal: RenderGoal) -> VariantValues {
+ values(goal: goal, by: ==)
+ }
+}
diff --git a/Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift b/Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift
index fcaea8bd8..42d42ddfc 100644
--- a/Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift
+++ b/Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift
@@ -46,6 +46,13 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
.init(kind: .text, spelling: " ", preciseIdentifier: nil),
.init(kind: .identifier, spelling: "SomeClass", preciseIdentifier: nil),
]),
+ makeSymbol(id: "some-protocol-id", kind: .class, pathComponents: ["SomeProtocol"], docComment: """
+ Some in-source description of this protocol.
+ """, declaration: [
+ .init(kind: .keyword, spelling: "protocol", preciseIdentifier: nil),
+ .init(kind: .text, spelling: " ", preciseIdentifier: nil),
+ .init(kind: .identifier, spelling: "SomeProtocol", preciseIdentifier: nil),
+ ]),
makeSymbol(
id: "some-method-id", kind: .method, pathComponents: ["SomeClass", "someMethod(with:and:)"],
docComment: """
@@ -100,7 +107,8 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
]
)
], relationships: [
- .init(source: "some-method-id", target: "some-class-id", kind: .memberOf, targetFallback: nil)
+ .init(source: "some-method-id", target: "some-class-id", kind: .memberOf, targetFallback: nil),
+ .init(source: "some-class-id", target: "some-protocol-id", kind: .conformsTo, targetFallback: nil)
])),
TextFile(name: "ModuleName.md", utf8Content: """
@@ -177,10 +185,12 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
├─ index.html
├─ somearticle/
│ ╰─ index.html
- ╰─ someclass/
- ├─ index.html
- ╰─ somemethod(with:and:)/
- ╰─ index.html
+ ├─ someclass/
+ │ ├─ index.html
+ │ ╰─ somemethod(with:and:)/
+ │ ╰─ index.html
+ ╰─ someprotocol/
+ ╰─ index.html
""")
try assert(readHTML: fileSystem.contents(of: URL(fileURLWithPath: "/output-dir/documentation/modulename/index.html")), matches: """
@@ -190,7 +200,8 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
ModuleName
-
+
+