From 5bec535d760f76e4ad74dec2e8c0c2787c125662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 5 Dec 2025 14:28:29 +0100 Subject: [PATCH] Add a helper function for rendering a Topics/SeeAlso section as HTML rdar://163326857 --- Sources/DocCHTML/CMakeLists.txt | 1 + .../DocCHTML/MarkdownRenderer+Topics.swift | 152 +++++++++++++++++ .../MarkdownRenderer+PageElementsTests.swift | 159 ++++++++++++++++++ 3 files changed, 312 insertions(+) create mode 100644 Sources/DocCHTML/MarkdownRenderer+Topics.swift 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: """ +
+

+ \(expectedGroupTitle) +

+

+ Group title +

+

Some description of this group

+ +
+ """) + case .conciseness: + groupedSection.assertMatches(prettyFormatted: true, expectedXMLString: """ +

\(expectedGroupTitle)

+

Group title

+

Some description of this group

+ + """) + } + } + // MARK: - private func makeRenderer(