diff --git a/Sources/DocCCommandLine/Action/Actions/Convert/ConvertAction.swift b/Sources/DocCCommandLine/Action/Actions/Convert/ConvertAction.swift
index 62d2baf744..b9fb3a088d 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 c17eaf7720..c4104d8ea3 100644
--- a/Sources/DocCCommandLine/Action/Actions/Convert/ConvertFileWritingConsumer.swift
+++ b/Sources/DocCCommandLine/Action/Actions/Convert/ConvertFileWritingConsumer.swift
@@ -237,7 +237,7 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer {
let template = "\(templateContents)"
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 2fcaf2eae7..83db0611ac 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: "\(template.content)", 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: "