diff --git a/Sources/DocCHTML/CMakeLists.txt b/Sources/DocCHTML/CMakeLists.txt
index 531ccff0b..012804723 100644
--- a/Sources/DocCHTML/CMakeLists.txt
+++ b/Sources/DocCHTML/CMakeLists.txt
@@ -12,6 +12,7 @@ add_library(DocCHTML STATIC
MarkdownRenderer+Availability.swift
MarkdownRenderer+Breadcrumbs.swift
MarkdownRenderer+Parameters.swift
+ MarkdownRenderer+Topics.swift
MarkdownRenderer.swift
WordBreak.swift
XMLNode+element.swift)
diff --git a/Sources/DocCHTML/MarkdownRenderer+Topics.swift b/Sources/DocCHTML/MarkdownRenderer+Topics.swift
new file mode 100644
index 000000000..ea298fdd8
--- /dev/null
+++ b/Sources/DocCHTML/MarkdownRenderer+Topics.swift
@@ -0,0 +1,152 @@
+/*
+ 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 Markdown
+package import DocCCommon
+
+package extension MarkdownRenderer {
+ /// Information about a task group that organizes other API into a hierarchy on this page.
+ struct TaskGroupInfo {
+ /// The title of this group of API
+ package var title: String?
+ /// Any additional free-form content that describes the group of API.
+ package var content: [any Markup]
+ /// A list of already resolved references that the renderer should display, in order, for this group.
+ package var references: [URL]
+
+ package init(title: String?, content: [any Markup], references: [URL]) {
+ self.title = title
+ self.content = content
+ self.references = references
+ }
+ }
+
+ /// Creates a grouped section with a given name, for example "topics" or "see also" that describes and organizes groups of related API.
+ ///
+ /// If each language representation of the API has their own language-specific parameters, pass each language representation's parameter information.
+ ///
+ /// If the API has the _same_ parameters in all language representations, only pass the parameters for one language.
+ /// 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)
+ func groupedSection(named sectionName: String, groups taskGroups: [SourceLanguage: [TaskGroupInfo]]) -> [XMLNode] {
+ let taskGroups = RenderHelpers.sortedLanguageSpecificValues(taskGroups)
+
+ let items: [XMLElement] = if taskGroups.count == 1 {
+ taskGroups.first!.value.flatMap { taskGroup in
+ _singleTaskGroupElements(for: taskGroup)
+ }
+ } 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.
+ taskGroups.flatMap { language, taskGroups in
+ let attribute = XMLNode.attribute(withName: "class", stringValue: "\(language.id)-only") as! XMLNode
+
+ let elements = taskGroups.flatMap { _singleTaskGroupElements(for: $0) }
+ for element in elements {
+ element.addAttribute(attribute)
+ }
+ return elements
+ }
+ }
+
+ return selfReferencingSection(named: sectionName, content: items)
+ }
+
+ private func _singleTaskGroupElements(for taskGroup: TaskGroupInfo) -> [XMLElement] {
+ let listItems = taskGroup.references.compactMap { reference in
+ linkProvider.element(for: reference).map { _taskGroupItem(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 = taskGroup.title {
+ items.append(selfReferencingHeading(level: 3, content: [.text(title)], plainTextTitle: title))
+ }
+ // Abstract/Discussion
+ for markup in taskGroup.content {
+ let rendered = visit(markup)
+ if let element = rendered as? XMLElement {
+ items.append(element)
+ } else {
+ // Wrap any inline content in an element. This is not expected to happen in practice
+ items.append(.element(named: "p", children: [rendered]))
+ }
+ }
+ // Links
+ items.append(.element(named: "ul", children: listItems))
+
+ return items
+ }
+
+ private func _taskGroupItem(for element: LinkedElement) -> XMLElement {
+ var items: [XMLNode]
+ switch element.subheadings {
+ case .single(.conceptual(let title)):
+ items = [.element(named: "p", children: [.text(title)])]
+
+ case .single(.symbol(let fragments)):
+ items = switch goal {
+ case .conciseness:
+ [ .element(named: "code", children: [.text(fragments.map(\.text).joined())]) ]
+ case .richness:
+ [ _symbolSubheading(fragments, languageFilter: nil) ]
+ }
+
+ case .languageSpecificSymbol(let fragmentsByLanguage):
+ let fragmentsByLanguage = RenderHelpers.sortedLanguageSpecificValues(fragmentsByLanguage)
+ items = if fragmentsByLanguage.count == 1 {
+ [ _symbolSubheading(fragmentsByLanguage.first!.value, languageFilter: nil) ]
+ } else if goal == .conciseness, let fragments = fragmentsByLanguage.first?.value {
+ [ _symbolSubheading(fragments, languageFilter: nil) ]
+ } else {
+ fragmentsByLanguage.map { language, fragments in
+ _symbolSubheading(fragments, languageFilter: language)
+ }
+ }
+ }
+
+ // Add the formatted abstract if the linked element has one.
+ if let abstract = element.abstract {
+ items.append(visit(abstract))
+ }
+
+ return .element(named: "li", children: [
+ // Wrap both the name and the abstract in an anchor so that the entire item is a link to that page.
+ .element(named: "a", children: items, attributes: ["href": path(to: element.path)])
+ ])
+ }
+
+ private func _symbolSubheading(_ fragments: [LinkedElement.SymbolNameFragment], languageFilter: SourceLanguage?) -> XMLElement {
+ switch goal {
+ case .richness:
+ .element(
+ named: "code",
+ children: fragments.map {
+ .element(named: "span", children: wordBreak(symbolName: $0.text), attributes: ["class": $0.kind.rawValue])
+ },
+ attributes: languageFilter.map { ["class": "\($0.id)-only"] }
+ )
+ case .conciseness:
+ .element(
+ named: "code",
+ children: [.text(fragments.map(\.text).joined())],
+ attributes: languageFilter.map { ["class": "\($0.id)-only"] }
+ )
+ }
+ }
+}
diff --git a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift
index 2ab9c7cd5..d44055389 100644
--- a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift
+++ b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift
@@ -269,6 +269,165 @@ struct MarkdownRenderer_PageElementsTests {
""")
}
+ @Test(arguments: RenderGoal.allCases, ["Topics", "See Also"])
+ func testRenderSingleLanguageGroupedSectionsWithMultiLanguageLinks(goal: RenderGoal, expectedGroupTitle: String) {
+ let elements = [
+ LinkedElement(
+ path: URL(string: "/documentation/ModuleName/SomeClass/index.html")!,
+ names: .languageSpecificSymbol([
+ .swift: "SomeClass",
+ .objectiveC: "TLASomeClass",
+ ]),
+ subheadings: .languageSpecificSymbol([
+ .swift: [
+ .init(text: "class ", kind: .decorator),
+ .init(text: "SomeClass", kind: .identifier),
+ ],
+ .objectiveC: [
+ .init(text: "class ", kind: .decorator),
+ .init(text: "TLASomeClass", kind: .identifier),
+ ],
+ ]),
+ abstract: parseMarkup(string: "Some _formatted_ description of this class").first as? Paragraph
+ ),
+ LinkedElement(
+ path: URL(string: "/documentation/ModuleName/SomeArticle/index.html")!,
+ names: .single(.conceptual("Some Article")),
+ subheadings: .single(.conceptual("Some Article")),
+ abstract: parseMarkup(string: "Some **formatted** description of this _article_.").first as? Paragraph
+ ),
+ LinkedElement(
+ path: URL(string: "/documentation/ModuleName/SomeClass/someMethod(with:and:)/index.html")!,
+ names: .languageSpecificSymbol([
+ .swift: "someMethod(with:and:)",
+ .objectiveC: "someMethodWithFirst:andSecond:",
+ ]),
+ subheadings: .languageSpecificSymbol([
+ .swift: [
+ .init(text: "func ", kind: .decorator),
+ .init(text: "someMethod", kind: .identifier),
+ .init(text: "(", kind: .decorator),
+ .init(text: "with", kind: .identifier),
+ .init(text: ": Int, ", kind: .decorator),
+ .init(text: "and", kind: .identifier),
+ .init(text: ": String)", kind: .decorator),
+ ],
+ .objectiveC: [
+ .init(text: "- ", kind: .decorator),
+ .init(text: "someMethodWithFirst:andSecond:", kind: .identifier),
+ ],
+ ]),
+ abstract: nil
+ ),
+ ]
+
+ let renderer = makeRenderer(goal: goal, elementsToReturn: elements)
+ let expectedSectionID = expectedGroupTitle.replacingOccurrences(of: " ", with: "-")
+ let groupedSection = renderer.groupedSection(named: expectedGroupTitle, groups: [
+ .swift: [
+ .init(title: "Group title", content: parseMarkup(string: "Some description of this group"), references: [
+ URL(string: "/documentation/ModuleName/SomeClass/index.html")!,
+ URL(string: "/documentation/ModuleName/SomeArticle/index.html")!,
+ URL(string: "/documentation/ModuleName/SomeClass/someMethod(with:and:)/index.html")!,
+ ])
+ ]
+ ])
+
+ switch goal {
+ case .richness:
+ groupedSection.assertMatches(prettyFormatted: true, expectedXMLString: """
+ Some description of this group Some formatted
+ description of this class Some Article Some formatted
+ description of this article
+ .
+ \(expectedGroupTitle)
+
+
+ Group title
+
+
+
+
+ class
+ Some
+
+ class
+ TLASome
+
+ func
+ some
+
+ -
+ some
+
+
Some description of this group
+ + """) + } + } + // MARK: - private func makeRenderer(