From 9112efd50b3df415d1b551ebc1b16428bb7c5660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 4 Dec 2025 19:49:40 +0100 Subject: [PATCH 1/3] Add a helper function for rendering a parameters section as HTML rdar://163326857 --- Sources/DocCHTML/CMakeLists.txt | 1 + .../MarkdownRenderer+Parameters.swift | 143 ++++++++++++++++ Sources/DocCHTML/MarkdownRenderer.swift | 35 +++- .../MarkdownRenderer+PageElementsTests.swift | 159 ++++++++++++++++++ 4 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 Sources/DocCHTML/MarkdownRenderer+Parameters.swift diff --git a/Sources/DocCHTML/CMakeLists.txt b/Sources/DocCHTML/CMakeLists.txt index 590c395ef..552f25cf6 100644 --- a/Sources/DocCHTML/CMakeLists.txt +++ b/Sources/DocCHTML/CMakeLists.txt @@ -10,6 +10,7 @@ See https://swift.org/LICENSE.txt for license information add_library(DocCHTML STATIC LinkProvider.swift MarkdownRenderer+Availability.swift + MarkdownRenderer+Parameters.swift MarkdownRenderer.swift WordBreak.swift XMLNode+element.swift) diff --git a/Sources/DocCHTML/MarkdownRenderer+Parameters.swift b/Sources/DocCHTML/MarkdownRenderer+Parameters.swift new file mode 100644 index 000000000..c2de46de2 --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer+Parameters.swift @@ -0,0 +1,143 @@ +/* + 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 + package var content: [any Markup] + + package init(name: String, content: [any Markup]) { + self.name = name + self.content = content + } + } + + /// Creates a "parameters" section that described all parameters for a piece of API. + /// + /// If the API has different parameters in different language representations, 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 in 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 `
` and `
` HTML elements ("terms" and "definitions" in a "description list" (`
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 `
` and `
` 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. + // If that assumption is wrong, it will produce correct looking results but some repeated markup. + 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() + + // 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 exist 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 `
` and `
` 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) + } +} diff --git a/Sources/DocCHTML/MarkdownRenderer.swift b/Sources/DocCHTML/MarkdownRenderer.swift index 294d3b660..8ee60d8db 100644 --- a/Sources/DocCHTML/MarkdownRenderer.swift +++ b/Sources/DocCHTML/MarkdownRenderer.swift @@ -91,7 +91,7 @@ package struct MarkdownRenderer { ) } - /// Transforms a markdown heading into a`` HTML element whose content is wrapped in an `` element that references the heading itself. + /// Transforms a markdown heading into a`` HTML element whose content is wrapped in an `` 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 @@ -107,11 +107,14 @@ package struct MarkdownRenderer { /// /// ``` /// - /// - 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 `` HTML element whose content is wrapped in an `` 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: @@ -131,6 +134,34 @@ package struct MarkdownRenderer { } } + /// 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`
` HTML element. + /// The first child of that `
` HTML element is an `

` HTML element that wraps a `` HTML element that references the section. + /// After that `

` 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 `

` 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`` HTML element. func visit(_ emphasis: Emphasis) -> XMLNode { .element(named: "i", children: visit(emphasis.children)) diff --git a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift index a8a2619e3..3e291f3c8 100644 --- a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift +++ b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift @@ -48,6 +48,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: """ +
+

+ Parameters +

+
+
+ First +
+
+

+ Some formatted description with code +

+
+
+ Second +
+
+

+ Some other formatted description

+

That spans two paragraphs

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

Parameters

+
+
+ First +
+
+

Some formatteddescription with code +

+
+
+ Second +
+
+

+ Some other formatted description

+

That spans two paragraphs

+
+
+ """) + } + } + + @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: """ +
+

+ Parameters +

+
+
+ FirstCommon +
+
+

Available in both languages

+
+
+ SwiftOnly +
+
+

Only available in Swift

+
+
+ SecondCommon +
+
+

Also available in both languages

+
+
+ ObjectiveCOnly +
+
+

Only available in Objective-C

+
+
+
+ """) + } + + @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: """ +
+

+ Parameters +

+
+
+ First +
+
+

Some description

+
+
+
+
+ Third +
+
+

Some description

+
+
+
+
+ Second +
+
+

Some description

+
+
+
+ """) + } + // MARK: - private func makeRenderer( From dea4b914b9ec526420e1a87439ed8de73d274f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 5 Dec 2025 10:14:38 +0100 Subject: [PATCH 2/3] Apply wording suggestions in documentation comments Co-authored-by: Pat Shaughnessy Co-authored-by: Joseph Heck --- Sources/DocCHTML/MarkdownRenderer+Parameters.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/DocCHTML/MarkdownRenderer+Parameters.swift b/Sources/DocCHTML/MarkdownRenderer+Parameters.swift index c2de46de2..81e5b03b0 100644 --- a/Sources/DocCHTML/MarkdownRenderer+Parameters.swift +++ b/Sources/DocCHTML/MarkdownRenderer+Parameters.swift @@ -23,7 +23,7 @@ package extension MarkdownRenderer { struct ParameterInfo { /// The name of the parameter. package var name: String - /// The markdown content that describes + /// The markdown content that describes the parameter. package var content: [any Markup] package init(name: String, content: [any Markup]) { @@ -32,12 +32,12 @@ package extension MarkdownRenderer { } } - /// Creates a "parameters" section that described all parameters for a piece of API. + /// Creates a "parameters" section that describes all the parameters for a symbol. /// - /// If the API has different parameters in different language representations, pass each language representation's parameter information. + /// 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 in 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 (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 { @@ -124,7 +124,7 @@ package extension MarkdownRenderer { items[index + 1].addAttributes(["class": "\(primary.language.id)-only"]) } - // Insert parameter that only exist in the secondary language representation. + // 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 `
` and `
` HTML element) let index = (offset + primaryOnlyIndices.count(where: { $0 < offset })) * 2 From 31a040e10e2854a3bd0f22c215a4e38ab1403905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 5 Dec 2025 10:17:53 +0100 Subject: [PATCH 3/3] Add TODO comment about a debug assertion for the parameter name order --- Sources/DocCHTML/MarkdownRenderer+Parameters.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/DocCHTML/MarkdownRenderer+Parameters.swift b/Sources/DocCHTML/MarkdownRenderer+Parameters.swift index 81e5b03b0..46e2ac32b 100644 --- a/Sources/DocCHTML/MarkdownRenderer+Parameters.swift +++ b/Sources/DocCHTML/MarkdownRenderer+Parameters.swift @@ -108,6 +108,7 @@ package extension MarkdownRenderer { // 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. // 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.