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 @@ -11,6 +11,7 @@ add_library(DocCHTML STATIC
LinkProvider.swift
MarkdownRenderer+Availability.swift
MarkdownRenderer+Breadcrumbs.swift
MarkdownRenderer+Parameters.swift
MarkdownRenderer.swift
WordBreak.swift
XMLNode+element.swift)
Expand Down
144 changes: 144 additions & 0 deletions Sources/DocCHTML/MarkdownRenderer+Parameters.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
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
#else
package import Foundation
#endif

package import Markdown
package import DocCCommon

package extension MarkdownRenderer {
/// Information about a specific parameter for a piece of API.
struct ParameterInfo {
/// The name of the parameter.
package var name: String
/// The markdown content that describes the parameter.
package var content: [any Markup]

package init(name: String, content: [any Markup]) {
self.name = name
self.content = content
}
}

/// Creates a "parameters" section that describes all the parameters for a symbol.
///
/// 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 parameters(_ info: [SourceLanguage: [ParameterInfo]]) -> [XMLNode] {
let info = RenderHelpers.sortedLanguageSpecificValues(info)
guard info.contains(where: { _, parameters in !parameters.isEmpty }) else {
// Don't create a section if there are no parameters to describe.
return []
}

let items: [XMLElement] = switch info.count {
case 1:
[_singleLanguageParameters(info.first!.value)]

case 2:
[_dualLanguageParameters(primary: info.first!, secondary: info.last!)]

default:
// In practice DocC only encounters one or two different languages. If there would be a third one,
// produce correct looking pages that may include duplicated markup by not trying to share parameters across languages.
info.map { language, info in
.element(
named: "dl",
children: _singleLanguageParameterItems(info),
attributes: ["class": "\(language.id)-only"]
)
}
}

return selfReferencingSection(named: "Parameters", content: items)
}

private func _singleLanguageParameters(_ parameterInfo: [ParameterInfo]) -> XMLElement {
.element(named: "dl", children: _singleLanguageParameterItems(parameterInfo))
}

private func _singleLanguageParameterItems(_ parameterInfo: [ParameterInfo]) -> [XMLElement] {
// When there's only a single language representation, create a list of `<dt>` and `<dd>` HTML elements ("terms" and "definitions" in a "description list" (`<dl> HTML element`)
var items: [XMLElement] = []
items.reserveCapacity(parameterInfo.count * 2)
for parameter in parameterInfo {
// name
items.append(
.element(named: "dt", children: [
.element(named: "code", children: [.text(parameter.name)])
])
)
// description
items.append(
.element(named: "dd", children: parameter.content.map { visit($0) })
)
}

return items
}

