Skip to content

Commit 4ee8b3a

Browse files
Add Static Hosting Support (#44)
Relevant forums discussion: https://forums.swift.org/t/support-hosting-docc-archives-in-static-hosting-environments/53572. Resolves rdar://70800606. Co-authored-by: Ethan Kusters <[email protected]>
1 parent 7ca772d commit 4ee8b3a

24 files changed

+1251
-54
lines changed

Sources/SwiftDocC/Infrastructure/NodeURLGenerator.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public struct NodeURLGenerator {
4141
public enum Path {
4242
public static let tutorialsFolderName = "tutorials"
4343
public static let documentationFolderName = "documentation"
44+
public static let dataFolderName = "data"
4445

4546
public static let tutorialsFolder = "/\(tutorialsFolderName)"
4647
public static let documentationFolder = "/\(documentationFolderName)"

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

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@ public struct ConvertAction: Action, RecreatingContext {
3838
let documentationCoverageOptions: DocumentationCoverageOptions
3939
let diagnosticLevel: DiagnosticSeverity
4040
let diagnosticEngine: DiagnosticEngine
41-
41+
42+
let transformForStaticHosting: Bool
43+
let hostingBasePath: String?
44+
45+
4246
private(set) var context: DocumentationContext {
4347
didSet {
4448
// current platforms?
@@ -88,7 +92,10 @@ public struct ConvertAction: Action, RecreatingContext {
8892
diagnosticEngine: DiagnosticEngine? = nil,
8993
emitFixits: Bool = false,
9094
inheritDocs: Bool = false,
91-
experimentalEnableCustomTemplates: Bool = false) throws
95+
experimentalEnableCustomTemplates: Bool = false,
96+
transformForStaticHosting: Bool = false,
97+
hostingBasePath: String? = nil
98+
) throws
9299
{
93100
self.rootURL = documentationBundleURL
94101
self.outOfProcessResolver = outOfProcessResolver
@@ -101,7 +108,9 @@ public struct ConvertAction: Action, RecreatingContext {
101108
self.injectedDataProvider = dataProvider
102109
self.fileManager = fileManager
103110
self.documentationCoverageOptions = documentationCoverageOptions
104-
111+
self.transformForStaticHosting = transformForStaticHosting
112+
self.hostingBasePath = hostingBasePath
113+
105114
let filterLevel: DiagnosticSeverity
106115
if analyze {
107116
filterLevel = .information
@@ -189,7 +198,9 @@ public struct ConvertAction: Action, RecreatingContext {
189198
diagnosticEngine: DiagnosticEngine? = nil,
190199
emitFixits: Bool = false,
191200
inheritDocs: Bool = false,
192-
experimentalEnableCustomTemplates: Bool = false
201+
experimentalEnableCustomTemplates: Bool = false,
202+
transformForStaticHosting: Bool,
203+
hostingBasePath: String?
193204
) throws {
194205
// Note: This public initializer exists separately from the above internal one
195206
// because the FileManagerProtocol type we use to enable mocking in tests
@@ -217,7 +228,9 @@ public struct ConvertAction: Action, RecreatingContext {
217228
diagnosticEngine: diagnosticEngine,
218229
emitFixits: emitFixits,
219230
inheritDocs: inheritDocs,
220-
experimentalEnableCustomTemplates: experimentalEnableCustomTemplates
231+
experimentalEnableCustomTemplates: experimentalEnableCustomTemplates,
232+
transformForStaticHosting: transformForStaticHosting,
233+
hostingBasePath: hostingBasePath
221234
)
222235
}
223236

@@ -240,7 +253,7 @@ public struct ConvertAction: Action, RecreatingContext {
240253
mutating func cancel() throws {
241254
/// If the action is not running, there is nothing to cancel
242255
guard isPerforming.sync({ $0 }) == true else { return }
243-
256+
244257
/// If the action is already cancelled throw `cancelPending`.
245258
if isCancelled.sync({ $0 }) == true {
246259
throw Error.cancelPending
@@ -278,6 +291,28 @@ public struct ConvertAction: Action, RecreatingContext {
278291
let temporaryFolder = try createTempFolder(
279292
with: htmlTemplateDirectory)
280293

294+
var indexHTMLData: Data?
295+
296+
// The `template-index.html` is a duplicate version of `index.html` with extra template
297+
// tokens that allow for customizing the base-path.
298+
// If a base bath is provided we will transform the template using the base path
299+
// to produce a replacement index.html file.
300+
// After any required transforming has been done the template file will be removed.
301+
let templateURL: URL = temporaryFolder.appendingPathComponent(HTMLTemplate.templateFileName.rawValue)
302+
if fileManager.fileExists(atPath: templateURL.path) {
303+
// If the `transformForStaticHosting` is not set but there is a `hostingBasePath`
304+
// then transform the index template
305+
if !transformForStaticHosting,
306+
let hostingBasePath = hostingBasePath,
307+
!hostingBasePath.isEmpty {
308+
indexHTMLData = try StaticHostableTransformer.transformHTMLTemplate(htmlTemplate: temporaryFolder, hostingBasePath: hostingBasePath)
309+
let indexURL = temporaryFolder.appendingPathComponent(HTMLTemplate.indexFileName.rawValue)
310+
try indexHTMLData!.write(to: indexURL)
311+
}
312+
313+
try fileManager.removeItem(at: templateURL)
314+
}
315+
281316
defer {
282317
try? fileManager.removeItem(at: temporaryFolder)
283318
}
@@ -330,15 +365,26 @@ public struct ConvertAction: Action, RecreatingContext {
330365
allProblems.append(contentsOf: indexerProblems)
331366
}
332367

368+
// Process Static Hosting as needed.
369+
if transformForStaticHosting, let templateDirectory = htmlTemplateDirectory {
370+
if indexHTMLData == nil {
371+
indexHTMLData = try StaticHostableTransformer.transformHTMLTemplate(htmlTemplate: templateDirectory, hostingBasePath: hostingBasePath)
372+
}
373+
374+
let dataProvider = try LocalFileSystemDataProvider(rootURL: temporaryFolder.appendingPathComponent(NodeURLGenerator.Path.dataFolderName))
375+
let transformer = StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: temporaryFolder, indexHTMLData: indexHTMLData!)
376+
try transformer.transform()
377+
}
378+
333379
// We should generally only replace the current build output if we didn't encounter errors
334380
// during conversion. However, if the `emitDigest` flag is true,
335381
// we should replace the current output with our digest of problems.
336382
if !allProblems.containsErrors || emitDigest {
337383
try moveOutput(from: temporaryFolder, to: targetDirectory)
338384
}
339-
385+
340386
// Log the output size.
341-
benchmark(add: Benchmark.OutputSize(dataURL: targetDirectory.appendingPathComponent("data")))
387+
benchmark(add: Benchmark.OutputSize(dataURL: targetDirectory.appendingPathComponent(NodeURLGenerator.Path.dataFolderName)))
342388

343389
if Benchmark.main.isEnabled {
344390
// Write the benchmark files directly in the target directory.
@@ -363,6 +409,7 @@ public struct ConvertAction: Action, RecreatingContext {
363409
}
364410

365411
func createTempFolder(with templateURL: URL?) throws -> URL {
412+
366413
let targetURL = URL(fileURLWithPath: NSTemporaryDirectory())
367414
.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString)
368415

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
import SwiftDocC
13+
14+
/// An action that emits a static hostable website from a DocC Archive.
15+
struct TransformForStaticHostingAction: Action {
16+
17+
let rootURL: URL
18+
let outputURL: URL
19+
let hostingBasePath: String?
20+
let outputIsExternal: Bool
21+
let htmlTemplateDirectory: URL
22+
23+
let fileManager: FileManagerProtocol
24+
25+
var diagnosticEngine: DiagnosticEngine
26+
27+
/// Initializes the action with the given validated options, creates or uses the given action workspace & context.
28+
init(documentationBundleURL: URL,
29+
outputURL:URL?,
30+
hostingBasePath: String?,
31+
htmlTemplateDirectory: URL,
32+
fileManager: FileManagerProtocol = FileManager.default,
33+
diagnosticEngine: DiagnosticEngine = .init()) throws
34+
{
35+
// Initialize the action context.
36+
self.rootURL = documentationBundleURL
37+
self.outputURL = outputURL ?? documentationBundleURL
38+
self.outputIsExternal = outputURL != nil
39+
self.hostingBasePath = hostingBasePath
40+
self.htmlTemplateDirectory = htmlTemplateDirectory
41+
self.fileManager = fileManager
42+
self.diagnosticEngine = diagnosticEngine
43+
self.diagnosticEngine.add(DiagnosticConsoleWriter(formattingOptions: []))
44+
}
45+
46+
/// Converts each eligible file from the source archive and
47+
/// saves the results in the given output folder.
48+
mutating func perform(logHandle: LogHandle) throws -> ActionResult {
49+
try emit()
50+
return ActionResult(didEncounterError: false, outputs: [outputURL])
51+
}
52+
53+
mutating private func emit() throws {
54+
55+
56+
// If the emit is to create the static hostable content outside of the source archive
57+
// then the output folder needs to be set up and the archive data copied
58+
// to the new folder.
59+
if outputIsExternal {
60+
61+
try setupOutputDirectory(outputURL: outputURL)
62+
63+
// Copy the appropriate folders from the archive.
64+
// We will copy individual items from the folder rather then just copy the folder
65+
// as we want to preserve anything intentionally left in the output URL by `setupOutputDirectory`
66+
for sourceItem in try fileManager.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: [], options:[.skipsHiddenFiles]) {
67+
let targetItem = outputURL.appendingPathComponent(sourceItem.lastPathComponent)
68+
try fileManager.copyItem(at: sourceItem, to: targetItem)
69+
}
70+
}
71+
72+
// Copy the HTML template to the output folder.
73+
var excludedFiles = [HTMLTemplate.templateFileName.rawValue]
74+
75+
if outputIsExternal {
76+
excludedFiles.append(HTMLTemplate.indexFileName.rawValue)
77+
}
78+
79+
for content in try fileManager.contentsOfDirectory(atPath: htmlTemplateDirectory.path) {
80+
81+
guard !excludedFiles.contains(content) else { continue }
82+
83+
let source = htmlTemplateDirectory.appendingPathComponent(content)
84+
let target = outputURL.appendingPathComponent(content)
85+
if fileManager.fileExists(atPath: target.path){
86+
try fileManager.removeItem(at: target)
87+
}
88+
try fileManager.copyItem(at: source, to: target)
89+
}
90+
91+
// Transform the indexHTML if needed.
92+
let indexHTMLData = try StaticHostableTransformer.transformHTMLTemplate(htmlTemplate: htmlTemplateDirectory, hostingBasePath: hostingBasePath)
93+
94+
// Create a StaticHostableTransformer targeted at the archive data folder
95+
let dataProvider = try LocalFileSystemDataProvider(rootURL: rootURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName))
96+
let transformer = StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: outputURL, indexHTMLData: indexHTMLData)
97+
try transformer.transform()
98+
99+
}
100+
101+
/// Create output directory or empty its contents if it already exists.
102+
private func setupOutputDirectory(outputURL: URL) throws {
103+
104+
var isDirectory: ObjCBool = false
105+
if fileManager.fileExists(atPath: outputURL.path, isDirectory: &isDirectory), isDirectory.boolValue {
106+
let contents = try fileManager.contentsOfDirectory(at: outputURL, includingPropertiesForKeys: [], options: [.skipsHiddenFiles])
107+
for content in contents {
108+
try fileManager.removeItem(at: content)
109+
}
110+
} else {
111+
try fileManager.createDirectory(at: outputURL, withIntermediateDirectories: false, attributes: [:])
112+
}
113+
}
114+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ extension ConvertAction {
7979
diagnosticLevel: convert.diagnosticLevel,
8080
emitFixits: convert.emitFixits,
8181
inheritDocs: convert.enableInheritedDocs,
82-
experimentalEnableCustomTemplates: convert.experimentalEnableCustomTemplates
82+
experimentalEnableCustomTemplates: convert.experimentalEnableCustomTemplates,
83+
transformForStaticHosting: convert.transformForStaticHosting,
84+
hostingBasePath: convert.hostingBasePath
8385
)
8486
}
8587
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
import ArgumentParser
13+
14+
15+
extension TransformForStaticHostingAction {
16+
/// Initializes ``TransformForStaticHostingAction`` from the options in the ``TransformForStaticHosting`` command.
17+
/// - Parameters:
18+
/// - cmd: The emit command this `TransformForStaticHostingAction` will be based on.
19+
init(fromCommand cmd: Docc.ProcessArchive.TransformForStaticHosting, withFallbackTemplate fallbackTemplateURL: URL? = nil) throws {
20+
// Initialize the `TransformForStaticHostingAction` from the options provided by the `EmitStaticHostable` command
21+
22+
guard let htmlTemplateFolder = cmd.templateOption.templateURL ?? fallbackTemplateURL else {
23+
throw TemplateOption.missingHTMLTemplateError(
24+
path: cmd.templateOption.defaultTemplateURL.path
25+
)
26+
}
27+
28+
try self.init(
29+
documentationBundleURL: cmd.documentationArchive.urlOrFallback,
30+
outputURL: cmd.outputURL,
31+
hostingBasePath: cmd.hostingBasePath,
32+
htmlTemplateDirectory: htmlTemplateFolder )
33+
}
34+
}

Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,45 @@ import ArgumentParser
1212
import Foundation
1313

1414
/// Resolves and validates a URL value that provides the path to a documentation archive.
15-
///
16-
/// This option is used by the ``Docc/Index`` subcommand.
17-
public struct DocumentationArchiveOption: DirectoryPathOption {
15+
public struct DocCArchiveOption: DirectoryPathOption {
1816

19-
public init() {}
17+
public init(){}
2018

2119
/// The name of the command line argument used to specify a source archive path.
2220
static let argumentValueName = "source-archive-path"
21+
static let expectedContent: Set<String> = ["data"]
2322

24-
/// The path to an archive to be indexed by DocC.
23+
/// The path to an archive to be used by DocC.
2524
@Argument(
2625
help: ArgumentHelp(
27-
"Path to a documentation archive data directory of JSON files.",
28-
discussion: "The '.doccarchive' bundle docc will index.",
26+
"Path to the DocC Archive ('.doccarchive') that should be processed.",
2927
valueName: argumentValueName),
3028
transform: URL.init(fileURLWithPath:))
3129
public var url: URL?
30+
31+
public mutating func validate() throws {
32+
33+
// Validate that the URL represents a directory
34+
guard urlOrFallback.hasDirectoryPath else {
35+
throw ValidationError("'\(urlOrFallback.path)' is not a valid DocC Archive. Expected a directory but a path to a file was provided")
36+
}
37+
38+
var archiveContents: [String]
39+
do {
40+
archiveContents = try FileManager.default.contentsOfDirectory(atPath: urlOrFallback.path)
41+
} catch {
42+
throw ValidationError("'\(urlOrFallback.path)' is not a valid DocC Archive: \(error)")
43+
}
44+
45+
let missingContents = Array(Set(DocCArchiveOption.expectedContent).subtracting(archiveContents))
46+
guard missingContents.isEmpty else {
47+
throw ValidationError(
48+
"""
49+
'\(urlOrFallback.path)' is not a valid DocC Archive.
50+
Expected a 'data' directory at the root of the archive.
51+
"""
52+
)
53+
}
54+
55+
}
3256
}

0 commit comments

Comments
 (0)