From cf7ebc606c4840269c5578779db419f8d903561a Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 2 Sep 2025 16:25:11 +0100 Subject: [PATCH 01/28] Add experimental markdown output flag and pass it through to the convert feature flags --- Sources/SwiftDocC/Utility/FeatureFlags.swift | 3 +++ .../ConvertAction+CommandInitialization.swift | 1 + .../ArgumentParsing/Subcommands/Convert.swift | 9 +++++++++ 3 files changed, 13 insertions(+) diff --git a/Sources/SwiftDocC/Utility/FeatureFlags.swift b/Sources/SwiftDocC/Utility/FeatureFlags.swift index b2ec4dbc5..3ca3f3bab 100644 --- a/Sources/SwiftDocC/Utility/FeatureFlags.swift +++ b/Sources/SwiftDocC/Utility/FeatureFlags.swift @@ -23,6 +23,9 @@ public struct FeatureFlags: Codable { /// Whether or not experimental support for combining overloaded symbol pages is enabled. public var isExperimentalOverloadedSymbolPresentationEnabled = false + /// Whether or not experimental markdown generation is enabled + public var isExperimentalMarkdownOutputEnabled = false + /// Whether support for automatically rendering links on symbol documentation to articles that mention that symbol is enabled. public var isMentionedInEnabled = true diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift index e8c8a31b4..382c9637a 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift @@ -25,6 +25,7 @@ extension ConvertAction { FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled = convert.enableExperimentalOverloadedSymbolPresentation FeatureFlags.current.isMentionedInEnabled = convert.enableMentionedIn FeatureFlags.current.isParametersAndReturnsValidationEnabled = convert.enableParametersAndReturnsValidation + FeatureFlags.current.isExperimentalMarkdownOutputEnabled = convert.enableExperimentalMarkdownOutput // If the user-provided a URL for an external link resolver, attempt to // initialize an `OutOfProcessReferenceResolver` with the provided URL. diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift index 557b4f2a5..64db12bbd 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift @@ -516,6 +516,9 @@ extension Docc { @available(*, deprecated, message: "This deprecated API will be removed after 6.2 is released") var enableExperimentalMentionedIn = false + @Flag(help: "Experimental: Create markdown versions of documents") + var enableExperimentalMarkdownOutput = false + @Flag( name: .customLong("parameters-and-returns-validation"), inversion: .prefixedEnableDisable, @@ -602,6 +605,12 @@ extension Docc { get { featureFlags.enableExperimentalOverloadedSymbolPresentation } set { featureFlags.enableExperimentalOverloadedSymbolPresentation = newValue } } + + /// A user-provided value that is true if the user enables experimental markdown output + public var enableExperimentalMarkdownOutput: Bool { + get { featureFlags.enableExperimentalMarkdownOutput } + set { featureFlags.enableExperimentalMarkdownOutput = newValue } + } /// A user-provided value that is true if the user enables experimental automatically generated "mentioned in" /// links on symbols. From 4b1b94a2173fa477d3e7098bac8269148e951847 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 4 Sep 2025 12:14:31 +0100 Subject: [PATCH 02/28] Initial export of Markdown from Article --- .../DocumentationContextConverter.swift | 22 +++ .../ConvertActionConverter.swift | 5 + .../ConvertOutputConsumer.swift | 3 + .../MarkdownOutput/MarkdownOutputNode.swift | 149 ++++++++++++++++ .../MarkdownOutputNodeTranslator.swift | 162 ++++++++++++++++++ .../Convert/ConvertFileWritingConsumer.swift | 4 + .../JSONEncodingRenderNodeWriter.swift | 44 +++++ 7 files changed, 389 insertions(+) create mode 100644 Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift create mode 100644 Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift diff --git a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift index 72e23665b..878425f3e 100644 --- a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift +++ b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift @@ -101,4 +101,26 @@ 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) -> MarkdownOutputNode? { + guard !node.isVirtual else { + return nil + } + + var translator = MarkdownOutputNodeTranslator( + context: context, + bundle: bundle, + identifier: node.reference, +// renderContext: renderContext, +// emitSymbolSourceFileURIs: shouldEmitSymbolSourceFileURIs, +// emitSymbolAccessLevels: shouldEmitSymbolAccessLevels, +// sourceRepository: sourceRepository, +// symbolIdentifiersWithExpandedDocumentation: symbolIdentifiersWithExpandedDocumentation + ) + return translator.visit(node.semantic) + } } diff --git a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift index 17a5db0a7..71cfc70b4 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift @@ -131,6 +131,11 @@ package enum ConvertActionConverter { try outputConsumer.consume(renderNode: renderNode) + if + FeatureFlags.current.isExperimentalMarkdownOutputEnabled, + let markdownNode = converter.markdownNode(for: entity) { + try outputConsumer.consume(markdownNode: markdownNode) + } switch documentationCoverageOptions.level { case .detailed, .brief: let coverageEntry = try CoverageDataEntry( diff --git a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift index 830404dda..9297d5338 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift @@ -50,6 +50,9 @@ 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: MarkdownOutputNode) throws } // Default implementations that discard the documentation conversion products, for consumers that don't need these diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift new file mode 100644 index 000000000..183a6dac6 --- /dev/null +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift @@ -0,0 +1,149 @@ +public import Foundation +public import Markdown +/// A markdown version of a documentation node. +public struct MarkdownOutputNode { + + public let context: DocumentationContext + public let bundle: DocumentationBundle + public let identifier: ResolvedTopicReference + + public init(context: DocumentationContext, bundle: DocumentationBundle, identifier: ResolvedTopicReference) { + self.context = context + self.bundle = bundle + self.identifier = identifier + } + + public var metadata: [String: String] = [:] + public var markdown: String = "" + + public var data: Data { + get throws { + Data(markdown.utf8) + } + } + + fileprivate var removeIndentation = false +} + +extension MarkdownOutputNode { + mutating func visit(_ optionalMarkup: (any Markup)?) -> Void { + if let markup = optionalMarkup { + self.visit(markup) + } + } + + mutating func visit(section: (any Section)?) -> Void { + section?.content.forEach { + self.visit($0) + } + } + + mutating func visit(container: MarkupContainer?) -> Void { + container?.elements.forEach { + self.visit($0) + } + } +} + +extension MarkdownOutputNode: MarkupWalker { + + public mutating func defaultVisit(_ markup: any Markup) -> () { + let output = markup.format() + if removeIndentation { + markdown.append(output.removingLeadingWhitespace()) + } else { + markdown.append(output) + } + } + + public mutating func visitImage(_ image: Image) -> () { + guard let source = image.source else { + return + } + let unescaped = source.removingPercentEncoding ?? source + var filename = source + if + let resolved = context.resolveAsset(named: unescaped, in: identifier, withType: .image), let first = resolved.variants.first?.value { + filename = first.lastPathComponent + } + + markdown.append("![\(image.altText ?? "")](images/\(bundle.id)/\(filename))") + } + + public mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> () { + guard + let destination = symbolLink.destination, + let resolved = context.referenceIndex[destination], + let node = context.topicGraph.nodeWithReference(resolved) + else { + markdown.append(symbolLink.format()) + return + } + let link = Link(destination: destination, title: node.title, [InlineCode(node.title)]) + visit(link) + } + + public mutating func visitSoftBreak(_ softBreak: SoftBreak) -> () { + markdown.append("\n") + } + + public mutating func visitParagraph(_ paragraph: Paragraph) -> () { + + if !markdown.hasSuffix("\n\n") { markdown.append("\n\n") } + + for child in paragraph.children { + visit(child) + } + } + + public mutating func visitBlockDirective(_ blockDirective: BlockDirective) -> () { + + switch blockDirective.name { + case VideoMedia.directiveName: + guard let video = VideoMedia(from: blockDirective, for: bundle) else { + return + } + + let unescaped = video.source.path.removingPercentEncoding ?? video.source.path + var filename = video.source.url.lastPathComponent + if + let resolvedVideos = context.resolveAsset(named: unescaped, in: identifier, withType: .video), let first = resolvedVideos.variants.first?.value { + filename = first.lastPathComponent + } + + markdown.append("\n\n![\(video.altText ?? "")](videos/\(bundle.id)/\(filename))") + visit(container: video.caption) + case Row.directiveName: + guard let row = Row(from: blockDirective, for: bundle) else { + return + } + for column in row.columns { + markdown.append("\n\n") + removeIndentation = true + visit(container: column.content) + removeIndentation = false + } + case TabNavigator.directiveName: + guard let tabs = TabNavigator(from: blockDirective, for: bundle) else { + return + } + if + let defaultLanguage = context.sourceLanguages(for: identifier).first?.name, + let languageMatch = tabs.tabs.first(where: { $0.title.lowercased() == defaultLanguage }) { + visit(container: languageMatch.content) + } else { + for tab in tabs.tabs { + // Don't make any assumptions about headings here + let para = Paragraph([Strong(Text("\(tab.title):"))]) + visit(para) + removeIndentation = true + visit(container: tab.content) + removeIndentation = false + } + } + + default: return + } + + } +} diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift new file mode 100644 index 000000000..96c7f03c2 --- /dev/null +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift @@ -0,0 +1,162 @@ +import Foundation +import Markdown + +public struct MarkdownOutputNodeTranslator: SemanticVisitor { + + public let context: DocumentationContext + public let bundle: DocumentationBundle + public let identifier: ResolvedTopicReference + + public init(context: DocumentationContext, bundle: DocumentationBundle, identifier: ResolvedTopicReference) { + self.context = context + self.bundle = bundle + self.identifier = identifier + } + + public typealias Result = MarkdownOutputNode? + + public mutating func visitArticle(_ article: Article) -> MarkdownOutputNode? { + var node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier) + + node.visit(article.title) + node.visit(article.abstractSection?.paragraph) + node.markdown.append("\n\n") + node.visit(section: article.discussion) + node.visit(section: article.seeAlso) + return node + } + + public mutating func visitCode(_ code: Code) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitStep(_ step: Step) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitSteps(_ steps: Steps) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitTutorialSection(_ tutorialSection: TutorialSection) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitTutorial(_ tutorial: Tutorial) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitIntro(_ intro: Intro) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitXcodeRequirement(_ xcodeRequirement: XcodeRequirement) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitAssessments(_ assessments: Assessments) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitMultipleChoice(_ multipleChoice: MultipleChoice) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitJustification(_ justification: Justification) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitChoice(_ choice: Choice) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitMarkupContainer(_ markupContainer: MarkupContainer) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitTechnology(_ technology: TutorialTableOfContents) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitImageMedia(_ imageMedia: ImageMedia) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitVideoMedia(_ videoMedia: VideoMedia) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitContentAndMedia(_ contentAndMedia: ContentAndMedia) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitVolume(_ volume: Volume) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitChapter(_ chapter: Chapter) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitTutorialReference(_ tutorialReference: TutorialReference) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitResources(_ resources: Resources) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitTile(_ tile: Tile) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitComment(_ comment: Comment) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitTutorialArticle(_ article: TutorialArticle) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitStack(_ stack: Stack) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public mutating func visitSymbol(_ symbol: Symbol) -> MarkdownOutputNode? { + return nil + } + + public mutating func visitDeprecationSummary(_ summary: DeprecationSummary) -> MarkdownOutputNode? { + print(#function) + return nil + } + + public func visitTutorialTableOfContents(_ tutorialTableOfContents: TutorialTableOfContents) -> MarkdownOutputNode? { + print(#function) + return nil + } +} diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift index 9d0370dda..91918c624 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift @@ -68,6 +68,10 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer { indexer?.index(renderNode) } + func consume(markdownNode: MarkdownOutputNode) throws { + try renderNodeWriter.write(markdownNode) + } + func consume(externalRenderNode: ExternalRenderNode) throws { // Index the external node, if indexing is enabled. indexer?.index(externalRenderNode) diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift index 30ac26eb0..69e5ebac3 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift @@ -115,4 +115,48 @@ class JSONEncodingRenderNodeWriter { try fileManager._copyItem(at: indexHTML, to: htmlTargetFileURL) } } + + // TODO: Should this be a separate writer? Will we write markdown without creating render JSON? + /// Writes a markdown node to a file at a location based on the node's relative URL. + /// + /// If the target path to the JSON file includes intermediate folders that don't exist, the writer object will ask the file manager, with which it was created, to + /// create those intermediate folders before writing the JSON file. + /// + /// - Parameters: + /// - markdownNode: The node which the writer object writes + func write(_ markdownNode: MarkdownOutputNode) throws { + let fileSafePath = NodeURLGenerator.fileSafeReferencePath( + markdownNode.identifier, + lowercased: true + ) + + // The path on disk to write the markdown file at. + let markdownNodeTargetFileURL = renderNodeURLGenerator + .urlForReference( + markdownNode.identifier, + fileSafePath: fileSafePath + ) + .appendingPathExtension("md") + + let renderNodeTargetFolderURL = markdownNodeTargetFileURL.deletingLastPathComponent() + + // On Linux sometimes it takes a moment for the directory to be created and that leads to + // errors when trying to write files concurrently in the same target location. + // We keep an index in `directoryIndex` and create new sub-directories as needed. + // When the symbol's directory already exists no code is executed during the lock below + // besides the set lookup. + try directoryIndex.sync { directoryIndex in + let (insertedMarkdownNodeTargetFolderURL, _) = directoryIndex.insert(renderNodeTargetFolderURL) + if insertedMarkdownNodeTargetFolderURL { + try fileManager.createDirectory( + at: renderNodeTargetFolderURL, + withIntermediateDirectories: true, + attributes: nil + ) + } + } + + let data = try markdownNode.data + try fileManager.createFile(at: markdownNodeTargetFileURL, contents: data, options: nil) + } } From 55e7836cc7d2cd16269f50ad5ea8165000da2083 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 4 Sep 2025 16:47:50 +0100 Subject: [PATCH 03/28] Initial processing of a type-level symbol --- .../MarkdownOutput/MarkdownOutputNode.swift | 86 ++++++++++++++++--- .../MarkdownOutputNodeTranslator.swift | 25 ++++-- .../JSONEncodingRenderNodeWriter.swift | 6 +- 3 files changed, 98 insertions(+), 19 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift index 183a6dac6..d6fcaa37f 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift @@ -22,7 +22,22 @@ public struct MarkdownOutputNode { } } - fileprivate var removeIndentation = false + private(set) var removeIndentation = false + private(set) var isRenderingLinkList = false + + public mutating func withRenderingLinkList(_ process: (inout MarkdownOutputNode) -> Void) { + isRenderingLinkList = true + process(&self) + isRenderingLinkList = false + } + + public mutating func withRemoveIndentation(_ process: (inout MarkdownOutputNode) -> Void) { + removeIndentation = true + process(&self) + removeIndentation = false + } + + private var linkListAbstract: (any Markup)? } extension MarkdownOutputNode { @@ -32,7 +47,15 @@ extension MarkdownOutputNode { } } - mutating func visit(section: (any Section)?) -> Void { + mutating func visit(section: (any Section)?, addingHeading: String? = nil) -> Void { + guard let content = section?.content, content.isEmpty == false else { + return + } + + if let addingHeading { + visit(Heading(level: 2, Text(addingHeading))) + } + section?.content.forEach { self.visit($0) } @@ -43,6 +66,10 @@ extension MarkdownOutputNode { self.visit($0) } } + + mutating func startNewParagraphIfRequired() { + if !markdown.isEmpty, !markdown.hasSuffix("\n\n") { markdown.append("\n\n") } + } } extension MarkdownOutputNode: MarkupWalker { @@ -55,6 +82,26 @@ extension MarkdownOutputNode: MarkupWalker { markdown.append(output) } } + + public mutating func visitHeading(_ heading: Heading) -> () { + startNewParagraphIfRequired() + markdown.append(heading.detachedFromParent.format()) + } + + public mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> () { + guard isRenderingLinkList else { + return defaultVisit(unorderedList) + } + + startNewParagraphIfRequired() + for item in unorderedList.listItems { + linkListAbstract = nil + item.children.forEach { visit($0) } + visit(linkListAbstract) + linkListAbstract = nil + startNewParagraphIfRequired() + } + } public mutating func visitImage(_ image: Image) -> () { guard let source = image.source else { @@ -79,7 +126,25 @@ extension MarkdownOutputNode: MarkupWalker { markdown.append(symbolLink.format()) return } - let link = Link(destination: destination, title: node.title, [InlineCode(node.title)]) + + let linkTitle: String + if + isRenderingLinkList, + let doc = try? context.entity(with: resolved), + let symbol = doc.semantic as? Symbol + { + linkListAbstract = (doc.semantic as? Symbol)?.abstract + if let fragments = symbol.navigator { + linkTitle = fragments + .map { $0.spelling } + .joined(separator: " ") + } else { + linkTitle = symbol.title + } + } else { + linkTitle = node.title + } + let link = Link(destination: destination, title: linkTitle, [InlineCode(linkTitle)]) visit(link) } @@ -89,7 +154,7 @@ extension MarkdownOutputNode: MarkupWalker { public mutating func visitParagraph(_ paragraph: Paragraph) -> () { - if !markdown.hasSuffix("\n\n") { markdown.append("\n\n") } + startNewParagraphIfRequired() for child in paragraph.children { visit(child) @@ -119,9 +184,9 @@ extension MarkdownOutputNode: MarkupWalker { } for column in row.columns { markdown.append("\n\n") - removeIndentation = true - visit(container: column.content) - removeIndentation = false + withRemoveIndentation { + $0.visit(container: column.content) + } } case TabNavigator.directiveName: guard let tabs = TabNavigator(from: blockDirective, for: bundle) else { @@ -136,9 +201,10 @@ extension MarkdownOutputNode: MarkupWalker { // Don't make any assumptions about headings here let para = Paragraph([Strong(Text("\(tab.title):"))]) visit(para) - removeIndentation = true - visit(container: tab.content) - removeIndentation = false + withRemoveIndentation { + $0.visit(container: tab.content) + + } } } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift index 96c7f03c2..a652668f3 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift @@ -19,10 +19,25 @@ public struct MarkdownOutputNodeTranslator: SemanticVisitor { var node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier) node.visit(article.title) - node.visit(article.abstractSection?.paragraph) - node.markdown.append("\n\n") + node.visit(article.abstract) node.visit(section: article.discussion) - node.visit(section: article.seeAlso) + node.withRenderingLinkList { + $0.visit(section: article.topics, addingHeading: "Topics") + $0.visit(section: article.seeAlso, addingHeading: "See Also") + } + return node + } + + public mutating func visitSymbol(_ symbol: Symbol) -> MarkdownOutputNode? { + var node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier) + + node.visit(Heading(level: 1, Text(symbol.title))) + node.visit(symbol.abstract) + node.visit(section: symbol.discussion, addingHeading: "Overview") + node.withRenderingLinkList { + $0.visit(section: symbol.topics, addingHeading: "Topics") + $0.visit(section: symbol.seeAlso, addingHeading: "See Also") + } return node } @@ -146,9 +161,7 @@ public struct MarkdownOutputNodeTranslator: SemanticVisitor { return nil } - public mutating func visitSymbol(_ symbol: Symbol) -> MarkdownOutputNode? { - return nil - } + public mutating func visitDeprecationSummary(_ summary: DeprecationSummary) -> MarkdownOutputNode? { print(#function) diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift index 69e5ebac3..494fee42b 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift @@ -138,7 +138,7 @@ class JSONEncodingRenderNodeWriter { ) .appendingPathExtension("md") - let renderNodeTargetFolderURL = markdownNodeTargetFileURL.deletingLastPathComponent() + let markdownNodeTargetFolderURL = markdownNodeTargetFileURL.deletingLastPathComponent() // On Linux sometimes it takes a moment for the directory to be created and that leads to // errors when trying to write files concurrently in the same target location. @@ -146,10 +146,10 @@ class JSONEncodingRenderNodeWriter { // When the symbol's directory already exists no code is executed during the lock below // besides the set lookup. try directoryIndex.sync { directoryIndex in - let (insertedMarkdownNodeTargetFolderURL, _) = directoryIndex.insert(renderNodeTargetFolderURL) + let (insertedMarkdownNodeTargetFolderURL, _) = directoryIndex.insert(markdownNodeTargetFolderURL) if insertedMarkdownNodeTargetFolderURL { try fileManager.createDirectory( - at: renderNodeTargetFolderURL, + at: markdownNodeTargetFolderURL, withIntermediateDirectories: true, attributes: nil ) From 53a61961b7eef93c0648d3acf898e12b6e349644 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Fri, 5 Sep 2025 10:06:48 +0100 Subject: [PATCH 04/28] Adds symbol declarations and article reference links --- .../MarkdownOutput/MarkdownOutputNode.swift | 59 ++++++++++++++++--- .../MarkdownOutputNodeTranslator.swift | 21 ++++++- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift index d6fcaa37f..62b05d44a 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift @@ -48,15 +48,22 @@ extension MarkdownOutputNode { } mutating func visit(section: (any Section)?, addingHeading: String? = nil) -> Void { - guard let content = section?.content, content.isEmpty == false else { + guard + let section = section, + section.content.isEmpty == false else { return } - if let addingHeading { - visit(Heading(level: 2, Text(addingHeading))) + if let heading = addingHeading ?? type(of: section).title { + // Don't add if there is already a heading in the content + if let first = section.content.first as? Heading, first.level == 2 { + // Do nothing + } else { + visit(Heading(level: 2, Text(heading))) + } } - section?.content.forEach { + section.content.forEach { self.visit($0) } } @@ -116,15 +123,19 @@ extension MarkdownOutputNode: MarkupWalker { markdown.append("![\(image.altText ?? "")](images/\(bundle.id)/\(filename))") } - + + public mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> () { + startNewParagraphIfRequired() + markdown.append(codeBlock.detachedFromParent.format()) + } + public mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> () { guard let destination = symbolLink.destination, let resolved = context.referenceIndex[destination], let node = context.topicGraph.nodeWithReference(resolved) else { - markdown.append(symbolLink.format()) - return + return defaultVisit(symbolLink) } let linkTitle: String @@ -148,6 +159,32 @@ extension MarkdownOutputNode: MarkupWalker { visit(link) } + public mutating func visitLink(_ link: Link) -> () { + guard + link.isAutolink, + let destination = link.destination, + let resolved = context.referenceIndex[destination], + let doc = try? context.entity(with: resolved) + else { + return defaultVisit(link) + } + + let linkTitle: String + if + let article = doc.semantic as? Article + { + if isRenderingLinkList { + linkListAbstract = article.abstract + } + linkTitle = article.title?.plainText ?? resolved.lastPathComponent + } else { + linkTitle = resolved.lastPathComponent + } + let link = Link(destination: destination, title: linkTitle, [InlineCode(linkTitle)]) + defaultVisit(link) + } + + public mutating func visitSoftBreak(_ softBreak: SoftBreak) -> () { markdown.append("\n") } @@ -207,6 +244,14 @@ extension MarkdownOutputNode: MarkupWalker { } } } + case Links.directiveName: + withRemoveIndentation { + $0.withRenderingLinkList { + for child in blockDirective.children { + $0.visit(child) + } + } + } default: return } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift index a652668f3..43bd7ec9d 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift @@ -33,7 +33,25 @@ public struct MarkdownOutputNodeTranslator: SemanticVisitor { node.visit(Heading(level: 1, Text(symbol.title))) node.visit(symbol.abstract) - node.visit(section: symbol.discussion, addingHeading: "Overview") + if let declarationFragments = symbol.declaration.first?.value.declarationFragments { + let declaration = declarationFragments + .map { $0.spelling } + .joined() + let code = CodeBlock(declaration) + node.visit(code) + } + + if let parametersSection = symbol.parametersSection, parametersSection.parameters.isEmpty == false { + node.visit(Heading(level: 2, Text(ParametersSection.title ?? "Parameters"))) + for parameter in parametersSection.parameters { + node.visit(Paragraph(InlineCode(parameter.name))) + node.visit(container: MarkupContainer(parameter.contents)) + } + } + + node.visit(section: symbol.returnsSection) + + node.visit(section: symbol.discussion, addingHeading: symbol.kind.identifier.swiftSymbolCouldHaveChildren ? "Overview" : "Discussion") node.withRenderingLinkList { $0.visit(section: symbol.topics, addingHeading: "Topics") $0.visit(section: symbol.seeAlso, addingHeading: "See Also") @@ -41,6 +59,7 @@ public struct MarkdownOutputNodeTranslator: SemanticVisitor { return node } + public mutating func visitCode(_ code: Code) -> MarkdownOutputNode? { print(#function) return nil From 3513a73d4e91c8c799655e42e110abffdb9df435 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Fri, 5 Sep 2025 13:26:21 +0100 Subject: [PATCH 05/28] Output tutorials to markdown --- .../MarkdownOutput/MarkdownOutputNode.swift | 79 ++++++++-- .../MarkdownOutputNodeTranslator.swift | 147 ++++++++++++------ 2 files changed, 164 insertions(+), 62 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift index 62b05d44a..54e422c6d 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift @@ -67,13 +67,7 @@ extension MarkdownOutputNode { self.visit($0) } } - - mutating func visit(container: MarkupContainer?) -> Void { - container?.elements.forEach { - self.visit($0) - } - } - + mutating func startNewParagraphIfRequired() { if !markdown.isEmpty, !markdown.hasSuffix("\n\n") { markdown.append("\n\n") } } @@ -205,16 +199,14 @@ extension MarkdownOutputNode: MarkupWalker { guard let video = VideoMedia(from: blockDirective, for: bundle) else { return } - - let unescaped = video.source.path.removingPercentEncoding ?? video.source.path - var filename = video.source.url.lastPathComponent - if - let resolvedVideos = context.resolveAsset(named: unescaped, in: identifier, withType: .video), let first = resolvedVideos.variants.first?.value { - filename = first.lastPathComponent - } + visit(video) - markdown.append("\n\n![\(video.altText ?? "")](videos/\(bundle.id)/\(filename))") - visit(container: video.caption) + case ImageMedia.directiveName: + guard let image = ImageMedia(from: blockDirective, for: bundle) else { + return + } + visit(image) + case Row.directiveName: guard let row = Row(from: blockDirective, for: bundle) else { return @@ -258,3 +250,58 @@ extension MarkdownOutputNode: MarkupWalker { } } + +// Semantic handling +extension MarkdownOutputNode { + + mutating func visit(container: MarkupContainer?) -> Void { + container?.elements.forEach { + self.visit($0) + } + } + + mutating func visit(_ video: VideoMedia) -> Void { + let unescaped = video.source.path.removingPercentEncoding ?? video.source.path + var filename = video.source.url.lastPathComponent + if + let resolvedVideos = context.resolveAsset(named: unescaped, in: identifier, withType: .video), let first = resolvedVideos.variants.first?.value { + filename = first.lastPathComponent + } + + markdown.append("\n\n![\(video.altText ?? "")](videos/\(bundle.id)/\(filename))") + visit(container: video.caption) + } + + mutating func visit(_ image: ImageMedia) -> Void { + let unescaped = image.source.path.removingPercentEncoding ?? image.source.path + var filename = image.source.url.lastPathComponent + if let resolvedImages = context.resolveAsset(named: unescaped, in: identifier, withType: .image), let first = resolvedImages.variants.first?.value { + filename = first.lastPathComponent + } + markdown.append("\n\n![\(image.altText ?? "")](images/\(bundle.id)/\(filename))") + } + + mutating func visit(_ code: Code) -> Void { + guard let codeIdentifier = context.identifier(forAssetName: code.fileReference.path, in: identifier) else { + return + } + let fileReference = ResourceReference(bundleID: code.fileReference.bundleID, path: codeIdentifier) + let codeText: String + if + let data = try? context.resource(with: fileReference), + let string = String(data: data, encoding: .utf8) { + codeText = string + } else if + let asset = context.resolveAsset(named: code.fileReference.path, in: identifier), + let string = try? String(contentsOf: asset.data(bestMatching: .init()).url, encoding: .utf8) + { + codeText = string + } else { + return + } + + visit(Paragraph(Emphasis(Text(code.fileName)))) + visit(CodeBlock(codeText)) + } + +} diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift index 43bd7ec9d..fab222328 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift @@ -12,8 +12,18 @@ public struct MarkdownOutputNodeTranslator: SemanticVisitor { self.bundle = bundle self.identifier = identifier } - + public typealias Result = MarkdownOutputNode? + private var node: Result = nil + + // Tutorial processing + private var sectionIndex = 0 + private var stepIndex = 0 + private var lastCode: Code? +} + +// MARK: Article Output +extension MarkdownOutputNodeTranslator { public mutating func visitArticle(_ article: Article) -> MarkdownOutputNode? { var node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier) @@ -27,6 +37,10 @@ public struct MarkdownOutputNodeTranslator: SemanticVisitor { } return node } +} + +// MARK: Symbol Output +extension MarkdownOutputNodeTranslator { public mutating func visitSymbol(_ symbol: Symbol) -> MarkdownOutputNode? { var node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier) @@ -58,88 +72,137 @@ public struct MarkdownOutputNodeTranslator: SemanticVisitor { } return node } - - - public mutating func visitCode(_ code: Code) -> MarkdownOutputNode? { - print(#function) +} + +// MARK: Tutorial Output +extension MarkdownOutputNodeTranslator { + // Tutorial table of contents is not useful as markdown or indexable content + public func visitTutorialTableOfContents(_ tutorialTableOfContents: TutorialTableOfContents) -> MarkdownOutputNode? { return nil } - public mutating func visitStep(_ step: Step) -> MarkdownOutputNode? { - print(#function) - return nil + public mutating func visitTutorial(_ tutorial: Tutorial) -> MarkdownOutputNode? { + node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier) + sectionIndex = 0 + for child in tutorial.children { + node = visit(child) ?? node + } + return node } - public mutating func visitSteps(_ steps: Steps) -> MarkdownOutputNode? { - print(#function) + public mutating func visitTutorialSection(_ tutorialSection: TutorialSection) -> MarkdownOutputNode? { + sectionIndex += 1 + + node?.visit(Heading(level: 2, Text("Section \(sectionIndex): \(tutorialSection.title)"))) + for child in tutorialSection.children { + node = visit(child) ?? node + } return nil } - public mutating func visitTutorialSection(_ tutorialSection: TutorialSection) -> MarkdownOutputNode? { - print(#function) - return nil + public mutating func visitSteps(_ steps: Steps) -> MarkdownOutputNode? { + stepIndex = 0 + for child in steps.children { + node = visit(child) ?? node + } + + if let code = lastCode { + node?.visit(code) + lastCode = nil + } + + return node } - public mutating func visitTutorial(_ tutorial: Tutorial) -> MarkdownOutputNode? { - print(#function) - return nil + public mutating func visitStep(_ step: Step) -> MarkdownOutputNode? { + stepIndex += 1 + node?.visit(Heading(level: 3, Text("Step \(stepIndex)"))) + for child in step.children { + node = visit(child) ?? node + } + if let media = step.media { + node = visit(media) ?? node + } + return node } public mutating func visitIntro(_ intro: Intro) -> MarkdownOutputNode? { - print(#function) - return nil + + node?.visit(Heading(level: 1, Text(intro.title))) + + for child in intro.children { + node = visit(child) ?? node + } + return node } - public mutating func visitXcodeRequirement(_ xcodeRequirement: XcodeRequirement) -> MarkdownOutputNode? { - print(#function) - return nil + public mutating func visitMarkupContainer(_ markupContainer: MarkupContainer) -> MarkdownOutputNode? { + node?.withRemoveIndentation { + $0.visit(container: markupContainer) + } + return node } - public mutating func visitAssessments(_ assessments: Assessments) -> MarkdownOutputNode? { - print(#function) - return nil + public mutating func visitImageMedia(_ imageMedia: ImageMedia) -> MarkdownOutputNode? { + node?.visit(imageMedia) + return node } - public mutating func visitMultipleChoice(_ multipleChoice: MultipleChoice) -> MarkdownOutputNode? { - print(#function) - return nil + public mutating func visitVideoMedia(_ videoMedia: VideoMedia) -> MarkdownOutputNode? { + node?.visit(videoMedia) + return node } - public mutating func visitJustification(_ justification: Justification) -> MarkdownOutputNode? { - print(#function) - return nil + public mutating func visitContentAndMedia(_ contentAndMedia: ContentAndMedia) -> MarkdownOutputNode? { + for child in contentAndMedia.children { + node = visit(child) ?? node + } + return node } - public mutating func visitChoice(_ choice: Choice) -> MarkdownOutputNode? { + public mutating func visitCode(_ code: Code) -> MarkdownOutputNode? { + if let lastCode, lastCode.fileName != code.fileName { + node?.visit(code) + } + lastCode = code + return nil + } +} + + +// MARK: Visitors not used for markdown output +extension MarkdownOutputNodeTranslator { + + public mutating func visitXcodeRequirement(_ xcodeRequirement: XcodeRequirement) -> MarkdownOutputNode? { print(#function) return nil } - public mutating func visitMarkupContainer(_ markupContainer: MarkupContainer) -> MarkdownOutputNode? { + public mutating func visitAssessments(_ assessments: Assessments) -> MarkdownOutputNode? { print(#function) return nil } - public mutating func visitTechnology(_ technology: TutorialTableOfContents) -> MarkdownOutputNode? { + public mutating func visitMultipleChoice(_ multipleChoice: MultipleChoice) -> MarkdownOutputNode? { print(#function) return nil } - public mutating func visitImageMedia(_ imageMedia: ImageMedia) -> MarkdownOutputNode? { + public mutating func visitJustification(_ justification: Justification) -> MarkdownOutputNode? { print(#function) return nil } - public mutating func visitVideoMedia(_ videoMedia: VideoMedia) -> MarkdownOutputNode? { + public mutating func visitChoice(_ choice: Choice) -> MarkdownOutputNode? { print(#function) return nil } - - public mutating func visitContentAndMedia(_ contentAndMedia: ContentAndMedia) -> MarkdownOutputNode? { + + public mutating func visitTechnology(_ technology: TutorialTableOfContents) -> MarkdownOutputNode? { print(#function) return nil } - + public mutating func visitVolume(_ volume: Volume) -> MarkdownOutputNode? { print(#function) return nil @@ -151,7 +214,6 @@ public struct MarkdownOutputNodeTranslator: SemanticVisitor { } public mutating func visitTutorialReference(_ tutorialReference: TutorialReference) -> MarkdownOutputNode? { - print(#function) return nil } @@ -180,15 +242,8 @@ public struct MarkdownOutputNodeTranslator: SemanticVisitor { return nil } - - public mutating func visitDeprecationSummary(_ summary: DeprecationSummary) -> MarkdownOutputNode? { print(#function) return nil } - - public func visitTutorialTableOfContents(_ tutorialTableOfContents: TutorialTableOfContents) -> MarkdownOutputNode? { - print(#function) - return nil - } } From 81d2d5a6833dddad31b83a3ad6912faf97276e18 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Fri, 5 Sep 2025 14:11:13 +0100 Subject: [PATCH 06/28] Be smarter about removing indentation from within block directives --- .../MarkdownOutput/MarkdownOutputNode.swift | 36 +++++++++++-------- .../MarkdownOutputNodeTranslator.swift | 2 +- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift index 54e422c6d..19cc568dc 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift @@ -22,7 +22,7 @@ public struct MarkdownOutputNode { } } - private(set) var removeIndentation = false + private(set) var indentationToRemove: String? private(set) var isRenderingLinkList = false public mutating func withRenderingLinkList(_ process: (inout MarkdownOutputNode) -> Void) { @@ -31,10 +31,19 @@ public struct MarkdownOutputNode { isRenderingLinkList = false } - public mutating func withRemoveIndentation(_ process: (inout MarkdownOutputNode) -> Void) { - removeIndentation = true + public mutating func withRemoveIndentation(from base: (any Markup)?, process: (inout MarkdownOutputNode) -> Void) { + indentationToRemove = nil + if let toRemove = base? + .format() + .splitByNewlines + .first(where: { $0.isEmpty == false })? + .prefix(while: { $0.isWhitespace && !$0.isNewline }) { + if toRemove.isEmpty == false { + indentationToRemove = String(toRemove) + } + } process(&self) - removeIndentation = false + indentationToRemove = nil } private var linkListAbstract: (any Markup)? @@ -76,12 +85,11 @@ extension MarkdownOutputNode { extension MarkdownOutputNode: MarkupWalker { public mutating func defaultVisit(_ markup: any Markup) -> () { - let output = markup.format() - if removeIndentation { - markdown.append(output.removingLeadingWhitespace()) - } else { - markdown.append(output) + var output = markup.format() + if let indentationToRemove, output.hasPrefix(indentationToRemove) { + output.removeFirst(indentationToRemove.count) } + markdown.append(output) } public mutating func visitHeading(_ heading: Heading) -> () { @@ -213,7 +221,7 @@ extension MarkdownOutputNode: MarkupWalker { } for column in row.columns { markdown.append("\n\n") - withRemoveIndentation { + withRemoveIndentation(from: column.childMarkup.first) { $0.visit(container: column.content) } } @@ -230,16 +238,16 @@ extension MarkdownOutputNode: MarkupWalker { // Don't make any assumptions about headings here let para = Paragraph([Strong(Text("\(tab.title):"))]) visit(para) - withRemoveIndentation { + withRemoveIndentation(from: tab.childMarkup.first) { $0.visit(container: tab.content) } } } case Links.directiveName: - withRemoveIndentation { - $0.withRenderingLinkList { - for child in blockDirective.children { + withRenderingLinkList { + for child in blockDirective.children { + $0.withRemoveIndentation(from: child) { $0.visit(child) } } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift index fab222328..ad83520b2 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift @@ -137,7 +137,7 @@ extension MarkdownOutputNodeTranslator { } public mutating func visitMarkupContainer(_ markupContainer: MarkupContainer) -> MarkdownOutputNode? { - node?.withRemoveIndentation { + node?.withRemoveIndentation(from: markupContainer.elements.first) { $0.visit(container: markupContainer) } return node From f3fa5abe02e1cf96f8929cc1fef61a9ef7c32246 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Mon, 8 Sep 2025 10:05:40 +0100 Subject: [PATCH 07/28] Baseline for adding new tests for markdown output --- .../Infrastructure/ConvertOutputConsumer.swift | 1 + .../Model/MarkdownOutput/MarkdownOutputNode.swift | 11 +++++++++++ .../MarkdownOutput/MarkdownOutputNodeTranslator.swift | 10 ++++++++++ 3 files changed, 22 insertions(+) diff --git a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift index 9297d5338..9b236e2aa 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift @@ -61,6 +61,7 @@ public extension ConvertOutputConsumer { func consume(renderReferenceStore: RenderReferenceStore) throws {} func consume(buildMetadata: BuildMetadata) throws {} func consume(linkResolutionInformation: SerializableLinkResolutionInformation) throws {} + func consume(markdownNode: MarkdownOutputNode) throws {} } // Default implementation so that conforming types don't need to implement deprecated API. diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift index 19cc568dc..e118e7dfd 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift @@ -1,5 +1,16 @@ +/* + This source file is part of the Swift.org open source project + + 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 + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + public import Foundation public import Markdown + /// A markdown version of a documentation node. public struct MarkdownOutputNode { diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift index ad83520b2..2f27ce273 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift @@ -1,3 +1,13 @@ +/* + This source file is part of the Swift.org open source project + + 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 + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + import Foundation import Markdown From 5013a09bde6e7369e1e848894763f6a4691d07b5 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Mon, 8 Sep 2025 12:18:39 +0100 Subject: [PATCH 08/28] Basic test infrastructure for markdown output --- .../MarkdownOutput/MarkdownOutputNode.swift | 21 ++++--- .../MarkdownOutputNodeTranslator.swift | 2 +- .../Markdown/MarkdownOutputTests.swift | 60 +++++++++++++++++++ .../MarkdownOutput.docc/Info.plist | 14 +++++ .../Test Bundles/MarkdownOutput.docc/Links.md | 13 ++++ .../MarkdownOutput.docc/MarkdownOutput.md | 11 ++++ .../MarkdownOutput.docc/RowsAndColumns.md | 14 +++++ 7 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Info.plist create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/RowsAndColumns.md diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift index e118e7dfd..c797a18aa 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2021-2025 Apple Inc. and the Swift project authors + 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 @@ -56,8 +56,6 @@ public struct MarkdownOutputNode { process(&self) indentationToRemove = nil } - - private var linkListAbstract: (any Markup)? } extension MarkdownOutputNode { @@ -115,10 +113,7 @@ extension MarkdownOutputNode: MarkupWalker { startNewParagraphIfRequired() for item in unorderedList.listItems { - linkListAbstract = nil item.children.forEach { visit($0) } - visit(linkListAbstract) - linkListAbstract = nil startNewParagraphIfRequired() } } @@ -152,6 +147,7 @@ extension MarkdownOutputNode: MarkupWalker { } let linkTitle: String + var linkListAbstract: (any Markup)? if isRenderingLinkList, let doc = try? context.entity(with: resolved), @@ -170,6 +166,7 @@ extension MarkdownOutputNode: MarkupWalker { } let link = Link(destination: destination, title: linkTitle, [InlineCode(linkTitle)]) visit(link) + visit(linkListAbstract) } public mutating func visitLink(_ link: Link) -> () { @@ -183,6 +180,7 @@ extension MarkdownOutputNode: MarkupWalker { } let linkTitle: String + var linkListAbstract: (any Markup)? if let article = doc.semantic as? Article { @@ -193,8 +191,17 @@ extension MarkdownOutputNode: MarkupWalker { } else { linkTitle = resolved.lastPathComponent } - let link = Link(destination: destination, title: linkTitle, [InlineCode(linkTitle)]) + + let linkMarkup: any RecurringInlineMarkup + if doc.semantic is Symbol { + linkMarkup = InlineCode(linkTitle) + } else { + linkMarkup = Text(linkTitle) + } + + let link = Link(destination: destination, title: linkTitle, [linkMarkup]) defaultVisit(link) + visit(linkListAbstract) } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift index 2f27ce273..9e6a91bc4 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2021-2025 Apple Inc. and the Swift project authors + 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 diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift new file mode 100644 index 000000000..2d5912074 --- /dev/null +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -0,0 +1,60 @@ +/* + 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 +*/ + +import Foundation +import XCTest +@testable import SwiftDocC + +final class MarkdownOutputTests: XCTestCase { + + static var loadingTask: Task<(DocumentationBundle, DocumentationContext), any Error>? + + func bundleAndContext() async throws -> (bundle: DocumentationBundle, context: DocumentationContext) { + + if let task = Self.loadingTask { + return try await task.value + } else { + let task = Task { + try await testBundleAndContext(named: "MarkdownOutput") + } + Self.loadingTask = task + return try await task.value + } + } + + private func generateMarkdown(path: String) async throws -> MarkdownOutputNode { + let (bundle, context) = try await bundleAndContext() + let reference = ResolvedTopicReference(bundleID: bundle.id, path: "/documentation/MarkdownOutput/\(path)", sourceLanguage: .swift) + let article = try XCTUnwrap(context.entity(with: reference).semantic) + var translator = MarkdownOutputNodeTranslator(context: context, bundle: bundle, identifier: reference) + let node = try XCTUnwrap(translator.visit(article)) + return node + } + + func testRowsAndColumns() async throws { + let node = try await generateMarkdown(path: "RowsAndColumns") + let expected = "I am the content of column one\n\nI am the content of column two" + XCTAssert(node.markdown.hasSuffix(expected)) + } + + func testInlineDocumentLinkFormatting() async throws { + let node = try await generateMarkdown(path: "Links") + let expected = "inline link: [Rows and Columns](doc://org.swift.MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns)" + XCTAssert(node.markdown.contains(expected)) + } + + func testTopicListLinkFormatting() async throws { + let node = try await generateMarkdown(path: "Links") + let expected = "[Rows and Columns](doc://org.swift.MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns)\n\nDemonstrates how row and column directives are rendered as markdown" + XCTAssert(node.markdown.contains(expected)) + } + + +} diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Info.plist b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Info.plist new file mode 100644 index 000000000..84193a341 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Info.plist @@ -0,0 +1,14 @@ + + + + + CFBundleName + MarkdownOutput + CFBundleDisplayName + MarkdownOutput + CFBundleIdentifier + org.swift.MarkdownOutput + CFBundleVersion + 0.1.0 + + diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md new file mode 100644 index 000000000..a6e33efc1 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md @@ -0,0 +1,13 @@ +# Links + +Tests the appearance of inline and linked lists + +## Overview + +This is an inline link: + +## Topics + +### Links with abstracts + +- diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md new file mode 100644 index 000000000..353baf325 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md @@ -0,0 +1,11 @@ +# ``MarkdownOutput`` + +This catalog contains various documents to test aspects of markdown output functionality + +## Topics + +### Directive Processing + +- + + diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/RowsAndColumns.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/RowsAndColumns.md new file mode 100644 index 000000000..1744c9ff8 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/RowsAndColumns.md @@ -0,0 +1,14 @@ +# Rows and Columns + +Demonstrates how row and column directives are rendered as markdown + +## Overview + +@Row { + @Column { + I am the content of column one + } + @Column { + I am the content of column two + } +} From 67981629ce1b4255e5272d488a9fead8573432cb Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Mon, 8 Sep 2025 13:22:53 +0100 Subject: [PATCH 09/28] Adds symbol link tests to markdown output --- .../Markdown/MarkdownOutputTests.swift | 19 +++++++++++++++---- .../Test Bundles/MarkdownOutput.docc/Links.md | 6 +++++- .../MarkdownOutput.docc/MarkdownOutput.md | 2 +- .../MarkdownOutput.symbols.json | 1 + .../MarkdownOutput.docc/RowsAndColumns.md | 2 ++ 5 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 2d5912074..d324f5059 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -44,17 +44,28 @@ final class MarkdownOutputTests: XCTestCase { XCTAssert(node.markdown.hasSuffix(expected)) } - func testInlineDocumentLinkFormatting() async throws { + func testInlineDocumentLinkArticleFormatting() async throws { let node = try await generateMarkdown(path: "Links") let expected = "inline link: [Rows and Columns](doc://org.swift.MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns)" XCTAssert(node.markdown.contains(expected)) } - func testTopicListLinkFormatting() async throws { + func testTopicListLinkArticleFormatting() async throws { let node = try await generateMarkdown(path: "Links") let expected = "[Rows and Columns](doc://org.swift.MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns)\n\nDemonstrates how row and column directives are rendered as markdown" XCTAssert(node.markdown.contains(expected)) } - - + + func testInlineDocumentLinkSymbolFormatting() async throws { + let node = try await generateMarkdown(path: "Links") + let expected = "inline link: [`MarkdownSymbol`](doc://org.swift.MarkdownOutput/documentation/MarkdownOutput/MarkdownSymbol)" + XCTAssert(node.markdown.contains(expected)) + } + + func testTopicListLinkSymbolFormatting() async throws { + let node = try await generateMarkdown(path: "Links") + let expected = "[`MarkdownSymbol`](doc://org.swift.MarkdownOutput/documentation/MarkdownOutput/MarkdownSymbol)\n\nA basic symbol to test markdown output." + XCTAssert(node.markdown.contains(expected)) + } + } diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md index a6e33efc1..9ab91d0d6 100644 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md @@ -5,9 +5,13 @@ Tests the appearance of inline and linked lists ## Overview This is an inline link: +This is an inline link: ``MarkdownSymbol`` ## Topics ### Links with abstracts -- +- +- ``MarkdownSymbol`` + + diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md index 353baf325..9f845d23a 100644 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md @@ -8,4 +8,4 @@ This catalog contains various documents to test aspects of markdown output funct - - + diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json new file mode 100644 index 000000000..ae673a319 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json @@ -0,0 +1 @@ +{"metadata":{"formatVersion":{"major":0,"minor":6,"patch":0},"generator":"Apple Swift version 6.2 (swiftlang-6.2.0.19.9 clang-1700.3.19.1)"},"module":{"name":"MarkdownOutput","platform":{"architecture":"arm64","vendor":"apple","operatingSystem":{"name":"macosx","minimumVersion":{"major":10,"minor":13}}}},"symbols":[{"kind":{"identifier":"swift.struct","displayName":"Structure"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol"],"names":{"title":"MarkdownSymbol","navigator":[{"kind":"identifier","spelling":"MarkdownSymbol"}],"subHeading":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"MarkdownSymbol"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":43}},"text":"A basic symbol to test markdown output."},{"range":{"start":{"line":1,"character":3},"end":{"line":1,"character":3}},"text":""},{"range":{"start":{"line":2,"character":4},"end":{"line":2,"character":39}},"text":"This is the overview of the symbol."}]},"declarationFragments":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"MarkdownSymbol"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","position":{"line":3,"character":14}}},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV4nameSSvp","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","name"],"names":{"title":"name","subHeading":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]},"declarationFragments":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","position":{"line":4,"character":15}}}],"relationships":[{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV4nameSSvp","target":"s:14MarkdownOutput0A6SymbolV"}]} diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/RowsAndColumns.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/RowsAndColumns.md index 1744c9ff8..2aa55277c 100644 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/RowsAndColumns.md +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/RowsAndColumns.md @@ -12,3 +12,5 @@ Demonstrates how row and column directives are rendered as markdown I am the content of column two } } + + From 132cd6caa75a1d0bc223a0be124b41e04dbc0637 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Mon, 8 Sep 2025 14:48:16 +0100 Subject: [PATCH 10/28] Tutoorial code rendering markdown tests --- .../MarkdownOutput/MarkdownOutputNode.swift | 8 ++- .../MarkdownOutputNodeTranslator.swift | 22 ++++++-- .../Markdown/MarkdownOutputTests.swift | 47 +++++++++++++++++- .../Resources/Images/placeholder~light@2x.png | Bin 0 -> 4618 bytes .../Resources/code-files/01-step-01.swift | 3 ++ .../Test Bundles/MarkdownOutput.docc/Tabs.md | 27 ++++++++++ .../MarkdownOutput.docc/Tutorial.tutorial | 36 ++++++++++++++ 7 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/Images/placeholder~light@2x.png create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-01.swift create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tabs.md create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tutorial.tutorial diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift index c797a18aa..d3352cc6b 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift @@ -24,7 +24,11 @@ public struct MarkdownOutputNode { self.identifier = identifier } - public var metadata: [String: String] = [:] + public struct Metadata { + static let version = SemanticVersion(major: 0, minor: 1, patch: 0) + } + + public var metadata: Metadata = Metadata() public var markdown: String = "" public var data: Data { @@ -249,7 +253,7 @@ extension MarkdownOutputNode: MarkupWalker { } if let defaultLanguage = context.sourceLanguages(for: identifier).first?.name, - let languageMatch = tabs.tabs.first(where: { $0.title.lowercased() == defaultLanguage }) { + let languageMatch = tabs.tabs.first(where: { $0.title.lowercased() == defaultLanguage.lowercased() }) { visit(container: languageMatch.content) } else { for tab in tabs.tabs { diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift index 9e6a91bc4..f903de150 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift @@ -125,6 +125,23 @@ extension MarkdownOutputNodeTranslator { } public mutating func visitStep(_ step: Step) -> MarkdownOutputNode? { + + // Check if the step contains another version of the current code reference + if let code = lastCode { + if let stepCode = step.code { + if stepCode.fileName != code.fileName { + // New reference, render before proceeding + node?.visit(code) + } + } else { + // No code, render the current one before proceeding + node?.visit(code) + lastCode = nil + } + } + + lastCode = step.code + stepIndex += 1 node?.visit(Heading(level: 3, Text("Step \(stepIndex)"))) for child in step.children { @@ -171,10 +188,7 @@ extension MarkdownOutputNodeTranslator { } public mutating func visitCode(_ code: Code) -> MarkdownOutputNode? { - if let lastCode, lastCode.fileName != code.fileName { - node?.visit(code) - } - lastCode = code + // Code rendering is handled in visitStep(_:) return nil } } diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index d324f5059..a8ef453c1 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -29,19 +29,28 @@ final class MarkdownOutputTests: XCTestCase { } } + /// Generates markdown from a given path + /// - Parameter path: The path. If you just supply a name (no leading slash), it will prepend `/documentation/MarkdownOutput/`, otherwise the path will be used + /// - Returns: The generated markdown output node private func generateMarkdown(path: String) async throws -> MarkdownOutputNode { let (bundle, context) = try await bundleAndContext() - let reference = ResolvedTopicReference(bundleID: bundle.id, path: "/documentation/MarkdownOutput/\(path)", sourceLanguage: .swift) + var path = path + if !path.hasPrefix("/") { + path = "/documentation/MarkdownOutput/\(path)" + } + let reference = ResolvedTopicReference(bundleID: bundle.id, path: path, sourceLanguage: .swift) let article = try XCTUnwrap(context.entity(with: reference).semantic) var translator = MarkdownOutputNodeTranslator(context: context, bundle: bundle, identifier: reference) let node = try XCTUnwrap(translator.visit(article)) return node } + // MARK: Directive special processing + func testRowsAndColumns() async throws { let node = try await generateMarkdown(path: "RowsAndColumns") let expected = "I am the content of column one\n\nI am the content of column two" - XCTAssert(node.markdown.hasSuffix(expected)) + XCTAssert(node.markdown.contains(expected)) } func testInlineDocumentLinkArticleFormatting() async throws { @@ -68,4 +77,38 @@ final class MarkdownOutputTests: XCTestCase { XCTAssert(node.markdown.contains(expected)) } + func testLanguageTabOnlyIncludesPrimaryLanguage() async throws { + let node = try await generateMarkdown(path: "Tabs") + XCTAssertFalse(node.markdown.contains("I am an Objective-C code block")) + XCTAssertTrue(node.markdown.contains("I am a Swift code block")) + } + + func testNonLanguageTabIncludesAllEntries() async throws { + let node = try await generateMarkdown(path: "Tabs") + XCTAssertTrue(node.markdown.contains("**Left:**\n\nLeft text")) + XCTAssertTrue(node.markdown.contains("**Right:**\n\nRight text")) + } + + func testTutorialCodeIsOnlyTheFinalVersion() async throws { + let node = try await generateMarkdown(path: "/tutorials/MarkdownOutput/Tutorial") + XCTAssertFalse(node.markdown.contains("// STEP ONE")) + XCTAssertFalse(node.markdown.contains("// STEP TWO")) + XCTAssertTrue(node.markdown.contains("// STEP THREE")) + } + + func testTutorialCodeAddedAtFinalReferencedStep() async throws { + let node = try await generateMarkdown(path: "/tutorials/MarkdownOutput/Tutorial") + let codeIndex = try XCTUnwrap(node.markdown.firstRange(of: "// STEP THREE")) + let step4Index = try XCTUnwrap(node.markdown.firstRange(of: "### Step 4")) + XCTAssert(codeIndex.lowerBound < step4Index.lowerBound) + } + + func testTutorialCodeWithNewFileIsAdded() async throws { + let node = try await generateMarkdown(path: "/tutorials/MarkdownOutput/Tutorial") + XCTAssertTrue(node.markdown.contains("struct StartCodeAgain {")) + print(node.markdown) + } + + + } diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/Images/placeholder~light@2x.png b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/Images/placeholder~light@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..5b5494fdd74cc7bf92ff8c2948cff3c422c2f93c GIT binary patch literal 4618 zcmeHLX#eM;Hl8^CU4WI<%ClBhD{ie_306!YH-uJJo4ZeZ_f1erDKrumvP(4br}W?o zuicq1_B^dw7)%sOEbbEzp_q_6l~>&mM+h#g!%>%>|iyH0r8O^8X z2THL=txqPtNxyf+*Fnu#(kiQWqCFW_8GHZv{;9#=kON2cmpN8gKaR*Sg|ZOMQ<+{7 zk&zFB_8)}9*=)Ao$&(wXiDa^IBw519$26umyStOLRAw;q3JzD9kCQZ|S~- z!*OzQ!Y(bP@|tRDZU-!PV9fys=%$1Lr7nvpl55vzdQ}G%mz7~L7*9(v+O)M{9@@7B ztVYDd#3Usx_OrsWv$LaZ54AYHQR*xUG%9-Ok+J{u>C;nOvyp(pM@5xmI8Jc5CsDP> zQqem@Hb7jxuw0@@R*pgnMP>fpy?f2ZDHL|;F80Y}MgHpa7yplMn0*L0w;dZ+=jG*1 z#uFnVA}(B*9M5sfX)w)~*k@F{2!A8jrt&b%zTbnHLb``zQHKaEGn>ux);h(=u9_?l zByxCYXjYs0s;rbwe=3HO8qTwVMg9H3!NKhM!=kHkagSaehKtirS?Z1bE+t)|tVN?~42Nee~Jv|MHxz@JGv!S8S$J#$B+~lp9c%4VH z{t8@pW@ZN5S)ssjMB?FL^1@j21;b2iV1SYgGrd)LdE*vNf(&x?@PHB!V7V_wJ3l`UD5K1?KP}+fL^ZCY z*?J%XGk1@~)P=HwX*61LaA8 zpNB(GQhE0?!Af=3GT63J{GR8T)|Zc#piG)T-%CWIezKzoU`6x^-d|8ySV++|NC4w2 z@{^L2QJ$X}B8$5&+O~#;hNkLOli=|DHlTT`E zYXRcIQwRhCP|4+)k+yYxW_&#voQV7p&Fot?nFqe_;MLDP;si4p`QEUy^H)1gI3zZV zOWX4 zMn2#fph*trPn9a3vH}poeftI+Ss!g?X(HYx&t(SfFYtQc&k;GH#Q-9B5l{l31JauS zg_*7r7ZZgVAFo;%Zw(9#oPzlu_*A_J$RD4Q;wn&ozTxwUX#JZ*jD*xw#G=>E9B;1p z;HTLjQy7ww?KuR%Z4~kdQ-TZG#l^*;VPRodtVLC|E{qk-?DKUPsV7ALwQr%ly}h&Z zr0>iT#xj%Bjf9JXLnb(e>N4`g44VF#VRuZM;B-26S#OIzK@`YEw-o(X`y$0N>`wg_r6R z-#R)v65A5yv^#`xT9qOt19@E(dlc2WBnFvKc5vLznobZkb^iM%*fi*IcQ^CD*_ij> z+1S`jhEr8ucR{sTESBlvhxB#F*Xjr7(QoZ_=AzfzMk=gHHTeOI0EYr!;_8>Jw9c$_ zeZ{+_%f`VcVkkeL9&MgD_ASw#)b&mCW>wZTVM3usqj@qIi^Jh$39LW(@?}bvGn?LUQgbn>Uat)AC0)fzVQ9XXyM;)7o>?GY|;@xImwfjItd#qd0IRpZ1C!-~DFZ0ZrJy zd_jdM#V#evHp_k*7ICmJ8=C@OMqOQn*N*U$1lBuh(IetS%tm~lu>G3hMyFMq0wZ+A_NRW zDwR%KZ`7X?c6FuY+P%D3dvDF9DVYpMmO3!BS}l2{vK2-pn~nNJ!Sl4(OP8wqV*2D- zkRN%6;QG~W>~r9?zbKEhf_J6OZf1{J##fPyP6#A&PMb>HJD6$hO5ofA$B@tcRpNq& zhsole4-ACVJiN+0}L^rWc?vv11~wAj+pl6eq`K(uqcY(_tx zyZAnt@^`hKrhE79H5!ANiGkczcjXU6DwmqCxq4Ukru^oX)$vrmjvqPD3pkOrVT%bTrA^jJOV# zdA$1^B^He~4!iX0m*pJwIWGBzEa$)x{5qnS;QFRqE`^JyzE8YGscxe%&Jj=fwLYX10Ichp^=mZ@r4msA?T6?a_$ z65;r1Hi<-H19c39%R_=j!bt}n)#6FZuFO}A z@){Z&jb@XeClxTGgn?v{;{(b!O*Zuc>d03=6Z$(^fcw6rW-gvA|9P|5*?G^s>{{UZ* B$Nc~R literal 0 HcmV?d00001 diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-01.swift b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-01.swift new file mode 100644 index 000000000..e3458faa6 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-01.swift @@ -0,0 +1,3 @@ +struct StartCode { + // STEP ONE +} diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tabs.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tabs.md new file mode 100644 index 000000000..d8cb3ff84 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tabs.md @@ -0,0 +1,27 @@ +# Tabs + +Showing how language tabs only render the primary language, but other tabs render all instances. + +## Overview + +@TabNavigator { + @Tab("Objective-C") { + ```objc + I am an Objective-C code block + ``` + } + @Tab("Swift") { + ```swift + I am a Swift code block + ``` + } +} + +@TabNavigator { + @Tab("Left") { + Left text + } + @Tab("Right") { + Right text + } +} diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tutorial.tutorial b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tutorial.tutorial new file mode 100644 index 000000000..2e3adb785 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tutorial.tutorial @@ -0,0 +1,36 @@ +@Tutorial(time: 30) { + @Intro(title: "Tutorial") { + A tutorial for testing markdown output. + + @Image(source: placeholder.png, alt: "Alternative text") + } + + @Section(title: "The first section") { + + Here is some free floating content + + @Steps { + @Step { + Do the first set of things + @Code(name: "File.swift", file: 01-step-01.swift) + } + + Inter-step content + + @Step { + Do the second set of things + @Code(name: "File.swift", file: 01-step-02.swift) + } + + @Step { + Do the third set of things + @Code(name: "File.swift", file: 01-step-03.swift) + } + + @Step { + Do the fourth set of things + @Code(name: "File2.swift", file: 02-step-01.swift) + } + } + } +} From 1753ccaf5ad90da67f9804c0c957aa8740fb0bd8 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Mon, 8 Sep 2025 20:06:31 +0100 Subject: [PATCH 11/28] Adding metadata to markdown output --- .../MarkdownOutput/MarkdownOutputNode.swift | 27 +++++--- .../MarkdownOutputNodeMetadata.swift | 43 ++++++++++++ .../MarkdownOutputNodeTranslator.swift | 29 ++++++++- .../Markdown/MarkdownOutputTests.swift | 61 +++++++++++++++++- .../MarkdownOutput.symbols.json | 2 +- .../Resources/Images/placeholder~dark@2x.png | Bin 0 -> 4729 bytes .../Resources/code-files/01-step-02.swift | 4 ++ .../Resources/code-files/01-step-03.swift | 5 ++ .../Resources/code-files/02-step-01.swift | 3 + .../Test Bundles/MarkdownOutput.docc/Tabs.md | 2 + .../MarkdownOutput.docc/Tutorial.tutorial | 4 +- 11 files changed, 166 insertions(+), 14 deletions(-) create mode 100644 Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/Images/placeholder~dark@2x.png create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-02.swift create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-03.swift create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/02-step-01.swift diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift index d3352cc6b..7ac41d95d 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift @@ -17,35 +17,46 @@ public struct MarkdownOutputNode { public let context: DocumentationContext public let bundle: DocumentationBundle public let identifier: ResolvedTopicReference + public var metadata: Metadata - public init(context: DocumentationContext, bundle: DocumentationBundle, identifier: ResolvedTopicReference) { + public init(context: DocumentationContext, bundle: DocumentationBundle, identifier: ResolvedTopicReference, documentType: Metadata.DocumentType) { self.context = context self.bundle = bundle self.identifier = identifier + self.metadata = Metadata(documentType: documentType, bundle: bundle, reference: identifier) } - - public struct Metadata { - static let version = SemanticVersion(major: 0, minor: 1, patch: 0) - } - - public var metadata: Metadata = Metadata() + + /// The markdown content of this node public var markdown: String = "" + /// Data for this node to be rendered to disk public var data: Data { get throws { - Data(markdown.utf8) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted] + let metadata = try encoder.encode(metadata) + let commentOpen = "\n\n".utf8 + var data = Data() + data.append(contentsOf: commentOpen) + data.append(metadata) + data.append(contentsOf: commentClose) + data.append(contentsOf: markdown.utf8) + return data } } private(set) var indentationToRemove: String? private(set) var isRenderingLinkList = false + /// Perform actions while rendering a link list, which affects the output formatting of links public mutating func withRenderingLinkList(_ process: (inout MarkdownOutputNode) -> Void) { isRenderingLinkList = true process(&self) isRenderingLinkList = false } + /// Perform actions while removing a base level of indentation, typically while processing the contents of block directives. public mutating func withRemoveIndentation(from base: (any Markup)?, process: (inout MarkdownOutputNode) -> Void) { indentationToRemove = nil if let toRemove = base? diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift new file mode 100644 index 000000000..f55f1e5ec --- /dev/null +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift @@ -0,0 +1,43 @@ +/* + 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 +*/ + +extension MarkdownOutputNode { + public struct Metadata: Codable { + + static let version = SemanticVersion(major: 0, minor: 1, patch: 0) + public enum DocumentType: String, Codable { + case article, tutorial, symbol + } + + public struct Availability: Codable, Equatable { + let platform: String + let introduced: String? + let deprecated: String? + let unavailable: String? + } + + public let version: String + public let documentType: DocumentType + public let uri: String + public var title: String + public let framework: String + public var symbolKind: String? + public var symbolAvailability: [Availability]? + + public init(documentType: DocumentType, bundle: DocumentationBundle, reference: ResolvedTopicReference) { + self.documentType = documentType + self.version = Self.version.description + self.uri = reference.path + self.title = reference.lastPathComponent + self.framework = bundle.displayName + } + } + +} diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift index f903de150..acc11e06a 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift @@ -36,7 +36,10 @@ public struct MarkdownOutputNodeTranslator: SemanticVisitor { extension MarkdownOutputNodeTranslator { public mutating func visitArticle(_ article: Article) -> MarkdownOutputNode? { - var node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier) + var node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier, documentType: .article) + if let title = article.title?.plainText { + node.metadata.title = title + } node.visit(article.title) node.visit(article.abstract) @@ -53,7 +56,12 @@ extension MarkdownOutputNodeTranslator { extension MarkdownOutputNodeTranslator { public mutating func visitSymbol(_ symbol: Symbol) -> MarkdownOutputNode? { - var node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier) + var node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier, documentType: .symbol) + + node.metadata.symbolKind = symbol.kind.displayName + node.metadata.symbolAvailability = symbol.availability?.availability.map { + MarkdownOutputNode.Metadata.Availability($0) + } node.visit(Heading(level: 1, Text(symbol.title))) node.visit(symbol.abstract) @@ -84,6 +92,17 @@ extension MarkdownOutputNodeTranslator { } } +import SymbolKit + +extension MarkdownOutputNode.Metadata.Availability { + init(_ item: SymbolGraph.Symbol.Availability.AvailabilityItem) { + self.platform = item.domain?.rawValue ?? "*" + self.introduced = item.introducedVersion?.description + self.deprecated = item.deprecatedVersion?.description + self.unavailable = item.obsoletedVersion?.description + } +} + // MARK: Tutorial Output extension MarkdownOutputNodeTranslator { // Tutorial table of contents is not useful as markdown or indexable content @@ -92,7 +111,11 @@ extension MarkdownOutputNodeTranslator { } public mutating func visitTutorial(_ tutorial: Tutorial) -> MarkdownOutputNode? { - node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier) + node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier, documentType: .tutorial) + if tutorial.intro.title.isEmpty == false { + node?.metadata.title = tutorial.intro.title + } + sectionIndex = 0 for child in tutorial.children { node = visit(child) ?? node diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index a8ef453c1..623b2d6f1 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -106,7 +106,66 @@ final class MarkdownOutputTests: XCTestCase { func testTutorialCodeWithNewFileIsAdded() async throws { let node = try await generateMarkdown(path: "/tutorials/MarkdownOutput/Tutorial") XCTAssertTrue(node.markdown.contains("struct StartCodeAgain {")) - print(node.markdown) + } + + // MARK: - Metadata + + func testArticleDocumentType() async throws { + let node = try await generateMarkdown(path: "Links") + XCTAssert(node.metadata.documentType == .article) + } + + func testArticleTitle() async throws { + let node = try await generateMarkdown(path: "RowsAndColumns") + XCTAssert(node.metadata.title == "Rows and Columns") + } + + func testSymbolDocumentType() async throws { + let node = try await generateMarkdown(path: "MarkdownSymbol") + XCTAssert(node.metadata.documentType == .symbol) + } + + func testSymbolTitle() async throws { + let node = try await generateMarkdown(path: "MarkdownSymbol/init(name:)") + XCTAssert(node.metadata.title == "init(name:)") + } + + func testSymbolKind() async throws { + let node = try await generateMarkdown(path: "MarkdownSymbol/init(name:)") + XCTAssert(node.metadata.symbolKind == "Initializer") + } + + func testSymbolDeprecation() async throws { + let node = try await generateMarkdown(path: "MarkdownSymbol/fullName") + let availability = try XCTUnwrap(node.metadata.symbolAvailability) + XCTAssertEqual(availability[0], .init(platform: "iOS", introduced: "1.0.0", deprecated: "4.0.0", unavailable: nil)) + XCTAssertEqual(availability[1], .init(platform: "macOS", introduced: "2.0.0", deprecated: "4.0.0", unavailable: nil)) + } + + func testSymbolObsolete() async throws { + let node = try await generateMarkdown(path: "MarkdownSymbol/otherName") + let availability = try XCTUnwrap(node.metadata.symbolAvailability) + XCTAssertEqual(availability[0], .init(platform: "iOS", introduced: nil, deprecated: nil, unavailable: "5.0.0")) + } + + func testTutorialDocumentType() async throws { + let node = try await generateMarkdown(path: "/tutorials/MarkdownOutput/Tutorial") + XCTAssert(node.metadata.documentType == .tutorial) + } + + func testTutorialTitle() async throws { + let node = try await generateMarkdown(path: "/tutorials/MarkdownOutput/Tutorial") + XCTAssert(node.metadata.title == "Tutorial Title") + } + + func testURI() async throws { + let node = try await generateMarkdown(path: "Links") + XCTAssert(node.metadata.uri == "/documentation/MarkdownOutput/Links") + } + + func testFramework() async throws { + let node = try await generateMarkdown(path: "MarkdownSymbol") + XCTAssert(node.metadata.framework == "MarkdownOutput") } diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json index ae673a319..f235e5a0c 100644 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json @@ -1 +1 @@ -{"metadata":{"formatVersion":{"major":0,"minor":6,"patch":0},"generator":"Apple Swift version 6.2 (swiftlang-6.2.0.19.9 clang-1700.3.19.1)"},"module":{"name":"MarkdownOutput","platform":{"architecture":"arm64","vendor":"apple","operatingSystem":{"name":"macosx","minimumVersion":{"major":10,"minor":13}}}},"symbols":[{"kind":{"identifier":"swift.struct","displayName":"Structure"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol"],"names":{"title":"MarkdownSymbol","navigator":[{"kind":"identifier","spelling":"MarkdownSymbol"}],"subHeading":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"MarkdownSymbol"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":43}},"text":"A basic symbol to test markdown output."},{"range":{"start":{"line":1,"character":3},"end":{"line":1,"character":3}},"text":""},{"range":{"start":{"line":2,"character":4},"end":{"line":2,"character":39}},"text":"This is the overview of the symbol."}]},"declarationFragments":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"MarkdownSymbol"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","position":{"line":3,"character":14}}},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV4nameSSvp","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","name"],"names":{"title":"name","subHeading":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]},"declarationFragments":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","position":{"line":4,"character":15}}}],"relationships":[{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV4nameSSvp","target":"s:14MarkdownOutput0A6SymbolV"}]} +{"metadata":{"formatVersion":{"major":0,"minor":6,"patch":0},"generator":"Apple Swift version 6.2 (swiftlang-6.2.0.19.9 clang-1700.3.19.1)"},"module":{"name":"MarkdownOutput","platform":{"architecture":"arm64","vendor":"apple","operatingSystem":{"name":"macosx","minimumVersion":{"major":10,"minor":13}}}},"symbols":[{"kind":{"identifier":"swift.struct","displayName":"Structure"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol"],"names":{"title":"MarkdownSymbol","navigator":[{"kind":"identifier","spelling":"MarkdownSymbol"}],"subHeading":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"MarkdownSymbol"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":43}},"text":"A basic symbol to test markdown output."},{"range":{"start":{"line":1,"character":3},"end":{"line":1,"character":3}},"text":""},{"range":{"start":{"line":2,"character":4},"end":{"line":2,"character":39}},"text":"This is the overview of the symbol."}]},"declarationFragments":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"MarkdownSymbol"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","position":{"line":3,"character":14}}},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV4nameSSvp","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","name"],"names":{"title":"name","subHeading":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]},"declarationFragments":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","position":{"line":4,"character":15}}},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV8fullNameSSvp","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","fullName"],"names":{"title":"fullName","subHeading":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"fullName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]},"declarationFragments":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"fullName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}],"accessLevel":"public","availability":[{"domain":"macOS","introduced":{"major":2,"minor":0},"deprecated":{"major":4,"minor":0},"message":"Don't be so formal"},{"domain":"iOS","introduced":{"major":1,"minor":0},"deprecated":{"major":4,"minor":0},"message":"Don't be so formal"}],"location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","position":{"line":8,"character":15}}},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV9otherNameSSSgvp","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","otherName"],"names":{"title":"otherName","subHeading":[{"kind":"keyword","spelling":"var"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"otherName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":"?"}]},"declarationFragments":[{"kind":"keyword","spelling":"var"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"otherName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":"?"}],"accessLevel":"public","availability":[{"domain":"iOS","obsoleted":{"major":5,"minor":0}}],"location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","position":{"line":11,"character":15}}},{"kind":{"identifier":"swift.init","displayName":"Initializer"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV4nameACSS_tcfc","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","init(name:)"],"names":{"title":"init(name:)","subHeading":[{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":")"}]},"functionSignature":{"parameters":[{"name":"name","declarationFragments":[{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]}]},"declarationFragments":[{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":")"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","position":{"line":13,"character":11}}}],"relationships":[{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV4nameSSvp","target":"s:14MarkdownOutput0A6SymbolV"},{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV8fullNameSSvp","target":"s:14MarkdownOutput0A6SymbolV"},{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV9otherNameSSSgvp","target":"s:14MarkdownOutput0A6SymbolV"},{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV4nameACSS_tcfc","target":"s:14MarkdownOutput0A6SymbolV"}]} \ No newline at end of file diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/Images/placeholder~dark@2x.png b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/Images/placeholder~dark@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..7e32851706c54c4a13b28db69985e118b0a9fa29 GIT binary patch literal 4729 zcmeHL`&*J{A7^V@%~IQ2)0qdhxp!2Ww5eH=vQAcP!(McnhqUZqcq~a&P}rvFS{a$A zOp(j=5^tGO84@8fGZi%@Wq_zi9?%RC0udB}_xA7DzSs5q0MEny-1qnUIbB>ihCXa> zx5W+uf!H5Cau5T7ynh@5dGE%$_rW)o$SXn!y?rK-+6?uNzh8>SnUHj9mV;>=IH1y;4eVsp-Vr^2x_L8Zy zpCHVlF{0+a)?!q^Aiv!=B{LzAmD>dye}47P^-mA}ju`0SFWPB)w^jD?Z59m$Ii@j! z;>zcHdU~Eee?B%g))waJ>8T)@Q&F>R`D z^5oKls=9BQM>Jwk$Q5n7)oLwCyxK9*_V%ak_kgMYf#%N49UaQ)X~teWWS_o@;qCaHpZ6VZD#Or`&4^8QmaJNtqO> z^3NA9UdXvwy-Hd@2j*UQy*P3g=(L+-rmaE{;L=m;e}jBbdi7e9LRsH&<8OwI=mr{x$v zMy>NC=%%{I4-~iPsWmk%d1gEvQ$?lbXANx zKp3jJx>~Q-t5hlk0+A!#>+2g~7$Cxb3eqfjX1C1oWAiVU{SfWd0Pi9LVX zy4em4PK_rL2!u0drX&q9@VJ)U8cj(vn_W~?XFwoJ6bD0(g*?XBlV)Zc1VOcEr)PqsH?q zgV_R3-O9H-f%cFTYEAy)1Q7xC22@v5lReuXafTSd#|%wT2_pFTi&Ttr@+$+jb?tHWYUh$ ziWo@CLKstu_@CjdOrcN=4P^re=x2mssMDuUi$tQzrz`Et7OO-e866#+m`FfjlalU- zZ>JKmoX36V^e+x8P>bVeY$BjbX4KzcfF@h&TV@J+4ad^C-?7uA60M%wYi(Tm$YL zSBGV?9K&1jRcAP9NmQ`(BnmaF9$~Kux#H~X91s9dOWP*4S&jTKR5?odEA+?y`7b*F z7~*4M>ch7ehfE5>0L%=8Jnif=-!wp<3{DLNJ~J~D`ru?U*x$p|dZoq-_2!L*8+Ax560|1In6>cTxl27WSaP)Tt( z9L;aM>gR$RGA$9){WY#{+ksjAPL_f5Y-1VR|xr`<)QC+Kvy+Ho)!QNqBVb=IGTY$ z83;l2GI;4z!SMcblCAfSdjs8wsQ35vEyXNr#}ABgXb0-W+nuf^CnpzWO?(ao(kpyeLLw_Z{jiT`M>n*s8^~tXYcg z7Ucp0i0dO~PW0JUg}u31a^BBD=-wbs0`8M@%jlP=0Go1#vUhiJyKFnZxb_BU3nK_n zu^i@A8|onsG##Vu@bQV`lk&2&vjNgi+HM*(G@^rmqs%ExYLK5QC|n*J`hAJL2D=O?S-8QaDoIquit zD_=@Knf_(8Iw1Wzg_6aUM>6A1tT@@Nt*ER_mp>DOJv2T@XGH@UT~p{1jO{&Y%L#B$ zC|`$huHR@c*!hQNe|kdn1Nzd1x9>7S3xSe>J6#m$jG1nk4&dj2W}mhEMx|1Vi;D@o+QWxc zhUHhUUX?uR^IU!THs!boMF)~c87UMq$mbL$%4*ZJ2V1O(^L0U~RiRA+?FE3wTUqJSRgamC- z;YtjX$s}KQt@Gbfyw~qpb2EN+edx9zvb80m-rmuXY!FAT+L|DW-46PsG7vo6EpIXx zXwLvM9O3}S*20|npFjAIPuGhV2k98u$Ou9mi}ol(nC3uc(EoQ4STL>mC5nDCaRX^# zOX2ryz=TK*(3>^$-w!6Sbf1iul*Uy4TR5r4b7yK>{f;xG#KpNNz5=>s%QLdgdX`Fg zDNEi}4IEgOGVS8W;F-dJonTsx$57aF=cXe$s*SeOpd%&5FmN_T@u}&sZx0+euomZ{ zqb~pkDH5gyDv)I6=2Gs3zcmo@VQ!?ag*2qbXl6yL0U=<`7jkp=Dq>SpQ`NXp8AqI? z^s;lfUJzUaTDYTY-LU_~jW$2jRz*=y7&km|JE z;KdBN#@=DmexH4Gj0+SRx(0q>Y-mW3Sm!YR=}o?28&YSG#)tm+qrJWTr*@ZfKR5LS zD5mNKGt<+gazuVgYAP@L-5&}~=np^Sn$>JzI5`FseQ;=K2qYN(^4ogMy}#6WT5ujA z3h767KWN2V-peNghf}Ou(C3&JQ-j;880W!maVxqH_pE9DU7rf%bESEP$Rp|d0(qENh)7Fqz<0fO>;Rz$NbcUfHL#Mj^mJxpV=K@?LCv4>W5dJ4fF8H_ zx7ZQ&<-N+b_?N_sX0v%IAs>v@mQa}qI3g4Z<9P(u!1*U5>Z^*;%;aP*vR7E^CLn!OY(;{0Uo+T3chn&XM?kk^ruZeKv^ kKK|$Wrw4x%3|OG({`2;Jp2B4CmlEXYA@sq<{U?9@KM%Do)c^nh literal 0 HcmV?d00001 diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-02.swift b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-02.swift new file mode 100644 index 000000000..3885e0692 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-02.swift @@ -0,0 +1,4 @@ +struct StartCode { + // STEP TWO + let property1: Int +} diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-03.swift b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-03.swift new file mode 100644 index 000000000..71968c3a5 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-03.swift @@ -0,0 +1,5 @@ +struct StartCode { + // STEP THREE + let property1: Int + let property2: Int +} diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/02-step-01.swift b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/02-step-01.swift new file mode 100644 index 000000000..dbf7b5620 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/02-step-01.swift @@ -0,0 +1,3 @@ +struct StartCodeAgain { + +} diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tabs.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tabs.md index d8cb3ff84..3034fe024 100644 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tabs.md +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tabs.md @@ -25,3 +25,5 @@ Showing how language tabs only render the primary language, but other tabs rende Right text } } + + diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tutorial.tutorial b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tutorial.tutorial index 2e3adb785..dd409ea55 100644 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tutorial.tutorial +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tutorial.tutorial @@ -1,5 +1,5 @@ @Tutorial(time: 30) { - @Intro(title: "Tutorial") { + @Intro(title: "Tutorial Title") { A tutorial for testing markdown output. @Image(source: placeholder.png, alt: "Alternative text") @@ -34,3 +34,5 @@ } } } + + From 301d7da3804edc4eabeab14789819e468b4a8edd Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 9 Sep 2025 09:53:13 +0100 Subject: [PATCH 12/28] Include package source for markdown output test catalog --- .../MarkdownOutput.docc/MarkdownOutput.md | 4 + .../MarkdownOutput.symbols.json | 509 +++++++++++++++++- .../original-source/MarkdownOutput.zip | Bin 0 -> 1572 bytes 3 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/original-source/MarkdownOutput.zip diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md index 9f845d23a..2b39fe11b 100644 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md @@ -2,6 +2,10 @@ This catalog contains various documents to test aspects of markdown output functionality +## Overview + +The symbol graph included in this catalog is generated from a package held in the original-source folder. + ## Topics ### Directive Processing diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json index f235e5a0c..bd2c58062 100644 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json @@ -1 +1,508 @@ -{"metadata":{"formatVersion":{"major":0,"minor":6,"patch":0},"generator":"Apple Swift version 6.2 (swiftlang-6.2.0.19.9 clang-1700.3.19.1)"},"module":{"name":"MarkdownOutput","platform":{"architecture":"arm64","vendor":"apple","operatingSystem":{"name":"macosx","minimumVersion":{"major":10,"minor":13}}}},"symbols":[{"kind":{"identifier":"swift.struct","displayName":"Structure"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol"],"names":{"title":"MarkdownSymbol","navigator":[{"kind":"identifier","spelling":"MarkdownSymbol"}],"subHeading":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"MarkdownSymbol"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":43}},"text":"A basic symbol to test markdown output."},{"range":{"start":{"line":1,"character":3},"end":{"line":1,"character":3}},"text":""},{"range":{"start":{"line":2,"character":4},"end":{"line":2,"character":39}},"text":"This is the overview of the symbol."}]},"declarationFragments":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"MarkdownSymbol"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","position":{"line":3,"character":14}}},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV4nameSSvp","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","name"],"names":{"title":"name","subHeading":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]},"declarationFragments":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","position":{"line":4,"character":15}}},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV8fullNameSSvp","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","fullName"],"names":{"title":"fullName","subHeading":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"fullName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]},"declarationFragments":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"fullName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}],"accessLevel":"public","availability":[{"domain":"macOS","introduced":{"major":2,"minor":0},"deprecated":{"major":4,"minor":0},"message":"Don't be so formal"},{"domain":"iOS","introduced":{"major":1,"minor":0},"deprecated":{"major":4,"minor":0},"message":"Don't be so formal"}],"location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","position":{"line":8,"character":15}}},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV9otherNameSSSgvp","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","otherName"],"names":{"title":"otherName","subHeading":[{"kind":"keyword","spelling":"var"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"otherName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":"?"}]},"declarationFragments":[{"kind":"keyword","spelling":"var"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"otherName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":"?"}],"accessLevel":"public","availability":[{"domain":"iOS","obsoleted":{"major":5,"minor":0}}],"location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","position":{"line":11,"character":15}}},{"kind":{"identifier":"swift.init","displayName":"Initializer"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV4nameACSS_tcfc","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","init(name:)"],"names":{"title":"init(name:)","subHeading":[{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":")"}]},"functionSignature":{"parameters":[{"name":"name","declarationFragments":[{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]}]},"declarationFragments":[{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":")"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift","position":{"line":13,"character":11}}}],"relationships":[{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV4nameSSvp","target":"s:14MarkdownOutput0A6SymbolV"},{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV8fullNameSSvp","target":"s:14MarkdownOutput0A6SymbolV"},{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV9otherNameSSSgvp","target":"s:14MarkdownOutput0A6SymbolV"},{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV4nameACSS_tcfc","target":"s:14MarkdownOutput0A6SymbolV"}]} \ No newline at end of file +{ + "metadata": { + "formatVersion": { + "major": 0, + "minor": 6, + "patch": 0 + }, + "generator": "Apple Swift version 6.2 (swiftlang-6.2.0.19.9 clang-1700.3.19.1)" + }, + "module": { + "name": "MarkdownOutput", + "platform": { + "architecture": "arm64", + "vendor": "apple", + "operatingSystem": { + "name": "macosx", + "minimumVersion": { + "major": 10, + "minor": 13 + } + } + } + }, + "symbols": [ + { + "kind": { + "identifier": "swift.struct", + "displayName": "Structure" + }, + "identifier": { + "precise": "s:14MarkdownOutput0A6SymbolV", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "MarkdownSymbol" + ], + "names": { + "title": "MarkdownSymbol", + "navigator": [ + { + "kind": "identifier", + "spelling": "MarkdownSymbol" + } + ], + "subHeading": [ + { + "kind": "keyword", + "spelling": "struct" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "MarkdownSymbol" + } + ] + }, + "docComment": { + "uri": "file://PATH/TO/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", + "module": "MarkdownOutput", + "lines": [ + { + "range": { + "start": { + "line": 0, + "character": 4 + }, + "end": { + "line": 0, + "character": 43 + } + }, + "text": "A basic symbol to test markdown output." + }, + { + "range": { + "start": { + "line": 1, + "character": 3 + }, + "end": { + "line": 1, + "character": 3 + } + }, + "text": "" + }, + { + "range": { + "start": { + "line": 2, + "character": 4 + }, + "end": { + "line": 2, + "character": 39 + } + }, + "text": "This is the overview of the symbol." + } + ] + }, + "declarationFragments": [ + { + "kind": "keyword", + "spelling": "struct" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "MarkdownSymbol" + } + ], + "accessLevel": "public", + "location": { + "uri": "file://PATH/TO/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", + "position": { + "line": 3, + "character": 14 + } + } + }, + { + "kind": { + "identifier": "swift.property", + "displayName": "Instance Property" + }, + "identifier": { + "precise": "s:14MarkdownOutput0A6SymbolV4nameSSvp", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "MarkdownSymbol", + "name" + ], + "names": { + "title": "name", + "subHeading": [ + { + "kind": "keyword", + "spelling": "let" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "name" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "String", + "preciseIdentifier": "s:SS" + } + ] + }, + "declarationFragments": [ + { + "kind": "keyword", + "spelling": "let" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "name" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "String", + "preciseIdentifier": "s:SS" + } + ], + "accessLevel": "public", + "location": { + "uri": "file://PATH/TO/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", + "position": { + "line": 4, + "character": 15 + } + } + }, + { + "kind": { + "identifier": "swift.property", + "displayName": "Instance Property" + }, + "identifier": { + "precise": "s:14MarkdownOutput0A6SymbolV8fullNameSSvp", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "MarkdownSymbol", + "fullName" + ], + "names": { + "title": "fullName", + "subHeading": [ + { + "kind": "keyword", + "spelling": "let" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "fullName" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "String", + "preciseIdentifier": "s:SS" + } + ] + }, + "declarationFragments": [ + { + "kind": "keyword", + "spelling": "let" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "fullName" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "String", + "preciseIdentifier": "s:SS" + } + ], + "accessLevel": "public", + "availability": [ + { + "domain": "macOS", + "introduced": { + "major": 2, + "minor": 0 + }, + "deprecated": { + "major": 4, + "minor": 0 + }, + "message": "Don't be so formal" + }, + { + "domain": "iOS", + "introduced": { + "major": 1, + "minor": 0 + }, + "deprecated": { + "major": 4, + "minor": 0 + }, + "message": "Don't be so formal" + } + ], + "location": { + "uri": "file://PATH/TO/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", + "position": { + "line": 8, + "character": 15 + } + } + }, + { + "kind": { + "identifier": "swift.property", + "displayName": "Instance Property" + }, + "identifier": { + "precise": "s:14MarkdownOutput0A6SymbolV9otherNameSSSgvp", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "MarkdownSymbol", + "otherName" + ], + "names": { + "title": "otherName", + "subHeading": [ + { + "kind": "keyword", + "spelling": "var" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "otherName" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "String", + "preciseIdentifier": "s:SS" + }, + { + "kind": "text", + "spelling": "?" + } + ] + }, + "declarationFragments": [ + { + "kind": "keyword", + "spelling": "var" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "otherName" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "String", + "preciseIdentifier": "s:SS" + }, + { + "kind": "text", + "spelling": "?" + } + ], + "accessLevel": "public", + "availability": [ + { + "domain": "iOS", + "obsoleted": { + "major": 5, + "minor": 0 + } + } + ], + "location": { + "uri": "file://PATH/TO/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", + "position": { + "line": 11, + "character": 15 + } + } + }, + { + "kind": { + "identifier": "swift.init", + "displayName": "Initializer" + }, + "identifier": { + "precise": "s:14MarkdownOutput0A6SymbolV4nameACSS_tcfc", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "MarkdownSymbol", + "init(name:)" + ], + "names": { + "title": "init(name:)", + "subHeading": [ + { + "kind": "keyword", + "spelling": "init" + }, + { + "kind": "text", + "spelling": "(" + }, + { + "kind": "externalParam", + "spelling": "name" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "String", + "preciseIdentifier": "s:SS" + }, + { + "kind": "text", + "spelling": ")" + } + ] + }, + "functionSignature": { + "parameters": [ + { + "name": "name", + "declarationFragments": [ + { + "kind": "identifier", + "spelling": "name" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "String", + "preciseIdentifier": "s:SS" + } + ] + } + ] + }, + "declarationFragments": [ + { + "kind": "keyword", + "spelling": "init" + }, + { + "kind": "text", + "spelling": "(" + }, + { + "kind": "externalParam", + "spelling": "name" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "String", + "preciseIdentifier": "s:SS" + }, + { + "kind": "text", + "spelling": ")" + } + ], + "accessLevel": "public", + "location": { + "uri": "file://PATH/TO/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", + "position": { + "line": 13, + "character": 11 + } + } + } + ], + "relationships": [ + { + "kind": "memberOf", + "source": "s:14MarkdownOutput0A6SymbolV4nameSSvp", + "target": "s:14MarkdownOutput0A6SymbolV" + }, + { + "kind": "memberOf", + "source": "s:14MarkdownOutput0A6SymbolV8fullNameSSvp", + "target": "s:14MarkdownOutput0A6SymbolV" + }, + { + "kind": "memberOf", + "source": "s:14MarkdownOutput0A6SymbolV9otherNameSSSgvp", + "target": "s:14MarkdownOutput0A6SymbolV" + }, + { + "kind": "memberOf", + "source": "s:14MarkdownOutput0A6SymbolV4nameACSS_tcfc", + "target": "s:14MarkdownOutput0A6SymbolV" + } + ] +} \ No newline at end of file diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/original-source/MarkdownOutput.zip b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/original-source/MarkdownOutput.zip new file mode 100644 index 0000000000000000000000000000000000000000..1c363b2aa584c60e97566ed409d6c91215b52110 GIT binary patch literal 1572 zcmWIWW@h1H00F^vjc70fO7JttF!&}GWvAqq=lPeG6qJ_ehlX%6FmL*^F9U>2E4UdL zS-vtdFtCUKwFCeS0?`}{G07UylhZx_`v9e`Gcf>-!7wNwF*!RiJyox`JTt8XY-;`9 zeHo2FjAm+Ykgxw?1D?Il!z1RHWoE1L$*2`R?wWBVFQeV0)O__%jn2al?oT$!PBh>w z%1qjy{@Zr@I>k9F9-CS(Km3cgRw3QIApCCEuC}{^>z^)kUApu*!yJY-otYU4Uc30_ z8ME8$bNh6I$vQ*nWr<}_gX5|=xeY>1XCBxb-o_$lpyAKFT*@iq2`9&Q;Uq-`S#93D z2hRe3?JHIPEmGN0vstRRxjZ*S#_WU9zR#Xk+nyIl?NRQ2eB(!-)~1}2Ns77Lv!wKG zpI*PSbIRcjOv=@p+I@VSGy@i;M(VNtVd1}eXS3E-JF(&?>rXB}d`Z4QFQX~@}jUN&qI@4o?o78Xwc%(b9F19 z*E9K~15Xm~{d;kuQca$_{YPb!=mZXkp?9Vs}#AuO^NKzp4mlRKb z{txJCL12=yBHDbIb-|UnN%=WQ2@YiEJxn|2PB`cXOmJ=QYq_$g=U%$P!!q5IL#>dN z?3A=PspPUBY2Pn*ZjW|ES;6V5=cn!u#;DmeB#0p2mY-|1VxR z$>{n{X?m-GQhtT~D~8OEf{j&Q_8t=su)d?;v+2bmBgvC?*Ld`mSM4hKp`~y%B5A@w z&u;4#H?qqmwmhtuwD*deR3oL`nx5 z4oc~G49868$cEbhQzj0>(S$=ofOdgWAXd8|L4}#-k%MXu(RRVCM$G}Z>}O>I#Vjij LG5~GhV*&91t=R;4 literal 0 HcmV?d00001 From 9607e3b677a68f0f86fd5cdf39d1761d2b900843 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 9 Sep 2025 16:49:46 +0100 Subject: [PATCH 13/28] Output metadata updates --- .../DocumentationContextConverter.swift | 2 +- .../MarkdownOutputNodeMetadata.swift | 46 +++++++++++++----- .../MarkdownOutputNodeTranslator.swift | 45 +++++++++++++++--- .../Markdown/MarkdownOutputTests.swift | 47 ++++++++++++++++--- .../MarkdownOutput.docc/APICollection.md | 10 ++++ .../MarkdownOutput.symbols.json | 14 +++--- 6 files changed, 131 insertions(+), 33 deletions(-) create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/APICollection.md diff --git a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift index 878425f3e..c9ad65cbe 100644 --- a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift +++ b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift @@ -114,7 +114,7 @@ public class DocumentationContextConverter { var translator = MarkdownOutputNodeTranslator( context: context, bundle: bundle, - identifier: node.reference, + node: node // renderContext: renderContext, // emitSymbolSourceFileURIs: shouldEmitSymbolSourceFileURIs, // emitSymbolAccessLevels: shouldEmitSymbolAccessLevels, diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift index f55f1e5ec..646b8eb33 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift @@ -12,28 +12,52 @@ extension MarkdownOutputNode { public struct Metadata: Codable { static let version = SemanticVersion(major: 0, minor: 1, patch: 0) + public enum DocumentType: String, Codable { case article, tutorial, symbol } - public struct Availability: Codable, Equatable { - let platform: String - let introduced: String? - let deprecated: String? - let unavailable: String? + public struct Symbol: Codable { + + public let availability: [Availability]? + public let kind: String + public let preciseIdentifier: String + public let modules: [String] + + public struct Availability: Codable, Equatable { + + let platform: String + let introduced: String? + let deprecated: String? + let unavailable: String? + + public init(platform: String, introduced: String? = nil, deprecated: String? = nil, unavailable: String? = nil) { + self.platform = platform + self.introduced = introduced + self.deprecated = deprecated + self.unavailable = unavailable + } + } + + public init(availability: [MarkdownOutputNode.Metadata.Symbol.Availability]? = nil, kind: String, preciseIdentifier: String, modules: [String]) { + self.availability = availability + self.kind = kind + self.preciseIdentifier = preciseIdentifier + self.modules = modules + } } - - public let version: String + + 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 symbolKind: String? - public var symbolAvailability: [Availability]? - + public var symbol: Symbol? + public init(documentType: DocumentType, bundle: DocumentationBundle, reference: ResolvedTopicReference) { self.documentType = documentType - self.version = Self.version.description + self.metadataVersion = Self.version.description self.uri = reference.path self.title = reference.lastPathComponent self.framework = bundle.displayName diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift index acc11e06a..c3cffb4e0 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift @@ -15,12 +15,14 @@ public struct MarkdownOutputNodeTranslator: SemanticVisitor { public let context: DocumentationContext public let bundle: DocumentationBundle + public let documentationNode: DocumentationNode public let identifier: ResolvedTopicReference - public init(context: DocumentationContext, bundle: DocumentationBundle, identifier: ResolvedTopicReference) { + public init(context: DocumentationContext, bundle: DocumentationBundle, node: DocumentationNode) { self.context = context self.bundle = bundle - self.identifier = identifier + self.documentationNode = node + self.identifier = node.reference } public typealias Result = MarkdownOutputNode? @@ -41,6 +43,7 @@ extension MarkdownOutputNodeTranslator { node.metadata.title = title } + node.metadata.role = DocumentationContentRenderer.roleForArticle(article, nodeKind: documentationNode.kind).rawValue node.visit(article.title) node.visit(article.abstract) node.visit(section: article.discussion) @@ -58,10 +61,7 @@ extension MarkdownOutputNodeTranslator { public mutating func visitSymbol(_ symbol: Symbol) -> MarkdownOutputNode? { var node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier, documentType: .symbol) - node.metadata.symbolKind = symbol.kind.displayName - node.metadata.symbolAvailability = symbol.availability?.availability.map { - MarkdownOutputNode.Metadata.Availability($0) - } + node.metadata.symbol = .init(symbol, context: context) node.visit(Heading(level: 1, Text(symbol.title))) node.visit(symbol.abstract) @@ -94,7 +94,37 @@ extension MarkdownOutputNodeTranslator { import SymbolKit -extension MarkdownOutputNode.Metadata.Availability { +extension MarkdownOutputNode.Metadata.Symbol { + init(_ symbol: SwiftDocC.Symbol, context: DocumentationContext) { + self.kind = symbol.kind.displayName + self.preciseIdentifier = symbol.externalID ?? "" + let symbolAvailability = symbol.availability?.availability.map { + MarkdownOutputNode.Metadata.Symbol.Availability($0) + } + + if let availability = symbolAvailability, availability.isEmpty == false { + self.availability = availability + } else { + self.availability = nil + } + + // Gather modules + var modules = [String]() + if let main = try? context.entity(with: symbol.moduleReference) { + modules.append(main.name.plainText) + } + if let crossImport = symbol.crossImportOverlayModule { + modules.append(contentsOf: crossImport.bystanderModules) + } + if let extended = symbol.extendedModuleVariants.firstValue, modules.contains(extended) == false { + modules.append(extended) + } + + self.modules = modules + } +} + +extension MarkdownOutputNode.Metadata.Symbol.Availability { init(_ item: SymbolGraph.Symbol.Availability.AvailabilityItem) { self.platform = item.domain?.rawValue ?? "*" self.introduced = item.introducedVersion?.description @@ -103,6 +133,7 @@ extension MarkdownOutputNode.Metadata.Availability { } } + // MARK: Tutorial Output extension MarkdownOutputNodeTranslator { // Tutorial table of contents is not useful as markdown or indexable content diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 623b2d6f1..212b19702 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -39,10 +39,10 @@ final class MarkdownOutputTests: XCTestCase { path = "/documentation/MarkdownOutput/\(path)" } let reference = ResolvedTopicReference(bundleID: bundle.id, path: path, sourceLanguage: .swift) - let article = try XCTUnwrap(context.entity(with: reference).semantic) - var translator = MarkdownOutputNodeTranslator(context: context, bundle: bundle, identifier: reference) - let node = try XCTUnwrap(translator.visit(article)) - return node + let node = try XCTUnwrap(context.entity(with: reference)) + var translator = MarkdownOutputNodeTranslator(context: context, bundle: bundle, node: node) + let outputNode = try XCTUnwrap(translator.visit(node.semantic)) + return outputNode } // MARK: Directive special processing @@ -115,6 +115,16 @@ final class MarkdownOutputTests: XCTestCase { XCTAssert(node.metadata.documentType == .article) } + func testArticleRole() async throws { + let node = try await generateMarkdown(path: "RowsAndColumns") + XCTAssert(node.metadata.role == RenderMetadata.Role.article.rawValue) + } + + func testAPICollectionRole() async throws { + let node = try await generateMarkdown(path: "APICollection") + XCTAssert(node.metadata.role == RenderMetadata.Role.collectionGroup.rawValue) + } + func testArticleTitle() async throws { let node = try await generateMarkdown(path: "RowsAndColumns") XCTAssert(node.metadata.title == "Rows and Columns") @@ -132,22 +142,45 @@ final class MarkdownOutputTests: XCTestCase { func testSymbolKind() async throws { let node = try await generateMarkdown(path: "MarkdownSymbol/init(name:)") - XCTAssert(node.metadata.symbolKind == "Initializer") + XCTAssert(node.metadata.symbol?.kind == "Initializer") + } + + func testSymbolSingleModule() async throws { + let node = try await generateMarkdown(path: "MarkdownSymbol") + XCTAssertEqual(node.metadata.symbol?.modules, ["MarkdownOutput"]) + } + + func testSymbolExtendedModule() async throws { + let (bundle, context) = try await testBundleAndContext(named: "ModuleWithSingleExtension") + let entity = try XCTUnwrap(context.entity(with: ResolvedTopicReference(bundleID: bundle.id, path: "/documentation/ModuleWithSingleExtension/Swift/Array/asdf", sourceLanguage: .swift))) + var translator = MarkdownOutputNodeTranslator(context: context, bundle: bundle, node: entity) + let node = try XCTUnwrap(translator.visit(entity.semantic)) + XCTAssertEqual(node.metadata.symbol?.modules, ["ModuleWithSingleExtension", "Swift"]) + } + + func testNoAvailabilityWhenNothingPresent() async throws { + let node = try await generateMarkdown(path: "MarkdownSymbol") + XCTAssertNil(node.metadata.symbol?.availability) } func testSymbolDeprecation() async throws { let node = try await generateMarkdown(path: "MarkdownSymbol/fullName") - let availability = try XCTUnwrap(node.metadata.symbolAvailability) + let availability = try XCTUnwrap(node.metadata.symbol?.availability) XCTAssertEqual(availability[0], .init(platform: "iOS", introduced: "1.0.0", deprecated: "4.0.0", unavailable: nil)) XCTAssertEqual(availability[1], .init(platform: "macOS", introduced: "2.0.0", deprecated: "4.0.0", unavailable: nil)) } func testSymbolObsolete() async throws { let node = try await generateMarkdown(path: "MarkdownSymbol/otherName") - let availability = try XCTUnwrap(node.metadata.symbolAvailability) + let availability = try XCTUnwrap(node.metadata.symbol?.availability) XCTAssertEqual(availability[0], .init(platform: "iOS", introduced: nil, deprecated: nil, unavailable: "5.0.0")) } + func testSymbolIdentifier() async throws { + let node = try await generateMarkdown(path: "MarkdownSymbol") + XCTAssertEqual(node.metadata.symbol?.preciseIdentifier, "s:14MarkdownOutput0A6SymbolV") + } + func testTutorialDocumentType() async throws { let node = try await generateMarkdown(path: "/tutorials/MarkdownOutput/Tutorial") XCTAssert(node.metadata.documentType == .tutorial) diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/APICollection.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/APICollection.md new file mode 100644 index 000000000..f6dc13eb5 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/APICollection.md @@ -0,0 +1,10 @@ +# API Collection + +This is an API collection + +## Topics + +### Topic subgroup + +- +- diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json index bd2c58062..065917401 100644 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json @@ -58,7 +58,7 @@ ] }, "docComment": { - "uri": "file://PATH/TO/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", + "uri": "file://path/to/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", "module": "MarkdownOutput", "lines": [ { @@ -118,7 +118,7 @@ ], "accessLevel": "public", "location": { - "uri": "file://PATH/TO/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", + "uri": "file://path/to/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", "position": { "line": 3, "character": 14 @@ -189,7 +189,7 @@ ], "accessLevel": "public", "location": { - "uri": "file://PATH/TO/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", + "uri": "file://path/to/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", "position": { "line": 4, "character": 15 @@ -286,7 +286,7 @@ } ], "location": { - "uri": "file://PATH/TO/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", + "uri": "file://path/to/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", "position": { "line": 8, "character": 15 @@ -374,7 +374,7 @@ } ], "location": { - "uri": "file://PATH/TO/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", + "uri": "file://path/to/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", "position": { "line": 11, "character": 15 @@ -475,7 +475,7 @@ ], "accessLevel": "public", "location": { - "uri": "file://PATH/TO/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", + "uri": "file://path/to/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", "position": { "line": 13, "character": 11 @@ -505,4 +505,4 @@ "target": "s:14MarkdownOutput0A6SymbolV" } ] -} \ No newline at end of file +} From 5244f0f74a229d0c3ea7ae14b86a1514c1684f10 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 11 Sep 2025 14:13:06 +0100 Subject: [PATCH 14/28] Adds default availability for modules to markdown export --- .../MarkdownOutput/MarkdownOutputNode.swift | 2 +- .../MarkdownOutputNodeMetadata.swift | 8 +++- .../MarkdownOutputNodeTranslator.swift | 39 ++++++++++++------- .../Markdown/MarkdownOutputTests.swift | 28 +++++++++---- .../MarkdownOutput.docc/APICollection.md | 2 + .../MarkdownOutput.docc/Info.plist | 12 ++++++ 6 files changed, 67 insertions(+), 24 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift index 7ac41d95d..ec01003c8 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNode.swift @@ -33,7 +33,7 @@ public struct MarkdownOutputNode { public var data: Data { get throws { let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted] + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] let metadata = try encoder.encode(metadata) let commentOpen = "\n\n".utf8 diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift index 646b8eb33..3bff25e99 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift @@ -29,9 +29,9 @@ extension MarkdownOutputNode { let platform: String let introduced: String? let deprecated: String? - let unavailable: String? + let unavailable: Bool - public init(platform: String, introduced: String? = nil, deprecated: String? = nil, unavailable: String? = nil) { + public init(platform: String, introduced: String? = nil, deprecated: String? = nil, unavailable: Bool) { self.platform = platform self.introduced = introduced self.deprecated = deprecated @@ -45,6 +45,10 @@ extension MarkdownOutputNode { self.preciseIdentifier = preciseIdentifier self.modules = modules } + + public func availability(for platform: String) -> Availability? { + availability?.first(where: { $0.platform == platform }) + } } public let metadataVersion: String diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift index c3cffb4e0..206baca5d 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift @@ -61,7 +61,7 @@ extension MarkdownOutputNodeTranslator { public mutating func visitSymbol(_ symbol: Symbol) -> MarkdownOutputNode? { var node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier, documentType: .symbol) - node.metadata.symbol = .init(symbol, context: context) + node.metadata.symbol = .init(symbol, context: context, bundle: bundle) node.visit(Heading(level: 1, Text(symbol.title))) node.visit(symbol.abstract) @@ -95,23 +95,16 @@ extension MarkdownOutputNodeTranslator { import SymbolKit extension MarkdownOutputNode.Metadata.Symbol { - init(_ symbol: SwiftDocC.Symbol, context: DocumentationContext) { + init(_ symbol: SwiftDocC.Symbol, context: DocumentationContext, bundle: DocumentationBundle) { self.kind = symbol.kind.displayName self.preciseIdentifier = symbol.externalID ?? "" - let symbolAvailability = symbol.availability?.availability.map { - MarkdownOutputNode.Metadata.Symbol.Availability($0) - } - - if let availability = symbolAvailability, availability.isEmpty == false { - self.availability = availability - } else { - self.availability = nil - } - + // Gather modules var modules = [String]() + var primaryModule: String? if let main = try? context.entity(with: symbol.moduleReference) { modules.append(main.name.plainText) + primaryModule = main.name.plainText } if let crossImport = symbol.crossImportOverlayModule { modules.append(contentsOf: crossImport.bystanderModules) @@ -121,6 +114,18 @@ extension MarkdownOutputNode.Metadata.Symbol { } self.modules = modules + + let symbolAvailability = symbol.availability?.availability.map { + MarkdownOutputNode.Metadata.Symbol.Availability($0) + } + + if let availability = symbolAvailability, availability.isEmpty == false { + self.availability = availability + } else if let primaryModule, let defaultAvailability = bundle.info.defaultAvailability?.modules[primaryModule] { + self.availability = defaultAvailability.map { .init($0) } + } else { + self.availability = nil + } } } @@ -129,11 +134,17 @@ extension MarkdownOutputNode.Metadata.Symbol.Availability { self.platform = item.domain?.rawValue ?? "*" self.introduced = item.introducedVersion?.description self.deprecated = item.deprecatedVersion?.description - self.unavailable = item.obsoletedVersion?.description + self.unavailable = item.obsoletedVersion != nil + } + + init(_ availability: DefaultAvailability.ModuleAvailability) { + self.platform = availability.platformName.displayName + self.introduced = availability.introducedVersion + self.deprecated = nil + self.unavailable = availability.versionInformation == .unavailable } } - // MARK: Tutorial Output extension MarkdownOutputNodeTranslator { // Tutorial table of contents is not useful as markdown or indexable content diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 212b19702..6acba0609 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -158,22 +158,36 @@ final class MarkdownOutputTests: XCTestCase { XCTAssertEqual(node.metadata.symbol?.modules, ["ModuleWithSingleExtension", "Swift"]) } - func testNoAvailabilityWhenNothingPresent() async throws { + func testSymbolDefaultAvailabilityWhenNothingPresent() async throws { let node = try await generateMarkdown(path: "MarkdownSymbol") - XCTAssertNil(node.metadata.symbol?.availability) + let availability = try XCTUnwrap(node.metadata.symbol?.availability) + XCTAssertEqual(availability[0], .init(platform: "iOS", introduced: "1.0.0", deprecated: nil, unavailable: false)) + } + + func testSymbolModuleDefaultAvailability() async throws { + let node = try await generateMarkdown(path: "/documentation/MarkdownOutput") + let availability = try XCTUnwrap(node.metadata.symbol?.availability(for: "iOS")) + XCTAssertEqual(availability.introduced, "1.0") + XCTAssertFalse(availability.unavailable) } func testSymbolDeprecation() async throws { let node = try await generateMarkdown(path: "MarkdownSymbol/fullName") - let availability = try XCTUnwrap(node.metadata.symbol?.availability) - XCTAssertEqual(availability[0], .init(platform: "iOS", introduced: "1.0.0", deprecated: "4.0.0", unavailable: nil)) - XCTAssertEqual(availability[1], .init(platform: "macOS", introduced: "2.0.0", deprecated: "4.0.0", unavailable: nil)) + let availability = try XCTUnwrap(node.metadata.symbol?.availability(for: "iOS")) + XCTAssertEqual(availability.introduced, "1.0.0") + XCTAssertEqual(availability.deprecated, "4.0.0") + XCTAssertEqual(availability.unavailable, false) + + let macAvailability = try XCTUnwrap(node.metadata.symbol?.availability(for: "macOS")) + XCTAssertEqual(macAvailability.introduced, "2.0.0") + XCTAssertEqual(macAvailability.deprecated, "4.0.0") + XCTAssertEqual(macAvailability.unavailable, false) } func testSymbolObsolete() async throws { let node = try await generateMarkdown(path: "MarkdownSymbol/otherName") - let availability = try XCTUnwrap(node.metadata.symbol?.availability) - XCTAssertEqual(availability[0], .init(platform: "iOS", introduced: nil, deprecated: nil, unavailable: "5.0.0")) + let availability = try XCTUnwrap(node.metadata.symbol?.availability(for: "iOS")) + XCTAssert(availability.unavailable) } func testSymbolIdentifier() async throws { diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/APICollection.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/APICollection.md index f6dc13eb5..b664dc899 100644 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/APICollection.md +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/APICollection.md @@ -8,3 +8,5 @@ This is an API collection - - + + diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Info.plist b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Info.plist index 84193a341..c68f20e42 100644 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Info.plist +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Info.plist @@ -10,5 +10,17 @@ org.swift.MarkdownOutput CFBundleVersion 0.1.0 + CDAppleDefaultAvailability + + MarkdownOutput + + + name + iOS + version + 1.0 + + + From a6a740e905a2f7131109ba6a2e3079536697a4d7 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 16 Sep 2025 11:35:33 +0100 Subject: [PATCH 15/28] Move availability out of symbol and in to general metadata for markdown output --- .../MarkdownOutputNodeMetadata.swift | 43 +++++++++---------- .../MarkdownOutputNodeTranslator.swift | 43 ++++++++++++------- .../Markdown/MarkdownOutputTests.swift | 16 ++++--- .../AvailabilityArticle.md | 15 +++++++ 4 files changed, 75 insertions(+), 42 deletions(-) create mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/AvailabilityArticle.md diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift index 3bff25e99..0b9af3c0a 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift @@ -17,38 +17,32 @@ extension MarkdownOutputNode { case article, tutorial, symbol } + public struct Availability: Codable, Equatable { + + 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 + self.introduced = introduced + self.deprecated = deprecated + self.unavailable = unavailable + } + } + public struct Symbol: Codable { - - public let availability: [Availability]? public let kind: String public let preciseIdentifier: String public let modules: [String] - public struct Availability: Codable, Equatable { - - 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 - self.introduced = introduced - self.deprecated = deprecated - self.unavailable = unavailable - } - } - public init(availability: [MarkdownOutputNode.Metadata.Symbol.Availability]? = nil, kind: String, preciseIdentifier: String, modules: [String]) { - self.availability = availability + public init(kind: String, preciseIdentifier: String, modules: [String]) { self.kind = kind self.preciseIdentifier = preciseIdentifier self.modules = modules } - - public func availability(for platform: String) -> Availability? { - availability?.first(where: { $0.platform == platform }) - } } public let metadataVersion: String @@ -58,6 +52,7 @@ extension MarkdownOutputNode { public var title: String public let framework: String public var symbol: Symbol? + public var availability: [Availability]? public init(documentType: DocumentType, bundle: DocumentationBundle, reference: ResolvedTopicReference) { self.documentType = documentType @@ -66,6 +61,10 @@ extension MarkdownOutputNode { self.title = reference.lastPathComponent self.framework = bundle.displayName } + + public func availability(for platform: String) -> Availability? { + availability?.first(where: { $0.platform == platform }) + } } } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift index 206baca5d..7dda3ef4f 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift @@ -43,6 +43,11 @@ extension MarkdownOutputNodeTranslator { node.metadata.title = title } + if + let metadataAvailability = article.metadata?.availability, + !metadataAvailability.isEmpty { + node.metadata.availability = metadataAvailability.map { .init($0) } + } node.metadata.role = DocumentationContentRenderer.roleForArticle(article, nodeKind: documentationNode.kind).rawValue node.visit(article.title) node.visit(article.abstract) @@ -63,6 +68,20 @@ extension MarkdownOutputNodeTranslator { node.metadata.symbol = .init(symbol, context: context, bundle: bundle) + // Availability + + let symbolAvailability = symbol.availability?.availability.map { + MarkdownOutputNode.Metadata.Availability($0) + } + + if let availability = symbolAvailability, availability.isEmpty == false { + node.metadata.availability = availability + } else if let primaryModule = node.metadata.symbol?.modules.first, let defaultAvailability = bundle.info.defaultAvailability?.modules[primaryModule] { + node.metadata.availability = defaultAvailability.map { .init($0) } + } + + // Content + node.visit(Heading(level: 1, Text(symbol.title))) node.visit(symbol.abstract) if let declarationFragments = symbol.declaration.first?.value.declarationFragments { @@ -101,10 +120,9 @@ extension MarkdownOutputNode.Metadata.Symbol { // Gather modules var modules = [String]() - var primaryModule: String? + if let main = try? context.entity(with: symbol.moduleReference) { modules.append(main.name.plainText) - primaryModule = main.name.plainText } if let crossImport = symbol.crossImportOverlayModule { modules.append(contentsOf: crossImport.bystanderModules) @@ -114,22 +132,10 @@ extension MarkdownOutputNode.Metadata.Symbol { } self.modules = modules - - let symbolAvailability = symbol.availability?.availability.map { - MarkdownOutputNode.Metadata.Symbol.Availability($0) - } - - if let availability = symbolAvailability, availability.isEmpty == false { - self.availability = availability - } else if let primaryModule, let defaultAvailability = bundle.info.defaultAvailability?.modules[primaryModule] { - self.availability = defaultAvailability.map { .init($0) } - } else { - self.availability = nil - } } } -extension MarkdownOutputNode.Metadata.Symbol.Availability { +extension MarkdownOutputNode.Metadata.Availability { init(_ item: SymbolGraph.Symbol.Availability.AvailabilityItem) { self.platform = item.domain?.rawValue ?? "*" self.introduced = item.introducedVersion?.description @@ -143,6 +149,13 @@ extension MarkdownOutputNode.Metadata.Symbol.Availability { self.deprecated = nil self.unavailable = availability.versionInformation == .unavailable } + + init(_ availability: Metadata.Availability) { + self.platform = availability.platform.rawValue + self.introduced = availability.introduced.description + self.deprecated = availability.deprecated?.description + self.unavailable = false + } } // MARK: Tutorial Output diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 6acba0609..0df855e7a 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -130,6 +130,12 @@ final class MarkdownOutputTests: XCTestCase { XCTAssert(node.metadata.title == "Rows and Columns") } + func testArticleAvailability() async throws { + let node = try await generateMarkdown(path: "AvailabilityArticle") + XCTAssert(node.metadata.availability(for: "Xcode")?.introduced == "14.3.0") + XCTAssert(node.metadata.availability(for: "macOS")?.introduced == "13.0.0") + } + func testSymbolDocumentType() async throws { let node = try await generateMarkdown(path: "MarkdownSymbol") XCTAssert(node.metadata.documentType == .symbol) @@ -160,25 +166,25 @@ final class MarkdownOutputTests: XCTestCase { func testSymbolDefaultAvailabilityWhenNothingPresent() async throws { let node = try await generateMarkdown(path: "MarkdownSymbol") - let availability = try XCTUnwrap(node.metadata.symbol?.availability) + let availability = try XCTUnwrap(node.metadata.availability) XCTAssertEqual(availability[0], .init(platform: "iOS", introduced: "1.0.0", deprecated: nil, unavailable: false)) } func testSymbolModuleDefaultAvailability() async throws { let node = try await generateMarkdown(path: "/documentation/MarkdownOutput") - let availability = try XCTUnwrap(node.metadata.symbol?.availability(for: "iOS")) + let availability = try XCTUnwrap(node.metadata.availability(for: "iOS")) XCTAssertEqual(availability.introduced, "1.0") XCTAssertFalse(availability.unavailable) } func testSymbolDeprecation() async throws { let node = try await generateMarkdown(path: "MarkdownSymbol/fullName") - let availability = try XCTUnwrap(node.metadata.symbol?.availability(for: "iOS")) + let availability = try XCTUnwrap(node.metadata.availability(for: "iOS")) XCTAssertEqual(availability.introduced, "1.0.0") XCTAssertEqual(availability.deprecated, "4.0.0") XCTAssertEqual(availability.unavailable, false) - let macAvailability = try XCTUnwrap(node.metadata.symbol?.availability(for: "macOS")) + let macAvailability = try XCTUnwrap(node.metadata.availability(for: "macOS")) XCTAssertEqual(macAvailability.introduced, "2.0.0") XCTAssertEqual(macAvailability.deprecated, "4.0.0") XCTAssertEqual(macAvailability.unavailable, false) @@ -186,7 +192,7 @@ final class MarkdownOutputTests: XCTestCase { func testSymbolObsolete() async throws { let node = try await generateMarkdown(path: "MarkdownSymbol/otherName") - let availability = try XCTUnwrap(node.metadata.symbol?.availability(for: "iOS")) + let availability = try XCTUnwrap(node.metadata.availability(for: "iOS")) XCTAssert(availability.unavailable) } diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/AvailabilityArticle.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/AvailabilityArticle.md new file mode 100644 index 000000000..656b6272e --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/AvailabilityArticle.md @@ -0,0 +1,15 @@ +# Availability Demonstration + +@Metadata { + @PageKind(sampleCode) + @Available(Xcode, introduced: "14.3") + @Available(macOS, introduced: "13.0") +} + +This article demonstrates platform availability defined in metadata + +## Overview + +Some stuff + + From d0dbf4448f366a740ecdf51cf2bf59b3730f81b7 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Fri, 19 Sep 2025 11:03:29 +0100 Subject: [PATCH 16/28] Refactor markdown output so the final node type is standalone --- .../DocumentationContextConverter.swift | 4 +- .../ConvertOutputConsumer.swift | 4 +- .../MarkdownOutputNodeMetadata.swift | 70 --------- .../Model/MarkdownOutputNode.swift | 138 +++++++++++++++++ .../Model/MarkdownOutputNodeMetadata.swift | 11 ++ .../MarkdownOutputMarkdownWalker.swift} | 57 ++----- .../MarkdownOutputNodeTranslator.swift | 33 ++++ .../MarkdownOutputSemanticVisitor.swift} | 143 ++++++++++-------- .../Convert/ConvertFileWritingConsumer.swift | 2 +- .../JSONEncodingRenderNodeWriter.swift | 5 +- .../Markdown/MarkdownOutputTests.swift | 17 ++- 11 files changed, 294 insertions(+), 190 deletions(-) delete mode 100644 Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift create mode 100644 Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift create mode 100644 Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNodeMetadata.swift rename Sources/SwiftDocC/Model/MarkdownOutput/{MarkdownOutputNode.swift => Translation/MarkdownOutputMarkdownWalker.swift} (87%) create mode 100644 Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift rename Sources/SwiftDocC/Model/MarkdownOutput/{MarkdownOutputNodeTranslator.swift => Translation/MarkdownOutputSemanticVisitor.swift} (67%) diff --git a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift index c9ad65cbe..bed1dd249 100644 --- a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift +++ b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift @@ -106,7 +106,7 @@ public class DocumentationContextConverter { /// - Parameters: /// - node: The documentation node to convert. /// - Returns: The markdown node representation of the documentation node. - public func markdownNode(for node: DocumentationNode) -> MarkdownOutputNode? { + public func markdownNode(for node: DocumentationNode) -> WritableMarkdownOutputNode? { guard !node.isVirtual else { return nil } @@ -121,6 +121,6 @@ public class DocumentationContextConverter { // sourceRepository: sourceRepository, // symbolIdentifiersWithExpandedDocumentation: symbolIdentifiersWithExpandedDocumentation ) - return translator.visit(node.semantic) + return translator.createOutput() } } diff --git a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift index 9b236e2aa..67a4b3c74 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift @@ -52,7 +52,7 @@ public protocol ConvertOutputConsumer { func consume(linkResolutionInformation: SerializableLinkResolutionInformation) throws /// Consumes a markdown output node - func consume(markdownNode: MarkdownOutputNode) throws + func consume(markdownNode: WritableMarkdownOutputNode) throws } // Default implementations that discard the documentation conversion products, for consumers that don't need these @@ -61,7 +61,7 @@ public extension ConvertOutputConsumer { func consume(renderReferenceStore: RenderReferenceStore) throws {} func consume(buildMetadata: BuildMetadata) throws {} func consume(linkResolutionInformation: SerializableLinkResolutionInformation) throws {} - func consume(markdownNode: MarkdownOutputNode) throws {} + func consume(markdownNode: WritableMarkdownOutputNode) throws {} } // Default implementation so that conforming types don't need to implement deprecated API. diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift b/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift deleted file mode 100644 index 0b9af3c0a..000000000 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeMetadata.swift +++ /dev/null @@ -1,70 +0,0 @@ -/* - 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 -*/ - -extension MarkdownOutputNode { - public struct Metadata: Codable { - - static let version = SemanticVersion(major: 0, minor: 1, patch: 0) - - public enum DocumentType: String, Codable { - case article, tutorial, symbol - } - - public struct Availability: Codable, Equatable { - - 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 - self.introduced = introduced - self.deprecated = deprecated - self.unavailable = unavailable - } - } - - public struct Symbol: Codable { - 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, bundle: DocumentationBundle, reference: ResolvedTopicReference) { - self.documentType = documentType - self.metadataVersion = Self.version.description - self.uri = reference.path - self.title = reference.lastPathComponent - self.framework = bundle.displayName - } - - public func availability(for platform: String) -> Availability? { - availability?.first(where: { $0.platform == platform }) - } - } - -} diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift new file mode 100644 index 000000000..4f8971494 --- /dev/null +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift @@ -0,0 +1,138 @@ +/* + 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 { + + /// 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 { + + static let version = "0.1.0" + + public enum DocumentType: String, Codable { + case article, tutorial, symbol + } + + public struct Availability: Codable, Equatable { + + 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 + self.introduced = introduced + self.deprecated = deprecated + self.unavailable = unavailable + } + } + + public struct Symbol: Codable { + 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\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.. Void) { + public mutating func withRenderingLinkList(_ process: (inout Self) -> Void) { isRenderingLinkList = true process(&self) isRenderingLinkList = false } /// Perform actions while removing a base level of indentation, typically while processing the contents of block directives. - public mutating func withRemoveIndentation(from base: (any Markup)?, process: (inout MarkdownOutputNode) -> Void) { + public mutating func withRemoveIndentation(from base: (any Markup)?, process: (inout Self) -> Void) { indentationToRemove = nil if let toRemove = base? .format() @@ -67,13 +38,13 @@ public struct MarkdownOutputNode { if toRemove.isEmpty == false { indentationToRemove = String(toRemove) } - } + } process(&self) indentationToRemove = nil } } -extension MarkdownOutputNode { +extension MarkdownOutputMarkupWalker { mutating func visit(_ optionalMarkup: (any Markup)?) -> Void { if let markup = optionalMarkup { self.visit(markup) @@ -106,7 +77,7 @@ extension MarkdownOutputNode { } } -extension MarkdownOutputNode: MarkupWalker { +extension MarkdownOutputMarkupWalker { public mutating func defaultVisit(_ markup: any Markup) -> () { var output = markup.format() @@ -293,7 +264,7 @@ extension MarkdownOutputNode: MarkupWalker { } // Semantic handling -extension MarkdownOutputNode { +extension MarkdownOutputMarkupWalker { mutating func visit(container: MarkupContainer?) -> Void { container?.elements.forEach { diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift new file mode 100644 index 000000000..118469b95 --- /dev/null +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift @@ -0,0 +1,33 @@ +/* + 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 +*/ + +import Foundation + +/// Creates a ``MarkdownOutputNode`` from a ``DocumentationNode``. +public struct MarkdownOutputNodeTranslator { + + var visitor: MarkdownOutputSemanticVisitor + + public init(context: DocumentationContext, bundle: DocumentationBundle, node: DocumentationNode) { + self.visitor = MarkdownOutputSemanticVisitor(context: context, bundle: bundle, node: node) + } + + public mutating func createOutput() -> WritableMarkdownOutputNode? { + if let node = visitor.start() { + return WritableMarkdownOutputNode(identifier: visitor.identifier, node: node) + } + return nil + } +} + +public struct WritableMarkdownOutputNode { + public let identifier: ResolvedTopicReference + public let node: MarkdownOutputNode +} diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift similarity index 67% rename from Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift rename to Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 7dda3ef4f..b177ffc46 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -8,65 +8,80 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -import Foundation -import Markdown - -public struct MarkdownOutputNodeTranslator: SemanticVisitor { +/// Visits the semantic structure of a documentation node and returns a ``MarkdownOutputNode`` +internal struct MarkdownOutputSemanticVisitor: SemanticVisitor { - public let context: DocumentationContext - public let bundle: DocumentationBundle - public let documentationNode: DocumentationNode - public let identifier: ResolvedTopicReference + let context: DocumentationContext + let bundle: DocumentationBundle + let documentationNode: DocumentationNode + let identifier: ResolvedTopicReference + var markdownWalker: MarkdownOutputMarkupWalker - public init(context: DocumentationContext, bundle: DocumentationBundle, node: DocumentationNode) { + init(context: DocumentationContext, bundle: DocumentationBundle, node: DocumentationNode) { self.context = context self.bundle = bundle self.documentationNode = node self.identifier = node.reference + self.markdownWalker = MarkdownOutputMarkupWalker(context: context, bundle: bundle, identifier: identifier) } public typealias Result = MarkdownOutputNode? - private var node: Result = nil // Tutorial processing private var sectionIndex = 0 private var stepIndex = 0 private var lastCode: Code? + + mutating func start() -> MarkdownOutputNode? { + visit(documentationNode.semantic) + } +} + +extension MarkdownOutputNode.Metadata { + public init(documentType: DocumentType, bundle: DocumentationBundle, reference: ResolvedTopicReference) { + self.documentType = documentType + self.metadataVersion = Self.version.description + self.uri = reference.path + self.title = reference.lastPathComponent + self.framework = bundle.displayName + } } // MARK: Article Output -extension MarkdownOutputNodeTranslator { +extension MarkdownOutputSemanticVisitor { public mutating func visitArticle(_ article: Article) -> MarkdownOutputNode? { - var node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier, documentType: .article) + var metadata = MarkdownOutputNode.Metadata(documentType: .article, bundle: bundle, reference: identifier) if let title = article.title?.plainText { - node.metadata.title = title + metadata.title = title } if let metadataAvailability = article.metadata?.availability, !metadataAvailability.isEmpty { - node.metadata.availability = metadataAvailability.map { .init($0) } + metadata.availability = metadataAvailability.map { .init($0) } } - node.metadata.role = DocumentationContentRenderer.roleForArticle(article, nodeKind: documentationNode.kind).rawValue - node.visit(article.title) - node.visit(article.abstract) - node.visit(section: article.discussion) - node.withRenderingLinkList { + metadata.role = DocumentationContentRenderer.roleForArticle(article, nodeKind: documentationNode.kind).rawValue + markdownWalker.visit(article.title) + markdownWalker.visit(article.abstract) + markdownWalker.visit(section: article.discussion) + markdownWalker.withRenderingLinkList { $0.visit(section: article.topics, addingHeading: "Topics") $0.visit(section: article.seeAlso, addingHeading: "See Also") } - return node + return MarkdownOutputNode(metadata: metadata, markdown: markdownWalker.markdown) } } +import Markdown + // MARK: Symbol Output -extension MarkdownOutputNodeTranslator { +extension MarkdownOutputSemanticVisitor { public mutating func visitSymbol(_ symbol: Symbol) -> MarkdownOutputNode? { - var node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier, documentType: .symbol) + var metadata = MarkdownOutputNode.Metadata(documentType: .symbol, bundle: bundle, reference: identifier) - node.metadata.symbol = .init(symbol, context: context, bundle: bundle) + metadata.symbol = .init(symbol, context: context, bundle: bundle) // Availability @@ -75,39 +90,39 @@ extension MarkdownOutputNodeTranslator { } if let availability = symbolAvailability, availability.isEmpty == false { - node.metadata.availability = availability - } else if let primaryModule = node.metadata.symbol?.modules.first, let defaultAvailability = bundle.info.defaultAvailability?.modules[primaryModule] { - node.metadata.availability = defaultAvailability.map { .init($0) } + metadata.availability = availability + } else if let primaryModule = metadata.symbol?.modules.first, let defaultAvailability = bundle.info.defaultAvailability?.modules[primaryModule] { + metadata.availability = defaultAvailability.map { .init($0) } } // Content - node.visit(Heading(level: 1, Text(symbol.title))) - node.visit(symbol.abstract) + markdownWalker.visit(Heading(level: 1, Text(symbol.title))) + markdownWalker.visit(symbol.abstract) if let declarationFragments = symbol.declaration.first?.value.declarationFragments { let declaration = declarationFragments .map { $0.spelling } .joined() let code = CodeBlock(declaration) - node.visit(code) + markdownWalker.visit(code) } if let parametersSection = symbol.parametersSection, parametersSection.parameters.isEmpty == false { - node.visit(Heading(level: 2, Text(ParametersSection.title ?? "Parameters"))) + markdownWalker.visit(Heading(level: 2, Text(ParametersSection.title ?? "Parameters"))) for parameter in parametersSection.parameters { - node.visit(Paragraph(InlineCode(parameter.name))) - node.visit(container: MarkupContainer(parameter.contents)) + markdownWalker.visit(Paragraph(InlineCode(parameter.name))) + markdownWalker.visit(container: MarkupContainer(parameter.contents)) } } - node.visit(section: symbol.returnsSection) + markdownWalker.visit(section: symbol.returnsSection) - node.visit(section: symbol.discussion, addingHeading: symbol.kind.identifier.swiftSymbolCouldHaveChildren ? "Overview" : "Discussion") - node.withRenderingLinkList { + markdownWalker.visit(section: symbol.discussion, addingHeading: symbol.kind.identifier.swiftSymbolCouldHaveChildren ? "Overview" : "Discussion") + markdownWalker.withRenderingLinkList { $0.visit(section: symbol.topics, addingHeading: "Topics") $0.visit(section: symbol.seeAlso, addingHeading: "See Also") } - return node + return MarkdownOutputNode(metadata: metadata, markdown: markdownWalker.markdown) } } @@ -159,31 +174,31 @@ extension MarkdownOutputNode.Metadata.Availability { } // MARK: Tutorial Output -extension MarkdownOutputNodeTranslator { +extension MarkdownOutputSemanticVisitor { // Tutorial table of contents is not useful as markdown or indexable content public func visitTutorialTableOfContents(_ tutorialTableOfContents: TutorialTableOfContents) -> MarkdownOutputNode? { return nil } public mutating func visitTutorial(_ tutorial: Tutorial) -> MarkdownOutputNode? { - node = MarkdownOutputNode(context: context, bundle: bundle, identifier: identifier, documentType: .tutorial) + var metadata = MarkdownOutputNode.Metadata(documentType: .tutorial, bundle: bundle, reference: identifier) if tutorial.intro.title.isEmpty == false { - node?.metadata.title = tutorial.intro.title + metadata.title = tutorial.intro.title } sectionIndex = 0 for child in tutorial.children { - node = visit(child) ?? node + _ = visit(child) } - return node + return MarkdownOutputNode(metadata: metadata, markdown: markdownWalker.markdown) } public mutating func visitTutorialSection(_ tutorialSection: TutorialSection) -> MarkdownOutputNode? { sectionIndex += 1 - node?.visit(Heading(level: 2, Text("Section \(sectionIndex): \(tutorialSection.title)"))) + markdownWalker.visit(Heading(level: 2, Text("Section \(sectionIndex): \(tutorialSection.title)"))) for child in tutorialSection.children { - node = visit(child) ?? node + _ = visit(child) } return nil } @@ -191,15 +206,15 @@ extension MarkdownOutputNodeTranslator { public mutating func visitSteps(_ steps: Steps) -> MarkdownOutputNode? { stepIndex = 0 for child in steps.children { - node = visit(child) ?? node + _ = visit(child) } if let code = lastCode { - node?.visit(code) + markdownWalker.visit(code) lastCode = nil } - return node + return nil } public mutating func visitStep(_ step: Step) -> MarkdownOutputNode? { @@ -209,11 +224,11 @@ extension MarkdownOutputNodeTranslator { if let stepCode = step.code { if stepCode.fileName != code.fileName { // New reference, render before proceeding - node?.visit(code) + markdownWalker.visit(code) } } else { // No code, render the current one before proceeding - node?.visit(code) + markdownWalker.visit(code) lastCode = nil } } @@ -221,48 +236,48 @@ extension MarkdownOutputNodeTranslator { lastCode = step.code stepIndex += 1 - node?.visit(Heading(level: 3, Text("Step \(stepIndex)"))) + markdownWalker.visit(Heading(level: 3, Text("Step \(stepIndex)"))) for child in step.children { - node = visit(child) ?? node + _ = visit(child) } if let media = step.media { - node = visit(media) ?? node + _ = visit(media) } - return node + return nil } public mutating func visitIntro(_ intro: Intro) -> MarkdownOutputNode? { - node?.visit(Heading(level: 1, Text(intro.title))) + markdownWalker.visit(Heading(level: 1, Text(intro.title))) for child in intro.children { - node = visit(child) ?? node + _ = visit(child) } - return node + return nil } public mutating func visitMarkupContainer(_ markupContainer: MarkupContainer) -> MarkdownOutputNode? { - node?.withRemoveIndentation(from: markupContainer.elements.first) { + markdownWalker.withRemoveIndentation(from: markupContainer.elements.first) { $0.visit(container: markupContainer) } - return node + return nil } public mutating func visitImageMedia(_ imageMedia: ImageMedia) -> MarkdownOutputNode? { - node?.visit(imageMedia) - return node + markdownWalker.visit(imageMedia) + return nil } public mutating func visitVideoMedia(_ videoMedia: VideoMedia) -> MarkdownOutputNode? { - node?.visit(videoMedia) - return node + markdownWalker.visit(videoMedia) + return nil } public mutating func visitContentAndMedia(_ contentAndMedia: ContentAndMedia) -> MarkdownOutputNode? { for child in contentAndMedia.children { - node = visit(child) ?? node + _ = visit(child) } - return node + return nil } public mutating func visitCode(_ code: Code) -> MarkdownOutputNode? { @@ -273,7 +288,7 @@ extension MarkdownOutputNodeTranslator { // MARK: Visitors not used for markdown output -extension MarkdownOutputNodeTranslator { +extension MarkdownOutputSemanticVisitor { public mutating func visitXcodeRequirement(_ xcodeRequirement: XcodeRequirement) -> MarkdownOutputNode? { print(#function) diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift index 91918c624..4467c6097 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift @@ -68,7 +68,7 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer { indexer?.index(renderNode) } - func consume(markdownNode: MarkdownOutputNode) throws { + func consume(markdownNode: WritableMarkdownOutputNode) throws { try renderNodeWriter.write(markdownNode) } diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift index 494fee42b..2be151605 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift @@ -124,7 +124,8 @@ class JSONEncodingRenderNodeWriter { /// /// - Parameters: /// - markdownNode: The node which the writer object writes - func write(_ markdownNode: MarkdownOutputNode) throws { + func write(_ markdownNode: WritableMarkdownOutputNode) throws { + let fileSafePath = NodeURLGenerator.fileSafeReferencePath( markdownNode.identifier, lowercased: true @@ -156,7 +157,7 @@ class JSONEncodingRenderNodeWriter { } } - let data = try markdownNode.data + let data = try markdownNode.node.data try fileManager.createFile(at: markdownNodeTargetFileURL, contents: data, options: nil) } } diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 0df855e7a..aeb5ad3d4 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -41,8 +41,8 @@ final class MarkdownOutputTests: XCTestCase { let reference = ResolvedTopicReference(bundleID: bundle.id, path: path, sourceLanguage: .swift) let node = try XCTUnwrap(context.entity(with: reference)) var translator = MarkdownOutputNodeTranslator(context: context, bundle: bundle, node: node) - let outputNode = try XCTUnwrap(translator.visit(node.semantic)) - return outputNode + let outputNode = try XCTUnwrap(translator.createOutput()) + return outputNode.node } // MARK: Directive special processing @@ -160,8 +160,8 @@ final class MarkdownOutputTests: XCTestCase { let (bundle, context) = try await testBundleAndContext(named: "ModuleWithSingleExtension") let entity = try XCTUnwrap(context.entity(with: ResolvedTopicReference(bundleID: bundle.id, path: "/documentation/ModuleWithSingleExtension/Swift/Array/asdf", sourceLanguage: .swift))) var translator = MarkdownOutputNodeTranslator(context: context, bundle: bundle, node: entity) - let node = try XCTUnwrap(translator.visit(entity.semantic)) - XCTAssertEqual(node.metadata.symbol?.modules, ["ModuleWithSingleExtension", "Swift"]) + let node = try XCTUnwrap(translator.createOutput()) + XCTAssertEqual(node.node.metadata.symbol?.modules, ["ModuleWithSingleExtension", "Swift"]) } func testSymbolDefaultAvailabilityWhenNothingPresent() async throws { @@ -221,6 +221,11 @@ final class MarkdownOutputTests: XCTestCase { XCTAssert(node.metadata.framework == "MarkdownOutput") } - - + func testMarkdownRoundTrip() async throws { + let node = try await generateMarkdown(path: "MarkdownSymbol") + let data = try node.data + let fromData = try MarkdownOutputNode(data) + XCTAssertEqual(node.markdown, fromData.markdown) + XCTAssertEqual(node.metadata.uri, fromData.metadata.uri) + } } From 595831ade4925ad05f78cdcc7c4886d5d8051432 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 23 Sep 2025 09:37:10 +0100 Subject: [PATCH 17/28] Add generated markdown flag to render node metadata --- .../Converter/DocumentationContextConverter.swift | 5 ----- .../SwiftDocC/Infrastructure/ConvertActionConverter.swift | 8 +++++--- .../Model/Rendering/RenderNode/RenderMetadata.swift | 7 +++++++ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift index bed1dd249..1b6b4eba1 100644 --- a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift +++ b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift @@ -115,11 +115,6 @@ public class DocumentationContextConverter { context: context, bundle: bundle, node: node -// renderContext: renderContext, -// emitSymbolSourceFileURIs: shouldEmitSymbolSourceFileURIs, -// emitSymbolAccessLevels: shouldEmitSymbolAccessLevels, -// sourceRepository: sourceRepository, -// symbolIdentifiersWithExpandedDocumentation: symbolIdentifiersWithExpandedDocumentation ) return translator.createOutput() } diff --git a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift index 71cfc70b4..f7a1c75ce 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift @@ -124,18 +124,20 @@ 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 } - try outputConsumer.consume(renderNode: renderNode) - if FeatureFlags.current.isExperimentalMarkdownOutputEnabled, let markdownNode = converter.markdownNode(for: entity) { try outputConsumer.consume(markdownNode: markdownNode) + renderNode.metadata.hasGeneratedMarkdown = true } + + try outputConsumer.consume(renderNode: renderNode) + switch documentationCoverageOptions.level { case .detailed, .brief: let coverageEntry = try CoverageDataEntry( diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift b/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift index 7d71f68d0..aeb0397a4 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift @@ -177,6 +177,9 @@ public struct RenderMetadata: VariantContainer { /// It's the renderer's responsibility to fetch the full version of the page, for example using /// the ``RenderNode/variants`` property. public var hasNoExpandedDocumentation: Bool = false + + /// If a markdown equivalent of this page was generated at render time. + public var hasGeneratedMarkdown: Bool = false } extension RenderMetadata: Codable { @@ -248,6 +251,7 @@ extension RenderMetadata: Codable { public static let color = CodingKeys(stringValue: "color") public static let customMetadata = CodingKeys(stringValue: "customMetadata") public static let hasNoExpandedDocumentation = CodingKeys(stringValue: "hasNoExpandedDocumentation") + public static let hasGeneratedMarkdown = CodingKeys(stringValue: "hasGeneratedMarkdown") } public init(from decoder: any Decoder) throws { @@ -278,6 +282,7 @@ extension RenderMetadata: Codable { remoteSourceVariants = try container.decodeVariantCollectionIfPresent(ofValueType: RemoteSource?.self, forKey: .remoteSource) tags = try container.decodeIfPresent([RenderNode.Tag].self, forKey: .tags) hasNoExpandedDocumentation = try container.decodeIfPresent(Bool.self, forKey: .hasNoExpandedDocumentation) ?? false + hasGeneratedMarkdown = try container.decodeIfPresent(Bool.self, forKey: .hasGeneratedMarkdown) ?? false let extraKeys = Set(container.allKeys).subtracting( [ @@ -343,6 +348,7 @@ extension RenderMetadata: Codable { try container.encodeIfPresent(color, forKey: .color) try container.encodeIfNotEmpty(customMetadata, forKey: .customMetadata) try container.encodeIfTrue(hasNoExpandedDocumentation, forKey: .hasNoExpandedDocumentation) + try container.encodeIfTrue(hasGeneratedMarkdown, forKey: .hasGeneratedMarkdown) } } @@ -376,6 +382,7 @@ extension RenderMetadata: RenderJSONDiffable { diffBuilder.addDifferences(atKeyPath: \.remoteSource, forKey: CodingKeys.remoteSource) diffBuilder.addDifferences(atKeyPath: \.tags, forKey: CodingKeys.tags) diffBuilder.addDifferences(atKeyPath: \.hasNoExpandedDocumentation, forKey: CodingKeys.hasNoExpandedDocumentation) + diffBuilder.addDifferences(atKeyPath: \.hasGeneratedMarkdown, forKey: CodingKeys.hasGeneratedMarkdown) return diffBuilder.differences } From 104f9ebae3254d2e63795421f6e549b080c4cad3 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 23 Sep 2025 09:57:21 +0100 Subject: [PATCH 18/28] Only include unavailable in markdown header if it is true --- .../Model/MarkdownOutputNode.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift index 4f8971494..00c13998a 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift @@ -42,12 +42,34 @@ extension MarkdownOutputNode { let deprecated: String? let unavailable: Bool + public enum CodingKeys: String, CodingKey { + case platform, introduced, deprecated, unavailable + } + public init(platform: String, introduced: String? = nil, deprecated: String? = nil, unavailable: Bool) { self.platform = platform self.introduced = introduced self.deprecated = deprecated self.unavailable = unavailable } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(platform, forKey: .platform) + try container.encodeIfPresent(introduced, forKey: .introduced) + try container.encodeIfPresent(deprecated, forKey: .deprecated) + if unavailable { + try container.encode(unavailable, forKey: .unavailable) + } + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + platform = try container.decode(String.self, forKey: .platform) + introduced = try container.decodeIfPresent(String.self, forKey: .introduced) + deprecated = try container.decodeIfPresent(String.self, forKey: .deprecated) + unavailable = try container.decodeIfPresent(Bool.self, forKey: .unavailable) ?? false + } } public struct Symbol: Codable { From a2aa8f10c21797a94782c88ef3bb864bfdf6e1f3 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 23 Sep 2025 12:17:49 +0100 Subject: [PATCH 19/28] Initial setup of manifest output, no references --- .../ConvertActionConverter.swift | 13 +++++ .../ConvertOutputConsumer.swift | 4 ++ .../Model/MarkdownOutputManifest.swift | 57 +++++++++++++++++++ .../Model/MarkdownOutputNodeMetadata.swift | 11 ---- .../MarkdownOutputNodeTranslator.swift | 3 +- .../MarkdownOutputSemanticVisitor.swift | 23 ++++++++ Sources/SwiftDocC/Utility/FeatureFlags.swift | 3 + .../Convert/ConvertFileWritingConsumer.swift | 8 +++ .../ConvertAction+CommandInitialization.swift | 1 + .../ArgumentParsing/Subcommands/Convert.swift | 9 +++ 10 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift delete mode 100644 Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNodeMetadata.swift diff --git a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift index f7a1c75ce..2592bb3b8 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift @@ -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. // @@ -134,6 +135,14 @@ package enum ConvertActionConverter { let markdownNode = converter.markdownNode(for: entity) { try outputConsumer.consume(markdownNode: markdownNode) renderNode.metadata.hasGeneratedMarkdown = true + if + FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled, + let document = markdownNode.manifestDocument + { + resultsGroup.async(queue: resultsSyncQueue) { + markdownManifest.documents.append(document) + } + } } try outputConsumer.consume(renderNode: renderNode) @@ -220,6 +229,10 @@ package enum ConvertActionConverter { } } } + + if FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled { + try outputConsumer.consume(markdownManifest: markdownManifest) + } switch documentationCoverageOptions.level { case .detailed, .brief: diff --git a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift index 67a4b3c74..6b81d286c 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift @@ -53,6 +53,9 @@ public protocol ConvertOutputConsumer { /// 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 @@ -62,6 +65,7 @@ public extension ConvertOutputConsumer { 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. diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift new file mode 100644 index 000000000..cd1d9248c --- /dev/null +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift @@ -0,0 +1,57 @@ +/* + 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 { + public static let version = "0.1.0" + + public let manifestVersion: String + public let title: String + public var documents: [Document] + + public init(title: String, documents: [Document]) { + self.manifestVersion = Self.version + self.title = title + self.documents = documents + } +} + +extension MarkdownOutputManifest { + + public enum DocumentType: String, Codable { + case article, tutorial, symbol + } + + public enum RelationshipType: String, Codable { + case topics + } + + public struct Document: Codable { + /// 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 + /// The outgoing references of the document, grouped by relationship type + public var references: [RelationshipType: [String]] + + public init(uri: String, documentType: MarkdownOutputManifest.DocumentType, title: String, references: [MarkdownOutputManifest.RelationshipType : [String]]) { + self.uri = uri + self.documentType = documentType + self.title = title + self.references = references + } + } +} diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNodeMetadata.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNodeMetadata.swift deleted file mode 100644 index 6f57b3a64..000000000 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNodeMetadata.swift +++ /dev/null @@ -1,11 +0,0 @@ -/* - 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 -*/ - - diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift index 118469b95..8a3f30ff1 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift @@ -21,7 +21,7 @@ public struct MarkdownOutputNodeTranslator { public mutating func createOutput() -> WritableMarkdownOutputNode? { if let node = visitor.start() { - return WritableMarkdownOutputNode(identifier: visitor.identifier, node: node) + return WritableMarkdownOutputNode(identifier: visitor.identifier, node: node, manifestDocument: visitor.manifestDocument) } return nil } @@ -30,4 +30,5 @@ public struct MarkdownOutputNodeTranslator { public struct WritableMarkdownOutputNode { public let identifier: ResolvedTopicReference public let node: MarkdownOutputNode + public let manifestDocument: MarkdownOutputManifest.Document? } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index b177ffc46..069c2b9eb 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -16,6 +16,7 @@ internal struct MarkdownOutputSemanticVisitor: SemanticVisitor { let documentationNode: DocumentationNode let identifier: ResolvedTopicReference var markdownWalker: MarkdownOutputMarkupWalker + var manifestDocument: MarkdownOutputManifest.Document? init(context: DocumentationContext, bundle: DocumentationBundle, node: DocumentationNode) { self.context = context @@ -56,6 +57,13 @@ extension MarkdownOutputSemanticVisitor { metadata.title = title } + manifestDocument = MarkdownOutputManifest.Document( + uri: identifier.path, + documentType: .article, + title: metadata.title, + references: [:] + ) + if let metadataAvailability = article.metadata?.availability, !metadataAvailability.isEmpty { @@ -83,6 +91,13 @@ extension MarkdownOutputSemanticVisitor { metadata.symbol = .init(symbol, context: context, bundle: bundle) + manifestDocument = MarkdownOutputManifest.Document( + uri: identifier.path, + documentType: .symbol, + title: metadata.title, + references: [:] + ) + // Availability let symbolAvailability = symbol.availability?.availability.map { @@ -182,10 +197,18 @@ extension MarkdownOutputSemanticVisitor { public mutating func visitTutorial(_ tutorial: Tutorial) -> MarkdownOutputNode? { var metadata = MarkdownOutputNode.Metadata(documentType: .tutorial, bundle: bundle, reference: identifier) + if tutorial.intro.title.isEmpty == false { metadata.title = tutorial.intro.title } + manifestDocument = MarkdownOutputManifest.Document( + uri: identifier.path, + documentType: .tutorial, + title: metadata.title, + references: [:] + ) + sectionIndex = 0 for child in tutorial.children { _ = visit(child) diff --git a/Sources/SwiftDocC/Utility/FeatureFlags.swift b/Sources/SwiftDocC/Utility/FeatureFlags.swift index 3ca3f3bab..cb46cd8b3 100644 --- a/Sources/SwiftDocC/Utility/FeatureFlags.swift +++ b/Sources/SwiftDocC/Utility/FeatureFlags.swift @@ -26,6 +26,9 @@ public struct FeatureFlags: Codable { /// Whether or not experimental markdown generation is enabled public var isExperimentalMarkdownOutputEnabled = false + /// Whether or not experimental markdown manifest generation is enabled + public var isExperimentalMarkdownOutputManifestEnabled = false + /// Whether support for automatically rendering links on symbol documentation to articles that mention that symbol is enabled. public var isMentionedInEnabled = true diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift index 4467c6097..dc87171d6 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift @@ -72,6 +72,14 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer { try renderNodeWriter.write(markdownNode) } + func consume(markdownManifest: MarkdownOutputManifest) throws { + let url = targetFolder.appendingPathComponent("\(markdownManifest.title)-markdown-manifest.json", isDirectory: false) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(markdownManifest) + try fileManager.createFile(at: url, contents: data) + } + func consume(externalRenderNode: ExternalRenderNode) throws { // Index the external node, if indexing is enabled. indexer?.index(externalRenderNode) diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift index 382c9637a..94bda9a5e 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift @@ -26,6 +26,7 @@ extension ConvertAction { FeatureFlags.current.isMentionedInEnabled = convert.enableMentionedIn FeatureFlags.current.isParametersAndReturnsValidationEnabled = convert.enableParametersAndReturnsValidation FeatureFlags.current.isExperimentalMarkdownOutputEnabled = convert.enableExperimentalMarkdownOutput + FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled = convert.enableExperimentalMarkdownOutputManifest // If the user-provided a URL for an external link resolver, attempt to // initialize an `OutOfProcessReferenceResolver` with the provided URL. diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift index 64db12bbd..2acf8faf0 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift @@ -519,6 +519,9 @@ extension Docc { @Flag(help: "Experimental: Create markdown versions of documents") var enableExperimentalMarkdownOutput = false + @Flag(help: "Experimental: Create manifest file of markdown outputs. Ignored if --enable-experimental-markdown-output is not set.") + var enableExperimentalMarkdownOutputManifest = false + @Flag( name: .customLong("parameters-and-returns-validation"), inversion: .prefixedEnableDisable, @@ -611,6 +614,12 @@ extension Docc { get { featureFlags.enableExperimentalMarkdownOutput } set { featureFlags.enableExperimentalMarkdownOutput = newValue } } + + /// A user-provided value that is true if the user enables experimental markdown output + public var enableExperimentalMarkdownOutputManifest: Bool { + get { featureFlags.enableExperimentalMarkdownOutputManifest } + set { featureFlags.enableExperimentalMarkdownOutputManifest = newValue } + } /// A user-provided value that is true if the user enables experimental automatically generated "mentioned in" /// links on symbols. From b5ed5593e252e68e5442337b579eaa86852ab570 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Wed, 24 Sep 2025 11:04:30 +0100 Subject: [PATCH 20/28] Output of manifest / relationships --- .../Model/MarkdownOutputManifest.swift | 12 +- .../MarkdownOutputMarkdownWalker.swift | 7 +- .../MarkdownOutputSemanticVisitor.swift | 56 +- .../Convert/ConvertFileWritingConsumer.swift | 3 + .../Markdown/MarkdownOutputTests.swift | 89 ++- .../Test Bundles/MarkdownOutput.docc/Links.md | 1 + .../MarkdownOutput.symbols.json | 509 +----------------- 7 files changed, 157 insertions(+), 520 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift index cd1d9248c..27402e177 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift @@ -35,6 +35,13 @@ extension MarkdownOutputManifest { public enum RelationshipType: String, Codable { case topics + case memberSymbols + case relationships + } + + public struct RelatedDocument: Codable, Hashable { + public let uri: String + public let subtype: String } public struct Document: Codable { @@ -44,10 +51,11 @@ extension MarkdownOutputManifest { public let documentType: DocumentType /// The title of the document public let title: String + /// The outgoing references of the document, grouped by relationship type - public var references: [RelationshipType: [String]] + public var references: [RelationshipType: Set] - public init(uri: String, documentType: MarkdownOutputManifest.DocumentType, title: String, references: [MarkdownOutputManifest.RelationshipType : [String]]) { + public init(uri: String, documentType: MarkdownOutputManifest.DocumentType, title: String, references: [MarkdownOutputManifest.RelationshipType : Set]) { self.uri = uri self.documentType = documentType self.title = title diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index c1d3d1f28..5d94e5038 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -16,6 +16,7 @@ internal struct MarkdownOutputMarkupWalker: MarkupWalker { let bundle: DocumentationBundle let identifier: ResolvedTopicReference var markdown = "" + var outgoingReferences: Set = [] private(set) var indentationToRemove: String? private(set) var isRenderingLinkList = false @@ -58,7 +59,7 @@ extension MarkdownOutputMarkupWalker { return } - if let heading = addingHeading ?? type(of: section).title { + if let heading = addingHeading ?? type(of: section).title, heading.isEmpty == false { // Don't add if there is already a heading in the content if let first = section.content.first as? Heading, first.level == 2 { // Do nothing @@ -131,7 +132,7 @@ extension MarkdownOutputMarkupWalker { else { return defaultVisit(symbolLink) } - + outgoingReferences.insert(resolved) let linkTitle: String var linkListAbstract: (any Markup)? if @@ -164,7 +165,7 @@ extension MarkdownOutputMarkupWalker { else { return defaultVisit(link) } - + outgoingReferences.insert(resolved) let linkTitle: String var linkListAbstract: (any Markup)? if diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 069c2b9eb..6b982e257 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -48,6 +48,29 @@ extension MarkdownOutputNode.Metadata { } } +extension MarkdownOutputManifest.Document { + mutating func add(reference: ResolvedTopicReference, subtype: String, forRelationshipType type: MarkdownOutputManifest.RelationshipType) { + let related = MarkdownOutputManifest.RelatedDocument(uri: reference.path, subtype: subtype) + references[type, default: []].insert(related) + } + + mutating func add(fallbackReference: String, subtype: String, forRelationshipType type: MarkdownOutputManifest.RelationshipType) { + let uri: String + let components = fallbackReference.components(separatedBy: ".") + if components.count > 1 { + uri = "/documentation/\(components.joined(separator: "/"))" + } else { + uri = fallbackReference + } + let related = MarkdownOutputManifest.RelatedDocument(uri: uri, subtype: subtype) + references[type, default: []].insert(related) + } + + func references(for type: MarkdownOutputManifest.RelationshipType) -> Set? { + references[type] + } +} + // MARK: Article Output extension MarkdownOutputSemanticVisitor { @@ -73,10 +96,16 @@ extension MarkdownOutputSemanticVisitor { markdownWalker.visit(article.title) markdownWalker.visit(article.abstract) markdownWalker.visit(section: article.discussion) + + // Only care about references from these sections + markdownWalker.outgoingReferences = [] markdownWalker.withRenderingLinkList { $0.visit(section: article.topics, addingHeading: "Topics") $0.visit(section: article.seeAlso, addingHeading: "See Also") } + for reference in markdownWalker.outgoingReferences { + manifestDocument?.add(reference: reference, subtype: "Topics", forRelationshipType: .topics) + } return MarkdownOutputNode(metadata: metadata, markdown: markdownWalker.markdown) } } @@ -90,6 +119,7 @@ extension MarkdownOutputSemanticVisitor { var metadata = MarkdownOutputNode.Metadata(documentType: .symbol, bundle: bundle, reference: identifier) metadata.symbol = .init(symbol, context: context, bundle: bundle) + metadata.role = symbol.kind.displayName manifestDocument = MarkdownOutputManifest.Document( uri: identifier.path, @@ -133,10 +163,34 @@ extension MarkdownOutputSemanticVisitor { markdownWalker.visit(section: symbol.returnsSection) markdownWalker.visit(section: symbol.discussion, addingHeading: symbol.kind.identifier.swiftSymbolCouldHaveChildren ? "Overview" : "Discussion") + + markdownWalker.outgoingReferences = [] markdownWalker.withRenderingLinkList { $0.visit(section: symbol.topics, addingHeading: "Topics") $0.visit(section: symbol.seeAlso, addingHeading: "See Also") } + for reference in markdownWalker.outgoingReferences { + manifestDocument?.add(reference: reference, subtype: "Topics", forRelationshipType: .topics) + } + for child in context.children(of: identifier) { + // Only interested in symbols + guard child.kind.isSymbol else { continue } + // Not interested in symbols that have been curated already + if markdownWalker.outgoingReferences.contains(child.reference) { continue } + manifestDocument?.add(reference: child.reference, subtype: child.kind.name, forRelationshipType: .memberSymbols) + } + for relationshipGroup in symbol.relationships.groups { + for destination in relationshipGroup.destinations { + switch context.resolve(destination, in: identifier) { + case .success(let resolved): + manifestDocument?.add(reference: resolved, subtype: relationshipGroup.kind.rawValue, forRelationshipType: .relationships) + case .failure(let unresolved, let error): + if let fallback = symbol.relationships.targetFallbacks[destination] { + manifestDocument?.add(fallbackReference: fallback, subtype: relationshipGroup.kind.rawValue, forRelationshipType: .relationships) + } + } + } + } return MarkdownOutputNode(metadata: metadata, markdown: markdownWalker.markdown) } } @@ -145,7 +199,7 @@ import SymbolKit extension MarkdownOutputNode.Metadata.Symbol { init(_ symbol: SwiftDocC.Symbol, context: DocumentationContext, bundle: DocumentationBundle) { - self.kind = symbol.kind.displayName + self.kind = symbol.kind.identifier.identifier self.preciseIdentifier = symbol.externalID ?? "" // Gather modules diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift index dc87171d6..fe949f528 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift @@ -76,6 +76,9 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer { let url = targetFolder.appendingPathComponent("\(markdownManifest.title)-markdown-manifest.json", isDirectory: false) let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + #if DEBUG + encoder.outputFormatting.insert(.prettyPrinted) + #endif let data = try encoder.encode(markdownManifest) try fileManager.createFile(at: url, contents: data) } diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index aeb5ad3d4..158d4ba00 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -28,11 +28,11 @@ final class MarkdownOutputTests: XCTestCase { return try await task.value } } - - /// Generates markdown from a given path + + /// Generates a writable markdown node from a given path /// - Parameter path: The path. If you just supply a name (no leading slash), it will prepend `/documentation/MarkdownOutput/`, otherwise the path will be used - /// - Returns: The generated markdown output node - private func generateMarkdown(path: String) async throws -> MarkdownOutputNode { + /// - Returns: The generated writable markdown output node + private func generateWritableMarkdown(path: String) async throws -> WritableMarkdownOutputNode { let (bundle, context) = try await bundleAndContext() var path = path if !path.hasPrefix("/") { @@ -41,9 +41,23 @@ final class MarkdownOutputTests: XCTestCase { let reference = ResolvedTopicReference(bundleID: bundle.id, path: path, sourceLanguage: .swift) let node = try XCTUnwrap(context.entity(with: reference)) var translator = MarkdownOutputNodeTranslator(context: context, bundle: bundle, node: node) - let outputNode = try XCTUnwrap(translator.createOutput()) + return try XCTUnwrap(translator.createOutput()) + } + /// Generates a markdown node from a given path + /// - Parameter path: The path. If you just supply a name (no leading slash), it will prepend `/documentation/MarkdownOutput/`, otherwise the path will be used + /// - Returns: The generated markdown output node + private func generateMarkdown(path: String) async throws -> MarkdownOutputNode { + let outputNode = try await generateWritableMarkdown(path: path) return outputNode.node } + + /// Generates a markdown manifest document (with relationships) from a given path + /// - Parameter path: The path. If you just supply a name (no leading slash), it will prepend `/documentation/MarkdownOutput/`, otherwise the path will be used + /// - Returns: The generated markdown output manifest document + private func generateMarkdownManifestDocument(path: String) async throws -> MarkdownOutputManifest.Document { + let outputNode = try await generateWritableMarkdown(path: path) + return try XCTUnwrap(outputNode.manifestDocument) + } // MARK: Directive special processing @@ -148,7 +162,8 @@ final class MarkdownOutputTests: XCTestCase { func testSymbolKind() async throws { let node = try await generateMarkdown(path: "MarkdownSymbol/init(name:)") - XCTAssert(node.metadata.symbol?.kind == "Initializer") + XCTAssert(node.metadata.symbol?.kind == "init") + XCTAssert(node.metadata.role == "Initializer") } func testSymbolSingleModule() async throws { @@ -221,6 +236,7 @@ final class MarkdownOutputTests: XCTestCase { XCTAssert(node.metadata.framework == "MarkdownOutput") } + // MARK: - Encoding / Decoding func testMarkdownRoundTrip() async throws { let node = try await generateMarkdown(path: "MarkdownSymbol") let data = try node.data @@ -228,4 +244,65 @@ final class MarkdownOutputTests: XCTestCase { XCTAssertEqual(node.markdown, fromData.markdown) XCTAssertEqual(node.metadata.uri, fromData.metadata.uri) } + + // MARK: - Manifest + func testArticleManifestLinks() async throws { + let document = try await generateMarkdownManifestDocument(path: "Links") + let topics = try XCTUnwrap(document.references(for: .topics)) + XCTAssertEqual(topics.count, 2) + let ids = topics.map { $0.uri } + XCTAssert(ids.contains("/documentation/MarkdownOutput/RowsAndColumns")) + XCTAssert(ids.contains("/documentation/MarkdownOutput/MarkdownSymbol")) + } + + func testSymbolManifestChildSymbols() async throws { + let document = try await generateMarkdownManifestDocument(path: "MarkdownSymbol") + let children = try XCTUnwrap(document.references(for: .memberSymbols)) + XCTAssertEqual(children.count, 4) + let ids = children.map { $0.uri } + XCTAssert(ids.contains("/documentation/MarkdownOutput/MarkdownSymbol/name")) + XCTAssert(ids.contains("/documentation/MarkdownOutput/MarkdownSymbol/otherName")) + XCTAssert(ids.contains("/documentation/MarkdownOutput/MarkdownSymbol/fullName")) + XCTAssert(ids.contains("/documentation/MarkdownOutput/MarkdownSymbol/init(name:)")) + } + + func testSymbolManifestInheritance() async throws { + let document = try await generateMarkdownManifestDocument(path: "LocalSubclass") + let relationships = try XCTUnwrap(document.references(for: .relationships)) + XCTAssert(relationships.contains(where: { + $0.uri == "/documentation/MarkdownOutput/LocalSuperclass" && $0.subtype == "inheritsFrom" + })) + } + + func testSymbolManifestInheritedBy() async throws { + let document = try await generateMarkdownManifestDocument(path: "LocalSuperclass") + let relationships = try XCTUnwrap(document.references(for: .relationships)) + XCTAssert(relationships.contains(where: { + $0.uri == "/documentation/MarkdownOutput/LocalSubclass" && $0.subtype == "inheritedBy" + })) + } + + func testSymbolManifestConformsTo() async throws { + let document = try await generateMarkdownManifestDocument(path: "LocalConformer") + let relationships = try XCTUnwrap(document.references(for: .relationships)) + XCTAssert(relationships.contains(where: { + $0.uri == "/documentation/MarkdownOutput/LocalProtocol" && $0.subtype == "conformsTo" + })) + } + + func testSymbolManifestConformingTypes() async throws { + let document = try await generateMarkdownManifestDocument(path: "LocalProtocol") + let relationships = try XCTUnwrap(document.references(for: .relationships)) + XCTAssert(relationships.contains(where: { + $0.uri == "/documentation/MarkdownOutput/LocalConformer" && $0.subtype == "conformingTypes" + })) + } + + func testSymbolManifestExternalConformsTo() async throws { + let document = try await generateMarkdownManifestDocument(path: "ExternalConformer") + let relationships = try XCTUnwrap(document.references(for: .relationships)) + XCTAssert(relationships.contains(where: { + $0.uri == "/documentation/Swift/Hashable" && $0.subtype == "conformsTo" + })) + } } diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md index 9ab91d0d6..195f66b08 100644 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md @@ -6,6 +6,7 @@ Tests the appearance of inline and linked lists This is an inline link: This is an inline link: ``MarkdownSymbol`` +This is a link that isn't curated in a topic so shouldn't come up in the manifest: . ## Topics diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json index 065917401..4ef6ca607 100644 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json @@ -1,508 +1 @@ -{ - "metadata": { - "formatVersion": { - "major": 0, - "minor": 6, - "patch": 0 - }, - "generator": "Apple Swift version 6.2 (swiftlang-6.2.0.19.9 clang-1700.3.19.1)" - }, - "module": { - "name": "MarkdownOutput", - "platform": { - "architecture": "arm64", - "vendor": "apple", - "operatingSystem": { - "name": "macosx", - "minimumVersion": { - "major": 10, - "minor": 13 - } - } - } - }, - "symbols": [ - { - "kind": { - "identifier": "swift.struct", - "displayName": "Structure" - }, - "identifier": { - "precise": "s:14MarkdownOutput0A6SymbolV", - "interfaceLanguage": "swift" - }, - "pathComponents": [ - "MarkdownSymbol" - ], - "names": { - "title": "MarkdownSymbol", - "navigator": [ - { - "kind": "identifier", - "spelling": "MarkdownSymbol" - } - ], - "subHeading": [ - { - "kind": "keyword", - "spelling": "struct" - }, - { - "kind": "text", - "spelling": " " - }, - { - "kind": "identifier", - "spelling": "MarkdownSymbol" - } - ] - }, - "docComment": { - "uri": "file://path/to/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", - "module": "MarkdownOutput", - "lines": [ - { - "range": { - "start": { - "line": 0, - "character": 4 - }, - "end": { - "line": 0, - "character": 43 - } - }, - "text": "A basic symbol to test markdown output." - }, - { - "range": { - "start": { - "line": 1, - "character": 3 - }, - "end": { - "line": 1, - "character": 3 - } - }, - "text": "" - }, - { - "range": { - "start": { - "line": 2, - "character": 4 - }, - "end": { - "line": 2, - "character": 39 - } - }, - "text": "This is the overview of the symbol." - } - ] - }, - "declarationFragments": [ - { - "kind": "keyword", - "spelling": "struct" - }, - { - "kind": "text", - "spelling": " " - }, - { - "kind": "identifier", - "spelling": "MarkdownSymbol" - } - ], - "accessLevel": "public", - "location": { - "uri": "file://path/to/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", - "position": { - "line": 3, - "character": 14 - } - } - }, - { - "kind": { - "identifier": "swift.property", - "displayName": "Instance Property" - }, - "identifier": { - "precise": "s:14MarkdownOutput0A6SymbolV4nameSSvp", - "interfaceLanguage": "swift" - }, - "pathComponents": [ - "MarkdownSymbol", - "name" - ], - "names": { - "title": "name", - "subHeading": [ - { - "kind": "keyword", - "spelling": "let" - }, - { - "kind": "text", - "spelling": " " - }, - { - "kind": "identifier", - "spelling": "name" - }, - { - "kind": "text", - "spelling": ": " - }, - { - "kind": "typeIdentifier", - "spelling": "String", - "preciseIdentifier": "s:SS" - } - ] - }, - "declarationFragments": [ - { - "kind": "keyword", - "spelling": "let" - }, - { - "kind": "text", - "spelling": " " - }, - { - "kind": "identifier", - "spelling": "name" - }, - { - "kind": "text", - "spelling": ": " - }, - { - "kind": "typeIdentifier", - "spelling": "String", - "preciseIdentifier": "s:SS" - } - ], - "accessLevel": "public", - "location": { - "uri": "file://path/to/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", - "position": { - "line": 4, - "character": 15 - } - } - }, - { - "kind": { - "identifier": "swift.property", - "displayName": "Instance Property" - }, - "identifier": { - "precise": "s:14MarkdownOutput0A6SymbolV8fullNameSSvp", - "interfaceLanguage": "swift" - }, - "pathComponents": [ - "MarkdownSymbol", - "fullName" - ], - "names": { - "title": "fullName", - "subHeading": [ - { - "kind": "keyword", - "spelling": "let" - }, - { - "kind": "text", - "spelling": " " - }, - { - "kind": "identifier", - "spelling": "fullName" - }, - { - "kind": "text", - "spelling": ": " - }, - { - "kind": "typeIdentifier", - "spelling": "String", - "preciseIdentifier": "s:SS" - } - ] - }, - "declarationFragments": [ - { - "kind": "keyword", - "spelling": "let" - }, - { - "kind": "text", - "spelling": " " - }, - { - "kind": "identifier", - "spelling": "fullName" - }, - { - "kind": "text", - "spelling": ": " - }, - { - "kind": "typeIdentifier", - "spelling": "String", - "preciseIdentifier": "s:SS" - } - ], - "accessLevel": "public", - "availability": [ - { - "domain": "macOS", - "introduced": { - "major": 2, - "minor": 0 - }, - "deprecated": { - "major": 4, - "minor": 0 - }, - "message": "Don't be so formal" - }, - { - "domain": "iOS", - "introduced": { - "major": 1, - "minor": 0 - }, - "deprecated": { - "major": 4, - "minor": 0 - }, - "message": "Don't be so formal" - } - ], - "location": { - "uri": "file://path/to/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", - "position": { - "line": 8, - "character": 15 - } - } - }, - { - "kind": { - "identifier": "swift.property", - "displayName": "Instance Property" - }, - "identifier": { - "precise": "s:14MarkdownOutput0A6SymbolV9otherNameSSSgvp", - "interfaceLanguage": "swift" - }, - "pathComponents": [ - "MarkdownSymbol", - "otherName" - ], - "names": { - "title": "otherName", - "subHeading": [ - { - "kind": "keyword", - "spelling": "var" - }, - { - "kind": "text", - "spelling": " " - }, - { - "kind": "identifier", - "spelling": "otherName" - }, - { - "kind": "text", - "spelling": ": " - }, - { - "kind": "typeIdentifier", - "spelling": "String", - "preciseIdentifier": "s:SS" - }, - { - "kind": "text", - "spelling": "?" - } - ] - }, - "declarationFragments": [ - { - "kind": "keyword", - "spelling": "var" - }, - { - "kind": "text", - "spelling": " " - }, - { - "kind": "identifier", - "spelling": "otherName" - }, - { - "kind": "text", - "spelling": ": " - }, - { - "kind": "typeIdentifier", - "spelling": "String", - "preciseIdentifier": "s:SS" - }, - { - "kind": "text", - "spelling": "?" - } - ], - "accessLevel": "public", - "availability": [ - { - "domain": "iOS", - "obsoleted": { - "major": 5, - "minor": 0 - } - } - ], - "location": { - "uri": "file://path/to/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", - "position": { - "line": 11, - "character": 15 - } - } - }, - { - "kind": { - "identifier": "swift.init", - "displayName": "Initializer" - }, - "identifier": { - "precise": "s:14MarkdownOutput0A6SymbolV4nameACSS_tcfc", - "interfaceLanguage": "swift" - }, - "pathComponents": [ - "MarkdownSymbol", - "init(name:)" - ], - "names": { - "title": "init(name:)", - "subHeading": [ - { - "kind": "keyword", - "spelling": "init" - }, - { - "kind": "text", - "spelling": "(" - }, - { - "kind": "externalParam", - "spelling": "name" - }, - { - "kind": "text", - "spelling": ": " - }, - { - "kind": "typeIdentifier", - "spelling": "String", - "preciseIdentifier": "s:SS" - }, - { - "kind": "text", - "spelling": ")" - } - ] - }, - "functionSignature": { - "parameters": [ - { - "name": "name", - "declarationFragments": [ - { - "kind": "identifier", - "spelling": "name" - }, - { - "kind": "text", - "spelling": ": " - }, - { - "kind": "typeIdentifier", - "spelling": "String", - "preciseIdentifier": "s:SS" - } - ] - } - ] - }, - "declarationFragments": [ - { - "kind": "keyword", - "spelling": "init" - }, - { - "kind": "text", - "spelling": "(" - }, - { - "kind": "externalParam", - "spelling": "name" - }, - { - "kind": "text", - "spelling": ": " - }, - { - "kind": "typeIdentifier", - "spelling": "String", - "preciseIdentifier": "s:SS" - }, - { - "kind": "text", - "spelling": ")" - } - ], - "accessLevel": "public", - "location": { - "uri": "file://path/to/MarkdownOutput/Sources/MarkdownOutput/MarkdownSymbol.swift", - "position": { - "line": 13, - "character": 11 - } - } - } - ], - "relationships": [ - { - "kind": "memberOf", - "source": "s:14MarkdownOutput0A6SymbolV4nameSSvp", - "target": "s:14MarkdownOutput0A6SymbolV" - }, - { - "kind": "memberOf", - "source": "s:14MarkdownOutput0A6SymbolV8fullNameSSvp", - "target": "s:14MarkdownOutput0A6SymbolV" - }, - { - "kind": "memberOf", - "source": "s:14MarkdownOutput0A6SymbolV9otherNameSSSgvp", - "target": "s:14MarkdownOutput0A6SymbolV" - }, - { - "kind": "memberOf", - "source": "s:14MarkdownOutput0A6SymbolV4nameACSS_tcfc", - "target": "s:14MarkdownOutput0A6SymbolV" - } - ] -} +{"metadata":{"formatVersion":{"major":0,"minor":6,"patch":0},"generator":"Apple Swift version 6.2.1 (swiftlang-6.2.1.2.1 clang-1700.4.2.2)"},"module":{"name":"MarkdownOutput","platform":{"architecture":"arm64","vendor":"apple","operatingSystem":{"name":"macosx","minimumVersion":{"major":10,"minor":13}}}},"symbols":[{"kind":{"identifier":"swift.struct","displayName":"Structure"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol"],"names":{"title":"MarkdownSymbol","navigator":[{"kind":"identifier","spelling":"MarkdownSymbol"}],"subHeading":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"MarkdownSymbol"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":2,"character":4},"end":{"line":2,"character":43}},"text":"A basic symbol to test markdown output."},{"range":{"start":{"line":3,"character":3},"end":{"line":3,"character":3}},"text":""},{"range":{"start":{"line":4,"character":4},"end":{"line":4,"character":39}},"text":"This is the overview of the symbol."}]},"declarationFragments":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"MarkdownSymbol"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":5,"character":14}}},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV4nameSSvp","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","name"],"names":{"title":"name","subHeading":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]},"declarationFragments":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":6,"character":15}}},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV8fullNameSSvp","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","fullName"],"names":{"title":"fullName","subHeading":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"fullName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]},"declarationFragments":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"fullName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}],"accessLevel":"public","availability":[{"domain":"macOS","introduced":{"major":2,"minor":0},"deprecated":{"major":4,"minor":0},"message":"Don't be so formal"},{"domain":"iOS","introduced":{"major":1,"minor":0},"deprecated":{"major":4,"minor":0},"message":"Don't be so formal"}],"location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":10,"character":15}}},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV9otherNameSSSgvp","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","otherName"],"names":{"title":"otherName","subHeading":[{"kind":"keyword","spelling":"var"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"otherName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":"?"}]},"declarationFragments":[{"kind":"keyword","spelling":"var"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"otherName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":"?"}],"accessLevel":"public","availability":[{"domain":"iOS","obsoleted":{"major":5,"minor":0}}],"location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":13,"character":15}}},{"kind":{"identifier":"swift.init","displayName":"Initializer"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV4nameACSS_tcfc","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","init(name:)"],"names":{"title":"init(name:)","subHeading":[{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":")"}]},"functionSignature":{"parameters":[{"name":"name","declarationFragments":[{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]}]},"declarationFragments":[{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":")"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":15,"character":11}}},{"kind":{"identifier":"swift.struct","displayName":"Structure"},"identifier":{"precise":"s:14MarkdownOutput17ExternalConformerV","interfaceLanguage":"swift"},"pathComponents":["ExternalConformer"],"names":{"title":"ExternalConformer","navigator":[{"kind":"identifier","spelling":"ExternalConformer"}],"subHeading":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"ExternalConformer"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":21,"character":4},"end":{"line":21,"character":53}},"text":"This type conforms to multiple external protocols"}]},"declarationFragments":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"ExternalConformer"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":22,"character":14}}},{"kind":{"identifier":"swift.func.op","displayName":"Operator"},"identifier":{"precise":"s:SQsE2neoiySbx_xtFZ::SYNTHESIZED::s:14MarkdownOutput17ExternalConformerV","interfaceLanguage":"swift"},"pathComponents":["ExternalConformer","!=(_:_:)"],"names":{"title":"!=(_:_:)","subHeading":[{"kind":"keyword","spelling":"static"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"!="},{"kind":"text","spelling":" "},{"kind":"text","spelling":"("},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":", "},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":") -> "},{"kind":"typeIdentifier","spelling":"Bool","preciseIdentifier":"s:Sb"}]},"docComment":{"module":"Swift","lines":[{"text":"Returns a Boolean value indicating whether two values are not equal."},{"text":""},{"text":"Inequality is the inverse of equality. For any values `a` and `b`, `a != b`"},{"text":"implies that `a == b` is `false`."},{"text":""},{"text":"This is the default implementation of the not-equal-to operator (`!=`)"},{"text":"for any type that conforms to `Equatable`."},{"text":""},{"text":"- Parameters:"},{"text":" - lhs: A value to compare."},{"text":" - rhs: Another value to compare."}]},"functionSignature":{"parameters":[{"name":"lhs","declarationFragments":[{"kind":"identifier","spelling":"lhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"}]},{"name":"rhs","declarationFragments":[{"kind":"identifier","spelling":"rhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"}]}],"returns":[{"kind":"typeIdentifier","spelling":"Bool","preciseIdentifier":"s:Sb"}]},"swiftExtension":{"extendedModule":"Swift","typeKind":"swift.protocol"},"declarationFragments":[{"kind":"keyword","spelling":"static"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"!="},{"kind":"text","spelling":" "},{"kind":"text","spelling":"("},{"kind":"internalParam","spelling":"lhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":", "},{"kind":"internalParam","spelling":"rhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":") -> "},{"kind":"typeIdentifier","spelling":"Bool","preciseIdentifier":"s:Sb"}],"accessLevel":"public"},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput17ExternalConformerV2idSSvp","interfaceLanguage":"swift"},"pathComponents":["ExternalConformer","id"],"names":{"title":"id","subHeading":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"id"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]},"docComment":{"module":"Swift","lines":[{"text":"The stable identity of the entity associated with this instance."}]},"declarationFragments":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"id"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":23,"character":15}}},{"kind":{"identifier":"swift.init","displayName":"Initializer"},"identifier":{"precise":"s:14MarkdownOutput17ExternalConformerV4fromACs7Decoder_p_tKcfc","interfaceLanguage":"swift"},"pathComponents":["ExternalConformer","init(from:)"],"names":{"title":"init(from:)","subHeading":[{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"from"},{"kind":"text","spelling":": any "},{"kind":"typeIdentifier","spelling":"Decoder","preciseIdentifier":"s:s7DecoderP"},{"kind":"text","spelling":") "},{"kind":"keyword","spelling":"throws"}]},"docComment":{"module":"Swift","lines":[{"text":"Creates a new instance by decoding from the given decoder."},{"text":""},{"text":"This initializer throws an error if reading from the decoder fails, or"},{"text":"if the data read is corrupted or otherwise invalid."},{"text":""},{"text":"- Parameter decoder: The decoder to read data from."}]},"functionSignature":{"parameters":[{"name":"from","internalName":"decoder","declarationFragments":[{"kind":"identifier","spelling":"decoder"},{"kind":"text","spelling":": any "},{"kind":"typeIdentifier","spelling":"Decoder","preciseIdentifier":"s:s7DecoderP"}]}]},"declarationFragments":[{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"from"},{"kind":"text","spelling":" "},{"kind":"internalParam","spelling":"decoder"},{"kind":"text","spelling":": any "},{"kind":"typeIdentifier","spelling":"Decoder","preciseIdentifier":"s:s7DecoderP"},{"kind":"text","spelling":") "},{"kind":"keyword","spelling":"throws"}],"accessLevel":"public"},{"kind":{"identifier":"swift.enum","displayName":"Enumeration"},"identifier":{"precise":"s:14MarkdownOutput14LocalConformerO","interfaceLanguage":"swift"},"pathComponents":["LocalConformer"],"names":{"title":"LocalConformer","navigator":[{"kind":"identifier","spelling":"LocalConformer"}],"subHeading":[{"kind":"keyword","spelling":"enum"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalConformer"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":26,"character":4},"end":{"line":26,"character":55}},"text":"This type demonstrates conformance in documentation"}]},"declarationFragments":[{"kind":"keyword","spelling":"enum"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalConformer"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":27,"character":12}}},{"kind":{"identifier":"swift.func.op","displayName":"Operator"},"identifier":{"precise":"s:SQsE2neoiySbx_xtFZ::SYNTHESIZED::s:14MarkdownOutput14LocalConformerO","interfaceLanguage":"swift"},"pathComponents":["LocalConformer","!=(_:_:)"],"names":{"title":"!=(_:_:)","subHeading":[{"kind":"keyword","spelling":"static"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"!="},{"kind":"text","spelling":" "},{"kind":"text","spelling":"("},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":", "},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":") -> "},{"kind":"typeIdentifier","spelling":"Bool","preciseIdentifier":"s:Sb"}]},"docComment":{"module":"Swift","lines":[{"text":"Returns a Boolean value indicating whether two values are not equal."},{"text":""},{"text":"Inequality is the inverse of equality. For any values `a` and `b`, `a != b`"},{"text":"implies that `a == b` is `false`."},{"text":""},{"text":"This is the default implementation of the not-equal-to operator (`!=`)"},{"text":"for any type that conforms to `Equatable`."},{"text":""},{"text":"- Parameters:"},{"text":" - lhs: A value to compare."},{"text":" - rhs: Another value to compare."}]},"functionSignature":{"parameters":[{"name":"lhs","declarationFragments":[{"kind":"identifier","spelling":"lhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"}]},{"name":"rhs","declarationFragments":[{"kind":"identifier","spelling":"rhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"}]}],"returns":[{"kind":"typeIdentifier","spelling":"Bool","preciseIdentifier":"s:Sb"}]},"swiftExtension":{"extendedModule":"Swift","typeKind":"swift.protocol"},"declarationFragments":[{"kind":"keyword","spelling":"static"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"!="},{"kind":"text","spelling":" "},{"kind":"text","spelling":"("},{"kind":"internalParam","spelling":"lhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":", "},{"kind":"internalParam","spelling":"rhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":") -> "},{"kind":"typeIdentifier","spelling":"Bool","preciseIdentifier":"s:Sb"}],"accessLevel":"public"},{"kind":{"identifier":"swift.method","displayName":"Instance Method"},"identifier":{"precise":"s:14MarkdownOutput14LocalConformerO11localMethodyyF","interfaceLanguage":"swift"},"pathComponents":["LocalConformer","localMethod()"],"names":{"title":"localMethod()","subHeading":[{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"localMethod"},{"kind":"text","spelling":"()"}]},"functionSignature":{"returns":[{"kind":"text","spelling":"()"}]},"declarationFragments":[{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"localMethod"},{"kind":"text","spelling":"()"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":28,"character":16}}},{"kind":{"identifier":"swift.enum.case","displayName":"Case"},"identifier":{"precise":"s:14MarkdownOutput14LocalConformerO3booyA2CmF","interfaceLanguage":"swift"},"pathComponents":["LocalConformer","boo"],"names":{"title":"LocalConformer.boo","subHeading":[{"kind":"keyword","spelling":"case"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"boo"}]},"declarationFragments":[{"kind":"keyword","spelling":"case"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"boo"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":32,"character":9}}},{"kind":{"identifier":"swift.protocol","displayName":"Protocol"},"identifier":{"precise":"s:14MarkdownOutput13LocalProtocolP","interfaceLanguage":"swift"},"pathComponents":["LocalProtocol"],"names":{"title":"LocalProtocol","navigator":[{"kind":"identifier","spelling":"LocalProtocol"}],"subHeading":[{"kind":"keyword","spelling":"protocol"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalProtocol"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":35,"character":4},"end":{"line":35,"character":76}},"text":"This is a locally defined protocol to support the relationship test case"}]},"declarationFragments":[{"kind":"keyword","spelling":"protocol"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalProtocol"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":36,"character":16}}},{"kind":{"identifier":"swift.method","displayName":"Instance Method"},"identifier":{"precise":"s:14MarkdownOutput13LocalProtocolP11localMethodyyF","interfaceLanguage":"swift"},"pathComponents":["LocalProtocol","localMethod()"],"names":{"title":"localMethod()","subHeading":[{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"localMethod"},{"kind":"text","spelling":"()"}]},"functionSignature":{"returns":[{"kind":"text","spelling":"()"}]},"declarationFragments":[{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"localMethod"},{"kind":"text","spelling":"()"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":37,"character":9}}},{"kind":{"identifier":"swift.class","displayName":"Class"},"identifier":{"precise":"s:14MarkdownOutput13LocalSubclassC","interfaceLanguage":"swift"},"pathComponents":["LocalSubclass"],"names":{"title":"LocalSubclass","navigator":[{"kind":"identifier","spelling":"LocalSubclass"}],"subHeading":[{"kind":"keyword","spelling":"class"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalSubclass"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":40,"character":4},"end":{"line":40,"character":63}},"text":"This is a class to demonstrate inheritance in documentation"}]},"declarationFragments":[{"kind":"keyword","spelling":"class"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalSubclass"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":41,"character":13}}},{"kind":{"identifier":"swift.class","displayName":"Class"},"identifier":{"precise":"s:14MarkdownOutput15LocalSuperclassC","interfaceLanguage":"swift"},"pathComponents":["LocalSuperclass"],"names":{"title":"LocalSuperclass","navigator":[{"kind":"identifier","spelling":"LocalSuperclass"}],"subHeading":[{"kind":"keyword","spelling":"class"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalSuperclass"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":45,"character":4},"end":{"line":45,"character":70}},"text":"This is a class to demonstrate inheritance in symbol documentation"}]},"declarationFragments":[{"kind":"keyword","spelling":"class"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalSuperclass"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":46,"character":13}}}],"relationships":[{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV4nameSSvp","target":"s:14MarkdownOutput0A6SymbolV"},{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV8fullNameSSvp","target":"s:14MarkdownOutput0A6SymbolV"},{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV9otherNameSSSgvp","target":"s:14MarkdownOutput0A6SymbolV"},{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV4nameACSS_tcfc","target":"s:14MarkdownOutput0A6SymbolV"},{"kind":"memberOf","source":"s:SQsE2neoiySbx_xtFZ::SYNTHESIZED::s:14MarkdownOutput17ExternalConformerV","target":"s:14MarkdownOutput17ExternalConformerV","sourceOrigin":{"identifier":"s:SQsE2neoiySbx_xtFZ","displayName":"Equatable.!=(_:_:)"}},{"kind":"conformsTo","source":"s:14MarkdownOutput17ExternalConformerV","target":"s:Se","targetFallback":"Swift.Decodable"},{"kind":"conformsTo","source":"s:14MarkdownOutput17ExternalConformerV","target":"s:SE","targetFallback":"Swift.Encodable"},{"kind":"conformsTo","source":"s:14MarkdownOutput17ExternalConformerV","target":"s:s12IdentifiableP","targetFallback":"Swift.Identifiable"},{"kind":"conformsTo","source":"s:14MarkdownOutput17ExternalConformerV","target":"s:SH","targetFallback":"Swift.Hashable"},{"kind":"conformsTo","source":"s:14MarkdownOutput17ExternalConformerV","target":"s:SQ","targetFallback":"Swift.Equatable"},{"kind":"memberOf","source":"s:14MarkdownOutput17ExternalConformerV2idSSvp","target":"s:14MarkdownOutput17ExternalConformerV","sourceOrigin":{"identifier":"s:s12IdentifiableP2id2IDQzvp","displayName":"Identifiable.id"}},{"kind":"memberOf","source":"s:14MarkdownOutput17ExternalConformerV4fromACs7Decoder_p_tKcfc","target":"s:14MarkdownOutput17ExternalConformerV","sourceOrigin":{"identifier":"s:Se4fromxs7Decoder_p_tKcfc","displayName":"Decodable.init(from:)"}},{"kind":"memberOf","source":"s:SQsE2neoiySbx_xtFZ::SYNTHESIZED::s:14MarkdownOutput14LocalConformerO","target":"s:14MarkdownOutput14LocalConformerO","sourceOrigin":{"identifier":"s:SQsE2neoiySbx_xtFZ","displayName":"Equatable.!=(_:_:)"}},{"kind":"conformsTo","source":"s:14MarkdownOutput14LocalConformerO","target":"s:SQ","targetFallback":"Swift.Equatable"},{"kind":"conformsTo","source":"s:14MarkdownOutput14LocalConformerO","target":"s:SH","targetFallback":"Swift.Hashable"},{"kind":"conformsTo","source":"s:14MarkdownOutput14LocalConformerO","target":"s:14MarkdownOutput13LocalProtocolP"},{"kind":"memberOf","source":"s:14MarkdownOutput14LocalConformerO11localMethodyyF","target":"s:14MarkdownOutput14LocalConformerO","sourceOrigin":{"identifier":"s:14MarkdownOutput13LocalProtocolP11localMethodyyF","displayName":"LocalProtocol.localMethod()"}},{"kind":"memberOf","source":"s:14MarkdownOutput14LocalConformerO3booyA2CmF","target":"s:14MarkdownOutput14LocalConformerO"},{"kind":"requirementOf","source":"s:14MarkdownOutput13LocalProtocolP11localMethodyyF","target":"s:14MarkdownOutput13LocalProtocolP"},{"kind":"inheritsFrom","source":"s:14MarkdownOutput13LocalSubclassC","target":"s:14MarkdownOutput15LocalSuperclassC"}]} \ No newline at end of file From 74b602375db36940cc753f8b8021605008fe10f5 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Wed, 24 Sep 2025 15:44:09 +0100 Subject: [PATCH 21/28] Manifest output format updates --- .../ConvertActionConverter.swift | 5 +- .../Model/MarkdownOutputManifest.swift | 48 +++++++---- .../Model/MarkdownOutputNode.swift | 10 +-- .../MarkdownOutputMarkdownWalker.swift | 32 ++++++- .../MarkdownOutputNodeTranslator.swift | 4 +- .../MarkdownOutputSemanticVisitor.swift | 66 +++++++------- .../Markdown/MarkdownOutputTests.swift | 81 ++++++++++-------- .../original-source/MarkdownOutput.zip | Bin 1572 -> 2295 bytes 8 files changed, 148 insertions(+), 98 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift index 2592bb3b8..afebc6e26 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift @@ -137,10 +137,11 @@ package enum ConvertActionConverter { renderNode.metadata.hasGeneratedMarkdown = true if FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled, - let document = markdownNode.manifestDocument + let manifest = markdownNode.manifest { resultsGroup.async(queue: resultsSyncQueue) { - markdownManifest.documents.append(document) + markdownManifest.documents.formUnion(manifest.documents) + markdownManifest.relationships.formUnion(manifest.relationships) } } } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift index 27402e177..560129796 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift @@ -13,53 +13,65 @@ 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 { +public struct MarkdownOutputManifest: Codable, Sendable { public static let version = "0.1.0" public let manifestVersion: String public let title: String - public var documents: [Document] + public var documents: Set + public var relationships: Set - public init(title: String, documents: [Document]) { + public init(title: String, documents: Set = [], relationships: Set = []) { self.manifestVersion = Self.version self.title = title self.documents = documents + self.relationships = relationships } } extension MarkdownOutputManifest { - public enum DocumentType: String, Codable { + public enum DocumentType: String, Codable, Sendable { case article, tutorial, symbol } - public enum RelationshipType: String, Codable { - case topics - case memberSymbols - case relationships + public enum RelationshipType: String, Codable, Sendable { + case belongsToTopic + case memberSymbol + case relatedSymbol } - public struct RelatedDocument: Codable, Hashable { - public let uri: String - public let subtype: String + 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 { + 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 - - /// The outgoing references of the document, grouped by relationship type - public var references: [RelationshipType: Set] - - public init(uri: String, documentType: MarkdownOutputManifest.DocumentType, title: String, references: [MarkdownOutputManifest.RelationshipType : Set]) { + + public init(uri: String, documentType: MarkdownOutputManifest.DocumentType, title: String) { self.uri = uri self.documentType = documentType self.title = title - self.references = references + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(uri) } } } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift index 00c13998a..866b031a2 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift @@ -13,7 +13,7 @@ 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 { +public struct MarkdownOutputNode: Sendable { /// The metadata about this node public var metadata: Metadata @@ -27,15 +27,15 @@ public struct MarkdownOutputNode { } extension MarkdownOutputNode { - public struct Metadata: Codable { + public struct Metadata: Codable, Sendable { static let version = "0.1.0" - public enum DocumentType: String, Codable { + public enum DocumentType: String, Codable, Sendable { case article, tutorial, symbol } - public struct Availability: Codable, Equatable { + public struct Availability: Codable, Equatable, Sendable { let platform: String let introduced: String? @@ -72,7 +72,7 @@ extension MarkdownOutputNode { } } - public struct Symbol: Codable { + public struct Symbol: Codable, Sendable { public let kind: String public let preciseIdentifier: String public let modules: [String] diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index 5d94e5038..7534432b0 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -15,11 +15,19 @@ internal struct MarkdownOutputMarkupWalker: MarkupWalker { let context: DocumentationContext let bundle: DocumentationBundle let identifier: ResolvedTopicReference + + init(context: DocumentationContext, bundle: DocumentationBundle, identifier: ResolvedTopicReference) { + self.context = context + self.bundle = bundle + self.identifier = identifier + } + var markdown = "" - var outgoingReferences: Set = [] + var outgoingReferences: Set = [] private(set) var indentationToRemove: String? private(set) var isRenderingLinkList = false + private var lastHeading: String? = nil /// Perform actions while rendering a link list, which affects the output formatting of links public mutating func withRenderingLinkList(_ process: (inout Self) -> Void) { @@ -91,6 +99,9 @@ extension MarkdownOutputMarkupWalker { public mutating func visitHeading(_ heading: Heading) -> () { startNewParagraphIfRequired() markdown.append(heading.detachedFromParent.format()) + if heading.level > 1 { + lastHeading = heading.plainText + } } public mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> () { @@ -132,7 +143,7 @@ extension MarkdownOutputMarkupWalker { else { return defaultVisit(symbolLink) } - outgoingReferences.insert(resolved) + let linkTitle: String var linkListAbstract: (any Markup)? if @@ -148,6 +159,7 @@ extension MarkdownOutputMarkupWalker { } else { linkTitle = symbol.title } + add(source: resolved, type: .belongsToTopic, subtype: nil) } else { linkTitle = node.title } @@ -165,7 +177,7 @@ extension MarkdownOutputMarkupWalker { else { return defaultVisit(link) } - outgoingReferences.insert(resolved) + let linkTitle: String var linkListAbstract: (any Markup)? if @@ -173,6 +185,7 @@ extension MarkdownOutputMarkupWalker { { if isRenderingLinkList { linkListAbstract = article.abstract + add(source: resolved, type: .belongsToTopic, subtype: nil) } linkTitle = article.title?.plainText ?? resolved.lastPathComponent } else { @@ -318,3 +331,16 @@ extension MarkdownOutputMarkupWalker { } } + +// MARK: - Manifest construction +extension MarkdownOutputMarkupWalker { + mutating func add(source: ResolvedTopicReference, type: MarkdownOutputManifest.RelationshipType, subtype: String?) { + var targetURI = identifier.path + if let lastHeading { + targetURI.append("#\(urlReadableFragment(lastHeading))") + } + let relationship = MarkdownOutputManifest.Relationship(sourceURI: source.path, relationshipType: type, subtype: subtype, targetURI: targetURI) + outgoingReferences.insert(relationship) + + } +} diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift index 8a3f30ff1..b11b731f2 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift @@ -21,7 +21,7 @@ public struct MarkdownOutputNodeTranslator { public mutating func createOutput() -> WritableMarkdownOutputNode? { if let node = visitor.start() { - return WritableMarkdownOutputNode(identifier: visitor.identifier, node: node, manifestDocument: visitor.manifestDocument) + return WritableMarkdownOutputNode(identifier: visitor.identifier, node: node, manifest: visitor.manifest) } return nil } @@ -30,5 +30,5 @@ public struct MarkdownOutputNodeTranslator { public struct WritableMarkdownOutputNode { public let identifier: ResolvedTopicReference public let node: MarkdownOutputNode - public let manifestDocument: MarkdownOutputManifest.Document? + public let manifest: MarkdownOutputManifest? } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 6b982e257..9a025bb64 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -16,7 +16,7 @@ internal struct MarkdownOutputSemanticVisitor: SemanticVisitor { let documentationNode: DocumentationNode let identifier: ResolvedTopicReference var markdownWalker: MarkdownOutputMarkupWalker - var manifestDocument: MarkdownOutputManifest.Document? + var manifest: MarkdownOutputManifest? init(context: DocumentationContext, bundle: DocumentationBundle, node: DocumentationNode) { self.context = context @@ -48,26 +48,27 @@ extension MarkdownOutputNode.Metadata { } } -extension MarkdownOutputManifest.Document { - mutating func add(reference: ResolvedTopicReference, subtype: String, forRelationshipType type: MarkdownOutputManifest.RelationshipType) { - let related = MarkdownOutputManifest.RelatedDocument(uri: reference.path, subtype: subtype) - references[type, default: []].insert(related) +// MARK: - Manifest construction +extension MarkdownOutputSemanticVisitor { + + mutating func add(target: ResolvedTopicReference, type: MarkdownOutputManifest.RelationshipType, subtype: String?) { + add(targetURI: target.path, type: type, subtype: subtype) } - mutating func add(fallbackReference: String, subtype: String, forRelationshipType type: MarkdownOutputManifest.RelationshipType) { + mutating func add(fallbackTarget: String, type: MarkdownOutputManifest.RelationshipType, subtype: String?) { let uri: String - let components = fallbackReference.components(separatedBy: ".") + let components = fallbackTarget.components(separatedBy: ".") if components.count > 1 { uri = "/documentation/\(components.joined(separator: "/"))" } else { - uri = fallbackReference + uri = fallbackTarget } - let related = MarkdownOutputManifest.RelatedDocument(uri: uri, subtype: subtype) - references[type, default: []].insert(related) + add(targetURI: uri, type: type, subtype: subtype) } - func references(for type: MarkdownOutputManifest.RelationshipType) -> Set? { - references[type] + mutating func add(targetURI: String, type: MarkdownOutputManifest.RelationshipType, subtype: String?) { + let relationship = MarkdownOutputManifest.Relationship(sourceURI: identifier.path, relationshipType: type, subtype: subtype, targetURI: targetURI) + manifest?.relationships.insert(relationship) } } @@ -80,13 +81,14 @@ extension MarkdownOutputSemanticVisitor { metadata.title = title } - manifestDocument = MarkdownOutputManifest.Document( + let document = MarkdownOutputManifest.Document( uri: identifier.path, documentType: .article, - title: metadata.title, - references: [:] + title: metadata.title ) + manifest = MarkdownOutputManifest(title: bundle.displayName, documents: [document]) + if let metadataAvailability = article.metadata?.availability, !metadataAvailability.isEmpty { @@ -103,9 +105,8 @@ extension MarkdownOutputSemanticVisitor { $0.visit(section: article.topics, addingHeading: "Topics") $0.visit(section: article.seeAlso, addingHeading: "See Also") } - for reference in markdownWalker.outgoingReferences { - manifestDocument?.add(reference: reference, subtype: "Topics", forRelationshipType: .topics) - } + + manifest?.relationships.formUnion(markdownWalker.outgoingReferences) return MarkdownOutputNode(metadata: metadata, markdown: markdownWalker.markdown) } } @@ -121,12 +122,12 @@ extension MarkdownOutputSemanticVisitor { metadata.symbol = .init(symbol, context: context, bundle: bundle) metadata.role = symbol.kind.displayName - manifestDocument = MarkdownOutputManifest.Document( + let document = MarkdownOutputManifest.Document( uri: identifier.path, documentType: .symbol, - title: metadata.title, - references: [:] + title: metadata.title ) + manifest = MarkdownOutputManifest(title: bundle.displayName, documents: [document]) // Availability @@ -169,24 +170,22 @@ extension MarkdownOutputSemanticVisitor { $0.visit(section: symbol.topics, addingHeading: "Topics") $0.visit(section: symbol.seeAlso, addingHeading: "See Also") } - for reference in markdownWalker.outgoingReferences { - manifestDocument?.add(reference: reference, subtype: "Topics", forRelationshipType: .topics) - } + + manifest?.relationships.formUnion(markdownWalker.outgoingReferences) + for child in context.children(of: identifier) { // Only interested in symbols guard child.kind.isSymbol else { continue } - // Not interested in symbols that have been curated already - if markdownWalker.outgoingReferences.contains(child.reference) { continue } - manifestDocument?.add(reference: child.reference, subtype: child.kind.name, forRelationshipType: .memberSymbols) + add(target: child.reference, type: .memberSymbol, subtype: child.kind.name) } for relationshipGroup in symbol.relationships.groups { for destination in relationshipGroup.destinations { switch context.resolve(destination, in: identifier) { case .success(let resolved): - manifestDocument?.add(reference: resolved, subtype: relationshipGroup.kind.rawValue, forRelationshipType: .relationships) - case .failure(let unresolved, let error): + add(target: resolved, type: .relatedSymbol, subtype: relationshipGroup.kind.rawValue) + case .failure: if let fallback = symbol.relationships.targetFallbacks[destination] { - manifestDocument?.add(fallbackReference: fallback, subtype: relationshipGroup.kind.rawValue, forRelationshipType: .relationships) + add(fallbackTarget: fallback, type: .relatedSymbol, subtype: relationshipGroup.kind.rawValue) } } } @@ -256,13 +255,14 @@ extension MarkdownOutputSemanticVisitor { metadata.title = tutorial.intro.title } - manifestDocument = MarkdownOutputManifest.Document( + let document = MarkdownOutputManifest.Document( uri: identifier.path, documentType: .tutorial, - title: metadata.title, - references: [:] + title: metadata.title ) + manifest = MarkdownOutputManifest(title: metadata.title, documents: [document]) + sectionIndex = 0 for child in tutorial.children { _ = visit(child) diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 158d4ba00..333195ef5 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -54,9 +54,9 @@ final class MarkdownOutputTests: XCTestCase { /// Generates a markdown manifest document (with relationships) from a given path /// - Parameter path: The path. If you just supply a name (no leading slash), it will prepend `/documentation/MarkdownOutput/`, otherwise the path will be used /// - Returns: The generated markdown output manifest document - private func generateMarkdownManifestDocument(path: String) async throws -> MarkdownOutputManifest.Document { + private func generateMarkdownManifest(path: String) async throws -> MarkdownOutputManifest { let outputNode = try await generateWritableMarkdown(path: path) - return try XCTUnwrap(outputNode.manifestDocument) + return try XCTUnwrap(outputNode.manifest) } // MARK: Directive special processing @@ -247,62 +247,73 @@ final class MarkdownOutputTests: XCTestCase { // MARK: - Manifest func testArticleManifestLinks() async throws { - let document = try await generateMarkdownManifestDocument(path: "Links") - let topics = try XCTUnwrap(document.references(for: .topics)) - XCTAssertEqual(topics.count, 2) - let ids = topics.map { $0.uri } - XCTAssert(ids.contains("/documentation/MarkdownOutput/RowsAndColumns")) - XCTAssert(ids.contains("/documentation/MarkdownOutput/MarkdownSymbol")) + let manifest = try await generateMarkdownManifest(path: "Links") + let rows = MarkdownOutputManifest.Relationship( + sourceURI: "/documentation/MarkdownOutput/RowsAndColumns", + relationshipType: .belongsToTopic, + targetURI: "/documentation/MarkdownOutput/Links#Links-with-abstracts" + ) + + let symbol = MarkdownOutputManifest.Relationship( + sourceURI: "/documentation/MarkdownOutput/MarkdownSymbol", + relationshipType: .belongsToTopic, + targetURI: "/documentation/MarkdownOutput/Links#Links-with-abstracts" + ) + + XCTAssert(manifest.relationships.contains(rows)) + XCTAssert(manifest.relationships.contains(symbol)) } func testSymbolManifestChildSymbols() async throws { - let document = try await generateMarkdownManifestDocument(path: "MarkdownSymbol") - let children = try XCTUnwrap(document.references(for: .memberSymbols)) + let manifest = try await generateMarkdownManifest(path: "MarkdownSymbol") + let children = manifest.relationships + .filter { $0.relationshipType == .memberSymbol } + .map { $0.targetURI } XCTAssertEqual(children.count, 4) - let ids = children.map { $0.uri } - XCTAssert(ids.contains("/documentation/MarkdownOutput/MarkdownSymbol/name")) - XCTAssert(ids.contains("/documentation/MarkdownOutput/MarkdownSymbol/otherName")) - XCTAssert(ids.contains("/documentation/MarkdownOutput/MarkdownSymbol/fullName")) - XCTAssert(ids.contains("/documentation/MarkdownOutput/MarkdownSymbol/init(name:)")) + + XCTAssert(children.contains("/documentation/MarkdownOutput/MarkdownSymbol/name")) + XCTAssert(children.contains("/documentation/MarkdownOutput/MarkdownSymbol/otherName")) + XCTAssert(children.contains("/documentation/MarkdownOutput/MarkdownSymbol/fullName")) + XCTAssert(children.contains("/documentation/MarkdownOutput/MarkdownSymbol/init(name:)")) } func testSymbolManifestInheritance() async throws { - let document = try await generateMarkdownManifestDocument(path: "LocalSubclass") - let relationships = try XCTUnwrap(document.references(for: .relationships)) - XCTAssert(relationships.contains(where: { - $0.uri == "/documentation/MarkdownOutput/LocalSuperclass" && $0.subtype == "inheritsFrom" + let manifest = try await generateMarkdownManifest(path: "LocalSubclass") + let related = manifest.relationships.filter { $0.relationshipType == .relatedSymbol } + XCTAssert(related.contains(where: { + $0.targetURI == "/documentation/MarkdownOutput/LocalSuperclass" && $0.subtype == "inheritsFrom" })) } func testSymbolManifestInheritedBy() async throws { - let document = try await generateMarkdownManifestDocument(path: "LocalSuperclass") - let relationships = try XCTUnwrap(document.references(for: .relationships)) - XCTAssert(relationships.contains(where: { - $0.uri == "/documentation/MarkdownOutput/LocalSubclass" && $0.subtype == "inheritedBy" + let manifest = try await generateMarkdownManifest(path: "LocalSuperclass") + let related = manifest.relationships.filter { $0.relationshipType == .relatedSymbol } + XCTAssert(related.contains(where: { + $0.targetURI == "/documentation/MarkdownOutput/LocalSubclass" && $0.subtype == "inheritedBy" })) } func testSymbolManifestConformsTo() async throws { - let document = try await generateMarkdownManifestDocument(path: "LocalConformer") - let relationships = try XCTUnwrap(document.references(for: .relationships)) - XCTAssert(relationships.contains(where: { - $0.uri == "/documentation/MarkdownOutput/LocalProtocol" && $0.subtype == "conformsTo" + let manifest = try await generateMarkdownManifest(path: "LocalConformer") + let related = manifest.relationships.filter { $0.relationshipType == .relatedSymbol } + XCTAssert(related.contains(where: { + $0.targetURI == "/documentation/MarkdownOutput/LocalProtocol" && $0.subtype == "conformsTo" })) } func testSymbolManifestConformingTypes() async throws { - let document = try await generateMarkdownManifestDocument(path: "LocalProtocol") - let relationships = try XCTUnwrap(document.references(for: .relationships)) - XCTAssert(relationships.contains(where: { - $0.uri == "/documentation/MarkdownOutput/LocalConformer" && $0.subtype == "conformingTypes" + let manifest = try await generateMarkdownManifest(path: "LocalProtocol") + let related = manifest.relationships.filter { $0.relationshipType == .relatedSymbol } + XCTAssert(related.contains(where: { + $0.targetURI == "/documentation/MarkdownOutput/LocalConformer" && $0.subtype == "conformingTypes" })) } func testSymbolManifestExternalConformsTo() async throws { - let document = try await generateMarkdownManifestDocument(path: "ExternalConformer") - let relationships = try XCTUnwrap(document.references(for: .relationships)) - XCTAssert(relationships.contains(where: { - $0.uri == "/documentation/Swift/Hashable" && $0.subtype == "conformsTo" + let manifest = try await generateMarkdownManifest(path: "ExternalConformer") + let related = manifest.relationships.filter { $0.relationshipType == .relatedSymbol } + XCTAssert(related.contains(where: { + $0.targetURI == "/documentation/Swift/Hashable" && $0.subtype == "conformsTo" })) } } diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/original-source/MarkdownOutput.zip b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/original-source/MarkdownOutput.zip index 1c363b2aa584c60e97566ed409d6c91215b52110..9fe8ca2982db40c5f08cd22d1b954c05c4597563 100644 GIT binary patch delta 705 zcmZ3&^IdR453`^5>5cu{8JQpXT1@t2vSH`ga5;l}!{yCAOf8Jk%q$`d3=A9$K*{Ld zTdR`qGcquoWno~jVvw2a&#FH80TV~Pe`!fUX^CEOd1hKkXb2|*GsuKFJ1%FGR&X;g zvU~+<0h=;6B(VF6fk55+^%C!n+D7bJ{^E4giaA|P%Maa?_n7?BbViDo(aqwCzu&86 zmao4n$0hD`GDYov&F9ph-0Dv}^EIthBPOq2bvBJzeu1E)HwXLe35;q9JUsPbKjORI z$p$>JE1H|^{U>)h^OBRYOm8o~H@(Na^{AY|t5yBICnvmFoU%i5VVQHAz339dHi?vi zLyyGD-fZ2iz3)?{{x>J-ixM#}<#(HA9G@35WyQ7+oAyODAG)_#WR7{M! zA1B`7Zc`If$~YOFsHb<{aqWcX8`jU`wyxJ))_bhRUi-z#sui45_@0>LzgjJMF0Nw1 zlI8Q6_Et|xU(sV&da(_3x4kWHNf_sfqWHy4|_dA`h>p)#|e?Ed^?4X+=X{L+n?d}`|YhpU~A@4DLj)wM-${WRN!S3M7~ zYPU{{sXsOKora7im%1S*Q&-9s&7&6{C@bvBcqn~Ra%s|k8Sfdw8H>GjHvMPYsl0U- zmvvTYl$W4@)FR3ACPy0*_&W1f%dVOn7kO6T_a~{(t3PIN-M#R#uA)Zrv&X-ZSDJ^s zmw8KF>IldySQI@;YwPr9^{32abTZ2~YF_)6@$Cis_XCf)DIF&%lj~R~O`gEYrGS}$B!CHvfdQB}7?w2hu}uERA~`vol^>L%L{L){D>y|> V{>Li8$Hu_M(7?pNkjMto3ji3tD@OnT delta 77 zcmew^xP)gy4|C(*eH;6?Gcr$JpfTB#$%g%y#{P_x8v8f*FtsplPG*yX$h5F~F#}~L YUu2&o!pg<~1gt>Fz{J47#{%L30CsQ|!2kdN From 198d8e8285571ce15fd9908a344b56ef814d1dc5 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 25 Sep 2025 11:31:10 +0100 Subject: [PATCH 22/28] Remove member symbol relationship --- .../Model/MarkdownOutputManifest.swift | 22 ++++++++++++++++++- .../MarkdownOutputSemanticVisitor.swift | 5 ----- .../Markdown/MarkdownOutputTests.swift | 22 +++++++++++++++---- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift index 560129796..f9144de63 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift @@ -16,9 +16,13 @@ import Foundation 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 + /// Relationships involving documents in the manifest public var relationships: Set public init(title: String, documents: Set = [], relationships: Set = []) { @@ -36,11 +40,15 @@ extension MarkdownOutputManifest { } 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 - case memberSymbol + /// 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 @@ -74,4 +82,16 @@ extension MarkdownOutputManifest { hasher.combine(uri) } } + + public func children(of parent: Document) -> Set { + 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 + } + } } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 9a025bb64..bdb855d56 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -173,11 +173,6 @@ extension MarkdownOutputSemanticVisitor { manifest?.relationships.formUnion(markdownWalker.outgoingReferences) - for child in context.children(of: identifier) { - // Only interested in symbols - guard child.kind.isSymbol else { continue } - add(target: child.reference, type: .memberSymbol, subtype: child.kind.name) - } for relationshipGroup in symbol.relationships.groups { for destination in relationshipGroup.destinations { switch context.resolve(destination, in: identifier) { diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 333195ef5..8470b1ac7 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -265,10 +265,24 @@ final class MarkdownOutputTests: XCTestCase { } func testSymbolManifestChildSymbols() async throws { - let manifest = try await generateMarkdownManifest(path: "MarkdownSymbol") - let children = manifest.relationships - .filter { $0.relationshipType == .memberSymbol } - .map { $0.targetURI } + // This is a calculated function so we don't need to ingest anything + let documentURIs: [String] = [ + "/documentation/MarkdownOutput/MarkdownSymbol", + "/documentation/MarkdownOutput/MarkdownSymbol/name", + "/documentation/MarkdownOutput/MarkdownSymbol/otherName", + "/documentation/MarkdownOutput/MarkdownSymbol/fullName", + "/documentation/MarkdownOutput/MarkdownSymbol/init(name:)", + "documentation/MarkdownOutput/MarkdownSymbol/Child/Grandchild", + "documentation/MarkdownOutput/Sibling/name" + ] + + let documents = documentURIs.map { + MarkdownOutputManifest.Document(uri: $0, documentType: .symbol, title: $0) + } + let manifest = MarkdownOutputManifest(title: "Test", documents: Set(documents)) + + let document = try XCTUnwrap(manifest.documents.first(where: { $0.uri == "/documentation/MarkdownOutput/MarkdownSymbol" })) + let children = manifest.children(of: document).map { $0.uri } XCTAssertEqual(children.count, 4) XCTAssert(children.contains("/documentation/MarkdownOutput/MarkdownSymbol/name")) From 53ba222e90d5d9533f20afed17a2c07fed234a1d Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Fri, 26 Sep 2025 12:49:42 +0100 Subject: [PATCH 23/28] More compact availability, deal with metadata availability for symbols --- .../Model/MarkdownOutputNode.swift | 74 ++++++++++++++----- .../MarkdownOutputSemanticVisitor.swift | 27 +++++-- .../Markdown/MarkdownOutputTests.swift | 59 ++++++++++++++- .../MarkdownOutput.docc/MarkdownOutput.md | 4 + 4 files changed, 137 insertions(+), 27 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift index 866b031a2..e060aa7db 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift @@ -41,35 +41,73 @@ extension MarkdownOutputNode { let introduced: String? let deprecated: String? let unavailable: Bool - - public enum CodingKeys: String, CodingKey { - case platform, introduced, deprecated, unavailable - } - + public init(platform: String, introduced: String? = nil, deprecated: String? = nil, unavailable: Bool) { self.platform = platform - self.introduced = introduced + // Can't have deprecated without an introduced + self.introduced = introduced ?? deprecated self.deprecated = deprecated - self.unavailable = unavailable + // 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.container(keyedBy: CodingKeys.self) - try container.encode(platform, forKey: .platform) - try container.encodeIfPresent(introduced, forKey: .introduced) - try container.encodeIfPresent(deprecated, forKey: .deprecated) + 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 { - try container.encode(unavailable, forKey: .unavailable) + stringRepresentation += "-" + } else { + if let introduced, introduced.isEmpty == false { + stringRepresentation += "\(introduced) -" + if let deprecated, deprecated.isEmpty == false { + stringRepresentation += " \(deprecated)" + } + } else { + stringRepresentation += "-" + } } + return stringRepresentation } - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - platform = try container.decode(String.self, forKey: .platform) - introduced = try container.decodeIfPresent(String.self, forKey: .introduced) - deprecated = try container.decodeIfPresent(String.self, forKey: .deprecated) - unavailable = try container.decodeIfPresent(Bool.self, forKey: .unavailable) ?? false + 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 { diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index bdb855d56..0a38d57e1 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -129,18 +129,28 @@ extension MarkdownOutputSemanticVisitor { ) manifest = MarkdownOutputManifest(title: bundle.displayName, documents: [document]) - // Availability + // Availability - defaults, overridden with symbol, overriden with metadata - let symbolAvailability = symbol.availability?.availability.map { - MarkdownOutputNode.Metadata.Availability($0) + var availabilities: [String: MarkdownOutputNode.Metadata.Availability] = [:] + if let primaryModule = metadata.symbol?.modules.first { + bundle.info.defaultAvailability?.modules[primaryModule]?.forEach { + let meta = MarkdownOutputNode.Metadata.Availability($0) + availabilities[meta.platform] = meta + } + } + + symbol.availability?.availability.forEach { + let meta = MarkdownOutputNode.Metadata.Availability($0) + availabilities[meta.platform] = meta } - if let availability = symbolAvailability, availability.isEmpty == false { - metadata.availability = availability - } else if let primaryModule = metadata.symbol?.modules.first, let defaultAvailability = bundle.info.defaultAvailability?.modules[primaryModule] { - metadata.availability = defaultAvailability.map { .init($0) } + documentationNode.metadata?.availability.forEach { + let meta = MarkdownOutputNode.Metadata.Availability($0) + availabilities[meta.platform] = meta } + metadata.availability = availabilities.values.sorted(by: \.platform) + // Content markdownWalker.visit(Heading(level: 1, Text(symbol.title))) @@ -221,8 +231,9 @@ extension MarkdownOutputNode.Metadata.Availability { self.unavailable = item.obsoletedVersion != nil } + // From the info.plist of the module init(_ availability: DefaultAvailability.ModuleAvailability) { - self.platform = availability.platformName.displayName + self.platform = availability.platformName.rawValue self.introduced = availability.introducedVersion self.deprecated = nil self.unavailable = availability.versionInformation == .unavailable diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 8470b1ac7..7cd970a7e 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -182,9 +182,66 @@ final class MarkdownOutputTests: XCTestCase { func testSymbolDefaultAvailabilityWhenNothingPresent() async throws { let node = try await generateMarkdown(path: "MarkdownSymbol") let availability = try XCTUnwrap(node.metadata.availability) - XCTAssertEqual(availability[0], .init(platform: "iOS", introduced: "1.0.0", deprecated: nil, unavailable: false)) + XCTAssert(availability.contains(.init(platform: "iOS", introduced: "1.0.0", deprecated: nil, unavailable: false))) } + func testSymbolAvailabilityFromMetadataBlock() async throws { + let node = try await generateMarkdown(path: "/documentation/MarkdownOutput") + let availability = try XCTUnwrap(node.metadata.availability) + XCTAssert(availability.contains(where: { $0.platform == "iPadOS" && $0.introduced == "13.1.0" })) + } + + func testAvailabilityStringRepresentationIntroduced() async throws { + let a = "iOS: 14.0" + let availability = MarkdownOutputNode.Metadata.Availability(stringRepresentation: a) + XCTAssertEqual(availability.platform, "iOS") + XCTAssertEqual(availability.introduced, "14.0") + XCTAssertNil(availability.deprecated) + XCTAssertFalse(availability.unavailable) + } + + func testAvailabilityStringRepresentationDeprecated() async throws { + let a = "iOS: 14.0 - 15.0" + let availability = MarkdownOutputNode.Metadata.Availability(stringRepresentation: a) + XCTAssertEqual(availability.platform, "iOS") + XCTAssertEqual(availability.introduced, "14.0") + XCTAssertEqual(availability.deprecated, "15.0") + XCTAssertFalse(availability.unavailable) + } + + func testAvailabilityStringRepresentationUnavailable() async throws { + let a = "iOS: -" + let availability = MarkdownOutputNode.Metadata.Availability(stringRepresentation: a) + XCTAssertEqual(availability.platform, "iOS") + XCTAssertNil(availability.introduced) + XCTAssertNil(availability.deprecated) + XCTAssert(availability.unavailable) + } + + func testAvailabilityCreateStringRepresentationIntroduced() async throws { + let availability = MarkdownOutputNode.Metadata.Availability(platform: "iOS", introduced: "14.0", unavailable: false) + let expected = "iOS: 14.0 -" + XCTAssertEqual(availability.stringRepresentation, expected) + } + + func testAvailabilityCreateStringRepresentationDeprecated() async throws { + let availability = MarkdownOutputNode.Metadata.Availability(platform: "iOS", introduced: "14.0", deprecated: "15.0", unavailable: false) + let expected = "iOS: 14.0 - 15.0" + XCTAssertEqual(availability.stringRepresentation, expected) + } + + func testAvailabilityCreateStringRepresentationUnavailable() async throws { + let availability = MarkdownOutputNode.Metadata.Availability(platform: "iOS", unavailable: true) + let expected = "iOS: -" + XCTAssertEqual(availability.stringRepresentation, expected) + } + + func testAvailabilityCreateStringRepresentationEmptyAvailability() async throws { + let availability = MarkdownOutputNode.Metadata.Availability(platform: "iOS", introduced: "", unavailable: false) + let expected = "iOS: -" + XCTAssertEqual(availability.stringRepresentation, expected) + } + func testSymbolModuleDefaultAvailability() async throws { let node = try await generateMarkdown(path: "/documentation/MarkdownOutput") let availability = try XCTUnwrap(node.metadata.availability(for: "iOS")) diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md index 2b39fe11b..78b57b807 100644 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md +++ b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md @@ -1,5 +1,9 @@ # ``MarkdownOutput`` +@Metadata { + @Available(iPadOS, introduced: "13.1") +} + This catalog contains various documents to test aspects of markdown output functionality ## Overview From 478438093eacff6004fa94bea138eb9a8d069ff0 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Mon, 29 Sep 2025 16:04:24 +0100 Subject: [PATCH 24/28] Update tests so all inputs are locally defined --- .../Markdown/MarkdownOutputTests.swift | 712 +++++++++++++----- .../MarkdownOutput.docc/APICollection.md | 12 - .../AvailabilityArticle.md | 15 - .../MarkdownOutput.docc/Info.plist | 26 - .../Test Bundles/MarkdownOutput.docc/Links.md | 18 - .../MarkdownOutput.docc/MarkdownOutput.md | 19 - .../MarkdownOutput.symbols.json | 1 - .../Resources/Images/placeholder~dark@2x.png | Bin 4729 -> 0 bytes .../Resources/Images/placeholder~light@2x.png | Bin 4618 -> 0 bytes .../Resources/code-files/01-step-01.swift | 3 - .../Resources/code-files/01-step-02.swift | 4 - .../Resources/code-files/01-step-03.swift | 5 - .../Resources/code-files/02-step-01.swift | 3 - .../original-source/MarkdownOutput.zip | Bin 2295 -> 0 bytes .../MarkdownOutput.docc/RowsAndColumns.md | 16 - .../Test Bundles/MarkdownOutput.docc/Tabs.md | 29 - .../MarkdownOutput.docc/Tutorial.tutorial | 38 - 17 files changed, 543 insertions(+), 358 deletions(-) delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/APICollection.md delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/AvailabilityArticle.md delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Info.plist delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/Images/placeholder~dark@2x.png delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/Images/placeholder~light@2x.png delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-01.swift delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-02.swift delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-03.swift delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/02-step-01.swift delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/original-source/MarkdownOutput.zip delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/RowsAndColumns.md delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tabs.md delete mode 100644 Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tutorial.tutorial diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 7cd970a7e..c18eff152 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -10,30 +10,17 @@ import Foundation import XCTest +import SwiftDocCTestUtilities +import SymbolKit + @testable import SwiftDocC final class MarkdownOutputTests: XCTestCase { - - static var loadingTask: Task<(DocumentationBundle, DocumentationContext), any Error>? - func bundleAndContext() async throws -> (bundle: DocumentationBundle, context: DocumentationContext) { - - if let task = Self.loadingTask { - return try await task.value - } else { - let task = Task { - try await testBundleAndContext(named: "MarkdownOutput") - } - Self.loadingTask = task - return try await task.value - } - } - - /// Generates a writable markdown node from a given path - /// - Parameter path: The path. If you just supply a name (no leading slash), it will prepend `/documentation/MarkdownOutput/`, otherwise the path will be used - /// - Returns: The generated writable markdown output node - private func generateWritableMarkdown(path: String) async throws -> WritableMarkdownOutputNode { - let (bundle, context) = try await bundleAndContext() + // MARK: - Test conveniences + + private func markdownOutput(catalog: Folder, path: String) async throws -> (MarkdownOutputNode, MarkdownOutputManifest) { + let (bundle, context) = try await loadBundle(catalog: catalog) var path = path if !path.hasPrefix("/") { path = "/documentation/MarkdownOutput/\(path)" @@ -41,152 +28,405 @@ final class MarkdownOutputTests: XCTestCase { let reference = ResolvedTopicReference(bundleID: bundle.id, path: path, sourceLanguage: .swift) let node = try XCTUnwrap(context.entity(with: reference)) var translator = MarkdownOutputNodeTranslator(context: context, bundle: bundle, node: node) - return try XCTUnwrap(translator.createOutput()) - } - /// Generates a markdown node from a given path - /// - Parameter path: The path. If you just supply a name (no leading slash), it will prepend `/documentation/MarkdownOutput/`, otherwise the path will be used - /// - Returns: The generated markdown output node - private func generateMarkdown(path: String) async throws -> MarkdownOutputNode { - let outputNode = try await generateWritableMarkdown(path: path) - return outputNode.node + let output = try XCTUnwrap(translator.createOutput()) + let manifest = try XCTUnwrap(output.manifest) + return (output.node, manifest) } - /// Generates a markdown manifest document (with relationships) from a given path - /// - Parameter path: The path. If you just supply a name (no leading slash), it will prepend `/documentation/MarkdownOutput/`, otherwise the path will be used - /// - Returns: The generated markdown output manifest document - private func generateMarkdownManifest(path: String) async throws -> MarkdownOutputManifest { - let outputNode = try await generateWritableMarkdown(path: path) - return try XCTUnwrap(outputNode.manifest) - } + private func catalog(files: [any File] = []) -> Folder { + Folder(name: "MarkdownOutput.docc", content: [ + TextFile(name: "Article.md", utf8Content: """ + # Article + A mostly empty article to make sure paths are formatted correctly + + ## Overview + + Nothing to see here + """) + ] + files + ) + } + // MARK: Directive special processing func testRowsAndColumns() async throws { - let node = try await generateMarkdown(path: "RowsAndColumns") + + let catalog = catalog(files: [ + TextFile(name: "RowsAndColumns.md", utf8Content: """ + # Rows and Columns + + Demonstrates how row and column directives are rendered as markdown + + ## Overview + + @Row { + @Column { + I am the content of column one + } + @Column { + I am the content of column two + } + } + """) + ]) + + let (node, _) = try await markdownOutput(catalog: catalog, path: "RowsAndColumns") let expected = "I am the content of column one\n\nI am the content of column two" XCTAssert(node.markdown.contains(expected)) } - func testInlineDocumentLinkArticleFormatting() async throws { - let node = try await generateMarkdown(path: "Links") - let expected = "inline link: [Rows and Columns](doc://org.swift.MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns)" - XCTAssert(node.markdown.contains(expected)) - } - - func testTopicListLinkArticleFormatting() async throws { - let node = try await generateMarkdown(path: "Links") - let expected = "[Rows and Columns](doc://org.swift.MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns)\n\nDemonstrates how row and column directives are rendered as markdown" - XCTAssert(node.markdown.contains(expected)) - } - - func testInlineDocumentLinkSymbolFormatting() async throws { - let node = try await generateMarkdown(path: "Links") - let expected = "inline link: [`MarkdownSymbol`](doc://org.swift.MarkdownOutput/documentation/MarkdownOutput/MarkdownSymbol)" - XCTAssert(node.markdown.contains(expected)) - } - - func testTopicListLinkSymbolFormatting() async throws { - let node = try await generateMarkdown(path: "Links") - let expected = "[`MarkdownSymbol`](doc://org.swift.MarkdownOutput/documentation/MarkdownOutput/MarkdownSymbol)\n\nA basic symbol to test markdown output." - XCTAssert(node.markdown.contains(expected)) + func testLinkArticleFormatting() async throws { + let catalog = catalog(files: [ + TextFile(name: "RowsAndColumns.md", utf8Content: """ + # Rows and Columns + + Just here for the links + """), + TextFile(name: "Links.md", utf8Content: """ + # Links + + Tests the appearance of inline and linked lists + + ## Overview + + This is an inline link: + + ## Topics + + ### Links with abstracts + + - + """) + ]) + + let (node, _) = try await markdownOutput(catalog: catalog, path: "Links") + let expectedInline = "inline link: [Rows and Columns](doc://MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns)" + XCTAssert(node.markdown.contains(expectedInline)) + + let expectedLinkList = "[Rows and Columns](doc://MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns)\n\nJust here for the links" + XCTAssert(node.markdown.contains(expectedLinkList)) + } + + func testLinkSymbolFormatting() async throws { + let catalog = catalog(files: [ + TextFile(name: "Links.md", utf8Content: """ + # Links + + Tests the appearance of inline and linked lists + + ## Overview + + This is an inline link: ``MarkdownSymbol`` + + ## Topics + + ### Links with abstracts + + - ``MarkdownSymbol`` + """), + JSONFile(name: "MarkdownOutput.symbols.json", content: makeSymbolGraph(moduleName: "MarkdownOutput", symbols: [ + makeSymbol(id: "MarkdownSymbol", kind: .struct, pathComponents: ["MarkdownSymbol"], docComment: "A basic symbol to test markdown output") + ])) + ]) + + let (node, _) = try await markdownOutput(catalog: catalog, path: "Links") + let expectedInline = "inline link: [`MarkdownSymbol`](doc://MarkdownOutput/documentation/MarkdownOutput/MarkdownSymbol)" + XCTAssert(node.markdown.contains(expectedInline)) + + let expectedLinkList = "[`MarkdownSymbol`](doc://MarkdownOutput/documentation/MarkdownOutput/MarkdownSymbol)\n\nA basic symbol to test markdown output" + XCTAssert(node.markdown.contains(expectedLinkList)) } - + func testLanguageTabOnlyIncludesPrimaryLanguage() async throws { - let node = try await generateMarkdown(path: "Tabs") + let catalog = catalog(files: [ + TextFile(name: "Tabs.md", utf8Content: """ + # Tabs + + Showing how language tabs only render the primary language + + ## Overview + + @TabNavigator { + @Tab("Objective-C") { + ```objc + I am an Objective-C code block + ``` + } + @Tab("Swift") { + ```swift + I am a Swift code block + ``` + } + } + """) + ]) + + let (node, _) = try await markdownOutput(catalog: catalog, path: "Tabs") XCTAssertFalse(node.markdown.contains("I am an Objective-C code block")) XCTAssertTrue(node.markdown.contains("I am a Swift code block")) } func testNonLanguageTabIncludesAllEntries() async throws { - let node = try await generateMarkdown(path: "Tabs") + let catalog = catalog(files: [ + TextFile(name: "Tabs.md", utf8Content: """ + # Tabs + + Showing how non-language tabs render all instances. + + ## Overview + + @TabNavigator { + @Tab("Left") { + Left text + } + @Tab("Right") { + Right text + } + } + """) + ]) + + let (node, _) = try await markdownOutput(catalog: catalog, path: "Tabs") XCTAssertTrue(node.markdown.contains("**Left:**\n\nLeft text")) XCTAssertTrue(node.markdown.contains("**Right:**\n\nRight text")) } - func testTutorialCodeIsOnlyTheFinalVersion() async throws { - let node = try await generateMarkdown(path: "/tutorials/MarkdownOutput/Tutorial") - XCTAssertFalse(node.markdown.contains("// STEP ONE")) - XCTAssertFalse(node.markdown.contains("// STEP TWO")) - XCTAssertTrue(node.markdown.contains("// STEP THREE")) - } - - func testTutorialCodeAddedAtFinalReferencedStep() async throws { - let node = try await generateMarkdown(path: "/tutorials/MarkdownOutput/Tutorial") - let codeIndex = try XCTUnwrap(node.markdown.firstRange(of: "// STEP THREE")) + func testTutorialCode() async throws { + + let tutorial = TextFile(name: "Tutorial.tutorial", utf8Content: """ + @Tutorial(time: 30) { + @Intro(title: "Tutorial Title") { + A tutorial for testing markdown output. + + @Image(source: placeholder.png, alt: "Alternative text") + } + + @Section(title: "The first section") { + + Here is some free floating content + + @Steps { + @Step { + Do the first set of things + @Code(name: "File.swift", file: 01-step-01.swift) + } + + Inter-step content + + @Step { + Do the second set of things + @Code(name: "File.swift", file: 01-step-02.swift) + } + + @Step { + Do the third set of things + @Code(name: "File.swift", file: 01-step-03.swift) + } + + @Step { + Do the fourth set of things + @Code(name: "File2.swift", file: 02-step-01.swift) + } + } + } + } + """ + ) + + let codeOne = TextFile(name: "01-step-01.swift", utf8Content: """ + struct StartCode { + // STEP ONE + } + """) + + let codeTwo = TextFile(name: "01-step-02.swift", utf8Content: """ + struct StartCode { + // STEP TWO + let property1: Int + } + """) + + let codeThree = TextFile(name: "01-step-03.swift", utf8Content: """ + struct StartCode { + // STEP THREE + let property1: Int + let property2: Int + } + """) + + let codeFour = TextFile(name: "02-step-01.swift", utf8Content: """ + struct StartCodeAgain { + + } + """) + + let codeFolder = Folder(name: "code-files", content: [codeOne, codeTwo, codeThree, codeFour]) + let resourceFolder = Folder(name: "Resources", content: [codeFolder]) + + let catalog = catalog(files: [ + tutorial, + resourceFolder + ]) + + let (node, _) = try await markdownOutput(catalog: catalog, path: "/tutorials/MarkdownOutput/Tutorial") + XCTAssertFalse(node.markdown.contains("// STEP ONE"), "Non-final code versions are not included") + XCTAssertFalse(node.markdown.contains("// STEP TWO"), "Non-final code versions are not included") + let codeIndex = try XCTUnwrap(node.markdown.firstRange(of: "// STEP THREE"), "Final code version is included") let step4Index = try XCTUnwrap(node.markdown.firstRange(of: "### Step 4")) - XCTAssert(codeIndex.lowerBound < step4Index.lowerBound) + XCTAssert(codeIndex.lowerBound < step4Index.lowerBound, "Code reference is added after the last step that references it") + XCTAssertTrue(node.markdown.contains("struct StartCodeAgain {"), "New file reference is included") } - - func testTutorialCodeWithNewFileIsAdded() async throws { - let node = try await generateMarkdown(path: "/tutorials/MarkdownOutput/Tutorial") - XCTAssertTrue(node.markdown.contains("struct StartCodeAgain {")) - } - + // MARK: - Metadata - func testArticleDocumentType() async throws { - let node = try await generateMarkdown(path: "Links") + func testArticleMetadata() async throws { + let catalog = catalog(files: [ + TextFile(name: "ArticleRole.md", utf8Content: """ + # Article Role + + This article will have the correct document type and role + + ## Overview + + Content + """) + ]) + let (node, _) = try await markdownOutput(catalog: catalog, path: "ArticleRole") XCTAssert(node.metadata.documentType == .article) - } - - func testArticleRole() async throws { - let node = try await generateMarkdown(path: "RowsAndColumns") XCTAssert(node.metadata.role == RenderMetadata.Role.article.rawValue) + XCTAssert(node.metadata.title == "Article Role") + XCTAssert(node.metadata.uri == "/documentation/MarkdownOutput/ArticleRole") + XCTAssert(node.metadata.framework == "MarkdownOutput") } func testAPICollectionRole() async throws { - let node = try await generateMarkdown(path: "APICollection") + let catalog = catalog(files: [ + TextFile(name: "APICollection.md", utf8Content: """ + # API Collection + + This is an API collection + + ## Topics + + ### Topic subgroup + + - + - + + """), + TextFile(name: "Links.md", utf8Content: """ + # Links + + An article to be linked to + """), + TextFile(name: "RowsAndColumns.md", utf8Content: """ + # Rows and Columns + + An article to be linked to + """) + + ]) + let (node, _) = try await markdownOutput(catalog: catalog, path: "APICollection") XCTAssert(node.metadata.role == RenderMetadata.Role.collectionGroup.rawValue) } - - func testArticleTitle() async throws { - let node = try await generateMarkdown(path: "RowsAndColumns") - XCTAssert(node.metadata.title == "Rows and Columns") - } - + func testArticleAvailability() async throws { - let node = try await generateMarkdown(path: "AvailabilityArticle") + let catalog = catalog(files: [ + TextFile(name: "AvailabilityArticle.md", utf8Content: """ + # Availability Demonstration + + @Metadata { + @PageKind(sampleCode) + @Available(Xcode, introduced: "14.3") + @Available(macOS, introduced: "13.0") + } + + This article demonstrates platform availability defined in metadata + + ## Overview + + Some stuff + """) + ]) + + let (node, _) = try await markdownOutput(catalog: catalog, path: "AvailabilityArticle") XCTAssert(node.metadata.availability(for: "Xcode")?.introduced == "14.3.0") XCTAssert(node.metadata.availability(for: "macOS")?.introduced == "13.0.0") } func testSymbolDocumentType() async throws { - let node = try await generateMarkdown(path: "MarkdownSymbol") + let catalog = catalog(files: [ + JSONFile(name: "MarkdownOutput.symbols.json", content: makeSymbolGraph(moduleName: "MarkdownOutput", symbols: [ + makeSymbol(id: "MarkdownSymbol", kind: .struct, pathComponents: ["MarkdownSymbol"], docComment: "A basic symbol to test markdown output") + ])) + ]) + let (node, _) = try await markdownOutput(catalog: catalog, path: "MarkdownSymbol") XCTAssert(node.metadata.documentType == .symbol) } - func testSymbolTitle() async throws { - let node = try await generateMarkdown(path: "MarkdownSymbol/init(name:)") + func testSymbolMetadata() async throws { + let catalog = catalog(files: [ + JSONFile(name: "MarkdownOutput.symbols.json", content: makeSymbolGraph(moduleName: "MarkdownOutput", symbols: [ + makeSymbol(id: "MarkdownSymbol", kind: .struct, pathComponents: ["MarkdownSymbol"], docComment: "A basic symbol to test markdown output"), + makeSymbol(id: "MarkdownSymbol_init_name", kind: .`init`, pathComponents: ["MarkdownSymbol", "init(name:)"]) + ])) + ]) + let (node, _) = try await markdownOutput(catalog: catalog, path: "MarkdownSymbol/init(name:)") XCTAssert(node.metadata.title == "init(name:)") - } - - func testSymbolKind() async throws { - let node = try await generateMarkdown(path: "MarkdownSymbol/init(name:)") XCTAssert(node.metadata.symbol?.kind == "init") XCTAssert(node.metadata.role == "Initializer") - } - - func testSymbolSingleModule() async throws { - let node = try await generateMarkdown(path: "MarkdownSymbol") XCTAssertEqual(node.metadata.symbol?.modules, ["MarkdownOutput"]) } - + func testSymbolExtendedModule() async throws { - let (bundle, context) = try await testBundleAndContext(named: "ModuleWithSingleExtension") - let entity = try XCTUnwrap(context.entity(with: ResolvedTopicReference(bundleID: bundle.id, path: "/documentation/ModuleWithSingleExtension/Swift/Array/asdf", sourceLanguage: .swift))) - var translator = MarkdownOutputNodeTranslator(context: context, bundle: bundle, node: entity) - let node = try XCTUnwrap(translator.createOutput()) - XCTAssertEqual(node.node.metadata.symbol?.modules, ["ModuleWithSingleExtension", "Swift"]) + let catalog = catalog(files: [ + JSONFile(name: "MarkdownOutput.symbols.json", content: makeSymbolGraph(moduleName: "MarkdownOutput", symbols: [ + makeSymbol(id: "Array_asdf", kind: .property, pathComponents: ["Swift", "Array", "asdf"], otherMixins: [SymbolGraph.Symbol.Swift.Extension(extendedModule: "Swift", constraints: [])]) + ]) + ) + ]) + let (node, _) = try await markdownOutput(catalog: catalog, path: "Swift/Array/asdf") + XCTAssertEqual(node.metadata.symbol?.modules, ["MarkdownOutput", "Swift"]) } func testSymbolDefaultAvailabilityWhenNothingPresent() async throws { - let node = try await generateMarkdown(path: "MarkdownSymbol") + let catalog = catalog(files: [ + JSONFile(name: "MarkdownOutput.symbols.json", content: makeSymbolGraph(moduleName: "MarkdownOutput", symbols: [ + makeSymbol(id: "MarkdownSymbol", kind: .struct, pathComponents: ["MarkdownSymbol"], docComment: "A basic symbol to test markdown output") + ])), + InfoPlist(defaultAvailability: [ + "MarkdownOutput" : [.init(platformName: .iOS, platformVersion: "1.0.0")] + ]) + ]) + let (node, _) = try await markdownOutput(catalog: catalog, path: "MarkdownSymbol") let availability = try XCTUnwrap(node.metadata.availability) XCTAssert(availability.contains(.init(platform: "iOS", introduced: "1.0.0", deprecated: nil, unavailable: false))) } func testSymbolAvailabilityFromMetadataBlock() async throws { - let node = try await generateMarkdown(path: "/documentation/MarkdownOutput") + let catalog = catalog(files: [ + JSONFile(name: "MarkdownOutput.symbols.json", content: makeSymbolGraph(moduleName: "MarkdownOutput", symbols: [ + makeSymbol(id: "MarkdownSymbol", kind: .struct, pathComponents: ["MarkdownSymbol"], docComment: "A basic symbol to test markdown output") + ])), + InfoPlist(defaultAvailability: [ + "MarkdownOutput" : [.init(platformName: .iOS, platformVersion: "1.0.0")] + ]), + TextFile(name: "MarkdownSymbol.md", utf8Content: """ + # ``MarkdownSymbol`` + + @Metadata { + @Available(iPadOS, introduced: "13.1") + } + + A basic symbol to test markdown output + + ## Overview + + Overview goes here + """) + ]) + let (node, _) = try await markdownOutput(catalog: catalog, path: "MarkdownSymbol") let availability = try XCTUnwrap(node.metadata.availability) XCTAssert(availability.contains(where: { $0.platform == "iPadOS" && $0.introduced == "13.1.0" })) } @@ -242,15 +482,51 @@ final class MarkdownOutputTests: XCTestCase { XCTAssertEqual(availability.stringRepresentation, expected) } - func testSymbolModuleDefaultAvailability() async throws { - let node = try await generateMarkdown(path: "/documentation/MarkdownOutput") - let availability = try XCTUnwrap(node.metadata.availability(for: "iOS")) - XCTAssertEqual(availability.introduced, "1.0") - XCTAssertFalse(availability.unavailable) - } - func testSymbolDeprecation() async throws { - let node = try await generateMarkdown(path: "MarkdownSymbol/fullName") + let catalog = catalog(files: [ + JSONFile(name: "MarkdownOutput.symbols.json", content: makeSymbolGraph(moduleName: "MarkdownOutput", symbols: [ + makeSymbol(id: "MarkdownSymbol", kind: .struct, pathComponents: ["MarkdownSymbol"], docComment: "A basic symbol to test markdown output"), + makeSymbol( + id: "MarkdownSymbol_fullName", + kind: .property, + pathComponents: ["MarkdownSymbol", "fullName"], + docComment: "A basic property to test markdown output", + availability: [ + .init(domain: .init(rawValue: "iOS"), + introducedVersion: .init(string: "1.0.0"), + deprecatedVersion: .init(string: "4.0.0"), + obsoletedVersion: nil, + message: nil, + renamed: nil, + isUnconditionallyDeprecated: false, + isUnconditionallyUnavailable: false, + willEventuallyBeDeprecated: false + ), + .init(domain: .init(rawValue: "macOS"), + introducedVersion: .init(string: "2.0.0"), + deprecatedVersion: .init(string: "4.0.0"), + obsoletedVersion: nil, + message: nil, + renamed: nil, + isUnconditionallyDeprecated: false, + isUnconditionallyUnavailable: false, + willEventuallyBeDeprecated: false + ), + .init(domain: .init(rawValue: "visionOS"), + introducedVersion: .init(string: "2.0.0"), + deprecatedVersion: .init(string: "4.0.0"), + obsoletedVersion: .init(string: "5.0.0"), + message: nil, + renamed: nil, + isUnconditionallyDeprecated: false, + isUnconditionallyUnavailable: false, + willEventuallyBeDeprecated: false + ) + ]) + ])) + ]) + + let (node, _) = try await markdownOutput(catalog: catalog, path: "MarkdownSymbol/fullName") let availability = try XCTUnwrap(node.metadata.availability(for: "iOS")) XCTAssertEqual(availability.introduced, "1.0.0") XCTAssertEqual(availability.deprecated, "4.0.0") @@ -260,42 +536,74 @@ final class MarkdownOutputTests: XCTestCase { XCTAssertEqual(macAvailability.introduced, "2.0.0") XCTAssertEqual(macAvailability.deprecated, "4.0.0") XCTAssertEqual(macAvailability.unavailable, false) + + let visionAvailability = try XCTUnwrap(node.metadata.availability(for: "visionOS")) + XCTAssert(visionAvailability.unavailable) } - func testSymbolObsolete() async throws { - let node = try await generateMarkdown(path: "MarkdownSymbol/otherName") - let availability = try XCTUnwrap(node.metadata.availability(for: "iOS")) - XCTAssert(availability.unavailable) - } func testSymbolIdentifier() async throws { - let node = try await generateMarkdown(path: "MarkdownSymbol") - XCTAssertEqual(node.metadata.symbol?.preciseIdentifier, "s:14MarkdownOutput0A6SymbolV") - } - - func testTutorialDocumentType() async throws { - let node = try await generateMarkdown(path: "/tutorials/MarkdownOutput/Tutorial") + let catalog = catalog(files: [ + JSONFile(name: "MarkdownOutput.symbols.json", content: makeSymbolGraph(moduleName: "MarkdownOutput", symbols: [ + makeSymbol(id: "MarkdownSymbol_Identifier", kind: .struct, pathComponents: ["MarkdownSymbol"], docComment: "A basic symbol to test markdown output"), + ])) + ]) + + let (node, _) = try await markdownOutput(catalog: catalog, path: "MarkdownSymbol") + XCTAssertEqual(node.metadata.symbol?.preciseIdentifier, "MarkdownSymbol_Identifier") + } + + func testTutorialMetadata() async throws { + let catalog = catalog(files: [ + TextFile(name: "Tutorial.tutorial", utf8Content: """ + @Tutorial(time: 30) { + @Intro(title: "Tutorial Title") { + A tutorial for testing markdown output. + + @Image(source: placeholder.png, alt: "Alternative text") + } + + @Section(title: "The first section") { + + @Steps { + @Step { + Do the first set of things + } + } + } + } + """ + ) + ]) + let (node, _) = try await markdownOutput(catalog: catalog, path: "/tutorials/MarkdownOutput/Tutorial") XCTAssert(node.metadata.documentType == .tutorial) - } - - func testTutorialTitle() async throws { - let node = try await generateMarkdown(path: "/tutorials/MarkdownOutput/Tutorial") XCTAssert(node.metadata.title == "Tutorial Title") } - - func testURI() async throws { - let node = try await generateMarkdown(path: "Links") - XCTAssert(node.metadata.uri == "/documentation/MarkdownOutput/Links") - } - - func testFramework() async throws { - let node = try await generateMarkdown(path: "MarkdownSymbol") - XCTAssert(node.metadata.framework == "MarkdownOutput") - } - + // MARK: - Encoding / Decoding func testMarkdownRoundTrip() async throws { - let node = try await generateMarkdown(path: "MarkdownSymbol") + let catalog = catalog(files: [ + TextFile(name: "Links.md", utf8Content: """ + # Links + + Tests the appearance of inline and linked lists + + ## Overview + + This is an inline link: ``MarkdownSymbol`` + + ## Topics + + ### Links with abstracts + + - ``MarkdownSymbol`` + """), + JSONFile(name: "MarkdownOutput.symbols.json", content: makeSymbolGraph(moduleName: "MarkdownOutput", symbols: [ + makeSymbol(id: "MarkdownSymbol", kind: .struct, pathComponents: ["MarkdownSymbol"], docComment: "A basic symbol to test markdown output") + ])) + ]) + + let (node, _) = try await markdownOutput(catalog: catalog, path: "MarkdownSymbol") let data = try node.data let fromData = try MarkdownOutputNode(data) XCTAssertEqual(node.markdown, fromData.markdown) @@ -304,7 +612,46 @@ final class MarkdownOutputTests: XCTestCase { // MARK: - Manifest func testArticleManifestLinks() async throws { - let manifest = try await generateMarkdownManifest(path: "Links") + + let catalog = catalog(files: [ + JSONFile(name: "MarkdownOutput.symbols.json", content: makeSymbolGraph(moduleName: "MarkdownOutput", symbols: [ + makeSymbol(id: "MarkdownSymbol_Identifier", kind: .struct, pathComponents: ["MarkdownSymbol"], docComment: "A basic symbol to test markdown output"), + ])), + TextFile(name: "RowsAndColumns.md", utf8Content: """ + # Rows and Columns + + Just here for the links + """), + TextFile(name: "APICollection.md", utf8Content: """ + # API Collection + + An API collection + + ## Topics + + - + """), + TextFile(name: "Links.md", utf8Content: """ + # Links + + Tests the appearance of inline and linked lists + + ## Overview + + This is an inline link: + This is an inline link: ``MarkdownSymbol`` + This is a link that isn't curated in a topic so shouldn't come up in the manifest: . + + ## Topics + + ### Links with abstracts + + - + - ``MarkdownSymbol`` + """) + ]) + + let (_, manifest) = try await markdownOutput(catalog: catalog, path: "Links") let rows = MarkdownOutputManifest.Relationship( sourceURI: "/documentation/MarkdownOutput/RowsAndColumns", relationshipType: .belongsToTopic, @@ -349,41 +696,68 @@ final class MarkdownOutputTests: XCTestCase { } func testSymbolManifestInheritance() async throws { - let manifest = try await generateMarkdownManifest(path: "LocalSubclass") + + let symbols = [ + makeSymbol(id: "MO_Subclass", kind: .class, pathComponents: ["LocalSubclass"]), + makeSymbol(id: "MO_Superclass", kind: .class, pathComponents: ["LocalSuperclass"]) + ] + + let relationships = [ + SymbolGraph.Relationship(source: "MO_Subclass", target: "MO_Superclass", kind: .inheritsFrom, targetFallback: nil) + ] + + let catalog = catalog(files: [ + JSONFile(name: "MarkdownOutput.symbols.json", content: + makeSymbolGraph(moduleName: "MarkdownOutput", symbols: symbols, relationships: relationships)) + ]) + + + let (_, manifest) = try await markdownOutput(catalog: catalog, path: "LocalSubclass") let related = manifest.relationships.filter { $0.relationshipType == .relatedSymbol } XCTAssert(related.contains(where: { $0.targetURI == "/documentation/MarkdownOutput/LocalSuperclass" && $0.subtype == "inheritsFrom" })) - } - - func testSymbolManifestInheritedBy() async throws { - let manifest = try await generateMarkdownManifest(path: "LocalSuperclass") - let related = manifest.relationships.filter { $0.relationshipType == .relatedSymbol } - XCTAssert(related.contains(where: { + + let (_, parentManifest) = try await markdownOutput(catalog: catalog, path: "LocalSuperclass") + let parentRelated = parentManifest.relationships.filter { $0.relationshipType == .relatedSymbol } + XCTAssert(parentRelated.contains(where: { $0.targetURI == "/documentation/MarkdownOutput/LocalSubclass" && $0.subtype == "inheritedBy" })) } - - func testSymbolManifestConformsTo() async throws { - let manifest = try await generateMarkdownManifest(path: "LocalConformer") + + func testSymbolManifestConformance() async throws { + + let symbols = [ + makeSymbol(id: "MO_Conformer", kind: .struct, pathComponents: ["LocalConformer"]), + makeSymbol(id: "MO_Protocol", kind: .protocol, pathComponents: ["LocalProtocol"]), + makeSymbol(id: "MO_ExternalConformer", kind: .struct, pathComponents: ["ExternalConformer"]) + ] + + let relationships = [ + SymbolGraph.Relationship(source: "MO_Conformer", target: "MO_Protocol", kind: .conformsTo, targetFallback: nil), + SymbolGraph.Relationship(source: "MO_ExternalConformer", target: "s:SH", kind: .conformsTo, targetFallback: "Swift.Hashable") + ] + + let catalog = catalog(files: [ + JSONFile(name: "MarkdownOutput.symbols.json", content: + makeSymbolGraph(moduleName: "MarkdownOutput", symbols: symbols, relationships: relationships)) + ]) + + let (_, manifest) = try await markdownOutput(catalog: catalog, path: "LocalConformer") let related = manifest.relationships.filter { $0.relationshipType == .relatedSymbol } XCTAssert(related.contains(where: { $0.targetURI == "/documentation/MarkdownOutput/LocalProtocol" && $0.subtype == "conformsTo" })) - } - - func testSymbolManifestConformingTypes() async throws { - let manifest = try await generateMarkdownManifest(path: "LocalProtocol") - let related = manifest.relationships.filter { $0.relationshipType == .relatedSymbol } - XCTAssert(related.contains(where: { + + let (_, protocolManifest) = try await markdownOutput(catalog: catalog, path: "LocalProtocol") + let protocolRelated = protocolManifest.relationships.filter { $0.relationshipType == .relatedSymbol } + XCTAssert(protocolRelated.contains(where: { $0.targetURI == "/documentation/MarkdownOutput/LocalConformer" && $0.subtype == "conformingTypes" })) - } - - func testSymbolManifestExternalConformsTo() async throws { - let manifest = try await generateMarkdownManifest(path: "ExternalConformer") - let related = manifest.relationships.filter { $0.relationshipType == .relatedSymbol } - XCTAssert(related.contains(where: { + + let (_, externalManifest) = try await markdownOutput(catalog: catalog, path: "ExternalConformer") + let externalRelated = externalManifest.relationships.filter { $0.relationshipType == .relatedSymbol } + XCTAssert(externalRelated.contains(where: { $0.targetURI == "/documentation/Swift/Hashable" && $0.subtype == "conformsTo" })) } diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/APICollection.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/APICollection.md deleted file mode 100644 index b664dc899..000000000 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/APICollection.md +++ /dev/null @@ -1,12 +0,0 @@ -# API Collection - -This is an API collection - -## Topics - -### Topic subgroup - -- -- - - diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/AvailabilityArticle.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/AvailabilityArticle.md deleted file mode 100644 index 656b6272e..000000000 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/AvailabilityArticle.md +++ /dev/null @@ -1,15 +0,0 @@ -# Availability Demonstration - -@Metadata { - @PageKind(sampleCode) - @Available(Xcode, introduced: "14.3") - @Available(macOS, introduced: "13.0") -} - -This article demonstrates platform availability defined in metadata - -## Overview - -Some stuff - - diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Info.plist b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Info.plist deleted file mode 100644 index c68f20e42..000000000 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Info.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleName - MarkdownOutput - CFBundleDisplayName - MarkdownOutput - CFBundleIdentifier - org.swift.MarkdownOutput - CFBundleVersion - 0.1.0 - CDAppleDefaultAvailability - - MarkdownOutput - - - name - iOS - version - 1.0 - - - - - diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md deleted file mode 100644 index 195f66b08..000000000 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Links.md +++ /dev/null @@ -1,18 +0,0 @@ -# Links - -Tests the appearance of inline and linked lists - -## Overview - -This is an inline link: -This is an inline link: ``MarkdownSymbol`` -This is a link that isn't curated in a topic so shouldn't come up in the manifest: . - -## Topics - -### Links with abstracts - -- -- ``MarkdownSymbol`` - - diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md deleted file mode 100644 index 78b57b807..000000000 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.md +++ /dev/null @@ -1,19 +0,0 @@ -# ``MarkdownOutput`` - -@Metadata { - @Available(iPadOS, introduced: "13.1") -} - -This catalog contains various documents to test aspects of markdown output functionality - -## Overview - -The symbol graph included in this catalog is generated from a package held in the original-source folder. - -## Topics - -### Directive Processing - -- - - diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json deleted file mode 100644 index 4ef6ca607..000000000 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/MarkdownOutput.symbols.json +++ /dev/null @@ -1 +0,0 @@ -{"metadata":{"formatVersion":{"major":0,"minor":6,"patch":0},"generator":"Apple Swift version 6.2.1 (swiftlang-6.2.1.2.1 clang-1700.4.2.2)"},"module":{"name":"MarkdownOutput","platform":{"architecture":"arm64","vendor":"apple","operatingSystem":{"name":"macosx","minimumVersion":{"major":10,"minor":13}}}},"symbols":[{"kind":{"identifier":"swift.struct","displayName":"Structure"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol"],"names":{"title":"MarkdownSymbol","navigator":[{"kind":"identifier","spelling":"MarkdownSymbol"}],"subHeading":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"MarkdownSymbol"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":2,"character":4},"end":{"line":2,"character":43}},"text":"A basic symbol to test markdown output."},{"range":{"start":{"line":3,"character":3},"end":{"line":3,"character":3}},"text":""},{"range":{"start":{"line":4,"character":4},"end":{"line":4,"character":39}},"text":"This is the overview of the symbol."}]},"declarationFragments":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"MarkdownSymbol"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":5,"character":14}}},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV4nameSSvp","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","name"],"names":{"title":"name","subHeading":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]},"declarationFragments":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":6,"character":15}}},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV8fullNameSSvp","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","fullName"],"names":{"title":"fullName","subHeading":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"fullName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]},"declarationFragments":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"fullName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}],"accessLevel":"public","availability":[{"domain":"macOS","introduced":{"major":2,"minor":0},"deprecated":{"major":4,"minor":0},"message":"Don't be so formal"},{"domain":"iOS","introduced":{"major":1,"minor":0},"deprecated":{"major":4,"minor":0},"message":"Don't be so formal"}],"location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":10,"character":15}}},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV9otherNameSSSgvp","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","otherName"],"names":{"title":"otherName","subHeading":[{"kind":"keyword","spelling":"var"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"otherName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":"?"}]},"declarationFragments":[{"kind":"keyword","spelling":"var"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"otherName"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":"?"}],"accessLevel":"public","availability":[{"domain":"iOS","obsoleted":{"major":5,"minor":0}}],"location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":13,"character":15}}},{"kind":{"identifier":"swift.init","displayName":"Initializer"},"identifier":{"precise":"s:14MarkdownOutput0A6SymbolV4nameACSS_tcfc","interfaceLanguage":"swift"},"pathComponents":["MarkdownSymbol","init(name:)"],"names":{"title":"init(name:)","subHeading":[{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":")"}]},"functionSignature":{"parameters":[{"name":"name","declarationFragments":[{"kind":"identifier","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]}]},"declarationFragments":[{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"name"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"},{"kind":"text","spelling":")"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":15,"character":11}}},{"kind":{"identifier":"swift.struct","displayName":"Structure"},"identifier":{"precise":"s:14MarkdownOutput17ExternalConformerV","interfaceLanguage":"swift"},"pathComponents":["ExternalConformer"],"names":{"title":"ExternalConformer","navigator":[{"kind":"identifier","spelling":"ExternalConformer"}],"subHeading":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"ExternalConformer"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":21,"character":4},"end":{"line":21,"character":53}},"text":"This type conforms to multiple external protocols"}]},"declarationFragments":[{"kind":"keyword","spelling":"struct"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"ExternalConformer"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":22,"character":14}}},{"kind":{"identifier":"swift.func.op","displayName":"Operator"},"identifier":{"precise":"s:SQsE2neoiySbx_xtFZ::SYNTHESIZED::s:14MarkdownOutput17ExternalConformerV","interfaceLanguage":"swift"},"pathComponents":["ExternalConformer","!=(_:_:)"],"names":{"title":"!=(_:_:)","subHeading":[{"kind":"keyword","spelling":"static"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"!="},{"kind":"text","spelling":" "},{"kind":"text","spelling":"("},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":", "},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":") -> "},{"kind":"typeIdentifier","spelling":"Bool","preciseIdentifier":"s:Sb"}]},"docComment":{"module":"Swift","lines":[{"text":"Returns a Boolean value indicating whether two values are not equal."},{"text":""},{"text":"Inequality is the inverse of equality. For any values `a` and `b`, `a != b`"},{"text":"implies that `a == b` is `false`."},{"text":""},{"text":"This is the default implementation of the not-equal-to operator (`!=`)"},{"text":"for any type that conforms to `Equatable`."},{"text":""},{"text":"- Parameters:"},{"text":" - lhs: A value to compare."},{"text":" - rhs: Another value to compare."}]},"functionSignature":{"parameters":[{"name":"lhs","declarationFragments":[{"kind":"identifier","spelling":"lhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"}]},{"name":"rhs","declarationFragments":[{"kind":"identifier","spelling":"rhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"}]}],"returns":[{"kind":"typeIdentifier","spelling":"Bool","preciseIdentifier":"s:Sb"}]},"swiftExtension":{"extendedModule":"Swift","typeKind":"swift.protocol"},"declarationFragments":[{"kind":"keyword","spelling":"static"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"!="},{"kind":"text","spelling":" "},{"kind":"text","spelling":"("},{"kind":"internalParam","spelling":"lhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":", "},{"kind":"internalParam","spelling":"rhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":") -> "},{"kind":"typeIdentifier","spelling":"Bool","preciseIdentifier":"s:Sb"}],"accessLevel":"public"},{"kind":{"identifier":"swift.property","displayName":"Instance Property"},"identifier":{"precise":"s:14MarkdownOutput17ExternalConformerV2idSSvp","interfaceLanguage":"swift"},"pathComponents":["ExternalConformer","id"],"names":{"title":"id","subHeading":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"id"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}]},"docComment":{"module":"Swift","lines":[{"text":"The stable identity of the entity associated with this instance."}]},"declarationFragments":[{"kind":"keyword","spelling":"let"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"id"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"String","preciseIdentifier":"s:SS"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":23,"character":15}}},{"kind":{"identifier":"swift.init","displayName":"Initializer"},"identifier":{"precise":"s:14MarkdownOutput17ExternalConformerV4fromACs7Decoder_p_tKcfc","interfaceLanguage":"swift"},"pathComponents":["ExternalConformer","init(from:)"],"names":{"title":"init(from:)","subHeading":[{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"from"},{"kind":"text","spelling":": any "},{"kind":"typeIdentifier","spelling":"Decoder","preciseIdentifier":"s:s7DecoderP"},{"kind":"text","spelling":") "},{"kind":"keyword","spelling":"throws"}]},"docComment":{"module":"Swift","lines":[{"text":"Creates a new instance by decoding from the given decoder."},{"text":""},{"text":"This initializer throws an error if reading from the decoder fails, or"},{"text":"if the data read is corrupted or otherwise invalid."},{"text":""},{"text":"- Parameter decoder: The decoder to read data from."}]},"functionSignature":{"parameters":[{"name":"from","internalName":"decoder","declarationFragments":[{"kind":"identifier","spelling":"decoder"},{"kind":"text","spelling":": any "},{"kind":"typeIdentifier","spelling":"Decoder","preciseIdentifier":"s:s7DecoderP"}]}]},"declarationFragments":[{"kind":"keyword","spelling":"init"},{"kind":"text","spelling":"("},{"kind":"externalParam","spelling":"from"},{"kind":"text","spelling":" "},{"kind":"internalParam","spelling":"decoder"},{"kind":"text","spelling":": any "},{"kind":"typeIdentifier","spelling":"Decoder","preciseIdentifier":"s:s7DecoderP"},{"kind":"text","spelling":") "},{"kind":"keyword","spelling":"throws"}],"accessLevel":"public"},{"kind":{"identifier":"swift.enum","displayName":"Enumeration"},"identifier":{"precise":"s:14MarkdownOutput14LocalConformerO","interfaceLanguage":"swift"},"pathComponents":["LocalConformer"],"names":{"title":"LocalConformer","navigator":[{"kind":"identifier","spelling":"LocalConformer"}],"subHeading":[{"kind":"keyword","spelling":"enum"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalConformer"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":26,"character":4},"end":{"line":26,"character":55}},"text":"This type demonstrates conformance in documentation"}]},"declarationFragments":[{"kind":"keyword","spelling":"enum"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalConformer"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":27,"character":12}}},{"kind":{"identifier":"swift.func.op","displayName":"Operator"},"identifier":{"precise":"s:SQsE2neoiySbx_xtFZ::SYNTHESIZED::s:14MarkdownOutput14LocalConformerO","interfaceLanguage":"swift"},"pathComponents":["LocalConformer","!=(_:_:)"],"names":{"title":"!=(_:_:)","subHeading":[{"kind":"keyword","spelling":"static"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"!="},{"kind":"text","spelling":" "},{"kind":"text","spelling":"("},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":", "},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":") -> "},{"kind":"typeIdentifier","spelling":"Bool","preciseIdentifier":"s:Sb"}]},"docComment":{"module":"Swift","lines":[{"text":"Returns a Boolean value indicating whether two values are not equal."},{"text":""},{"text":"Inequality is the inverse of equality. For any values `a` and `b`, `a != b`"},{"text":"implies that `a == b` is `false`."},{"text":""},{"text":"This is the default implementation of the not-equal-to operator (`!=`)"},{"text":"for any type that conforms to `Equatable`."},{"text":""},{"text":"- Parameters:"},{"text":" - lhs: A value to compare."},{"text":" - rhs: Another value to compare."}]},"functionSignature":{"parameters":[{"name":"lhs","declarationFragments":[{"kind":"identifier","spelling":"lhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"}]},{"name":"rhs","declarationFragments":[{"kind":"identifier","spelling":"rhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"}]}],"returns":[{"kind":"typeIdentifier","spelling":"Bool","preciseIdentifier":"s:Sb"}]},"swiftExtension":{"extendedModule":"Swift","typeKind":"swift.protocol"},"declarationFragments":[{"kind":"keyword","spelling":"static"},{"kind":"text","spelling":" "},{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"!="},{"kind":"text","spelling":" "},{"kind":"text","spelling":"("},{"kind":"internalParam","spelling":"lhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":", "},{"kind":"internalParam","spelling":"rhs"},{"kind":"text","spelling":": "},{"kind":"typeIdentifier","spelling":"Self"},{"kind":"text","spelling":") -> "},{"kind":"typeIdentifier","spelling":"Bool","preciseIdentifier":"s:Sb"}],"accessLevel":"public"},{"kind":{"identifier":"swift.method","displayName":"Instance Method"},"identifier":{"precise":"s:14MarkdownOutput14LocalConformerO11localMethodyyF","interfaceLanguage":"swift"},"pathComponents":["LocalConformer","localMethod()"],"names":{"title":"localMethod()","subHeading":[{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"localMethod"},{"kind":"text","spelling":"()"}]},"functionSignature":{"returns":[{"kind":"text","spelling":"()"}]},"declarationFragments":[{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"localMethod"},{"kind":"text","spelling":"()"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":28,"character":16}}},{"kind":{"identifier":"swift.enum.case","displayName":"Case"},"identifier":{"precise":"s:14MarkdownOutput14LocalConformerO3booyA2CmF","interfaceLanguage":"swift"},"pathComponents":["LocalConformer","boo"],"names":{"title":"LocalConformer.boo","subHeading":[{"kind":"keyword","spelling":"case"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"boo"}]},"declarationFragments":[{"kind":"keyword","spelling":"case"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"boo"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":32,"character":9}}},{"kind":{"identifier":"swift.protocol","displayName":"Protocol"},"identifier":{"precise":"s:14MarkdownOutput13LocalProtocolP","interfaceLanguage":"swift"},"pathComponents":["LocalProtocol"],"names":{"title":"LocalProtocol","navigator":[{"kind":"identifier","spelling":"LocalProtocol"}],"subHeading":[{"kind":"keyword","spelling":"protocol"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalProtocol"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":35,"character":4},"end":{"line":35,"character":76}},"text":"This is a locally defined protocol to support the relationship test case"}]},"declarationFragments":[{"kind":"keyword","spelling":"protocol"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalProtocol"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":36,"character":16}}},{"kind":{"identifier":"swift.method","displayName":"Instance Method"},"identifier":{"precise":"s:14MarkdownOutput13LocalProtocolP11localMethodyyF","interfaceLanguage":"swift"},"pathComponents":["LocalProtocol","localMethod()"],"names":{"title":"localMethod()","subHeading":[{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"localMethod"},{"kind":"text","spelling":"()"}]},"functionSignature":{"returns":[{"kind":"text","spelling":"()"}]},"declarationFragments":[{"kind":"keyword","spelling":"func"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"localMethod"},{"kind":"text","spelling":"()"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":37,"character":9}}},{"kind":{"identifier":"swift.class","displayName":"Class"},"identifier":{"precise":"s:14MarkdownOutput13LocalSubclassC","interfaceLanguage":"swift"},"pathComponents":["LocalSubclass"],"names":{"title":"LocalSubclass","navigator":[{"kind":"identifier","spelling":"LocalSubclass"}],"subHeading":[{"kind":"keyword","spelling":"class"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalSubclass"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":40,"character":4},"end":{"line":40,"character":63}},"text":"This is a class to demonstrate inheritance in documentation"}]},"declarationFragments":[{"kind":"keyword","spelling":"class"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalSubclass"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":41,"character":13}}},{"kind":{"identifier":"swift.class","displayName":"Class"},"identifier":{"precise":"s:14MarkdownOutput15LocalSuperclassC","interfaceLanguage":"swift"},"pathComponents":["LocalSuperclass"],"names":{"title":"LocalSuperclass","navigator":[{"kind":"identifier","spelling":"LocalSuperclass"}],"subHeading":[{"kind":"keyword","spelling":"class"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalSuperclass"}]},"docComment":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","module":"MarkdownOutput","lines":[{"range":{"start":{"line":45,"character":4},"end":{"line":45,"character":70}},"text":"This is a class to demonstrate inheritance in symbol documentation"}]},"declarationFragments":[{"kind":"keyword","spelling":"class"},{"kind":"text","spelling":" "},{"kind":"identifier","spelling":"LocalSuperclass"}],"accessLevel":"public","location":{"uri":"file:///Users/richard/Documents/MarkdownOutput/Sources/MarkdownOutput/MarkdownOutput.swift","position":{"line":46,"character":13}}}],"relationships":[{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV4nameSSvp","target":"s:14MarkdownOutput0A6SymbolV"},{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV8fullNameSSvp","target":"s:14MarkdownOutput0A6SymbolV"},{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV9otherNameSSSgvp","target":"s:14MarkdownOutput0A6SymbolV"},{"kind":"memberOf","source":"s:14MarkdownOutput0A6SymbolV4nameACSS_tcfc","target":"s:14MarkdownOutput0A6SymbolV"},{"kind":"memberOf","source":"s:SQsE2neoiySbx_xtFZ::SYNTHESIZED::s:14MarkdownOutput17ExternalConformerV","target":"s:14MarkdownOutput17ExternalConformerV","sourceOrigin":{"identifier":"s:SQsE2neoiySbx_xtFZ","displayName":"Equatable.!=(_:_:)"}},{"kind":"conformsTo","source":"s:14MarkdownOutput17ExternalConformerV","target":"s:Se","targetFallback":"Swift.Decodable"},{"kind":"conformsTo","source":"s:14MarkdownOutput17ExternalConformerV","target":"s:SE","targetFallback":"Swift.Encodable"},{"kind":"conformsTo","source":"s:14MarkdownOutput17ExternalConformerV","target":"s:s12IdentifiableP","targetFallback":"Swift.Identifiable"},{"kind":"conformsTo","source":"s:14MarkdownOutput17ExternalConformerV","target":"s:SH","targetFallback":"Swift.Hashable"},{"kind":"conformsTo","source":"s:14MarkdownOutput17ExternalConformerV","target":"s:SQ","targetFallback":"Swift.Equatable"},{"kind":"memberOf","source":"s:14MarkdownOutput17ExternalConformerV2idSSvp","target":"s:14MarkdownOutput17ExternalConformerV","sourceOrigin":{"identifier":"s:s12IdentifiableP2id2IDQzvp","displayName":"Identifiable.id"}},{"kind":"memberOf","source":"s:14MarkdownOutput17ExternalConformerV4fromACs7Decoder_p_tKcfc","target":"s:14MarkdownOutput17ExternalConformerV","sourceOrigin":{"identifier":"s:Se4fromxs7Decoder_p_tKcfc","displayName":"Decodable.init(from:)"}},{"kind":"memberOf","source":"s:SQsE2neoiySbx_xtFZ::SYNTHESIZED::s:14MarkdownOutput14LocalConformerO","target":"s:14MarkdownOutput14LocalConformerO","sourceOrigin":{"identifier":"s:SQsE2neoiySbx_xtFZ","displayName":"Equatable.!=(_:_:)"}},{"kind":"conformsTo","source":"s:14MarkdownOutput14LocalConformerO","target":"s:SQ","targetFallback":"Swift.Equatable"},{"kind":"conformsTo","source":"s:14MarkdownOutput14LocalConformerO","target":"s:SH","targetFallback":"Swift.Hashable"},{"kind":"conformsTo","source":"s:14MarkdownOutput14LocalConformerO","target":"s:14MarkdownOutput13LocalProtocolP"},{"kind":"memberOf","source":"s:14MarkdownOutput14LocalConformerO11localMethodyyF","target":"s:14MarkdownOutput14LocalConformerO","sourceOrigin":{"identifier":"s:14MarkdownOutput13LocalProtocolP11localMethodyyF","displayName":"LocalProtocol.localMethod()"}},{"kind":"memberOf","source":"s:14MarkdownOutput14LocalConformerO3booyA2CmF","target":"s:14MarkdownOutput14LocalConformerO"},{"kind":"requirementOf","source":"s:14MarkdownOutput13LocalProtocolP11localMethodyyF","target":"s:14MarkdownOutput13LocalProtocolP"},{"kind":"inheritsFrom","source":"s:14MarkdownOutput13LocalSubclassC","target":"s:14MarkdownOutput15LocalSuperclassC"}]} \ No newline at end of file diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/Images/placeholder~dark@2x.png b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/Images/placeholder~dark@2x.png deleted file mode 100644 index 7e32851706c54c4a13b28db69985e118b0a9fa29..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4729 zcmeHL`&*J{A7^V@%~IQ2)0qdhxp!2Ww5eH=vQAcP!(McnhqUZqcq~a&P}rvFS{a$A zOp(j=5^tGO84@8fGZi%@Wq_zi9?%RC0udB}_xA7DzSs5q0MEny-1qnUIbB>ihCXa> zx5W+uf!H5Cau5T7ynh@5dGE%$_rW)o$SXn!y?rK-+6?uNzh8>SnUHj9mV;>=IH1y;4eVsp-Vr^2x_L8Zy zpCHVlF{0+a)?!q^Aiv!=B{LzAmD>dye}47P^-mA}ju`0SFWPB)w^jD?Z59m$Ii@j! z;>zcHdU~Eee?B%g))waJ>8T)@Q&F>R`D z^5oKls=9BQM>Jwk$Q5n7)oLwCyxK9*_V%ak_kgMYf#%N49UaQ)X~teWWS_o@;qCaHpZ6VZD#Or`&4^8QmaJNtqO> z^3NA9UdXvwy-Hd@2j*UQy*P3g=(L+-rmaE{;L=m;e}jBbdi7e9LRsH&<8OwI=mr{x$v zMy>NC=%%{I4-~iPsWmk%d1gEvQ$?lbXANx zKp3jJx>~Q-t5hlk0+A!#>+2g~7$Cxb3eqfjX1C1oWAiVU{SfWd0Pi9LVX zy4em4PK_rL2!u0drX&q9@VJ)U8cj(vn_W~?XFwoJ6bD0(g*?XBlV)Zc1VOcEr)PqsH?q zgV_R3-O9H-f%cFTYEAy)1Q7xC22@v5lReuXafTSd#|%wT2_pFTi&Ttr@+$+jb?tHWYUh$ ziWo@CLKstu_@CjdOrcN=4P^re=x2mssMDuUi$tQzrz`Et7OO-e866#+m`FfjlalU- zZ>JKmoX36V^e+x8P>bVeY$BjbX4KzcfF@h&TV@J+4ad^C-?7uA60M%wYi(Tm$YL zSBGV?9K&1jRcAP9NmQ`(BnmaF9$~Kux#H~X91s9dOWP*4S&jTKR5?odEA+?y`7b*F z7~*4M>ch7ehfE5>0L%=8Jnif=-!wp<3{DLNJ~J~D`ru?U*x$p|dZoq-_2!L*8+Ax560|1In6>cTxl27WSaP)Tt( z9L;aM>gR$RGA$9){WY#{+ksjAPL_f5Y-1VR|xr`<)QC+Kvy+Ho)!QNqBVb=IGTY$ z83;l2GI;4z!SMcblCAfSdjs8wsQ35vEyXNr#}ABgXb0-W+nuf^CnpzWO?(ao(kpyeLLw_Z{jiT`M>n*s8^~tXYcg z7Ucp0i0dO~PW0JUg}u31a^BBD=-wbs0`8M@%jlP=0Go1#vUhiJyKFnZxb_BU3nK_n zu^i@A8|onsG##Vu@bQV`lk&2&vjNgi+HM*(G@^rmqs%ExYLK5QC|n*J`hAJL2D=O?S-8QaDoIquit zD_=@Knf_(8Iw1Wzg_6aUM>6A1tT@@Nt*ER_mp>DOJv2T@XGH@UT~p{1jO{&Y%L#B$ zC|`$huHR@c*!hQNe|kdn1Nzd1x9>7S3xSe>J6#m$jG1nk4&dj2W}mhEMx|1Vi;D@o+QWxc zhUHhUUX?uR^IU!THs!boMF)~c87UMq$mbL$%4*ZJ2V1O(^L0U~RiRA+?FE3wTUqJSRgamC- z;YtjX$s}KQt@Gbfyw~qpb2EN+edx9zvb80m-rmuXY!FAT+L|DW-46PsG7vo6EpIXx zXwLvM9O3}S*20|npFjAIPuGhV2k98u$Ou9mi}ol(nC3uc(EoQ4STL>mC5nDCaRX^# zOX2ryz=TK*(3>^$-w!6Sbf1iul*Uy4TR5r4b7yK>{f;xG#KpNNz5=>s%QLdgdX`Fg zDNEi}4IEgOGVS8W;F-dJonTsx$57aF=cXe$s*SeOpd%&5FmN_T@u}&sZx0+euomZ{ zqb~pkDH5gyDv)I6=2Gs3zcmo@VQ!?ag*2qbXl6yL0U=<`7jkp=Dq>SpQ`NXp8AqI? z^s;lfUJzUaTDYTY-LU_~jW$2jRz*=y7&km|JE z;KdBN#@=DmexH4Gj0+SRx(0q>Y-mW3Sm!YR=}o?28&YSG#)tm+qrJWTr*@ZfKR5LS zD5mNKGt<+gazuVgYAP@L-5&}~=np^Sn$>JzI5`FseQ;=K2qYN(^4ogMy}#6WT5ujA z3h767KWN2V-peNghf}Ou(C3&JQ-j;880W!maVxqH_pE9DU7rf%bESEP$Rp|d0(qENh)7Fqz<0fO>;Rz$NbcUfHL#Mj^mJxpV=K@?LCv4>W5dJ4fF8H_ zx7ZQ&<-N+b_?N_sX0v%IAs>v@mQa}qI3g4Z<9P(u!1*U5>Z^*;%;aP*vR7E^CLn!OY(;{0Uo+T3chn&XM?kk^ruZeKv^ kKK|$Wrw4x%3|OG({`2;Jp2B4CmlEXYA@sq<{U?9@KM%Do)c^nh diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/Images/placeholder~light@2x.png b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/Images/placeholder~light@2x.png deleted file mode 100644 index 5b5494fdd74cc7bf92ff8c2948cff3c422c2f93c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4618 zcmeHLX#eM;Hl8^CU4WI<%ClBhD{ie_306!YH-uJJo4ZeZ_f1erDKrumvP(4br}W?o zuicq1_B^dw7)%sOEbbEzp_q_6l~>&mM+h#g!%>%>|iyH0r8O^8X z2THL=txqPtNxyf+*Fnu#(kiQWqCFW_8GHZv{;9#=kON2cmpN8gKaR*Sg|ZOMQ<+{7 zk&zFB_8)}9*=)Ao$&(wXiDa^IBw519$26umyStOLRAw;q3JzD9kCQZ|S~- z!*OzQ!Y(bP@|tRDZU-!PV9fys=%$1Lr7nvpl55vzdQ}G%mz7~L7*9(v+O)M{9@@7B ztVYDd#3Usx_OrsWv$LaZ54AYHQR*xUG%9-Ok+J{u>C;nOvyp(pM@5xmI8Jc5CsDP> zQqem@Hb7jxuw0@@R*pgnMP>fpy?f2ZDHL|;F80Y}MgHpa7yplMn0*L0w;dZ+=jG*1 z#uFnVA}(B*9M5sfX)w)~*k@F{2!A8jrt&b%zTbnHLb``zQHKaEGn>ux);h(=u9_?l zByxCYXjYs0s;rbwe=3HO8qTwVMg9H3!NKhM!=kHkagSaehKtirS?Z1bE+t)|tVN?~42Nee~Jv|MHxz@JGv!S8S$J#$B+~lp9c%4VH z{t8@pW@ZN5S)ssjMB?FL^1@j21;b2iV1SYgGrd)LdE*vNf(&x?@PHB!V7V_wJ3l`UD5K1?KP}+fL^ZCY z*?J%XGk1@~)P=HwX*61LaA8 zpNB(GQhE0?!Af=3GT63J{GR8T)|Zc#piG)T-%CWIezKzoU`6x^-d|8ySV++|NC4w2 z@{^L2QJ$X}B8$5&+O~#;hNkLOli=|DHlTT`E zYXRcIQwRhCP|4+)k+yYxW_&#voQV7p&Fot?nFqe_;MLDP;si4p`QEUy^H)1gI3zZV zOWX4 zMn2#fph*trPn9a3vH}poeftI+Ss!g?X(HYx&t(SfFYtQc&k;GH#Q-9B5l{l31JauS zg_*7r7ZZgVAFo;%Zw(9#oPzlu_*A_J$RD4Q;wn&ozTxwUX#JZ*jD*xw#G=>E9B;1p z;HTLjQy7ww?KuR%Z4~kdQ-TZG#l^*;VPRodtVLC|E{qk-?DKUPsV7ALwQr%ly}h&Z zr0>iT#xj%Bjf9JXLnb(e>N4`g44VF#VRuZM;B-26S#OIzK@`YEw-o(X`y$0N>`wg_r6R z-#R)v65A5yv^#`xT9qOt19@E(dlc2WBnFvKc5vLznobZkb^iM%*fi*IcQ^CD*_ij> z+1S`jhEr8ucR{sTESBlvhxB#F*Xjr7(QoZ_=AzfzMk=gHHTeOI0EYr!;_8>Jw9c$_ zeZ{+_%f`VcVkkeL9&MgD_ASw#)b&mCW>wZTVM3usqj@qIi^Jh$39LW(@?}bvGn?LUQgbn>Uat)AC0)fzVQ9XXyM;)7o>?GY|;@xImwfjItd#qd0IRpZ1C!-~DFZ0ZrJy zd_jdM#V#evHp_k*7ICmJ8=C@OMqOQn*N*U$1lBuh(IetS%tm~lu>G3hMyFMq0wZ+A_NRW zDwR%KZ`7X?c6FuY+P%D3dvDF9DVYpMmO3!BS}l2{vK2-pn~nNJ!Sl4(OP8wqV*2D- zkRN%6;QG~W>~r9?zbKEhf_J6OZf1{J##fPyP6#A&PMb>HJD6$hO5ofA$B@tcRpNq& zhsole4-ACVJiN+0}L^rWc?vv11~wAj+pl6eq`K(uqcY(_tx zyZAnt@^`hKrhE79H5!ANiGkczcjXU6DwmqCxq4Ukru^oX)$vrmjvqPD3pkOrVT%bTrA^jJOV# zdA$1^B^He~4!iX0m*pJwIWGBzEa$)x{5qnS;QFRqE`^JyzE8YGscxe%&Jj=fwLYX10Ichp^=mZ@r4msA?T6?a_$ z65;r1Hi<-H19c39%R_=j!bt}n)#6FZuFO}A z@){Z&jb@XeClxTGgn?v{;{(b!O*Zuc>d03=6Z$(^fcw6rW-gvA|9P|5*?G^s>{{UZ* B$Nc~R diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-01.swift b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-01.swift deleted file mode 100644 index e3458faa6..000000000 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-01.swift +++ /dev/null @@ -1,3 +0,0 @@ -struct StartCode { - // STEP ONE -} diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-02.swift b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-02.swift deleted file mode 100644 index 3885e0692..000000000 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-02.swift +++ /dev/null @@ -1,4 +0,0 @@ -struct StartCode { - // STEP TWO - let property1: Int -} diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-03.swift b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-03.swift deleted file mode 100644 index 71968c3a5..000000000 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/01-step-03.swift +++ /dev/null @@ -1,5 +0,0 @@ -struct StartCode { - // STEP THREE - let property1: Int - let property2: Int -} diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/02-step-01.swift b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/02-step-01.swift deleted file mode 100644 index dbf7b5620..000000000 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/code-files/02-step-01.swift +++ /dev/null @@ -1,3 +0,0 @@ -struct StartCodeAgain { - -} diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/original-source/MarkdownOutput.zip b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Resources/original-source/MarkdownOutput.zip deleted file mode 100644 index 9fe8ca2982db40c5f08cd22d1b954c05c4597563..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2295 zcmWIWW@h1H00F^vjc70fO7JttF!&}GWvAqq=lPeG6qJ_ehlX%6FmL*^F9U>2E4UdL zS-vtdFtCUKwFCeS0?`}{G07UylhZx_`v9e`Gcf>-!7wNwF*!RiJyox`JTt8XY-;`9 zeHnhC6M0!`oQo3^e?imrFTiJmKW{E}W#OAgj%r z_uyIJuYINJzeOrLYBoz1H<#yz$e4XF+V|PhYTNSysXfZwk8k|w)7q3%GD$I)dzO^G z?bGXbc1}6Gfl0Y~Q@f9klV-r8)JQ$nKP>!L?`+n(YA06wWc|tIhcC$&hfRUpE#8{#nV&EfR3lsy@a2tp%NpWnroWTvmSj>kcsmTj8 zqL&m;fBp~XYC&LpsND1zk#{P_ZK#XSR+zAK$fC;YceJxk^^xR8V zcvz-;a;O!u@;zmpbnULvq#a&!{5am;_1vYjt4lacNb}#^^B?tF8f-NrOL!k%)-pQ4 z($jd*>;J_ICmCJeDNSz`P|B~cf5njbQLwS<%id$c0oHf)dp5mTWF&dg?i!E2@~T}W zKeQB%MkGx*=-F+(;zoA4#FmE@llES5(`wfK8jIiakNK6xh>Tx#ct`V= zADD^HvXB$vkTL@)#6hto7N_ix zTv+DZW-q$LuuUSR;LsznvNv0IYw!D1ssGJM`l3Y4OZnZV8OP^^Oj)t*!=`;v&4=zS z7MWun_nfy|v12z&>Bot8xZBhOl`>97C+g{)cU(K+`G)oLxUDsp^&YFS*M4!bY6a&M zz9%O6uU1Q*i>p|$Wchriz136FcrN>W&eFQ~bVaTlqi=M&`W{`T^Rn*Kf}cBo4Y0ju zAYaAesdrtnDEvl7#CJBvdgCf}o-gxesLU)VyFdR}!|R78zjR|JpPIV<;cBPjyRJ5W zb#2jGKh1XGRnG&g+O5-KPECEMA*0EqZpg{hm9jnfkx7mjR}~`xErtXb-a3MqucX9ffx!*xC~1gJuwYM zt9*bqfGQuXHsC5Qk)8b-Xc3yT5rrqva8Tii$8gM|6WMSZU}1;Da5Ul25TIS4EQZxC zT*WAIP|YFQE|}G*g(}c~P@#&)e#}Az*?vA?F#|Ih8vn3>L=#5k!ipGN{%2(ar7Sie LYykQ&5zGSs0Y^5% diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/RowsAndColumns.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/RowsAndColumns.md deleted file mode 100644 index 2aa55277c..000000000 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/RowsAndColumns.md +++ /dev/null @@ -1,16 +0,0 @@ -# Rows and Columns - -Demonstrates how row and column directives are rendered as markdown - -## Overview - -@Row { - @Column { - I am the content of column one - } - @Column { - I am the content of column two - } -} - - diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tabs.md b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tabs.md deleted file mode 100644 index 3034fe024..000000000 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tabs.md +++ /dev/null @@ -1,29 +0,0 @@ -# Tabs - -Showing how language tabs only render the primary language, but other tabs render all instances. - -## Overview - -@TabNavigator { - @Tab("Objective-C") { - ```objc - I am an Objective-C code block - ``` - } - @Tab("Swift") { - ```swift - I am a Swift code block - ``` - } -} - -@TabNavigator { - @Tab("Left") { - Left text - } - @Tab("Right") { - Right text - } -} - - diff --git a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tutorial.tutorial b/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tutorial.tutorial deleted file mode 100644 index dd409ea55..000000000 --- a/Tests/SwiftDocCTests/Test Bundles/MarkdownOutput.docc/Tutorial.tutorial +++ /dev/null @@ -1,38 +0,0 @@ -@Tutorial(time: 30) { - @Intro(title: "Tutorial Title") { - A tutorial for testing markdown output. - - @Image(source: placeholder.png, alt: "Alternative text") - } - - @Section(title: "The first section") { - - Here is some free floating content - - @Steps { - @Step { - Do the first set of things - @Code(name: "File.swift", file: 01-step-01.swift) - } - - Inter-step content - - @Step { - Do the second set of things - @Code(name: "File.swift", file: 01-step-02.swift) - } - - @Step { - Do the third set of things - @Code(name: "File.swift", file: 01-step-03.swift) - } - - @Step { - Do the fourth set of things - @Code(name: "File2.swift", file: 02-step-01.swift) - } - } - } -} - - From abc9985c7c5e395696a101e4fb0abad4a383414f Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 2 Oct 2025 09:38:06 +0100 Subject: [PATCH 25/28] Remove print statements from unused visitors --- .../MarkdownOutputSemanticVisitor.swift | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 0a38d57e1..2a996aadc 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -370,46 +370,38 @@ extension MarkdownOutputSemanticVisitor { } -// MARK: Visitors not used for markdown output +// MARK: Visitors not currently used for markdown output extension MarkdownOutputSemanticVisitor { public mutating func visitXcodeRequirement(_ xcodeRequirement: XcodeRequirement) -> MarkdownOutputNode? { - print(#function) return nil } public mutating func visitAssessments(_ assessments: Assessments) -> MarkdownOutputNode? { - print(#function) return nil } public mutating func visitMultipleChoice(_ multipleChoice: MultipleChoice) -> MarkdownOutputNode? { - print(#function) return nil } public mutating func visitJustification(_ justification: Justification) -> MarkdownOutputNode? { - print(#function) return nil } public mutating func visitChoice(_ choice: Choice) -> MarkdownOutputNode? { - print(#function) return nil } public mutating func visitTechnology(_ technology: TutorialTableOfContents) -> MarkdownOutputNode? { - print(#function) return nil } public mutating func visitVolume(_ volume: Volume) -> MarkdownOutputNode? { - print(#function) return nil } public mutating func visitChapter(_ chapter: Chapter) -> MarkdownOutputNode? { - print(#function) return nil } @@ -418,32 +410,26 @@ extension MarkdownOutputSemanticVisitor { } public mutating func visitResources(_ resources: Resources) -> MarkdownOutputNode? { - print(#function) return nil } public mutating func visitTile(_ tile: Tile) -> MarkdownOutputNode? { - print(#function) return nil } public mutating func visitComment(_ comment: Comment) -> MarkdownOutputNode? { - print(#function) return nil } public mutating func visitTutorialArticle(_ article: TutorialArticle) -> MarkdownOutputNode? { - print(#function) return nil } public mutating func visitStack(_ stack: Stack) -> MarkdownOutputNode? { - print(#function) return nil } public mutating func visitDeprecationSummary(_ summary: DeprecationSummary) -> MarkdownOutputNode? { - print(#function) return nil } } From eb77d39c1880194f41a0b72d445308f88f7e4c3f Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 2 Oct 2025 13:53:58 +0100 Subject: [PATCH 26/28] Added snippet handling --- .../MarkdownOutputMarkdownWalker.swift | 29 ++++ .../Markdown/MarkdownOutputTests.swift | 137 ++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index 7534432b0..40a5868f4 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -270,7 +270,36 @@ extension MarkdownOutputMarkupWalker { } } } + case Snippet.directiveName: + guard let snippet = Snippet(from: blockDirective, for: bundle) else { + return + } + guard case .success(let resolved) = context.snippetResolver.resolveSnippet(path: snippet.path) else { + return + } + + let lines: [String] + let renderExplanation: Bool + if let slice = snippet.slice { + renderExplanation = false + guard let sliceRange = resolved.mixin.slices[slice] else { + return + } + let sliceLines = resolved.mixin + .lines[sliceRange] + .linesWithoutLeadingWhitespace() + lines = sliceLines.map { String($0) } + } else { + renderExplanation = true + lines = resolved.mixin.lines + } + + if renderExplanation, let explanation = resolved.explanation { + visit(explanation) + } + let code = CodeBlock(language: resolved.mixin.language, lines.joined(separator: "\n")) + visit(code) default: return } diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index c18eff152..3e2887d27 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -277,7 +277,144 @@ final class MarkdownOutputTests: XCTestCase { XCTAssert(codeIndex.lowerBound < step4Index.lowerBound, "Code reference is added after the last step that references it") XCTAssertTrue(node.markdown.contains("struct StartCodeAgain {"), "New file reference is included") } + + func testSnippetInclusion() async throws { + let articleWithSnippet = TextFile(name: "SnippetArticle.md", utf8Content: """ + # Snippets + + Here is an article with some snippets + + ## Overview + + @Snippet(path: "MarkdownOutput/SnippetA") + + Post snippet content + """) + + let snippetContent = """ + import Foundation + // I am a code snippet + """ + + let snippet = makeSnippet(pathComponents: ["MarkdownOutput", "SnippetA"], explanation: nil, code: snippetContent) + let graph = JSONFile(name: "MarkdownOutput.symbols.json", content: makeSymbolGraph(moduleName: "MarkdownOutput", symbols: [snippet])) + + let asMarkdown = "```swift\n\(snippetContent)\n```" + let catalog = catalog(files: [articleWithSnippet, graph]) + let (node, _) = try await markdownOutput(catalog: catalog, path: "SnippetArticle") + XCTAssert(node.markdown.contains(asMarkdown)) + } + + func testSnippetInclusionWithSlice() async throws { + let articleWithSnippet = TextFile(name: "SnippetArticle.md", utf8Content: """ + # Snippets + + Here is an article with some snippets + + ## Overview + + @Snippet(path: "MarkdownOutput/SnippetA", slice: "sliceOne") + + Post snippet content + """) + + let snippetContent = """ + import Foundation + // I am a code snippet + + // snippet.sliceOne + // I am slice one + """ + + let snippet = makeSnippet(pathComponents: ["MarkdownOutput", "SnippetA"], explanation: nil, code: snippetContent, slices: ["sliceOne": 4..<5]) + let graph = JSONFile(name: "MarkdownOutput.symbols.json", content: makeSymbolGraph(moduleName: "MarkdownOutput", symbols: [snippet])) + + let catalog = catalog(files: [articleWithSnippet, graph]) + let (node, _) = try await markdownOutput(catalog: catalog, path: "SnippetArticle") + XCTAssert(node.markdown.contains("// I am slice one")) + XCTAssertFalse(node.markdown.contains("// I am a code snippet")) + } + + func testSnippetInclusionWithHiding() async throws { + let articleWithSnippet = TextFile(name: "SnippetArticle.md", utf8Content: """ + # Snippets + + Here is an article with some snippets + + ## Overview + + @Snippet(path: "MarkdownOutput/SnippetA", slice: "sliceOne") + + Post snippet content + """) + let snippetContent = """ + import Foundation + // I am a code snippet + + // snippet.hide + // I am hidden content + """ + + let snippet = makeSnippet(pathComponents: ["MarkdownOutput", "SnippetA"], explanation: nil, code: snippetContent) + let graph = JSONFile(name: "MarkdownOutput.symbols.json", content: makeSymbolGraph(moduleName: "MarkdownOutput", symbols: [snippet])) + + let catalog = catalog(files: [articleWithSnippet, graph]) + let (node, _) = try await markdownOutput(catalog: catalog, path: "SnippetArticle") + XCTAssertFalse(node.markdown.contains("// I am hidden content")) + } + + func testSnippetInclusionWithExplanation() async throws { + let articleWithSnippet = TextFile(name: "SnippetArticle.md", utf8Content: """ + # Snippets + + Here is an article with some snippets + + ## Overview + + @Snippet(path: "MarkdownOutput/SnippetA") + + Post snippet content + """) + + let snippetContent = """ + import Foundation + // I am a code snippet + """ + + let explanation = """ + I am the explanatory text. + I am two lines long. + """ + let snippet = makeSnippet(pathComponents: ["MarkdownOutput", "SnippetA"], explanation: explanation, code: snippetContent) + let graph = JSONFile(name: "MarkdownOutput.symbols.json", content: makeSymbolGraph(moduleName: "MarkdownOutput", symbols: [snippet])) + + let catalog = catalog(files: [articleWithSnippet, graph]) + let (node, _) = try await markdownOutput(catalog: catalog, path: "SnippetArticle") + XCTAssert(node.markdown.contains(explanation)) + } + + private func makeSnippet( + pathComponents: [String], + explanation: String?, + code: String, + slices: [String: Range] = [:] + ) -> SymbolGraph.Symbol { + makeSymbol( + id: "$snippet__module-name.\(pathComponents.map { $0.lowercased() }.joined(separator: "."))", + kind: .snippet, + pathComponents: pathComponents, + docComment: explanation, + otherMixins: [ + SymbolGraph.Symbol.Snippet( + language: SourceLanguage.swift.id, + lines: code.components(separatedBy: "\n"), + slices: slices + ) + ] + ) + } + // MARK: - Metadata func testArticleMetadata() async throws { From 0e867d773f2bbdddf8895e5a1a1e0fdb007ef47e Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 2 Oct 2025 16:01:58 +0100 Subject: [PATCH 27/28] Remove or _spi-hide new public API --- Package.swift | 11 ++ .../DocumentationContextConverter.swift | 2 +- .../ConvertActionConverter.swift | 15 ++- .../ConvertOutputConsumer.swift | 11 +- .../MarkdownOutputMarkdownWalker.swift | 1 + .../MarkdownOutputNodeTranslator.swift | 49 ++++++-- .../MarkdownOutputSemanticVisitor.swift | 112 ++++++++++-------- .../Rendering/RenderNode/RenderMetadata.swift | 5 - .../MarkdownOutputManifest.swift | 1 + .../MarkdownOutputNode.swift | 13 +- .../Convert/ConvertFileWritingConsumer.swift | 13 +- .../JSONEncodingRenderNodeWriter.swift | 5 +- .../Markdown/MarkdownOutputTests.swift | 1 + 13 files changed, 147 insertions(+), 92 deletions(-) rename Sources/{SwiftDocC/Model/MarkdownOutput/Model => SwiftDocCMarkdownOutput}/MarkdownOutputManifest.swift (99%) rename Sources/{SwiftDocC/Model/MarkdownOutput/Model => SwiftDocCMarkdownOutput}/MarkdownOutputNode.swift (96%) diff --git a/Package.swift b/Package.swift index f954d0090..b1fb4229a 100644 --- a/Package.swift +++ b/Package.swift @@ -33,6 +33,10 @@ let package = Package( name: "SwiftDocC", targets: ["SwiftDocC"] ), + .library( + name: "SwiftDocCMarkdownOutput", + targets: ["SwiftDocCMarkdownOutput"] + ), .executable( name: "docc", targets: ["docc"] @@ -47,6 +51,7 @@ let package = Package( .product(name: "SymbolKit", package: "swift-docc-symbolkit"), .product(name: "CLMDB", package: "swift-lmdb"), .product(name: "Crypto", package: "swift-crypto"), + .target(name: "SwiftDocCMarkdownOutput") ], swiftSettings: swiftSettings ), @@ -126,6 +131,12 @@ let package = Package( swiftSettings: swiftSettings ), + // Experimental markdown output + .target( + name: "SwiftDocCMarkdownOutput", + dependencies: [] + ) + ] ) diff --git a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift index 1b6b4eba1..d310c4f0a 100644 --- a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift +++ b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift @@ -106,7 +106,7 @@ public class DocumentationContextConverter { /// - Parameters: /// - node: The documentation node to convert. /// - Returns: The markdown node representation of the documentation node. - public func markdownNode(for node: DocumentationNode) -> WritableMarkdownOutputNode? { + internal func markdownOutput(for node: DocumentationNode) -> CollectedMarkdownOutput? { guard !node.isVirtual else { return nil } diff --git a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift index afebc6e26..bb84fcde6 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift @@ -9,6 +9,7 @@ */ import Foundation +@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput #if canImport(os) package import os @@ -125,16 +126,16 @@ package enum ConvertActionConverter { do { let entity = try context.entity(with: identifier) - guard var renderNode = converter.renderNode(for: entity) else { + guard let 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 + let markdownConsumer = outputConsumer as? (any ConvertOutputMarkdownConsumer), + let markdownNode = converter.markdownOutput(for: entity) { + try markdownConsumer.consume(markdownNode: markdownNode.writable) if FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled, let manifest = markdownNode.manifest @@ -231,8 +232,10 @@ package enum ConvertActionConverter { } } - if FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled { - try outputConsumer.consume(markdownManifest: markdownManifest) + if + FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled, + let markdownConsumer = outputConsumer as? (any ConvertOutputMarkdownConsumer) { + try markdownConsumer.consume(markdownManifest: try markdownManifest.writable) } switch documentationCoverageOptions.level { diff --git a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift index 03cf265d8..79b6d0abd 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift @@ -9,7 +9,7 @@ */ import Foundation - +@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput /// 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 @@ -51,11 +51,16 @@ public protocol ConvertOutputConsumer { /// Consumes a file representation of the local link resolution information. func consume(linkResolutionInformation: SerializableLinkResolutionInformation) throws +} + +// Merge into ConvertOutputMarkdownConsumer when no longer SPI +@_spi(MarkdownOutput) +public protocol ConvertOutputMarkdownConsumer { /// Consumes a markdown output node func consume(markdownNode: WritableMarkdownOutputNode) throws /// Consumes a markdown output manifest - func consume(markdownManifest: MarkdownOutputManifest) throws + func consume(markdownManifest: WritableMarkdownOutputManifest) throws } // Default implementations that discard the documentation conversion products, for consumers that don't need these @@ -64,8 +69,6 @@ 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. diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index 40a5868f4..1d4a170ce 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -9,6 +9,7 @@ */ import Markdown +@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput /// Performs any markup processing necessary to build the final output markdown internal struct MarkdownOutputMarkupWalker: MarkupWalker { diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift index b11b731f2..0fb0e2b8a 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift @@ -8,27 +8,60 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -import Foundation +public import Foundation +@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput -/// Creates a ``MarkdownOutputNode`` from a ``DocumentationNode``. -public struct MarkdownOutputNodeTranslator { +/// Creates ``CollectedMarkdownOutput`` from a ``DocumentationNode``. +internal struct MarkdownOutputNodeTranslator { var visitor: MarkdownOutputSemanticVisitor - public init(context: DocumentationContext, bundle: DocumentationBundle, node: DocumentationNode) { + init(context: DocumentationContext, bundle: DocumentationBundle, node: DocumentationNode) { self.visitor = MarkdownOutputSemanticVisitor(context: context, bundle: bundle, node: node) } - public mutating func createOutput() -> WritableMarkdownOutputNode? { + mutating func createOutput() -> CollectedMarkdownOutput? { if let node = visitor.start() { - return WritableMarkdownOutputNode(identifier: visitor.identifier, node: node, manifest: visitor.manifest) + return CollectedMarkdownOutput(identifier: visitor.identifier, node: node, manifest: visitor.manifest) } return nil } } +struct CollectedMarkdownOutput { + let identifier: ResolvedTopicReference + let node: MarkdownOutputNode + let manifest: MarkdownOutputManifest? + + var writable: WritableMarkdownOutputNode { + get throws { + WritableMarkdownOutputNode(identifier: identifier, nodeData: try node.data) + } + } +} + +@_spi(MarkdownOutput) public struct WritableMarkdownOutputNode { public let identifier: ResolvedTopicReference - public let node: MarkdownOutputNode - public let manifest: MarkdownOutputManifest? + public let nodeData: Data +} + +extension MarkdownOutputManifest { + var writable: WritableMarkdownOutputManifest { + get throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + #if DEBUG + encoder.outputFormatting.insert(.prettyPrinted) + #endif + let data = try encoder.encode(self) + return WritableMarkdownOutputManifest(title: title, manifestData: data) + } + } +} + +@_spi(MarkdownOutput) +public struct WritableMarkdownOutputManifest { + public let title: String + public let manifestData: Data } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 2a996aadc..6ffa5a3d2 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -8,6 +8,8 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ +@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput + /// Visits the semantic structure of a documentation node and returns a ``MarkdownOutputNode`` internal struct MarkdownOutputSemanticVisitor: SemanticVisitor { @@ -26,7 +28,7 @@ internal struct MarkdownOutputSemanticVisitor: SemanticVisitor { self.markdownWalker = MarkdownOutputMarkupWalker(context: context, bundle: bundle, identifier: identifier) } - public typealias Result = MarkdownOutputNode? + typealias Result = MarkdownOutputNode? // Tutorial processing private var sectionIndex = 0 @@ -39,12 +41,13 @@ internal struct MarkdownOutputSemanticVisitor: SemanticVisitor { } extension MarkdownOutputNode.Metadata { - public init(documentType: DocumentType, bundle: DocumentationBundle, reference: ResolvedTopicReference) { - self.documentType = documentType - self.metadataVersion = Self.version.description - self.uri = reference.path - self.title = reference.lastPathComponent - self.framework = bundle.displayName + init(documentType: DocumentType, bundle: DocumentationBundle, reference: ResolvedTopicReference) { + self.init( + documentType: documentType, + uri: reference.path, + title: reference.lastPathComponent, + framework: bundle.displayName + ) } } @@ -75,7 +78,7 @@ extension MarkdownOutputSemanticVisitor { // MARK: Article Output extension MarkdownOutputSemanticVisitor { - public mutating func visitArticle(_ article: Article) -> MarkdownOutputNode? { + mutating func visitArticle(_ article: Article) -> MarkdownOutputNode? { var metadata = MarkdownOutputNode.Metadata(documentType: .article, bundle: bundle, reference: identifier) if let title = article.title?.plainText { metadata.title = title @@ -116,7 +119,7 @@ import Markdown // MARK: Symbol Output extension MarkdownOutputSemanticVisitor { - public mutating func visitSymbol(_ symbol: Symbol) -> MarkdownOutputNode? { + mutating func visitSymbol(_ symbol: Symbol) -> MarkdownOutputNode? { var metadata = MarkdownOutputNode.Metadata(documentType: .symbol, bundle: bundle, reference: identifier) metadata.symbol = .init(symbol, context: context, bundle: bundle) @@ -203,8 +206,6 @@ import SymbolKit extension MarkdownOutputNode.Metadata.Symbol { init(_ symbol: SwiftDocC.Symbol, context: DocumentationContext, bundle: DocumentationBundle) { - self.kind = symbol.kind.identifier.identifier - self.preciseIdentifier = symbol.externalID ?? "" // Gather modules var modules = [String]() @@ -218,43 +219,52 @@ extension MarkdownOutputNode.Metadata.Symbol { if let extended = symbol.extendedModuleVariants.firstValue, modules.contains(extended) == false { modules.append(extended) } - - self.modules = modules + self.init( + kind: symbol.kind.identifier.identifier, + preciseIdentifier: symbol.externalID ?? "", + modules: modules + ) } } extension MarkdownOutputNode.Metadata.Availability { init(_ item: SymbolGraph.Symbol.Availability.AvailabilityItem) { - self.platform = item.domain?.rawValue ?? "*" - self.introduced = item.introducedVersion?.description - self.deprecated = item.deprecatedVersion?.description - self.unavailable = item.obsoletedVersion != nil + self.init( + platform: item.domain?.rawValue ?? "*", + introduced: item.introducedVersion?.description, + deprecated: item.deprecatedVersion?.description, + unavailable: item.obsoletedVersion != nil + ) } // From the info.plist of the module init(_ availability: DefaultAvailability.ModuleAvailability) { - self.platform = availability.platformName.rawValue - self.introduced = availability.introducedVersion - self.deprecated = nil - self.unavailable = availability.versionInformation == .unavailable + self.init( + platform: availability.platformName.rawValue, + introduced: availability.introducedVersion, + deprecated: nil, + unavailable: availability.versionInformation == .unavailable + ) } init(_ availability: Metadata.Availability) { - self.platform = availability.platform.rawValue - self.introduced = availability.introduced.description - self.deprecated = availability.deprecated?.description - self.unavailable = false + self.init( + platform: availability.platform.rawValue, + introduced: availability.introduced.description, + deprecated: availability.deprecated?.description, + unavailable: false + ) } } // MARK: Tutorial Output extension MarkdownOutputSemanticVisitor { // Tutorial table of contents is not useful as markdown or indexable content - public func visitTutorialTableOfContents(_ tutorialTableOfContents: TutorialTableOfContents) -> MarkdownOutputNode? { + func visitTutorialTableOfContents(_ tutorialTableOfContents: TutorialTableOfContents) -> MarkdownOutputNode? { return nil } - public mutating func visitTutorial(_ tutorial: Tutorial) -> MarkdownOutputNode? { + mutating func visitTutorial(_ tutorial: Tutorial) -> MarkdownOutputNode? { var metadata = MarkdownOutputNode.Metadata(documentType: .tutorial, bundle: bundle, reference: identifier) if tutorial.intro.title.isEmpty == false { @@ -276,7 +286,7 @@ extension MarkdownOutputSemanticVisitor { return MarkdownOutputNode(metadata: metadata, markdown: markdownWalker.markdown) } - public mutating func visitTutorialSection(_ tutorialSection: TutorialSection) -> MarkdownOutputNode? { + mutating func visitTutorialSection(_ tutorialSection: TutorialSection) -> MarkdownOutputNode? { sectionIndex += 1 markdownWalker.visit(Heading(level: 2, Text("Section \(sectionIndex): \(tutorialSection.title)"))) @@ -286,7 +296,7 @@ extension MarkdownOutputSemanticVisitor { return nil } - public mutating func visitSteps(_ steps: Steps) -> MarkdownOutputNode? { + mutating func visitSteps(_ steps: Steps) -> MarkdownOutputNode? { stepIndex = 0 for child in steps.children { _ = visit(child) @@ -300,7 +310,7 @@ extension MarkdownOutputSemanticVisitor { return nil } - public mutating func visitStep(_ step: Step) -> MarkdownOutputNode? { + mutating func visitStep(_ step: Step) -> MarkdownOutputNode? { // Check if the step contains another version of the current code reference if let code = lastCode { @@ -329,7 +339,7 @@ extension MarkdownOutputSemanticVisitor { return nil } - public mutating func visitIntro(_ intro: Intro) -> MarkdownOutputNode? { + mutating func visitIntro(_ intro: Intro) -> MarkdownOutputNode? { markdownWalker.visit(Heading(level: 1, Text(intro.title))) @@ -339,31 +349,31 @@ extension MarkdownOutputSemanticVisitor { return nil } - public mutating func visitMarkupContainer(_ markupContainer: MarkupContainer) -> MarkdownOutputNode? { + mutating func visitMarkupContainer(_ markupContainer: MarkupContainer) -> MarkdownOutputNode? { markdownWalker.withRemoveIndentation(from: markupContainer.elements.first) { $0.visit(container: markupContainer) } return nil } - public mutating func visitImageMedia(_ imageMedia: ImageMedia) -> MarkdownOutputNode? { + mutating func visitImageMedia(_ imageMedia: ImageMedia) -> MarkdownOutputNode? { markdownWalker.visit(imageMedia) return nil } - public mutating func visitVideoMedia(_ videoMedia: VideoMedia) -> MarkdownOutputNode? { + mutating func visitVideoMedia(_ videoMedia: VideoMedia) -> MarkdownOutputNode? { markdownWalker.visit(videoMedia) return nil } - public mutating func visitContentAndMedia(_ contentAndMedia: ContentAndMedia) -> MarkdownOutputNode? { + mutating func visitContentAndMedia(_ contentAndMedia: ContentAndMedia) -> MarkdownOutputNode? { for child in contentAndMedia.children { _ = visit(child) } return nil } - public mutating func visitCode(_ code: Code) -> MarkdownOutputNode? { + mutating func visitCode(_ code: Code) -> MarkdownOutputNode? { // Code rendering is handled in visitStep(_:) return nil } @@ -373,63 +383,63 @@ extension MarkdownOutputSemanticVisitor { // MARK: Visitors not currently used for markdown output extension MarkdownOutputSemanticVisitor { - public mutating func visitXcodeRequirement(_ xcodeRequirement: XcodeRequirement) -> MarkdownOutputNode? { + mutating func visitXcodeRequirement(_ xcodeRequirement: XcodeRequirement) -> MarkdownOutputNode? { return nil } - public mutating func visitAssessments(_ assessments: Assessments) -> MarkdownOutputNode? { + mutating func visitAssessments(_ assessments: Assessments) -> MarkdownOutputNode? { return nil } - public mutating func visitMultipleChoice(_ multipleChoice: MultipleChoice) -> MarkdownOutputNode? { + mutating func visitMultipleChoice(_ multipleChoice: MultipleChoice) -> MarkdownOutputNode? { return nil } - public mutating func visitJustification(_ justification: Justification) -> MarkdownOutputNode? { + mutating func visitJustification(_ justification: Justification) -> MarkdownOutputNode? { return nil } - public mutating func visitChoice(_ choice: Choice) -> MarkdownOutputNode? { + mutating func visitChoice(_ choice: Choice) -> MarkdownOutputNode? { return nil } - public mutating func visitTechnology(_ technology: TutorialTableOfContents) -> MarkdownOutputNode? { + mutating func visitTechnology(_ technology: TutorialTableOfContents) -> MarkdownOutputNode? { return nil } - public mutating func visitVolume(_ volume: Volume) -> MarkdownOutputNode? { + mutating func visitVolume(_ volume: Volume) -> MarkdownOutputNode? { return nil } - public mutating func visitChapter(_ chapter: Chapter) -> MarkdownOutputNode? { + mutating func visitChapter(_ chapter: Chapter) -> MarkdownOutputNode? { return nil } - public mutating func visitTutorialReference(_ tutorialReference: TutorialReference) -> MarkdownOutputNode? { + mutating func visitTutorialReference(_ tutorialReference: TutorialReference) -> MarkdownOutputNode? { return nil } - public mutating func visitResources(_ resources: Resources) -> MarkdownOutputNode? { + mutating func visitResources(_ resources: Resources) -> MarkdownOutputNode? { return nil } - public mutating func visitTile(_ tile: Tile) -> MarkdownOutputNode? { + mutating func visitTile(_ tile: Tile) -> MarkdownOutputNode? { return nil } - public mutating func visitComment(_ comment: Comment) -> MarkdownOutputNode? { + mutating func visitComment(_ comment: Comment) -> MarkdownOutputNode? { return nil } - public mutating func visitTutorialArticle(_ article: TutorialArticle) -> MarkdownOutputNode? { + mutating func visitTutorialArticle(_ article: TutorialArticle) -> MarkdownOutputNode? { return nil } - public mutating func visitStack(_ stack: Stack) -> MarkdownOutputNode? { + mutating func visitStack(_ stack: Stack) -> MarkdownOutputNode? { return nil } - public mutating func visitDeprecationSummary(_ summary: DeprecationSummary) -> MarkdownOutputNode? { + mutating func visitDeprecationSummary(_ summary: DeprecationSummary) -> MarkdownOutputNode? { return nil } } diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift b/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift index aeb0397a4..952526253 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift @@ -178,8 +178,6 @@ public struct RenderMetadata: VariantContainer { /// the ``RenderNode/variants`` property. public var hasNoExpandedDocumentation: Bool = false - /// If a markdown equivalent of this page was generated at render time. - public var hasGeneratedMarkdown: Bool = false } extension RenderMetadata: Codable { @@ -282,7 +280,6 @@ extension RenderMetadata: Codable { remoteSourceVariants = try container.decodeVariantCollectionIfPresent(ofValueType: RemoteSource?.self, forKey: .remoteSource) tags = try container.decodeIfPresent([RenderNode.Tag].self, forKey: .tags) hasNoExpandedDocumentation = try container.decodeIfPresent(Bool.self, forKey: .hasNoExpandedDocumentation) ?? false - hasGeneratedMarkdown = try container.decodeIfPresent(Bool.self, forKey: .hasGeneratedMarkdown) ?? false let extraKeys = Set(container.allKeys).subtracting( [ @@ -348,7 +345,6 @@ extension RenderMetadata: Codable { try container.encodeIfPresent(color, forKey: .color) try container.encodeIfNotEmpty(customMetadata, forKey: .customMetadata) try container.encodeIfTrue(hasNoExpandedDocumentation, forKey: .hasNoExpandedDocumentation) - try container.encodeIfTrue(hasGeneratedMarkdown, forKey: .hasGeneratedMarkdown) } } @@ -382,7 +378,6 @@ extension RenderMetadata: RenderJSONDiffable { diffBuilder.addDifferences(atKeyPath: \.remoteSource, forKey: CodingKeys.remoteSource) diffBuilder.addDifferences(atKeyPath: \.tags, forKey: CodingKeys.tags) diffBuilder.addDifferences(atKeyPath: \.hasNoExpandedDocumentation, forKey: CodingKeys.hasNoExpandedDocumentation) - diffBuilder.addDifferences(atKeyPath: \.hasGeneratedMarkdown, forKey: CodingKeys.hasGeneratedMarkdown) return diffBuilder.differences } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift b/Sources/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift similarity index 99% rename from Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift rename to Sources/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift index f9144de63..ee9358a4f 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputManifest.swift +++ b/Sources/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift @@ -13,6 +13,7 @@ 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 +@_spi(MarkdownOutput) public struct MarkdownOutputManifest: Codable, Sendable { public static let version = "0.1.0" diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift b/Sources/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift similarity index 96% rename from Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift rename to Sources/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift index e060aa7db..1966c08dd 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Model/MarkdownOutputNode.swift +++ b/Sources/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift @@ -13,6 +13,7 @@ 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. +@_spi(MarkdownOutput) public struct MarkdownOutputNode: Sendable { /// The metadata about this node @@ -37,10 +38,10 @@ extension MarkdownOutputNode { public struct Availability: Codable, Equatable, Sendable { - let platform: String - let introduced: String? - let deprecated: String? - let unavailable: Bool + public let platform: String + public let introduced: String? + public let deprecated: String? + public let unavailable: Bool public init(platform: String, introduced: String? = nil, deprecated: String? = nil, unavailable: Bool) { self.platform = platform @@ -66,7 +67,7 @@ extension MarkdownOutputNode { self.init(stringRepresentation: stringRepresentation) } - var stringRepresentation: String { + public var stringRepresentation: String { var stringRepresentation = "\(platform): " if unavailable { stringRepresentation += "-" @@ -83,7 +84,7 @@ extension MarkdownOutputNode { return stringRepresentation } - init(stringRepresentation: String) { + public init(stringRepresentation: String) { let words = stringRepresentation.split(separator: ":", maxSplits: 1) if words.count != 2 { platform = stringRepresentation diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift index 2b1305d00..5ed6c1b0f 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift @@ -10,8 +10,9 @@ import Foundation import SwiftDocC +@_spi(MarkdownOutput) import SwiftDocC -struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer { +struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer, ConvertOutputMarkdownConsumer { var targetFolder: URL var bundleRootFolder: URL? var fileManager: any FileManagerProtocol @@ -72,15 +73,9 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer { try renderNodeWriter.write(markdownNode) } - func consume(markdownManifest: MarkdownOutputManifest) throws { + func consume(markdownManifest: WritableMarkdownOutputManifest) throws { let url = targetFolder.appendingPathComponent("\(markdownManifest.title)-markdown-manifest.json", isDirectory: false) - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] - #if DEBUG - encoder.outputFormatting.insert(.prettyPrinted) - #endif - let data = try encoder.encode(markdownManifest) - try fileManager.createFile(at: url, contents: data) + try fileManager.createFile(at: url, contents: markdownManifest.manifestData) } func consume(externalRenderNode: ExternalRenderNode) throws { diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift index 2be151605..c67f4b3ff 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift @@ -10,6 +10,8 @@ import Foundation import SwiftDocC +@_spi(MarkdownOutput) import SwiftDocC +@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput /// An object that writes render nodes, as JSON files, into a target folder. /// @@ -157,7 +159,6 @@ class JSONEncodingRenderNodeWriter { } } - let data = try markdownNode.node.data - try fileManager.createFile(at: markdownNodeTargetFileURL, contents: data, options: nil) + try fileManager.createFile(at: markdownNodeTargetFileURL, contents: markdownNode.nodeData, options: nil) } } diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 3e2887d27..4b77c2379 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -12,6 +12,7 @@ import Foundation import XCTest import SwiftDocCTestUtilities import SymbolKit +@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput @testable import SwiftDocC From f07f6837c4b2ab76c62369c1c7f887ecbfcda396 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 2 Oct 2025 16:06:47 +0100 Subject: [PATCH 28/28] Remove or _spi-hide new public API (part 2) --- .../MarkdownOutputMarkdownWalker.swift | 24 +++++++++---------- .../Rendering/RenderNode/RenderMetadata.swift | 1 - 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index 1d4a170ce..1e5985515 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -31,14 +31,14 @@ internal struct MarkdownOutputMarkupWalker: MarkupWalker { private var lastHeading: String? = nil /// Perform actions while rendering a link list, which affects the output formatting of links - public mutating func withRenderingLinkList(_ process: (inout Self) -> Void) { + mutating func withRenderingLinkList(_ process: (inout Self) -> Void) { isRenderingLinkList = true process(&self) isRenderingLinkList = false } /// Perform actions while removing a base level of indentation, typically while processing the contents of block directives. - public mutating func withRemoveIndentation(from base: (any Markup)?, process: (inout Self) -> Void) { + mutating func withRemoveIndentation(from base: (any Markup)?, process: (inout Self) -> Void) { indentationToRemove = nil if let toRemove = base? .format() @@ -89,7 +89,7 @@ extension MarkdownOutputMarkupWalker { extension MarkdownOutputMarkupWalker { - public mutating func defaultVisit(_ markup: any Markup) -> () { + mutating func defaultVisit(_ markup: any Markup) -> () { var output = markup.format() if let indentationToRemove, output.hasPrefix(indentationToRemove) { output.removeFirst(indentationToRemove.count) @@ -97,7 +97,7 @@ extension MarkdownOutputMarkupWalker { markdown.append(output) } - public mutating func visitHeading(_ heading: Heading) -> () { + mutating func visitHeading(_ heading: Heading) -> () { startNewParagraphIfRequired() markdown.append(heading.detachedFromParent.format()) if heading.level > 1 { @@ -105,7 +105,7 @@ extension MarkdownOutputMarkupWalker { } } - public mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> () { + mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> () { guard isRenderingLinkList else { return defaultVisit(unorderedList) } @@ -117,7 +117,7 @@ extension MarkdownOutputMarkupWalker { } } - public mutating func visitImage(_ image: Image) -> () { + mutating func visitImage(_ image: Image) -> () { guard let source = image.source else { return } @@ -131,12 +131,12 @@ extension MarkdownOutputMarkupWalker { markdown.append("![\(image.altText ?? "")](images/\(bundle.id)/\(filename))") } - public mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> () { + mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> () { startNewParagraphIfRequired() markdown.append(codeBlock.detachedFromParent.format()) } - public mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> () { + mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> () { guard let destination = symbolLink.destination, let resolved = context.referenceIndex[destination], @@ -169,7 +169,7 @@ extension MarkdownOutputMarkupWalker { visit(linkListAbstract) } - public mutating func visitLink(_ link: Link) -> () { + mutating func visitLink(_ link: Link) -> () { guard link.isAutolink, let destination = link.destination, @@ -206,11 +206,11 @@ extension MarkdownOutputMarkupWalker { } - public mutating func visitSoftBreak(_ softBreak: SoftBreak) -> () { + mutating func visitSoftBreak(_ softBreak: SoftBreak) -> () { markdown.append("\n") } - public mutating func visitParagraph(_ paragraph: Paragraph) -> () { + mutating func visitParagraph(_ paragraph: Paragraph) -> () { startNewParagraphIfRequired() @@ -219,7 +219,7 @@ extension MarkdownOutputMarkupWalker { } } - public mutating func visitBlockDirective(_ blockDirective: BlockDirective) -> () { + mutating func visitBlockDirective(_ blockDirective: BlockDirective) -> () { switch blockDirective.name { case VideoMedia.directiveName: diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift b/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift index 952526253..f27618c06 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift @@ -249,7 +249,6 @@ extension RenderMetadata: Codable { public static let color = CodingKeys(stringValue: "color") public static let customMetadata = CodingKeys(stringValue: "customMetadata") public static let hasNoExpandedDocumentation = CodingKeys(stringValue: "hasNoExpandedDocumentation") - public static let hasGeneratedMarkdown = CodingKeys(stringValue: "hasGeneratedMarkdown") } public init(from decoder: any Decoder) throws {