private func _dualLanguageParameters(
primary: (key: SourceLanguage, value: [ParameterInfo]),
secondary: (key: SourceLanguage, value: [ParameterInfo])
) -> XMLElement {
// "Shadow" the parameters with more descriptive tuple labels
let primary = (language: primary.key, parameters: primary.value)
let secondary = (language: secondary.key, parameters: secondary.value)

// When there are exactly two language representations, which is very common,
// avoid duplication and only create `<dt>` and `<dd>` HTML elements _once_ if the parameter exist in both language representations.

// Start by rendering the primary language's parameter, then update that list with information about language-specific parameters.
var items = _singleLanguageParameterItems(primary.parameters)

// Find all the inserted and deleted parameters.
// This assumes that parameters appear in the same _order_ in each language representation, which is true in practice.
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we add a debug assertion to check this order? Or just sort them here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It would be a bit long, but doable, to fit the order check inside of the assert call so that it doesn't get evaluated in release builds.

For example, consider these two lists of parameter names that are in order with a few language specific parameters ("A" and "B"):

One  A  Two     Three
One     Two  B  Three

An implementation that checks the order could either:

  • manually iterate each list in parallel so that it can advance one but not the other to skip past language specific parameters in either list
  • iterate through one of the lists, check if the other contains that parameter name, and then check if it contains all the parameters before that and verify that index(of: current) is greater than all index(of: earlier).
  • shrink two subsequences from the start to verify that the first element that exist in both is the same, then shrink the subsequence to after that element and repeat until one of the sub sequences is empty.

In either case, the check would be so long that it's best to write it as either an inline closure or as a private function that only the assert calls.

// If that assumption is wrong, it will produce correct looking results but some repeated markup.
// TODO: Consider adding a debug assertion that verifies the order and a test that verifies the output of out-of-order parameter names.
let differences = secondary.parameters.difference(from: primary.parameters, by: { $0.name == $1.name })

// Track which parameters _only_ exist in the primary language in order to insert the secondary languages's _unique_ parameters in the right locations.
var primaryOnlyIndices = Set<Int>()

// Add a "class" attribute to the parameters that only exist in the secondary language representation.
// Through CSS, the rendered page can show and hide HTML elements that only apply to a specific language representation.
for case let .remove(offset, _, _) in differences.removals {
// This item only exists in the primary parameters
primaryOnlyIndices.insert(offset)
let index = offset * 2
// Mark those items as only being applying to the first language
items[index ].addAttributes(["class": "\(primary.language.id)-only"])
items[index + 1].addAttributes(["class": "\(primary.language.id)-only"])
}

// Insert parameter that only exists in the secondary language representation.
for case let .insert(offset, parameter, _) in differences.insertions {
// Account for any primary-only parameters that appear before this (times 2 because each parameter has a `<dt>` and `<dd>` HTML element)
let index = (offset + primaryOnlyIndices.count(where: { $0 < offset })) * 2
items.insert(contentsOf: [
// Name
.element(named: "dt", children: [
.element(named: "code", children: [.text(parameter.name)])
], attributes: ["class": "\(secondary.language.id)-only"]),
// Description
.element(named: "dd", children: parameter.content.map { visit($0) }, attributes: ["class": "\(secondary.language.id)-only"])
], at: index)
}

return .element(named: "dl", children: items)
}
}
35 changes: 33 additions & 2 deletions Sources/DocCHTML/MarkdownRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ package struct MarkdownRenderer<Provider: LinkProvider> {
)
}

