Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/DocCHTML/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions Sources/DocCHTML/MarkdownRenderer+Relationships.swift
Original file line number Diff line number Diff line change
@@ -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)])
])
}
}
57 changes: 54 additions & 3 deletions Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,13 +230,41 @@ 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(
renderer.discussion(discussion.content, fallbackSectionName: symbol.kind.identifier.swiftSymbolCouldHaveChildren ? "Overview" : "Discussion")
)
}

// 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(
Expand Down Expand Up @@ -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<Value> {
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<Variant> {
private extension DocumentationDataVariants {
func values(goal: RenderGoal, by areEquivalent: (Variant, Variant) -> Bool) -> VariantValues<Variant> {
guard let primaryValue = firstValue else {
return .empty
}
Expand All @@ -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
Expand All @@ -348,3 +393,9 @@ private extension DocumentationDataVariants where Variant: Equatable {
}
}
}

private extension DocumentationDataVariants where Variant: Equatable {
func values(goal: RenderGoal) -> VariantValues<Variant> {
values(goal: goal, by: ==)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: """
Expand Down Expand Up @@ -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: """
Expand Down Expand Up @@ -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: """
Expand All @@ -190,7 +200,8 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
<link rel="icon" href="/favicon.ico" />
<title>ModuleName</title>
<script>var baseUrl = "/"</script>
<meta content="Some formatted description of this module" name="description"/></head>
<meta content="Some formatted description of this module" name="description"/>
</head>
<body>
<noscript>
<article>
Expand All @@ -212,14 +223,30 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
<link rel="icon" href="/favicon.ico" />
<title>SomeClass</title>
<script>var baseUrl = "/"</script>
<meta content="Some in-source description of this class." name="description"/></head>
<meta content="Some in-source description of this class." name="description"/>
</head>
<body>
<noscript>
<article>
<section>
<h1>SomeClass</h1>
<p>Some in-source description of this class.</p>
</section>
<h2>Mentioned In</h2>
<ul>
<li>
<a href="../somearticle/index.html">Some article</a>
</li>
</ul>
<h2>Relationships</h2>
<h3>Conforms To</h3>
<ul>
<li>
<a href="../someprotocol/index.html">
<code>SomeProtocol</code>
</a>
</li>
</ul>
</article>
</noscript>
<div id="app"></div>
Expand All @@ -234,7 +261,8 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
<link rel="icon" href="/favicon.ico" />
<title>someMethod(with:and:)</title>
<script>var baseUrl = "/"</script>
<meta content="Some in-source description of this method." name="description"/></head>
<meta content="Some in-source description of this method." name="description"/>
</head>
<body>
<noscript>
<article>
Expand All @@ -258,7 +286,8 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
<link rel="icon" href="/favicon.ico" />
<title>Some article</title>
<script>var baseUrl = "/"</script>
<meta content="This is an formatted article." name="description"/></head>
<meta content="This is an formatted article." name="description"/>
</head>
<body>
<noscript>
<article>
Expand All @@ -276,6 +305,38 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
</body>
</html>
""")

try assert(readHTML: fileSystem.contents(of: URL(fileURLWithPath: "/output-dir/documentation/modulename/someprotocol/index.html")), matches: """
<html>
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<title>SomeProtocol</title>
<script>var baseUrl = "/"</script>
<meta content="Some in-source description of this protocol." name="description"/>
</head>
<body>
<noscript>
<article>
<section>
<h1>SomeProtocol</h1>
<p>Some in-source description of this protocol.</p>
</section>
<h2>Relationships</h2>
<h3>Conforming Types</h3>
<ul>
<li>
<a href="../someclass/index.html">
<code>SomeClass</code>
</a>
</li>
</ul>
</article>
</noscript>
<div id="app"></div>
</body>
</html>
""")
}

}
Expand Down