diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift index adad3d7d5..3dd473b1a 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift @@ -1852,23 +1852,45 @@ public struct RenderNodeTranslator: SemanticVisitor { private func variants(for documentationNode: DocumentationNode) -> [RenderNode.Variant] { let generator = PresentationURLGenerator(context: context, baseURL: bundle.baseURL) - return documentationNode.availableSourceLanguages - .sorted(by: { language1, language2 in + var allVariants: [SourceLanguage: ResolvedTopicReference] = documentationNode.availableSourceLanguages.reduce(into: [:]) { partialResult, language in + partialResult[language] = identifier + } + + // Apply alternate representations + if let alternateRepresentations = documentationNode.metadata?.alternateRepresentations { + for alternateRepresentation in alternateRepresentations { + // Only alternate representations which were able to be resolved to a reference should be included as an alternate representation. + // Unresolved alternate representations can be ignored, as they would have been reported during link resolution. + guard case .resolved(.success(let alternateRepresentationReference)) = alternateRepresentation.reference else { + continue + } + + // Add the language representations of the alternate symbol as additional variants for the current symbol. + // Symbols can only specify custom alternate language representations for languages that the documented symbol doesn't already have a representation for. + // If the current symbol and its custom alternate representation share language representations, the custom language representation is ignored. + allVariants.merge( + alternateRepresentationReference.sourceLanguages.map { ($0, alternateRepresentationReference) } + ) { existing, _ in existing } + } + } + + return allVariants + .sorted(by: { variant1, variant2 in // Emit Swift first, then alphabetically. - switch (language1, language2) { + switch (variant1.key, variant2.key) { case (.swift, _): return true case (_, .swift): return false - default: return language1.id < language2.id - } - }) - .map { sourceLanguage in - RenderNode.Variant( - traits: [.interfaceLanguage(sourceLanguage.id)], - paths: [ - generator.presentationURLForReference(identifier).path - ] - ) + default: return variant1.key.id < variant2.key.id } + }) + .map { sourceLanguage, reference in + RenderNode.Variant( + traits: [.interfaceLanguage(sourceLanguage.id)], + paths: [ + generator.presentationURLForReference(reference).path + ] + ) + } } private mutating func convertFragments(_ fragments: [SymbolGraph.Symbol.DeclarationFragments.Fragment]) -> [DeclarationRenderSection.Token] { diff --git a/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift b/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift index b0b267ab0..ee1755b98 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift @@ -1495,4 +1495,84 @@ class RenderNodeTranslatorTests: XCTestCase { } } } + + func testAlternateRepresentationsRenderedAsVariants() throws { + let (bundle, context) = try loadBundle(catalog: Folder( + name: "unit-test.docc", + content: [ + TextFile(name: "Symbol.md", utf8Content: """ + # ``Symbol`` + @Metadata { + @AlternateRepresentation(``CounterpartSymbol``) + } + A symbol extension file defining an alternate representation. + """), + TextFile(name: "OtherSymbol.md", utf8Content: """ + # ``OtherSymbol`` + @Metadata { + @AlternateRepresentation(``MissingCounterpart``) + } + A symbol extension file defining an alternate representation which doesn't exist. + """), + TextFile(name: "MultipleSwiftVariantsSymbol.md", utf8Content: """ + # ``MultipleSwiftVariantsSymbol`` + @Metadata { + @AlternateRepresentation(``Symbol``) + } + A symbol extension file defining an alternate representation which is also in Swift. + """), + JSONFile( + name: "unit-test.swift.symbols.json", + content: makeSymbolGraph( + moduleName: "unit-test", + symbols: [ + makeSymbol(id: "symbol-id", kind: .class, pathComponents: ["Symbol"]), + makeSymbol(id: "other-symbol-id", kind: .class, pathComponents: ["OtherSymbol"]), + makeSymbol(id: "multiple-swift-variants-symbol-id", kind: .class, pathComponents: ["MultipleSwiftVariantsSymbol"]), + ] + ) + ), + JSONFile( + name: "unit-test.occ.symbols.json", + content: makeSymbolGraph( + moduleName: "unit-test", + symbols: [ + makeSymbol(id: "counterpart-symbol-id", language: .objectiveC, kind: .class, pathComponents: ["CounterpartSymbol"]), + ] + ) + ), + ] + )) + + func renderNodeArticleFromReferencePath( + referencePath: String + ) throws -> RenderNode { + let reference = ResolvedTopicReference(bundleID: bundle.id, path: referencePath, sourceLanguage: .swift) + let symbol = try XCTUnwrap(context.entity(with: reference).semantic as? Symbol) + var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: reference) + return try XCTUnwrap(translator.visitSymbol(symbol) as? RenderNode) + } + + // Assert that CounterpartSymbol's source languages have been added as source languages of Symbol + var renderNode = try renderNodeArticleFromReferencePath(referencePath: "/documentation/unit-test/Symbol") + XCTAssertEqual(renderNode.variants?.count, 2) + XCTAssertEqual(renderNode.variants, [ + .init(traits: [.interfaceLanguage("swift")], paths: ["/documentation/unit-test/symbol"]), + .init(traits: [.interfaceLanguage("occ")], paths: ["/documentation/unit-test/counterpartsymbol"]) + ]) + + // Assert that alternate representations which can't be resolved are ignored + renderNode = try renderNodeArticleFromReferencePath(referencePath: "/documentation/unit-test/OtherSymbol") + XCTAssertEqual(renderNode.variants?.count, 1) + XCTAssertEqual(renderNode.variants, [ + .init(traits: [.interfaceLanguage("swift")], paths: ["/documentation/unit-test/othersymbol"]), + ]) + + // Assert that duplicate alternate representations are not added as variants + renderNode = try renderNodeArticleFromReferencePath(referencePath: "/documentation/unit-test/MultipleSwiftVariantsSymbol") + XCTAssertEqual(renderNode.variants?.count, 1) + XCTAssertEqual(renderNode.variants, [ + .init(traits: [.interfaceLanguage("swift")], paths: ["/documentation/unit-test/multipleswiftvariantssymbol"]), + ]) + } }