/// Transforms a markdown heading into a`<h[1...6]>` HTML element whose content is wrapped in an `<a>` element that references the heading itself.
/// Transforms a markdown heading into a`<h[1...6]>` HTML element whose content is wrapped in an `<a>` HTML element that references the heading itself.
///
/// As part of transforming the heading, the renderer also transforms all of the its content recursively.
/// For example, the renderer transforms this markdown
Expand All @@ -107,11 +107,14 @@ package struct MarkdownRenderer<Provider: LinkProvider> {
/// </h1>
/// ```
///
/// - Note: When the renderer has a ``RenderGoal/conciseness`` goal, it doesn't wrap the headings content in an anchor.
/// - Note: When the renderer has a ``RenderGoal/conciseness`` goal, it doesn't wrap the heading's content in an anchor.
package func visit(_ heading: Heading) -> XMLNode {
selfReferencingHeading(level: heading.level, content: visit(heading.children), plainTextTitle: heading.plainText)
}

/// Returns a `<h[1...6]>` HTML element whose content is wrapped in an `<a>` HTML element that references the heading itself.
///
/// - Note: When the renderer has a ``RenderGoal/conciseness`` goal, it doesn't wrap the heading's content in an anchor.
func selfReferencingHeading(level: Int, content: [XMLNode], plainTextTitle: @autoclosure () -> String) -> XMLElement {
switch goal {
case .conciseness:
Expand All @@ -131,6 +134,34 @@ package struct MarkdownRenderer<Provider: LinkProvider> {
}
}

/// Returns a "section" with a level-2 heading that references the section it's in.
///
/// When the renderer has a ``RenderGoal/richness`` goal, the returned section is a`<section>` HTML element.
/// The first child of that `<section>` HTML element is an `<h2>` HTML element that wraps a `<a>` HTML element that references the section.
/// After that `<h2>` HTML element, the section contains the already transformed `content` nodes representing the rest of its HTML content.
///
/// When the renderer has a ``RenderGoal/conciseness`` goal, it returns a plain `<h2>` element followed by the already transformed `content` nodes.
func selfReferencingSection(named sectionName: String, content: [XMLNode]) -> [XMLNode] {
guard !content.isEmpty else { return [] }

switch goal {
case .richness:
let id = urlReadableFragment(sectionName)

return [.element(
named: "section",
children: [
.element(named: "h2", children: [
.element(named: "a", children: [.text(sectionName)], attributes: ["href": "#\(id)"])
])
] + content,
attributes: ["id": id]
)]
case .conciseness:
return [.element(named: "h2", children: [.text(sectionName)]) as XMLNode] + content
}
}

/// Transforms a markdown emphasis into a`<i>` HTML element.
func visit(_ emphasis: Emphasis) -> XMLNode {
.element(named: "i", children: visit(emphasis.children))
Expand Down
159 changes: 159 additions & 0 deletions Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,165 @@ struct MarkdownRenderer_PageElementsTests {
}
}

@Test(arguments: RenderGoal.allCases)
func testRenderSingleLanguageParameters(goal: RenderGoal) {
let parameters = makeRenderer(goal: goal).parameters([
.swift: [
.init(name: "First", content: parseMarkup(string: "Some _formatted_ description with `code`")),
.init(name: "Second", content: parseMarkup(string: """
Some **other** _formatted_ description
That spans two paragraphs
""")),
]
])

switch goal {
case .richness:
parameters.assertMatches(prettyFormatted: true, expectedXMLString: """
<section id="Parameters">
<h2>
<a href="#Parameters">Parameters</a>
</h2>
<dl>
<dt>
<code>First</code>
</dt>
<dd>
<p>
Some <i>formatted</i> description with <code>code</code>
</p>
</dd>
<dt>
<code>Second</code>
</dt>
<dd>
<p>
Some <b>other</b> <i>formatted</i> description</p>
<p>That spans two paragraphs</p>
</dd>
</dl>
</section>
""")
case .conciseness:
parameters.assertMatches(prettyFormatted: true, expectedXMLString: """
<h2>Parameters</h2>
<dl>
<dt>
<code>First</code>
</dt>
<dd>
<p>Some <i>formatted</i>description with <code>code</code>
</p>
</dd>
<dt>
<code>Second</code>
</dt>
<dd>
<p>
Some <b>other</b> <i>formatted</i> description</p>
<p>That spans two paragraphs</p>
</dd>
</dl>
""")
}
}

@Test
func testRenderLanguageSpecificParameters() {
let parameters = makeRenderer(goal: .richness).parameters([
.swift: [
.init(name: "FirstCommon", content: parseMarkup(string: "Available in both languages")),
.init(name: "SwiftOnly", content: parseMarkup(string: "Only available in Swift")),
.init(name: "SecondCommon", content: parseMarkup(string: "Also available in both languages")),
],
.objectiveC: [
.init(name: "FirstCommon", content: parseMarkup(string: "Available in both languages")),
.init(name: "SecondCommon", content: parseMarkup(string: "Also available in both languages")),
.init(name: "ObjectiveCOnly", content: parseMarkup(string: "Only available in Objective-C")),
],
])
parameters.assertMatches(prettyFormatted: true, expectedXMLString: """
<section id="Parameters">
<h2>
<a href="#Parameters">Parameters</a>
</h2>
<dl>
<dt>
<code>FirstCommon</code>
</dt>
<dd>
<p>Available in both languages</p>
</dd>
<dt class="swift-only">
<code>SwiftOnly</code>
</dt>
<dd class="swift-only">
<p>Only available in Swift</p>
</dd>
<dt>
<code>SecondCommon</code>
</dt>
<dd>
<p>Also available in both languages</p>
</dd>
<dt class="occ-only">
<code>ObjectiveCOnly</code>
</dt>
<dd class="occ-only">
<p>Only available in Objective-C</p>
</dd>
</dl>
</section>
""")
}

@Test
func testRenderManyLanguageSpecificParameters() {
let parameters = makeRenderer(goal: .richness).parameters([
.swift: [
.init(name: "First", content: parseMarkup(string: "Some description")),
],
.objectiveC: [
.init(name: "Second", content: parseMarkup(string: "Some description")),
],
.data: [
.init(name: "Third", content: parseMarkup(string: "Some description")),
],
])
parameters.assertMatches(prettyFormatted: true, expectedXMLString: """
<section id="Parameters">
<h2>
<a href="#Parameters">Parameters</a>
</h2>
<dl class="swift-only">
<dt>
<code>First</code>
</dt>
<dd>
<p>Some description</p>
</dd>
</dl>
<dl class="data-only">
<dt>
<code>Third</code>
</dt>
<dd>
<p>Some description</p>
</dd>
</dl>
<dl class="occ-only">
<dt>
<code>Second</code>
</dt>
<dd>
<p>Some description</p>
</dd>
</dl>
</section>
""")
}

// MARK: -

private func makeRenderer(
Expand Down