From 0af79fe2576890fb229a365d063c2828b4a2df3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 11 Dec 2025 19:48:03 +0100 Subject: [PATCH] Include Mentioned In and Relationships sections in the HTML output --- Sources/DocCHTML/CMakeLists.txt | 1 + .../MarkdownRenderer+Relationships.swift | 106 ++++++++++++++++++ .../Model/Rendering/HTML/HTMLRenderer.swift | 55 ++++++++- .../FileWritingHTMLContentConsumerTests.swift | 79 +++++++++++-- 4 files changed, 229 insertions(+), 12 deletions(-) create mode 100644 Sources/DocCHTML/MarkdownRenderer+Relationships.swift diff --git a/Sources/DocCHTML/CMakeLists.txt b/Sources/DocCHTML/CMakeLists.txt index bf168988c..6e3d0012c 100644 --- a/Sources/DocCHTML/CMakeLists.txt +++ b/Sources/DocCHTML/CMakeLists.txt @@ -13,6 +13,7 @@ add_library(DocCHTML STATIC MarkdownRenderer+Breadcrumbs.swift MarkdownRenderer+Declaration.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 fae8cddcb..201871480 100644 --- a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift +++ b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift @@ -223,6 +223,32 @@ struct HTMLRenderer { hero.addChild(paragraph) } + if FeatureFlags.current.isMentionedInEnabled { + articleElement.addChildren( + renderer.groupedListSection(named: "Mentioned In", groups: [ + .swift: [.init(title: nil, references: context.articleSymbolMentions.articlesMentioning(reference).map(\.url))] + ]) + ) + } + + 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( @@ -291,19 +317,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 } @@ -321,7 +364,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 @@ -334,3 +377,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 9190fba8b..4326988f8 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 - + +