Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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)]
),

Expand All @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions Sources/DocCHTML/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
116 changes: 116 additions & 0 deletions Sources/DocCHTML/LinkProvider.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
76 changes: 76 additions & 0 deletions Sources/DocCHTML/MarkdownRenderer+Availability.swift
Original file line number Diff line number Diff line change
@@ -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"]
)
}
}
67 changes: 67 additions & 0 deletions Sources/DocCHTML/MarkdownRenderer+Breadcrumbs.swift
Original file line number Diff line number Diff line change
@@ -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 `<nav>` HTML element with an "id".
case .richness: .element(named: "nav", children: [list], attributes: ["id": "breadcrumbs"])
}
}
}
Loading