Skip to content

Commit b21d94f

Browse files
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 <[email protected]> * Fix typo in test data --------- Co-authored-by: Andrea Fernandez Buitrago <[email protected]>
1 parent f2b213d commit b21d94f

File tree

9 files changed

+404
-29
lines changed

9 files changed

+404
-29
lines changed

Sources/DocCCommandLine/Action/Actions/Convert/ConvertAction.swift

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public struct ConvertAction: AsyncAction {
3030
let diagnosticEngine: DiagnosticEngine
3131

3232
private let transformForStaticHosting: Bool
33+
private let includeContentInEachHTMLFile: Bool
3334
private let hostingBasePath: String?
3435

3536
let sourceRepository: SourceRepository?
@@ -64,6 +65,7 @@ public struct ConvertAction: AsyncAction {
6465
/// - experimentalEnableCustomTemplates: `true` if the convert action should enable support for custom "header.html" and "footer.html" template files, otherwise `false`.
6566
/// - experimentalModifyCatalogWithGeneratedCuration: `true` if the convert action should write documentation extension files containing markdown representations of DocC's automatic curation into the `documentationBundleURL`, otherwise `false`.
6667
/// - transformForStaticHosting: `true` if the convert action should process the build documentation archive so that it supports a static hosting environment, otherwise `false`.
68+
/// - 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`.
6769
/// - allowArbitraryCatalogDirectories: `true` if the convert action should consider the root location as a documentation bundle if it doesn't discover another bundle, otherwise `false`.
6870
/// - hostingBasePath: The base path where the built documentation archive will be hosted at.
6971
/// - sourceRepository: The source repository where the documentation's sources are hosted.
@@ -91,6 +93,7 @@ public struct ConvertAction: AsyncAction {
9193
experimentalEnableCustomTemplates: Bool = false,
9294
experimentalModifyCatalogWithGeneratedCuration: Bool = false,
9395
transformForStaticHosting: Bool = false,
96+
includeContentInEachHTMLFile: Bool = false,
9497
allowArbitraryCatalogDirectories: Bool = false,
9598
hostingBasePath: String? = nil,
9699
sourceRepository: SourceRepository? = nil,
@@ -105,6 +108,7 @@ public struct ConvertAction: AsyncAction {
105108
self.temporaryDirectory = temporaryDirectory
106109
self.documentationCoverageOptions = documentationCoverageOptions
107110
self.transformForStaticHosting = transformForStaticHosting
111+
self.includeContentInEachHTMLFile = includeContentInEachHTMLFile
108112
self.hostingBasePath = hostingBasePath
109113
self.sourceRepository = sourceRepository
110114

@@ -189,6 +193,11 @@ public struct ConvertAction: AsyncAction {
189193
/// A block of extra work that tests perform to affect the time it takes to convert documentation
190194
var _extraTestWork: (() async -> Void)?
191195

196+
/// The `Indexer` type doesn't work with virtual file systems.
197+
///
198+
/// 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.
199+
var _completelySkipBuildingIndex: Bool = false
200+
192201
/// Converts each eligible file from the source documentation bundle,
193202
/// saves the results in the given output alongside the template files.
194203
public func perform(logHandle: inout LogHandle) async throws -> ActionResult {
@@ -286,7 +295,7 @@ public struct ConvertAction: AsyncAction {
286295
workingDirectory: temporaryFolder,
287296
fileManager: fileManager)
288297

289-
let indexer = try Indexer(outputURL: temporaryFolder, bundleID: inputs.id)
298+
let indexer = _completelySkipBuildingIndex ? nil : try Indexer(outputURL: temporaryFolder, bundleID: inputs.id)
290299

291300
let registerInterval = signposter.beginInterval("Register", id: signposter.makeSignpostID())
292301
let context = try await DocumentationContext(bundle: inputs, dataProvider: dataProvider, diagnosticEngine: diagnosticEngine, configuration: configuration)
@@ -299,9 +308,23 @@ public struct ConvertAction: AsyncAction {
299308
context: context,
300309
indexer: indexer,
301310
enableCustomTemplates: experimentalEnableCustomTemplates,
302-
transformForStaticHostingIndexHTML: transformForStaticHosting ? indexHTML : nil,
311+
// Don't transform for static hosting if the `FileWritingHTMLContentConsumer` will create per-page index.html files
312+
transformForStaticHostingIndexHTML: transformForStaticHosting && !includeContentInEachHTMLFile ? indexHTML : nil,
303313
bundleID: inputs.id
304314
)
315+
316+
let htmlConsumer: FileWritingHTMLContentConsumer?
317+
if includeContentInEachHTMLFile, let indexHTML {
318+
htmlConsumer = try FileWritingHTMLContentConsumer(
319+
targetFolder: temporaryFolder,
320+
fileManager: fileManager,
321+
htmlTemplate: indexHTML,
322+
customHeader: experimentalEnableCustomTemplates ? inputs.customHeader : nil,
323+
customFooter: experimentalEnableCustomTemplates ? inputs.customFooter : nil
324+
)
325+
} else {
326+
htmlConsumer = nil
327+
}
305328

306329
if experimentalModifyCatalogWithGeneratedCuration, let catalogURL = rootURL {
307330
let writer = GeneratedCurationWriter(context: context, catalogURL: catalogURL, outputURL: catalogURL)
@@ -320,7 +343,7 @@ public struct ConvertAction: AsyncAction {
320343
try ConvertActionConverter.convert(
321344
context: context,
322345
outputConsumer: outputConsumer,
323-
htmlContentConsumer: nil,
346+
htmlContentConsumer: htmlConsumer,
324347
sourceRepository: sourceRepository,
325348
emitDigest: emitDigest,
326349
documentationCoverageOptions: documentationCoverageOptions
@@ -375,7 +398,7 @@ public struct ConvertAction: AsyncAction {
375398
}
376399

377400
// If we're building a navigation index, finalize the process and collect encountered problems.
378-
do {
401+
if let indexer {
379402
let finalizeNavigationIndexMetric = benchmark(begin: Benchmark.Duration(id: "finalize-navigation-index"))
380403

381404
// Always emit a JSON representation of the index but only emit the LMDB

Sources/DocCCommandLine/Action/Actions/Convert/ConvertFileWritingConsumer.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer {
237237
let template = "<template id=\"\(id.rawValue)\">\(templateContents)</template>"
238238
var newIndexContents = indexContents
239239
newIndexContents.replaceSubrange(bodyTagRange, with: indexContents[bodyTagRange] + template)
240-
try newIndexContents.write(to: index, atomically: true, encoding: .utf8)
240+
try fileManager.createFile(at: index, contents: Data(newIndexContents.utf8))
241241
}
242242

243243
/// File name for the documentation coverage file emitted during conversion.

Sources/DocCCommandLine/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@ import SwiftDocC
2020
import DocCHTML
2121

2222
struct FileWritingHTMLContentConsumer: HTMLContentConsumer {
23-
var targetFolder: URL
24-
var fileManager: any FileManagerProtocol
2523
var prettyPrintOutput: Bool
2624

2725
private struct HTMLTemplate {
@@ -30,24 +28,51 @@ struct FileWritingHTMLContentConsumer: HTMLContentConsumer {
3028
var titleReplacementRange: Range<String.Index>
3129
var descriptionReplacementRange: Range<String.Index>
3230

33-
init(data: Data) throws {
34-
let content = String(decoding: data, as: UTF8.self)
31+
struct CustomTemplate {
32+
var id, content: String
33+
}
34+
35+
init(data: Data, customTemplates: [CustomTemplate]) throws {
36+
var content = String(decoding: data, as: UTF8.self)
3537

36-
// ???: Should we parse the content with XMLParser instead? If so, what do we do if it's not valid XHTML?
37-
let noScriptStart = content.utf8.firstRange(of: "<noscript>".utf8)!.upperBound
38-
let noScriptEnd = content.utf8.firstRange(of: "</noscript>".utf8)!.lowerBound
38+
// Ensure that the index.html file has at least a `<head>` and a `<body>`.
39+
guard var beforeEndOfHead = content.utf8.firstRange(of: "</head>".utf8)?.lowerBound,
40+
var afterStartOfBody = content.range(of: "<body[^>]*>", options: .regularExpression)?.upperBound
41+
else {
42+
struct MissingRequiredTagsError: DescribedError {
43+
let errorDescription = "Missing required `<head>` and `<body>` elements in \"index.html\" file."
44+
}
45+
throw MissingRequiredTagsError()
46+
}
3947

40-
let titleStart = content.utf8.firstRange(of: "<title>".utf8)!.upperBound
41-
let titleEnd = content.utf8.firstRange(of: "</title>".utf8)!.lowerBound
48+
for template in customTemplates { // Use the order as `ConvertFileWritingConsumer`
49+
content.insert(contentsOf: "<template id=\"\(template.id)\">\(template.content)</template>", at: afterStartOfBody)
50+
}
4251

43-
let beforeHeadEnd = content.utf8.firstRange(of: "</head>".utf8)!.lowerBound
52+
if let titleStart = content.utf8.firstRange(of: "<title>".utf8)?.upperBound,
53+
let titleEnd = content.utf8.firstRange(of: "</title>".utf8)?.lowerBound
54+
{
55+
titleReplacementRange = titleStart ..< titleEnd
56+
} else {
57+
content.insert(contentsOf: "<title></title>", at: beforeEndOfHead)
58+
content.utf8.formIndex(&beforeEndOfHead, offsetBy: "<title></title>".utf8.count)
59+
content.utf8.formIndex(&afterStartOfBody, offsetBy: "<title></title>".utf8.count)
60+
let titleInside = content.utf8.index(beforeEndOfHead, offsetBy: -"</title>".utf8.count)
61+
titleReplacementRange = titleInside ..< titleInside
62+
}
4463

64+
if let noScriptStart = content.utf8.firstRange(of: "<noscript>".utf8)?.upperBound,
65+
let noScriptEnd = content.utf8.firstRange(of: "</noscript>".utf8)?.lowerBound
66+
{
67+
contentReplacementRange = noScriptStart ..< noScriptEnd
68+
} else {
69+
content.insert(contentsOf: "<noscript></noscript>", at: afterStartOfBody)
70+
let noScriptInside = content.utf8.index(afterStartOfBody, offsetBy: "<noscript>".utf8.count)
71+
contentReplacementRange = noScriptInside ..< noScriptInside
72+
}
73+
4574
original = content
46-
// TODO: If the template doesn't already contain a <noscript> element, add one to the start of the <body> element
47-
// TODO: If the template doesn't already contain a <title> element, add one to the end of the <head> element
48-
contentReplacementRange = noScriptStart ..< noScriptEnd
49-
titleReplacementRange = titleStart ..< titleEnd
50-
descriptionReplacementRange = beforeHeadEnd ..< beforeHeadEnd
75+
descriptionReplacementRange = beforeEndOfHead ..< beforeEndOfHead
5176

5277
assert(titleReplacementRange.upperBound < descriptionReplacementRange.lowerBound, "The title replacement range should be before the description replacement range")
5378
assert(descriptionReplacementRange.upperBound < contentReplacementRange.lowerBound, "The description replacement range should be before the content replacement range")
@@ -78,11 +103,27 @@ struct FileWritingHTMLContentConsumer: HTMLContentConsumer {
78103
targetFolder: URL,
79104
fileManager: some FileManagerProtocol,
80105
htmlTemplate: URL,
106+
customHeader: URL?,
107+
customFooter: URL?,
81108
prettyPrintOutput: Bool = shouldPrettyPrintOutputJSON
82109
) throws {
83-
self.targetFolder = targetFolder
84-
self.fileManager = fileManager
85-
self.htmlTemplate = try HTMLTemplate(data: fileManager.contents(of: htmlTemplate))
110+
var customTemplates: [HTMLTemplate.CustomTemplate] = []
111+
if let customHeader {
112+
customTemplates.append(.init(
113+
id: "custom-header",
114+
content: String(decoding: try fileManager.contents(of: customHeader), as: UTF8.self)
115+
))
116+
}
117+
if let customFooter {
118+
customTemplates.append(.init(
119+
id: "custom-footer",
120+
content: String(decoding: try fileManager.contents(of: customFooter), as: UTF8.self)
121+
))
122+
}
123+
self.htmlTemplate = try HTMLTemplate(
124+
data: fileManager.contents(of: htmlTemplate),
125+
customTemplates: customTemplates
126+
)
86127
self.prettyPrintOutput = prettyPrintOutput
87128
self.fileWriter = JSONEncodingRenderNodeWriter(
88129
targetFolder: targetFolder,

Sources/DocCCommandLine/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ extension ConvertAction {
7878
experimentalEnableCustomTemplates: convert.experimentalEnableCustomTemplates,
7979
experimentalModifyCatalogWithGeneratedCuration: convert.experimentalModifyCatalogWithGeneratedCuration,
8080
transformForStaticHosting: convert.transformForStaticHosting,
81+
includeContentInEachHTMLFile: convert.experimentalTransformForStaticHostingWithContent,
8182
allowArbitraryCatalogDirectories: convert.allowArbitraryCatalogDirectories,
8283
hostingBasePath: convert.hostingBasePath,
8384
sourceRepository: SourceRepository(from: convert.sourceRepositoryArguments),

Sources/DocCCommandLine/ArgumentParsing/Subcommands/Convert.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,20 @@ extension Docc {
184184
help: "Produce a DocC archive that supports static hosting environments."
185185
)
186186
var transformForStaticHosting = true
187+
188+
@Flag(help: "Include documentation content in each HTML file for static hosting environments.")
189+
var experimentalTransformForStaticHostingWithContent = false
190+
191+
mutating func validate() throws {
192+
if experimentalTransformForStaticHostingWithContent, !transformForStaticHosting {
193+
warnAboutDiagnostic(.init(
194+
severity: .warning,
195+
identifier: "org.swift.docc.IgnoredNoTransformForStaticHosting",
196+
summary: "Passing '--experimental-transform-for-static-hosting-with-content' also implies '--transform-for-static-hosting'. Passing '--no-transform-for-static-hosting' has no effect."
197+
))
198+
transformForStaticHosting = true
199+
}
200+
}
187201
}
188202

189203
/// 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 {
194208
set { hostingOptions.transformForStaticHosting = newValue }
195209
}
196210

211+
/// A Boolean value that is true if the DocC archive produced by this conversion will support browsing without JavaScript enabled.
212+
public var experimentalTransformForStaticHostingWithContent: Bool {
213+
get { hostingOptions.experimentalTransformForStaticHostingWithContent }
214+
set { hostingOptions.experimentalTransformForStaticHostingWithContent = newValue }
215+
}
216+
197217
/// A user-provided relative path to be used in the archived output
198218
var hostingBasePath: String? {
199219
hostingOptions.hostingBasePath

Tests/DocCCommandLineTests/ArgumentParsing/ConvertSubcommandTests.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,37 @@ class ConvertSubcommandTests: XCTestCase {
596596
let disabledFlagConvert = try Docc.Convert.parse(["--disable-mentioned-in"])
597597
XCTAssertEqual(disabledFlagConvert.enableMentionedIn, false)
598598
}
599+
600+
func testStaticHostingWithContentFlag() throws {
601+
// The feature is disabled when no flag is passed.
602+
let noFlagConvert = try Docc.Convert.parse([])
603+
XCTAssertEqual(noFlagConvert.experimentalTransformForStaticHostingWithContent, false)
604+
605+
let enabledFlagConvert = try Docc.Convert.parse(["--experimental-transform-for-static-hosting-with-content"])
606+
XCTAssertEqual(enabledFlagConvert.experimentalTransformForStaticHostingWithContent, true)
607+
608+
// The '...-transform...-with-content' flag also implies the base '--transform-...' flag.
609+
do {
610+
let originalErrorLogHandle = Docc.Convert._errorLogHandle
611+
let originalDiagnosticFormattingOptions = Docc.Convert._diagnosticFormattingOptions
612+
defer {
613+
Docc.Convert._errorLogHandle = originalErrorLogHandle
614+
Docc.Convert._diagnosticFormattingOptions = originalDiagnosticFormattingOptions
615+
}
616+
617+
let logStorage = LogHandle.LogStorage()
618+
Docc.Convert._errorLogHandle = .memory(logStorage)
619+
Docc.Convert._diagnosticFormattingOptions = .formatConsoleOutputForTools
620+
621+
let conflictingFlagsConvert = try Docc.Convert.parse(["--experimental-transform-for-static-hosting-with-content", "--no-transform-for-static-hosting"])
622+
XCTAssertEqual(conflictingFlagsConvert.experimentalTransformForStaticHostingWithContent, true)
623+
XCTAssertEqual(conflictingFlagsConvert.transformForStaticHosting, true)
624+
625+
XCTAssertEqual(logStorage.text.trimmingCharacters(in: .whitespacesAndNewlines), """
626+
warning: Passing '--experimental-transform-for-static-hosting-with-content' also implies '--transform-for-static-hosting'. Passing '--no-transform-for-static-hosting' has no effect.
627+
""")
628+
}
629+
}
599630

600631
// This test calls ``ConvertOptions.infoPlistFallbacks._unusedVersionForBackwardsCompatibility`` which is deprecated.
601632
// Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test.

0 commit comments

Comments
 (0)