Skip to content
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cf7ebc6
Add experimental markdown output flag and pass it through to the conv…
Sep 2, 2025
4b1b94a
Initial export of Markdown from Article
Sep 4, 2025
55e7836
Initial processing of a type-level symbol
Sep 4, 2025
53a6196
Adds symbol declarations and article reference links
Sep 5, 2025
3513a73
Output tutorials to markdown
Sep 5, 2025
81d2d5a
Be smarter about removing indentation from within block directives
Sep 5, 2025
f3fa5ab
Baseline for adding new tests for markdown output
Sep 8, 2025
5013a09
Basic test infrastructure for markdown output
Sep 8, 2025
6798162
Adds symbol link tests to markdown output
Sep 8, 2025
132cd6c
Tutoorial code rendering markdown tests
Sep 8, 2025
1753cca
Adding metadata to markdown output
Sep 8, 2025
301d7da
Include package source for markdown output test catalog
Sep 9, 2025
9607e3b
Output metadata updates
Sep 9, 2025
5244f0f
Adds default availability for modules to markdown export
Sep 11, 2025
a6a740e
Move availability out of symbol and in to general metadata for markdo…
Sep 16, 2025
d0dbf44
Refactor markdown output so the final node type is standalone
Sep 19, 2025
595831a
Add generated markdown flag to render node metadata
Sep 23, 2025
104f9eb
Only include unavailable in markdown header if it is true
Sep 23, 2025
a2aa8f1
Initial setup of manifest output, no references
Sep 23, 2025
b5ed559
Output of manifest / relationships
Sep 24, 2025
74b6023
Manifest output format updates
Sep 24, 2025
198d8e8
Remove member symbol relationship
Sep 25, 2025
53ba222
More compact availability, deal with metadata availability for symbols
Sep 26, 2025
af890be
Merge branch 'main' into 159600318/experimental-markdown-ouput
jrturton Sep 26, 2025
4784380
Update tests so all inputs are locally defined
Sep 29, 2025
abc9985
Remove print statements from unused visitors
Oct 2, 2025
edc74da
Merge branch 'main' into 159600318/experimental-markdown-ouput
Oct 2, 2025
eb77d39
Added snippet handling
Oct 2, 2025
0e867d7
Remove or _spi-hide new public API
Oct 2, 2025
f07f683
Remove or _spi-hide new public API (part 2)
Oct 2, 2025
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
17 changes: 17 additions & 0 deletions Sources/SwiftDocC/Converter/DocumentationContextConverter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,21 @@ public class DocumentationContextConverter {
)
return translator.visit(node.semantic) as? RenderNode
}

