diff --git a/Sources/DocCHTML/CMakeLists.txt b/Sources/DocCHTML/CMakeLists.txt index bf168988c..786cf5626 100644 --- a/Sources/DocCHTML/CMakeLists.txt +++ b/Sources/DocCHTML/CMakeLists.txt @@ -12,6 +12,7 @@ add_library(DocCHTML STATIC MarkdownRenderer+Availability.swift MarkdownRenderer+Breadcrumbs.swift MarkdownRenderer+Declaration.swift + MarkdownRenderer+Discussion.swift MarkdownRenderer+Parameters.swift MarkdownRenderer+Returns.swift MarkdownRenderer+Topics.swift diff --git a/Sources/DocCHTML/MarkdownRenderer+Discussion.swift b/Sources/DocCHTML/MarkdownRenderer+Discussion.swift new file mode 100644 index 000000000..9bf524436 --- /dev/null +++ b/Sources/DocCHTML/MarkdownRenderer+Discussion.swift @@ -0,0 +1,40 @@ +/* + 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 +*/ + +#if canImport(FoundationXML) +// TODO: Consider other HTML rendering options as a future improvement (rdar://165755530) +package import FoundationXML +#else +package import Foundation +#endif + +package import Markdown + +package extension MarkdownRenderer { + + /// Creates a discussion section with the given markup. + /// + /// If the markup doesn't start with a level-2 heading, the renderer will insert a level-2 heading based on the `fallbackSectionName`. + func discussion(_ markup: [any Markup], fallbackSectionName: String) -> [XMLNode] { + guard !markup.isEmpty else { return [] } + var remaining = markup[...] + + let sectionName: String + // Check if the markup already contains an explicit heading + if let heading = remaining.first as? Heading, heading.level == 2 { + _ = remaining.removeFirst() // Remove the heading so that it's not rendered twice + sectionName = heading.plainText + } else { + sectionName = fallbackSectionName + } + + return selfReferencingSection(named: sectionName, content: remaining.map { visit($0) }) + } +} diff --git a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift index fae8cddcb..59b05046c 100644 --- a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift +++ b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift @@ -178,6 +178,13 @@ struct HTMLRenderer { hero.addChild(paragraph) } + // Discussion + if let discussion = article.discussion { + articleElement.addChildren( + renderer.discussion(discussion.content, fallbackSectionName: "Overview") + ) + } + return RenderedPageInfo( content: goal == .richness ? main : articleElement, metadata: .init( @@ -223,6 +230,13 @@ struct HTMLRenderer { hero.addChild(paragraph) } + // Discussion + if let discussion = symbol.discussion { + articleElement.addChildren( + renderer.discussion(discussion.content, fallbackSectionName: symbol.kind.identifier.swiftSymbolCouldHaveChildren ? "Overview" : "Discussion") + ) + } + return RenderedPageInfo( content: goal == .richness ? main : articleElement, metadata: .init( diff --git a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift index 199a88a9b..e606d648a 100644 --- a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift +++ b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift @@ -641,6 +641,79 @@ struct MarkdownRenderer_PageElementsTests { } } + @Test(arguments: RenderGoal.allCases) + func testEmptyDiscussionSection(goal: RenderGoal) { + let renderer = makeRenderer(goal: goal) + let discussion = renderer.discussion([], fallbackSectionName: "Fallback") + #expect(discussion.isEmpty) + } + + @Test(arguments: RenderGoal.allCases) + func testDiscussionSectionWithoutHeading(goal: RenderGoal) { + let renderer = makeRenderer(goal: goal) + let discussion = renderer.discussion(parseMarkup(string: """ + First paragraph + + Second paragraph + """), fallbackSectionName: "Fallback") + + let commonHTML = """ +

First paragraph

+

Second paragraph

+ """ + + switch goal { + case .richness: + discussion.assertMatches(prettyFormatted: true, expectedXMLString: """ +
+

+ Fallback +

+ \(commonHTML) +
+ """) + case .conciseness: + discussion.assertMatches(prettyFormatted: true, expectedXMLString: """ +

Fallback

+ \(commonHTML) + """) + } + } + + @Test(arguments: RenderGoal.allCases) + func testDiscussionSectionWithHeading(goal: RenderGoal) { + let renderer = makeRenderer(goal: goal) + let discussion = renderer.discussion(parseMarkup(string: """ + ## Some Heading + + First paragraph + + Second paragraph + """), fallbackSectionName: "Fallback") + + let commonHTML = """ +

First paragraph

+

Second paragraph

+ """ + + switch goal { + case .richness: + discussion.assertMatches(prettyFormatted: true, expectedXMLString: """ +
+

+ Some Heading +

+ \(commonHTML) +
+ """) + case .conciseness: + discussion.assertMatches(prettyFormatted: true, expectedXMLString: """ +

Some Heading

+ \(commonHTML) + """) + } + } + // MARK: - private func makeRenderer( diff --git a/Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift b/Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift index 9190fba8b..fcaea8bd8 100644 --- a/Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift @@ -242,6 +242,8 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {

someMethod(with:and:)

Some in-source description of this method.

+

Discussion

+

Further description of this method and how to use it.

@@ -264,6 +266,10 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {

Some article

This is an formatted article.

+

Custom discussion

+

It explains how a developer can perform some task using SomeClass in this module.

+

Details

+

This subsection describes something more detailed.