From c530437f9a65d22646eb82ca8cd5b68ee5ec1dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Wed, 27 Aug 2025 18:08:06 +0200 Subject: [PATCH 01/68] Initial HTML output proof of concept --- .../ConvertActionConverter.swift | 13 +- .../ConvertOutputConsumer.swift | 6 +- .../Rendering/HTML/HTMLMarkupRender.swift | 480 ++++++++++ .../Model/Rendering/HTML/HTMLRenderer.swift | 837 ++++++++++++++++++ .../Convert/ConvertFileWritingConsumer.swift | 8 + 5 files changed, 1342 insertions(+), 2 deletions(-) create mode 100644 Sources/SwiftDocC/Model/Rendering/HTML/HTMLMarkupRender.swift create mode 100644 Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift diff --git a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift index 17a5db0a70..79a00b834c 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift @@ -122,8 +122,19 @@ package enum ConvertActionConverter { // Wrap JSON encoding in an autorelease pool to avoid retaining the autoreleased ObjC objects returned by `JSONSerialization` autoreleasepool { do { + // FIXME: This needs a feature flag instead of being hard-coded like this + var renderer = HTMLRenderer(reference: identifier, context: context, renderContext: renderContext) + let entity = try context.entity(with: identifier) - + if let symbol = entity.semantic as? Symbol { + let html = renderer.renderSymbol(symbol) + try outputConsumer.consume(page: html, for: identifier) + } else if let article = entity.semantic as? Article { + let html = renderer.renderArticle(article) + try outputConsumer.consume(page: html, for: identifier) + } + return + guard let renderNode = converter.renderNode(for: entity) else { // No render node was produced for this entity, so just skip it. return diff --git a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift index f5e1ebd432..5915d58bd1 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift @@ -8,7 +8,7 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -import Foundation +public import Foundation /// A consumer for output produced by a documentation conversion. /// @@ -23,6 +23,9 @@ public protocol ConvertOutputConsumer { /// > Warning: This method might be called concurrently. func consume(renderNode: RenderNode) throws + // FIXME: I don't know if the same consumer should handle both kinds of output + func consume(page: XMLNode, for reference: ResolvedTopicReference) throws + /// Consumes a documentation bundle with the purpose of extracting its on-disk assets. func consume(assetsInBundle bundle: DocumentationBundle) throws @@ -58,6 +61,7 @@ public extension ConvertOutputConsumer { func consume(renderReferenceStore: RenderReferenceStore) throws {} func consume(buildMetadata: BuildMetadata) throws {} func consume(linkResolutionInformation: SerializableLinkResolutionInformation) throws {} + func consume(page: XMLNode, for reference: ResolvedTopicReference) throws {} } // Default implementation so that conforming types don't need to implement deprecated API. diff --git a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLMarkupRender.swift b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLMarkupRender.swift new file mode 100644 index 0000000000..c4151919bd --- /dev/null +++ b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLMarkupRender.swift @@ -0,0 +1,480 @@ +/* + 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 Foundation +import Markdown + +struct HTMLMarkupRender: MarkupVisitor { + let reference: ResolvedTopicReference + let context: DocumentationContext + + mutating func defaultVisit(_ markup: any Markdown.Markup) -> XMLNode { + fatalError("A placeholder node also crashes here so we might as well make it explicit") + } + + // + + + mutating func visitParagraph(_ paragraph: Paragraph) -> XMLNode { + .element(named: "p", children: visit(paragraph.children)) + } + + mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> XMLNode { + let aside = Aside(blockQuote) + + var children: [XMLNode] = [ + .element(named: "p", children: [.text(aside.kind.displayName)], attributes: ["class": "label"]) + ] + for child in aside.content { + children.append(visit(child)) + } + + return .element( + named: "blockquote", + children: children, + attributes: ["class": "aside \(aside.kind.rawValue.lowercased())"] + ) + } + + mutating func visitHeading(_ heading: Heading) -> XMLNode { + .element(named: "h\(heading.level)", children: visit(heading.children)) + } + + mutating func visitEmphasis(_ emphasis: Emphasis) -> XMLNode { + .element(named: "i", children: visit(emphasis.children)) + } + + mutating func visitStrong(_ strong: Strong) -> XMLNode { + .element(named: "b", children: visit(strong.children)) + } + + mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> XMLNode { + .element(named: "s", children: visit(strikethrough.children)) + } + + mutating func visitInlineCode(_ inlineCode: InlineCode) -> XMLNode { + .element(named: "code", children: [.text(inlineCode.code)]) + } + + func visitText(_ text: Text) -> XMLNode { + .text(text.string) + } + + func visitLineBreak(_ lineBreak: LineBreak) -> XMLNode { + .element(named: "br") + } + + func visitSoftBreak(_ softBreak: SoftBreak) -> XMLNode { + .text(" ") // A soft line break doesn't actually break the content + } + + func visitThematicBreak(_ thematicBreak: ThematicBreak) -> XMLNode { + .element(named: "hr") + } + + private func _removeComments(from node: XMLNode) { + guard let element = node as? XMLElement, + let children = element.children + else { + return + } + + let withoutComments = children.filter { $0.kind != .comment } + element.setChildren(withoutComments) + + for child in withoutComments { + _removeComments(from: child) + } + } + + func visitHTMLBlock(_ html: HTMLBlock) -> XMLNode { + do { + let parsed = try XMLElement(xmlString: html.rawHTML) + _removeComments(from: parsed) + return parsed + } catch { + return .text("") + } + } + + func visitInlineHTML(_ html: InlineHTML) -> XMLNode { + // Inline HTML is one tag at a time, meaning that the closing and opening tags are parsed separately + // Because of this, we can't parse it with `XMLElement` or `XMLParser`. + + // We assume that we want all tags except for comments + guard !html.rawHTML.hasPrefix("