/// Converts a documentation node to a markdown node.
/// - Parameters:
/// - node: The documentation node to convert.
/// - Returns: The markdown node representation of the documentation node.
public func markdownNode(for node: DocumentationNode) -> WritableMarkdownOutputNode? {
guard !node.isVirtual else {
return nil
}

var translator = MarkdownOutputNodeTranslator(
context: context,
bundle: bundle,
node: node
)
return translator.createOutput()
}
}
23 changes: 22 additions & 1 deletion Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ package enum ConvertActionConverter {
var assets = [RenderReferenceType : [any RenderReference]]()
var coverageInfo = [CoverageDataEntry]()
let coverageFilterClosure = documentationCoverageOptions.generateFilterClosure()
var markdownManifest = MarkdownOutputManifest(title: bundle.displayName, documents: [])

// An inner function to gather problems for errors encountered during the conversion.
//
Expand Down Expand Up @@ -124,11 +125,27 @@ package enum ConvertActionConverter {
do {
let entity = try context.entity(with: identifier)

guard let renderNode = converter.renderNode(for: entity) else {
guard var renderNode = converter.renderNode(for: entity) else {
// No render node was produced for this entity, so just skip it.
return
}

if
FeatureFlags.current.isExperimentalMarkdownOutputEnabled,
let markdownNode = converter.markdownNode(for: entity) {
try outputConsumer.consume(markdownNode: markdownNode)
renderNode.metadata.hasGeneratedMarkdown = true
if
FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled,
let manifest = markdownNode.manifest
{
resultsGroup.async(queue: resultsSyncQueue) {
markdownManifest.documents.formUnion(manifest.documents)
markdownManifest.relationships.formUnion(manifest.relationships)
}
}
}

try outputConsumer.consume(renderNode: renderNode)

switch documentationCoverageOptions.level {
Expand Down Expand Up @@ -213,6 +230,10 @@ package enum ConvertActionConverter {
}
}
}

if FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled {
try outputConsumer.consume(markdownManifest: markdownManifest)
}

switch documentationCoverageOptions.level {
case .detailed, .brief:
Expand Down
8 changes: 8 additions & 0 deletions Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ public protocol ConvertOutputConsumer {

/// Consumes a file representation of the local link resolution information.
func consume(linkResolutionInformation: SerializableLinkResolutionInformation) throws

/// Consumes a markdown output node
func consume(markdownNode: WritableMarkdownOutputNode) throws

/// Consumes a markdown output manifest
func consume(markdownManifest: MarkdownOutputManifest) throws
}

// Default implementations that discard the documentation conversion products, for consumers that don't need these
Expand All @@ -58,6 +64,8 @@ public extension ConvertOutputConsumer {
func consume(renderReferenceStore: RenderReferenceStore) throws {}
func consume(buildMetadata: BuildMetadata) throws {}
func consume(linkResolutionInformation: SerializableLinkResolutionInformation) throws {}
func consume(markdownNode: WritableMarkdownOutputNode) throws {}
func consume(markdownManifest: MarkdownOutputManifest) throws {}
}

// Default implementation so that conforming types don't need to implement deprecated API.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2024-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
*/

import Foundation

// Consumers of `MarkdownOutputManifest` in other packages should be able to lift this file and be able to use it standalone, without any dependencies from SwiftDocC.

/// A manifest of markdown-generated documentation from a single catalog
public struct MarkdownOutputManifest: Codable, Sendable {
public static let version = "0.1.0"

/// The version of this manifest
public let manifestVersion: String
/// The manifest title, this will typically match the module that the manifest is generated for
public let title: String
/// All documents contained in the manifest
public var documents: Set<Document>
/// Relationships involving documents in the manifest
public var relationships: Set<Relationship>

public init(title: String, documents: Set<Document> = [], relationships: Set<Relationship> = []) {
self.manifestVersion = Self.version
self.title = title
self.documents = documents
self.relationships = relationships
}
}

extension MarkdownOutputManifest {

public enum DocumentType: String, Codable, Sendable {
case article, tutorial, symbol
}

public enum RelationshipType: String, Codable, Sendable {
/// For this relationship, the source URI will be the URI of a document, and the target URI will be the topic to which it belongs
case belongsToTopic
/// For this relationship, the source and target URIs will be indicated by the directionality of the subtype, e.g. source "conformsTo" target.
case relatedSymbol
}

/// A relationship between two documents in the manifest.
///
/// Parent / child symbol relationships are not included here, because those relationships are implicit in the URI structure of the documents. See ``children(of:)``.
public struct Relationship: Codable, Hashable, Sendable {

public let sourceURI: String
public let relationshipType: RelationshipType
public let subtype: String?
public let targetURI: String

public init(sourceURI: String, relationshipType: MarkdownOutputManifest.RelationshipType, subtype: String? = nil, targetURI: String) {
self.sourceURI = sourceURI
self.relationshipType = relationshipType
self.subtype = subtype
self.targetURI = targetURI
}
}

public struct Document: Codable, Hashable, Sendable {
/// The URI of the document
public let uri: String
/// The type of the document
public let documentType: DocumentType
/// The title of the document
public let title: String

public init(uri: String, documentType: MarkdownOutputManifest.DocumentType, title: String) {
self.uri = uri
self.documentType = documentType
self.title = title
}

public func hash(into hasher: inout Hasher) {
hasher.combine(uri)
}
}

public func children(of parent: Document) -> Set<Document> {
let parentPrefix = parent.uri + "/"
let prefixEnd = parentPrefix.endIndex
return documents.filter { document in
guard document.uri.hasPrefix(parentPrefix) else {
return false
}
let components = document.uri[prefixEnd...].components(separatedBy: "/")
return components.count == 1
}
}
}
198 changes: 198 additions & 0 deletions Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/*
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
*/

public import Foundation

// Consumers of `MarkdownOutputNode` in other packages should be able to lift this file and be able to use it standalone, without any dependencies from SwiftDocC.

/// A markdown version of a documentation node.
public struct MarkdownOutputNode: Sendable {

/// The metadata about this node
public var metadata: Metadata
/// The markdown content of this node
public var markdown: String = ""

public init(metadata: Metadata, markdown: String) {
self.metadata = metadata
self.markdown = markdown
}
}

extension MarkdownOutputNode {
public struct Metadata: Codable, Sendable {

static let version = "0.1.0"

public enum DocumentType: String, Codable, Sendable {
case article, tutorial, symbol
}

public struct Availability: Codable, Equatable, Sendable {

let platform: String
let introduced: String?
let deprecated: String?
let unavailable: Bool

public init(platform: String, introduced: String? = nil, deprecated: String? = nil, unavailable: Bool) {
self.platform = platform
// Can't have deprecated without an introduced
self.introduced = introduced ?? deprecated
self.deprecated = deprecated
// If no introduced, we are unavailable
self.unavailable = unavailable || introduced == nil
}

// For a compact representation on-disk and for human and machine readers, availability is stored as a single string:
// platform: introduced - (not deprecated)
// platform: introduced - deprecated (deprecated)
// platform: - (unavailable)
public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(stringRepresentation)
}

public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let stringRepresentation = try container.decode(String.self)
self.init(stringRepresentation: stringRepresentation)
}

var stringRepresentation: String {
var stringRepresentation = "\(platform): "
if unavailable {
stringRepresentation += "-"
} else {
if let introduced, introduced.isEmpty == false {
stringRepresentation += "\(introduced) -"
if let deprecated, deprecated.isEmpty == false {
stringRepresentation += " \(deprecated)"
}
} else {
stringRepresentation += "-"
}
}
return stringRepresentation
}

init(stringRepresentation: String) {
let words = stringRepresentation.split(separator: ":", maxSplits: 1)
if words.count != 2 {
platform = stringRepresentation
unavailable = true
introduced = nil
deprecated = nil
return
}
platform = String(words[0])
let available = words[1]
.split(separator: "-")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { $0.isEmpty == false }

introduced = available.first
if available.count > 1 {
deprecated = available.last
} else {
deprecated = nil
}

unavailable = available.isEmpty
}

}

public struct Symbol: Codable, Sendable {
public let kind: String
public let preciseIdentifier: String
public let modules: [String]


public init(kind: String, preciseIdentifier: String, modules: [String]) {
self.kind = kind
self.preciseIdentifier = preciseIdentifier
self.modules = modules
}
}

public let metadataVersion: String
public let documentType: DocumentType
public var role: String?
public let uri: String
public var title: String
public let framework: String
public var symbol: Symbol?
public var availability: [Availability]?

public init(documentType: DocumentType, uri: String, title: String, framework: String) {
self.documentType = documentType
self.metadataVersion = Self.version
self.uri = uri
self.title = title
self.framework = framework
}

public func availability(for platform: String) -> Availability? {
availability?.first(where: { $0.platform == platform })
}
}
}

// MARK: I/O
extension MarkdownOutputNode {
/// Data for this node to be rendered to disk
public var data: Data {
get throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
let metadata = try encoder.encode(metadata)
var data = Data()
data.append(contentsOf: Self.commentOpen)
data.append(metadata)
data.append(contentsOf: Self.commentClose)
data.append(contentsOf: markdown.utf8)
return data
}
}

private static let commentOpen = "<!--\n".utf8
private static let commentClose = "\n-->\n\n".utf8

public enum MarkdownOutputNodeDecodingError: Error {

case metadataSectionNotFound
case metadataDecodingFailed(any Error)

var localizedDescription: String {
switch self {
case .metadataSectionNotFound:
"The data did not contain a metadata section."
case .metadataDecodingFailed(let error):
"Metadata decoding failed: \(error.localizedDescription)"
}
}
}

/// Recreates the node from the data exported in ``data``
public init(_ data: Data) throws {
guard let open = data.range(of: Data(Self.commentOpen)), let close = data.range(of: Data(Self.commentClose)) else {
throw MarkdownOutputNodeDecodingError.metadataSectionNotFound
}
let metaSection = data[open.endIndex..<close.startIndex]
do {
self.metadata = try JSONDecoder().decode(Metadata.self, from: metaSection)
} catch {
throw MarkdownOutputNodeDecodingError.metadataDecodingFailed(error)
}

self.markdown = String(data: data[close.endIndex...], encoding: .utf8) ?? ""
}
}
Loading