diff --git a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift index e830202e3..1bf6bcd66 100644 --- a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift +++ b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift @@ -157,13 +157,17 @@ struct HTMLRenderer { mutating func renderArticle(_ article: Article) -> RenderedPageInfo { let node = context.documentationCache[reference]! - let main = XMLElement(name: "main") let articleElement = XMLElement(name: "article") - main.addChild(articleElement) - let hero = XMLElement(name: "section") articleElement.addChild(hero) + // Breadcrumbs and Eyebrow + hero.addChild(renderer.breadcrumbs( + references: (context.shortestFinitePath(to: reference) ?? [context.soleRootModuleReference!]).map { $0.url }, + currentPageNames: .single(.conceptual(node.name.plainText)) + )) + addEyebrow(text: article.topics == nil ? "Article": "API Collection", to: hero) + // Title hero.addChild( .element(named: "h1", children: [.text(node.name.plainText)]) @@ -171,11 +175,12 @@ struct HTMLRenderer { // Abstract if let abstract = article.abstract { - let paragraph = renderer.visit(abstract) as! XMLElement - if goal == .richness { - paragraph.addAttribute(XMLNode.attribute(withName: "id", stringValue: "abstract") as! XMLNode) - } - hero.addChild(paragraph) + addAbstract(abstract, to: hero) + } + + // Deprecation message + if let deprecationMessage = article.deprecationSummary?.elements { + addDeprecationSummary(markup: deprecationMessage, to: hero) } // Discussion @@ -185,8 +190,31 @@ struct HTMLRenderer { ) } + // Topics + if let topics = article.topics { + separateSectionsIfNeeded(in: articleElement) + + // TODO: Support language specific topic sections, indicated using @SupportedLanguage directives (rdar://166308418) + articleElement.addChildren( + renderer.groupedSection(named: "Topics", groups: [ + .swift: topics.taskGroups.map { group in + .init(title: group.heading?.title, content: group.content, references: group.links.compactMap { + $0.destination.flatMap { URL(string: $0) } + }) + } + ]) + ) + } + // Articles don't have _automatic_ topic sections. + + // See Also + if let seeAlso = article.seeAlso { + addSeeAlso(seeAlso, to: articleElement) + } + // _Automatic_ See Also sections are very heavily tied into the RenderJSON model and require information from the JSON to determine. + return RenderedPageInfo( - content: goal == .richness ? main : articleElement, + content: articleElement, metadata: .init( title: article.title?.plainText ?? node.name.plainText, plainDescription: article.abstract?.plainText @@ -195,13 +223,19 @@ struct HTMLRenderer { } mutating func renderSymbol(_ symbol: Symbol) -> RenderedPageInfo { - let main = XMLElement(name: "main") - let articleElement = XMLElement(name: "article") - main.addChild(articleElement) + let node = context.documentationCache[reference]! + let articleElement = XMLElement(name: "article") let hero = XMLElement(name: "section") articleElement.addChild(hero) + // Breadcrumbs and Eyebrow + hero.addChild(renderer.breadcrumbs( + references: (context.linkResolver.localResolver.breadcrumbs(of: reference, in: reference.sourceLanguage) ?? []).map { $0.url }, + currentPageNames: node.makeNames(goal: goal) + )) + addEyebrow(text: symbol.roleHeading, to: hero) + // Title switch symbol.titleVariants.values(goal: goal) { case .single(let title): @@ -223,11 +257,72 @@ struct HTMLRenderer { // Abstract if let abstract = symbol.abstract { - let paragraph = renderer.visit(abstract) as! XMLElement - if goal == .richness { - paragraph.addAttribute(XMLNode.attribute(withName: "id", stringValue: "abstract") as! XMLNode) + addAbstract(abstract, to: hero) + } + + // Availability + if let availability = symbol.availability?.availability.filter({ $0.domain != nil }).sorted(by: \.domain!.rawValue), + !availability.isEmpty + { + hero.addChild( + renderer.availability(availability.map { item in + .init( + name: item.domain!.rawValue, // Verified non-empty above + introduced: item.introducedVersion.map { "\($0.major).\($0.minor)" }, + deprecated: item.deprecatedVersion.map { "\($0.major).\($0.minor)" }, + isBeta: false // TODO: Derive and pass beta information + ) + }) + ) + } + + // Declaration + if !symbol.declarationVariants.allValues.isEmpty { + // TODO: Display platform specific declarations + + var fragmentsByLanguage = [SourceLanguage: [SymbolGraph.Symbol.DeclarationFragments.Fragment]]() + for (trait, variant) in symbol.declarationVariants.allValues { + guard let language = trait.sourceLanguage else { continue } + fragmentsByLanguage[language] = variant.values.first?.declarationFragments } - hero.addChild(paragraph) + + if fragmentsByLanguage.values.contains(where: { !$0.isEmpty }) { + hero.addChild( renderer.declaration(fragmentsByLanguage) ) + } + } + + // Deprecation message + if let deprecationMessage = symbol.deprecatedSummary?.content { + addDeprecationSummary(markup: deprecationMessage, to: hero) + } + + // Parameters + if let parameterSections = symbol.parametersSectionVariants + .values(goal: goal, by: { $0.parameters.elementsEqual($1.parameters, by: { $0.name == $1.name }) }) + .valuesByLanguage() + { + articleElement.addChildren(renderer.parameters( + parameterSections.mapValues { section in + section.parameters.map { + MarkdownRenderer.ParameterInfo(name: $0.name, content: $0.contents) + } + } + )) + } + + // Return value + if !symbol.returnsSectionVariants.allValues.isEmpty { + articleElement.addChildren( + renderer.returns( + .init( + symbol.returnsSectionVariants.allValues.map { trait, returnSection in ( + key: trait.sourceLanguage ?? .swift, + value: returnSection.content + )}, + uniquingKeysWith: { _, new in new } + ) + ) + ) } // Mentioned In @@ -246,6 +341,31 @@ struct HTMLRenderer { ) } + // Topics + do { + // TODO: Support language specific topic sections, indicated using @SupportedLanguage directives (rdar://166308418) + var taskGroupInfo: [MarkdownRenderer.TaskGroupInfo] = [] + + if let authored = symbol.topics?.taskGroups { + taskGroupInfo.append(contentsOf: authored.map { group in + .init(title: group.heading?.title, content: group.content, references: group.links.compactMap { + $0.destination.flatMap { URL(string: $0) } + }) + }) + } + if let automatic = try? AutomaticCuration.topics(for: node, withTraits: [.swift, .objectiveC], context: context) { + taskGroupInfo.append(contentsOf: automatic.map { group in + .init(title: group.title, content: [], references: group.references.compactMap { $0.url }) + }) + } + + if !taskGroupInfo.isEmpty { + separateSectionsIfNeeded(in: articleElement) + + articleElement.addChildren(renderer.groupedSection(named: "Topics", groups: [.swift: taskGroupInfo])) + } + } + // Relationships if let relationships = symbol.relationshipsVariants .values(goal: goal, by: { $0.groups.elementsEqual($1.groups, by: { $0 == $1 }) }) @@ -265,14 +385,68 @@ struct HTMLRenderer { ) } + // See Also + if let seeAlso = symbol.seeAlso { + addSeeAlso(seeAlso, to: articleElement) + } + return RenderedPageInfo( - content: goal == .richness ? main : articleElement, + content: articleElement, metadata: .init( title: symbol.title, plainDescription: symbol.abstract?.plainText ) ) } + + private func addEyebrow(text: String, to element: XMLElement) { + element.addChild( + .element(named: "p", children: [.text(text)], attributes: goal == .richness ? ["id": "eyebrow"] : [:]) + ) + } + + private func addAbstract(_ abstract: Paragraph, to element: XMLElement) { + let paragraph = renderer.visit(abstract) as! XMLElement + if goal == .richness { + paragraph.addAttribute(XMLNode.attribute(withName: "id", stringValue: "abstract") as! XMLNode) + } + element.addChild(paragraph) + } + + private func addDeprecationSummary(markup: [any Markup], to element: XMLElement) { + var children: [XMLNode] = [ + .element(named: "p", children: [.text("Deprecated")], attributes: ["class": "label"]) + ] + for child in markup { + children.append(renderer.visit(child)) + } + + element.addChild( + .element(named: "blockquote", children: children, attributes: ["class": "aside deprecated"]) + ) + } + + private func separateSectionsIfNeeded(in element: XMLElement) { + guard goal == .richness, ((element.children ?? []).last as? XMLElement)?.name == "section" else { + return + } + + element.addChild(.element(named: "hr")) // Separate the sections with a thematic break + } + + private func addSeeAlso(_ seeAlso: SeeAlsoSection, to element: XMLElement) { + separateSectionsIfNeeded(in: element) + + element.addChildren( + renderer.groupedSection(named: "See Also", groups: [ + .swift: seeAlso.taskGroups.map { group in + .init(title: group.heading?.title, content: group.content, references: group.links.compactMap { + $0.destination.flatMap { URL(string: $0) } + }) + } + ]) + ) + } // TODO: As a future enhancement, add another layer on top of this that creates complete HTML pages (both `` and ``) (rdar://165912669) } diff --git a/Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift b/Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift index 42d42ddfc..ac32af05e 100644 --- a/Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift @@ -25,6 +25,10 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase { This is an _formatted_ article. + @DeprecationSummary { + Description of why this _article_ is deprecated. + } + ## Custom discussion It explains how a developer can perform some task using ``SomeClass`` in this module. @@ -46,7 +50,7 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase { .init(kind: .text, spelling: " ", preciseIdentifier: nil), .init(kind: .identifier, spelling: "SomeClass", preciseIdentifier: nil), ]), - makeSymbol(id: "some-protocol-id", kind: .class, pathComponents: ["SomeProtocol"], docComment: """ + makeSymbol(id: "some-protocol-id", kind: .protocol, pathComponents: ["SomeProtocol"], docComment: """ Some in-source description of this protocol. """, declaration: [ .init(kind: .keyword, spelling: "protocol", preciseIdentifier: nil), @@ -60,6 +64,10 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase { Further description of this method and how to use it. + @DeprecationSummary { + Some **formatted** description of why this method is deprecated. + } + - Parameters: - first: Description of the `first` parameter. - second: Description of the `second` parameter. @@ -206,9 +214,39 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {
@@ -229,8 +267,18 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {