From 8b86578286bd4aa825a9223e21d1c90e881e512c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 4 Dec 2025 18:31:35 +0100 Subject: [PATCH 01/12] Add a new target for rendering Markdown content into static HTML (#1369) * Add a new target for rendering content into static HTML rdar://163326857 * Include documentation comments with examples for the various `visit(_:)` methods. * Document what a `LinkedAsset` represents * Add code comments to explain how the inline HTML markup is parsed * Don't lowercase anchors for self-referencing headings * Avoid nested scopes when switching over names * Add a code comment about what an "autolink" is --- Package.swift | 24 +- Sources/DocCHTML/CMakeLists.txt | 23 + Sources/DocCHTML/LinkProvider.swift | 116 +++ Sources/DocCHTML/MarkdownRenderer.swift | 837 ++++++++++++++++++ Sources/DocCHTML/WordBreak.swift | 82 ++ Sources/DocCHTML/XMLNode+element.swift | 57 ++ .../DocCHTMLTests/MarkdownRendererTests.swift | 683 ++++++++++++++ Tests/DocCHTMLTests/WordBreakTests.swift | 86 ++ 8 files changed, 1907 insertions(+), 1 deletion(-) create mode 100644 Sources/DocCHTML/CMakeLists.txt create mode 100644 Sources/DocCHTML/LinkProvider.swift create mode 100644 Sources/DocCHTML/MarkdownRenderer.swift create mode 100644 Sources/DocCHTML/WordBreak.swift create mode 100644 Sources/DocCHTML/XMLNode+element.swift create mode 100644 Tests/DocCHTMLTests/MarkdownRendererTests.swift create mode 100644 Tests/DocCHTMLTests/WordBreakTests.swift diff --git a/Package.swift b/Package.swift index 3ceb324be8..f5162a432b 100644 --- a/Package.swift +++ b/Package.swift @@ -2,7 +2,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2021-2024 Apple Inc. and the Swift project authors + Copyright (c) 2021-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 @@ -122,6 +122,7 @@ let package = Package( // This target shouldn't have any local dependencies so that all other targets can depend on it. // We can add dependencies on SymbolKit and Markdown here but they're not needed yet. ], + exclude: ["CMakeLists.txt"], swiftSettings: [.swiftLanguageMode(.v6)] ), @@ -134,6 +135,27 @@ let package = Package( swiftSettings: [.swiftLanguageMode(.v6)] ), + .target( + name: "DocCHTML", + dependencies: [ + .target(name: "DocCCommon"), + .product(name: "Markdown", package: "swift-markdown"), + .product(name: "SymbolKit", package: "swift-docc-symbolkit"), + ], + exclude: ["CMakeLists.txt"], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "DocCHTMLTests", + dependencies: [ + .target(name: "DocCHTML"), + .target(name: "SwiftDocC"), + .product(name: "Markdown", package: "swift-markdown"), + .target(name: "SwiftDocCTestUtilities"), + ], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + // Test app for SwiftDocCUtilities .executableTarget( name: "signal-test-app", diff --git a/Sources/DocCHTML/CMakeLists.txt b/Sources/DocCHTML/CMakeLists.txt new file mode 100644 index 0000000000..3af4f6d1ff --- /dev/null +++ b/Sources/DocCHTML/CMakeLists.txt @@ -0,0 +1,23 @@ +#[[ +This source file is part of the Swift open source project + +Copyright © 2014 - 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 +#]] + +add_library(DocCHTML STATIC + LinkProvider.swift + MarkdownRenderer.swift + WordBreak.swift + XMLNode+element.swift) +target_link_libraries(DocCHTML PRIVATE + DocCCommon) +target_link_libraries(DocCHTML PUBLIC + SwiftMarkdown::Markdown + DocC::SymbolKit) +# FIXME(compnerd) workaround leaking dependencies +target_link_libraries(DocCHTML PUBLIC + libcmark-gfm + libcmark-gfm-extensions) diff --git a/Sources/DocCHTML/LinkProvider.swift b/Sources/DocCHTML/LinkProvider.swift new file mode 100644 index 0000000000..8125970a53 --- /dev/null +++ b/Sources/DocCHTML/LinkProvider.swift @@ -0,0 +1,116 @@ +/* + 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 +*/ + +package import Foundation +package import Markdown +package import DocCCommon + +/// A type that provides information about other pages, and on-page elements, that the rendered page references. +package protocol LinkProvider { + /// Provide information about another page or on-page element, or `nil` if the other page can't be found. + func element(for path: URL) -> LinkedElement? + + /// Provide the path for a symbol based on its unique identifier, or `nil` if the other symbol with that identifier can't be found. + func pathForSymbolID(_ usr: String) -> URL? + + /// Provide information about an asset (for example an image or video), or `nil` if the asset can't be found. + func assetNamed(_ assetName: String) -> LinkedAsset? + + /// Fallback link text for a link string that the provider couldn't provide any information for. + func fallbackLinkText(linkString: String) -> String +} + +package struct LinkedElement { + /// The path within the output archive to the linked element. + package var path: URL + /// The names of the linked element, for display when the element is referenced in inline content. + /// + /// Articles, headings, tutorials, and similar pages have a ``Names/single/conceptual(_:)`` name. + /// Symbols can either have a ``Names/single/symbol(_:)`` name or have different names for each language representation (``Names/languageSpecificSymbol``). + package var names: Names + /// The subheadings of the linked element, for display when the element is referenced in either a Topics section, See Also section, or in a `@Links` directive. + /// + /// Articles, headings, tutorials, and similar pages have a ``Names/single/conceptual(_:)`` name. + /// Symbols can either have a ``Names/single/symbol(_:)`` name or have different names for each language representation (``Names/languageSpecificSymbol``). + package var subheadings: Subheadings + /// The abstract of the page—to be displayed in either a Topics section, See Also section, or in a `@Links` directive—or `nil` if the linked element doesn't have an abstract. + package var abstract: Paragraph? + + package init(path: URL, names: Names, subheadings: Subheadings, abstract: Paragraph?) { + self.path = path + self.names = names + self.subheadings = subheadings + self.abstract = abstract + } + + /// The single name or language-specific names to use when referring to a linked element in inline content. + package enum Names { + /// This element has the same name in all language representations + case single(Name) + /// This element is a symbol with different names in different languages. + /// + /// Because `@DisplayName` applies to all language representations, these language specific names are always the symbol's subheading declaration and should display in a monospaced font. + case languageSpecificSymbol([SourceLanguage: String]) + } + package enum Name { + /// The name refers to an article, heading, or custom `@DisplayName` and should display as regular text. + case conceptual(String) + /// The name refers to a symbol's subheading declaration and should display in a monospaced font. + case symbol(String) + } + + /// The single subheading or language-specific subheadings to use when referring to a linked element in either a Topics section, See Also section, or in a `@Links` directive. + package enum Subheadings { + /// This element has the same name in all language representations + case single(Subheading) + /// This element is a symbol with different names in different languages. + /// + /// Because `@DisplayName` applies to all language representations, these language specific names are always the symbol's subheading declaration and should display in a monospaced font. + case languageSpecificSymbol([SourceLanguage: [SymbolNameFragment]]) + } + package enum Subheading { + /// The name refers to an article, heading, or custom `@DisplayName` and should display as regular text. + case conceptual(String) + /// The name refers to a symbol's subheading declaration and should display in a monospaced font. + case symbol([SymbolNameFragment]) + } + + /// A fragment in a symbol's name + package struct SymbolNameFragment { + /// The textual spelling of this fragment + package var text: String + /// The kind of fragment + package var kind: Kind + + /// The display kind of a single symbol name fragment + package enum Kind: String { + case identifier, decorator + } + + package init(text: String, kind: Kind) { + self.text = text + self.kind = kind + } + } +} + +/// Information about a referenced image, video, or download asset that may be represented by more than one file for different color styles and display scales. +package struct LinkedAsset { + /// The path within the output archive to each file for this asset, grouped by their light/dark style and display scale. + package var files: [ColorStyle: [Int /* display scale*/: URL]] + + package init(files: [ColorStyle : [Int /* display scale*/: URL]]) { + self.files = files + } + + package enum ColorStyle: String { + case light, dark + } +} diff --git a/Sources/DocCHTML/MarkdownRenderer.swift b/Sources/DocCHTML/MarkdownRenderer.swift new file mode 100644 index 0000000000..294d3b6602 --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer.swift @@ -0,0 +1,837 @@ +/* + 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 +internal import struct Foundation.CharacterSet +#else +package import Foundation +#endif +package import Markdown + +/// The primary goal for the rendered HTML output. +package enum RenderGoal { + /// The rendered output should prioritize richness, optimizing for human consumption. + /// + /// The rendered output might include explicit work-breaks, syntax highlighted code, etc. + case richness + /// The minimalistic rendered output should prioritize conciseness, optimizing for consumption by machines such as SEO indexers or LLMs. + case conciseness +} + +/// An HTML renderer for DocC markdown content. +/// +/// Markdown elements that have different meaning depending on where they occur in the page structure (for example links in prose vs. links in topic sections) should be handled at a layer above this plain markdown renderer. +package struct MarkdownRenderer { + /// The path within the output archive to the page that this renderer renders. + let path: URL + /// The goal of the rendered HTML output. + let goal: RenderGoal + /// A type that provides information about other pages that the rendered page references. + let linkProvider: Provider + + package init(path: URL, goal: RenderGoal, linkProvider: Provider) { + self.path = path + self.goal = goal + self.linkProvider = linkProvider + } + + /// Transforms a markdown paragraph into a `

` HTML element. + /// + /// As part of transforming the paragraph, the renderer also transforms all of the its content recursively. + /// For example, the renderer transforms this markdown + /// ```md + /// Some _formatted_ text + /// ``` + /// into XML nodes representing this HTML structure + /// ```html + ///

Some formatted text

+ /// ``` + func visit(_ paragraph: Paragraph) -> XMLNode { + .element(named: "p", children: visit(paragraph.children)) + } + + /// Transforms a markdown block quote into a `
` HTML element that represents an "aside". + /// + /// As part of transforming the paragraph, the renderer also transforms all of its content recursively. + /// For example, the renderer transforms this markdown + /// ```md + /// > Note: Something noteworthy + /// ``` + /// into XML nodes representing this HTML structure + /// ``` + ///
+ ///

Note

+ ///

Something noteworthy

+ ///
+ /// ``` + func visit(_ blockQuote: BlockQuote) -> XMLNode { + let aside = Aside(blockQuote) + + var children: [XMLNode] = [ + .element(named: "p", children: [.text(aside.kind.displayName)], attributes: ["class": "label"]) + ] + for child in aside.content { + children.append(visit(child)) + } + + return .element( + named: "blockquote", + children: children, + attributes: ["class": "aside \(aside.kind.rawValue.lowercased())"] + ) + } + + /// Transforms a markdown heading into a`` HTML element whose content is wrapped in an `` 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 + /// ```md + /// # Some _Formatted_ text + /// ``` + /// into XML nodes representing this HTML structure + /// ``` + ///

+ /// + /// Some formattedtext + /// + ///

+ /// ``` + /// + /// - Note: When the renderer has a ``RenderGoal/conciseness`` goal, it doesn't wrap the headings content in an anchor. + package func visit(_ heading: Heading) -> XMLNode { + selfReferencingHeading(level: heading.level, content: visit(heading.children), plainTextTitle: heading.plainText) + } + + func selfReferencingHeading(level: Int, content: [XMLNode], plainTextTitle: @autoclosure () -> String) -> XMLElement { + switch goal { + case .conciseness: + return .element(named: "h\(level)", children: content) + + case .richness: + let id = urlReadableFragment(plainTextTitle()) + return .element( + named: "h\(level)", + children: [ + // Wrap the heading content in an anchor ... + .element(named: "a", children: content, attributes: ["href": "#\(id)"]) + ], + // ... that refers to the heading itself + attributes: ["id": id] + ) + } + } + + /// Transforms a markdown emphasis into a`` HTML element. + func visit(_ emphasis: Emphasis) -> XMLNode { + .element(named: "i", children: visit(emphasis.children)) + } + + /// Transforms a markdown strong into a`` HTML element. + func visit(_ strong: Strong) -> XMLNode { + .element(named: "b", children: visit(strong.children)) + } + + /// Transforms a markdown strikethrough into a`` HTML element. + func visit(_ strikethrough: Strikethrough) -> XMLNode { + .element(named: "s", children: visit(strikethrough.children)) + } + + /// Transforms a markdown inline code into a`` HTML element. + func visit(_ inlineCode: InlineCode) -> XMLNode { + .element(named: "code", children: [.text(inlineCode.code)]) + } + + /// Transforms a markdown text into an HTML escaped text node. + func visit(_ text: Text) -> XMLNode { + .text(text.string) + } + + /// Transforms a markdown line break into an empty`
` HTML element. + func visit(_: LineBreak) -> XMLNode { + .element(named: "br") + } + + /// Transforms a markdown line break into a single space. + func visit(_: SoftBreak) -> XMLNode { + .text(" ") // A soft line break doesn't actually break the content + } + + /// Transforms a markdown line break into an empty`
` HTML element. + func visit(_: ThematicBreak) -> XMLNode { + .element(named: "hr") + } + + private func _removeComments(from node: XMLNode) { + guard let element = node as? XMLElement, + let children = element.children + else { + return + } + + let withoutComments = children.filter { $0.kind != .comment } + element.setChildren(withoutComments) + + for child in withoutComments { + _removeComments(from: child) + } + } + + /// Transforms a block of HTML in the source markdown into XML nodes representing the same structure with all the comments removed. + func visit(_ html: HTMLBlock) -> XMLNode { + do { + let parsed = try XMLElement(xmlString: html.rawHTML) + _removeComments(from: parsed) + return parsed + } catch { + return .text("") + } + } + + /// Transforms an inline HTML tag in the source markdown into XML nodes representing the same structure with all the comments removed. + func visit(_ html: InlineHTML) -> XMLNode { + // Inline HTML is one tag at a time, meaning that the closing and opening tags are parsed separately + // Because of this, we can't parse it with `XMLElement` or `XMLParser`. + + // We assume that we want all tags except for comments + guard !html.rawHTML.hasPrefix("` that we'd want to exclude from the output. + // - An empty element like `
` or `
` that's complete on its own. + // - An element with children like `Something` that needs to be created out of multiple markup elements. + // + // FIXME: See if this can be extracted into 2 private functions to make the code easier to read. + // Because it may take multiple markdown elements to create an HTML element, we pop elements rather than iterating + var elements = Array(container) + outer: while !elements.isEmpty { + let element = elements.removeFirst() + + guard let start = element as? InlineHTML else { + // If the markup _isn't_ inline HTML we can simply visit it to transform it. + children.append(visit(element)) + continue + } + + // Otherwise, we need to determine how long this markdown element it. + var rawHTML = start.rawHTML + guard !rawHTML.hasPrefix("formatted paragraph.", + matches: "

This is a formatted paragraph.

" + ) + + assert( + rendering: "This
is a formattedparagraph.", + matches: "

This
is a formatted paragraph.

" + ) + + assert( + rendering: "This is a custom formatted paragraph.", + matches: "

This is a custom formatted paragraph.

" + ) + + // This markup doesn't properly close the `` tag (it uses an `` tag. + // In this case we drop both tags but not their content in between. This matches what DocC does for inline HTML with regards to the Render JSON output. + assert( + rendering: "This is a custom formatted paragraph.", + matches: "

This is a custom formatted paragraph.

" + ) + + // Any content _within_ HTML tags in the markdown isn't parsed as markdown content. + assert( + rendering: "This is a custom **not** formatted paragraph.", + matches: "

This is a custom **not** formatted paragraph.

" + ) + + assert( + rendering: """ +
+ Some summary + +

Some longer description

+
+ + """, + matches: """ +
+ Some summary +

Some longer description

+
+ """ + ) + } + private func assert( rendering markdownContent: String, elementToReturn: LinkedElement? = nil, From e7baa700e25958ad8707fdb683e9275b4af8e735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Mon, 8 Dec 2025 11:45:45 +0100 Subject: [PATCH 06/12] Add a helper function for rendering a returns sections as HTML (#1381) * Add a helper function for rendering a returns sections as HTML rdar://163326857 * Minor phrasing updates to the parameter section helper's documentation * Add new source file to Windows CMake file list * Correct grammar in documentation comments Co-authored-by: Pat Shaughnessy --------- Co-authored-by: Pat Shaughnessy --- Sources/DocCHTML/CMakeLists.txt | 1 + .../MarkdownRenderer+Parameters.swift | 4 +- .../DocCHTML/MarkdownRenderer+Returns.swift | 52 ++++++++++++++++ .../MarkdownRenderer+PageElementsTests.swift | 60 +++++++++++++++++++ 4 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 Sources/DocCHTML/MarkdownRenderer+Returns.swift diff --git a/Sources/DocCHTML/CMakeLists.txt b/Sources/DocCHTML/CMakeLists.txt index 531ccff0b1..6c15d451e5 100644 --- a/Sources/DocCHTML/CMakeLists.txt +++ b/Sources/DocCHTML/CMakeLists.txt @@ -12,6 +12,7 @@ add_library(DocCHTML STATIC MarkdownRenderer+Availability.swift MarkdownRenderer+Breadcrumbs.swift MarkdownRenderer+Parameters.swift + MarkdownRenderer+Returns.swift MarkdownRenderer.swift WordBreak.swift XMLNode+element.swift) diff --git a/Sources/DocCHTML/MarkdownRenderer+Parameters.swift b/Sources/DocCHTML/MarkdownRenderer+Parameters.swift index 46e2ac32b3..a316f6e498 100644 --- a/Sources/DocCHTML/MarkdownRenderer+Parameters.swift +++ b/Sources/DocCHTML/MarkdownRenderer+Parameters.swift @@ -34,9 +34,9 @@ package extension MarkdownRenderer { /// 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 each language representation of the symbol has its own language-specific parameters, pass the parameter information for all language representations. /// - /// If the API has the _same_ parameters in all language representations, only pass the parameters for one language. + /// If all language representations of the symbol have the _same_ parameters, only pass the parameter information 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) diff --git a/Sources/DocCHTML/MarkdownRenderer+Returns.swift b/Sources/DocCHTML/MarkdownRenderer+Returns.swift new file mode 100644 index 0000000000..f1599590d3 --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer+Returns.swift @@ -0,0 +1,52 @@ +/* + 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 { + /// Creates a "returns" section that describes all return values of a symbol. + /// + /// If each language representation of the symbol has its own language-specific return values, pass the return value content for all language representations. + /// + /// If all language representations of the symbol have the _same_ return value, only pass the return value content for one language. + /// This produces a "returns" section that doesn't hide the return value content for any of the languages (same as if the symbol only had one language representation) + func returns(_ languageSpecificSections: [SourceLanguage: [any Markup]]) -> [XMLNode] { + let info = RenderHelpers.sortedLanguageSpecificValues(languageSpecificSections) + let items: [XMLNode] = if info.count == 1 { + info.first!.value.map { visit($0) } + } else { + info.flatMap { language, content in + let attributes = ["class": "\(language.id)-only"] + // Most return sections only have 1 paragraph of content with 2 and 3 paragraphs being increasingly uncommon. + // Avoid wrapping that content in a `
` or other container element and instead add the language specific class attribute to each paragraph. + return content.map { markup in + let node = visit(markup) + if let element = node as? XMLElement { + element.addAttributes(attributes) + return element + } else { + // Any text _should_ already be contained in a markdown paragraph, but if the input is unexpected, wrap the raw text in a paragraph here. + return .element(named: "p", children: [node], attributes: attributes) + } + } + } + } + + return selfReferencingSection(named: "Return Value", content: items) + } +} diff --git a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift index 2ab9c7cd5d..51f943733c 100644 --- a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift +++ b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift @@ -269,6 +269,66 @@ struct MarkdownRenderer_PageElementsTests { """) } + @Test(arguments: RenderGoal.allCases) + func testRenderSingleLanguageReturnSections(goal: RenderGoal) { + let returns = makeRenderer(goal: goal).returns([ + .swift: parseMarkup(string: "First paragraph\n\nSecond paragraph") + ]) + + let commonHTML = """ +

First paragraph

+

Second paragraph

+ """ + + switch goal { + case .richness: + returns.assertMatches(prettyFormatted: true, expectedXMLString: """ +
+

+ Return Value +

+ \(commonHTML) +
+ """) + case .conciseness: + returns.assertMatches(prettyFormatted: true, expectedXMLString: """ +

Return Value

+ \(commonHTML) + """) + } + } + + @Test(arguments: RenderGoal.allCases) + func testRenderLanguageSpecificReturnSections(goal: RenderGoal) { + let returns = makeRenderer(goal: goal).returns([ + .swift: parseMarkup(string: "First paragraph\n\nSecond paragraph"), + .objectiveC: parseMarkup(string: "Other language's paragraph"), + ]) + + let commonHTML = """ +

First paragraph

+

Second paragraph

+

Other language’s paragraph

+ """ + + switch goal { + case .richness: + returns.assertMatches(prettyFormatted: true, expectedXMLString: """ +
+

+ Return Value +

+ \(commonHTML) +
+ """) + case .conciseness: + returns.assertMatches(prettyFormatted: true, expectedXMLString: """ +

Return Value

+ \(commonHTML) + """) + } + } + // MARK: - private func makeRenderer( From c3484611f8ad2af77def5f852910a28839495604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Tue, 9 Dec 2025 15:38:23 +0100 Subject: [PATCH 07/12] Add a helper function for rendering symbol declarations as HTML (#1384) * Add a helper function for rendering symbol declarations as HTML rdar://163326857 * Add code comments to describe why the concise declaration: - Only includes the primary language - Joins the declaration fragments into a single string --- Sources/DocCHTML/CMakeLists.txt | 1 + .../MarkdownRenderer+Declaration.swift | 78 +++++++++ .../MarkdownRenderer+PageElementsTests.swift | 153 ++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 Sources/DocCHTML/MarkdownRenderer+Declaration.swift diff --git a/Sources/DocCHTML/CMakeLists.txt b/Sources/DocCHTML/CMakeLists.txt index 6c15d451e5..23846123d4 100644 --- a/Sources/DocCHTML/CMakeLists.txt +++ b/Sources/DocCHTML/CMakeLists.txt @@ -11,6 +11,7 @@ add_library(DocCHTML STATIC LinkProvider.swift MarkdownRenderer+Availability.swift MarkdownRenderer+Breadcrumbs.swift + MarkdownRenderer+Declaration.swift MarkdownRenderer+Parameters.swift MarkdownRenderer+Returns.swift MarkdownRenderer.swift diff --git a/Sources/DocCHTML/MarkdownRenderer+Declaration.swift b/Sources/DocCHTML/MarkdownRenderer+Declaration.swift new file mode 100644 index 0000000000..11acbd83d9 --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer+Declaration.swift @@ -0,0 +1,78 @@ +/* + 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 DocCCommon +package import SymbolKit + +package extension MarkdownRenderer { + + typealias DeclarationFragment = SymbolGraph.Symbol.DeclarationFragments.Fragment + + /// Creates a`
` HTML element hierarchy that represents the symbol's language-specific declarations.
+    ///
+    /// When the renderer has a ``RenderGoal/richness`` goal, it creates a `` element for each declaration fragment so that to enable syntax highlighting.
+    ///
+    /// When the renderer has a ``RenderGoal/conciseness`` goal, it joins the different fragments into string.
+    func declaration(_ fragmentsByLanguage: [SourceLanguage: [DeclarationFragment]]) -> XMLElement {
+        let fragmentsByLanguage = RenderHelpers.sortedLanguageSpecificValues(fragmentsByLanguage)
+        
+        guard goal == .richness else {
+            // On the rendered page, language specific content _could_ be hidden through CSS but that wouldn't help the tool that reads the raw HTML.
+            // So that tools don't need to filter out language specific content themselves, include only the primary language's (plain text) declaration.
+            let plainTextDeclaration: [XMLNode] = fragmentsByLanguage.first.map { _, fragments in
+                // The main purpose of individual HTML elements per declaration fragment would be syntax highlighting on the rendered page.
+                // That structure likely won't be beneficial (and could even be detrimental) to the tool's ability to consume the declaration information.
+                [.element(named: "code", children: [.text(fragments.map(\.spelling).joined())])]
+            } ?? []
+            return .element(named: "pre", children: plainTextDeclaration)
+        }
+        
+        let declarations: [XMLElement] = if fragmentsByLanguage.count == 1 {
+            // If there's only a single language there's no need to mark anything as language specific.
+            [XMLNode.element(named: "code", children: _declarationTokens(for: fragmentsByLanguage.first!.value))]
+        } else {
+            fragmentsByLanguage.map { language, fragments in
+                XMLNode.element(named: "code", children: _declarationTokens(for: fragments), attributes: ["class": "\(language.id)-only"])
+            }
+        }
+        return .element(named: "pre", children: declarations, attributes: ["id": "declaration"])
+    }
+    
+    private func _declarationTokens(for fragments: [DeclarationFragment]) -> [XMLNode] {
+        // TODO: Pretty print declarations for Swift and Objective-C by placing attributes and parameters on their own lines (rdar://165918402)
+        fragments.map { fragment in
+            let elementClass = "token-\(fragment.kind.rawValue)"
+            
+            if fragment.kind == .typeIdentifier,
+               let symbolID = fragment.preciseIdentifier,
+               let reference = linkProvider.pathForSymbolID(symbolID)
+            {
+                // If the token refers to a symbol that the `linkProvider` is aware of, make that fragment a link to that symbol.
+                return .element(named: "a", children: [.text(fragment.spelling)], attributes: [
+                    "href": path(to: reference),
+                    "class": elementClass
+                ])
+            } else if fragment.kind == .text {
+                // ???: Does text also need a  element or can that be avoided?
+                return .text(fragment.spelling)
+            } else {
+                // The declaration element is expected to scroll, so individual fragments don't need to contain explicit word breaks.
+                return .element(named: "span", children: [.text(fragment.spelling)], attributes: ["class": elementClass])
+            }
+        }
+    }
+}
diff --git a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift
index 51f943733c..c54c7751ee 100644
--- a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift
+++ b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift
@@ -328,6 +328,159 @@ struct MarkdownRenderer_PageElementsTests {
             """)
         }
     }
+
+    @Test(arguments: RenderGoal.allCases)
+    func testRenderSwiftDeclaration(goal: RenderGoal) {
+        let symbolPaths = [
+            "first-parameter-symbol-id":  URL(string: "/documentation/ModuleName/FirstParameterValue/index.html")!,
+            "second-parameter-symbol-id": URL(string: "/documentation/ModuleName/SecondParameterValue/index.html")!,
+            "return-value-symbol-id":     URL(string: "/documentation/ModuleName/ReturnValue/index.html")!,
+        ]
+        
+        let declaration = makeRenderer(goal: goal, pathsToReturn: symbolPaths).declaration([
+            .swift:  [
+                .init(kind: .keyword,           spelling: "func",        preciseIdentifier: nil),
+                .init(kind: .text,              spelling: " ",           preciseIdentifier: nil),
+                .init(kind: .identifier,        spelling: "doSomething", preciseIdentifier: nil),
+                .init(kind: .text,              spelling: "(",           preciseIdentifier: nil),
+                .init(kind: .externalParameter, spelling: "with",        preciseIdentifier: nil),
+                .init(kind: .text,              spelling: " ",           preciseIdentifier: nil),
+                .init(kind: .internalParameter, spelling: "first",       preciseIdentifier: nil),
+                .init(kind: .text,              spelling: ": ",          preciseIdentifier: nil),
+                .init(kind: .typeIdentifier,    spelling: "FirstParameterValue", preciseIdentifier: "first-parameter-symbol-id"),
+                .init(kind: .text,              spelling: ", ",          preciseIdentifier: nil),
+                .init(kind: .externalParameter, spelling: "and",         preciseIdentifier: nil),
+                .init(kind: .text,              spelling: " ",           preciseIdentifier: nil),
+                .init(kind: .internalParameter, spelling: "second",      preciseIdentifier: nil),
+                .init(kind: .text,              spelling: ": ",          preciseIdentifier: nil),
+                .init(kind: .typeIdentifier,    spelling: "SecondParameterValue", preciseIdentifier: "second-parameter-symbol-id"),
+                .init(kind: .text,              spelling: ") ",          preciseIdentifier: nil),
+                .init(kind: .keyword,           spelling: "throws",      preciseIdentifier: nil),
+                .init(kind: .text,              spelling: "-> ",         preciseIdentifier: nil),
+                .init(kind: .typeIdentifier,    spelling: "ReturnValue", preciseIdentifier: "return-value-symbol-id"),
+            ]
+        ])
+        switch goal {
+        case .richness:
+            declaration.assertMatches(prettyFormatted: true, expectedXMLString: """
+            
+            
+              func
+               doSomething
+              (with
+               first
+              : FirstParameterValue
+              , and
+               second
+              : SecondParameterValue
+              ) throws
+              -> ReturnValue
+            
+            
+ """) + case .conciseness: + declaration.assertMatches(prettyFormatted: true, expectedXMLString: """ +
+              func doSomething(with first: FirstParameterValue, and second: SecondParameterValue) throws-> ReturnValue
+            
+ """) + } + } + + @Test(arguments: RenderGoal.allCases) + func testRenderLanguageSpecificDeclarations(goal: RenderGoal) { + let symbolPaths = [ + "first-parameter-symbol-id": URL(string: "/documentation/ModuleName/FirstParameterValue/index.html")!, + "second-parameter-symbol-id": URL(string: "/documentation/ModuleName/SecondParameterValue/index.html")!, + "return-value-symbol-id": URL(string: "/documentation/ModuleName/ReturnValue/index.html")!, + "error-parameter-symbol-id": URL(string: "/documentation/Foundation/NSError/index.html")!, + ] + + let declaration = makeRenderer(goal: goal, pathsToReturn: symbolPaths).declaration([ + .swift: [ + .init(kind: .keyword, spelling: "func", preciseIdentifier: nil), + .init(kind: .text, spelling: " ", preciseIdentifier: nil), + .init(kind: .identifier, spelling: "doSomething", preciseIdentifier: nil), + .init(kind: .text, spelling: "(", preciseIdentifier: nil), + .init(kind: .externalParameter, spelling: "with", preciseIdentifier: nil), + .init(kind: .text, spelling: " ", preciseIdentifier: nil), + .init(kind: .internalParameter, spelling: "first", preciseIdentifier: nil), + .init(kind: .text, spelling: ": ", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "FirstParameterValue", preciseIdentifier: "first-parameter-symbol-id"), + .init(kind: .text, spelling: ", ", preciseIdentifier: nil), + .init(kind: .externalParameter, spelling: "and", preciseIdentifier: nil), + .init(kind: .text, spelling: " ", preciseIdentifier: nil), + .init(kind: .internalParameter, spelling: "second", preciseIdentifier: nil), + .init(kind: .text, spelling: ": ", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "SecondParameterValue", preciseIdentifier: "second-parameter-symbol-id"), + .init(kind: .text, spelling: ") ", preciseIdentifier: nil), + .init(kind: .keyword, spelling: "throws", preciseIdentifier: nil), + .init(kind: .text, spelling: "-> ", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "ReturnValue", preciseIdentifier: "return-value-symbol-id"), + ], + + .objectiveC: [ + .init(kind: .text, spelling: "- (", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "ReturnValue", preciseIdentifier: "return-value-symbol-id"), + .init(kind: .text, spelling: ") ", preciseIdentifier: nil), + .init(kind: .identifier, spelling: "doSomethingWithFirst", preciseIdentifier: nil), + .init(kind: .text, spelling: ": (", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "FirstParameterValue", preciseIdentifier: "first-parameter-symbol-id"), + .init(kind: .text, spelling: ") ", preciseIdentifier: nil), + .init(kind: .internalParameter, spelling: "first", preciseIdentifier: nil), + .init(kind: .text, spelling: " ", preciseIdentifier: nil), + .init(kind: .identifier, spelling: "andSecond", preciseIdentifier: nil), + .init(kind: .text, spelling: ": (", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "SecondParameterValue", preciseIdentifier: "second-parameter-symbol-id"), + .init(kind: .text, spelling: ") ", preciseIdentifier: nil), + .init(kind: .internalParameter, spelling: "second", preciseIdentifier: nil), + .init(kind: .text, spelling: " ", preciseIdentifier: nil), + .init(kind: .identifier, spelling: "error", preciseIdentifier: nil), + .init(kind: .text, spelling: ": (", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "NSError", preciseIdentifier: "error-parameter-symbol-id"), + .init(kind: .text, spelling: " **) ", preciseIdentifier: nil), + .init(kind: .internalParameter, spelling: "error", preciseIdentifier: nil), + .init(kind: .text, spelling: ";", preciseIdentifier: nil), + ] + ]) + switch goal { + case .richness: + declaration.assertMatches(prettyFormatted: true, expectedXMLString: """ +
+            
+              func
+               doSomething
+              (with
+               first
+              : FirstParameterValue
+              , and
+               second
+              : SecondParameterValue
+              ) throws
+              -> ReturnValue
+            
+            - (ReturnValue
+              ) doSomethingWithFirst
+              : (FirstParameterValue
+              ) first
+               andSecond
+              : (SecondParameterValue
+              ) second
+               error
+              : (NSError
+               **) error
+              ;
+            
+ """) + + case .conciseness: + declaration.assertMatches(prettyFormatted: true, expectedXMLString: """ +
+              func doSomething(with first: FirstParameterValue, and second: SecondParameterValue) throws-> ReturnValue
+            
+ """) + } + } // MARK: - From cf2099665d8ba1504bceae93d62b4fb92343bb76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Tue, 9 Dec 2025 16:31:19 +0100 Subject: [PATCH 08/12] Add a helper function for rendering a Topics/SeeAlso section as HTML (#1382) * Add a helper function for rendering a Topics/SeeAlso section as HTML rdar://163326857 * Add more code comments and internal documentation comments * Very slightly correct the test data --- Sources/DocCHTML/CMakeLists.txt | 1 + .../DocCHTML/MarkdownRenderer+Topics.swift | 168 ++++++++++++++++++ .../MarkdownRenderer+PageElementsTests.swift | 161 ++++++++++++++++- 3 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 Sources/DocCHTML/MarkdownRenderer+Topics.swift diff --git a/Sources/DocCHTML/CMakeLists.txt b/Sources/DocCHTML/CMakeLists.txt index 23846123d4..bf168988c5 100644 --- a/Sources/DocCHTML/CMakeLists.txt +++ b/Sources/DocCHTML/CMakeLists.txt @@ -14,6 +14,7 @@ add_library(DocCHTML STATIC MarkdownRenderer+Declaration.swift MarkdownRenderer+Parameters.swift MarkdownRenderer+Returns.swift + MarkdownRenderer+Topics.swift MarkdownRenderer.swift WordBreak.swift XMLNode+element.swift) diff --git a/Sources/DocCHTML/MarkdownRenderer+Topics.swift b/Sources/DocCHTML/MarkdownRenderer+Topics.swift new file mode 100644 index 0000000000..6e1e62487f --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer+Topics.swift @@ -0,0 +1,168 @@ +/* + 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 its own task groups, pass the task groups for each language representation. + /// + /// If the API has the _same_ task groups in all language representations, only pass the task groups for one language. + /// This produces a named section that doesn't hide any task groups 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] { + 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)): + 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 { + // On the rendered page, language specific symbol names _could_ be hidden through CSS but that wouldn't help the tool that reads the raw HTML. + // So that tools don't need to filter out language specific names themselves, include only the primary language's subheading. + [ _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)]) + ]) + } + + /// Transforms the symbol name fragments into a `` HTML element that represents a symbol's subheading. + /// + /// When the renderer has a ``RenderGoal/richness`` goal, it creates one `` HTML element per fragment that could be styled differently through CSS: + /// ``` + /// + /// class + /// SomeClass + /// + /// ``` + /// + /// When the renderer has a ``RenderGoal/conciseness`` goal, it joins the fragment's text into a single string: + /// ``` + /// class SomeClass + /// ``` + private func _symbolSubheading(_ fragments: [LinkedElement.SymbolNameFragment], languageFilter: SourceLanguage?) -> XMLElement { + 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"] } + ) + } + } +} diff --git a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift index c54c7751ee..199a88a9b7 100644 --- a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift +++ b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift @@ -42,7 +42,7 @@ struct MarkdownRenderer_PageElementsTests { .init(text: "Something", kind: .identifier), ], .objectiveC: [ - .init(text: "class ", kind: .decorator), + .init(text: "@interface ", kind: .decorator), .init(text: "TLASomething", kind: .identifier), ], ]), @@ -482,6 +482,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: "@interface ", 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: [ + .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: """ +
+

+ \(expectedGroupTitle) +

+

+ Group title +

+

Some description of this group

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

\(expectedGroupTitle)

+

Group title

+

Some description of this group

+ + """) + } + } + // MARK: - private func makeRenderer( From b5fec255b42f42efa1d016e9c4ab502d65e6bd79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 11 Dec 2025 10:31:43 +0100 Subject: [PATCH 09/12] Minimally integrate per-page HTML content into each "index.html" file (#1383) * Minimally integrate per-page HTML content into each "index.html" file rdar://163326857 * Extract some common code into private helpers * Move a few common checks into the new private helpers --- Package.swift | 1 + Sources/SwiftDocC/CMakeLists.txt | 4 + .../ConvertActionConverter.swift | 31 +- .../ConvertOutputConsumer.swift | 2 - .../Rendering/HTML/HTMLContentConsumer.swift | 40 +++ .../Model/Rendering/HTML/HTMLRenderer.swift | 336 ++++++++++++++++++ .../SymbolGraphCreation.swift | 17 +- .../Actions/Convert/ConvertAction.swift | 1 + .../FileWritingHTMLContentConsumer.swift | 119 +++++++ .../JSONEncodingRenderNodeWriter.swift | 63 ++-- Sources/SwiftDocCUtilities/CMakeLists.txt | 1 + ...recatedDiagnosticsDigestWarningTests.swift | 2 + .../Indexing/ExternalRenderNodeTests.swift | 1 + .../TestRenderNodeOutputConsumer.swift | 1 + .../FileWritingHTMLContentConsumerTests.swift | 318 +++++++++++++++++ .../MergeActionTests.swift | 2 +- 16 files changed, 900 insertions(+), 39 deletions(-) create mode 100644 Sources/SwiftDocC/Model/Rendering/HTML/HTMLContentConsumer.swift create mode 100644 Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift create mode 100644 Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift create mode 100644 Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift diff --git a/Package.swift b/Package.swift index f5162a432b..86249f0bf4 100644 --- a/Package.swift +++ b/Package.swift @@ -44,6 +44,7 @@ let package = Package( name: "SwiftDocC", dependencies: [ .target(name: "DocCCommon"), + .target(name: "DocCHTML"), .product(name: "Markdown", package: "swift-markdown"), .product(name: "SymbolKit", package: "swift-docc-symbolkit"), .product(name: "CLMDB", package: "swift-lmdb"), diff --git a/Sources/SwiftDocC/CMakeLists.txt b/Sources/SwiftDocC/CMakeLists.txt index c5e6ef0e26..ce74f882ea 100644 --- a/Sources/SwiftDocC/CMakeLists.txt +++ b/Sources/SwiftDocC/CMakeLists.txt @@ -175,6 +175,8 @@ add_library(SwiftDocC Model/Rendering/Diffing/Differences.swift Model/Rendering/Diffing/RenderNode+Diffable.swift Model/Rendering/DocumentationContentRenderer.swift + Model/Rendering/HTML/HTMLContentConsumer.swift + Model/Rendering/HTML/HTMLRenderer.swift Model/Rendering/LinkTitleResolver.swift "Model/Rendering/Navigation Tree/RenderHierarchy.swift" "Model/Rendering/Navigation Tree/RenderHierarchyChapter.swift" @@ -465,6 +467,8 @@ add_library(SwiftDocC Utility/Version.swift) target_link_libraries(SwiftDocC PRIVATE DocCCommon) +target_link_libraries(SwiftDocC PRIVATE + DocCHTML) target_link_libraries(SwiftDocC PUBLIC SwiftMarkdown::Markdown DocC::SymbolKit diff --git a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift index c18d64004b..d82fcce906 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift @@ -26,6 +26,7 @@ package enum ConvertActionConverter { /// - Parameters: /// - context: The context that the bundle is a part of. /// - outputConsumer: The consumer that the conversion passes outputs of the conversion to. + /// - htmlContentConsumer: The consumer for HTML content that the conversion produces, or `nil` if the conversion shouldn't produce any HTML content. /// - sourceRepository: The source repository where the documentation's sources are hosted. /// - emitDigest: Whether the conversion should pass additional metadata output––such as linkable entities information, indexing information, or asset references by asset type––to the consumer. /// - documentationCoverageOptions: The level of experimental documentation coverage information that the conversion should pass to the consumer. @@ -33,6 +34,7 @@ package enum ConvertActionConverter { package static func convert( context: DocumentationContext, outputConsumer: some ConvertOutputConsumer & ExternalNodeConsumer, + htmlContentConsumer: (any HTMLContentConsumer)?, sourceRepository: SourceRepository?, emitDigest: Bool, documentationCoverageOptions: DocumentationCoverageOptions @@ -103,7 +105,7 @@ package enum ConvertActionConverter { let renderSignpostHandle = signposter.beginInterval("Render", id: signposter.makeSignpostID(), "Render \(context.knownPages.count) pages") - var conversionProblems: [Problem] = context.knownPages.concurrentPerform { identifier, results in + var conversionProblems: [Problem] = context.knownPages.concurrentPerform { [htmlContentConsumer] identifier, results in // If cancelled skip all concurrent conversion work in this block. guard !Task.isCancelled else { return } @@ -111,7 +113,19 @@ package enum ConvertActionConverter { autoreleasepool { do { let entity = try context.entity(with: identifier) - + + if let htmlContentConsumer { + var renderer = HTMLRenderer(reference: identifier, context: context, goal: .conciseness) + + if let symbol = entity.semantic as? Symbol { + let renderedPageInfo = renderer.renderSymbol(symbol) + try htmlContentConsumer.consume(pageInfo: renderedPageInfo, forPage: identifier) + } else if let article = entity.semantic as? Article { + let renderedPageInfo = renderer.renderArticle(article) + try htmlContentConsumer.consume(pageInfo: renderedPageInfo, forPage: identifier) + } + } + guard let renderNode = converter.renderNode(for: entity) else { // No render node was produced for this entity, so just skip it. return @@ -247,3 +261,16 @@ package enum ConvertActionConverter { return conversionProblems } } + +private extension HTMLContentConsumer { + func consume(pageInfo: HTMLRenderer.RenderedPageInfo, forPage reference: ResolvedTopicReference) throws { + try consume( + mainContent: pageInfo.content, + metadata: ( + title: pageInfo.metadata.title, + description: pageInfo.metadata.plainDescription + ), + forPage: reference + ) + } +} diff --git a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift index f5e1ebd432..288e84334e 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift @@ -8,8 +8,6 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -import Foundation - /// A consumer for output produced by a documentation conversion. /// /// Types that conform to this protocol manage what to do with documentation conversion products, for example persist them to disk diff --git a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLContentConsumer.swift b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLContentConsumer.swift new file mode 100644 index 0000000000..877e6932dd --- /dev/null +++ b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLContentConsumer.swift @@ -0,0 +1,40 @@ +/* + 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 + +/// A consumer for HTML content produced during documentation conversion. +package protocol HTMLContentConsumer { + // One reason that this is its own protocol, rather than an extension of ConvertOutputConsumer, is so that we can avoid exposing `XMLNode` in any public API. + // That way, we are completely free to replace the entire internal HTML rendering implementation with something else in the future, without breaking API. + + /// Consumes the HTML content and metadata for a given page. + /// + /// The content and metadata doesn't make up a full valid HTML page. + /// It's the consumers responsibility to insert the information into a template or skeletal structure to produce a valid HTML file for each page. + /// + /// - Parameters: + /// - mainContent: The contents for this page as an XHTML node. + /// - metadata: Metadata information (title and description) about this page. + /// - reference: The resolved topic reference that identifies this page. + func consume( + mainContent: XMLNode, + metadata: ( + title: String, + description: String? + ), + forPage reference: ResolvedTopicReference + ) throws +} diff --git a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift new file mode 100644 index 0000000000..fae8cddcb5 --- /dev/null +++ b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift @@ -0,0 +1,336 @@ +/* + 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) +import FoundationXML +import FoundationEssentials +#else +import Foundation +#endif +import DocCHTML +import Markdown +import SymbolKit + +/// A link provider that provider structured information about resolved links and assets to the underlying HTML renderer. +private struct ContextLinkProvider: LinkProvider { + let reference: ResolvedTopicReference + let context: DocumentationContext + let goal: RenderGoal + + func element(for url: URL) -> LinkedElement? { + guard url.scheme == "doc", + let rawBundleID = url.host, + // TODO: Support returning information about external pages (rdar://165912415) + let node = context.documentationCache[ResolvedTopicReference(bundleID: .init(rawValue: rawBundleID), path: url.path, fragment: url.fragment, sourceLanguage: .swift /* The reference's language doesn't matter */)] + else { + return nil + } + + // A helper function that transforms SymbolKit fragments into renderable identifier/decorator fragments + func convert(_ fragments: [SymbolGraph.Symbol.DeclarationFragments.Fragment]) -> [LinkedElement.SymbolNameFragment] { + func convert(kind: SymbolGraph.Symbol.DeclarationFragments.Fragment.Kind) -> LinkedElement.SymbolNameFragment.Kind { + switch kind { + case .identifier, .externalParameter: .identifier + default: .decorator + } + } + guard var current = fragments.first.map({ LinkedElement.SymbolNameFragment(text: $0.spelling, kind: convert(kind: $0.kind)) }) else { + return [] + } + + // Join together multiple fragments of the same identifier/decorator kind to produce a smaller output. + var result: [LinkedElement.SymbolNameFragment] = [] + for fragment in fragments.dropFirst() { + let kind = convert(kind: fragment.kind) + if kind == current.kind { + current.text += fragment.spelling + } else { + result.append(current) + current = .init(text: fragment.spelling, kind: kind) + } + } + result.append(current) + return result + } + + let subheadings: LinkedElement.Subheadings = if let symbol = node.semantic as? Symbol { + switch symbol.subHeadingVariants.values(goal: goal) { + case .single(let subHeading): + .single(.symbol(convert(subHeading))) + case .languageSpecific(let subHeadings): + .languageSpecificSymbol(subHeadings.mapValues(convert)) + case .empty: + // This shouldn't happen but because of a shortcoming in the API design of `DocumentationDataVariants`, it can't be guaranteed. + .single(.symbol([])) + } + } else { + .single(.conceptual(node.name.plainText)) + } + + return .init( + path: Self.filePath(for: node.reference), + names: node.makeNames(goal: goal), + subheadings: subheadings, + abstract: (node.semantic as? (any Abstracted))?.abstract + ) + } + + func pathForSymbolID(_ usr: String) -> URL? { + context.localOrExternalReference(symbolID: usr).map { + Self.filePath(for: $0) + } + } + + func assetNamed(_ assetName: String) -> LinkedAsset? { + guard let asset = context.resolveAsset(named: assetName, in: reference) else { + // The context + return nil + } + + var files = [LinkedAsset.ColorStyle: [Int: URL]]() + for (traits, url) in asset.variants { + let scale = (traits.displayScale ?? .standard).scaleFactor + + files[traits.userInterfaceStyle == .dark ? .dark : .light, default: [:]][scale] = url + } + + return .init(files: files) + } + + func fallbackLinkText(linkString: String) -> String { + // For unresolved links, especially to symbols, prefer to display only the the last link component without its disambiguation + PathHierarchy.PathParser.parse(path: linkString).components.last.map { String($0.name) } ?? linkString + } + + static func filePath(for reference: ResolvedTopicReference) -> URL { + reference.url.withoutHostAndPortAndScheme().appendingPathComponent("index.html") + } +} + +// MARK: HTML Renderer + +/// A type that renders documentation pages into semantic HTML elements. +struct HTMLRenderer { + let reference: ResolvedTopicReference + let context: DocumentationContext + let goal: RenderGoal + + private let renderer: MarkdownRenderer + + init(reference: ResolvedTopicReference, context: DocumentationContext, goal: RenderGoal) { + self.reference = reference + self.context = context + self.goal = goal + self.renderer = MarkdownRenderer( + path: ContextLinkProvider.filePath(for: reference), + goal: goal, + linkProvider: ContextLinkProvider(reference: reference, context: context, goal: goal) + ) + } + + /// Information about a rendered page + struct RenderedPageInfo { + /// The HTML content of the page as an XMLNode hierarchy. + /// + /// The string representation of this node hierarchy is intended to be inserted _somewhere_ inside the `` HTML element. + /// It _doesn't_ include a page header, footer, navigator, etc. and may be an insufficient representation of the "entire" page + var content: XMLNode + /// The title and description/abstract of the page. + var metadata: Metadata + /// Meta information about the page that belongs in the HTML `` element. + struct Metadata { + /// The plain text title of this page, suitable as content for the HTML `` element. + var title: String + /// The plain text description/abstract of this page, suitable a data for a `<meta>` element for sharing purposes. + var plainDescription: String? + } + } + + mutating func renderArticle(_ article: Article) -> RenderedPageInfo { + let node = context.documentationCache[reference]! + + let main = XMLElement(name: "main") + let articleElement = XMLElement(name: "article") + main.addChild(articleElement) + + let hero = XMLElement(name: "section") + articleElement.addChild(hero) + + // Title + hero.addChild( + .element(named: "h1", children: [.text(node.name.plainText)]) + ) + + // Abstract + if let abstract = article.abstract { + let paragraph = renderer.visit(abstract) as! XMLElement + if goal == .richness { + paragraph.addAttribute(XMLNode.attribute(withName: "id", stringValue: "abstract") as! XMLNode) + } + hero.addChild(paragraph) + } + + return RenderedPageInfo( + content: goal == .richness ? main : articleElement, + metadata: .init( + title: article.title?.plainText ?? node.name.plainText, + plainDescription: article.abstract?.plainText + ) + ) + } + + mutating func renderSymbol(_ symbol: Symbol) -> RenderedPageInfo { + let main = XMLElement(name: "main") + let articleElement = XMLElement(name: "article") + main.addChild(articleElement) + + let hero = XMLElement(name: "section") + articleElement.addChild(hero) + + // Title + switch symbol.titleVariants.values(goal: goal) { + case .single(let title): + hero.addChild( + .element(named: "h1", children: renderer.wordBreak(symbolName: title)) + ) + case .languageSpecific(let languageSpecificTitles): + for (language, languageSpecificTitle) in languageSpecificTitles.sorted(by: { $0.key < $1.key }) { + hero.addChild( + .element(named: "h1", children: renderer.wordBreak(symbolName: languageSpecificTitle), attributes: ["class": "\(language.id)-only"]) + ) + } + case .empty: + // This shouldn't happen but because of a shortcoming in the API design of `DocumentationDataVariants`, it can't be guaranteed. + hero.addChild( + .element(named: "h1", children: renderer.wordBreak(symbolName: symbol.title /* This is internally force unwrapped */)) + ) + } + + // Abstract + if let abstract = symbol.abstract { + let paragraph = renderer.visit(abstract) as! XMLElement + if goal == .richness { + paragraph.addAttribute(XMLNode.attribute(withName: "id", stringValue: "abstract") as! XMLNode) + } + hero.addChild(paragraph) + } + + return RenderedPageInfo( + content: goal == .richness ? main : articleElement, + metadata: .init( + title: symbol.title, + plainDescription: symbol.abstract?.plainText + ) + ) + } + + // TODO: As a future enhancement, add another layer on top of this that creates complete HTML pages (both `<head>` and `<body>`) (rdar://165912669) +} + +// MARK: Helpers + +// Note; this isn't a Comparable conformance so that it can remain private to this file. +private extension DocumentationDataVariantsTrait { + static func < (lhs: DocumentationDataVariantsTrait, rhs: DocumentationDataVariantsTrait) -> Bool { + if let lhs = lhs.sourceLanguage { + if let rhs = rhs.sourceLanguage { + return lhs < rhs + } + return true // nil is after anything + } + return false // nil is after anything + } +} + +private extension XMLElement { + func addChildren(_ nodes: [XMLNode]) { + for node in nodes { + addChild(node) + } + } +} + +private extension DocumentationNode { + func makeNames(goal: RenderGoal) -> LinkedElement.Names { + switch name { + case .conceptual(let title): + // This node has a single "conceptual" name. + // It could either be an article or a symbol with an authored `@DisplayName`. + .single(.conceptual(title)) + case .symbol(let nodeTitle): + if let symbol = semantic as? Symbol { + symbol.makeNames(goal: goal, fallbackTitle: nodeTitle) + } else { + // This node has a symbol name, but for some reason doesn't have a symbol semantic. + // That's a bit strange and unexpected, but we can still make a single name for it. + .single(.symbol(nodeTitle)) + } + } + } +} + +private extension Symbol { + func makeNames(goal: RenderGoal, fallbackTitle: String) -> LinkedElement.Names { + switch titleVariants.values(goal: goal) { + case .single(let title): + .single(.symbol(title)) + case .languageSpecific(let titles): + .languageSpecificSymbol(titles) + case .empty: + // This shouldn't happen but because of a shortcoming in the API design of `DocumentationDataVariants`, it can't be guaranteed. + .single(.symbol(fallbackTitle)) + } + } +} + +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 +} + +// 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> { + guard let primaryValue = firstValue else { + return .empty + } + + guard goal == .richness else { + // On the rendered page, language specific symbol information _could_ be hidden through CSS but that wouldn't help the tool that reads the raw HTML. + // So that tools don't need to filter out language specific information themselves, include only the primary language's value. + return .single(primaryValue) + } + + let values = allValues + guard allValues.count > 1 else { + // Return a single value to simplify the caller's code + return .single(primaryValue) + } + + // 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 }) { + // There are multiple distinct values + return .languageSpecific([SourceLanguage: Variant]( + values.map { trait, value in + (trait.sourceLanguage ?? .swift, value) + }, uniquingKeysWith: { _, new in new } + )) + } else { + // There are multiple values, but the're all the same + return .single(primaryValue) + } + } +} diff --git a/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift b/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift index 6a15e4596b..8bd48ead94 100644 --- a/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift +++ b/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift @@ -90,6 +90,7 @@ extension XCTestCase { location: (position: SymbolGraph.LineList.SourceRange.Position, url: URL)? = (defaultSymbolPosition, defaultSymbolURL), signature: SymbolGraph.Symbol.FunctionSignature? = nil, availability: [SymbolGraph.Symbol.Availability.AvailabilityItem]? = nil, + declaration: [SymbolGraph.Symbol.DeclarationFragments.Fragment]? = nil, otherMixins: [any Mixin] = [] ) -> SymbolGraph.Symbol { precondition(!pathComponents.isEmpty, "Need at least one path component to name the symbol") @@ -104,10 +105,24 @@ extension XCTestCase { if let availability { mixins.append(SymbolGraph.Symbol.Availability(availability: availability)) } + if let declaration { + mixins.append(SymbolGraph.Symbol.DeclarationFragments(declarationFragments: declaration)) + } + + let names = if let declaration { + SymbolGraph.Symbol.Names( + title: pathComponents.last!, // Verified above to exist + navigator: declaration, + subHeading: declaration, + prose: nil + ) + } else { + makeSymbolNames(name: pathComponents.last!) // Verified above to exist + } return SymbolGraph.Symbol( identifier: SymbolGraph.Symbol.Identifier(precise: id, interfaceLanguage: language.id), - names: makeSymbolNames(name: pathComponents.last!), + names: names, pathComponents: pathComponents, docComment: docComment.map { makeLineList( diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift index b6c98ecedd..62d2baf744 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift @@ -320,6 +320,7 @@ public struct ConvertAction: AsyncAction { try ConvertActionConverter.convert( context: context, outputConsumer: outputConsumer, + htmlContentConsumer: nil, sourceRepository: sourceRepository, emitDigest: emitDigest, documentationCoverageOptions: documentationCoverageOptions diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift new file mode 100644 index 0000000000..2fcaf2eae7 --- /dev/null +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift @@ -0,0 +1,119 @@ +/* + 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) +import FoundationXML +import FoundationEssentials +#else +import Foundation +#endif + +import SwiftDocC +import DocCHTML + +struct FileWritingHTMLContentConsumer: HTMLContentConsumer { + var targetFolder: URL + var fileManager: any FileManagerProtocol + var prettyPrintOutput: Bool + + private struct HTMLTemplate { + var original: String + var contentReplacementRange: Range<String.Index> + var titleReplacementRange: Range<String.Index> + var descriptionReplacementRange: Range<String.Index> + + init(data: Data) throws { + let content = String(decoding: data, as: UTF8.self) + + // ???: Should we parse the content with XMLParser instead? If so, what do we do if it's not valid XHTML? + let noScriptStart = content.utf8.firstRange(of: "<noscript>".utf8)!.upperBound + let noScriptEnd = content.utf8.firstRange(of: "</noscript>".utf8)!.lowerBound + + let titleStart = content.utf8.firstRange(of: "<title>".utf8)!.upperBound + let titleEnd = content.utf8.firstRange(of: "".utf8)!.lowerBound + + let beforeHeadEnd = content.utf8.firstRange(of: "".utf8)!.lowerBound + + original = content + // TODO: If the template doesn't already contain a
@@ -264,6 +266,10 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {

Some article

This is an formatted article.

+

Custom discussion

+

It explains how a developer can perform some task using SomeClass in this module.

+

Details

+

This subsection describes something more detailed.

From 1dac8cac772deb4695b1e92f6a60a0f37d056a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 12 Dec 2025 16:05:43 +0100 Subject: [PATCH 11/12] Include Mentioned In and Relationships sections in the HTML output (#1391) --- Sources/DocCHTML/CMakeLists.txt | 1 + .../MarkdownRenderer+Relationships.swift | 106 ++++++++++++++++++ .../Model/Rendering/HTML/HTMLRenderer.swift | 57 +++++++++- .../FileWritingHTMLContentConsumerTests.swift | 79 +++++++++++-- 4 files changed, 231 insertions(+), 12 deletions(-) create mode 100644 Sources/DocCHTML/MarkdownRenderer+Relationships.swift diff --git a/Sources/DocCHTML/CMakeLists.txt b/Sources/DocCHTML/CMakeLists.txt index 786cf5626d..3652264887 100644 --- a/Sources/DocCHTML/CMakeLists.txt +++ b/Sources/DocCHTML/CMakeLists.txt @@ -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 diff --git a/Sources/DocCHTML/MarkdownRenderer+Relationships.swift b/Sources/DocCHTML/MarkdownRenderer+Relationships.swift new file mode 100644 index 0000000000..1a86f8824e --- /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 59b05046cf..e830202e38 100644 --- a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift +++ b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift @@ -230,6 +230,15 @@ 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( @@ -237,6 +246,25 @@ struct HTMLRenderer { ) } + // 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( @@ -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 { 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 } @@ -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 @@ -348,3 +393,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 fcaea8bd80..42d42ddfc5 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 - + +