From 9d0b830921292d5c28c94b8c8ebdda12e1f06a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 18 Dec 2025 14:05:08 +0100 Subject: [PATCH 1/2] Make a tiny simplification to the parameters HTML output (#1387) --- .../MarkdownRenderer+Parameters.swift | 8 +--- .../FileWritingHTMLContentConsumerTests.swift | 8 +--- .../MarkdownRenderer+PageElementsTests.swift | 44 +++++-------------- 3 files changed, 15 insertions(+), 45 deletions(-) diff --git a/Sources/DocCHTML/MarkdownRenderer+Parameters.swift b/Sources/DocCHTML/MarkdownRenderer+Parameters.swift index a316f6e49..45a43589f 100644 --- a/Sources/DocCHTML/MarkdownRenderer+Parameters.swift +++ b/Sources/DocCHTML/MarkdownRenderer+Parameters.swift @@ -78,9 +78,7 @@ package extension MarkdownRenderer { for parameter in parameterInfo { // name items.append( - .element(named: "dt", children: [ - .element(named: "code", children: [.text(parameter.name)]) - ]) + .element(named: "dt", children: [.text(parameter.name)]) ) // description items.append( @@ -131,9 +129,7 @@ package extension MarkdownRenderer { let index = (offset + primaryOnlyIndices.count(where: { $0 < offset })) * 2 items.insert(contentsOf: [ // Name - .element(named: "dt", children: [ - .element(named: "code", children: [.text(parameter.name)]) - ], attributes: ["class": "\(secondary.language.id)-only"]), + .element(named: "dt", children: [.text(parameter.name)], attributes: ["class": "\(secondary.language.id)-only"]), // Description .element(named: "dd", children: parameter.content.map { visit($0) }, attributes: ["class": "\(secondary.language.id)-only"]) ], at: index) diff --git a/Tests/DocCCommandLineTests/FileWritingHTMLContentConsumerTests.swift b/Tests/DocCCommandLineTests/FileWritingHTMLContentConsumerTests.swift index 9e20d7feb..6d4297a5f 100644 --- a/Tests/DocCCommandLineTests/FileWritingHTMLContentConsumerTests.swift +++ b/Tests/DocCCommandLineTests/FileWritingHTMLContentConsumerTests.swift @@ -347,15 +347,11 @@ final class FileWritingHTMLContentConsumerTests: XCTestCase {

Parameters

-
- first -
+
first

Description of the first parameter.

-
- second -
+
second

Description of the second parameter.

diff --git a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift index 0fcbbe58c..1503d7511 100644 --- a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift +++ b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift @@ -131,17 +131,13 @@ struct MarkdownRenderer_PageElementsTests { Parameters
-
- First -
+
First

Some formatted description with code

-
- Second -
+
Second

Some other formatted description

@@ -154,16 +150,12 @@ struct MarkdownRenderer_PageElementsTests { parameters.assertMatches(prettyFormatted: true, expectedXMLString: """

Parameters

-
- First -
+
First

Some formatteddescription with code

-
- Second -
+
Second

Some other formatted description

@@ -194,27 +186,19 @@ struct MarkdownRenderer_PageElementsTests { Parameters
-
- FirstCommon -
+
FirstCommon

Available in both languages

-
- SwiftOnly -
+
SwiftOnly

Only available in Swift

-
- SecondCommon -
+
SecondCommon

Also available in both languages

-
- ObjectiveCOnly -
+
ObjectiveCOnly

Only available in Objective-C

@@ -242,25 +226,19 @@ struct MarkdownRenderer_PageElementsTests { Parameters
-
- First -
+
First

Some description

-
- Third -
+
Third

Some description

-
- Second -
+
Second

Some description

From 5a13f10e8849c856396d3ef630951ee0db7a5fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 18 Dec 2025 14:51:40 +0100 Subject: [PATCH 2/2] Add CLI flag to enable DocC adding minimal per-page HTML content to each index.html file (#1396) * Add CLI flag to insert minimal HTML content in each "index.html" file rdar://163326857 * Support index.html files that are missing the expected HTML elements * Test that index.html files with content includes the hosting base path * Support custom header/footer templates alongside the per-page content * Remove properties that are never read * Avoid HTML encoding difference between platforms in new test * Add code comments about confusing test behaviors * Apply suggestions from code review Co-authored-by: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> * Fix typo in test data --------- Co-authored-by: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> --- .../Actions/Convert/ConvertAction.swift | 31 +++- .../Convert/ConvertFileWritingConsumer.swift | 2 +- .../FileWritingHTMLContentConsumer.swift | 77 ++++++--- .../ConvertAction+CommandInitialization.swift | 1 + .../ArgumentParsing/Subcommands/Convert.swift | 20 +++ .../ConvertSubcommandTests.swift | 31 ++++ .../FileWritingHTMLContentConsumerTests.swift | 118 +++++++++++++- .../StaticHostingWithContentTests.swift | 150 ++++++++++++++++++ features.json | 3 + 9 files changed, 404 insertions(+), 29 deletions(-) create mode 100644 Tests/DocCCommandLineTests/StaticHostingWithContentTests.swift diff --git a/Sources/DocCCommandLine/Action/Actions/Convert/ConvertAction.swift b/Sources/DocCCommandLine/Action/Actions/Convert/ConvertAction.swift index 62d2baf74..b9fb3a088 100644 --- a/Sources/DocCCommandLine/Action/Actions/Convert/ConvertAction.swift +++ b/Sources/DocCCommandLine/Action/Actions/Convert/ConvertAction.swift @@ -30,6 +30,7 @@ public struct ConvertAction: AsyncAction { let diagnosticEngine: DiagnosticEngine private let transformForStaticHosting: Bool + private let includeContentInEachHTMLFile: Bool private let hostingBasePath: String? let sourceRepository: SourceRepository? @@ -64,6 +65,7 @@ public struct ConvertAction: AsyncAction { /// - experimentalEnableCustomTemplates: `true` if the convert action should enable support for custom "header.html" and "footer.html" template files, otherwise `false`. /// - experimentalModifyCatalogWithGeneratedCuration: `true` if the convert action should write documentation extension files containing markdown representations of DocC's automatic curation into the `documentationBundleURL`, otherwise `false`. /// - transformForStaticHosting: `true` if the convert action should process the build documentation archive so that it supports a static hosting environment, otherwise `false`. + /// - includeContentInEachHTMLFile: `true` if the convert action should process each static hosting HTML file so that it includes documentation content for environments without JavaScript enabled, otherwise `false`. /// - allowArbitraryCatalogDirectories: `true` if the convert action should consider the root location as a documentation bundle if it doesn't discover another bundle, otherwise `false`. /// - hostingBasePath: The base path where the built documentation archive will be hosted at. /// - sourceRepository: The source repository where the documentation's sources are hosted. @@ -91,6 +93,7 @@ public struct ConvertAction: AsyncAction { experimentalEnableCustomTemplates: Bool = false, experimentalModifyCatalogWithGeneratedCuration: Bool = false, transformForStaticHosting: Bool = false, + includeContentInEachHTMLFile: Bool = false, allowArbitraryCatalogDirectories: Bool = false, hostingBasePath: String? = nil, sourceRepository: SourceRepository? = nil, @@ -105,6 +108,7 @@ public struct ConvertAction: AsyncAction { self.temporaryDirectory = temporaryDirectory self.documentationCoverageOptions = documentationCoverageOptions self.transformForStaticHosting = transformForStaticHosting + self.includeContentInEachHTMLFile = includeContentInEachHTMLFile self.hostingBasePath = hostingBasePath self.sourceRepository = sourceRepository @@ -189,6 +193,11 @@ public struct ConvertAction: AsyncAction { /// A block of extra work that tests perform to affect the time it takes to convert documentation var _extraTestWork: (() async -> Void)? + /// The `Indexer` type doesn't work with virtual file systems. + /// + /// Tests that don't verify the contents of the navigator index can set this to `true` so that they can use a virtual, in-memory, file system. + var _completelySkipBuildingIndex: Bool = false + /// Converts each eligible file from the source documentation bundle, /// saves the results in the given output alongside the template files. public func perform(logHandle: inout LogHandle) async throws -> ActionResult { @@ -286,7 +295,7 @@ public struct ConvertAction: AsyncAction { workingDirectory: temporaryFolder, fileManager: fileManager) - let indexer = try Indexer(outputURL: temporaryFolder, bundleID: inputs.id) + let indexer = _completelySkipBuildingIndex ? nil : try Indexer(outputURL: temporaryFolder, bundleID: inputs.id) let registerInterval = signposter.beginInterval("Register", id: signposter.makeSignpostID()) let context = try await DocumentationContext(bundle: inputs, dataProvider: dataProvider, diagnosticEngine: diagnosticEngine, configuration: configuration) @@ -299,9 +308,23 @@ public struct ConvertAction: AsyncAction { context: context, indexer: indexer, enableCustomTemplates: experimentalEnableCustomTemplates, - transformForStaticHostingIndexHTML: transformForStaticHosting ? indexHTML : nil, + // Don't transform for static hosting if the `FileWritingHTMLContentConsumer` will create per-page index.html files + transformForStaticHostingIndexHTML: transformForStaticHosting && !includeContentInEachHTMLFile ? indexHTML : nil, bundleID: inputs.id ) + + let htmlConsumer: FileWritingHTMLContentConsumer? + if includeContentInEachHTMLFile, let indexHTML { + htmlConsumer = try FileWritingHTMLContentConsumer( + targetFolder: temporaryFolder, + fileManager: fileManager, + htmlTemplate: indexHTML, + customHeader: experimentalEnableCustomTemplates ? inputs.customHeader : nil, + customFooter: experimentalEnableCustomTemplates ? inputs.customFooter : nil + ) + } else { + htmlConsumer = nil + } if experimentalModifyCatalogWithGeneratedCuration, let catalogURL = rootURL { let writer = GeneratedCurationWriter(context: context, catalogURL: catalogURL, outputURL: catalogURL) @@ -320,7 +343,7 @@ public struct ConvertAction: AsyncAction { try ConvertActionConverter.convert( context: context, outputConsumer: outputConsumer, - htmlContentConsumer: nil, + htmlContentConsumer: htmlConsumer, sourceRepository: sourceRepository, emitDigest: emitDigest, documentationCoverageOptions: documentationCoverageOptions @@ -375,7 +398,7 @@ public struct ConvertAction: AsyncAction { } // If we're building a navigation index, finalize the process and collect encountered problems. - do { + if let indexer { let finalizeNavigationIndexMetric = benchmark(begin: Benchmark.Duration(id: "finalize-navigation-index")) // Always emit a JSON representation of the index but only emit the LMDB diff --git a/Sources/DocCCommandLine/Action/Actions/Convert/ConvertFileWritingConsumer.swift b/Sources/DocCCommandLine/Action/Actions/Convert/ConvertFileWritingConsumer.swift index 56e492585..9b570988d 100644 --- a/Sources/DocCCommandLine/Action/Actions/Convert/ConvertFileWritingConsumer.swift +++ b/Sources/DocCCommandLine/Action/Actions/Convert/ConvertFileWritingConsumer.swift @@ -227,7 +227,7 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer { let template = "" var newIndexContents = indexContents newIndexContents.replaceSubrange(bodyTagRange, with: indexContents[bodyTagRange] + template) - try newIndexContents.write(to: index, atomically: true, encoding: .utf8) + try fileManager.createFile(at: index, contents: Data(newIndexContents.utf8)) } /// File name for the documentation coverage file emitted during conversion. diff --git a/Sources/DocCCommandLine/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift b/Sources/DocCCommandLine/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift index 2fcaf2eae..83db0611a 100644 --- a/Sources/DocCCommandLine/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift +++ b/Sources/DocCCommandLine/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift @@ -20,8 +20,6 @@ import SwiftDocC import DocCHTML struct FileWritingHTMLContentConsumer: HTMLContentConsumer { - var targetFolder: URL - var fileManager: any FileManagerProtocol var prettyPrintOutput: Bool private struct HTMLTemplate { @@ -30,24 +28,51 @@ struct FileWritingHTMLContentConsumer: HTMLContentConsumer { var titleReplacementRange: Range var descriptionReplacementRange: Range - init(data: Data) throws { - let content = String(decoding: data, as: UTF8.self) + struct CustomTemplate { + var id, content: String + } + + init(data: Data, customTemplates: [CustomTemplate]) throws { + var content = String(decoding: data, as: UTF8.self) - // ???: Should we parse the content with XMLParser instead? If so, what do we do if it's not valid XHTML? - let noScriptStart = content.utf8.firstRange(of: "".utf8)!.lowerBound + // Ensure that the index.html file has at least a `` and a ``. + guard var beforeEndOfHead = content.utf8.firstRange(of: "".utf8)?.lowerBound, + var afterStartOfBody = content.range(of: "]*>", options: .regularExpression)?.upperBound + else { + struct MissingRequiredTagsError: DescribedError { + let errorDescription = "Missing required `` and `` elements in \"index.html\" file." + } + throw MissingRequiredTagsError() + } - let titleStart = content.utf8.firstRange(of: "".utf8)!.upperBound - let titleEnd = content.utf8.firstRange(of: "".utf8)!.lowerBound + for template in customTemplates { // Use the order as `ConvertFileWritingConsumer` + content.insert(contentsOf: "", at: afterStartOfBody) + } - let beforeHeadEnd = content.utf8.firstRange(of: "".utf8)!.lowerBound + if let titleStart = content.utf8.firstRange(of: "".utf8)?.upperBound, + let titleEnd = content.utf8.firstRange(of: "".utf8)?.lowerBound + { + titleReplacementRange = titleStart ..< titleEnd + } else { + content.insert(contentsOf: "", at: beforeEndOfHead) + content.utf8.formIndex(&beforeEndOfHead, offsetBy: "".utf8.count) + content.utf8.formIndex(&afterStartOfBody, offsetBy: "".utf8.count) + let titleInside = content.utf8.index(beforeEndOfHead, offsetBy: -"".utf8.count) + titleReplacementRange = titleInside ..< titleInside + } + if let noScriptStart = content.utf8.firstRange(of: "".utf8)?.lowerBound + { + contentReplacementRange = noScriptStart ..< noScriptEnd + } else { + content.insert(contentsOf: "", at: afterStartOfBody) + let noScriptInside = content.utf8.index(afterStartOfBody, offsetBy: "