From cf7ebc606c4840269c5578779db419f8d903561a Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 2 Sep 2025 16:25:11 +0100 Subject: [PATCH 01/59] 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 b2ec4dbc5d..3ca3f3babe 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 e8c8a31b45..382c9637a3 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 557b4f2a52..64db12bbd7 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/59] 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 72e23665bb..878425f3ea 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 17a5db0a70..71cfc70b48 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 830404dda6..9297d5338c 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 0000000000..183a6dac65 --- /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 0000000000..96c7f03c22 --- /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 9d0370dda3..91918c6246 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 30ac26eb0d..69e5ebac35 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/59] 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 183a6dac65..d6fcaa37f3 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 96c7f03c22..a652668f3a 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 69e5ebac35..494fee42b1 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/59] 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 d6fcaa37f3..62b05d44a2 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 a652668f3a..43bd7ec9de 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/59] 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 62b05d44a2..54e422c6d1 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 43bd7ec9de..fab222328c 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/59] 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 54e422c6d1..19cc568dcd 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 fab222328c..ad83520b24 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/59] 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 9297d5338c..9b236e2aa9 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 19cc568dcd..e118e7dfdf 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 ad83520b24..2f27ce2736 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/59] 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 e118e7dfdf..c797a18aa1 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 2f27ce2736..9e6a91bc4c 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 0000000000..2d59120748 --- /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 0000000000..84193a341b --- /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 0000000000..a6e33efc1a --- /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 0000000000..353baf325c --- /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 0000000000..1744c9ff83 --- /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/59] 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 2d59120748..d324f50596 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 a6e33efc1a..9ab91d0d61 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 353baf325c..9f845d23a7 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 0000000000..ae673a3191 --- /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 1744c9ff83..2aa55277cd 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/59] 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 c797a18aa1..d3352cc6bb 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 9e6a91bc4c..f903de150f 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 d324f50596..a8ef453c1d 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 0000000000..e3458faa66 --- /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 0000000000..d8cb3ff845 --- /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 0000000000..2e3adb7851 --- /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/59] 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 d3352cc6bb..7ac41d95dd 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 0000000000..f55f1e5ecb --- /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 f903de150f..acc11e06ad 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 a8ef453c1d..623b2d6f11 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 ae673a3191..f235e5a0cc 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 0000000000..3885e0692c --- /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 0000000000..71968c3a50 --- /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 0000000000..dbf7b5620f --- /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 d8cb3ff845..3034fe0249 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 2e3adb7851..dd409ea556 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/59] 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 9f845d23a7..2b39fe11b7 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 f235e5a0cc..bd2c580621 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/59] 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 878425f3ea..c9ad65cbe3 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 f55f1e5ecb..646b8eb33b 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 acc11e06ad..c3cffb4e0d 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 623b2d6f11..212b197023 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 0000000000..f6dc13eb56 --- /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 bd2c580621..0659174016 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/59] 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 7ac41d95dd..ec01003c86 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 646b8eb33b..3bff25e99d 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 c3cffb4e0d..206baca5d1 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 212b197023..6acba0609a 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 f6dc13eb56..b664dc8995 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 84193a341b..c68f20e424 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/59] 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 3bff25e99d..0b9af3c0aa 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 206baca5d1..7dda3ef4ff 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 6acba0609a..0df855e7a2 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 0000000000..656b6272e8 --- /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/59] 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 c9ad65cbe3..bed1dd2490 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 9b236e2aa9..67a4b3c743 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 0b9af3c0aa..0000000000 --- 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 0000000000..4f89714947 --- /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 0000000000..118469b955 --- /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 7dda3ef4ff..b177ffc466 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 91918c6246..4467c6097a 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 494fee42b1..2be1516053 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 0df855e7a2..aeb5ad3d4b 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/59] 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 bed1dd2490..1b6b4eba13 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 71cfc70b48..f7a1c75ce5 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 7d71f68d00..aeb0397a41 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/59] 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 4f89714947..00c13998af 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/59] 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 f7a1c75ce5..2592bb3b8f 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 67a4b3c743..6b81d286cf 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 0000000000..cd1d9248c3 --- /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 6f57b3a64d..0000000000 --- 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 118469b955..8a3f30ff14 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 b177ffc466..069c2b9eb2 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 3ca3f3babe..cb46cd8b3d 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 4467c6097a..dc87171d69 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 382c9637a3..94bda9a5e6 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 64db12bbd7..2acf8faf08 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/59] 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 cd1d9248c3..27402e1778 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 c1d3d1f283..5d94e50387 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 069c2b9eb2..6b982e2579 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 dc87171d69..fe949f5284 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 aeb5ad3d4b..158d4ba00a 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 9ab91d0d61..195f66b08b 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 0659174016..4ef6ca6073 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/59] 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 2592bb3b8f..afebc6e26a 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 27402e1778..5601297960 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 00c13998af..866b031a21 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 5d94e50387..7534432b04 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 8a3f30ff14..b11b731f2f 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 6b982e2579..9a025bb649 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 158d4ba00a..333195ef56 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/59] 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 5601297960..f9144de634 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 9a025bb649..bdb855d56e 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 333195ef56..8470b1ac71 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/59] 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 866b031a21..e060aa7db2 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 bdb855d56e..0a38d57e19 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 8470b1ac71..7cd970a7ea 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 2b39fe11b7..78b57b8079 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/59] 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 7cd970a7ea..c18eff1522 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 b664dc8995..0000000000 --- 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 656b6272e8..0000000000 --- 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 c68f20e424..0000000000 --- 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 195f66b08b..0000000000 --- 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 78b57b8079..0000000000 --- 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 4ef6ca6073..0000000000 --- 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 e3458faa66..0000000000 --- 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 3885e0692c..0000000000 --- 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 71968c3a50..0000000000 --- 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 dbf7b5620f..0000000000 --- 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 2aa55277cd..0000000000 --- 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 3034fe0249..0000000000 --- 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 dd409ea556..0000000000 --- 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/59] 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 0a38d57e19..2a996aadc7 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/59] 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 7534432b04..40a5868f44 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 c18eff1522..3e2887d273 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/59] 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 f954d00903..b1fb4229a5 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 1b6b4eba13..d310c4f0ae 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 afebc6e26a..bb84fcde61 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 03cf265d8e..79b6d0abdb 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 40a5868f44..1d4a170ce4 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 b11b731f2f..0fb0e2b8aa 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 2a996aadc7..6ffa5a3d28 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 aeb0397a41..952526253d 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 f9144de634..ee9358a4f8 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 e060aa7db2..1966c08dd5 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 2b1305d00d..5ed6c1b0f7 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 2be1516053..c67f4b3ff5 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 3e2887d273..4b77c23796 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/59] 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 1d4a170ce4..1e59855152 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 952526253d..f27618c064 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 { From 6ccfe8f9c5f360896cf95d744679cbb6b7e870ea Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 6 Nov 2025 10:02:50 +0000 Subject: [PATCH 29/59] Prevent recursion when the abstract of a link contains a link and we are rendering links-with-abstracts --- .../MarkdownOutputMarkdownWalker.swift | 8 +++++ .../Markdown/MarkdownOutputTests.swift | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index 1e59855152..efce0be58a 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -165,8 +165,12 @@ extension MarkdownOutputMarkupWalker { linkTitle = node.title } let link = Link(destination: destination, title: linkTitle, [InlineCode(linkTitle)]) + // Only perform the linked list rendering for the first thing you find + let previous = isRenderingLinkList + isRenderingLinkList = false visit(link) visit(linkListAbstract) + isRenderingLinkList = previous } mutating func visitLink(_ link: Link) -> () { @@ -201,8 +205,12 @@ extension MarkdownOutputMarkupWalker { } let link = Link(destination: destination, title: linkTitle, [linkMarkup]) + // Only perform the linked list rendering for the first thing you find + let previous = isRenderingLinkList + isRenderingLinkList = false defaultVisit(link) visit(linkListAbstract) + isRenderingLinkList = previous } diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 4b77c23796..413e4c09a6 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -138,6 +138,38 @@ final class MarkdownOutputTests: XCTestCase { let expectedLinkList = "[`MarkdownSymbol`](doc://MarkdownOutput/documentation/MarkdownOutput/MarkdownSymbol)\n\nA basic symbol to test markdown output" XCTAssert(node.markdown.contains(expectedLinkList)) } + + func testLinkSymbolWithLinkInAbstractDoesntRecurse() 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`` + - ``OtherMarkdownSymbol`` + """), + 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. Different to ``OtherMarkdownSymbol``"), + makeSymbol(id: "OtherMarkdownSymbol", kind: .struct, pathComponents: ["OtherMarkdownSymbol"], docComment: "A basic symbol to test markdown output. Different to ``MarkdownSymbol``") + ])) + ]) + + 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. Different to [`OtherMarkdownSymbol`](doc://MarkdownOutput/documentation/MarkdownOutput/OtherMarkdownSymbol)" + XCTAssert(node.markdown.contains(expectedLinkList)) + } func testLanguageTabOnlyIncludesPrimaryLanguage() async throws { let catalog = catalog(files: [ From 4fcd9fece0ae3145e3ebdf62552a47d1a8b9a77f Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 20 Nov 2025 12:25:24 +0000 Subject: [PATCH 30/59] Test and temporary fix for colspan issue https://github.com/swiftlang/swift-markdown/issues/238 --- .../MarkdownOutputMarkdownWalker.swift | 33 ++++++++++++++++ .../Markdown/MarkdownOutputTests.swift | 38 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index efce0be58a..3b138b2351 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -313,6 +313,39 @@ extension MarkdownOutputMarkupWalker { } } + + mutating func visitTable(_ table: Table) -> () { + // TODO: Temporary fix while waiting for https://github.com/swiftlang/swift-markdown/issues/238 to be integrated. Once that is present this whole method can be deleted. + // Do any rows have spans due to misformatting that take it over the max number? + let columnCount = table.maxColumnCount + var safeRows = [Table.Row]() + for row in table.body.rows { + let widthIncludingSpan = row.cells.reduce(0) { $0 + $1.colspan } + if widthIncludingSpan > columnCount { + var truncated = row + var safeColumnCount: UInt = 0 + var safeCells = [Table.Cell]() + for cell in row.cells { + if safeColumnCount + cell.colspan <= columnCount { + safeCells.append(cell) + safeColumnCount += cell.colspan + } else { + var safeCell = cell + safeCell.colspan = 1 + safeCells.append(safeCell) + break + } + } + truncated.setCells(safeCells) + safeRows.append(truncated) + } else { + safeRows.append(row) + } + } + var table = table + table.body.setRows(safeRows) + defaultVisit(table) + } } // Semantic handling diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 413e4c09a6..8a2e049125 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -448,6 +448,44 @@ final class MarkdownOutputTests: XCTestCase { ) } + func testBadlyFormattedTablesCrash() async throws { + let catalog = catalog(files: [ + // It's the || that causes the problem - there is no issue if there is a space between the characters + TextFile(name: "DodgyTables.md", utf8Content: """ + # Tables + + Demonstrates how markdown tables that are badly formatted dont crash the export + + ## Overview + + | Parameter | Description | + |:----------|:------------| + | `a` | The first parameter | + | `b` | The second parameter || `c` | The third parameter | + + end of the table + """) + ]) + + let (node, _) = try await markdownOutput(catalog: catalog, path: "DodgyTables") + let expected = """ + # Tables + + Demonstrates how markdown tables that are badly formatted dont crash the export + + ## Overview + + |Parameter|Description | + |:--------|:-------------------| + |`a` |The first parameter | + |`b` |The second parameter| + + end of the table + """ + + XCTAssertEqual(node.markdown, expected) + } + // MARK: - Metadata func testArticleMetadata() async throws { From ba716edba7960c8cd64be9c7af87335d0728cca5 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 20 Nov 2025 12:50:04 +0000 Subject: [PATCH 31/59] Remove separate SwiftDocCMarkdownOutput target --- Package.swift | 11 ----------- .../Infrastructure/ConvertActionConverter.swift | 1 - .../Infrastructure/ConvertOutputConsumer.swift | 2 +- .../Translation/MarkdownOutputMarkdownWalker.swift | 1 - .../Translation/MarkdownOutputNodeTranslator.swift | 1 - .../Translation/MarkdownOutputSemanticVisitor.swift | 2 -- .../MarkdownOutputManifest.swift | 0 .../SwiftDocCMarkdownOutput/MarkdownOutputNode.swift | 0 .../Convert/JSONEncodingRenderNodeWriter.swift | 1 - .../Rendering/Markdown/MarkdownOutputTests.swift | 3 +-- 10 files changed, 2 insertions(+), 20 deletions(-) rename Sources/{ => SwiftDocC}/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift (100%) rename Sources/{ => SwiftDocC}/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift (100%) diff --git a/Package.swift b/Package.swift index b1fb4229a5..f954d00903 100644 --- a/Package.swift +++ b/Package.swift @@ -33,10 +33,6 @@ let package = Package( name: "SwiftDocC", targets: ["SwiftDocC"] ), - .library( - name: "SwiftDocCMarkdownOutput", - targets: ["SwiftDocCMarkdownOutput"] - ), .executable( name: "docc", targets: ["docc"] @@ -51,7 +47,6 @@ 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 ), @@ -131,12 +126,6 @@ let package = Package( swiftSettings: swiftSettings ), - // Experimental markdown output - .target( - name: "SwiftDocCMarkdownOutput", - dependencies: [] - ) - ] ) diff --git a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift index bb84fcde61..bf15d49c57 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift @@ -9,7 +9,6 @@ */ import Foundation -@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput #if canImport(os) package import os diff --git a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift index 79b6d0abdb..35a82d19b8 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 diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index 3b138b2351..ee81fab215 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -9,7 +9,6 @@ */ 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 0fb0e2b8aa..6cfc09d9eb 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift @@ -9,7 +9,6 @@ */ public import Foundation -@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput /// Creates ``CollectedMarkdownOutput`` from a ``DocumentationNode``. internal struct MarkdownOutputNodeTranslator { diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 6ffa5a3d28..27b576d3b9 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -8,8 +8,6 @@ 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 { diff --git a/Sources/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift similarity index 100% rename from Sources/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift rename to Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift diff --git a/Sources/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift similarity index 100% rename from Sources/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift rename to Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift index c67f4b3ff5..7baa00a577 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift @@ -11,7 +11,6 @@ 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. /// diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 8a2e049125..56988ff00a 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -12,8 +12,7 @@ import Foundation import XCTest import SwiftDocCTestUtilities import SymbolKit -@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput - +@_spi(MarkdownOutput) import SwiftDocC @testable import SwiftDocC final class MarkdownOutputTests: XCTestCase { From 5c835b0cbdbb02176c3ff9585a7182a7e357fab6 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 20 Nov 2025 18:35:34 +0000 Subject: [PATCH 32/59] Bump swift-markdown, remove temporary bug fix --- Package.resolved | 5 +-- .../MarkdownOutputMarkdownWalker.swift | 33 ------------------- 2 files changed, 3 insertions(+), 35 deletions(-) diff --git a/Package.resolved b/Package.resolved index ffb8d1e20d..1e47396199 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "6a2fba12d9d8cf682094fa377600d56c196ce947a70bf82d12a7faff1b81c530", "pins" : [ { "identity" : "swift-argument-parser", @@ -87,7 +88,7 @@ "location" : "https://github.com/swiftlang/swift-markdown.git", "state" : { "branch" : "main", - "revision" : "5ad49ed3219261b085e86da832d0657752bba63c" + "revision" : "b2135f426fca19029430fbf26564e953b2d0f3d3" } }, { @@ -109,5 +110,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index ee81fab215..c47edb4835 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -312,39 +312,6 @@ extension MarkdownOutputMarkupWalker { } } - - mutating func visitTable(_ table: Table) -> () { - // TODO: Temporary fix while waiting for https://github.com/swiftlang/swift-markdown/issues/238 to be integrated. Once that is present this whole method can be deleted. - // Do any rows have spans due to misformatting that take it over the max number? - let columnCount = table.maxColumnCount - var safeRows = [Table.Row]() - for row in table.body.rows { - let widthIncludingSpan = row.cells.reduce(0) { $0 + $1.colspan } - if widthIncludingSpan > columnCount { - var truncated = row - var safeColumnCount: UInt = 0 - var safeCells = [Table.Cell]() - for cell in row.cells { - if safeColumnCount + cell.colspan <= columnCount { - safeCells.append(cell) - safeColumnCount += cell.colspan - } else { - var safeCell = cell - safeCell.colspan = 1 - safeCells.append(safeCell) - break - } - } - truncated.setCells(safeCells) - safeRows.append(truncated) - } else { - safeRows.append(row) - } - } - var table = table - table.body.setRows(safeRows) - defaultVisit(table) - } } // Semantic handling From 43efc750b5b0892bfecaf1c92209e444d1cd5de7 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 20 Nov 2025 19:23:31 +0000 Subject: [PATCH 33/59] Updates to handle removed bundle parameter --- .../Converter/DocumentationContextConverter.swift | 1 - .../Infrastructure/ConvertActionConverter.swift | 2 +- .../Translation/MarkdownOutputMarkdownWalker.swift | 12 +++++------- .../Translation/MarkdownOutputNodeTranslator.swift | 4 ++-- .../Translation/MarkdownOutputSemanticVisitor.swift | 13 ++++++------- .../Rendering/Markdown/MarkdownOutputTests.swift | 2 +- 6 files changed, 15 insertions(+), 19 deletions(-) diff --git a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift index d30a22e0a0..369bf7eda8 100644 --- a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift +++ b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift @@ -125,7 +125,6 @@ public class DocumentationContextConverter { var translator = MarkdownOutputNodeTranslator( context: context, - bundle: bundle, node: node ) return translator.createOutput() diff --git a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift index fcfd297851..bf562a2232 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift @@ -78,7 +78,7 @@ package enum ConvertActionConverter { var assets = [RenderReferenceType : [any RenderReference]]() var coverageInfo = [CoverageDataEntry]() let coverageFilterClosure = documentationCoverageOptions.generateFilterClosure() - var markdownManifest = MarkdownOutputManifest(title: bundle.displayName, documents: []) + var markdownManifest = MarkdownOutputManifest(title: context.inputs.displayName, documents: []) // An inner function to gather problems for errors encountered during the conversion. // diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index c47edb4835..4d9794cc02 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -13,12 +13,10 @@ import Markdown /// Performs any markup processing necessary to build the final output markdown internal struct MarkdownOutputMarkupWalker: MarkupWalker { let context: DocumentationContext - let bundle: DocumentationBundle let identifier: ResolvedTopicReference - init(context: DocumentationContext, bundle: DocumentationBundle, identifier: ResolvedTopicReference) { + init(context: DocumentationContext, identifier: ResolvedTopicReference) { self.context = context - self.bundle = bundle self.identifier = identifier } @@ -127,7 +125,7 @@ extension MarkdownOutputMarkupWalker { filename = first.lastPathComponent } - markdown.append("![\(image.altText ?? "")](images/\(bundle.id)/\(filename))") + markdown.append("![\(image.altText ?? "")](images/\(context.inputs.id)/\(filename))") } mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> () { @@ -227,7 +225,7 @@ extension MarkdownOutputMarkupWalker { } mutating func visitBlockDirective(_ blockDirective: BlockDirective) -> () { - + let bundle = context.inputs switch blockDirective.name { case VideoMedia.directiveName: guard let video = VideoMedia(from: blockDirective, for: bundle) else { @@ -331,7 +329,7 @@ extension MarkdownOutputMarkupWalker { filename = first.lastPathComponent } - markdown.append("\n\n![\(video.altText ?? "")](videos/\(bundle.id)/\(filename))") + markdown.append("\n\n![\(video.altText ?? "")](videos/\(context.inputs.id)/\(filename))") visit(container: video.caption) } @@ -341,7 +339,7 @@ extension MarkdownOutputMarkupWalker { 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))") + markdown.append("\n\n![\(image.altText ?? "")](images/\(context.inputs.id)/\(filename))") } mutating func visit(_ code: Code) -> Void { diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift index 6cfc09d9eb..e8a8f57abb 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift @@ -15,8 +15,8 @@ internal struct MarkdownOutputNodeTranslator { var visitor: MarkdownOutputSemanticVisitor - init(context: DocumentationContext, bundle: DocumentationBundle, node: DocumentationNode) { - self.visitor = MarkdownOutputSemanticVisitor(context: context, bundle: bundle, node: node) + init(context: DocumentationContext, node: DocumentationNode) { + self.visitor = MarkdownOutputSemanticVisitor(context: context, node: node) } mutating func createOutput() -> CollectedMarkdownOutput? { diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 27b576d3b9..2c4bb6bdea 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -12,18 +12,16 @@ internal struct MarkdownOutputSemanticVisitor: SemanticVisitor { let context: DocumentationContext - let bundle: DocumentationBundle let documentationNode: DocumentationNode let identifier: ResolvedTopicReference var markdownWalker: MarkdownOutputMarkupWalker var manifest: MarkdownOutputManifest? - init(context: DocumentationContext, bundle: DocumentationBundle, node: DocumentationNode) { + init(context: DocumentationContext, node: DocumentationNode) { self.context = context - self.bundle = bundle self.documentationNode = node self.identifier = node.reference - self.markdownWalker = MarkdownOutputMarkupWalker(context: context, bundle: bundle, identifier: identifier) + self.markdownWalker = MarkdownOutputMarkupWalker(context: context, identifier: identifier) } typealias Result = MarkdownOutputNode? @@ -77,7 +75,7 @@ extension MarkdownOutputSemanticVisitor { extension MarkdownOutputSemanticVisitor { mutating func visitArticle(_ article: Article) -> MarkdownOutputNode? { - var metadata = MarkdownOutputNode.Metadata(documentType: .article, bundle: bundle, reference: identifier) + var metadata = MarkdownOutputNode.Metadata(documentType: .article, bundle: context.inputs, reference: identifier) if let title = article.title?.plainText { metadata.title = title } @@ -88,7 +86,7 @@ extension MarkdownOutputSemanticVisitor { title: metadata.title ) - manifest = MarkdownOutputManifest(title: bundle.displayName, documents: [document]) + manifest = MarkdownOutputManifest(title: context.inputs.displayName, documents: [document]) if let metadataAvailability = article.metadata?.availability, @@ -118,6 +116,7 @@ import Markdown extension MarkdownOutputSemanticVisitor { mutating func visitSymbol(_ symbol: Symbol) -> MarkdownOutputNode? { + let bundle = context.inputs var metadata = MarkdownOutputNode.Metadata(documentType: .symbol, bundle: bundle, reference: identifier) metadata.symbol = .init(symbol, context: context, bundle: bundle) @@ -263,7 +262,7 @@ extension MarkdownOutputSemanticVisitor { } mutating func visitTutorial(_ tutorial: Tutorial) -> MarkdownOutputNode? { - var metadata = MarkdownOutputNode.Metadata(documentType: .tutorial, bundle: bundle, reference: identifier) + var metadata = MarkdownOutputNode.Metadata(documentType: .tutorial, bundle: context.inputs, reference: identifier) if tutorial.intro.title.isEmpty == false { metadata.title = tutorial.intro.title diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 56988ff00a..08a9490429 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -27,7 +27,7 @@ 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) + var translator = MarkdownOutputNodeTranslator(context: context, node: node) let output = try XCTUnwrap(translator.createOutput()) let manifest = try XCTUnwrap(output.manifest) return (output.node, manifest) From 8c15b0344020747cf7b8256c17049b70c68e2fc9 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Wed, 3 Dec 2025 10:10:36 +0000 Subject: [PATCH 34/59] Extract common writer code into a helper function --- .../JSONEncodingRenderNodeWriter.swift | 65 ++++++------------- 1 file changed, 21 insertions(+), 44 deletions(-) diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift index 7baa00a577..d737abec61 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift @@ -48,37 +48,9 @@ class JSONEncodingRenderNodeWriter { /// - renderNode: The node which the writer object writes to a JSON file. /// - encoder: The encoder to serialize the render node with. func write(_ renderNode: RenderNode, encoder: JSONEncoder) throws { - let fileSafePath = NodeURLGenerator.fileSafeReferencePath( - renderNode.identifier, - lowercased: true - ) // The path on disk to write the render node JSON file at. - let renderNodeTargetFileURL = renderNodeURLGenerator - .urlForReference( - renderNode.identifier, - fileSafePath: fileSafePath - ) - .appendingPathExtension("json") - - let renderNodeTargetFolderURL = renderNodeTargetFileURL.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 (insertedRenderNodeTargetFolderURL, _) = directoryIndex.insert(renderNodeTargetFolderURL) - if insertedRenderNodeTargetFolderURL { - try fileManager.createDirectory( - at: renderNodeTargetFolderURL, - withIntermediateDirectories: true, - attributes: nil - ) - } - } - + let (fileSafePath, renderNodeTargetFileURL) = try targetFilePathAndURL(for: renderNode.identifier, pathExtension: "json") let data = try renderNode.encodeToJSON(with: encoder, renderReferenceCache: renderReferenceCache) try fileManager.createFile(at: renderNodeTargetFileURL, contents: data, options: nil) @@ -117,47 +89,52 @@ class JSONEncodingRenderNodeWriter { } } - // 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. + /// If the target path to the markdown 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 markdown file. /// /// - Parameters: /// - markdownNode: The node which the writer object writes func write(_ markdownNode: WritableMarkdownOutputNode) throws { - + + // The path on disk to write the markdown file at. + let (_, markdownNodeTargetFileURL) = try targetFilePathAndURL(for: markdownNode.identifier, pathExtension: "md") + try fileManager.createFile(at: markdownNodeTargetFileURL, contents: markdownNode.nodeData, options: nil) + } + + /// Returns the target URL for a given reference identifier, safely creating the containing directory structure if necessary + private func targetFilePathAndURL(for identifier: ResolvedTopicReference, pathExtension: String) throws -> (fileSafePath: String, targetURL: URL) { let fileSafePath = NodeURLGenerator.fileSafeReferencePath( - markdownNode.identifier, + identifier, lowercased: true ) - // The path on disk to write the markdown file at. - let markdownNodeTargetFileURL = renderNodeURLGenerator + // The path on disk to write the file at. + let targetFileURL = renderNodeURLGenerator .urlForReference( - markdownNode.identifier, + identifier, fileSafePath: fileSafePath ) - .appendingPathExtension("md") - - let markdownNodeTargetFolderURL = markdownNodeTargetFileURL.deletingLastPathComponent() + .appendingPathExtension(pathExtension) + let targetFolderURL = targetFileURL.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(markdownNodeTargetFolderURL) - if insertedMarkdownNodeTargetFolderURL { + let (insertedTargetFolderURL, _) = directoryIndex.insert(targetFolderURL) + if insertedTargetFolderURL { try fileManager.createDirectory( - at: markdownNodeTargetFolderURL, + at: targetFolderURL, withIntermediateDirectories: true, attributes: nil ) } } - try fileManager.createFile(at: markdownNodeTargetFileURL, contents: markdownNode.nodeData, options: nil) + return (fileSafePath, targetFileURL) } } From 4bd8267e09809479c38d6988c80b1c59966b6ca1 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Wed, 3 Dec 2025 13:11:09 +0000 Subject: [PATCH 35/59] Clarify use of article.md in test catalog --- .../Rendering/Markdown/MarkdownOutputTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 08a9490429..957b915118 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -38,7 +38,9 @@ final class MarkdownOutputTests: XCTestCase { TextFile(name: "Article.md", utf8Content: """ # Article - A mostly empty article to make sure paths are formatted correctly + A mostly empty article to make sure paths are formatted correctly. + + If we create a test catalog with a single file, then the reference for that file is doc://MarkdownOutput/documentation/FileName, instead of doc://MarkdownOutput/documentation/MarkdownOutput/Filename ## Overview From 4a8b19616d8ca8c8126c0f03e648fef06b107ce3 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Wed, 3 Dec 2025 14:57:38 +0000 Subject: [PATCH 36/59] Deal with internal links with anchor tags --- .../MarkdownOutputMarkdownWalker.swift | 35 ++++++++++++++++--- .../Markdown/MarkdownOutputTests.swift | 24 +++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index 4d9794cc02..2d14ac9038 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -171,16 +171,36 @@ extension MarkdownOutputMarkupWalker { } mutating func visitLink(_ link: Link) -> () { + guard link.isAutolink, let destination = link.destination, - let resolved = context.referenceIndex[destination], - let doc = try? context.entity(with: resolved) + let resolved = context.referenceIndex[destination] else { return defaultVisit(link) } - let linkTitle: String + let doc: DocumentationNode + let anchorSection: AnchorSection? + + // Does the link have a fragment? + if let _ = resolved.fragment { + let noFragment = resolved.withFragment(nil) + guard let parent = try? context.entity(with: noFragment) else { + return defaultVisit(link) + } + doc = parent + anchorSection = doc.anchorSections.first(where: { $0.reference == resolved }) + } else { + anchorSection = nil + if let found = try? context.entity(with: resolved) { + doc = found + } else { + return defaultVisit(link) + } + } + + var linkTitle: String var linkListAbstract: (any Markup)? if let article = doc.semantic as? Article @@ -189,9 +209,14 @@ extension MarkdownOutputMarkupWalker { linkListAbstract = article.abstract add(source: resolved, type: .belongsToTopic, subtype: nil) } - linkTitle = article.title?.plainText ?? resolved.lastPathComponent + linkTitle = anchorSection?.title ?? article.title?.plainText ?? resolved.lastPathComponent } else { - linkTitle = resolved.lastPathComponent + linkTitle = anchorSection?.title ?? resolved.lastPathComponent + } + + // No abstract for an anchor link + if anchorSection != nil { + linkListAbstract = nil } let linkMarkup: any RecurringInlineMarkup diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 957b915118..e59490c128 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -84,6 +84,14 @@ final class MarkdownOutputTests: XCTestCase { # Rows and Columns Just here for the links + + ## Overview + + I am the overview + + ## Multi-word heading + + I am here to test the url readable fragment stuff """), TextFile(name: "Links.md", utf8Content: """ # Links @@ -93,12 +101,19 @@ final class MarkdownOutputTests: XCTestCase { ## Overview This is an inline link: + This is an inline link with a heading: + This is an inline link with a multi-word heading: ## Topics ### Links with abstracts - + - + + ### No more links + + Empty section """) ]) @@ -106,8 +121,17 @@ final class MarkdownOutputTests: XCTestCase { let expectedInline = "inline link: [Rows and Columns](doc://MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns)" XCTAssert(node.markdown.contains(expectedInline)) + let expectedInlineAnchor = "inline link with a heading: [Overview](doc://MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns#Overview)" + XCTAssert(node.markdown.contains(expectedInlineAnchor)) + let expectedInlineAnchorMultiWord = "inline link with a multi-word heading: [Multi-word heading](doc://MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns#Multi-word-heading)" + XCTAssert(node.markdown.contains(expectedInlineAnchorMultiWord)) + let expectedLinkList = "[Rows and Columns](doc://MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns)\n\nJust here for the links" XCTAssert(node.markdown.contains(expectedLinkList)) + + // No abstract + let expectedLinkListAnchor = "[Overview](doc://MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns#Overview)\n\n###" + XCTAssert(node.markdown.contains(expectedLinkListAnchor)) } func testLinkSymbolFormatting() async throws { From fc6cf5c32371764e897ee93398a9028a9bb3f029 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Wed, 3 Dec 2025 15:41:30 +0000 Subject: [PATCH 37/59] Deal with unresolved symbol links --- .../Translation/MarkdownOutputMarkdownWalker.swift | 12 ++++++++++-- .../Rendering/Markdown/MarkdownOutputTests.swift | 11 +++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index 2d14ac9038..1854e9c677 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -134,12 +134,20 @@ extension MarkdownOutputMarkupWalker { } mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> () { + guard let destination = symbolLink.destination else { + return + } + guard - let destination = symbolLink.destination, let resolved = context.referenceIndex[destination], let node = context.topicGraph.nodeWithReference(resolved) else { - return defaultVisit(symbolLink) + // Unresolved symbol - use code voice, unless we're in a list, in which case, ignore it + if isRenderingLinkList { + return + } + let code = InlineCode(destination) + return visit(code) } let linkTitle: String diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index e59490c128..32d645443b 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -144,12 +144,16 @@ final class MarkdownOutputTests: XCTestCase { ## Overview This is an inline link: ``MarkdownSymbol`` + + This is an unresolvable link: ``Unresolvable`` ## Topics ### Links with abstracts - ``MarkdownSymbol`` + - ``UnresolvableInList`` + """), 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") @@ -162,6 +166,13 @@ final class MarkdownOutputTests: XCTestCase { let expectedLinkList = "[`MarkdownSymbol`](doc://MarkdownOutput/documentation/MarkdownOutput/MarkdownSymbol)\n\nA basic symbol to test markdown output" XCTAssert(node.markdown.contains(expectedLinkList)) + + let unresolvableLink = "[`Unresolvable`]" + XCTAssertFalse(node.markdown.contains(unresolvableLink)) + let unresolvableAsCodeVoice = "unresolvable link: `Unresolvable`" + XCTAssert(node.markdown.contains(unresolvableAsCodeVoice)) + XCTAssertFalse(node.markdown.contains("UnresolvableInList")) + } func testLinkSymbolWithLinkInAbstractDoesntRecurse() async throws { From 778d33a8dac5727a225ee9c5560cc213c60c743e Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Mon, 8 Dec 2025 12:18:38 +0000 Subject: [PATCH 38/59] Updates to improve public API / clarity of MarkdownOutputNode --- .../MarkdownOutputNodeTranslator.swift | 2 +- .../MarkdownOutputSemanticVisitor.swift | 2 +- .../MarkdownOutputNode.swift | 64 +++++++++++-------- .../Markdown/MarkdownOutputTests.swift | 4 +- 4 files changed, 41 insertions(+), 31 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift index e8a8f57abb..1644dcc99f 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift @@ -34,7 +34,7 @@ struct CollectedMarkdownOutput { var writable: WritableMarkdownOutputNode { get throws { - WritableMarkdownOutputNode(identifier: identifier, nodeData: try node.data) + WritableMarkdownOutputNode(identifier: identifier, nodeData: try node.generateDataRepresentation()) } } } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 2c4bb6bdea..efd0a7af34 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -217,7 +217,7 @@ extension MarkdownOutputNode.Metadata.Symbol { modules.append(extended) } self.init( - kind: symbol.kind.identifier.identifier, + kindDisplayName: symbol.kind.displayName, preciseIdentifier: symbol.externalID ?? "", modules: modules ) diff --git a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift index 1966c08dd5..3ba9ebd332 100644 --- a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift @@ -30,7 +30,7 @@ public struct MarkdownOutputNode: Sendable { extension MarkdownOutputNode { public struct Metadata: Codable, Sendable { - static let version = "0.1.0" + static let version = SemanticVersion(major: 0, minor: 1, patch: 0) public enum DocumentType: String, Codable, Sendable { case article, tutorial, symbol @@ -39,17 +39,17 @@ extension MarkdownOutputNode { public struct Availability: Codable, Equatable, Sendable { public let platform: String + /// A string representation of the introduced version public let introduced: String? + /// A string representation of the deprecated version public let deprecated: String? public let unavailable: Bool public init(platform: String, introduced: String? = nil, deprecated: String? = nil, unavailable: Bool) { self.platform = platform - // Can't have deprecated without an introduced - self.introduced = introduced ?? deprecated + self.introduced = introduced self.deprecated = deprecated - // If no introduced, we are unavailable - self.unavailable = unavailable || introduced == nil + self.unavailable = unavailable } // For a compact representation on-disk and for human and machine readers, availability is stored as a single string: @@ -86,7 +86,7 @@ extension MarkdownOutputNode { public init(stringRepresentation: String) { let words = stringRepresentation.split(separator: ":", maxSplits: 1) - if words.count != 2 { + guard words.count == 2 else { platform = stringRepresentation unavailable = true introduced = nil @@ -112,18 +112,24 @@ extension MarkdownOutputNode { } public struct Symbol: Codable, Sendable { - public let kind: String + public let kindDisplayName: String public let preciseIdentifier: String public let modules: [String] + public enum CodingKeys: String, CodingKey { + case kindDisplayName = "kind" + case preciseIdentifier + case modules + } - public init(kind: String, preciseIdentifier: String, modules: [String]) { - self.kind = kind + public init(kindDisplayName: String, preciseIdentifier: String, modules: [String]) { + self.kindDisplayName = kindDisplayName self.preciseIdentifier = preciseIdentifier self.modules = modules } } - + + /// A string representation of the metadata version public let metadataVersion: String public let documentType: DocumentType public var role: String? @@ -135,7 +141,7 @@ extension MarkdownOutputNode { public init(documentType: DocumentType, uri: String, title: String, framework: String) { self.documentType = documentType - self.metadataVersion = Self.version + self.metadataVersion = Self.version.stringRepresentation() self.uri = uri self.title = title self.framework = framework @@ -149,35 +155,36 @@ extension MarkdownOutputNode { // 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 - } + /// Data for this node to be rendered to disk as a markdown file. This method renders the metadata as a JSON header wrapped in an HTML comment block, then includes the document content. + public func generateDataRepresentation() throws -> Data { + 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 { + public enum MarkdownOutputNodeDecodingError: DescribedError { case metadataSectionNotFound case metadataDecodingFailed(any Error) + case markdownSectionDecodingFailed - var localizedDescription: String { + public var errorDescription: String { switch self { case .metadataSectionNotFound: "The data did not contain a metadata section." case .metadataDecodingFailed(let error): "Metadata decoding failed: \(error.localizedDescription)" + case .markdownSectionDecodingFailed: + "Markdown section was not UTF-8 encoded" } } } @@ -194,6 +201,9 @@ extension MarkdownOutputNode { throw MarkdownOutputNodeDecodingError.metadataDecodingFailed(error) } - self.markdown = String(data: data[close.endIndex...], encoding: .utf8) ?? "" + guard let markdown = String(data: data[close.endIndex...], encoding: .utf8) else { + throw MarkdownOutputNodeDecodingError.markdownSectionDecodingFailed + } + self.markdown = markdown } } diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 32d645443b..6a996a34b7 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -618,7 +618,7 @@ final class MarkdownOutputTests: XCTestCase { ]) let (node, _) = try await markdownOutput(catalog: catalog, path: "MarkdownSymbol/init(name:)") XCTAssert(node.metadata.title == "init(name:)") - XCTAssert(node.metadata.symbol?.kind == "init") + XCTAssert(node.metadata.symbol?.kindDisplayName == "Initializer") XCTAssert(node.metadata.role == "Initializer") XCTAssertEqual(node.metadata.symbol?.modules, ["MarkdownOutput"]) } @@ -848,7 +848,7 @@ final class MarkdownOutputTests: XCTestCase { ]) let (node, _) = try await markdownOutput(catalog: catalog, path: "MarkdownSymbol") - let data = try node.data + let data = try node.generateDataRepresentation() let fromData = try MarkdownOutputNode(data) XCTAssertEqual(node.markdown, fromData.markdown) XCTAssertEqual(node.metadata.uri, fromData.metadata.uri) From e23115aa96c2a2505b09afeddaee5b1f9be4af2c Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Mon, 8 Dec 2025 12:47:33 +0000 Subject: [PATCH 39/59] Improve public API of manifest, sort contents before writing to prevent unneccessary diffs on re-run --- .../Model/Rendering/SemanticVersion.swift | 2 +- .../MarkdownOutputManifest.swift | 31 ++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/SemanticVersion.swift b/Sources/SwiftDocC/Model/Rendering/SemanticVersion.swift index e69c56c010..c7fb368ea4 100644 --- a/Sources/SwiftDocC/Model/Rendering/SemanticVersion.swift +++ b/Sources/SwiftDocC/Model/Rendering/SemanticVersion.swift @@ -11,7 +11,7 @@ /// A semantic version. /// /// A version that follows the [Semantic Versioning](https://semver.org) specification. -public struct SemanticVersion: Codable, Equatable, Comparable, CustomStringConvertible { +public struct SemanticVersion: Codable, Equatable, Comparable, CustomStringConvertible, Sendable { /// The major version number. /// diff --git a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift index ee9358a4f8..3bb06c8277 100644 --- a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift +++ b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift @@ -15,10 +15,10 @@ import Foundation /// A manifest of markdown-generated documentation from a single catalog @_spi(MarkdownOutput) public struct MarkdownOutputManifest: Codable, Sendable { - public static let version = "0.1.0" + public static let version = SemanticVersion(major: 0, minor: 1, patch: 0) /// The version of this manifest - public let manifestVersion: String + public let manifestVersion: SemanticVersion /// 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 @@ -32,6 +32,14 @@ public struct MarkdownOutputManifest: Codable, Sendable { self.documents = documents self.relationships = relationships } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(manifestVersion, forKey: .manifestVersion) + try container.encode(title, forKey: .title) + try container.encode(documents.sorted(), forKey: .documents) + try container.encode(relationships.sorted(), forKey: .relationships) + } } extension MarkdownOutputManifest { @@ -50,7 +58,7 @@ extension MarkdownOutputManifest { /// 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 struct Relationship: Codable, Hashable, Sendable, Comparable { public let sourceURI: String public let relationshipType: RelationshipType @@ -63,9 +71,20 @@ extension MarkdownOutputManifest { self.subtype = subtype self.targetURI = targetURI } + + public static func < (lhs: MarkdownOutputManifest.Relationship, rhs: MarkdownOutputManifest.Relationship) -> Bool { + if lhs.sourceURI < rhs.sourceURI { + return true + } else if lhs.sourceURI == rhs.sourceURI { + return lhs.targetURI < rhs.targetURI + } else { + return false + } + } } - public struct Document: Codable, Hashable, Sendable { + public struct Document: Codable, Hashable, Sendable, Comparable { + /// The URI of the document public let uri: String /// The type of the document @@ -82,6 +101,10 @@ extension MarkdownOutputManifest { public func hash(into hasher: inout Hasher) { hasher.combine(uri) } + + public static func < (lhs: MarkdownOutputManifest.Document, rhs: MarkdownOutputManifest.Document) -> Bool { + lhs.uri < rhs.uri + } } public func children(of parent: Document) -> Set { From 365956075771c2a9582110fc46aa23a0ff2b80a8 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Mon, 8 Dec 2025 12:48:24 +0000 Subject: [PATCH 40/59] Don't use unstructured `Data` for WritableMarkdownOutputNode --- .../SwiftDocC/Infrastructure/ConvertOutputConsumer.swift | 1 - .../Translation/MarkdownOutputNodeTranslator.swift | 6 ++---- .../Actions/Convert/JSONEncodingRenderNodeWriter.swift | 3 ++- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift index 35a82d19b8..2888a0d33c 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift @@ -53,7 +53,6 @@ public protocol ConvertOutputConsumer { } -// Merge into ConvertOutputMarkdownConsumer when no longer SPI @_spi(MarkdownOutput) public protocol ConvertOutputMarkdownConsumer { /// Consumes a markdown output node diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift index 1644dcc99f..b3de623a71 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift @@ -33,16 +33,14 @@ struct CollectedMarkdownOutput { let manifest: MarkdownOutputManifest? var writable: WritableMarkdownOutputNode { - get throws { - WritableMarkdownOutputNode(identifier: identifier, nodeData: try node.generateDataRepresentation()) - } + WritableMarkdownOutputNode(identifier: identifier, node: node) } } @_spi(MarkdownOutput) public struct WritableMarkdownOutputNode { public let identifier: ResolvedTopicReference - public let nodeData: Data + public let node: MarkdownOutputNode } extension MarkdownOutputManifest { diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift index d737abec61..da514ed745 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift @@ -100,7 +100,8 @@ class JSONEncodingRenderNodeWriter { // The path on disk to write the markdown file at. let (_, markdownNodeTargetFileURL) = try targetFilePathAndURL(for: markdownNode.identifier, pathExtension: "md") - try fileManager.createFile(at: markdownNodeTargetFileURL, contents: markdownNode.nodeData, options: nil) + let data = try markdownNode.node.generateDataRepresentation() + try fileManager.createFile(at: markdownNodeTargetFileURL, contents: data, options: nil) } /// Returns the target URL for a given reference identifier, safely creating the containing directory structure if necessary From ff960cd8724d14ea8af5581125a18a27018a205f Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Mon, 8 Dec 2025 15:18:14 +0000 Subject: [PATCH 41/59] Reduce public SPI surface around markdown writing --- .../ConvertActionConverter.swift | 8 +++-- .../ConvertOutputConsumer.swift | 5 ++-- .../MarkdownOutputMarkdownWalker.swift | 1 + .../MarkdownOutputNodeTranslator.swift | 29 +++---------------- .../MarkdownOutputSemanticVisitor.swift | 5 ++-- .../Convert/ConvertFileWritingConsumer.swift | 10 +++++-- 6 files changed, 23 insertions(+), 35 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift index bf562a2232..0c79c8bdfa 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift @@ -121,7 +121,8 @@ package enum ConvertActionConverter { if FeatureFlags.current.isExperimentalMarkdownOutputEnabled, let markdownConsumer = outputConsumer as? (any ConvertOutputMarkdownConsumer), - let markdownNode = converter.markdownOutput(for: entity) { + let markdownNode = converter.markdownOutput(for: entity) + { try markdownConsumer.consume(markdownNode: markdownNode.writable) if FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled, @@ -241,8 +242,9 @@ package enum ConvertActionConverter { if FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled, - let markdownConsumer = outputConsumer as? (any ConvertOutputMarkdownConsumer) { - try markdownConsumer.consume(markdownManifest: try markdownManifest.writable) + let markdownConsumer = outputConsumer as? (any ConvertOutputMarkdownConsumer) + { + try markdownConsumer.consume(markdownManifest: markdownManifest) } switch documentationCoverageOptions.level { diff --git a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift index 2888a0d33c..0537a31fb0 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift @@ -53,13 +53,12 @@ public protocol ConvertOutputConsumer { } -@_spi(MarkdownOutput) -public protocol ConvertOutputMarkdownConsumer { +package protocol ConvertOutputMarkdownConsumer { /// Consumes a markdown output node func consume(markdownNode: WritableMarkdownOutputNode) throws /// Consumes a markdown output manifest - func consume(markdownManifest: WritableMarkdownOutputManifest) throws + func consume(markdownManifest: MarkdownOutputManifest) throws } // Default implementations that discard the documentation conversion products, for consumers that don't need these diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index 1854e9c677..dd034f7d6e 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -21,6 +21,7 @@ internal struct MarkdownOutputMarkupWalker: MarkupWalker { } var markdown = "" + // All references to other documents explicitly referenced in the text var outgoingReferences: Set = [] private(set) var indentationToRemove: String? diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift index b3de623a71..80b2e38bb1 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift @@ -8,7 +8,7 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -public import Foundation +import Foundation /// Creates ``CollectedMarkdownOutput`` from a ``DocumentationNode``. internal struct MarkdownOutputNodeTranslator { @@ -37,28 +37,7 @@ struct CollectedMarkdownOutput { } } -@_spi(MarkdownOutput) -public struct WritableMarkdownOutputNode { - public let identifier: ResolvedTopicReference - public let node: MarkdownOutputNode -} - -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 +package struct WritableMarkdownOutputNode { + package let identifier: ResolvedTopicReference + package let node: MarkdownOutputNode } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index efd0a7af34..35ab46da7d 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -155,6 +155,7 @@ extension MarkdownOutputSemanticVisitor { markdownWalker.visit(Heading(level: 1, Text(symbol.title))) markdownWalker.visit(symbol.abstract) + // Intentionally only including the primary declaration in the output, because we are only using the primary language. if let declarationFragments = symbol.declaration.first?.value.declarationFragments { let declaration = declarationFragments .map { $0.spelling } @@ -201,7 +202,7 @@ extension MarkdownOutputSemanticVisitor { import SymbolKit -extension MarkdownOutputNode.Metadata.Symbol { +private extension MarkdownOutputNode.Metadata.Symbol { init(_ symbol: SwiftDocC.Symbol, context: DocumentationContext, bundle: DocumentationBundle) { // Gather modules @@ -224,7 +225,7 @@ extension MarkdownOutputNode.Metadata.Symbol { } } -extension MarkdownOutputNode.Metadata.Availability { +private extension MarkdownOutputNode.Metadata.Availability { init(_ item: SymbolGraph.Symbol.Availability.AvailabilityItem) { self.init( platform: item.domain?.rawValue ?? "*", diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift index 5ed6c1b0f7..25a8d5118d 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift @@ -73,9 +73,15 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer, try renderNodeWriter.write(markdownNode) } - func consume(markdownManifest: WritableMarkdownOutputManifest) throws { + func consume(markdownManifest: MarkdownOutputManifest) throws { let url = targetFolder.appendingPathComponent("\(markdownManifest.title)-markdown-manifest.json", isDirectory: false) - try fileManager.createFile(at: url, contents: markdownManifest.manifestData) + 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) } func consume(externalRenderNode: ExternalRenderNode) throws { From 53ae33b55eb21362135258225e5d67b4daa64c6c Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Mon, 8 Dec 2025 16:03:58 +0000 Subject: [PATCH 42/59] Make relationship subtype a specific type rather than a string --- .../MarkdownOutputMarkdownWalker.swift | 2 +- .../MarkdownOutputSemanticVisitor.swift | 22 ++++++++++++++----- .../MarkdownOutputManifest.swift | 16 ++++++++++++-- .../Markdown/MarkdownOutputTests.swift | 10 ++++----- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index dd034f7d6e..2fe23be499 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -403,7 +403,7 @@ extension MarkdownOutputMarkupWalker { // MARK: - Manifest construction extension MarkdownOutputMarkupWalker { - mutating func add(source: ResolvedTopicReference, type: MarkdownOutputManifest.RelationshipType, subtype: String?) { + mutating func add(source: ResolvedTopicReference, type: MarkdownOutputManifest.RelationshipType, subtype: MarkdownOutputManifest.RelationshipSubType?) { var targetURI = identifier.path if let lastHeading { targetURI.append("#\(urlReadableFragment(lastHeading))") diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 35ab46da7d..723043f3e3 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -50,11 +50,11 @@ extension MarkdownOutputNode.Metadata { // MARK: - Manifest construction extension MarkdownOutputSemanticVisitor { - mutating func add(target: ResolvedTopicReference, type: MarkdownOutputManifest.RelationshipType, subtype: String?) { + mutating func add(target: ResolvedTopicReference, type: MarkdownOutputManifest.RelationshipType, subtype: MarkdownOutputManifest.RelationshipSubType?) { add(targetURI: target.path, type: type, subtype: subtype) } - mutating func add(fallbackTarget: String, type: MarkdownOutputManifest.RelationshipType, subtype: String?) { + mutating func add(fallbackTarget: String, type: MarkdownOutputManifest.RelationshipType, subtype: MarkdownOutputManifest.RelationshipSubType?) { let uri: String let components = fallbackTarget.components(separatedBy: ".") if components.count > 1 { @@ -65,7 +65,7 @@ extension MarkdownOutputSemanticVisitor { add(targetURI: uri, type: type, subtype: subtype) } - mutating func add(targetURI: String, type: MarkdownOutputManifest.RelationshipType, subtype: String?) { + mutating func add(targetURI: String, type: MarkdownOutputManifest.RelationshipType, subtype: MarkdownOutputManifest.RelationshipSubType?) { let relationship = MarkdownOutputManifest.Relationship(sourceURI: identifier.path, relationshipType: type, subtype: subtype, targetURI: targetURI) manifest?.relationships.insert(relationship) } @@ -188,10 +188,10 @@ extension MarkdownOutputSemanticVisitor { for destination in relationshipGroup.destinations { switch context.resolve(destination, in: identifier) { case .success(let resolved): - add(target: resolved, type: .relatedSymbol, subtype: relationshipGroup.kind.rawValue) + add(target: resolved, type: .relatedSymbol, subtype: relationshipGroup.kind.manifestRelationship) case .failure: if let fallback = symbol.relationships.targetFallbacks[destination] { - add(fallbackTarget: fallback, type: .relatedSymbol, subtype: relationshipGroup.kind.rawValue) + add(fallbackTarget: fallback, type: .relatedSymbol, subtype: relationshipGroup.kind.manifestRelationship) } } } @@ -202,6 +202,18 @@ extension MarkdownOutputSemanticVisitor { import SymbolKit +private extension RelationshipsGroup.Kind { + var manifestRelationship: MarkdownOutputManifest.RelationshipSubType? { + // Structured like this to cause a compiler error if a new case is added + switch self { + case .conformingTypes: .conformingTypes + case .conformsTo: .conformsTo + case .inheritsFrom: .inheritsFrom + case .inheritedBy: .inheritedBy + } + } +} + private extension MarkdownOutputNode.Metadata.Symbol { init(_ symbol: SwiftDocC.Symbol, context: DocumentationContext, bundle: DocumentationBundle) { diff --git a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift index 3bb06c8277..3aee9c74f7 100644 --- a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift +++ b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift @@ -55,6 +55,17 @@ extension MarkdownOutputManifest { case relatedSymbol } + public enum RelationshipSubType: String, Codable, Sendable { + /// One or more protocols to which a type conforms. + case conformsTo + /// One or more types that conform to a protocol. + case conformingTypes + /// One or more types that are parents of the symbol. + case inheritsFrom + /// One or more types that are children of the symbol. + case inheritedBy + } + /// 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:)``. @@ -62,10 +73,10 @@ extension MarkdownOutputManifest { public let sourceURI: String public let relationshipType: RelationshipType - public let subtype: String? + public let subtype: RelationshipSubType? public let targetURI: String - public init(sourceURI: String, relationshipType: MarkdownOutputManifest.RelationshipType, subtype: String? = nil, targetURI: String) { + public init(sourceURI: String, relationshipType: MarkdownOutputManifest.RelationshipType, subtype: RelationshipSubType? = nil, targetURI: String) { self.sourceURI = sourceURI self.relationshipType = relationshipType self.subtype = subtype @@ -98,6 +109,7 @@ extension MarkdownOutputManifest { self.title = title } + // The URI is a unique identifier so is the only thing included in the hash public func hash(into hasher: inout Hasher) { hasher.combine(uri) } diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 6a996a34b7..c3dcdfb96e 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -959,13 +959,13 @@ final class MarkdownOutputTests: XCTestCase { 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" + $0.targetURI == "/documentation/MarkdownOutput/LocalSuperclass" && $0.subtype == .inheritsFrom })) 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" + $0.targetURI == "/documentation/MarkdownOutput/LocalSubclass" && $0.subtype == .inheritedBy })) } @@ -990,19 +990,19 @@ final class MarkdownOutputTests: XCTestCase { 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" + $0.targetURI == "/documentation/MarkdownOutput/LocalProtocol" && $0.subtype == .conformsTo })) 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" + $0.targetURI == "/documentation/MarkdownOutput/LocalConformer" && $0.subtype == .conformingTypes })) 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" + $0.targetURI == "/documentation/Swift/Hashable" && $0.subtype == .conformsTo })) } } From c0d356cf548be108d03c26a90fb89e8c61b09ceb Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Mon, 8 Dec 2025 18:38:24 +0000 Subject: [PATCH 43/59] Make it clear that path component is a fallback title, harden linked list rendering --- .../MarkdownOutputMarkdownWalker.swift | 29 ++++++++++--------- .../MarkdownOutputSemanticVisitor.swift | 20 +++++-------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index 2fe23be499..e8c19b78dd 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -29,12 +29,13 @@ internal struct MarkdownOutputMarkupWalker: MarkupWalker { private var lastHeading: String? = nil /// Perform actions while rendering a link list, which affects the output formatting of links - mutating func withRenderingLinkList(_ process: (inout Self) -> Void) { - isRenderingLinkList = true + mutating func withRenderingLinkList(value: Bool = true, _ process: (inout Self) -> Void) { + let previous = isRenderingLinkList + isRenderingLinkList = value process(&self) - isRenderingLinkList = false + isRenderingLinkList = previous } - + /// Perform actions while removing a base level of indentation, typically while processing the contents of block directives. mutating func withRemoveIndentation(from base: (any Markup)?, process: (inout Self) -> Void) { indentationToRemove = nil @@ -172,11 +173,10 @@ extension MarkdownOutputMarkupWalker { } let link = Link(destination: destination, title: linkTitle, [InlineCode(linkTitle)]) // Only perform the linked list rendering for the first thing you find - let previous = isRenderingLinkList - isRenderingLinkList = false - visit(link) - visit(linkListAbstract) - isRenderingLinkList = previous + withRenderingLinkList(value: false) { + $0.visit(link) + $0.visit(linkListAbstract) + } } mutating func visitLink(_ link: Link) -> () { @@ -219,6 +219,8 @@ extension MarkdownOutputMarkupWalker { add(source: resolved, type: .belongsToTopic, subtype: nil) } linkTitle = anchorSection?.title ?? article.title?.plainText ?? resolved.lastPathComponent + } else if let symbol = doc.semantic as? Symbol { + linkTitle = anchorSection?.title ?? symbol.title } else { linkTitle = anchorSection?.title ?? resolved.lastPathComponent } @@ -237,11 +239,10 @@ extension MarkdownOutputMarkupWalker { let link = Link(destination: destination, title: linkTitle, [linkMarkup]) // Only perform the linked list rendering for the first thing you find - let previous = isRenderingLinkList - isRenderingLinkList = false - defaultVisit(link) - visit(linkListAbstract) - isRenderingLinkList = previous + withRenderingLinkList(value: false) { + $0.defaultVisit(link) + $0.visit(linkListAbstract) + } } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 723043f3e3..340f7df5b7 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -37,11 +37,11 @@ internal struct MarkdownOutputSemanticVisitor: SemanticVisitor { } extension MarkdownOutputNode.Metadata { - init(documentType: DocumentType, bundle: DocumentationBundle, reference: ResolvedTopicReference) { + init(documentType: DocumentType, bundle: DocumentationBundle, reference: ResolvedTopicReference, title: String) { self.init( documentType: documentType, uri: reference.path, - title: reference.lastPathComponent, + title: title, framework: bundle.displayName ) } @@ -75,11 +75,8 @@ extension MarkdownOutputSemanticVisitor { extension MarkdownOutputSemanticVisitor { mutating func visitArticle(_ article: Article) -> MarkdownOutputNode? { - var metadata = MarkdownOutputNode.Metadata(documentType: .article, bundle: context.inputs, reference: identifier) - if let title = article.title?.plainText { - metadata.title = title - } - + var metadata = MarkdownOutputNode.Metadata(documentType: .article, bundle: context.inputs, reference: identifier, title: article.title?.plainText ?? identifier.lastPathComponent) + let document = MarkdownOutputManifest.Document( uri: identifier.path, documentType: .article, @@ -117,7 +114,7 @@ extension MarkdownOutputSemanticVisitor { mutating func visitSymbol(_ symbol: Symbol) -> MarkdownOutputNode? { let bundle = context.inputs - var metadata = MarkdownOutputNode.Metadata(documentType: .symbol, bundle: bundle, reference: identifier) + var metadata = MarkdownOutputNode.Metadata(documentType: .symbol, bundle: bundle, reference: identifier, title: symbol.title) metadata.symbol = .init(symbol, context: context, bundle: bundle) metadata.role = symbol.kind.displayName @@ -275,12 +272,9 @@ extension MarkdownOutputSemanticVisitor { } mutating func visitTutorial(_ tutorial: Tutorial) -> MarkdownOutputNode? { - var metadata = MarkdownOutputNode.Metadata(documentType: .tutorial, bundle: context.inputs, reference: identifier) + let title = tutorial.intro.title.isEmpty ? identifier.lastPathComponent : tutorial.intro.title + let metadata = MarkdownOutputNode.Metadata(documentType: .tutorial, bundle: context.inputs, reference: identifier, title: title) - if tutorial.intro.title.isEmpty == false { - metadata.title = tutorial.intro.title - } - let document = MarkdownOutputManifest.Document( uri: identifier.path, documentType: .tutorial, From a85e59fd218eae07e41bd373031f5b06acf07afb Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 9 Dec 2025 10:15:44 +0000 Subject: [PATCH 44/59] Add todos and missing tests --- .../MarkdownOutputMarkdownWalker.swift | 13 ++-- .../MarkdownOutputSemanticVisitor.swift | 6 +- .../MarkdownOutputManifest.swift | 10 +-- .../MarkdownOutputNode.swift | 2 - .../Markdown/MarkdownOutputTests.swift | 62 ++++++++++++++++++- 5 files changed, 77 insertions(+), 16 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index e8c19b78dd..57222290d3 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -105,6 +105,7 @@ extension MarkdownOutputMarkupWalker { } mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> () { + //TODO: support formatting of term lists rdar://166128254 guard isRenderingLinkList else { return defaultVisit(unorderedList) } @@ -121,13 +122,17 @@ extension MarkdownOutputMarkupWalker { 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 + let resolved = context.resolveAsset(named: unescaped, in: identifier, withType: .image), + let first = resolved.variants.first?.value, + first.isFileURL + { + let filename = first.lastPathComponent + markdown.append("![\(image.altText ?? "")](images/\(context.inputs.id)/\(filename))") + } else { + markdown.append(image.format()) } - markdown.append("![\(image.altText ?? "")](images/\(context.inputs.id)/\(filename))") } mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> () { diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 340f7df5b7..9d352f267b 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -87,7 +87,8 @@ extension MarkdownOutputSemanticVisitor { if let metadataAvailability = article.metadata?.availability, - !metadataAvailability.isEmpty { + !metadataAvailability.isEmpty + { metadata.availability = metadataAvailability.map { .init($0) } } metadata.role = DocumentationContentRenderer.roleForArticle(article, nodeKind: documentationNode.kind).rawValue @@ -193,7 +194,9 @@ extension MarkdownOutputSemanticVisitor { } } } + // TODO: add support for missing sections rdar://166124742 return MarkdownOutputNode(metadata: metadata, markdown: markdownWalker.markdown) + } } @@ -435,6 +438,7 @@ extension MarkdownOutputSemanticVisitor { return nil } + // TODO: Add support for tutorial articles rdar://166124907 mutating func visitTutorialArticle(_ article: TutorialArticle) -> MarkdownOutputNode? { return nil } diff --git a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift index 3aee9c74f7..55e88ede1f 100644 --- a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift +++ b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift @@ -10,8 +10,6 @@ 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 { @@ -108,17 +106,13 @@ extension MarkdownOutputManifest { self.documentType = documentType self.title = title } - - // The URI is a unique identifier so is the only thing included in the hash - public func hash(into hasher: inout Hasher) { - hasher.combine(uri) - } - + public static func < (lhs: MarkdownOutputManifest.Document, rhs: MarkdownOutputManifest.Document) -> Bool { lhs.uri < rhs.uri } } + /// All documents in the manifest that have a given document as a parent, e.g. Framework/Symbol/property is a child of Framework/Symbol public func children(of parent: Document) -> Set { let parentPrefix = parent.uri + "/" let prefixEnd = parentPrefix.endIndex diff --git a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift index 3ba9ebd332..7d4f9c5c2c 100644 --- a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift @@ -10,8 +10,6 @@ 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 { diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index c3dcdfb96e..449738fd10 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -460,7 +460,15 @@ final class MarkdownOutputTests: XCTestCase { let catalog = catalog(files: [articleWithSnippet, graph]) let (node, _) = try await markdownOutput(catalog: catalog, path: "SnippetArticle") - XCTAssert(node.markdown.contains(explanation)) + guard let codeRange = node.markdown.range(of: snippetContent) else { + XCTFail("Code not included in snippet output") + return + } + guard let explanationRange = node.markdown.range(of: explanation) else { + XCTFail("Explanation not included in snippet output") + return + } + XCTAssert(explanationRange.lowerBound < codeRange.lowerBound) } private func makeSnippet( @@ -521,6 +529,58 @@ final class MarkdownOutputTests: XCTestCase { XCTAssertEqual(node.markdown, expected) } + + func testImages() async throws { + let catalog = catalog(files: [ + TextFile(name: "ImageArticle.md", utf8Content: """ + # Images + + Shows how images are represented in markdown output + + ## Overview + + ![Alternative Title](image.png) + ![](image.png) + ![Web Image](https://www.example.com/webimage.png) + ![Unresolved Image](unresolved.png) + """), + Folder(name: "Resources", content: [ + Folder(name: "Images", content: [ + CopyOfFile(original: Bundle.module.url(forResource: "image", withExtension: "png", subdirectory: "Test Resources")!) + ]) + ]) + ]) + + let (node, _) = try await markdownOutput(catalog: catalog, path: "ImageArticle") + XCTAssert(node.markdown.contains("![Alternative Title](images/MarkdownOutput/image.png")) + XCTAssert(node.markdown.contains("![](images/MarkdownOutput/image.png")) + XCTAssert(node.markdown.contains("![Web Image](https://www.example.com/webimage.png)")) + XCTAssert(node.markdown.contains("![Unresolved Image](unresolved.png)")) + } + + func testAside() async throws { + let content = """ + # Asides + + Shows how asides are represented in markdown output + + ## Overview + + Here is some content + + > Tip: This is an aside + + Here is some post-aside content + """ + let catalog = catalog(files: [ + TextFile(name: "AsideArticle.md", utf8Content: content) + ]) + + let (node, _) = try await markdownOutput(catalog: catalog, path: "AsideArticle") + XCTAssertEqual(node.markdown, content) + } + + // MARK: - Metadata From 293246a3f9dc873fd9ae4e523c2c668ad19dbeb3 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Fri, 12 Dec 2025 12:12:07 +0000 Subject: [PATCH 45/59] Move markdown output types from public SPI to package --- .../MarkdownOutputManifest.swift | 51 +++++++------ .../MarkdownOutputNode.swift | 75 +++++++++---------- .../Convert/ConvertFileWritingConsumer.swift | 1 - .../JSONEncodingRenderNodeWriter.swift | 1 - .../Markdown/MarkdownOutputTests.swift | 1 - 5 files changed, 62 insertions(+), 67 deletions(-) diff --git a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift index 55e88ede1f..fca323a9d3 100644 --- a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift +++ b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift @@ -11,27 +11,26 @@ import Foundation /// A manifest of markdown-generated documentation from a single catalog -@_spi(MarkdownOutput) -public struct MarkdownOutputManifest: Codable, Sendable { - public static let version = SemanticVersion(major: 0, minor: 1, patch: 0) +package struct MarkdownOutputManifest: Codable, Sendable { + package static let version = SemanticVersion(major: 0, minor: 1, patch: 0) /// The version of this manifest - public let manifestVersion: SemanticVersion + package let manifestVersion: SemanticVersion /// The manifest title, this will typically match the module that the manifest is generated for - public let title: String + package let title: String /// All documents contained in the manifest - public var documents: Set + package var documents: Set /// Relationships involving documents in the manifest - public var relationships: Set + package var relationships: Set - public init(title: String, documents: Set = [], relationships: Set = []) { + package init(title: String, documents: Set = [], relationships: Set = []) { self.manifestVersion = Self.version self.title = title self.documents = documents self.relationships = relationships } - public func encode(to encoder: any Encoder) throws { + package func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(manifestVersion, forKey: .manifestVersion) try container.encode(title, forKey: .title) @@ -42,18 +41,18 @@ public struct MarkdownOutputManifest: Codable, Sendable { extension MarkdownOutputManifest { - public enum DocumentType: String, Codable, Sendable { + package enum DocumentType: String, Codable, Sendable { case article, tutorial, symbol } - public enum RelationshipType: String, Codable, Sendable { + package enum RelationshipType: String, Codable, Sendable { /// For this relationship, the source URI will be the URI of a document, and the target URI will be the topic to which it belongs case belongsToTopic /// For this relationship, the source and target URIs will be indicated by the directionality of the subtype, e.g. source "conformsTo" target. case relatedSymbol } - public enum RelationshipSubType: String, Codable, Sendable { + package enum RelationshipSubType: String, Codable, Sendable { /// One or more protocols to which a type conforms. case conformsTo /// One or more types that conform to a protocol. @@ -67,21 +66,21 @@ extension MarkdownOutputManifest { /// 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, Comparable { + package struct Relationship: Codable, Hashable, Sendable, Comparable { - public let sourceURI: String - public let relationshipType: RelationshipType - public let subtype: RelationshipSubType? - public let targetURI: String + package let sourceURI: String + package let relationshipType: RelationshipType + package let subtype: RelationshipSubType? + package let targetURI: String - public init(sourceURI: String, relationshipType: MarkdownOutputManifest.RelationshipType, subtype: RelationshipSubType? = nil, targetURI: String) { + package init(sourceURI: String, relationshipType: MarkdownOutputManifest.RelationshipType, subtype: RelationshipSubType? = nil, targetURI: String) { self.sourceURI = sourceURI self.relationshipType = relationshipType self.subtype = subtype self.targetURI = targetURI } - public static func < (lhs: MarkdownOutputManifest.Relationship, rhs: MarkdownOutputManifest.Relationship) -> Bool { + package static func < (lhs: MarkdownOutputManifest.Relationship, rhs: MarkdownOutputManifest.Relationship) -> Bool { if lhs.sourceURI < rhs.sourceURI { return true } else if lhs.sourceURI == rhs.sourceURI { @@ -92,28 +91,28 @@ extension MarkdownOutputManifest { } } - public struct Document: Codable, Hashable, Sendable, Comparable { + package struct Document: Codable, Hashable, Sendable, Comparable { /// The URI of the document - public let uri: String + package let uri: String /// The type of the document - public let documentType: DocumentType + package let documentType: DocumentType /// The title of the document - public let title: String + package let title: String - public init(uri: String, documentType: MarkdownOutputManifest.DocumentType, title: String) { + package init(uri: String, documentType: MarkdownOutputManifest.DocumentType, title: String) { self.uri = uri self.documentType = documentType self.title = title } - public static func < (lhs: MarkdownOutputManifest.Document, rhs: MarkdownOutputManifest.Document) -> Bool { + package static func < (lhs: MarkdownOutputManifest.Document, rhs: MarkdownOutputManifest.Document) -> Bool { lhs.uri < rhs.uri } } /// All documents in the manifest that have a given document as a parent, e.g. Framework/Symbol/property is a child of Framework/Symbol - public func children(of parent: Document) -> Set { + package func children(of parent: Document) -> Set { let parentPrefix = parent.uri + "/" let prefixEnd = parentPrefix.endIndex return documents.filter { document in diff --git a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift index 7d4f9c5c2c..43799bd88b 100644 --- a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift @@ -8,42 +8,41 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -public import Foundation +package import Foundation /// A markdown version of a documentation node. -@_spi(MarkdownOutput) -public struct MarkdownOutputNode: Sendable { +package struct MarkdownOutputNode: Sendable { /// The metadata about this node - public var metadata: Metadata + package var metadata: Metadata /// The markdown content of this node - public var markdown: String = "" + package var markdown: String = "" - public init(metadata: Metadata, markdown: String) { + package init(metadata: Metadata, markdown: String) { self.metadata = metadata self.markdown = markdown } } extension MarkdownOutputNode { - public struct Metadata: Codable, Sendable { + package struct Metadata: Codable, Sendable { static let version = SemanticVersion(major: 0, minor: 1, patch: 0) - public enum DocumentType: String, Codable, Sendable { + package enum DocumentType: String, Codable, Sendable { case article, tutorial, symbol } - public struct Availability: Codable, Equatable, Sendable { + package struct Availability: Codable, Equatable, Sendable { - public let platform: String + package let platform: String /// A string representation of the introduced version - public let introduced: String? + package let introduced: String? /// A string representation of the deprecated version - public let deprecated: String? - public let unavailable: Bool + package let deprecated: String? + package let unavailable: Bool - public init(platform: String, introduced: String? = nil, deprecated: String? = nil, unavailable: Bool) { + package init(platform: String, introduced: String? = nil, deprecated: String? = nil, unavailable: Bool) { self.platform = platform self.introduced = introduced self.deprecated = deprecated @@ -54,18 +53,18 @@ extension MarkdownOutputNode { // platform: introduced - (not deprecated) // platform: introduced - deprecated (deprecated) // platform: - (unavailable) - public func encode(to encoder: any Encoder) throws { + package func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() try container.encode(stringRepresentation) } - public init(from decoder: any Decoder) throws { + package init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() let stringRepresentation = try container.decode(String.self) self.init(stringRepresentation: stringRepresentation) } - public var stringRepresentation: String { + package var stringRepresentation: String { var stringRepresentation = "\(platform): " if unavailable { stringRepresentation += "-" @@ -82,7 +81,7 @@ extension MarkdownOutputNode { return stringRepresentation } - public init(stringRepresentation: String) { + package init(stringRepresentation: String) { let words = stringRepresentation.split(separator: ":", maxSplits: 1) guard words.count == 2 else { platform = stringRepresentation @@ -109,18 +108,18 @@ extension MarkdownOutputNode { } - public struct Symbol: Codable, Sendable { - public let kindDisplayName: String - public let preciseIdentifier: String - public let modules: [String] + package struct Symbol: Codable, Sendable { + package let kindDisplayName: String + package let preciseIdentifier: String + package let modules: [String] - public enum CodingKeys: String, CodingKey { + package enum CodingKeys: String, CodingKey { case kindDisplayName = "kind" case preciseIdentifier case modules } - public init(kindDisplayName: String, preciseIdentifier: String, modules: [String]) { + package init(kindDisplayName: String, preciseIdentifier: String, modules: [String]) { self.kindDisplayName = kindDisplayName self.preciseIdentifier = preciseIdentifier self.modules = modules @@ -128,16 +127,16 @@ extension MarkdownOutputNode { } /// A string representation of the metadata version - 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]? + package let metadataVersion: String + package let documentType: DocumentType + package var role: String? + package let uri: String + package var title: String + package let framework: String + package var symbol: Symbol? + package var availability: [Availability]? - public init(documentType: DocumentType, uri: String, title: String, framework: String) { + package init(documentType: DocumentType, uri: String, title: String, framework: String) { self.documentType = documentType self.metadataVersion = Self.version.stringRepresentation() self.uri = uri @@ -145,7 +144,7 @@ extension MarkdownOutputNode { self.framework = framework } - public func availability(for platform: String) -> Availability? { + package func availability(for platform: String) -> Availability? { availability?.first(where: { $0.platform == platform }) } } @@ -154,7 +153,7 @@ extension MarkdownOutputNode { // MARK: I/O extension MarkdownOutputNode { /// Data for this node to be rendered to disk as a markdown file. This method renders the metadata as a JSON header wrapped in an HTML comment block, then includes the document content. - public func generateDataRepresentation() throws -> Data { + package func generateDataRepresentation() throws -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] let metadata = try encoder.encode(metadata) @@ -169,13 +168,13 @@ extension MarkdownOutputNode { private static let commentOpen = "\n\n".utf8 - public enum MarkdownOutputNodeDecodingError: DescribedError { + package enum MarkdownOutputNodeDecodingError: DescribedError { case metadataSectionNotFound case metadataDecodingFailed(any Error) case markdownSectionDecodingFailed - public var errorDescription: String { + package var errorDescription: String { switch self { case .metadataSectionNotFound: "The data did not contain a metadata section." @@ -188,7 +187,7 @@ extension MarkdownOutputNode { } /// Recreates the node from the data exported in ``data`` - public init(_ data: Data) throws { + package 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 } diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift index 25a8d5118d..8c23c80293 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift @@ -10,7 +10,6 @@ import Foundation import SwiftDocC -@_spi(MarkdownOutput) import SwiftDocC struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer, ConvertOutputMarkdownConsumer { var targetFolder: URL diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift index da514ed745..428a09a464 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift @@ -10,7 +10,6 @@ import Foundation import SwiftDocC -@_spi(MarkdownOutput) import SwiftDocC /// An object that writes render nodes, as JSON files, into a target folder. /// diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 449738fd10..274bab93c7 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -12,7 +12,6 @@ import Foundation import XCTest import SwiftDocCTestUtilities import SymbolKit -@_spi(MarkdownOutput) import SwiftDocC @testable import SwiftDocC final class MarkdownOutputTests: XCTestCase { From 33fa2491842bcfdf08b1138997f6f68de17cedb9 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Mon, 15 Dec 2025 11:36:27 +0000 Subject: [PATCH 46/59] don't include scheme or host in documentation links --- .../MarkdownOutputMarkdownWalker.swift | 8 ++++---- .../Markdown/MarkdownOutputTests.swift | 18 +++++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index 57222290d3..1f883f51aa 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -187,7 +187,6 @@ extension MarkdownOutputMarkupWalker { mutating func visitLink(_ link: Link) -> () { guard - link.isAutolink, let destination = link.destination, let resolved = context.referenceIndex[destination] else { @@ -196,15 +195,16 @@ extension MarkdownOutputMarkupWalker { let doc: DocumentationNode let anchorSection: AnchorSection? - + var outputDestination = resolved.path // Does the link have a fragment? - if let _ = resolved.fragment { + if let fragment = resolved.fragment { let noFragment = resolved.withFragment(nil) guard let parent = try? context.entity(with: noFragment) else { return defaultVisit(link) } doc = parent anchorSection = doc.anchorSections.first(where: { $0.reference == resolved }) + outputDestination.append("#" + fragment) } else { anchorSection = nil if let found = try? context.entity(with: resolved) { @@ -242,7 +242,7 @@ extension MarkdownOutputMarkupWalker { linkMarkup = Text(linkTitle) } - let link = Link(destination: destination, title: linkTitle, [linkMarkup]) + let link = Link(destination: outputDestination, title: linkTitle, [linkMarkup]) // Only perform the linked list rendering for the first thing you find withRenderingLinkList(value: false) { $0.defaultVisit(link) diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 274bab93c7..b901da1a0e 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -117,19 +117,19 @@ final class MarkdownOutputTests: XCTestCase { ]) let (node, _) = try await markdownOutput(catalog: catalog, path: "Links") - let expectedInline = "inline link: [Rows and Columns](doc://MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns)" + let expectedInline = "inline link: [Rows and Columns](/documentation/MarkdownOutput/RowsAndColumns)" XCTAssert(node.markdown.contains(expectedInline)) - let expectedInlineAnchor = "inline link with a heading: [Overview](doc://MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns#Overview)" + let expectedInlineAnchor = "inline link with a heading: [Overview](/documentation/MarkdownOutput/RowsAndColumns#Overview)" XCTAssert(node.markdown.contains(expectedInlineAnchor)) - let expectedInlineAnchorMultiWord = "inline link with a multi-word heading: [Multi-word heading](doc://MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns#Multi-word-heading)" + let expectedInlineAnchorMultiWord = "inline link with a multi-word heading: [Multi-word heading](/documentation/MarkdownOutput/RowsAndColumns#Multi-word-heading)" XCTAssert(node.markdown.contains(expectedInlineAnchorMultiWord)) - let expectedLinkList = "[Rows and Columns](doc://MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns)\n\nJust here for the links" + let expectedLinkList = "[Rows and Columns](/documentation/MarkdownOutput/RowsAndColumns)\n\nJust here for the links" XCTAssert(node.markdown.contains(expectedLinkList)) // No abstract - let expectedLinkListAnchor = "[Overview](doc://MarkdownOutput/documentation/MarkdownOutput/RowsAndColumns#Overview)\n\n###" + let expectedLinkListAnchor = "[Overview](/documentation/MarkdownOutput/RowsAndColumns#Overview)\n\n###" XCTAssert(node.markdown.contains(expectedLinkListAnchor)) } @@ -160,10 +160,10 @@ final class MarkdownOutputTests: XCTestCase { ]) let (node, _) = try await markdownOutput(catalog: catalog, path: "Links") - let expectedInline = "inline link: [`MarkdownSymbol`](doc://MarkdownOutput/documentation/MarkdownOutput/MarkdownSymbol)" + let expectedInline = "inline link: [`MarkdownSymbol`](/documentation/MarkdownOutput/MarkdownSymbol)" XCTAssert(node.markdown.contains(expectedInline)) - let expectedLinkList = "[`MarkdownSymbol`](doc://MarkdownOutput/documentation/MarkdownOutput/MarkdownSymbol)\n\nA basic symbol to test markdown output" + let expectedLinkList = "[`MarkdownSymbol`](/documentation/MarkdownOutput/MarkdownSymbol)\n\nA basic symbol to test markdown output" XCTAssert(node.markdown.contains(expectedLinkList)) let unresolvableLink = "[`Unresolvable`]" @@ -199,10 +199,10 @@ final class MarkdownOutputTests: XCTestCase { ]) let (node, _) = try await markdownOutput(catalog: catalog, path: "Links") - let expectedInline = "inline link: [`MarkdownSymbol`](doc://MarkdownOutput/documentation/MarkdownOutput/MarkdownSymbol)" + let expectedInline = "inline link: [`MarkdownSymbol`](/documentation/MarkdownOutput/MarkdownSymbol)" XCTAssert(node.markdown.contains(expectedInline)) - let expectedLinkList = "[`MarkdownSymbol`](doc://MarkdownOutput/documentation/MarkdownOutput/MarkdownSymbol)\n\nA basic symbol to test markdown output. Different to [`OtherMarkdownSymbol`](doc://MarkdownOutput/documentation/MarkdownOutput/OtherMarkdownSymbol)" + let expectedLinkList = "[`MarkdownSymbol`](/documentation/MarkdownOutput/MarkdownSymbol)\n\nA basic symbol to test markdown output. Different to [`OtherMarkdownSymbol`](/documentation/MarkdownOutput/OtherMarkdownSymbol)" XCTAssert(node.markdown.contains(expectedLinkList)) } From 9481ccfe806cb0112d8f0e27785cc3fa9dd42b01 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Mon, 15 Dec 2025 11:55:11 +0000 Subject: [PATCH 47/59] Reformat multi-condition if statements --- .../ConvertActionConverter.swift | 17 +++---- .../MarkdownOutputMarkdownWalker.swift | 47 +++++++++---------- .../MarkdownOutputSemanticVisitor.swift | 5 +- 3 files changed, 32 insertions(+), 37 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift index f0cbd01aa2..e6cb444bf5 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift @@ -132,15 +132,13 @@ package enum ConvertActionConverter { return } - if - FeatureFlags.current.isExperimentalMarkdownOutputEnabled, - let markdownConsumer = outputConsumer as? (any ConvertOutputMarkdownConsumer), - let markdownNode = converter.markdownOutput(for: entity) + if FeatureFlags.current.isExperimentalMarkdownOutputEnabled, + 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 + if FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled, + let manifest = markdownNode.manifest { resultsGroup.async(queue: resultsSyncQueue) { markdownManifest.documents.formUnion(manifest.documents) @@ -254,9 +252,8 @@ package enum ConvertActionConverter { } } - if - FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled, - let markdownConsumer = outputConsumer as? (any ConvertOutputMarkdownConsumer) + if FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled, + let markdownConsumer = outputConsumer as? (any ConvertOutputMarkdownConsumer) { try markdownConsumer.consume(markdownManifest: markdownManifest) } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index 1f883f51aa..2572076e94 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -43,7 +43,8 @@ internal struct MarkdownOutputMarkupWalker: MarkupWalker { .format() .splitByNewlines .first(where: { $0.isEmpty == false })? - .prefix(while: { $0.isWhitespace && !$0.isNewline }) { + .prefix(while: { $0.isWhitespace && !$0.isNewline }) + { if toRemove.isEmpty == false { indentationToRemove = String(toRemove) } @@ -122,10 +123,9 @@ extension MarkdownOutputMarkupWalker { return } let unescaped = source.removingPercentEncoding ?? source - if - let resolved = context.resolveAsset(named: unescaped, in: identifier, withType: .image), - let first = resolved.variants.first?.value, - first.isFileURL + if let resolved = context.resolveAsset(named: unescaped, in: identifier, withType: .image), + let first = resolved.variants.first?.value, + first.isFileURL { let filename = first.lastPathComponent markdown.append("![\(image.altText ?? "")](images/\(context.inputs.id)/\(filename))") @@ -159,10 +159,9 @@ extension MarkdownOutputMarkupWalker { let linkTitle: String var linkListAbstract: (any Markup)? - if - isRenderingLinkList, - let doc = try? context.entity(with: resolved), - let symbol = doc.semantic as? Symbol + 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 { @@ -216,9 +215,7 @@ extension MarkdownOutputMarkupWalker { var linkTitle: String var linkListAbstract: (any Markup)? - if - let article = doc.semantic as? Article - { + if let article = doc.semantic as? Article { if isRenderingLinkList { linkListAbstract = article.abstract add(source: resolved, type: .belongsToTopic, subtype: nil) @@ -293,9 +290,9 @@ extension MarkdownOutputMarkupWalker { 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.lowercased() }) { + if let defaultLanguage = context.sourceLanguages(for: identifier).first?.name, + let languageMatch = tabs.tabs.first(where: { $0.title.lowercased() == defaultLanguage.lowercased() }) + { visit(container: languageMatch.content) } else { for tab in tabs.tabs { @@ -364,8 +361,9 @@ extension MarkdownOutputMarkupWalker { 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 { + if let resolvedVideos = context.resolveAsset(named: unescaped, in: identifier, withType: .video), + let first = resolvedVideos.variants.first?.value + { filename = first.lastPathComponent } @@ -376,7 +374,9 @@ extension MarkdownOutputMarkupWalker { 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 { + 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/\(context.inputs.id)/\(filename))") @@ -388,13 +388,12 @@ extension MarkdownOutputMarkupWalker { } 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) { + 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) + } 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 { diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 9d352f267b..2008951355 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -85,9 +85,8 @@ extension MarkdownOutputSemanticVisitor { manifest = MarkdownOutputManifest(title: context.inputs.displayName, documents: [document]) - if - let metadataAvailability = article.metadata?.availability, - !metadataAvailability.isEmpty + if let metadataAvailability = article.metadata?.availability, + !metadataAvailability.isEmpty { metadata.availability = metadataAvailability.map { .init($0) } } From 0df68ddfd308652eeb2d64de97789e644ad28bcd Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 16 Dec 2025 10:08:12 +0000 Subject: [PATCH 48/59] remove whitespace-only change --- .../SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift b/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift index f27618c064..7d71f68d00 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift @@ -177,7 +177,6 @@ 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 - } extension RenderMetadata: Codable { From 172674c0589cdeb598ffbdfbe8fe99a046d857de Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 16 Dec 2025 10:08:38 +0000 Subject: [PATCH 49/59] Remove unused declaration --- .../Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift index 74b382b8b2..6cc368c098 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift @@ -101,7 +101,7 @@ class JSONEncodingRenderNodeWriter { markdownNode.identifier, lowercased: true ) - let data = try markdownNode.node.generateDataRepresentation() + try write( markdownNode.node.generateDataRepresentation(), toFileSafePath: "data/\(fileSafePath).md" From 8a91913a59d00f3acfc69a25d78fa33a801b7d1f Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 16 Dec 2025 10:09:00 +0000 Subject: [PATCH 50/59] Remove redundant return types --- .../MarkdownOutputMarkdownWalker.swift | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index 2572076e94..fd87b8d077 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -55,13 +55,13 @@ internal struct MarkdownOutputMarkupWalker: MarkupWalker { } extension MarkdownOutputMarkupWalker { - mutating func visit(_ optionalMarkup: (any Markup)?) -> Void { + mutating func visit(_ optionalMarkup: (any Markup)?) { if let markup = optionalMarkup { self.visit(markup) } } - mutating func visit(section: (any Section)?, addingHeading: String? = nil) -> Void { + mutating func visit(section: (any Section)?, addingHeading: String? = nil) { guard let section = section, section.content.isEmpty == false else { @@ -89,7 +89,7 @@ extension MarkdownOutputMarkupWalker { extension MarkdownOutputMarkupWalker { - 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) } - 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 { } } - mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> () { + mutating func visitUnorderedList(_ unorderedList: UnorderedList) { //TODO: support formatting of term lists rdar://166128254 guard isRenderingLinkList else { return defaultVisit(unorderedList) @@ -118,7 +118,7 @@ extension MarkdownOutputMarkupWalker { } } - mutating func visitImage(_ image: Image) -> () { + mutating func visitImage(_ image: Image) { guard let source = image.source else { return } @@ -135,12 +135,12 @@ extension MarkdownOutputMarkupWalker { } - mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> () { + mutating func visitCodeBlock(_ codeBlock: CodeBlock) { startNewParagraphIfRequired() markdown.append(codeBlock.detachedFromParent.format()) } - mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> () { + mutating func visitSymbolLink(_ symbolLink: SymbolLink) { guard let destination = symbolLink.destination else { return } @@ -183,7 +183,7 @@ extension MarkdownOutputMarkupWalker { } } - mutating func visitLink(_ link: Link) -> () { + mutating func visitLink(_ link: Link) { guard let destination = link.destination, @@ -248,11 +248,11 @@ extension MarkdownOutputMarkupWalker { } - mutating func visitSoftBreak(_ softBreak: SoftBreak) -> () { + mutating func visitSoftBreak(_ softBreak: SoftBreak) { markdown.append("\n") } - mutating func visitParagraph(_ paragraph: Paragraph) -> () { + mutating func visitParagraph(_ paragraph: Paragraph) { startNewParagraphIfRequired() @@ -261,7 +261,7 @@ extension MarkdownOutputMarkupWalker { } } - mutating func visitBlockDirective(_ blockDirective: BlockDirective) -> () { + mutating func visitBlockDirective(_ blockDirective: BlockDirective) { let bundle = context.inputs switch blockDirective.name { case VideoMedia.directiveName: @@ -352,13 +352,13 @@ extension MarkdownOutputMarkupWalker { // Semantic handling extension MarkdownOutputMarkupWalker { - mutating func visit(container: MarkupContainer?) -> Void { + mutating func visit(container: MarkupContainer?) { container?.elements.forEach { self.visit($0) } } - mutating func visit(_ video: VideoMedia) -> Void { + mutating func visit(_ video: VideoMedia) { 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), @@ -371,7 +371,7 @@ extension MarkdownOutputMarkupWalker { visit(container: video.caption) } - mutating func visit(_ image: ImageMedia) -> Void { + mutating func visit(_ image: ImageMedia) { 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), @@ -382,7 +382,7 @@ extension MarkdownOutputMarkupWalker { markdown.append("\n\n![\(image.altText ?? "")](images/\(context.inputs.id)/\(filename))") } - mutating func visit(_ code: Code) -> Void { + mutating func visit(_ code: Code) { guard let codeIdentifier = context.identifier(forAssetName: code.fileReference.path, in: identifier) else { return } From f9331f519b01464c04f307a203983f349221afa6 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 16 Dec 2025 10:20:14 +0000 Subject: [PATCH 51/59] Remove MarkdownOutputNodeTranslator --- .../DocumentationContextConverter.swift | 13 +++--- .../MarkdownOutputNodeTranslator.swift | 43 ------------------- .../MarkdownOutputSemanticVisitor.swift | 19 +++++++- 3 files changed, 24 insertions(+), 51 deletions(-) delete mode 100644 Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift diff --git a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift index 369bf7eda8..30ddd11785 100644 --- a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift +++ b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift @@ -122,11 +122,12 @@ public class DocumentationContextConverter { guard !node.isVirtual else { return nil } - - var translator = MarkdownOutputNodeTranslator( - context: context, - node: node - ) - return translator.createOutput() + + var visitor = MarkdownOutputSemanticVisitor(context: context, node: node) + + if let node = visitor.createOutput() { + return CollectedMarkdownOutput(identifier: visitor.identifier, node: node, manifest: visitor.manifest) + } + return nil } } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift deleted file mode 100644 index 80b2e38bb1..0000000000 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift +++ /dev/null @@ -1,43 +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 -*/ - -import Foundation - -/// Creates ``CollectedMarkdownOutput`` from a ``DocumentationNode``. -internal struct MarkdownOutputNodeTranslator { - - var visitor: MarkdownOutputSemanticVisitor - - init(context: DocumentationContext, node: DocumentationNode) { - self.visitor = MarkdownOutputSemanticVisitor(context: context, node: node) - } - - mutating func createOutput() -> CollectedMarkdownOutput? { - if let node = visitor.start() { - 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 { - WritableMarkdownOutputNode(identifier: identifier, node: node) - } -} - -package struct WritableMarkdownOutputNode { - package let identifier: ResolvedTopicReference - package let node: MarkdownOutputNode -} diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 2008951355..0092896f45 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -9,7 +9,7 @@ */ /// Visits the semantic structure of a documentation node and returns a ``MarkdownOutputNode`` -internal struct MarkdownOutputSemanticVisitor: SemanticVisitor { +struct MarkdownOutputSemanticVisitor: SemanticVisitor { let context: DocumentationContext let documentationNode: DocumentationNode @@ -31,7 +31,7 @@ internal struct MarkdownOutputSemanticVisitor: SemanticVisitor { private var stepIndex = 0 private var lastCode: Code? - mutating func start() -> MarkdownOutputNode? { + mutating func createOutput() -> MarkdownOutputNode? { visit(documentationNode.semantic) } } @@ -450,3 +450,18 @@ extension MarkdownOutputSemanticVisitor { return nil } } + +struct CollectedMarkdownOutput { + let identifier: ResolvedTopicReference + let node: MarkdownOutputNode + let manifest: MarkdownOutputManifest? + + var writable: WritableMarkdownOutputNode { + WritableMarkdownOutputNode(identifier: identifier, node: node) + } +} + +struct WritableMarkdownOutputNode { + let identifier: ResolvedTopicReference + let node: MarkdownOutputNode +} From c9f9131f5b56dbd5e8017e172a0e33f8278b94c1 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 16 Dec 2025 11:25:05 +0000 Subject: [PATCH 52/59] Add TODO for alternate declarations --- .../Translation/MarkdownOutputSemanticVisitor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 0092896f45..b2c17bbe30 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -152,7 +152,7 @@ extension MarkdownOutputSemanticVisitor { markdownWalker.visit(Heading(level: 1, Text(symbol.title))) markdownWalker.visit(symbol.abstract) - // Intentionally only including the primary declaration in the output, because we are only using the primary language. + // TODO: rdar://166606746 include alternate declarations if let declarationFragments = symbol.declaration.first?.value.declarationFragments { let declaration = declarationFragments .map { $0.spelling } From 5a4cedf92d776a22174780ba98c4342a68cbe448 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 16 Dec 2025 11:33:06 +0000 Subject: [PATCH 53/59] Add TODO for structural review --- .../Translation/MarkdownOutputSemanticVisitor.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index b2c17bbe30..9ae8e38384 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -8,6 +8,7 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ +//TODO: rdar://166607119 consider an alternative to a semantic visitor for this work /// Visits the semantic structure of a documentation node and returns a ``MarkdownOutputNode`` struct MarkdownOutputSemanticVisitor: SemanticVisitor { From ae694491eea157172fc674e82456d198088e8ce6 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Tue, 16 Dec 2025 12:07:03 +0000 Subject: [PATCH 54/59] Add stacks todo, set writable type back to package --- .../Translation/MarkdownOutputSemanticVisitor.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 9ae8e38384..4bec66d5ec 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -443,6 +443,7 @@ extension MarkdownOutputSemanticVisitor { return nil } + // TODO: Add support for stacks rdar://166608793 mutating func visitStack(_ stack: Stack) -> MarkdownOutputNode? { return nil } @@ -462,7 +463,7 @@ struct CollectedMarkdownOutput { } } -struct WritableMarkdownOutputNode { - let identifier: ResolvedTopicReference - let node: MarkdownOutputNode +package struct WritableMarkdownOutputNode { + package let identifier: ResolvedTopicReference + package let node: MarkdownOutputNode } From 0a4c25119f6f750db108e58e9b759d11701df167 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Wed, 17 Dec 2025 09:38:55 +0000 Subject: [PATCH 55/59] uri -> identifier --- .../MarkdownOutputMarkdownWalker.swift | 6 ++-- .../MarkdownOutputSemanticVisitor.swift | 22 ++++++------ .../MarkdownOutputManifest.swift | 34 +++++++++--------- .../MarkdownOutputNode.swift | 6 ++-- .../Markdown/MarkdownOutputTests.swift | 36 +++++++++---------- 5 files changed, 52 insertions(+), 52 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index fd87b8d077..2d8ac6f358 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -409,11 +409,11 @@ extension MarkdownOutputMarkupWalker { // MARK: - Manifest construction extension MarkdownOutputMarkupWalker { mutating func add(source: ResolvedTopicReference, type: MarkdownOutputManifest.RelationshipType, subtype: MarkdownOutputManifest.RelationshipSubType?) { - var targetURI = identifier.path + var targetIdentifier = identifier.path if let lastHeading { - targetURI.append("#\(urlReadableFragment(lastHeading))") + targetIdentifier.append("#\(urlReadableFragment(lastHeading))") } - let relationship = MarkdownOutputManifest.Relationship(sourceURI: source.path, relationshipType: type, subtype: subtype, targetURI: targetURI) + let relationship = MarkdownOutputManifest.Relationship(sourceIdentifier: source.path, relationshipType: type, subtype: subtype, targetIdentifier: targetIdentifier) outgoingReferences.insert(relationship) } diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 4bec66d5ec..47e9d24869 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -41,7 +41,7 @@ extension MarkdownOutputNode.Metadata { init(documentType: DocumentType, bundle: DocumentationBundle, reference: ResolvedTopicReference, title: String) { self.init( documentType: documentType, - uri: reference.path, + identifier: reference.path, title: title, framework: bundle.displayName ) @@ -52,22 +52,22 @@ extension MarkdownOutputNode.Metadata { extension MarkdownOutputSemanticVisitor { mutating func add(target: ResolvedTopicReference, type: MarkdownOutputManifest.RelationshipType, subtype: MarkdownOutputManifest.RelationshipSubType?) { - add(targetURI: target.path, type: type, subtype: subtype) + add(targetIdentifier: target.path, type: type, subtype: subtype) } mutating func add(fallbackTarget: String, type: MarkdownOutputManifest.RelationshipType, subtype: MarkdownOutputManifest.RelationshipSubType?) { - let uri: String + let targetIdentifier: String let components = fallbackTarget.components(separatedBy: ".") if components.count > 1 { - uri = "/documentation/\(components.joined(separator: "/"))" + targetIdentifier = "/documentation/\(components.joined(separator: "/"))" } else { - uri = fallbackTarget + targetIdentifier = fallbackTarget } - add(targetURI: uri, type: type, subtype: subtype) + add(targetIdentifier: targetIdentifier, type: type, subtype: subtype) } - mutating func add(targetURI: String, type: MarkdownOutputManifest.RelationshipType, subtype: MarkdownOutputManifest.RelationshipSubType?) { - let relationship = MarkdownOutputManifest.Relationship(sourceURI: identifier.path, relationshipType: type, subtype: subtype, targetURI: targetURI) + mutating func add(targetIdentifier: String, type: MarkdownOutputManifest.RelationshipType, subtype: MarkdownOutputManifest.RelationshipSubType?) { + let relationship = MarkdownOutputManifest.Relationship(sourceIdentifier: identifier.path, relationshipType: type, subtype: subtype, targetIdentifier: targetIdentifier) manifest?.relationships.insert(relationship) } } @@ -79,7 +79,7 @@ extension MarkdownOutputSemanticVisitor { var metadata = MarkdownOutputNode.Metadata(documentType: .article, bundle: context.inputs, reference: identifier, title: article.title?.plainText ?? identifier.lastPathComponent) let document = MarkdownOutputManifest.Document( - uri: identifier.path, + identifier: identifier.path, documentType: .article, title: metadata.title ) @@ -121,7 +121,7 @@ extension MarkdownOutputSemanticVisitor { metadata.role = symbol.kind.displayName let document = MarkdownOutputManifest.Document( - uri: identifier.path, + identifier: identifier.path, documentType: .symbol, title: metadata.title ) @@ -279,7 +279,7 @@ extension MarkdownOutputSemanticVisitor { let metadata = MarkdownOutputNode.Metadata(documentType: .tutorial, bundle: context.inputs, reference: identifier, title: title) let document = MarkdownOutputManifest.Document( - uri: identifier.path, + identifier: identifier.path, documentType: .tutorial, title: metadata.title ) diff --git a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift index fca323a9d3..e5b758333d 100644 --- a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift +++ b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift @@ -65,26 +65,26 @@ extension MarkdownOutputManifest { /// 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:)``. + /// Parent / child symbol relationships are not included here, because those relationships are implicit in the identifier structure of the documents. See ``children(of:)``. package struct Relationship: Codable, Hashable, Sendable, Comparable { - package let sourceURI: String + package let sourceIdentifier: String package let relationshipType: RelationshipType package let subtype: RelationshipSubType? - package let targetURI: String + package let targetIdentifier: String - package init(sourceURI: String, relationshipType: MarkdownOutputManifest.RelationshipType, subtype: RelationshipSubType? = nil, targetURI: String) { - self.sourceURI = sourceURI + package init(sourceIdentifier: String, relationshipType: MarkdownOutputManifest.RelationshipType, subtype: RelationshipSubType? = nil, targetIdentifier: String) { + self.sourceIdentifier = sourceIdentifier self.relationshipType = relationshipType self.subtype = subtype - self.targetURI = targetURI + self.targetIdentifier = targetIdentifier } package static func < (lhs: MarkdownOutputManifest.Relationship, rhs: MarkdownOutputManifest.Relationship) -> Bool { - if lhs.sourceURI < rhs.sourceURI { + if lhs.sourceIdentifier < rhs.sourceIdentifier { return true - } else if lhs.sourceURI == rhs.sourceURI { - return lhs.targetURI < rhs.targetURI + } else if lhs.sourceIdentifier == rhs.sourceIdentifier { + return lhs.targetIdentifier < rhs.targetIdentifier } else { return false } @@ -93,33 +93,33 @@ extension MarkdownOutputManifest { package struct Document: Codable, Hashable, Sendable, Comparable { - /// The URI of the document - package let uri: String + /// The identifier of the document + package let identifier: String /// The type of the document package let documentType: DocumentType /// The title of the document package let title: String - package init(uri: String, documentType: MarkdownOutputManifest.DocumentType, title: String) { - self.uri = uri + package init(identifier: String, documentType: MarkdownOutputManifest.DocumentType, title: String) { + self.identifier = identifier self.documentType = documentType self.title = title } package static func < (lhs: MarkdownOutputManifest.Document, rhs: MarkdownOutputManifest.Document) -> Bool { - lhs.uri < rhs.uri + lhs.identifier < rhs.identifier } } /// All documents in the manifest that have a given document as a parent, e.g. Framework/Symbol/property is a child of Framework/Symbol package func children(of parent: Document) -> Set { - let parentPrefix = parent.uri + "/" + let parentPrefix = parent.identifier + "/" let prefixEnd = parentPrefix.endIndex return documents.filter { document in - guard document.uri.hasPrefix(parentPrefix) else { + guard document.identifier.hasPrefix(parentPrefix) else { return false } - let components = document.uri[prefixEnd...].components(separatedBy: "/") + let components = document.identifier[prefixEnd...].components(separatedBy: "/") return components.count == 1 } } diff --git a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift index 43799bd88b..9e0ec3ed75 100644 --- a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift @@ -130,16 +130,16 @@ extension MarkdownOutputNode { package let metadataVersion: String package let documentType: DocumentType package var role: String? - package let uri: String + package let identifier: String package var title: String package let framework: String package var symbol: Symbol? package var availability: [Availability]? - package init(documentType: DocumentType, uri: String, title: String, framework: String) { + package init(documentType: DocumentType, identifier: String, title: String, framework: String) { self.documentType = documentType self.metadataVersion = Self.version.stringRepresentation() - self.uri = uri + self.identifier = identifier self.title = title self.framework = framework } diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index b901da1a0e..691f8f42c1 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -26,10 +26,10 @@ 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, node: node) - let output = try XCTUnwrap(translator.createOutput()) - let manifest = try XCTUnwrap(output.manifest) - return (output.node, manifest) + var visitor = MarkdownOutputSemanticVisitor(context: context, node: node) + let markdownNode = try XCTUnwrap(visitor.createOutput()) + let manifest = try XCTUnwrap(visitor.manifest) + return (markdownNode, manifest) } private func catalog(files: [any File] = []) -> Folder { @@ -599,7 +599,7 @@ final class MarkdownOutputTests: XCTestCase { XCTAssert(node.metadata.documentType == .article) XCTAssert(node.metadata.role == RenderMetadata.Role.article.rawValue) XCTAssert(node.metadata.title == "Article Role") - XCTAssert(node.metadata.uri == "/documentation/MarkdownOutput/ArticleRole") + XCTAssert(node.metadata.identifier == "/documentation/MarkdownOutput/ArticleRole") XCTAssert(node.metadata.framework == "MarkdownOutput") } @@ -910,7 +910,7 @@ final class MarkdownOutputTests: XCTestCase { let data = try node.generateDataRepresentation() let fromData = try MarkdownOutputNode(data) XCTAssertEqual(node.markdown, fromData.markdown) - XCTAssertEqual(node.metadata.uri, fromData.metadata.uri) + XCTAssertEqual(node.metadata.identifier, fromData.metadata.identifier) } // MARK: - Manifest @@ -956,15 +956,15 @@ final class MarkdownOutputTests: XCTestCase { let (_, manifest) = try await markdownOutput(catalog: catalog, path: "Links") let rows = MarkdownOutputManifest.Relationship( - sourceURI: "/documentation/MarkdownOutput/RowsAndColumns", + sourceIdentifier: "/documentation/MarkdownOutput/RowsAndColumns", relationshipType: .belongsToTopic, - targetURI: "/documentation/MarkdownOutput/Links#Links-with-abstracts" + targetIdentifier: "/documentation/MarkdownOutput/Links#Links-with-abstracts" ) let symbol = MarkdownOutputManifest.Relationship( - sourceURI: "/documentation/MarkdownOutput/MarkdownSymbol", + sourceIdentifier: "/documentation/MarkdownOutput/MarkdownSymbol", relationshipType: .belongsToTopic, - targetURI: "/documentation/MarkdownOutput/Links#Links-with-abstracts" + targetIdentifier: "/documentation/MarkdownOutput/Links#Links-with-abstracts" ) XCTAssert(manifest.relationships.contains(rows)) @@ -984,12 +984,12 @@ final class MarkdownOutputTests: XCTestCase { ] let documents = documentURIs.map { - MarkdownOutputManifest.Document(uri: $0, documentType: .symbol, title: $0) + MarkdownOutputManifest.Document(identifier: $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 } + let document = try XCTUnwrap(manifest.documents.first(where: { $0.identifier == "/documentation/MarkdownOutput/MarkdownSymbol" })) + let children = manifest.children(of: document).map { $0.identifier } XCTAssertEqual(children.count, 4) XCTAssert(children.contains("/documentation/MarkdownOutput/MarkdownSymbol/name")) @@ -1018,13 +1018,13 @@ final class MarkdownOutputTests: XCTestCase { 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 + $0.targetIdentifier == "/documentation/MarkdownOutput/LocalSuperclass" && $0.subtype == .inheritsFrom })) 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 + $0.targetIdentifier == "/documentation/MarkdownOutput/LocalSubclass" && $0.subtype == .inheritedBy })) } @@ -1049,19 +1049,19 @@ final class MarkdownOutputTests: XCTestCase { 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 + $0.targetIdentifier == "/documentation/MarkdownOutput/LocalProtocol" && $0.subtype == .conformsTo })) 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 + $0.targetIdentifier == "/documentation/MarkdownOutput/LocalConformer" && $0.subtype == .conformingTypes })) 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 + $0.targetIdentifier == "/documentation/Swift/Hashable" && $0.subtype == .conformsTo })) } } From 86e40faa755113b791bf182c4828ead6a3080dfa Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Wed, 17 Dec 2025 10:07:02 +0000 Subject: [PATCH 56/59] Use RelationshipsGroup.Kind instead of creating a mirror type --- .../MarkdownOutputMarkdownWalker.swift | 2 +- .../MarkdownOutputSemanticVisitor.swift | 22 +++------- .../Section/Sections/Relationships.swift | 2 +- .../MarkdownOutputManifest.swift | 41 ++++++++++++------- 4 files changed, 34 insertions(+), 33 deletions(-) diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift index 2d8ac6f358..ea6992818e 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -408,7 +408,7 @@ extension MarkdownOutputMarkupWalker { // MARK: - Manifest construction extension MarkdownOutputMarkupWalker { - mutating func add(source: ResolvedTopicReference, type: MarkdownOutputManifest.RelationshipType, subtype: MarkdownOutputManifest.RelationshipSubType?) { + mutating func add(source: ResolvedTopicReference, type: MarkdownOutputManifest.RelationshipType, subtype: RelationshipsGroup.Kind?) { var targetIdentifier = identifier.path if let lastHeading { targetIdentifier.append("#\(urlReadableFragment(lastHeading))") diff --git a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift index 47e9d24869..d5acbc2c32 100644 --- a/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -51,11 +51,11 @@ extension MarkdownOutputNode.Metadata { // MARK: - Manifest construction extension MarkdownOutputSemanticVisitor { - mutating func add(target: ResolvedTopicReference, type: MarkdownOutputManifest.RelationshipType, subtype: MarkdownOutputManifest.RelationshipSubType?) { + mutating func add(target: ResolvedTopicReference, type: MarkdownOutputManifest.RelationshipType, subtype: RelationshipsGroup.Kind?) { add(targetIdentifier: target.path, type: type, subtype: subtype) } - mutating func add(fallbackTarget: String, type: MarkdownOutputManifest.RelationshipType, subtype: MarkdownOutputManifest.RelationshipSubType?) { + mutating func add(fallbackTarget: String, type: MarkdownOutputManifest.RelationshipType, subtype: RelationshipsGroup.Kind?) { let targetIdentifier: String let components = fallbackTarget.components(separatedBy: ".") if components.count > 1 { @@ -66,7 +66,7 @@ extension MarkdownOutputSemanticVisitor { add(targetIdentifier: targetIdentifier, type: type, subtype: subtype) } - mutating func add(targetIdentifier: String, type: MarkdownOutputManifest.RelationshipType, subtype: MarkdownOutputManifest.RelationshipSubType?) { + mutating func add(targetIdentifier: String, type: MarkdownOutputManifest.RelationshipType, subtype: RelationshipsGroup.Kind?) { let relationship = MarkdownOutputManifest.Relationship(sourceIdentifier: identifier.path, relationshipType: type, subtype: subtype, targetIdentifier: targetIdentifier) manifest?.relationships.insert(relationship) } @@ -186,10 +186,10 @@ extension MarkdownOutputSemanticVisitor { for destination in relationshipGroup.destinations { switch context.resolve(destination, in: identifier) { case .success(let resolved): - add(target: resolved, type: .relatedSymbol, subtype: relationshipGroup.kind.manifestRelationship) + add(target: resolved, type: .relatedSymbol, subtype: relationshipGroup.kind) case .failure: if let fallback = symbol.relationships.targetFallbacks[destination] { - add(fallbackTarget: fallback, type: .relatedSymbol, subtype: relationshipGroup.kind.manifestRelationship) + add(fallbackTarget: fallback, type: .relatedSymbol, subtype: relationshipGroup.kind) } } } @@ -202,18 +202,6 @@ extension MarkdownOutputSemanticVisitor { import SymbolKit -private extension RelationshipsGroup.Kind { - var manifestRelationship: MarkdownOutputManifest.RelationshipSubType? { - // Structured like this to cause a compiler error if a new case is added - switch self { - case .conformingTypes: .conformingTypes - case .conformsTo: .conformsTo - case .inheritsFrom: .inheritsFrom - case .inheritedBy: .inheritedBy - } - } -} - private extension MarkdownOutputNode.Metadata.Symbol { init(_ symbol: SwiftDocC.Symbol, context: DocumentationContext, bundle: DocumentationBundle) { diff --git a/Sources/SwiftDocC/Model/Section/Sections/Relationships.swift b/Sources/SwiftDocC/Model/Section/Sections/Relationships.swift index 2e72857a16..96625b8543 100644 --- a/Sources/SwiftDocC/Model/Section/Sections/Relationships.swift +++ b/Sources/SwiftDocC/Model/Section/Sections/Relationships.swift @@ -44,7 +44,7 @@ extension Relationship { public struct RelationshipsGroup { /// Possible symbol relationships. - public enum Kind: String { + public enum Kind: String, Sendable { /// One or more protocols to which a type conforms. case conformsTo diff --git a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift index e5b758333d..19c33b4fc8 100644 --- a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift +++ b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift @@ -51,18 +51,7 @@ extension MarkdownOutputManifest { /// For this relationship, the source and target URIs will be indicated by the directionality of the subtype, e.g. source "conformsTo" target. case relatedSymbol } - - package enum RelationshipSubType: String, Codable, Sendable { - /// One or more protocols to which a type conforms. - case conformsTo - /// One or more types that conform to a protocol. - case conformingTypes - /// One or more types that are parents of the symbol. - case inheritsFrom - /// One or more types that are children of the symbol. - case inheritedBy - } - + /// A relationship between two documents in the manifest. /// /// Parent / child symbol relationships are not included here, because those relationships are implicit in the identifier structure of the documents. See ``children(of:)``. @@ -70,10 +59,17 @@ extension MarkdownOutputManifest { package let sourceIdentifier: String package let relationshipType: RelationshipType - package let subtype: RelationshipSubType? + package let subtype: RelationshipsGroup.Kind? package let targetIdentifier: String - package init(sourceIdentifier: String, relationshipType: MarkdownOutputManifest.RelationshipType, subtype: RelationshipSubType? = nil, targetIdentifier: String) { + enum CodingKeys: String, CodingKey { + case sourceIdentifier + case relationshipType + case subtype + case targetIdentifier + } + + package init(sourceIdentifier: String, relationshipType: MarkdownOutputManifest.RelationshipType, subtype: RelationshipsGroup.Kind? = nil, targetIdentifier: String) { self.sourceIdentifier = sourceIdentifier self.relationshipType = relationshipType self.subtype = subtype @@ -89,6 +85,23 @@ extension MarkdownOutputManifest { return false } } + + package init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: MarkdownOutputManifest.Relationship.CodingKeys.self) + self.sourceIdentifier = try container.decode(String.self, forKey: .sourceIdentifier) + self.relationshipType = try container.decode(RelationshipType.self, forKey: .relationshipType) + let subtypeValue = try container.decodeIfPresent(String.self, forKey: .subtype) + self.subtype = subtypeValue.flatMap(RelationshipsGroup.Kind.init(rawValue:)) + self.targetIdentifier = try container.decode(String.self, forKey: .targetIdentifier) + } + + package func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(sourceIdentifier, forKey: .sourceIdentifier) + try container.encode(relationshipType, forKey: .relationshipType) + try container.encodeIfPresent(subtype?.rawValue, forKey: .subtype) + try container.encode(targetIdentifier, forKey: .targetIdentifier) + } } package struct Document: Codable, Hashable, Sendable, Comparable { From 78361ebf3c1e4ed297ce1f1a8ebf0ca3d6487ac5 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Wed, 17 Dec 2025 16:58:28 +0000 Subject: [PATCH 57/59] Lower access level of manifest, remove children method --- .../MarkdownOutputManifest.swift | 57 +++++++------------ .../Markdown/MarkdownOutputTests.swift | 27 --------- 2 files changed, 22 insertions(+), 62 deletions(-) diff --git a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift index 19c33b4fc8..c64292c4ed 100644 --- a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift +++ b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift @@ -15,15 +15,15 @@ package struct MarkdownOutputManifest: Codable, Sendable { package static let version = SemanticVersion(major: 0, minor: 1, patch: 0) /// The version of this manifest - package let manifestVersion: SemanticVersion + let manifestVersion: SemanticVersion /// The manifest title, this will typically match the module that the manifest is generated for package let title: String /// All documents contained in the manifest - package var documents: Set + var documents: Set /// Relationships involving documents in the manifest - package var relationships: Set + var relationships: Set - package init(title: String, documents: Set = [], relationships: Set = []) { + init(title: String, documents: Set = [], relationships: Set = []) { self.manifestVersion = Self.version self.title = title self.documents = documents @@ -41,11 +41,11 @@ package struct MarkdownOutputManifest: Codable, Sendable { extension MarkdownOutputManifest { - package enum DocumentType: String, Codable, Sendable { + enum DocumentType: String, Codable, Sendable { case article, tutorial, symbol } - package enum RelationshipType: String, Codable, Sendable { + enum RelationshipType: String, Codable, Sendable { /// For this relationship, the source URI will be the URI of a document, and the target URI will be the topic to which it belongs case belongsToTopic /// For this relationship, the source and target URIs will be indicated by the directionality of the subtype, e.g. source "conformsTo" target. @@ -54,13 +54,13 @@ extension MarkdownOutputManifest { /// A relationship between two documents in the manifest. /// - /// Parent / child symbol relationships are not included here, because those relationships are implicit in the identifier structure of the documents. See ``children(of:)``. - package struct Relationship: Codable, Hashable, Sendable, Comparable { + /// Parent / child symbol relationships are not included here, because those relationships are implicit in the identifier structure of the documents. + struct Relationship: Codable, Hashable, Sendable, Comparable { - package let sourceIdentifier: String - package let relationshipType: RelationshipType - package let subtype: RelationshipsGroup.Kind? - package let targetIdentifier: String + let sourceIdentifier: String + let relationshipType: RelationshipType + let subtype: RelationshipsGroup.Kind? + let targetIdentifier: String enum CodingKeys: String, CodingKey { case sourceIdentifier @@ -69,14 +69,14 @@ extension MarkdownOutputManifest { case targetIdentifier } - package init(sourceIdentifier: String, relationshipType: MarkdownOutputManifest.RelationshipType, subtype: RelationshipsGroup.Kind? = nil, targetIdentifier: String) { + init(sourceIdentifier: String, relationshipType: MarkdownOutputManifest.RelationshipType, subtype: RelationshipsGroup.Kind? = nil, targetIdentifier: String) { self.sourceIdentifier = sourceIdentifier self.relationshipType = relationshipType self.subtype = subtype self.targetIdentifier = targetIdentifier } - package static func < (lhs: MarkdownOutputManifest.Relationship, rhs: MarkdownOutputManifest.Relationship) -> Bool { + static func < (lhs: MarkdownOutputManifest.Relationship, rhs: MarkdownOutputManifest.Relationship) -> Bool { if lhs.sourceIdentifier < rhs.sourceIdentifier { return true } else if lhs.sourceIdentifier == rhs.sourceIdentifier { @@ -86,7 +86,7 @@ extension MarkdownOutputManifest { } } - package init(from decoder: any Decoder) throws { + init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: MarkdownOutputManifest.Relationship.CodingKeys.self) self.sourceIdentifier = try container.decode(String.self, forKey: .sourceIdentifier) self.relationshipType = try container.decode(RelationshipType.self, forKey: .relationshipType) @@ -95,7 +95,7 @@ extension MarkdownOutputManifest { self.targetIdentifier = try container.decode(String.self, forKey: .targetIdentifier) } - package func encode(to encoder: any Encoder) throws { + func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(sourceIdentifier, forKey: .sourceIdentifier) try container.encode(relationshipType, forKey: .relationshipType) @@ -104,36 +104,23 @@ extension MarkdownOutputManifest { } } - package struct Document: Codable, Hashable, Sendable, Comparable { + struct Document: Codable, Hashable, Sendable, Comparable { /// The identifier of the document - package let identifier: String + let identifier: String /// The type of the document - package let documentType: DocumentType + let documentType: DocumentType /// The title of the document - package let title: String + let title: String - package init(identifier: String, documentType: MarkdownOutputManifest.DocumentType, title: String) { + init(identifier: String, documentType: MarkdownOutputManifest.DocumentType, title: String) { self.identifier = identifier self.documentType = documentType self.title = title } - package static func < (lhs: MarkdownOutputManifest.Document, rhs: MarkdownOutputManifest.Document) -> Bool { + static func < (lhs: MarkdownOutputManifest.Document, rhs: MarkdownOutputManifest.Document) -> Bool { lhs.identifier < rhs.identifier } } - - /// All documents in the manifest that have a given document as a parent, e.g. Framework/Symbol/property is a child of Framework/Symbol - package func children(of parent: Document) -> Set { - let parentPrefix = parent.identifier + "/" - let prefixEnd = parentPrefix.endIndex - return documents.filter { document in - guard document.identifier.hasPrefix(parentPrefix) else { - return false - } - let components = document.identifier[prefixEnd...].components(separatedBy: "/") - return components.count == 1 - } - } } diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 691f8f42c1..7436c962c8 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -970,34 +970,7 @@ final class MarkdownOutputTests: XCTestCase { XCTAssert(manifest.relationships.contains(rows)) XCTAssert(manifest.relationships.contains(symbol)) } - - func testSymbolManifestChildSymbols() async throws { - // 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(identifier: $0, documentType: .symbol, title: $0) - } - let manifest = MarkdownOutputManifest(title: "Test", documents: Set(documents)) - let document = try XCTUnwrap(manifest.documents.first(where: { $0.identifier == "/documentation/MarkdownOutput/MarkdownSymbol" })) - let children = manifest.children(of: document).map { $0.identifier } - XCTAssertEqual(children.count, 4) - - 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 symbols = [ From 71916e7460666e29aff17b238cbc1fbb49dc4e4c Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 18 Dec 2025 12:15:38 +0000 Subject: [PATCH 58/59] Restrict package level declarations to those actually used at package level --- .../MarkdownOutputNode.swift | 68 +++++++++---------- .../Markdown/MarkdownOutputTests.swift | 6 ++ 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift index 9e0ec3ed75..425e63a12a 100644 --- a/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift +++ b/Sources/SwiftDocC/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift @@ -14,35 +14,35 @@ package import Foundation package struct MarkdownOutputNode: Sendable { /// The metadata about this node - package var metadata: Metadata + var metadata: Metadata /// The markdown content of this node - package var markdown: String = "" + var markdown: String = "" - package init(metadata: Metadata, markdown: String) { + init(metadata: Metadata, markdown: String) { self.metadata = metadata self.markdown = markdown } } extension MarkdownOutputNode { - package struct Metadata: Codable, Sendable { + struct Metadata: Codable, Sendable { static let version = SemanticVersion(major: 0, minor: 1, patch: 0) - package enum DocumentType: String, Codable, Sendable { + enum DocumentType: String, Codable, Sendable { case article, tutorial, symbol } - package struct Availability: Codable, Equatable, Sendable { + struct Availability: Codable, Equatable, Sendable { - package let platform: String + let platform: String /// A string representation of the introduced version - package let introduced: String? + let introduced: String? /// A string representation of the deprecated version - package let deprecated: String? - package let unavailable: Bool + let deprecated: String? + let unavailable: Bool - package init(platform: String, introduced: String? = nil, deprecated: String? = nil, unavailable: Bool) { + init(platform: String, introduced: String? = nil, deprecated: String? = nil, unavailable: Bool) { self.platform = platform self.introduced = introduced self.deprecated = deprecated @@ -53,18 +53,18 @@ extension MarkdownOutputNode { // platform: introduced - (not deprecated) // platform: introduced - deprecated (deprecated) // platform: - (unavailable) - package func encode(to encoder: any Encoder) throws { + func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() try container.encode(stringRepresentation) } - package init(from decoder: any Decoder) throws { + init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() let stringRepresentation = try container.decode(String.self) self.init(stringRepresentation: stringRepresentation) } - package var stringRepresentation: String { + var stringRepresentation: String { var stringRepresentation = "\(platform): " if unavailable { stringRepresentation += "-" @@ -81,7 +81,7 @@ extension MarkdownOutputNode { return stringRepresentation } - package init(stringRepresentation: String) { + init(stringRepresentation: String) { let words = stringRepresentation.split(separator: ":", maxSplits: 1) guard words.count == 2 else { platform = stringRepresentation @@ -108,18 +108,18 @@ extension MarkdownOutputNode { } - package struct Symbol: Codable, Sendable { - package let kindDisplayName: String - package let preciseIdentifier: String - package let modules: [String] + struct Symbol: Codable, Sendable { + let kindDisplayName: String + let preciseIdentifier: String + let modules: [String] - package enum CodingKeys: String, CodingKey { + enum CodingKeys: String, CodingKey { case kindDisplayName = "kind" case preciseIdentifier case modules } - package init(kindDisplayName: String, preciseIdentifier: String, modules: [String]) { + init(kindDisplayName: String, preciseIdentifier: String, modules: [String]) { self.kindDisplayName = kindDisplayName self.preciseIdentifier = preciseIdentifier self.modules = modules @@ -127,26 +127,22 @@ extension MarkdownOutputNode { } /// A string representation of the metadata version - package let metadataVersion: String - package let documentType: DocumentType - package var role: String? - package let identifier: String - package var title: String - package let framework: String - package var symbol: Symbol? - package var availability: [Availability]? + let metadataVersion: String + let documentType: DocumentType + var role: String? + let identifier: String + var title: String + let framework: String + var symbol: Symbol? + var availability: [Availability]? - package init(documentType: DocumentType, identifier: String, title: String, framework: String) { + init(documentType: DocumentType, identifier: String, title: String, framework: String) { self.documentType = documentType self.metadataVersion = Self.version.stringRepresentation() self.identifier = identifier self.title = title self.framework = framework } - - package func availability(for platform: String) -> Availability? { - availability?.first(where: { $0.platform == platform }) - } } } @@ -186,8 +182,8 @@ extension MarkdownOutputNode { } } - /// Recreates the node from the data exported in ``data`` - package init(_ data: Data) throws { + /// Recreates the node from the data exported in ``generateDataRepresentation()`` + 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 } diff --git a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift index 7436c962c8..ccd84e71b8 100644 --- a/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift +++ b/Tests/SwiftDocCTests/Rendering/Markdown/MarkdownOutputTests.swift @@ -1038,3 +1038,9 @@ final class MarkdownOutputTests: XCTestCase { })) } } + +extension MarkdownOutputNode.Metadata { + func availability(for platform: String) -> Availability? { + availability?.first(where: { $0.platform == platform }) + } +} From 562fcc44b872e217cc5517a30119e62636a0ec36 Mon Sep 17 00:00:00 2001 From: Richard Turton Date: Thu, 18 Dec 2025 14:48:48 +0000 Subject: [PATCH 59/59] Merging main --- CONTRIBUTING.md | 91 +++++- Package.swift | 22 +- Sources/CMakeLists.txt | 3 +- .../Action/Action.swift | 0 .../Action/ActionResult.swift | 0 .../Action/Actions/Action+MoveOutput.swift | 0 .../Actions/Convert/ConvertAction.swift | 31 +- .../Convert/ConvertFileWritingConsumer.swift | 2 +- .../CoverageDataEntry+generateSummary.swift | 0 .../FileWritingHTMLContentConsumer.swift | 77 +++-- .../Action/Actions/Convert/Indexer.swift | 0 .../JSONEncodingRenderNodeWriter.swift | 0 .../Action/Actions/CoverageAction.swift | 0 .../Actions/EmitGeneratedCurationAction.swift | 0 .../Action/Actions/IndexAction.swift | 0 .../Action/Actions/Init/CatalogTemplate.swift | 0 .../Actions/Init/CatalogTemplateKind.swift | 0 .../Action/Actions/Init/InitAction.swift | 0 .../MergeAction+SynthesizedLandingPage.swift | 0 .../Action/Actions/Merge/MergeAction.swift | 0 .../Action/Actions/PreviewAction.swift | 0 .../TransformForStaticHostingAction.swift | 0 .../Action+performAndHandleResult.swift | 0 .../ConvertAction+CommandInitialization.swift | 1 + ...CurationAction+CommandInitialization.swift | 0 .../IndexAction+CommandInitialization.swift | 0 .../InitAction+CommandInitialization.swift | 0 .../PreviewAction+CommandInitialization.swift | 0 ...cHostingAction+CommandInitialization.swift | 0 .../URLArgumentValidator.swift | 0 .../Options/DirectoryPathOption.swift | 0 .../Options/DocumentationArchiveOption.swift | 0 .../Options/DocumentationBundleOption.swift | 0 ...DocumentationCoverageOptionsArgument.swift | 2 +- .../ArgumentParsing/Options/InitOptions.swift | 0 .../OutOfProcessLinkResolverOption.swift | 0 .../Options/PreviewOptions.swift | 0 .../SourceRepositoryArguments.swift | 0 .../Options/TemplateOption.swift | 0 .../ArgumentParsing/Subcommands/Convert.swift | 20 ++ .../Subcommands/EmitGeneratedCuration.swift | 0 .../ArgumentParsing/Subcommands/Index.swift | 0 .../ArgumentParsing/Subcommands/Init.swift | 0 .../ArgumentParsing/Subcommands/Merge.swift | 0 .../ArgumentParsing/Subcommands/Preview.swift | 0 .../Subcommands/ProcessArchive.swift | 0 .../Subcommands/ProcessCatalog.swift | 0 .../TransformForStaticHosting.swift | 0 .../CMakeLists.txt | 4 +- .../CommandLine.docc/CommandLine.md} | 8 +- .../CommandLine}/Actions/InitAction.md | 2 +- .../CommandLine}/Extensions/Docc.md | 2 +- .../CommandLine.docc}/footer.html | 0 .../CommandLine.docc}/header.html | 0 .../Docc.swift | 0 .../PreviewServer/PreviewHTTPHandler.swift | 0 .../PreviewServer/PreviewServer.swift | 0 .../DefaultRequestHandler.swift | 0 .../RequestHandler/ErrorRequestHandler.swift | 0 .../RequestHandler/FileRequestHandler.swift | 0 .../HTTPResponseHead+FromRequest.swift | 0 .../RequestHandlerFactory.swift | 0 .../StaticHostableTransformer.swift | 0 .../Utility/DirectoryMonitor.swift | 0 .../Sequence+Unique.swift | 0 .../FoundationExtensions/String+Path.swift | 0 .../URL+IsAbsoluteWebURL.swift | 0 .../FoundationExtensions/URL+Relative.swift | 0 .../Utility/PlatformArgumentParser.swift | 0 .../Utility/Signal.swift | 0 .../Utility/Throttle.swift | 0 .../MarkdownRenderer+Parameters.swift | 8 +- .../FilesAndFolders.swift | 0 .../SymbolGraphCreation.swift | 231 ++++++++++++++ .../TestFileSystem.swift | 0 .../XCTestCase+TemporaryDirectory.swift | 0 .../ConvertActionConverter.swift | 4 +- .../LinkTargets/LinkDestinationSummary.swift | 123 +++++--- .../Resources/LinkableEntities.json | 32 +- .../SwiftDocC/AddingFeatureFlags.md | 6 +- .../SwiftDocC/CompilerPipeline.md | 4 +- .../SymbolGraphCreation.swift | 233 -------------- Sources/docc/CMakeLists.txt | 2 +- Sources/docc/main.swift | 4 +- ...nvertSubcommandSourceRepositoryTests.swift | 6 +- .../ConvertSubcommandTests.swift | 37 ++- ...tationCoverageKindFilterOptionsTests.swift | 0 .../ArgumentParsing/ErrorMessageTests.swift | 4 +- .../MergeSubcommandTests.swift | 4 +- .../PreviewSubcommandTests.swift | 4 +- .../C+Extensions.swift | 0 .../ConvertActionIndexerTests.swift | 2 +- .../ConvertActionStaticHostableTests.swift | 6 +- .../ConvertActionTests.swift | 34 +- .../DirectoryMonitorTests.swift | 2 +- .../EmitGeneratedCurationsActionTests.swift | 6 +- .../FileWritingHTMLContentConsumerTests.swift | 130 +++++++- .../FolderStructure.swift | 6 +- .../FolderStructureTests.swift | 2 +- .../HTMLTemplateDirectory.swift | 2 +- .../IndexActionTests.swift | 6 +- .../Init/InitActionTests.swift | 6 +- .../JSONEncodingRenderNodeWriterTests.swift | 4 +- .../MergeActionTests.swift | 4 +- .../PlatformArgumentParserTests.swift | 4 +- .../PreviewActionIntegrationTests.swift | 6 +- .../PreviewHTTPHandlerTests.swift | 4 +- .../DefaultRequestHandlerTests.swift | 4 +- .../ErrorRequestHandlerTests.swift | 2 +- .../FileRequestHandlerTests.swift | 4 +- .../PreviewServer/ServerTestUtils.swift | 4 +- .../ProblemTests.swift | 4 +- .../SemanticAnalyzerTests.swift | 4 +- .../ShadowFileManagerTemporaryDirectory.swift | 0 .../SignalTests.swift | 0 .../StaticHostableTransformerTests.swift | 6 +- .../StaticHostingBaseTest.swift | 0 .../StaticHostingWithContentTests.swift | 150 +++++++++ .../Default Code Listing Syntax.md | 0 .../FillIntroduced.symbols.json | 0 .../Info.plist | 0 .../MyKit@SideKit.symbols.json | 0 .../TestOverview.tutorial | 0 .../TestTutorial.tutorial | 0 .../TestTutorial2.tutorial | 0 .../TestTutorialArticle.tutorial | 0 .../TutorialMediaWithSpaces.tutorial | 0 .../article.md | 0 .../article2.md | 0 .../article3.md | 0 .../documentation/myclass.md | 0 .../documentation/mykit.md | 0 .../documentation/myprotocol.md | 0 .../documentation/sideclass-init.md | 0 .../documentation/sidekit.md | 0 .../figure1.png | Bin .../figure1~dark.png | Bin .../helloworld.swift | 0 .../helloworld1.swift | 0 .../helloworld2.swift | 0 .../helloworld3.swift | 0 .../helloworld4.swift | 0 .../intro.png | Bin .../introposter.png | Bin .../introposter2.png | Bin .../introvideo.mp4 | Bin .../introvideo~dark.mp4 | Bin .../mykit-iOS.symbols.json | 0 .../project.zip | Bin .../sidekit.symbols.json | 0 .../something@2x.png | Bin .../step.png | Bin .../titled2up.png | Bin .../titled2upCapital.PNG | Bin .../with spaces.mp4 | Bin .../with spaces.png | Bin .../with spaces@2x.png | Bin .../MixedLanguageFramework.docc/Info.plist | 0 .../clang/MixedLanguageFramework.symbols.json | 0 .../swift/MixedLanguageFramework.symbols.json | 0 .../OverloadedSymbols.docc/Info.plist | 0 .../ShapeKit.symbols.json | 0 .../SingleArticleTestBundle.docc/Info.plist | 0 .../SingleArticleTestBundle.docc/article.md | 0 .../DeckKit-Objective-C.symbols.json | 0 .../Test Resources/Overview.tutorial | 0 .../Test Resources/Test Template/index.html | 0 .../TopLevelCuration.symbols.json | 0 .../Test Resources/UncuratedArticle.md | 0 .../Test Resources/image.png | Bin .../ThrottleTests.swift | 4 +- ...TransformForStaticHostingActionTests.swift | 6 +- .../Utility/DirectedGraphTests.swift | 213 +++++++------ .../Utility/FileTests.swift | 2 +- .../Utility/LogHandleTests.swift | 4 +- .../Utility/Sequence+UniqueTests.swift | 4 +- .../Utility/TestFileSystemTests.swift | 4 +- .../Utility/URL+IsAbsoluteWebURLTests.swift | 4 +- .../Utility/URL+RelativeTests.swift | 4 +- .../XCTestCase+enableFeatureFlag.swift | 0 .../XCTestCase+LoadingData.swift | 2 +- .../FixedSizeBitSetTests.swift | 10 +- .../SmallSourceLanguageSetTests.swift | 6 +- .../DocCCommonTests/SourceLanguageTests.swift | 12 +- .../MarkdownRenderer+PageElementsTests.swift | 64 ++-- .../DocCHTMLTests/MarkdownRendererTests.swift | 20 +- Tests/DocCHTMLTests/WordBreakTests.swift | 2 +- .../NonInclusiveLanguageCheckerTests.swift | 210 ++++++------- ...recatedDiagnosticsDigestWarningTests.swift | 2 +- ...icConsoleWriterDefaultFormattingTest.swift | 4 +- .../Diagnostics/DiagnosticTests.swift | 2 +- .../ConvertService/ConvertServiceTests.swift | 2 +- .../Indexing/ExternalRenderNodeTests.swift | 2 +- .../Indexing/NavigatorIndexTests.swift | 2 +- .../Indexing/RenderIndexTests.swift | 2 +- .../AutoCapitalizationTests.swift | 4 +- .../AutomaticCurationTests.swift | 2 +- .../Infrastructure/BundleDiscoveryTests.swift | 2 +- .../DocumentationContext+RootPageTests.swift | 2 +- .../DocumentationContextTests.swift | 4 +- .../DocumentationCuratorTests.swift | 290 +++++++++--------- .../ExternalPathHierarchyResolverTests.swift | 6 +- .../ExternalReferenceResolverTests.swift | 2 +- .../DocumentationInputsProviderTests.swift | 2 +- .../Infrastructure/NodeTagsTests.swift | 2 +- .../ParseDirectiveArgumentsTests.swift | 53 ++-- .../PathHierarchyBasedLinkResolverTests.swift | 26 +- .../Infrastructure/PathHierarchyTests.swift | 2 +- .../Infrastructure/SnippetResolverTests.swift | 2 +- ...tendedTypesFormatTransformationTests.swift | 1 + .../SymbolGraph/SymbolGraphLoaderTests.swift | 2 +- .../Infrastructure/SymbolReferenceTests.swift | 2 +- .../LinkDestinationSummaryTests.swift | 178 +---------- .../Model/DocumentationNodeTests.swift | 1 + .../Model/LineHighlighterTests.swift | 2 +- .../ParametersAndReturnValidatorTests.swift | 4 +- ...opertyListPossibleValuesSectionTests.swift | 2 +- .../SemaToRenderNodeMultiLanguageTests.swift | 2 +- .../Model/SemaToRenderNodeTests.swift | 2 +- ...OutOfProcessReferenceResolverV1Tests.swift | 2 +- ...OutOfProcessReferenceResolverV2Tests.swift | 2 +- .../Rendering/AutomaticSeeAlsoTests.swift | 2 +- .../ConstraintsRenderSectionTests.swift | 2 +- .../DeclarationsRenderSectionTests.swift | 2 +- .../Rendering/DefaultAvailabilityTests.swift | 2 +- .../DefaultCodeListingSyntaxTests.swift | 2 +- .../Rendering/HeadingAnchorTests.swift | 2 +- .../Markdown/MarkdownOutputTests.swift | 2 +- .../Rendering/PlistSymbolTests.swift | 4 +- ...ropertyListDetailsRenderSectionTests.swift | 2 +- .../Rendering/RESTSymbolsTests.swift | 2 +- .../RenderBlockContent+AsideTests.swift | 10 +- .../RenderContentCompilerTests.swift | 2 +- ...derNodeTranslatorSymbolVariantsTests.swift | 2 +- .../Rendering/RenderNodeTranslatorTests.swift | 2 +- .../Rendering/SymbolAvailabilityTests.swift | 2 +- .../Rendering/TermListTests.swift | 2 +- .../ArticleSymbolMentionsTests.swift | 2 +- .../Semantics/ChoiceTests.swift | 2 +- .../Semantics/DoxygenTests.swift | 2 +- .../MarkupReferenceResolverTests.swift | 2 +- .../Semantics/MultipleChoiceTests.swift | 2 +- .../SwiftDocCTests/Semantics/StackTests.swift | 2 +- .../SwiftDocCTests/Semantics/StepTests.swift | 2 +- .../Semantics/SymbolTests.swift | 4 +- .../Semantics/TutorialArticleTests.swift | 2 +- .../Semantics/TutorialTests.swift | 2 +- .../Semantics/VideoMediaTests.swift | 2 +- .../Semantics/VolumeTests.swift | 2 +- .../Testing+LoadingTestData.swift | 141 +++++++++ .../Testing+ParseDirective.swift | 183 +++++++++++ Tests/SwiftDocCTests/Utility/LMDBTests.swift | 2 +- .../Utility/ListItemExtractorTests.swift | 2 +- .../Utility/XCTestCase+MentionedIn.swift | 2 +- .../XCTestCase+LoadingTestData.swift | 84 ++--- Tests/signal-test-app/main.swift | 6 +- bin/check-source | 2 +- bin/preview-docs | 22 +- bin/update-gh-pages-documentation-site | 18 +- features.json | 3 + 260 files changed, 1800 insertions(+), 1288 deletions(-) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Action.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/ActionResult.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/Action+MoveOutput.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/Convert/ConvertAction.swift (93%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/Convert/ConvertFileWritingConsumer.swift (99%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/Convert/CoverageDataEntry+generateSummary.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift (53%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/Convert/Indexer.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/CoverageAction.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/EmitGeneratedCurationAction.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/IndexAction.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/Init/CatalogTemplate.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/Init/CatalogTemplateKind.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/Init/InitAction.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/Merge/MergeAction+SynthesizedLandingPage.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/Merge/MergeAction.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/PreviewAction.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Action/Actions/TransformForStaticHostingAction.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/ActionExtensions/Action+performAndHandleResult.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift (98%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/ActionExtensions/EmitGeneratedCurationAction+CommandInitialization.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/ActionExtensions/IndexAction+CommandInitialization.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/ActionExtensions/InitAction+CommandInitialization.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/ActionExtensions/PreviewAction+CommandInitialization.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/ArgumentValidation/URLArgumentValidator.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Options/DirectoryPathOption.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Options/DocumentationArchiveOption.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Options/DocumentationBundleOption.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Options/DocumentationCoverageOptionsArgument.swift (97%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Options/InitOptions.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Options/OutOfProcessLinkResolverOption.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Options/PreviewOptions.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Options/Source Repository/SourceRepositoryArguments.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Options/TemplateOption.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Subcommands/Convert.swift (96%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Subcommands/EmitGeneratedCuration.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Subcommands/Index.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Subcommands/Init.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Subcommands/Merge.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Subcommands/Preview.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Subcommands/ProcessArchive.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Subcommands/ProcessCatalog.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/ArgumentParsing/Subcommands/TransformForStaticHosting.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/CMakeLists.txt (97%) rename Sources/{SwiftDocCUtilities/SwiftDocCUtilities.docc/SwiftDocCUtilities.md => DocCCommandLine/CommandLine.docc/CommandLine.md} (54%) rename Sources/{SwiftDocCUtilities/SwiftDocCUtilities.docc/SwiftDocCUtilities => DocCCommandLine/CommandLine.docc/CommandLine}/Actions/InitAction.md (98%) rename Sources/{SwiftDocCUtilities/SwiftDocCUtilities.docc/SwiftDocCUtilities => DocCCommandLine/CommandLine.docc/CommandLine}/Extensions/Docc.md (90%) rename Sources/{SwiftDocCUtilities/SwiftDocCUtilities.docc => DocCCommandLine/CommandLine.docc}/footer.html (100%) rename Sources/{SwiftDocCUtilities/SwiftDocCUtilities.docc => DocCCommandLine/CommandLine.docc}/header.html (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Docc.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/PreviewServer/PreviewHTTPHandler.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/PreviewServer/PreviewServer.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/PreviewServer/RequestHandler/DefaultRequestHandler.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/PreviewServer/RequestHandler/ErrorRequestHandler.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/PreviewServer/RequestHandler/FileRequestHandler.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/PreviewServer/RequestHandler/HTTPResponseHead+FromRequest.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/PreviewServer/RequestHandler/RequestHandlerFactory.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Transformers/StaticHostableTransformer.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Utility/DirectoryMonitor.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Utility/FoundationExtensions/Sequence+Unique.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Utility/FoundationExtensions/String+Path.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Utility/FoundationExtensions/URL+IsAbsoluteWebURL.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Utility/FoundationExtensions/URL+Relative.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Utility/PlatformArgumentParser.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Utility/Signal.swift (100%) rename Sources/{SwiftDocCUtilities => DocCCommandLine}/Utility/Throttle.swift (100%) rename Sources/{SwiftDocCTestUtilities => DocCTestUtilities}/FilesAndFolders.swift (100%) create mode 100644 Sources/DocCTestUtilities/SymbolGraphCreation.swift rename Sources/{SwiftDocCTestUtilities => DocCTestUtilities}/TestFileSystem.swift (100%) rename Sources/{SwiftDocCTestUtilities => DocCTestUtilities}/XCTestCase+TemporaryDirectory.swift (100%) delete mode 100644 Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/ArgumentParsing/ConvertSubcommandSourceRepositoryTests.swift (97%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/ArgumentParsing/ConvertSubcommandTests.swift (92%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/ArgumentParsing/DocumentationCoverageKindFilterOptionsTests.swift (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/ArgumentParsing/ErrorMessageTests.swift (93%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/ArgumentParsing/MergeSubcommandTests.swift (99%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/ArgumentParsing/PreviewSubcommandTests.swift (97%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/C+Extensions.swift (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/ConvertActionIndexerTests.swift (98%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/ConvertActionStaticHostableTests.swift (96%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/ConvertActionTests.swift (98%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/DirectoryMonitorTests.swift (99%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/EmitGeneratedCurationsActionTests.swift (96%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/FileWritingHTMLContentConsumerTests.swift (80%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/FolderStructure.swift (97%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/FolderStructureTests.swift (99%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/HTMLTemplateDirectory.swift (98%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/IndexActionTests.swift (97%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Init/InitActionTests.swift (97%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/JSONEncodingRenderNodeWriterTests.swift (95%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/MergeActionTests.swift (99%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/PlatformArgumentParserTests.swift (97%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/PreviewActionIntegrationTests.swift (99%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/PreviewServer/PreviewHTTPHandlerTests.swift (97%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/PreviewServer/RequestHandler/DefaultRequestHandlerTests.swift (96%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/PreviewServer/RequestHandler/ErrorRequestHandlerTests.swift (98%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/PreviewServer/RequestHandler/FileRequestHandlerTests.swift (99%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/PreviewServer/ServerTestUtils.swift (97%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/ProblemTests.swift (89%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/SemanticAnalyzerTests.swift (98%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/ShadowFileManagerTemporaryDirectory.swift (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/SignalTests.swift (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/StaticHostableTransformerTests.swift (98%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/StaticHostingBaseTest.swift (100%) create mode 100644 Tests/DocCCommandLineTests/StaticHostingWithContentTests.swift rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/Default Code Listing Syntax.md (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/FillIntroduced.symbols.json (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/Info.plist (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/MyKit@SideKit.symbols.json (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/TestOverview.tutorial (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/TestTutorial.tutorial (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/TestTutorial2.tutorial (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/TestTutorialArticle.tutorial (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/TutorialMediaWithSpaces.tutorial (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/article.md (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/article2.md (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/article3.md (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/documentation/myclass.md (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/documentation/mykit.md (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/documentation/myprotocol.md (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/documentation/sideclass-init.md (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/documentation/sidekit.md (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/figure1.png (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/figure1~dark.png (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/helloworld.swift (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/helloworld1.swift (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/helloworld2.swift (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/helloworld3.swift (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/helloworld4.swift (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/intro.png (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/introposter.png (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/introposter2.png (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/introvideo.mp4 (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/introvideo~dark.mp4 (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/mykit-iOS.symbols.json (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/project.zip (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/sidekit.symbols.json (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/something@2x.png (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/step.png (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/titled2up.png (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/titled2upCapital.PNG (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/with spaces.mp4 (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/with spaces.png (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/LegacyBundle_DoNotUseInNewTests.docc/with spaces@2x.png (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/MixedLanguageFramework.docc/Info.plist (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/MixedLanguageFramework.docc/symbol-graphs/clang/MixedLanguageFramework.symbols.json (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/MixedLanguageFramework.docc/symbol-graphs/swift/MixedLanguageFramework.symbols.json (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/OverloadedSymbols.docc/Info.plist (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/OverloadedSymbols.docc/ShapeKit.symbols.json (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/SingleArticleTestBundle.docc/Info.plist (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Bundles/SingleArticleTestBundle.docc/article.md (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Resources/DeckKit-Objective-C.symbols.json (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Resources/Overview.tutorial (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Resources/Test Template/index.html (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Resources/TopLevelCuration.symbols.json (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Resources/UncuratedArticle.md (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Test Resources/image.png (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/ThrottleTests.swift (92%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/TransformForStaticHostingActionTests.swift (98%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Utility/DirectedGraphTests.swift (57%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Utility/FileTests.swift (99%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Utility/LogHandleTests.swift (97%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Utility/Sequence+UniqueTests.swift (93%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Utility/TestFileSystemTests.swift (99%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Utility/URL+IsAbsoluteWebURLTests.swift (90%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Utility/URL+RelativeTests.swift (97%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/Utility/XCTestCase+enableFeatureFlag.swift (100%) rename Tests/{SwiftDocCUtilitiesTests => DocCCommandLineTests}/XCTestCase+LoadingData.swift (98%) create mode 100644 Tests/SwiftDocCTests/Testing+LoadingTestData.swift create mode 100644 Tests/SwiftDocCTests/Testing+ParseDirective.swift diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da6d76fb8f..896acf8452 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -166,7 +166,7 @@ If you have commit access, you can run the required tests by commenting the foll If you do not have commit access, please ask one of the code owners to trigger them for you. For more details on Swift-DocC's continuous integration, see the -[Continous Integration](#continuous-integration) section below. +[Continuous Integration](#continuous-integration) section below. ### Introducing source breaking changes @@ -207,7 +207,90 @@ by navigating to the root of the repository and running the following: By running tests locally with the `test` script you will be best prepared for automated testing in CI as well. -### Testing in Xcode +### Adding new tests + +Please use [Swift Testing](https://developer.apple.com/documentation/testing) when you add new tests. +Currently there are few existing tests to draw inspiration from, so here are a few recommendations: + +- Prefer small test inputs that ideally use a virtual file system for both reading and writing. + + For example, if you want to test a behavior related to a symbol's in-source documentation and its documentation extension file, you only need one symbol for that. + You can use `load(catalog:...)`, `makeSymbolGraph(...)`, and `makeSymbol(...)` to define such inputs in a virtual file system and create a `DocumentationContext` from it: + + ```swift + let catalog = Folder(name: "Something.docc", content: [ + JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(moduleName: "ModuleName", symbols: [ + makeSymbol(id: "some-symbol-id", kind: .class, pathComponents: ["SomeClass"], docComment: """ + This is the in-source documentation for this class. + """) + ])), + + TextFile(name: "Something.md", utf8Content: """ + # ``SomeClass`` + + This is additional documentation for this class + """), + ]) + let context = try await load(catalog: catalog) + // Test rest of your test + ``` + +- Consider using parameterized tests if you're making the same verifications in multiple configurations or on multiple elements. + + You can find some examples of this if you search for `@Test(arguments:`. + Additionally, you might encounter a `XCTestCase` test that loops over one or more values and performs the same validation for all combinations: + ```swift + for withExplicitTechnologyRoot in [true, false] { + for withPageColor in [true, false] { + ... + ``` + Such `XCTestCase` tests can sometimes be expressed more nicely as parameterized tests in Swift Testing. + +- Think about what information would be helpful to someone else who might debug that test case if it fails in the future. + + In an open source project like Swift-DocC, it's possible that a person you've never met will continue to work on code that you wrote. + It could be that they're working on the same feature as you, or it could also be that they're working on something entirely different but their changes broke a test that you wrote. + To help make their experience better, we appreciate any time that you spend considering if there's any information that you would have wanted to tell that person, as if they were a colleague. + + One way to convey this information could be to verify assumptions (like "this test content has no user-facing warnings") using `#expect`. + Additionally, if there's any information that you can surface right in the test failure that will save the next developer from needing to add a breakpoint and run the test again to inspect the value, + that's a nice small little thing that you can do for the developer coming after you: + ```swift + #expect(problems.isEmpty, "Unexpected problems: \(problems.map(\.diagnostic.summary))") + ``` + + Similarly, code comments or `#expect` descriptions can be a way to convey information about _why_ the test is expecting a _specific_ value. + ```swift + #expect(graph.cycles(from: 0) == [ + [7,9], // through breadth-first-traversal, 7 is reached before 9. + ]) + ``` + That reason may be clear to you, but could be a mystery to a person who is unfamiliar with that part of the code base---or even a future you that may have forgotten certain details about how the code works. + +- Use `#require` rather that force unwrapping for behaviors that would change due to unexpected bugs in the code you're testing. + + If you know that some value will always be non-`nil` only _because_ the rest of the code behaves correctly, consider writing the test more defensively using `#require` instead of force unwrapping the value. + This has the benefit that if someone else working on Swift-DocC introduces a bug in that behavior that the test relied on, then the test will fail gracefully rather than crashing and aborting the rest of the test execution. + + A similar situation occurs when you "know" that an array contains _N_ elements. If your test accesses them through indexed subscripting, it will trap if that array was unexpectedly short due to a bug that someone introduced. + In this situation you can use `problems.dropFirst(N-1).first` to access the _Nth_ element safely. + This could either be used as an optional value in a `#expect` call, or be unwrapped using `#require` depending on how the element is used in the test. + +- Use a descriptive and readable phrase as the test name. + + It can be easier to understand a test's implementation if its name describes the _behavior_ that the test verifies. + A phrase that start with a verb can often help make a test's name a more readable description of what it's verifying. + For example: `sortsSwiftFirstAndThenByID`, `raisesDiagnosticAboutCyclicCuration`, `isDisabledByDefault`, and `considersCurationInUncuratedAPICollection`. + +### Updating existing tests + +If you're updating an existing test case with additional logic, we appreciate if you also modernize that test while updating it, but we don't expect it. +If the test case is part of a large file, you can create new test suite which contains just the test case that you're modernizing. + +If you modernize an existing test case, consider not only the syntactical differences between Swift Testing and XCTest, +but also if there are any Swift Testing features or other changes that would make the test case easier to read, maintain, or debug. + +### Testing DocC's integration with Xcode You can test a locally built version of Swift-DocC in Xcode 13 or later by setting the `DOCC_EXEC` build setting to the path of your local `docc`: @@ -520,7 +603,7 @@ For more in-depth technical information about Swift-DocC, please refer to the project's technical documentation: - [`SwiftDocC` framework documentation](https://swiftlang.github.io/swift-docc/documentation/swiftdocc/) -- [`SwiftDocCUtilities` framework documentation](https://swiftlang.github.io/swift-docc/documentation/swiftdoccutilities/) +- [`DocCCommandLine` framework documentation](https://swiftlang.github.io/swift-docc/documentation/docccommandline/) ### Related Projects @@ -545,4 +628,4 @@ project's technical documentation: with support for building and viewing documentation for your framework and its dependencies. - + diff --git a/Package.swift b/Package.swift index 86249f0bf4..1b01ef6043 100644 --- a/Package.swift +++ b/Package.swift @@ -58,7 +58,7 @@ let package = Package( dependencies: [ .target(name: "SwiftDocC"), .target(name: "DocCCommon"), - .target(name: "SwiftDocCTestUtilities"), + .target(name: "DocCTestUtilities"), ], resources: [ .copy("Test Resources"), @@ -70,7 +70,7 @@ let package = Package( ), // Command-line tool library .target( - name: "SwiftDocCUtilities", + name: "DocCCommandLine", dependencies: [ .target(name: "SwiftDocC"), .target(name: "DocCCommon"), @@ -81,12 +81,12 @@ let package = Package( swiftSettings: swiftSettings ), .testTarget( - name: "SwiftDocCUtilitiesTests", + name: "DocCCommandLineTests", dependencies: [ - .target(name: "SwiftDocCUtilities"), + .target(name: "DocCCommandLine"), .target(name: "SwiftDocC"), .target(name: "DocCCommon"), - .target(name: "SwiftDocCTestUtilities"), + .target(name: "DocCTestUtilities"), ], resources: [ .copy("Test Resources"), @@ -97,7 +97,7 @@ let package = Package( // Test utility library .target( - name: "SwiftDocCTestUtilities", + name: "DocCTestUtilities", dependencies: [ .target(name: "SwiftDocC"), .target(name: "DocCCommon"), @@ -110,7 +110,7 @@ let package = Package( .executableTarget( name: "docc", dependencies: [ - .target(name: "SwiftDocCUtilities"), + .target(name: "DocCCommandLine"), ], exclude: ["CMakeLists.txt"], swiftSettings: swiftSettings @@ -131,7 +131,7 @@ let package = Package( name: "DocCCommonTests", dependencies: [ .target(name: "DocCCommon"), - .target(name: "SwiftDocCTestUtilities"), + .target(name: "DocCTestUtilities"), ], swiftSettings: [.swiftLanguageMode(.v6)] ), @@ -152,16 +152,16 @@ let package = Package( .target(name: "DocCHTML"), .target(name: "SwiftDocC"), .product(name: "Markdown", package: "swift-markdown"), - .target(name: "SwiftDocCTestUtilities"), + .target(name: "DocCTestUtilities"), ], swiftSettings: [.swiftLanguageMode(.v6)] ), - // Test app for SwiftDocCUtilities + // Test app for DocCCommandLine .executableTarget( name: "signal-test-app", dependencies: [ - .target(name: "SwiftDocCUtilities"), + .target(name: "DocCCommandLine"), ], path: "Tests/signal-test-app", swiftSettings: swiftSettings diff --git a/Sources/CMakeLists.txt b/Sources/CMakeLists.txt index 840812426a..9470b09c8c 100644 --- a/Sources/CMakeLists.txt +++ b/Sources/CMakeLists.txt @@ -8,6 +8,7 @@ See https://swift.org/LICENSE.txt for license information #]] add_subdirectory(DocCCommon) +add_subdirectory(DocCHTML) add_subdirectory(SwiftDocC) -add_subdirectory(SwiftDocCUtilities) +add_subdirectory(DocCCommandLine) add_subdirectory(docc) diff --git a/Sources/SwiftDocCUtilities/Action/Action.swift b/Sources/DocCCommandLine/Action/Action.swift similarity index 100% rename from Sources/SwiftDocCUtilities/Action/Action.swift rename to Sources/DocCCommandLine/Action/Action.swift diff --git a/Sources/SwiftDocCUtilities/Action/ActionResult.swift b/Sources/DocCCommandLine/Action/ActionResult.swift similarity index 100% rename from Sources/SwiftDocCUtilities/Action/ActionResult.swift rename to Sources/DocCCommandLine/Action/ActionResult.swift diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Action+MoveOutput.swift b/Sources/DocCCommandLine/Action/Actions/Action+MoveOutput.swift similarity index 100% rename from Sources/SwiftDocCUtilities/Action/Actions/Action+MoveOutput.swift rename to Sources/DocCCommandLine/Action/Actions/Action+MoveOutput.swift diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift b/Sources/DocCCommandLine/Action/Actions/Convert/ConvertAction.swift similarity index 93% rename from Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift rename to Sources/DocCCommandLine/Action/Actions/Convert/ConvertAction.swift index 62d2baf744..b9fb3a088d 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift +++ b/Sources/DocCCommandLine/Action/Actions/Convert/ConvertAction.swift @@ -30,6 +30,7 @@ public struct ConvertAction: AsyncAction { let diagnosticEngine: DiagnosticEngine private let transformForStaticHosting: Bool + private let includeContentInEachHTMLFile: Bool private let hostingBasePath: String? let sourceRepository: SourceRepository? @@ -64,6 +65,7 @@ public struct ConvertAction: AsyncAction { /// - experimentalEnableCustomTemplates: `true` if the convert action should enable support for custom "header.html" and "footer.html" template files, otherwise `false`. /// - experimentalModifyCatalogWithGeneratedCuration: `true` if the convert action should write documentation extension files containing markdown representations of DocC's automatic curation into the `documentationBundleURL`, otherwise `false`. /// - transformForStaticHosting: `true` if the convert action should process the build documentation archive so that it supports a static hosting environment, otherwise `false`. + /// - includeContentInEachHTMLFile: `true` if the convert action should process each static hosting HTML file so that it includes documentation content for environments without JavaScript enabled, otherwise `false`. /// - allowArbitraryCatalogDirectories: `true` if the convert action should consider the root location as a documentation bundle if it doesn't discover another bundle, otherwise `false`. /// - hostingBasePath: The base path where the built documentation archive will be hosted at. /// - sourceRepository: The source repository where the documentation's sources are hosted. @@ -91,6 +93,7 @@ public struct ConvertAction: AsyncAction { experimentalEnableCustomTemplates: Bool = false, experimentalModifyCatalogWithGeneratedCuration: Bool = false, transformForStaticHosting: Bool = false, + includeContentInEachHTMLFile: Bool = false, allowArbitraryCatalogDirectories: Bool = false, hostingBasePath: String? = nil, sourceRepository: SourceRepository? = nil, @@ -105,6 +108,7 @@ public struct ConvertAction: AsyncAction { self.temporaryDirectory = temporaryDirectory self.documentationCoverageOptions = documentationCoverageOptions self.transformForStaticHosting = transformForStaticHosting + self.includeContentInEachHTMLFile = includeContentInEachHTMLFile self.hostingBasePath = hostingBasePath self.sourceRepository = sourceRepository @@ -189,6 +193,11 @@ public struct ConvertAction: AsyncAction { /// A block of extra work that tests perform to affect the time it takes to convert documentation var _extraTestWork: (() async -> Void)? + /// The `Indexer` type doesn't work with virtual file systems. + /// + /// Tests that don't verify the contents of the navigator index can set this to `true` so that they can use a virtual, in-memory, file system. + var _completelySkipBuildingIndex: Bool = false + /// Converts each eligible file from the source documentation bundle, /// saves the results in the given output alongside the template files. public func perform(logHandle: inout LogHandle) async throws -> ActionResult { @@ -286,7 +295,7 @@ public struct ConvertAction: AsyncAction { workingDirectory: temporaryFolder, fileManager: fileManager) - let indexer = try Indexer(outputURL: temporaryFolder, bundleID: inputs.id) + let indexer = _completelySkipBuildingIndex ? nil : try Indexer(outputURL: temporaryFolder, bundleID: inputs.id) let registerInterval = signposter.beginInterval("Register", id: signposter.makeSignpostID()) let context = try await DocumentationContext(bundle: inputs, dataProvider: dataProvider, diagnosticEngine: diagnosticEngine, configuration: configuration) @@ -299,9 +308,23 @@ public struct ConvertAction: AsyncAction { context: context, indexer: indexer, enableCustomTemplates: experimentalEnableCustomTemplates, - transformForStaticHostingIndexHTML: transformForStaticHosting ? indexHTML : nil, + // Don't transform for static hosting if the `FileWritingHTMLContentConsumer` will create per-page index.html files + transformForStaticHostingIndexHTML: transformForStaticHosting && !includeContentInEachHTMLFile ? indexHTML : nil, bundleID: inputs.id ) + + let htmlConsumer: FileWritingHTMLContentConsumer? + if includeContentInEachHTMLFile, let indexHTML { + htmlConsumer = try FileWritingHTMLContentConsumer( + targetFolder: temporaryFolder, + fileManager: fileManager, + htmlTemplate: indexHTML, + customHeader: experimentalEnableCustomTemplates ? inputs.customHeader : nil, + customFooter: experimentalEnableCustomTemplates ? inputs.customFooter : nil + ) + } else { + htmlConsumer = nil + } if experimentalModifyCatalogWithGeneratedCuration, let catalogURL = rootURL { let writer = GeneratedCurationWriter(context: context, catalogURL: catalogURL, outputURL: catalogURL) @@ -320,7 +343,7 @@ public struct ConvertAction: AsyncAction { try ConvertActionConverter.convert( context: context, outputConsumer: outputConsumer, - htmlContentConsumer: nil, + htmlContentConsumer: htmlConsumer, sourceRepository: sourceRepository, emitDigest: emitDigest, documentationCoverageOptions: documentationCoverageOptions @@ -375,7 +398,7 @@ public struct ConvertAction: AsyncAction { } // If we're building a navigation index, finalize the process and collect encountered problems. - do { + if let indexer { let finalizeNavigationIndexMetric = benchmark(begin: Benchmark.Duration(id: "finalize-navigation-index")) // Always emit a JSON representation of the index but only emit the LMDB diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift b/Sources/DocCCommandLine/Action/Actions/Convert/ConvertFileWritingConsumer.swift similarity index 99% rename from Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift rename to Sources/DocCCommandLine/Action/Actions/Convert/ConvertFileWritingConsumer.swift index 48a8838c4e..b22e88e365 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift +++ b/Sources/DocCCommandLine/Action/Actions/Convert/ConvertFileWritingConsumer.swift @@ -252,7 +252,7 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer, let template = "" var newIndexContents = indexContents newIndexContents.replaceSubrange(bodyTagRange, with: indexContents[bodyTagRange] + template) - try newIndexContents.write(to: index, atomically: true, encoding: .utf8) + try fileManager.createFile(at: index, contents: Data(newIndexContents.utf8)) } /// File name for the documentation coverage file emitted during conversion. diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/CoverageDataEntry+generateSummary.swift b/Sources/DocCCommandLine/Action/Actions/Convert/CoverageDataEntry+generateSummary.swift similarity index 100% rename from Sources/SwiftDocCUtilities/Action/Actions/Convert/CoverageDataEntry+generateSummary.swift rename to Sources/DocCCommandLine/Action/Actions/Convert/CoverageDataEntry+generateSummary.swift diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift b/Sources/DocCCommandLine/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift similarity index 53% rename from Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift rename to Sources/DocCCommandLine/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift index 2fcaf2eae7..83db0611ac 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift +++ b/Sources/DocCCommandLine/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift @@ -20,8 +20,6 @@ import SwiftDocC import DocCHTML struct FileWritingHTMLContentConsumer: HTMLContentConsumer { - var targetFolder: URL - var fileManager: any FileManagerProtocol var prettyPrintOutput: Bool private struct HTMLTemplate { @@ -30,24 +28,51 @@ struct FileWritingHTMLContentConsumer: HTMLContentConsumer { var titleReplacementRange: Range var descriptionReplacementRange: Range - init(data: Data) throws { - let content = String(decoding: data, as: UTF8.self) + struct CustomTemplate { + var id, content: String + } + + init(data: Data, customTemplates: [CustomTemplate]) throws { + var content = String(decoding: data, as: UTF8.self) - // ???: Should we parse the content with XMLParser instead? If so, what do we do if it's not valid XHTML? - let noScriptStart = content.utf8.firstRange(of: "".utf8)!.lowerBound + // Ensure that the index.html file has at least a `` and a ``. + guard var beforeEndOfHead = content.utf8.firstRange(of: "".utf8)?.lowerBound, + var afterStartOfBody = content.range(of: "]*>", options: .regularExpression)?.upperBound + else { + struct MissingRequiredTagsError: DescribedError { + let errorDescription = "Missing required `` and `` elements in \"index.html\" file." + } + throw MissingRequiredTagsError() + } - let titleStart = content.utf8.firstRange(of: "".utf8)!.upperBound - let titleEnd = content.utf8.firstRange(of: "".utf8)!.lowerBound + for template in customTemplates { // Use the order as `ConvertFileWritingConsumer` + content.insert(contentsOf: "", at: afterStartOfBody) + } - let beforeHeadEnd = content.utf8.firstRange(of: "".utf8)!.lowerBound + if let titleStart = content.utf8.firstRange(of: "".utf8)?.upperBound, + let titleEnd = content.utf8.firstRange(of: "".utf8)?.lowerBound + { + titleReplacementRange = titleStart ..< titleEnd + } else { + content.insert(contentsOf: "", at: beforeEndOfHead) + content.utf8.formIndex(&beforeEndOfHead, offsetBy: "".utf8.count) + content.utf8.formIndex(&afterStartOfBody, offsetBy: "".utf8.count) + let titleInside = content.utf8.index(beforeEndOfHead, offsetBy: -"".utf8.count) + titleReplacementRange = titleInside ..< titleInside + } + if let noScriptStart = content.utf8.firstRange(of: "".utf8)?.lowerBound + { + contentReplacementRange = noScriptStart ..< noScriptEnd + } else { + content.insert(contentsOf: "", at: afterStartOfBody) + let noScriptInside = content.utf8.index(afterStartOfBody, offsetBy: "