diff --git a/Package.swift b/Package.swift index 3ceb324be8..b1d3e3fbd7 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"), @@ -122,6 +123,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 +136,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/CMakeLists.txt b/Sources/CMakeLists.txt index 840812426a..f021d6df4c 100644 --- a/Sources/CMakeLists.txt +++ b/Sources/CMakeLists.txt @@ -8,6 +8,7 @@ See https://swift.org/LICENSE.txt for license information #]] add_subdirectory(DocCCommon) +add_subdirectory(DocCHTML) add_subdirectory(SwiftDocC) add_subdirectory(SwiftDocCUtilities) add_subdirectory(docc) diff --git a/Sources/DocCHTML/CMakeLists.txt b/Sources/DocCHTML/CMakeLists.txt new file mode 100644 index 0000000000..996b373c4b --- /dev/null +++ b/Sources/DocCHTML/CMakeLists.txt @@ -0,0 +1,29 @@ +#[[ +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+Availability.swift + MarkdownRenderer+Breadcrumbs.swift + MarkdownRenderer+Declaration.swift + MarkdownRenderer+Parameters.swift + MarkdownRenderer+Returns.swift + MarkdownRenderer+Topics.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(SwiftDocC PUBLIC + libcmark-gfm + libcmark-gfm-extensions) diff --git a/Sources/DocCHTML/LinkProvider.swift b/Sources/DocCHTML/LinkProvider.swift new file mode 100644 index 0000000000..b44dda554f --- /dev/null +++ b/Sources/DocCHTML/LinkProvider.swift @@ -0,0 +1,115 @@ +/* + 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, 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 + } + } +} + +package struct LinkedAsset { + /// The path within the output archive to each image variant, by their light/dark style. + package var images: [ColorStyle: [Int /* display scale*/: URL]] + + package init(images: [ColorStyle : [Int : URL]]) { + self.images = images + } + + package enum ColorStyle: String { + case light, dark + } +} diff --git a/Sources/DocCHTML/MarkdownRenderer+Availability.swift b/Sources/DocCHTML/MarkdownRenderer+Availability.swift new file mode 100644 index 0000000000..3c47245240 --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer+Availability.swift @@ -0,0 +1,70 @@ +/* + 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) +// FIXME: See if we can avoid depending on XMLNode/XMLParser to avoid needing to import FoundationXML +package import FoundationXML +#else +package import Foundation +#endif + +package extension MarkdownRenderer { + struct AvailabilityInfo { + package var name: String + package var introduced, deprecated: String? + package var isBeta: Bool + + package init(name: String, introduced: String? = nil, deprecated: String? = nil, isBeta: Bool) { + self.name = name + self.introduced = introduced + self.deprecated = deprecated + self.isBeta = isBeta + } + } + + /// Creates an HTML element for the availability information. + func availability(_ info: [AvailabilityInfo]) -> XMLNode { + let items: [XMLNode] = info.map { + var text = $0.name + + let description: String + if let introduced = $0.introduced { + if let deprecated = $0.deprecated{ + text += " \(introduced)–\(deprecated)" + description = "Introduced in \($0.name) \(introduced) and deprecated in \($0.name) \(deprecated)" + } else { + text += " \(introduced)+" + description = "Available on \(introduced) and later" + } + } else { + description = "Available on \($0.name)" + } + + var attributes = [ + "role": "text", + "aria-label": "\(text), \(description)", + "title": description + ] + if $0.isBeta { + attributes["class"] = "beta" + } else if $0.deprecated != nil { + attributes["class"] = "deprecated" + } + + return .element(named: "li", children: [.text(text)], attributes: goal == .richness ? attributes : [:]) + } + + return .element( + named: "ul", + children: items, + attributes: ["id": "availability"] + ) + } +} diff --git a/Sources/DocCHTML/MarkdownRenderer+Breadcrumbs.swift b/Sources/DocCHTML/MarkdownRenderer+Breadcrumbs.swift new file mode 100644 index 0000000000..5016d307b3 --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer+Breadcrumbs.swift @@ -0,0 +1,72 @@ +/* + 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) +// FIXME: See if we can avoid depending on XMLNode/XMLParser to avoid needing to import FoundationXML +package import FoundationXML +package import FoundationEssentials +#else +package import Foundation +#endif + +package extension MarkdownRenderer { + /// Creates an HTML element for the breadcrumbs leading up to the renderer's reference. + func breadcrumbs(references: [URL], currentPageNames: LinkedElement.Names) -> XMLNode { + // Breadcrumbs handle symbols differently than most elements, so there's no point in sharing _this_ code + func nameElements(for names: LinkedElement.Names) -> [XMLNode] { + switch names { + // Breadcrumbs display both symbolic names and conceptual in a default style + case .single(.conceptual(let name)), .single(.symbol(let name)): + return [.text(name)] + + case .languageSpecificSymbol(let namesByLanguageID): + let names = RenderHelpers.sortedLanguageSpecificValues(namesByLanguageID) + return switch goal { + case .richness: + if names.count == 1 { + [.text(names.first!.value)] + } else { + names.map { language, name in + .element(named: "span", children: [ + .text(name) // Breadcrumbs display symbol names in a default style (no "code voice") + ], attributes: ["class": "\(language.id)-only"]) + } + } + case .conciseness: + // If the goal is conciseness, only display the primary language's name + names.first.map { _, name in + [.text(name)] + } ?? [] + } + } + } + + var items: [XMLNode] = references.compactMap { + linkProvider.element(for: $0).map { page in + .element(named: "li", children: [ + .element(named: "a", children: nameElements(for: page.names), attributes: ["href": self.path(to: page.path)]) + ]) + } + } + + // The current page doesn't display as a link + items.append( + .element(named: "li", children: nameElements(for: currentPageNames)) + ) + let list = XMLNode.element(named: "ul", children: items) + + return switch goal { + case .conciseness: + list + case .richness: + .element(named: "nav", children: [list], attributes: ["id": "breadcrumbs"]) + } + } +} diff --git a/Sources/DocCHTML/MarkdownRenderer+Declaration.swift b/Sources/DocCHTML/MarkdownRenderer+Declaration.swift new file mode 100644 index 0000000000..1596766d6f --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer+Declaration.swift @@ -0,0 +1,72 @@ +/* + 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) +// FIXME: See if we can avoid depending on XMLNode/XMLParser to avoid needing to import FoundationXML +package import FoundationXML +#else +package import Foundation +#endif + +package import DocCCommon +package import SymbolKit + +package extension MarkdownRenderer { + + typealias DeclarationFragment = SymbolGraph.Symbol.DeclarationFragments.Fragment + + func declaration(_ fragmentsByLanguage: [SourceLanguage: [DeclarationFragment]]) -> XMLElement { + let fragmentsByLanguage = RenderHelpers.sortedLanguageSpecificValues(fragmentsByLanguage) + + guard goal == .richness else { + // If the goal is conciseness, display only the primary language's plain text declaration in a block + let plainTextDeclaration: [XMLNode] = fragmentsByLanguage.first.map { _, fragments in + [.element(named: "code", children: [.text(fragments.map(\.spelling).joined())])] + } ?? [] + return .element(named: "pre", children: plainTextDeclaration) + } + + // Note: declarations scroll, so they don't need to word wrap within tokens + + let declarations: [XMLElement] = if fragmentsByLanguage.count == 1 { + [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] { + // FIXME: Pretty print declarations for Swift and Objective-C + + fragments.map { fragment in + let elementClass = "token-\(fragment.kind.rawValue)" + + if fragment.kind == .typeIdentifier, + let symbolID = fragment.preciseIdentifier, + let reference = linkProvider.pathForSymbolID(symbolID) + { + // Make a link + return .element(named: "a", children: [.text(fragment.spelling)], attributes: [ + "href": path(to: reference), + "class": elementClass + ]) + } + else if fragment.kind == .text { + // ???: Does text need a element? + return .text(fragment.spelling) + } else { + return .element(named: "span", children: [.text(fragment.spelling)], attributes: ["class": elementClass]) + } + } + } +} diff --git a/Sources/DocCHTML/MarkdownRenderer+Parameters.swift b/Sources/DocCHTML/MarkdownRenderer+Parameters.swift new file mode 100644 index 0000000000..a173807036 --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer+Parameters.swift @@ -0,0 +1,120 @@ +/* + 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) +// FIXME: See if we can avoid depending on XMLNode/XMLParser to avoid needing to import FoundationXML +package import FoundationXML +#else +package import Foundation +#endif + +package import Markdown +package import DocCCommon + +package extension MarkdownRenderer { + struct ParameterInfo { + package var name: String + package var content: [any Markup] + + package init(name: String, content: [any Markup]) { + self.name = name + self.content = content + } + } + + func parameters(_ info: [SourceLanguage: [ParameterInfo]]) -> [XMLNode] { + let info = RenderHelpers.sortedLanguageSpecificValues(info) + guard info.contains(where: { _, params in !params.isEmpty }) else { return [] } + + let items: [XMLElement] = switch info.count { + case 1: + [_singleLanguageParameters(info.first!.value)] // Verified to exist above + + case 2: + [_dualLanguageParameters(primary: info.first!, secondary: info.last!)] // Both verified to exist above + + 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] { + 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) + + var items = _singleLanguageParameterItems(primary.parameters) + + let differences = secondary.parameters.difference(from: primary.parameters, by: { $0.name == $1.name }) + + var primaryOnlyIndices = Set() + + 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"]) + } + + for case let .insert(offset, parameter, _) in differences.insertions { + // This parameter only exist in the secondary parameters. + let index = (offset + primaryOnlyIndices.count(where: { $0 < offset })) * 2 + // Description first because we're appending twice + 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+Returns.swift b/Sources/DocCHTML/MarkdownRenderer+Returns.swift new file mode 100644 index 0000000000..5491aea3b5 --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer+Returns.swift @@ -0,0 +1,98 @@ +/* + 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) +// FIXME: See if we can avoid depending on XMLNode/XMLParser to avoid needing to import FoundationXML +package import FoundationXML +internal import struct Foundation.CharacterSet +#else +package import Foundation +#endif + +package import Markdown +package import DocCCommon + +package extension MarkdownRenderer { + 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) } // Verified to exist above + } 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 div 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 it here. + return .element(named: "p", children: [node], attributes: attributes) + } + } + } + } + + return selfReferencingSection(named: "Return Value", content: items) + } + + func selfReferencingSection(named sectionName: String, content: [XMLNode]) -> [XMLNode] { + guard !content.isEmpty else { return [] } + + switch goal { + case .richness: + let id = urlReadableFragment(sectionName.lowercased()) + + 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 + } + } +} + +private extension CharacterSet { + // For fragments + static let fragmentCharactersToRemove = CharacterSet.punctuationCharacters // Remove punctuation from fragments + .union(CharacterSet(charactersIn: "`")) // Also consider back-ticks as punctuation. They are used as quotes around symbols or other code. + .subtracting(CharacterSet(charactersIn: "-")) // Don't remove hyphens. They are used as a whitespace replacement. + static let whitespaceAndDashes = CharacterSet.whitespaces + .union(CharacterSet(charactersIn: "-–—")) // hyphen, en dash, em dash +} + +/// Creates a more readable version of a fragment by replacing characters that are not allowed in the fragment of a URL with hyphens. +/// +/// If this step is not performed, the disallowed characters are instead percent escape encoded, which is less readable. +/// For example, a fragment like `"#hello world"` is converted to `"#hello-world"` instead of `"#hello%20world"`. +func urlReadableFragment(_ fragment: some StringProtocol) -> String { + var fragment = fragment + // Trim leading/trailing whitespace + .trimmingCharacters(in: .whitespaces) + + // Replace continuous whitespace and dashes + .components(separatedBy: .whitespaceAndDashes) + .filter({ !$0.isEmpty }) + .joined(separator: "-") + + // Remove invalid characters + fragment.unicodeScalars.removeAll(where: CharacterSet.fragmentCharactersToRemove.contains) + + return fragment +} + diff --git a/Sources/DocCHTML/MarkdownRenderer+Topics.swift b/Sources/DocCHTML/MarkdownRenderer+Topics.swift new file mode 100644 index 0000000000..9f65cc5978 --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer+Topics.swift @@ -0,0 +1,142 @@ +/* + 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) +// FIXME: See if we can avoid depending on XMLNode/XMLParser to avoid needing to import FoundationXML +package import FoundationXML +package import FoundationEssentials +#else +package import Foundation +#endif + +package import Markdown +package import DocCCommon + +package extension MarkdownRenderer { + struct TaskGroupInfo { + package var title: String? + package var content: [any Markup] + package var references: [URL] + + package init(title: String?, content: [any Markup], references: [URL]) { + self.title = title + self.content = content + self.references = references + } + } + + 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 links and only mark add "class" attributes to the unique ones + 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 and abstract/discussion if this group has no links + 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) ] + } + break + + 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 { + [ _symbolSubheading(fragments, languageFilter: nil) ] + } else { + fragmentsByLanguage.map { language, fragments in + _symbolSubheading(fragments, languageFilter: language) + } + } + } + + // Add the formatted abstract if the + if let abstract = element.abstract { + items.append(visit(abstract)) + } + + return .element(named: "li", children: [ + .element(named: "a", children: items, attributes: ["href": path(to: element.path)]) + ]) + } + + 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/Sources/DocCHTML/MarkdownRenderer.swift b/Sources/DocCHTML/MarkdownRenderer.swift new file mode 100644 index 0000000000..d45848c442 --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer.swift @@ -0,0 +1,602 @@ +/* + 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) +// FIXME: See if we can avoid depending on XMLNode/XMLParser to avoid needing to import FoundationXML +package import FoundationXML +package import FoundationEssentials +#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 + } + + package func visit(_ paragraph: Paragraph) -> XMLNode { + .element(named: "p", children: visit(paragraph.children)) + } + + 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())"] + ) + } + + 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().lowercased()) + 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] + ) + } + } + + func visit(_ emphasis: Emphasis) -> XMLNode { + .element(named: "i", children: visit(emphasis.children)) + } + + func visit(_ strong: Strong) -> XMLNode { + .element(named: "b", children: visit(strong.children)) + } + + func visit(_ strikethrough: Strikethrough) -> XMLNode { + .element(named: "s", children: visit(strikethrough.children)) + } + + func visit(_ inlineCode: InlineCode) -> XMLNode { + .element(named: "code", children: [.text(inlineCode.code)]) + } + + func visit(_ text: Text) -> XMLNode { + .text(text.string) + } + + func visit(_: LineBreak) -> XMLNode { + .element(named: "br") + } + + func visit(_: SoftBreak) -> XMLNode { + .text(" ") // A soft line break doesn't actually break the content + } + + 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) + } + } + + func visit(_ html: HTMLBlock) -> XMLNode { + do { + let parsed = try XMLElement(xmlString: html.rawHTML) + _removeComments(from: parsed) + return parsed + } catch { + return .text("") + } + } + + 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("