From 17a6c55e8fe88783647dc4072610c2a264de0052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Mon, 15 Dec 2025 11:39:36 +0100 Subject: [PATCH 1/9] Add CLI flag to insert minimal HTML content in each "index.html" file rdar://163326857 --- .../Actions/Convert/ConvertAction.swift | 16 ++++++++-- .../ConvertAction+CommandInitialization.swift | 1 + .../ArgumentParsing/Subcommands/Convert.swift | 20 ++++++++++++ .../ConvertSubcommandTests.swift | 31 +++++++++++++++++++ features.json | 3 ++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift index 62d2baf744..6d56ab75c4 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift +++ b/Sources/SwiftDocCUtilities/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 @@ -299,9 +303,17 @@ 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) + } else { + htmlConsumer = nil + } if experimentalModifyCatalogWithGeneratedCuration, let catalogURL = rootURL { let writer = GeneratedCurationWriter(context: context, catalogURL: catalogURL, outputURL: catalogURL) @@ -320,7 +332,7 @@ public struct ConvertAction: AsyncAction { try ConvertActionConverter.convert( context: context, outputConsumer: outputConsumer, - htmlContentConsumer: nil, + htmlContentConsumer: htmlConsumer, sourceRepository: sourceRepository, emitDigest: emitDigest, documentationCoverageOptions: documentationCoverageOptions diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift index 4d7272d3a2..8ea1c161f1 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift @@ -78,6 +78,7 @@ extension ConvertAction { experimentalEnableCustomTemplates: convert.experimentalEnableCustomTemplates, experimentalModifyCatalogWithGeneratedCuration: convert.experimentalModifyCatalogWithGeneratedCuration, transformForStaticHosting: convert.transformForStaticHosting, + includeContentInEachHTMLFile: convert.experimentalTransformForStaticHostingWithContent, allowArbitraryCatalogDirectories: convert.allowArbitraryCatalogDirectories, hostingBasePath: convert.hostingBasePath, sourceRepository: SourceRepository(from: convert.sourceRepositoryArguments), diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift index a33715a625..6398b99221 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift @@ -184,6 +184,20 @@ extension Docc { help: "Produce a DocC archive that supports static hosting environments." ) var transformForStaticHosting = true + + @Flag(help: "Include documentation content in each HTML file for static hosting environments.") + var experimentalTransformForStaticHostingWithContent = false + + mutating func validate() throws { + if experimentalTransformForStaticHostingWithContent, !transformForStaticHosting { + warnAboutDiagnostic(.init( + severity: .warning, + identifier: "org.swift.docc.IgnoredNoTransformForStaticHosting", + summary: "Passing '--experimental-transform-for-static-hosting-with-content' also implies '--transform-for-static-hosting'. Passing '--no-transform-for-static-hosting' has no effect." + )) + transformForStaticHosting = true + } + } } /// A Boolean value that is true if the DocC archive produced by this conversion will support static hosting environments. @@ -194,6 +208,12 @@ extension Docc { set { hostingOptions.transformForStaticHosting = newValue } } + /// A Boolean value that is true if the DocC archive produced by this conversion will support browsing without JavaScript enabled. + public var experimentalTransformForStaticHostingWithContent: Bool { + get { hostingOptions.experimentalTransformForStaticHostingWithContent } + set { hostingOptions.experimentalTransformForStaticHostingWithContent = newValue } + } + /// A user-provided relative path to be used in the archived output var hostingBasePath: String? { hostingOptions.hostingBasePath diff --git a/Tests/SwiftDocCUtilitiesTests/ArgumentParsing/ConvertSubcommandTests.swift b/Tests/SwiftDocCUtilitiesTests/ArgumentParsing/ConvertSubcommandTests.swift index 41027fbba1..68f35ac060 100644 --- a/Tests/SwiftDocCUtilitiesTests/ArgumentParsing/ConvertSubcommandTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/ArgumentParsing/ConvertSubcommandTests.swift @@ -596,6 +596,37 @@ class ConvertSubcommandTests: XCTestCase { let disabledFlagConvert = try Docc.Convert.parse(["--disable-mentioned-in"]) XCTAssertEqual(disabledFlagConvert.enableMentionedIn, false) } + + func testStaticHostingWithContentFlag() throws { + // The feature is enabled when no flag is passed. + let noFlagConvert = try Docc.Convert.parse([]) + XCTAssertEqual(noFlagConvert.experimentalTransformForStaticHostingWithContent, false) + + let enabledFlagConvert = try Docc.Convert.parse(["--experimental-transform-for-static-hosting-with-content"]) + XCTAssertEqual(enabledFlagConvert.experimentalTransformForStaticHostingWithContent, true) + + // The '...-transform...-with-content' flag also implies the base '--transform-...' flag. + do { + let originalErrorLogHandle = Docc.Convert._errorLogHandle + let originalDiagnosticFormattingOptions = Docc.Convert._diagnosticFormattingOptions + defer { + Docc.Convert._errorLogHandle = originalErrorLogHandle + Docc.Convert._diagnosticFormattingOptions = originalDiagnosticFormattingOptions + } + + let logStorage = LogHandle.LogStorage() + Docc.Convert._errorLogHandle = .memory(logStorage) + Docc.Convert._diagnosticFormattingOptions = .formatConsoleOutputForTools + + let conflictingFlagsConvert = try Docc.Convert.parse(["--experimental-transform-for-static-hosting-with-content", "--no-transform-for-static-hosting"]) + XCTAssertEqual(conflictingFlagsConvert.experimentalTransformForStaticHostingWithContent, true) + XCTAssertEqual(conflictingFlagsConvert.transformForStaticHosting, true) + + XCTAssertEqual(logStorage.text.trimmingCharacters(in: .whitespacesAndNewlines), """ + warning: Passing '--experimental-transform-for-static-hosting-with-content' also implies '--transform-for-static-hosting'. Passing '--no-transform-for-static-hosting' has no effect. + """) + } + } // This test calls ``ConvertOptions.infoPlistFallbacks._unusedVersionForBackwardsCompatibility`` which is deprecated. // Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. diff --git a/features.json b/features.json index 014b9bf65c..d7b454d6de 100644 --- a/features.json +++ b/features.json @@ -14,6 +14,9 @@ }, { "name": "synthesized-landing-page-name" + }, + { + "name": "static-hosting-with-content" } ] } From 8e996c41cd196bdc0d690b8f423e30531ba5e1ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Mon, 15 Dec 2025 11:43:10 +0100 Subject: [PATCH 2/9] Support index.html files that are missing the expected HTML elements --- .../FileWritingHTMLContentConsumer.swift | 45 +++++--- .../FileWritingHTMLContentConsumerTests.swift | 102 ++++++++++++++++++ 2 files changed, 134 insertions(+), 13 deletions(-) diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift index 2fcaf2eae7..80a7bf8e26 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift @@ -31,23 +31,42 @@ struct FileWritingHTMLContentConsumer: HTMLContentConsumer { var descriptionReplacementRange: Range init(data: Data) throws { - let content = String(decoding: data, as: UTF8.self) + 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 - - let titleStart = content.utf8.firstRange(of: "".utf8)!.upperBound - let titleEnd = 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 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: "