diff --git a/Package.swift b/Package.swift index b1e0f9abc6..ecfb74c602 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 @@ -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/DocCHTML/CMakeLists.txt b/Sources/DocCHTML/CMakeLists.txt new file mode 100644 index 0000000000..3652264887 --- /dev/null +++ b/Sources/DocCHTML/CMakeLists.txt @@ -0,0 +1,31 @@ +#[[ +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+Discussion.swift + MarkdownRenderer+Parameters.swift + MarkdownRenderer+Relationships.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(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+Availability.swift b/Sources/DocCHTML/MarkdownRenderer+Availability.swift new file mode 100644 index 0000000000..eb302aa90e --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer+Availability.swift @@ -0,0 +1,76 @@ +/* + 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 extension MarkdownRenderer { + /// Information about the versions that a piece of API is available for a given platform. + struct AvailabilityInfo { + /// The name of the platform that this information applies to. + package var name: String + /// The pre-formatted version string that describes the version that this API was introduced in for this platform. + package var introduced: String? + /// The pre-formatted version string that describes the version that this API was deprecated in for this platform. + package var deprecated: String? + /// A Boolean value indicating if the platform is currently in beta. + 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 that describes the versions that a piece of API is available for the platforms described in the given 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..ce374e2db5 --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer+Breadcrumbs.swift @@ -0,0 +1,67 @@ +/* + 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 extension MarkdownRenderer { + /// Creates an HTML element for the breadcrumbs that lead to the renderer's current page. + func breadcrumbs(references: [URL], currentPageNames: LinkedElement.Names) -> XMLNode { + // Breadcrumbs handle symbols differently than most elements in that everything uses a default style (no "code voice") + func nameElements(for names: LinkedElement.Names) -> [XMLNode] { + switch names { + 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 + // Wrap the name in a span so that it can be given a language specific "class" attribute. + .element(named: "span", children: [.text(name)], 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)] } ?? [] + } + } + } + + // Create links for each of the breadcrumbs + 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)]) + ]) + } + } + + // Add the name of the current page. It doesn't display as a link because it would refer to the current page. + items.append( + .element(named: "li", children: nameElements(for: currentPageNames)) + ) + let list = XMLNode.element(named: "ul", children: items) + + return switch goal { + case .conciseness: list // If the goal is conciseness, don't wrap the list in a `