diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift index 951f735100..27b3904b19 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift @@ -598,7 +598,8 @@ public struct RenderNodeTranslator: SemanticVisitor { public mutating func visitArticle(_ article: Article) -> RenderTree? { var node = RenderNode(identifier: identifier, kind: .article) - var contentCompiler = RenderContentCompiler(context: context, bundle: bundle, identifier: identifier) + // Contains symbol references declared in the Topics section. + var topicSectionContentCompiler = RenderContentCompiler(context: context, bundle: bundle, identifier: identifier) node.metadata.title = article.title!.plainText @@ -674,7 +675,7 @@ public struct RenderNodeTranslator: SemanticVisitor { allowExternalLinks: false, allowedTraits: allowedTraits, availableTraits: documentationNode.availableVariantTraits, - contentCompiler: &contentCompiler + contentCompiler: &topicSectionContentCompiler ) ) } @@ -685,7 +686,7 @@ public struct RenderNodeTranslator: SemanticVisitor { sections.append( contentsOf: renderAutomaticTaskGroupsSection( article.automaticTaskGroups.filter { $0.renderPositionPreference == .top }, - contentCompiler: &contentCompiler + contentCompiler: &topicSectionContentCompiler ) ) } @@ -714,7 +715,7 @@ public struct RenderNodeTranslator: SemanticVisitor { } // Collect all child topic references. - contentCompiler.collectedTopicReferences.append(contentsOf: groups.flatMap(\.references)) + topicSectionContentCompiler.collectedTopicReferences.append(contentsOf: groups.flatMap(\.references)) // Add the final groups to the node. sections.append(contentsOf: groups.map(TaskGroupRenderSection.init(taskGroup:))) } @@ -725,7 +726,7 @@ public struct RenderNodeTranslator: SemanticVisitor { sections.append( contentsOf: renderAutomaticTaskGroupsSection( article.automaticTaskGroups.filter { $0.renderPositionPreference == .bottom }, - contentCompiler: &contentCompiler + contentCompiler: &topicSectionContentCompiler ) ) } @@ -736,11 +737,30 @@ public struct RenderNodeTranslator: SemanticVisitor { node.topicSectionsStyle = topicsSectionStyle(for: documentationNode) if shouldCreateAutomaticRoleHeading(for: documentationNode) { - if node.topicSections.isEmpty { - // Set an eyebrow for articles + + let role = contentRenderer.roleForArticle(article, nodeKind: documentationNode.kind) + node.metadata.role = role.rawValue + + switch role { + case .article: + // If there are no links to other nodes from the article, + // set the eyebrow for articles. node.metadata.roleHeading = "Article" + case .collectionGroup: + // If the article links to other nodes, set the eyebrow for + // API Collections if any linked node is a symbol. + // + // If none of the linked nodes are symbols (it's a plain collection), + // don't display anything as the eyebrow title. + let curatesSymbols = topicSectionContentCompiler.collectedTopicReferences.contains { topicReference in + context.topicGraph.nodeWithReference(topicReference)?.kind.isSymbol ?? false + } + if curatesSymbols { + node.metadata.roleHeading = "API Collection" + } + default: + break } - node.metadata.role = contentRenderer.roleForArticle(article, nodeKind: documentationNode.kind).rawValue } if let pageImages = documentationNode.metadata?.pageImages { @@ -780,7 +800,7 @@ public struct RenderNodeTranslator: SemanticVisitor { allowExternalLinks: true, allowedTraits: allowedTraits, availableTraits: documentationNode.availableVariantTraits, - contentCompiler: &contentCompiler + contentCompiler: &topicSectionContentCompiler ) ) } @@ -794,7 +814,7 @@ public struct RenderNodeTranslator: SemanticVisitor { renderContext: renderContext, renderer: contentRenderer ) { - contentCompiler.collectedTopicReferences.append(contentsOf: seeAlso.references) + topicSectionContentCompiler.collectedTopicReferences.append(contentsOf: seeAlso.references) seeAlsoSections.append(TaskGroupRenderSection( title: seeAlso.title, abstract: nil, @@ -851,7 +871,7 @@ public struct RenderNodeTranslator: SemanticVisitor { node.metadata.roleHeading = titleHeading.heading } - collectedTopicReferences.append(contentsOf: contentCompiler.collectedTopicReferences) + collectedTopicReferences.append(contentsOf: topicSectionContentCompiler.collectedTopicReferences) node.references = createTopicRenderReferences() addReferences(imageReferences, to: &node) @@ -860,7 +880,7 @@ public struct RenderNodeTranslator: SemanticVisitor { addReferences(downloadReferences, to: &node) // See Also can contain external links, we need to separately transfer // link references from the content compiler - addReferences(contentCompiler.linkReferences, to: &node) + addReferences(topicSectionContentCompiler.linkReferences, to: &node) return node } diff --git a/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift b/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift index f50440a8bb..90e3395da6 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift @@ -13,6 +13,7 @@ import XCTest @testable import SwiftDocC import SwiftDocCTestUtilities import Markdown +import SymbolKit class RenderNodeTranslatorTests: XCTestCase { private func findDiscussion(forSymbolPath: String, configureBundle: ((URL) throws -> Void)? = nil) throws -> ContentRenderSection? { @@ -1303,4 +1304,104 @@ class RenderNodeTranslatorTests: XCTestCase { XCTAssertEqual(roundTrippedSymbol.metadata.roleHeading, "TestBed Notes") XCTAssertEqual(roundTrippedSymbol.metadata.role, "collection") } + + func testExpectedRoleHeadingIsAssigned() throws { + func renderNodeArticleFromReferencePath( + referencePath: String + ) throws -> RenderNode { + let reference = ResolvedTopicReference( + bundleIdentifier: bundle.identifier, + path: referencePath, + sourceLanguage: .swift + ) + let symbol = try XCTUnwrap(context.entity(with: reference).semantic as? Article) + var translator = RenderNodeTranslator( + context: context, + bundle: bundle, + identifier: reference, + source: nil + ) + return try XCTUnwrap(translator.visitArticle(symbol) as? RenderNode) + } + + let exampleDocumentation = Folder( + name: "unit-test.docc", + content: [ + TextFile(name: "APICollection.md", utf8Content: """ + # API Collection + My API Collection Abstract. + ## Topics + - ``Symbol`` + - + - + """), + TextFile(name: "Collection.md", utf8Content: """ + # Collection + An abstract with a symbol link: ``MyKit/MyProtocol`` + ## Overview + An overview with a symbol link: ``MyKit/MyProtocol`` + ## Topics + A topic group abstract with a symbol link: ``MyKit/MyProtocol`` + - + - + """), + TextFile(name: "Article.md", utf8Content: """ + # Article + My Article Abstract. + ## Overview + An overview. + """), + TextFile(name: "CustomRole.md", utf8Content: """ + # Article 4 + @Metadata { + @TitleHeading("Custom Role") + } + My Article Abstract. + ## Overview + An overview. + """), + TextFile(name: "SampleCode.md", utf8Content: """ + # Sample Code + @Metadata { + @PageKind(sampleCode) + } + ## Topics + - + """), + JSONFile( + name: "unit-test.symbols.json", + content: makeSymbolGraph( + moduleName: "unit-test", + symbols: [SymbolGraph.Symbol( + identifier: .init(precise: "symbol-id", interfaceLanguage: "swift"), + names: .init(title: "Symbol", navigator: nil, subHeading: nil, prose: nil), + pathComponents: ["Symbol"], + docComment: nil, + accessLevel: .public, + kind: .init(parsedIdentifier: .class, displayName: "Kind Display Name"), + mixins: [:] + )] + ) + ), + ] + ) + let tempURL = try createTempFolder(content: [exampleDocumentation]) + let (_, bundle, context) = try loadBundle(from: tempURL) + + // Assert that articles that curates any symbol gets 'API Collection' assigned as the eyebrow title. + var renderNode = try renderNodeArticleFromReferencePath(referencePath: "/documentation/unit-test/APICollection") + XCTAssertEqual(renderNode.metadata.roleHeading, "API Collection") + // Assert that articles that curates only other articles don't get any value assigned as the eyebrow title. + renderNode = try renderNodeArticleFromReferencePath(referencePath: "/documentation/unit-test/Collection") + XCTAssertEqual(renderNode.metadata.roleHeading, nil) + // Assert that articles that don't curate anything else get 'Article' assigned as the eyebrow title. + renderNode = try renderNodeArticleFromReferencePath(referencePath: "/documentation/unit-test/Article") + XCTAssertEqual(renderNode.metadata.roleHeading, "Article") + // Assert that articles that have a custom title heading the eyebrow title assigned properly. + renderNode = try renderNodeArticleFromReferencePath(referencePath: "/documentation/unit-test/CustomRole") + XCTAssertEqual(renderNode.metadata.roleHeading, "Custom Role") + // Assert that articles that have a custom page kind the eyebrow title assigned properly. + renderNode = try renderNodeArticleFromReferencePath(referencePath: "/documentation/unit-test/SampleCode") + XCTAssertEqual(renderNode.metadata.roleHeading, "Sample Code") + } }