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 = "" 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: "", 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: "