diff --git a/Package.swift b/Package.swift index f954d0090..b1fb4229a 100644 --- a/Package.swift +++ b/Package.swift @@ -33,6 +33,10 @@ let package = Package( name: "SwiftDocC", targets: ["SwiftDocC"] ), + .library( + name: "SwiftDocCMarkdownOutput", + targets: ["SwiftDocCMarkdownOutput"] + ), .executable( name: "docc", targets: ["docc"] @@ -47,6 +51,7 @@ let package = Package( .product(name: "SymbolKit", package: "swift-docc-symbolkit"), .product(name: "CLMDB", package: "swift-lmdb"), .product(name: "Crypto", package: "swift-crypto"), + .target(name: "SwiftDocCMarkdownOutput") ], swiftSettings: swiftSettings ), @@ -126,6 +131,12 @@ let package = Package( swiftSettings: swiftSettings ), + // Experimental markdown output + .target( + name: "SwiftDocCMarkdownOutput", + dependencies: [] + ) + ] ) diff --git a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift index 72e23665b..d310c4f0a 100644 --- a/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift +++ b/Sources/SwiftDocC/Converter/DocumentationContextConverter.swift @@ -101,4 +101,21 @@ public class DocumentationContextConverter { ) return translator.visit(node.semantic) as? RenderNode } + + /// Converts a documentation node to a markdown node. + /// - Parameters: + /// - node: The documentation node to convert. + /// - Returns: The markdown node representation of the documentation node. + internal func markdownOutput(for node: DocumentationNode) -> CollectedMarkdownOutput? { + guard !node.isVirtual else { + return nil + } + + 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 17a5db0a7..bb84fcde6 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift @@ -9,6 +9,7 @@ */ import Foundation +@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput #if canImport(os) package import os @@ -81,6 +82,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. // @@ -129,6 +131,22 @@ package enum ConvertActionConverter { return } + 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 + { + resultsGroup.async(queue: resultsSyncQueue) { + markdownManifest.documents.formUnion(manifest.documents) + markdownManifest.relationships.formUnion(manifest.relationships) + } + } + } + try outputConsumer.consume(renderNode: renderNode) switch documentationCoverageOptions.level { @@ -213,6 +231,12 @@ package enum ConvertActionConverter { } } } + + if + FeatureFlags.current.isExperimentalMarkdownOutputManifestEnabled, + let markdownConsumer = outputConsumer as? (any ConvertOutputMarkdownConsumer) { + try markdownConsumer.consume(markdownManifest: try markdownManifest.writable) + } switch documentationCoverageOptions.level { case .detailed, .brief: diff --git a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift index f5e1ebd43..79b6d0abd 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift @@ -9,7 +9,7 @@ */ import Foundation - +@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput /// A consumer for output produced by a documentation conversion. /// /// Types that conform to this protocol manage what to do with documentation conversion products, for example persist them to disk @@ -50,6 +50,17 @@ 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: WritableMarkdownOutputManifest) 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 new file mode 100644 index 000000000..1e5985515 --- /dev/null +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputMarkdownWalker.swift @@ -0,0 +1,376 @@ +/* + 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 Markdown +@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput + +/// 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) { + self.context = context + self.bundle = bundle + self.identifier = identifier + } + + var markdown = "" + 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 + 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. + mutating func withRemoveIndentation(from base: (any Markup)?, process: (inout Self) -> 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) + indentationToRemove = nil + } +} + +extension MarkdownOutputMarkupWalker { + mutating func visit(_ optionalMarkup: (any Markup)?) -> Void { + if let markup = optionalMarkup { + self.visit(markup) + } + } + + mutating func visit(section: (any Section)?, addingHeading: String? = nil) -> Void { + guard + let section = section, + section.content.isEmpty == false else { + return + } + + 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 + } else { + visit(Heading(level: 2, Text(heading))) + } + } + + section.content.forEach { + self.visit($0) + } + } + + mutating func startNewParagraphIfRequired() { + if !markdown.isEmpty, !markdown.hasSuffix("\n\n") { markdown.append("\n\n") } + } +} + +extension MarkdownOutputMarkupWalker { + + mutating func defaultVisit(_ markup: any Markup) -> () { + var output = markup.format() + if let indentationToRemove, output.hasPrefix(indentationToRemove) { + output.removeFirst(indentationToRemove.count) + } + markdown.append(output) + } + + mutating func visitHeading(_ heading: Heading) -> () { + startNewParagraphIfRequired() + markdown.append(heading.detachedFromParent.format()) + if heading.level > 1 { + lastHeading = heading.plainText + } + } + + mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> () { + guard isRenderingLinkList else { + return defaultVisit(unorderedList) + } + + startNewParagraphIfRequired() + for item in unorderedList.listItems { + item.children.forEach { visit($0) } + startNewParagraphIfRequired() + } + } + + 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))") + } + + mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> () { + startNewParagraphIfRequired() + markdown.append(codeBlock.detachedFromParent.format()) + } + + mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> () { + guard + let destination = symbolLink.destination, + let resolved = context.referenceIndex[destination], + let node = context.topicGraph.nodeWithReference(resolved) + else { + return defaultVisit(symbolLink) + } + + let linkTitle: String + var linkListAbstract: (any Markup)? + 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 + } + add(source: resolved, type: .belongsToTopic, subtype: nil) + } else { + linkTitle = node.title + } + let link = Link(destination: destination, title: linkTitle, [InlineCode(linkTitle)]) + visit(link) + visit(linkListAbstract) + } + + 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 + var linkListAbstract: (any Markup)? + if + let article = doc.semantic as? Article + { + if isRenderingLinkList { + linkListAbstract = article.abstract + add(source: resolved, type: .belongsToTopic, subtype: nil) + } + linkTitle = article.title?.plainText ?? resolved.lastPathComponent + } else { + linkTitle = resolved.lastPathComponent + } + + 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) + } + + + mutating func visitSoftBreak(_ softBreak: SoftBreak) -> () { + markdown.append("\n") + } + + mutating func visitParagraph(_ paragraph: Paragraph) -> () { + + startNewParagraphIfRequired() + + for child in paragraph.children { + visit(child) + } + } + + mutating func visitBlockDirective(_ blockDirective: BlockDirective) -> () { + + switch blockDirective.name { + case VideoMedia.directiveName: + guard let video = VideoMedia(from: blockDirective, for: bundle) else { + return + } + visit(video) + + 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 + } + for column in row.columns { + markdown.append("\n\n") + withRemoveIndentation(from: column.childMarkup.first) { + $0.visit(container: column.content) + } + } + 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.lowercased() }) { + 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) + withRemoveIndentation(from: tab.childMarkup.first) { + $0.visit(container: tab.content) + + } + } + } + case Links.directiveName: + withRenderingLinkList { + for child in blockDirective.children { + $0.withRemoveIndentation(from: child) { + $0.visit(child) + } + } + } + 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 + } + + } +} + +// Semantic handling +extension MarkdownOutputMarkupWalker { + + 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)) + } + +} + +// 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 new file mode 100644 index 000000000..0fb0e2b8a --- /dev/null +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputNodeTranslator.swift @@ -0,0 +1,67 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2025 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +public import Foundation +@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput + +/// Creates ``CollectedMarkdownOutput`` from a ``DocumentationNode``. +internal struct MarkdownOutputNodeTranslator { + + var visitor: MarkdownOutputSemanticVisitor + + init(context: DocumentationContext, bundle: DocumentationBundle, node: DocumentationNode) { + self.visitor = MarkdownOutputSemanticVisitor(context: context, bundle: bundle, 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 { + get throws { + WritableMarkdownOutputNode(identifier: identifier, nodeData: try node.data) + } + } +} + +@_spi(MarkdownOutput) +public struct WritableMarkdownOutputNode { + public let identifier: ResolvedTopicReference + 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 new file mode 100644 index 000000000..6ffa5a3d2 --- /dev/null +++ b/Sources/SwiftDocC/Model/MarkdownOutput/Translation/MarkdownOutputSemanticVisitor.swift @@ -0,0 +1,445 @@ +/* + 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 +*/ + +@_spi(MarkdownOutput) import SwiftDocCMarkdownOutput + +/// Visits the semantic structure of a documentation node and returns a ``MarkdownOutputNode`` +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) { + self.context = context + self.bundle = bundle + self.documentationNode = node + self.identifier = node.reference + self.markdownWalker = MarkdownOutputMarkupWalker(context: context, bundle: bundle, identifier: identifier) + } + + typealias Result = MarkdownOutputNode? + + // Tutorial processing + private var sectionIndex = 0 + private var stepIndex = 0 + private var lastCode: Code? + + mutating func start() -> MarkdownOutputNode? { + visit(documentationNode.semantic) + } +} + +extension MarkdownOutputNode.Metadata { + init(documentType: DocumentType, bundle: DocumentationBundle, reference: ResolvedTopicReference) { + self.init( + documentType: documentType, + uri: reference.path, + title: reference.lastPathComponent, + framework: bundle.displayName + ) + } +} + +// 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(fallbackTarget: String, type: MarkdownOutputManifest.RelationshipType, subtype: String?) { + let uri: String + let components = fallbackTarget.components(separatedBy: ".") + if components.count > 1 { + uri = "/documentation/\(components.joined(separator: "/"))" + } else { + uri = fallbackTarget + } + add(targetURI: uri, type: type, subtype: subtype) + } + + 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) + } +} + +// MARK: Article Output +extension MarkdownOutputSemanticVisitor { + + 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 + } + + let document = MarkdownOutputManifest.Document( + uri: identifier.path, + documentType: .article, + title: metadata.title + ) + + manifest = MarkdownOutputManifest(title: bundle.displayName, documents: [document]) + + if + let metadataAvailability = article.metadata?.availability, + !metadataAvailability.isEmpty { + metadata.availability = metadataAvailability.map { .init($0) } + } + metadata.role = DocumentationContentRenderer.roleForArticle(article, nodeKind: documentationNode.kind).rawValue + 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") + } + + manifest?.relationships.formUnion(markdownWalker.outgoingReferences) + return MarkdownOutputNode(metadata: metadata, markdown: markdownWalker.markdown) + } +} + +import Markdown + +// MARK: Symbol Output +extension MarkdownOutputSemanticVisitor { + + mutating func visitSymbol(_ symbol: Symbol) -> MarkdownOutputNode? { + var metadata = MarkdownOutputNode.Metadata(documentType: .symbol, bundle: bundle, reference: identifier) + + metadata.symbol = .init(symbol, context: context, bundle: bundle) + metadata.role = symbol.kind.displayName + + let document = MarkdownOutputManifest.Document( + uri: identifier.path, + documentType: .symbol, + title: metadata.title + ) + manifest = MarkdownOutputManifest(title: bundle.displayName, documents: [document]) + + // Availability - defaults, overridden with symbol, overriden with metadata + + 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 + } + + 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))) + markdownWalker.visit(symbol.abstract) + if let declarationFragments = symbol.declaration.first?.value.declarationFragments { + let declaration = declarationFragments + .map { $0.spelling } + .joined() + let code = CodeBlock(declaration) + markdownWalker.visit(code) + } + + if let parametersSection = symbol.parametersSection, parametersSection.parameters.isEmpty == false { + markdownWalker.visit(Heading(level: 2, Text(ParametersSection.title ?? "Parameters"))) + for parameter in parametersSection.parameters { + markdownWalker.visit(Paragraph(InlineCode(parameter.name))) + markdownWalker.visit(container: MarkupContainer(parameter.contents)) + } + } + + 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") + } + + manifest?.relationships.formUnion(markdownWalker.outgoingReferences) + + for relationshipGroup in symbol.relationships.groups { + for destination in relationshipGroup.destinations { + switch context.resolve(destination, in: identifier) { + case .success(let resolved): + add(target: resolved, type: .relatedSymbol, subtype: relationshipGroup.kind.rawValue) + case .failure: + if let fallback = symbol.relationships.targetFallbacks[destination] { + add(fallbackTarget: fallback, type: .relatedSymbol, subtype: relationshipGroup.kind.rawValue) + } + } + } + } + return MarkdownOutputNode(metadata: metadata, markdown: markdownWalker.markdown) + } +} + +import SymbolKit + +extension MarkdownOutputNode.Metadata.Symbol { + init(_ symbol: SwiftDocC.Symbol, context: DocumentationContext, bundle: DocumentationBundle) { + + // 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.init( + kind: symbol.kind.identifier.identifier, + preciseIdentifier: symbol.externalID ?? "", + modules: modules + ) + } +} + +extension MarkdownOutputNode.Metadata.Availability { + init(_ item: SymbolGraph.Symbol.Availability.AvailabilityItem) { + 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.init( + platform: availability.platformName.rawValue, + introduced: availability.introducedVersion, + deprecated: nil, + unavailable: availability.versionInformation == .unavailable + ) + } + + init(_ availability: Metadata.Availability) { + 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 + func visitTutorialTableOfContents(_ tutorialTableOfContents: TutorialTableOfContents) -> MarkdownOutputNode? { + return nil + } + + 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 + } + + let document = MarkdownOutputManifest.Document( + uri: identifier.path, + documentType: .tutorial, + title: metadata.title + ) + + manifest = MarkdownOutputManifest(title: metadata.title, documents: [document]) + + sectionIndex = 0 + for child in tutorial.children { + _ = visit(child) + } + return MarkdownOutputNode(metadata: metadata, markdown: markdownWalker.markdown) + } + + mutating func visitTutorialSection(_ tutorialSection: TutorialSection) -> MarkdownOutputNode? { + sectionIndex += 1 + + markdownWalker.visit(Heading(level: 2, Text("Section \(sectionIndex): \(tutorialSection.title)"))) + for child in tutorialSection.children { + _ = visit(child) + } + return nil + } + + mutating func visitSteps(_ steps: Steps) -> MarkdownOutputNode? { + stepIndex = 0 + for child in steps.children { + _ = visit(child) + } + + if let code = lastCode { + markdownWalker.visit(code) + lastCode = nil + } + + return nil + } + + 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 + markdownWalker.visit(code) + } + } else { + // No code, render the current one before proceeding + markdownWalker.visit(code) + lastCode = nil + } + } + + lastCode = step.code + + stepIndex += 1 + markdownWalker.visit(Heading(level: 3, Text("Step \(stepIndex)"))) + for child in step.children { + _ = visit(child) + } + if let media = step.media { + _ = visit(media) + } + return nil + } + + mutating func visitIntro(_ intro: Intro) -> MarkdownOutputNode? { + + markdownWalker.visit(Heading(level: 1, Text(intro.title))) + + for child in intro.children { + _ = visit(child) + } + return nil + } + + mutating func visitMarkupContainer(_ markupContainer: MarkupContainer) -> MarkdownOutputNode? { + markdownWalker.withRemoveIndentation(from: markupContainer.elements.first) { + $0.visit(container: markupContainer) + } + return nil + } + + mutating func visitImageMedia(_ imageMedia: ImageMedia) -> MarkdownOutputNode? { + markdownWalker.visit(imageMedia) + return nil + } + + mutating func visitVideoMedia(_ videoMedia: VideoMedia) -> MarkdownOutputNode? { + markdownWalker.visit(videoMedia) + return nil + } + + mutating func visitContentAndMedia(_ contentAndMedia: ContentAndMedia) -> MarkdownOutputNode? { + for child in contentAndMedia.children { + _ = visit(child) + } + return nil + } + + mutating func visitCode(_ code: Code) -> MarkdownOutputNode? { + // Code rendering is handled in visitStep(_:) + return nil + } +} + + +// MARK: Visitors not currently used for markdown output +extension MarkdownOutputSemanticVisitor { + + mutating func visitXcodeRequirement(_ xcodeRequirement: XcodeRequirement) -> MarkdownOutputNode? { + return nil + } + + mutating func visitAssessments(_ assessments: Assessments) -> MarkdownOutputNode? { + return nil + } + + mutating func visitMultipleChoice(_ multipleChoice: MultipleChoice) -> MarkdownOutputNode? { + return nil + } + + mutating func visitJustification(_ justification: Justification) -> MarkdownOutputNode? { + return nil + } + + mutating func visitChoice(_ choice: Choice) -> MarkdownOutputNode? { + return nil + } + + mutating func visitTechnology(_ technology: TutorialTableOfContents) -> MarkdownOutputNode? { + return nil + } + + mutating func visitVolume(_ volume: Volume) -> MarkdownOutputNode? { + return nil + } + + mutating func visitChapter(_ chapter: Chapter) -> MarkdownOutputNode? { + return nil + } + + mutating func visitTutorialReference(_ tutorialReference: TutorialReference) -> MarkdownOutputNode? { + return nil + } + + mutating func visitResources(_ resources: Resources) -> MarkdownOutputNode? { + return nil + } + + mutating func visitTile(_ tile: Tile) -> MarkdownOutputNode? { + return nil + } + + mutating func visitComment(_ comment: Comment) -> MarkdownOutputNode? { + return nil + } + + mutating func visitTutorialArticle(_ article: TutorialArticle) -> MarkdownOutputNode? { + return nil + } + + mutating func visitStack(_ stack: Stack) -> MarkdownOutputNode? { + return nil + } + + 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 7d71f68d0..f27618c06 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift @@ -177,6 +177,7 @@ 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 { diff --git a/Sources/SwiftDocC/Utility/FeatureFlags.swift b/Sources/SwiftDocC/Utility/FeatureFlags.swift index def7e642d..4280fda3e 100644 --- a/Sources/SwiftDocC/Utility/FeatureFlags.swift +++ b/Sources/SwiftDocC/Utility/FeatureFlags.swift @@ -23,6 +23,12 @@ 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 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/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift b/Sources/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift new file mode 100644 index 000000000..ee9358a4f --- /dev/null +++ b/Sources/SwiftDocCMarkdownOutput/MarkdownOutputManifest.swift @@ -0,0 +1,98 @@ +/* + 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 +@_spi(MarkdownOutput) +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 = []) { + self.manifestVersion = Self.version + self.title = title + self.documents = documents + self.relationships = relationships + } +} + +extension MarkdownOutputManifest { + + public enum DocumentType: String, Codable, Sendable { + case article, tutorial, symbol + } + + public enum RelationshipType: String, Codable, Sendable { + /// For this relationship, the source URI will be the URI of a document, and the target URI will be the topic to which it belongs + case belongsToTopic + /// For this relationship, the source and target URIs will be indicated by the directionality of the subtype, e.g. source "conformsTo" target. + case relatedSymbol + } + + /// A relationship between two documents in the manifest. + /// + /// Parent / child symbol relationships are not included here, because those relationships are implicit in the URI structure of the documents. See ``children(of:)``. + public struct Relationship: Codable, Hashable, Sendable { + + public let sourceURI: String + public let relationshipType: RelationshipType + public let subtype: String? + public let targetURI: String + + public init(sourceURI: String, relationshipType: MarkdownOutputManifest.RelationshipType, subtype: String? = nil, targetURI: String) { + self.sourceURI = sourceURI + self.relationshipType = relationshipType + self.subtype = subtype + self.targetURI = targetURI + } + } + + public struct Document: Codable, Hashable, Sendable { + /// The URI of the document + public let uri: String + /// The type of the document + public let documentType: DocumentType + /// The title of the document + public let title: String + + public init(uri: String, documentType: MarkdownOutputManifest.DocumentType, title: String) { + self.uri = uri + self.documentType = documentType + self.title = title + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(uri) + } + } + + public func children(of parent: Document) -> Set { + 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/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift b/Sources/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift new file mode 100644 index 000000000..1966c08dd --- /dev/null +++ b/Sources/SwiftDocCMarkdownOutput/MarkdownOutputNode.swift @@ -0,0 +1,199 @@ +/* + 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. +@_spi(MarkdownOutput) +public struct MarkdownOutputNode: Sendable { + + /// The metadata about this node + public var metadata: Metadata + /// The markdown content of this node + public var markdown: String = "" + + public init(metadata: Metadata, markdown: String) { + self.metadata = metadata + self.markdown = markdown + } +} + +extension MarkdownOutputNode { + public struct Metadata: Codable, Sendable { + + static let version = "0.1.0" + + public enum DocumentType: String, Codable, Sendable { + case article, tutorial, symbol + } + + public struct Availability: Codable, Equatable, Sendable { + + 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 + // Can't have deprecated without an introduced + self.introduced = introduced ?? deprecated + self.deprecated = deprecated + // If no introduced, we are unavailable + self.unavailable = unavailable || introduced == nil + } + + // For a compact representation on-disk and for human and machine readers, availability is stored as a single string: + // platform: introduced - (not deprecated) + // platform: introduced - deprecated (deprecated) + // platform: - (unavailable) + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(stringRepresentation) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let stringRepresentation = try container.decode(String.self) + self.init(stringRepresentation: stringRepresentation) + } + + public var stringRepresentation: String { + var stringRepresentation = "\(platform): " + if unavailable { + stringRepresentation += "-" + } else { + if let introduced, introduced.isEmpty == false { + stringRepresentation += "\(introduced) -" + if let deprecated, deprecated.isEmpty == false { + stringRepresentation += " \(deprecated)" + } + } else { + stringRepresentation += "-" + } + } + return stringRepresentation + } + + public init(stringRepresentation: String) { + let words = stringRepresentation.split(separator: ":", maxSplits: 1) + if words.count != 2 { + platform = stringRepresentation + unavailable = true + introduced = nil + deprecated = nil + return + } + platform = String(words[0]) + let available = words[1] + .split(separator: "-") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { $0.isEmpty == false } + + introduced = available.first + if available.count > 1 { + deprecated = available.last + } else { + deprecated = nil + } + + unavailable = available.isEmpty + } + + } + + public struct Symbol: Codable, Sendable { + public let kind: String + public let preciseIdentifier: String + public let modules: [String] + + + public init(kind: String, preciseIdentifier: String, modules: [String]) { + self.kind = kind + self.preciseIdentifier = preciseIdentifier + self.modules = modules + } + } + + public let metadataVersion: String + public let documentType: DocumentType + public var role: String? + public let uri: String + public var title: String + public let framework: String + public var symbol: Symbol? + public var availability: [Availability]? + + public init(documentType: DocumentType, uri: String, title: String, framework: String) { + self.documentType = documentType + self.metadataVersion = Self.version + self.uri = uri + self.title = title + self.framework = framework + } + + public func availability(for platform: String) -> Availability? { + availability?.first(where: { $0.platform == platform }) + } + } +} + +// MARK: I/O +extension MarkdownOutputNode { + /// Data for this node to be rendered to disk + public var data: Data { + get throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let metadata = try encoder.encode(metadata) + var data = Data() + data.append(contentsOf: Self.commentOpen) + data.append(metadata) + data.append(contentsOf: Self.commentClose) + data.append(contentsOf: markdown.utf8) + return data + } + } + + private static let commentOpen = "\n\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.. (MarkdownOutputNode, MarkdownOutputManifest) { + let (bundle, context) = try await loadBundle(catalog: catalog) + var path = path + if !path.hasPrefix("/") { + path = "/documentation/MarkdownOutput/\(path)" + } + 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 output = try XCTUnwrap(translator.createOutput()) + let manifest = try XCTUnwrap(output.manifest) + return (output.node, 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 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 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 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 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 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, "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 { + 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) + 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 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 testArticleAvailability() async throws { + 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 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 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:)") + XCTAssert(node.metadata.symbol?.kind == "init") + XCTAssert(node.metadata.role == "Initializer") + XCTAssertEqual(node.metadata.symbol?.modules, ["MarkdownOutput"]) + } + + func testSymbolExtendedModule() async throws { + 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 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 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" })) + } + + 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 testSymbolDeprecation() 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_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") + XCTAssertEqual(availability.unavailable, false) + + 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) + + let visionAvailability = try XCTUnwrap(node.metadata.availability(for: "visionOS")) + XCTAssert(visionAvailability.unavailable) + } + + + func testSymbolIdentifier() async throws { + 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) + XCTAssert(node.metadata.title == "Tutorial Title") + } + + // MARK: - Encoding / Decoding + func testMarkdownRoundTrip() 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: "MarkdownSymbol") + let data = try node.data + let fromData = try MarkdownOutputNode(data) + XCTAssertEqual(node.markdown, fromData.markdown) + XCTAssertEqual(node.metadata.uri, fromData.metadata.uri) + } + + // MARK: - Manifest + func testArticleManifestLinks() async throws { + + 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, + 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 { + // 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")) + 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 = [ + 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" + })) + + 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 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" + })) + + 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" + })) + + 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" + })) + } +}