Skip to content
Open
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 @@ -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)
Expand Down
152 changes: 152 additions & 0 deletions Sources/DocCHTML/MarkdownRenderer+Topics.swift
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// If each language representation of the API has their own language-specific parameters, pass each language representation's parameter information.
/// If each language representation of the API has their own language-specific set of parameters, pass the parameter information for each language representation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And: has their -> has its

///
/// 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// 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)
/// This produces a "parameters" section that doesn't hide any parameters for any of the languages (the same as if the symbol only had one language representation).

func groupedSection(named sectionName: String, groups taskGroups: [SourceLanguage: [TaskGroupInfo]]) -> [XMLNode] {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comments above are talking about a parameters section. Should we update the comments to describe the task group section this function returns?

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)):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice more nested pattern matching :)

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we ignoring the second (and subsequent languages) here for concise mode? Please explain why with a comment.

[ _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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An HTML example here or above might be nice; this is getting a bit complex.

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"] }
)
}
}
}
159 changes: 159 additions & 0 deletions Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you've included the OCC representation with alternate parameters above, could we also include (or make a second set of asserts) that show the expected output when the language is ObjC?

.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: """
<section id="\(expectedSectionID)">
<h2>
<a href="#\(expectedSectionID)">\(expectedGroupTitle)</a>
</h2>
<h3 id="Group-title">
<a href="#Group-title">Group title</a>
</h3>
<p>Some description of this group</p>
<ul>
<li>
<a href="../../someclass/index.html">
<code class="swift-only">
<span class="decorator">class </span>
<span class="identifier">Some<wbr/>
Class</span>
</code>
<code class="occ-only">
<span class="decorator">class </span>
<span class="identifier">TLASome<wbr/>
Class</span>
</code>
<p>Some <i>formatted</i>
description of this class</p>
</a>
</li>
<li>
<a href="../../somearticle/index.html">
<p>Some Article</p>
<p>Some <b>formatted</b>
description of this <i>article</i>
.</p>
</a>
</li>
<li>
<a href="../../someclass/somemethod(with:and:)/index.html">
<code class="swift-only">
<span class="decorator">func </span>
<span class="identifier">some<wbr/>
Method</span>
<span class="decorator">(</span>
<span class="identifier">with</span>
<span class="decorator">:<wbr/>
Int, </span>
<span class="identifier">and</span>
<span class="decorator">:<wbr/>
String)</span>
</code>
<code class="occ-only">
<span class="decorator">- </span>
<span class="identifier">some<wbr/>
Method<wbr/>
With<wbr/>
First:<wbr/>
and<wbr/>
Second:</span>
</code>
</a>
</li>
</ul>
</section>
""")
case .conciseness:
groupedSection.assertMatches(prettyFormatted: true, expectedXMLString: """
<h2>\(expectedGroupTitle)</h2>
<h3>Group title</h3>
<p>Some description of this group</p>
<ul>
<li>
<a href="../../someclass/index.html">
<code>class SomeClass</code>
<p>Some <i>formatted</i>
description of this class</p>
</a>
</li>
<li>
<a href="../../somearticle/index.html">
<p>Some Article</p>
<p>Some <b>formatted</b>
description of this <i>article</i>
.</p>
</a>
</li>
<li>
<a href="../../someclass/somemethod(with:and:)/index.html">
<code>func someMethod(with: Int, and: String)</code>
</a>
</li>
</ul>
""")
}
}

// MARK: -

private func makeRenderer(
Expand Down