diff --git a/Sources/SwiftDocC/Infrastructure/NodeURLGenerator.swift b/Sources/SwiftDocC/Infrastructure/NodeURLGenerator.swift index 89abc480c9..af8cf8cc40 100644 --- a/Sources/SwiftDocC/Infrastructure/NodeURLGenerator.swift +++ b/Sources/SwiftDocC/Infrastructure/NodeURLGenerator.swift @@ -41,6 +41,7 @@ public struct NodeURLGenerator { public enum Path { public static let tutorialsFolderName = "tutorials" public static let documentationFolderName = "documentation" + public static let dataFolderName = "data" public static let tutorialsFolder = "/\(tutorialsFolderName)" public static let documentationFolder = "/\(documentationFolderName)" diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift index 8f639c46c5..72f973b3a0 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift @@ -38,7 +38,11 @@ public struct ConvertAction: Action, RecreatingContext { let documentationCoverageOptions: DocumentationCoverageOptions let diagnosticLevel: DiagnosticSeverity let diagnosticEngine: DiagnosticEngine - + + let transformForStaticHosting: Bool + let hostingBasePath: String? + + private(set) var context: DocumentationContext { didSet { // current platforms? @@ -88,7 +92,10 @@ public struct ConvertAction: Action, RecreatingContext { diagnosticEngine: DiagnosticEngine? = nil, emitFixits: Bool = false, inheritDocs: Bool = false, - experimentalEnableCustomTemplates: Bool = false) throws + experimentalEnableCustomTemplates: Bool = false, + transformForStaticHosting: Bool = false, + hostingBasePath: String? = nil + ) throws { self.rootURL = documentationBundleURL self.outOfProcessResolver = outOfProcessResolver @@ -101,7 +108,9 @@ public struct ConvertAction: Action, RecreatingContext { self.injectedDataProvider = dataProvider self.fileManager = fileManager self.documentationCoverageOptions = documentationCoverageOptions - + self.transformForStaticHosting = transformForStaticHosting + self.hostingBasePath = hostingBasePath + let filterLevel: DiagnosticSeverity if analyze { filterLevel = .information @@ -189,7 +198,9 @@ public struct ConvertAction: Action, RecreatingContext { diagnosticEngine: DiagnosticEngine? = nil, emitFixits: Bool = false, inheritDocs: Bool = false, - experimentalEnableCustomTemplates: Bool = false + experimentalEnableCustomTemplates: Bool = false, + transformForStaticHosting: Bool, + hostingBasePath: String? ) throws { // Note: This public initializer exists separately from the above internal one // because the FileManagerProtocol type we use to enable mocking in tests @@ -217,7 +228,9 @@ public struct ConvertAction: Action, RecreatingContext { diagnosticEngine: diagnosticEngine, emitFixits: emitFixits, inheritDocs: inheritDocs, - experimentalEnableCustomTemplates: experimentalEnableCustomTemplates + experimentalEnableCustomTemplates: experimentalEnableCustomTemplates, + transformForStaticHosting: transformForStaticHosting, + hostingBasePath: hostingBasePath ) } @@ -240,7 +253,7 @@ public struct ConvertAction: Action, RecreatingContext { mutating func cancel() throws { /// If the action is not running, there is nothing to cancel guard isPerforming.sync({ $0 }) == true else { return } - + /// If the action is already cancelled throw `cancelPending`. if isCancelled.sync({ $0 }) == true { throw Error.cancelPending @@ -278,6 +291,28 @@ public struct ConvertAction: Action, RecreatingContext { let temporaryFolder = try createTempFolder( with: htmlTemplateDirectory) + var indexHTMLData: Data? + + // The `template-index.html` is a duplicate version of `index.html` with extra template + // tokens that allow for customizing the base-path. + // If a base bath is provided we will transform the template using the base path + // to produce a replacement index.html file. + // After any required transforming has been done the template file will be removed. + let templateURL: URL = temporaryFolder.appendingPathComponent(HTMLTemplate.templateFileName.rawValue) + if fileManager.fileExists(atPath: templateURL.path) { + // If the `transformForStaticHosting` is not set but there is a `hostingBasePath` + // then transform the index template + if !transformForStaticHosting, + let hostingBasePath = hostingBasePath, + !hostingBasePath.isEmpty { + indexHTMLData = try StaticHostableTransformer.transformHTMLTemplate(htmlTemplate: temporaryFolder, hostingBasePath: hostingBasePath) + let indexURL = temporaryFolder.appendingPathComponent(HTMLTemplate.indexFileName.rawValue) + try indexHTMLData!.write(to: indexURL) + } + + try fileManager.removeItem(at: templateURL) + } + defer { try? fileManager.removeItem(at: temporaryFolder) } @@ -330,15 +365,26 @@ public struct ConvertAction: Action, RecreatingContext { allProblems.append(contentsOf: indexerProblems) } + // Process Static Hosting as needed. + if transformForStaticHosting, let templateDirectory = htmlTemplateDirectory { + if indexHTMLData == nil { + indexHTMLData = try StaticHostableTransformer.transformHTMLTemplate(htmlTemplate: templateDirectory, hostingBasePath: hostingBasePath) + } + + let dataProvider = try LocalFileSystemDataProvider(rootURL: temporaryFolder.appendingPathComponent(NodeURLGenerator.Path.dataFolderName)) + let transformer = StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: temporaryFolder, indexHTMLData: indexHTMLData!) + try transformer.transform() + } + // We should generally only replace the current build output if we didn't encounter errors // during conversion. However, if the `emitDigest` flag is true, // we should replace the current output with our digest of problems. if !allProblems.containsErrors || emitDigest { try moveOutput(from: temporaryFolder, to: targetDirectory) } - + // Log the output size. - benchmark(add: Benchmark.OutputSize(dataURL: targetDirectory.appendingPathComponent("data"))) + benchmark(add: Benchmark.OutputSize(dataURL: targetDirectory.appendingPathComponent(NodeURLGenerator.Path.dataFolderName))) if Benchmark.main.isEnabled { // Write the benchmark files directly in the target directory. @@ -363,6 +409,7 @@ public struct ConvertAction: Action, RecreatingContext { } func createTempFolder(with templateURL: URL?) throws -> URL { + let targetURL = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString) diff --git a/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift b/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift new file mode 100644 index 0000000000..7039e0daf5 --- /dev/null +++ b/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift @@ -0,0 +1,114 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import SwiftDocC + +/// An action that emits a static hostable website from a DocC Archive. +struct TransformForStaticHostingAction: Action { + + let rootURL: URL + let outputURL: URL + let hostingBasePath: String? + let outputIsExternal: Bool + let htmlTemplateDirectory: URL + + let fileManager: FileManagerProtocol + + var diagnosticEngine: DiagnosticEngine + + /// Initializes the action with the given validated options, creates or uses the given action workspace & context. + init(documentationBundleURL: URL, + outputURL:URL?, + hostingBasePath: String?, + htmlTemplateDirectory: URL, + fileManager: FileManagerProtocol = FileManager.default, + diagnosticEngine: DiagnosticEngine = .init()) throws + { + // Initialize the action context. + self.rootURL = documentationBundleURL + self.outputURL = outputURL ?? documentationBundleURL + self.outputIsExternal = outputURL != nil + self.hostingBasePath = hostingBasePath + self.htmlTemplateDirectory = htmlTemplateDirectory + self.fileManager = fileManager + self.diagnosticEngine = diagnosticEngine + self.diagnosticEngine.add(DiagnosticConsoleWriter(formattingOptions: [])) + } + + /// Converts each eligible file from the source archive and + /// saves the results in the given output folder. + mutating func perform(logHandle: LogHandle) throws -> ActionResult { + try emit() + return ActionResult(didEncounterError: false, outputs: [outputURL]) + } + + mutating private func emit() throws { + + + // If the emit is to create the static hostable content outside of the source archive + // then the output folder needs to be set up and the archive data copied + // to the new folder. + if outputIsExternal { + + try setupOutputDirectory(outputURL: outputURL) + + // Copy the appropriate folders from the archive. + // We will copy individual items from the folder rather then just copy the folder + // as we want to preserve anything intentionally left in the output URL by `setupOutputDirectory` + for sourceItem in try fileManager.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: [], options:[.skipsHiddenFiles]) { + let targetItem = outputURL.appendingPathComponent(sourceItem.lastPathComponent) + try fileManager.copyItem(at: sourceItem, to: targetItem) + } + } + + // Copy the HTML template to the output folder. + var excludedFiles = [HTMLTemplate.templateFileName.rawValue] + + if outputIsExternal { + excludedFiles.append(HTMLTemplate.indexFileName.rawValue) + } + + for content in try fileManager.contentsOfDirectory(atPath: htmlTemplateDirectory.path) { + + guard !excludedFiles.contains(content) else { continue } + + let source = htmlTemplateDirectory.appendingPathComponent(content) + let target = outputURL.appendingPathComponent(content) + if fileManager.fileExists(atPath: target.path){ + try fileManager.removeItem(at: target) + } + try fileManager.copyItem(at: source, to: target) + } + + // Transform the indexHTML if needed. + let indexHTMLData = try StaticHostableTransformer.transformHTMLTemplate(htmlTemplate: htmlTemplateDirectory, hostingBasePath: hostingBasePath) + + // Create a StaticHostableTransformer targeted at the archive data folder + let dataProvider = try LocalFileSystemDataProvider(rootURL: rootURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName)) + let transformer = StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: outputURL, indexHTMLData: indexHTMLData) + try transformer.transform() + + } + + /// Create output directory or empty its contents if it already exists. + private func setupOutputDirectory(outputURL: URL) throws { + + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: outputURL.path, isDirectory: &isDirectory), isDirectory.boolValue { + let contents = try fileManager.contentsOfDirectory(at: outputURL, includingPropertiesForKeys: [], options: [.skipsHiddenFiles]) + for content in contents { + try fileManager.removeItem(at: content) + } + } else { + try fileManager.createDirectory(at: outputURL, withIntermediateDirectories: false, attributes: [:]) + } + } +} diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift index 45ac510699..1d3357a485 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift @@ -79,7 +79,9 @@ extension ConvertAction { diagnosticLevel: convert.diagnosticLevel, emitFixits: convert.emitFixits, inheritDocs: convert.enableInheritedDocs, - experimentalEnableCustomTemplates: convert.experimentalEnableCustomTemplates + experimentalEnableCustomTemplates: convert.experimentalEnableCustomTemplates, + transformForStaticHosting: convert.transformForStaticHosting, + hostingBasePath: convert.hostingBasePath ) } } diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift new file mode 100644 index 0000000000..a768726d62 --- /dev/null +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift @@ -0,0 +1,34 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import ArgumentParser + + +extension TransformForStaticHostingAction { + /// Initializes ``TransformForStaticHostingAction`` from the options in the ``TransformForStaticHosting`` command. + /// - Parameters: + /// - cmd: The emit command this `TransformForStaticHostingAction` will be based on. + init(fromCommand cmd: Docc.ProcessArchive.TransformForStaticHosting, withFallbackTemplate fallbackTemplateURL: URL? = nil) throws { + // Initialize the `TransformForStaticHostingAction` from the options provided by the `EmitStaticHostable` command + + guard let htmlTemplateFolder = cmd.templateOption.templateURL ?? fallbackTemplateURL else { + throw TemplateOption.missingHTMLTemplateError( + path: cmd.templateOption.defaultTemplateURL.path + ) + } + + try self.init( + documentationBundleURL: cmd.documentationArchive.urlOrFallback, + outputURL: cmd.outputURL, + hostingBasePath: cmd.hostingBasePath, + htmlTemplateDirectory: htmlTemplateFolder ) + } +} diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift index 6aaa28607a..411c188923 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift @@ -12,21 +12,45 @@ import ArgumentParser import Foundation /// Resolves and validates a URL value that provides the path to a documentation archive. -/// -/// This option is used by the ``Docc/Index`` subcommand. -public struct DocumentationArchiveOption: DirectoryPathOption { +public struct DocCArchiveOption: DirectoryPathOption { - public init() {} + public init(){} /// The name of the command line argument used to specify a source archive path. static let argumentValueName = "source-archive-path" + static let expectedContent: Set = ["data"] - /// The path to an archive to be indexed by DocC. + /// The path to an archive to be used by DocC. @Argument( help: ArgumentHelp( - "Path to a documentation archive data directory of JSON files.", - discussion: "The '.doccarchive' bundle docc will index.", + "Path to the DocC Archive ('.doccarchive') that should be processed.", valueName: argumentValueName), transform: URL.init(fileURLWithPath:)) public var url: URL? + + public mutating func validate() throws { + + // Validate that the URL represents a directory + guard urlOrFallback.hasDirectoryPath else { + throw ValidationError("'\(urlOrFallback.path)' is not a valid DocC Archive. Expected a directory but a path to a file was provided") + } + + var archiveContents: [String] + do { + archiveContents = try FileManager.default.contentsOfDirectory(atPath: urlOrFallback.path) + } catch { + throw ValidationError("'\(urlOrFallback.path)' is not a valid DocC Archive: \(error)") + } + + let missingContents = Array(Set(DocCArchiveOption.expectedContent).subtracting(archiveContents)) + guard missingContents.isEmpty else { + throw ValidationError( + """ + '\(urlOrFallback.path)' is not a valid DocC Archive. + Expected a 'data' directory at the root of the archive. + """ + ) + } + + } } diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Options/TemplateOption.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Options/TemplateOption.swift index 4fa68396fc..dddc5be0ed 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Options/TemplateOption.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Options/TemplateOption.swift @@ -53,6 +53,30 @@ public struct TemplateOption: ParsableArguments { return templatePath } + static func invalidHTMLTemplateError( + path templatePath: String, + expectedFile expectedFileName: String + ) -> ValidationError { + return ValidationError( + """ + Invalid or missing HTML template directory configuration provided. + The directory at '\(templatePath)' does not contain a valid template. Missing '\(expectedFileName)' file. + Set the '\(TemplateOption.environmentVariableKey)' environment variable to use a custom HTML template. + """ + ) + } + + static func missingHTMLTemplateError( + path expectedTemplatePath: String + ) -> ValidationError { + return ValidationError( + """ + Invalid or missing HTML template directory, relative to the docc executable, at: '\(expectedTemplatePath)'. + Set the '\(TemplateOption.environmentVariableKey)' environment variable to use a custom HTML template. + """ + ) + } + public mutating func validate() throws { templateURL = ProcessInfo.processInfo.environment[TemplateOption.environmentVariableKey] .map { URL(fileURLWithPath: $0) } @@ -65,7 +89,7 @@ public struct TemplateOption: ParsableArguments { // Only perform further validation if a templateURL has been provided guard let templateURL = templateURL else { - if FileManager.default.fileExists(atPath: defaultTemplateURL.appendingPathComponent("index.html").path) { + if FileManager.default.fileExists(atPath: defaultTemplateURL.appendingPathComponent(HTMLTemplate.indexFileName.rawValue).path) { self.templateURL = defaultTemplateURL } return @@ -73,12 +97,9 @@ public struct TemplateOption: ParsableArguments { // Confirm that the provided directory contains an 'index.html' file which is a required part of // an HTML template for docc. - guard FileManager.default.fileExists(atPath: templateURL.appendingPathComponent("index.html").path) else { - throw ValidationError( - """ - Invalid HTML template directory configuration provided via the '\(TemplateOption.environmentVariableKey)' environment variable. - The directory at '\(templateURL.path)' does not contain a valid template. Missing 'index.html' file. - """) + guard FileManager.default.fileExists(atPath: templateURL.appendingPathComponent(HTMLTemplate.indexFileName.rawValue).path) + else { + throw Self.invalidHTMLTemplateError(path: templateURL.path, expectedFile: "index.html") } } } diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift index 05b8b5e7a5..299dfc6fac 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift @@ -155,7 +155,7 @@ extension Docc { help: "A fallback default language for code listings if no value is provided in the documentation bundle's Info.plist file." ) public var defaultCodeListingLanguage: String? - + @Option( help: """ A fallback default module kind if no value is provided \ @@ -217,6 +217,20 @@ extension Docc { return outputURL } + + /// Defaults to false + @Flag(help: "Produce a Swift-DocC Archive that supports a static hosting environment.") + public var transformForStaticHosting = false + + /// A user-provided relative path to be used in the archived output + @Option( + name: [.customLong("hosting-base-path")], + help: ArgumentHelp( + "The base path your documentation website will be hosted at.", + discussion: "For example, to deploy your site to 'example.com/my_name/my_project/documentation' instead of 'example.com/documentation', pass '/my_name/my_project' as the base path.") + ) + var hostingBasePath: String? + // MARK: - Property Validation @@ -231,9 +245,35 @@ extension Docc { if let outputParent = providedOutputURL?.deletingLastPathComponent() { var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: outputParent.path, isDirectory: &isDirectory), isDirectory.boolValue else { - throw ValidationError("No directory exist at '\(outputParent.path)'.") + throw ValidationError("No directory exists at '\(outputParent.path)'.") + } + } + + if transformForStaticHosting { + if let templateURL = templateOption.templateURL { + let neededFileName: String + + if hostingBasePath != nil { + neededFileName = HTMLTemplate.templateFileName.rawValue + }else { + neededFileName = HTMLTemplate.indexFileName.rawValue + } + + let indexTemplate = templateURL.appendingPathComponent(neededFileName) + if !FileManager.default.fileExists(atPath: indexTemplate.path) { + throw TemplateOption.invalidHTMLTemplateError( + path: templateURL.path, + expectedFile: neededFileName + ) + } + + } else { + throw TemplateOption.missingHTMLTemplateError( + path: templateOption.defaultTemplateURL.path + ) } } + } // MARK: - Execution diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Index.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Index.swift index 894013c766..df2e020a12 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Index.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Index.swift @@ -26,7 +26,7 @@ extension Docc { /// The user-provided path to a `.doccarchive` documentation archive. @OptionGroup() - public var documentationBundle: DocumentationArchiveOption + public var documentationBundle: DocCArchiveOption /// The user-provided bundle name to use for the produced index. @Option(help: "The bundle name for the index.") diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Preview.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Preview.swift index 383d0ae1a5..7e2b999ee1 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Preview.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Preview.swift @@ -35,11 +35,9 @@ extension Docc { // The default template wasn't validated by the Convert command. // If a template was configured as an environmental variable, that would have already been validated in TemplateOption. if previewOptions.convertCommand.templateOption.templateURL == nil { - throw ValidationError( - """ - Invalid or missing HTML template directory, relative to the docc executable, at: \(previewOptions.convertCommand.templateOption.defaultTemplateURL.path) - Set the '\(TemplateOption.environmentVariableKey)' environment variable to use a custom HTML template. - """) + throw TemplateOption.missingHTMLTemplateError( + path: previewOptions.convertCommand.templateOption.defaultTemplateURL.path + ) } } diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/ProcessArchive.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/ProcessArchive.swift new file mode 100644 index 0000000000..0752d867c1 --- /dev/null +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/ProcessArchive.swift @@ -0,0 +1,24 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import ArgumentParser +import Foundation + +extension Docc { + /// Processes an action on an archive + struct ProcessArchive: ParsableCommand { + + static var configuration = CommandConfiguration( + commandName: "process-archive", + abstract: "Perform operations on DocC Archives.", + subcommands: [TransformForStaticHosting.self, Index.self]) + + } +} diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/TransformForStaticHosting.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/TransformForStaticHosting.swift new file mode 100644 index 0000000000..419bfc0b72 --- /dev/null +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/TransformForStaticHosting.swift @@ -0,0 +1,77 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import ArgumentParser + +extension Docc.ProcessArchive { + /// Emits a statically hostable website from a DocC Archive. + struct TransformForStaticHosting: ParsableCommand { + + static var configuration = CommandConfiguration( + commandName: "transform-for-static-hosting", + abstract: "Transform an existing DocC Archive into one that supports a static hosting environment.") + + @OptionGroup() + var documentationArchive: DocCArchiveOption + + /// A user-provided location where the archive output will be put + @Option( + name: [.customLong("output-path")], + help: ArgumentHelp( + "The location where docc writes the transformed archive.", + discussion: "If no output-path is provided, docc will perform an in-place transformation of the provided DocC Archive." + ), + transform: URL.init(fileURLWithPath:) + ) + var outputURL: URL? + + /// A user-provided relative path to be used in the archived output + @Option( + name: [.customLong("hosting-base-path")], + help: ArgumentHelp( + "The base path your documentation website will be hosted at.", + discussion: "For example, to deploy your site to 'example.com/my_name/my_project/documentation' instead of 'example.com/documentation', pass '/my_name/my_project' as the base path.") + ) + var hostingBasePath: String? + + /// The user-provided path to an HTML documentation template. + @OptionGroup() + var templateOption: TemplateOption + + mutating func validate() throws { + + if let templateURL = templateOption.templateURL { + let indexTemplate = templateURL.appendingPathComponent(HTMLTemplate.templateFileName.rawValue) + if !FileManager.default.fileExists(atPath: indexTemplate.path) { + throw TemplateOption.invalidHTMLTemplateError( + path: templateURL.path, + expectedFile: HTMLTemplate.templateFileName.rawValue + ) + } + } else { + throw TemplateOption.missingHTMLTemplateError( + path: templateOption.defaultTemplateURL.path + ) + } + } + + // MARK: - Execution + + mutating func run() throws { + // Initialize an `TransformForStaticHostingAction` from the current options in the `TransformForStaticHostingAction` command. + var action = try TransformForStaticHostingAction(fromCommand: self) + + // Perform the emit and print any warnings or errors found + try action.performAndHandleResult() + } + } +} + diff --git a/Sources/SwiftDocCUtilities/Docc.swift b/Sources/SwiftDocCUtilities/Docc.swift index d5489a32c3..662dfeab37 100644 --- a/Sources/SwiftDocCUtilities/Docc.swift +++ b/Sources/SwiftDocCUtilities/Docc.swift @@ -15,7 +15,7 @@ public struct Docc: ParsableCommand { public static var configuration = CommandConfiguration( abstract: "Documentation Compiler: compile, analyze, and preview documentation.", - subcommands: [Convert.self, Index.self, Preview.self]) + subcommands: [Convert.self, Index.self, Preview.self, ProcessArchive.self]) public init() {} } diff --git a/Sources/SwiftDocCUtilities/Transformers/StaticHostableTransformer.swift b/Sources/SwiftDocCUtilities/Transformers/StaticHostableTransformer.swift new file mode 100644 index 0000000000..e84628b319 --- /dev/null +++ b/Sources/SwiftDocCUtilities/Transformers/StaticHostableTransformer.swift @@ -0,0 +1,183 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import SwiftDocC + +enum HTMLTemplate: String { + case templateFileName = "index-template.html" + case indexFileName = "index.html" + case tag = "{{BASE_PATH}}" +} + +enum StaticHostableTransformerError: DescribedError { + case dataProviderDoesNotReferenceValidInput(url: URL) + + var errorDescription: String { + switch self { + case .dataProviderDoesNotReferenceValidInput(let url): + return """ + The content of `\(url.absoluteString)` is not in the format expected by the transformer. + """ + } + } +} + +/// Navigates the contents of a FileSystemProvider pointing at the data folder of a `.doccarchive` to emit a static hostable website. +struct StaticHostableTransformer { + + /// The internal `FileSystemProvider` reference. + /// This should be the data folder of an archive. + private let dataProvider: FileSystemProvider + + /// Where the output will be written. + private let outputURL: URL + + /// The index.html file to be used. + private let indexHTMLData: Data + + private let fileManager: FileManagerProtocol + + /// Initialise with a dataProvider to the source doccarchive. + /// - Parameters: + /// - dataProvider: Should point to the data folder in a docc archive. + /// - fileManager: The FileManager to use for file processes. + /// - outputURL: The folder where the output will be placed + /// - indexHTMLData: Data representing the index.html to be written in the transformed folder structure. + init(dataProvider: FileSystemProvider, fileManager: FileManagerProtocol, outputURL: URL, indexHTMLData: Data) { + self.dataProvider = dataProvider + self.fileManager = fileManager + self.outputURL = outputURL + self.indexHTMLData = indexHTMLData + } + + /// Creates a static hostable version of the documentation in the data folder of an archive pointed to by the `dataProvider` + func transform() throws { + + let node = dataProvider.fileSystem + + // We should be starting at the data folder of a .doccarchive. + switch node { + case .directory(let dir): + try transformDirectoryContents(directoryRoot: outputURL, relativeSubPath: "", directoryContents: dir.children) + case .file(let file): + throw StaticHostableTransformerError.dataProviderDoesNotReferenceValidInput(url: file.url) + } + } + + + /// Create a directory at the provided URL + /// + private func createDirectory(url: URL) throws { + if !fileManager.fileExists(atPath: url.path) { + try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: [:]) + } + } + + /// Transforms the contents of a given directory + /// - Parameters: + /// - root: The root output URL + /// - directory: The relative path (to the root) of the directory for which then content will processed. + /// - nodes: The directory contents + /// - Returns: An array of problems that may have occurred during processing + private func transformDirectoryContents(directoryRoot: URL, relativeSubPath: String, directoryContents: [FSNode]) throws { + + for node in directoryContents { + switch node { + case .directory(let dir): + try transformDirectory(directoryRoot: directoryRoot, currentDirectoryNode: dir, directorySubPath: relativeSubPath) + case .file(let file): + let outputURL = directoryRoot.appendingPathComponent(relativeSubPath) + try transformFile(file: file, outputURL: outputURL) + } + } + + } + + /// Transform the given directory + /// - Parameters: + /// - root: The root output URL + /// - dir: The FSNode that represents the directory + /// - currentDirectory: The relative path (to the root) of the directory that will contain this directory + private func transformDirectory(directoryRoot: URL, currentDirectoryNode: FSNode.Directory, directorySubPath: String) throws { + + // Create the path for the new directory + var newDirectory = directorySubPath + let newPathComponent = currentDirectoryNode.url.lastPathComponent + + // We need to ensure the new directory component, if not empty, ends with / + if !newDirectory.isEmpty && !newDirectory.hasSuffix("/") { + newDirectory += "/" + } + newDirectory += newPathComponent + + + // Create the HTML output directory + + let htmlOutputURL = directoryRoot.appendingPathComponent(newDirectory) + try createDirectory(url: htmlOutputURL) + + // Process the directory contents + try transformDirectoryContents(directoryRoot: directoryRoot, relativeSubPath: newDirectory, directoryContents: currentDirectoryNode.children) + + } + + /// Transform the given File + /// - Parameters: + /// - file: The FSNode that represents the file + /// - outputURL: The directory the need to be placed in + private func transformFile(file: FSNode.File, outputURL: URL) throws { + + // For JSON files we need to create an associated index.html in a sub-folder of the same name. + guard file.url.pathExtension.lowercased() == "json" else { return } + + let dirURL = file.url.deletingPathExtension() + let newDir = dirURL.lastPathComponent + let newDirURL = outputURL.appendingPathComponent(newDir) + + if !fileManager.fileExists(atPath: newDirURL.path) { + try fileManager.createDirectory(at: newDirURL, withIntermediateDirectories: true, attributes: [:]) + } + + let fileURL = newDirURL.appendingPathComponent("index.html") + try self.indexHTMLData.write(to: fileURL) + } +} + +extension StaticHostableTransformer { + + /// Transforms an index-template.html file by replacing the template tag with the provided `hostingBasePath` + static func transformHTMLTemplate(htmlTemplate: URL, hostingBasePath: String?) throws -> Data { + + let indexFileName = hostingBasePath != nil ? HTMLTemplate.templateFileName.rawValue : HTMLTemplate.indexFileName.rawValue + let indexFileURL = htmlTemplate.appendingPathComponent(indexFileName) + var indexHTML = try String(contentsOfFile: indexFileURL.path) + + + if let hostingBasePath = hostingBasePath { + + var replacementString = hostingBasePath + + // We need to ensure that the base path has a leading / + if !replacementString.hasPrefix("/") { + replacementString = "/" + replacementString + } + + // Trailing /'s are not required so will be removed if provided. + if replacementString.hasSuffix("/") { + replacementString = String(replacementString.dropLast(1)) + } + + indexHTML = indexHTML.replacingOccurrences(of: HTMLTemplate.tag.rawValue, with: replacementString) + } + + return Data(indexHTML.utf8) + } +} diff --git a/Sources/SwiftDocCUtilities/Utility/FileManagerProtocol.swift b/Sources/SwiftDocCUtilities/Utility/FileManagerProtocol.swift index 960a4979b3..2de7db7fcb 100644 --- a/Sources/SwiftDocCUtilities/Utility/FileManagerProtocol.swift +++ b/Sources/SwiftDocCUtilities/Utility/FileManagerProtocol.swift @@ -46,6 +46,9 @@ protocol FileManagerProtocol { func createDirectory(at: URL, withIntermediateDirectories: Bool, attributes: [FileAttributeKey : Any]?) throws /// Removes a file from the given location. func removeItem(at: URL) throws + /// Returns a list of items in a directory + func contentsOfDirectory(atPath path: String) throws -> [String] + func contentsOfDirectory(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?, options mask: FileManager.DirectoryEnumerationOptions) throws -> [URL] /// Creates a file with the specified `contents` at the specified location. /// @@ -77,4 +80,5 @@ extension FileManager: FileManagerProtocol { func createFile(at location: URL, contents: Data) throws { try contents.write(to: location, options: .atomic) } + } diff --git a/Tests/SwiftDocCTests/Benchmark/OutputSizeTests.swift b/Tests/SwiftDocCTests/Benchmark/OutputSizeTests.swift index d2f24e41b4..c513b3c452 100644 --- a/Tests/SwiftDocCTests/Benchmark/OutputSizeTests.swift +++ b/Tests/SwiftDocCTests/Benchmark/OutputSizeTests.swift @@ -15,7 +15,7 @@ class OutputSizeTests: XCTestCase { func testOutputSize() throws { // Create a faux output folder let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) - let writeURL = tempURL.appendingPathComponent("data") + let writeURL = tempURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName) try FileManager.default.createDirectory(at: writeURL, withIntermediateDirectories: true, attributes: nil) defer { try? FileManager.default.removeItem(at: tempURL) } diff --git a/Tests/SwiftDocCUtilitiesTests/ConvertActionStaticHostableTests.swift b/Tests/SwiftDocCUtilitiesTests/ConvertActionStaticHostableTests.swift new file mode 100644 index 0000000000..05f75401f8 --- /dev/null +++ b/Tests/SwiftDocCUtilitiesTests/ConvertActionStaticHostableTests.swift @@ -0,0 +1,84 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import XCTest +import Foundation +@testable import SwiftDocC +@testable import SwiftDocCUtilities + +class ConvertActionStaticHostableTests: StaticHostingBaseTests { + /// Creates a DocC archive and then archives it with options to produce static content which is then validated. + func testConvertActionStaticHostableTestOutput() throws { + + let bundleURL = Bundle.module.url(forResource: "TestBundle", withExtension: "docc", subdirectory: "Test Bundles")! + let targetURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + + let fileManager = FileManager.default + try fileManager.createDirectory(at: targetURL, withIntermediateDirectories: true, attributes: nil) + defer { try? fileManager.removeItem(at: targetURL) } + + + let targetBundleURL = targetURL.appendingPathComponent("Result.doccarchive") + defer { try? fileManager.removeItem(at: targetBundleURL) } + + + let testTemplateURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + let templateFolder = Folder.testHTMLTemplateDirectory + try templateFolder.write(to: testTemplateURL) + defer { try? fileManager.removeItem(at: testTemplateURL) } + + + let basePath = "test/folder" + let indexHTML = Folder.testHTMLTemplate(basePath: "test/folder") + + var action = try ConvertAction( + documentationBundleURL: bundleURL, + outOfProcessResolver: nil, + analyze: false, + targetDirectory: targetBundleURL, + htmlTemplateDirectory: testTemplateURL, + emitDigest: false, + currentPlatforms: nil, + transformForStaticHosting: true, + hostingBasePath: basePath + ) + + _ = try action.perform(logHandle: .standardOutput) + + + // Test the content of the output folder. + var expectedContent = ["data", "documentation", "tutorials", "downloads", "images", "metadata.json" ,"videos", "index.html"] + expectedContent += templateFolder.content.filter { $0 is Folder }.map{ $0.name } + + let output = try fileManager.contentsOfDirectory(atPath: targetBundleURL.path) + XCTAssertEqual(Set(output), Set(expectedContent), "Unexpect output") + + for item in output { + + // Test the content of the documentation and tutorial folders match the expected content from the doccarchive. + switch item { + case "documentation": + compareJSONFolder(fileManager: fileManager, + output: targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.documentationFolderName), + input: targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName).appendingPathComponent(NodeURLGenerator.Path.documentationFolderName), + indexHTML: indexHTML) + case "tutorials": + compareJSONFolder(fileManager: fileManager, + output: targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.tutorialsFolderName), + input: targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName).appendingPathComponent(NodeURLGenerator.Path.tutorialsFolderName), + indexHTML: indexHTML) + default: + continue + } + } + + } +} + diff --git a/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift b/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift index fa6cee7f9e..36d5a68b7e 100644 --- a/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift @@ -2241,14 +2241,14 @@ class ConvertActionTests: XCTestCase { try action.performAndHandleResult() XCTAssertFalse( - testDataProvider.fileExists(atPath: targetDirectory.appendingPathComponent("data").path) + testDataProvider.fileExists(atPath: targetDirectory.appendingPathComponent(NodeURLGenerator.Path.dataFolderName).path) ) enableFeatureFlag(\.isExperimentalObjectiveCSupportEnabled) try action.performAndHandleResult() XCTAssertTrue( - testDataProvider.fileExists(atPath: targetDirectory.appendingPathComponent("data").path) + testDataProvider.fileExists(atPath: targetDirectory.appendingPathComponent(NodeURLGenerator.Path.dataFolderName).path) ) } diff --git a/Tests/SwiftDocCUtilitiesTests/EmptyHTMLTemplateDirectory.swift b/Tests/SwiftDocCUtilitiesTests/EmptyHTMLTemplateDirectory.swift deleted file mode 100644 index 3995d1b101..0000000000 --- a/Tests/SwiftDocCUtilitiesTests/EmptyHTMLTemplateDirectory.swift +++ /dev/null @@ -1,19 +0,0 @@ -/* - This source file is part of the Swift.org open source project - - Copyright (c) 2021 Apple Inc. and the Swift project authors - Licensed under Apache License v2.0 with Runtime Library Exception - - See https://swift.org/LICENSE.txt for license information - See https://swift.org/CONTRIBUTORS.txt for Swift project authors -*/ - -import Foundation -@testable import SwiftDocC - -/// A folder that represents a fake html-build dir for testing. -extension Folder { - static let emptyHTMLTemplateDirectory = Folder(name: "template", content: [ - TextFile(name: "index.html", utf8Content: ""), - ]) -} diff --git a/Tests/SwiftDocCUtilitiesTests/HTMLTemplateDirectory.swift b/Tests/SwiftDocCUtilitiesTests/HTMLTemplateDirectory.swift new file mode 100644 index 0000000000..118eb13476 --- /dev/null +++ b/Tests/SwiftDocCUtilitiesTests/HTMLTemplateDirectory.swift @@ -0,0 +1,48 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +@testable import SwiftDocC + +/// A folder that represents a fake html-build directory for testing. +extension Folder { + + static let emptyHTMLTemplateDirectory = Folder(name: "template", content: [ + TextFile(name: "index.html", utf8Content: ""), TextFile(name: "index-template.html", utf8Content: "") + ]) + + static let testHTMLTemplate = """ + + + """ + + static func testHTMLTemplate(basePath: String) -> String { + + return """ + + + """ + } + + static let testHTMLTemplateDirectory: Folder = { + + let css = Folder(name: "css", content: [ + TextFile(name: "test.css", utf8Content: "")]) + + let js = Folder(name: "js", content: [ + TextFile(name: "test.js", utf8Content: "")]) + + let index = TextFile(name: "index.html", utf8Content: "") + + let indexTemplate = TextFile(name: "index-template.html", utf8Content: testHTMLTemplate) + + return Folder(name: "template", content: [index, indexTemplate, css, js]) + }() +} diff --git a/Tests/SwiftDocCUtilitiesTests/StaticHostableTransformerTests.swift b/Tests/SwiftDocCUtilitiesTests/StaticHostableTransformerTests.swift new file mode 100644 index 0000000000..86ecf8082e --- /dev/null +++ b/Tests/SwiftDocCUtilitiesTests/StaticHostableTransformerTests.swift @@ -0,0 +1,218 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import XCTest +import Foundation +@testable import SwiftDocC +@testable import SwiftDocCUtilities + +class StaticHostableTransformerTests: StaticHostingBaseTests { + + /// Creates a DocC archive and then archive then executes and TransformForStaticHostingAction on it to produce static content which is then validated. + func testStaticHostableTransformerOutput() throws { + + // Convert a test bundle as input for the StaticHostableTransformer + let bundleURL = Bundle.module.url(forResource: "TestBundle", withExtension: "docc", subdirectory: "Test Bundles")! + let targetURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + + let fileManager = FileManager.default + try fileManager.createDirectory(at: targetURL, withIntermediateDirectories: true, attributes: nil) + + defer { try? fileManager.removeItem(at: targetURL) } + + let templateURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try Folder.emptyHTMLTemplateDirectory.write(to: templateURL) + defer { try? fileManager.removeItem(at: templateURL) } + + let targetBundleURL = targetURL.appendingPathComponent("Result.doccarchive") + defer { try? fileManager.removeItem(at: targetBundleURL) } + + var action = try ConvertAction( + documentationBundleURL: bundleURL, + outOfProcessResolver: nil, + analyze: false, + targetDirectory: targetBundleURL, + htmlTemplateDirectory: templateURL, + emitDigest: false, + currentPlatforms: nil + ) + + _ = try action.perform(logHandle: .standardOutput) + + let outputURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + defer { try? fileManager.removeItem(at: outputURL) } + + let testTemplateURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try Folder.testHTMLTemplateDirectory.write(to: testTemplateURL) + defer { try? fileManager.removeItem(at: testTemplateURL) } + + let basePath = "test/folder" + let indexHTML = Folder.testHTMLTemplate(basePath: basePath) + + let indexHTMLData = try StaticHostableTransformer.transformHTMLTemplate(htmlTemplate: testTemplateURL, hostingBasePath: basePath) + + let dataURL = targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName) + let dataProvider = try LocalFileSystemDataProvider(rootURL: dataURL) + let transformer = StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: outputURL, indexHTMLData: indexHTMLData) + + try transformer.transform() + + var isDirectory: ObjCBool = false + + // Test an output folder exists + guard fileManager.fileExists(atPath: outputURL.path, isDirectory: &isDirectory) else { + XCTFail("StaticHostableTransformer failed to create output folder") + return + } + + // Test the output folder really is a folder. + XCTAssert(isDirectory.boolValue) + + // Test the content of the output folder. + let expectedContent = ["documentation", "tutorials"] + let output = try fileManager.contentsOfDirectory(atPath: outputURL.path) + + XCTAssertEqual(output, expectedContent, "Unexpected output") + for item in output { + + // Test the content of the documentation and tutorial folders match the expected content from the doccarchive. + switch item { + case "documentation": + compareJSONFolder(fileManager: fileManager, + output: outputURL.appendingPathComponent("documentation"), + input: dataURL.appendingPathComponent("documentation"), + indexHTML: indexHTML) + case "tutorials": + compareJSONFolder(fileManager: fileManager, + output: outputURL.appendingPathComponent("tutorials"), + input: dataURL.appendingPathComponent("tutorials"), + indexHTML: indexHTML) + default: + continue + } + } + + } + + /// Creates a DocC archive and then archive then executes and TransformForStaticHostingAction on it to produce static content which is then validated. + func testStaticHostableTransformerBasePaths() throws { + + let testTemplateURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try Folder.testHTMLTemplateDirectory.write(to: testTemplateURL) + defer { try? FileManager.default.removeItem(at: testTemplateURL) } + + let basePaths = ["test": "test", + "/test": "test", + "test/": "test", + "/test/": "test", + "test/test": "test/test", + "/test/test": "test/test", + "test/test/": "test/test", + "/test/test/": "test/test"] + + for (basePath, testValue) in basePaths { + + let indexHTMLData = try StaticHostableTransformer.transformHTMLTemplate(htmlTemplate: testTemplateURL, hostingBasePath: basePath) + let testIndexHTML = String(decoding: indexHTMLData, as: UTF8.self) + let indexHTML = Folder.testHTMLTemplate(basePath: testValue) + + XCTAssertEqual(indexHTML, testIndexHTML, "Template HTML not transformed as expected") + } + } + + + + func testStaticHostableTransformerIndexHTMLOutput() throws { + + // Convert a test bundle as input for the StaticHostableTransformer + let bundleURL = Bundle.module.url(forResource: "TestBundle", withExtension: "docc", subdirectory: "Test Bundles")! + + let targetURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + + let fileManager = FileManager.default + try fileManager.createDirectory(at: targetURL, withIntermediateDirectories: true, attributes: nil) + + defer { try? fileManager.removeItem(at: targetURL) } + + let templateURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try Folder.emptyHTMLTemplateDirectory.write(to: templateURL) + defer { try? fileManager.removeItem(at: templateURL) } + + let targetBundleURL = targetURL.appendingPathComponent("Result.doccarchive") + defer { try? fileManager.removeItem(at: targetBundleURL) } + + var action = try ConvertAction( + documentationBundleURL: bundleURL, + outOfProcessResolver: nil, + analyze: false, + targetDirectory: targetBundleURL, + htmlTemplateDirectory: templateURL, + emitDigest: false, + currentPlatforms: nil + ) + + _ = try action.perform(logHandle: .standardOutput) + + let dataURL = targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName) + let dataProvider = try LocalFileSystemDataProvider(rootURL: dataURL) + + let testTemplateURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try Folder.testHTMLTemplateDirectory.write(to: testTemplateURL) + defer { try? fileManager.removeItem(at: testTemplateURL) } + + let basePaths = ["test": "test", + "/test": "test", + "test/": "test", + "/test/": "test", + "test/test": "test/test", + "/test/test": "test/test", + "test/test/": "test/test", + "/test/test/": "test/test"] + + for (basePath, testValue) in basePaths { + + let outputURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + defer { try? fileManager.removeItem(at: outputURL) } + + let indexHTMLData = try StaticHostableTransformer.transformHTMLTemplate(htmlTemplate: testTemplateURL, hostingBasePath: basePath) + + let transformer = StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: outputURL, indexHTMLData: indexHTMLData) + + try transformer.transform() + + + // Test an output folder exists + guard fileManager.fileExists(atPath: outputURL.path) else { + XCTFail("StaticHostableTransformer failed to create output folder") + return + } + + let indexHTML = Folder.testHTMLTemplate(basePath: testValue) + try compareIndexHTML(fileManager: fileManager, folder: outputURL, indexHTML: indexHTML) + } + } + + + private func compareIndexHTML(fileManager: FileManagerProtocol, folder: URL, indexHTML: String) throws { + + for item in try fileManager.contentsOfDirectory(atPath: folder.path) { + + guard item == "index.html" else { + let subFolder = folder.appendingPathComponent(item) + try compareIndexHTML(fileManager: fileManager, folder: subFolder, indexHTML: indexHTML) + continue + } + let indexFileURL = folder.appendingPathComponent("index.html") + let testHTMLString = try String(contentsOf: indexFileURL) + XCTAssertEqual(testHTMLString, indexHTML, "Unexpected content in index.html at \(indexFileURL)") + } + } +} + diff --git a/Tests/SwiftDocCUtilitiesTests/StaticHostingBaseTest.swift b/Tests/SwiftDocCUtilitiesTests/StaticHostingBaseTest.swift new file mode 100644 index 0000000000..713a167dfe --- /dev/null +++ b/Tests/SwiftDocCUtilitiesTests/StaticHostingBaseTest.swift @@ -0,0 +1,64 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import XCTest +import Foundation +@testable import SwiftDocC +@testable import SwiftDocCUtilities + +class StaticHostingBaseTests: XCTestCase { + + /// Checks that the content in the output URL is as expected based on any JSON files found in the inputURL and any any sub folders. + /// Also checks any index.html files contain the expected content. + func compareJSONFolder(fileManager: FileManager, output: URL, input: URL, indexHTML: String?) { + + do { + let inputContents = try fileManager.contentsOfDirectory(atPath: input.path) + let outputContents = try fileManager.contentsOfDirectory(atPath: output.path) + + for inputContent in inputContents { + if inputContent.lowercased().hasSuffix(".json") { + let folderName = String(inputContent.dropLast(5)) + XCTAssert(outputContents.contains(folderName), "Failed to find folder in output for input \(inputContent) in \(input)") + do { + let createdFolder = output.appendingPathComponent(folderName) + let jsonFolderContents = try fileManager.contentsOfDirectory(atPath: createdFolder.path) + guard jsonFolderContents.count > 0 else { + XCTFail("Unexpected number of files in \(createdFolder). Expected > 0 but found \(jsonFolderContents.count) - \(jsonFolderContents)") + continue + } + + guard jsonFolderContents.contains("index.html") else { + XCTFail("Expected to find index.html in \(createdFolder) but found \(jsonFolderContents)") + continue + } + + // Only check the indexHTML if we have some. + guard let indexHTML = indexHTML else { continue } + + let indexFileURL = createdFolder.appendingPathComponent("index.html") + let testHTMLString = try String(contentsOf: indexFileURL) + XCTAssertEqual(testHTMLString, indexHTML, "Unexpected content in index.html at \(indexFileURL)") + } catch { + XCTFail("Invalid contents during comparrison of \(input) and \(output) - \(error)") + continue + } + } else { + compareJSONFolder(fileManager: fileManager, + output: output.appendingPathComponent(inputContent), + input: input.appendingPathComponent(inputContent), + indexHTML: indexHTML) + } + } + } catch { + XCTFail("Invalid contents during comparrison of \(input) and \(output) - \(error)") + } + } +} diff --git a/Tests/SwiftDocCUtilitiesTests/TransformForStaticHostingActionTests.swift b/Tests/SwiftDocCUtilitiesTests/TransformForStaticHostingActionTests.swift new file mode 100644 index 0000000000..6409388bfb --- /dev/null +++ b/Tests/SwiftDocCUtilitiesTests/TransformForStaticHostingActionTests.swift @@ -0,0 +1,189 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import XCTest +import Foundation +@testable import SwiftDocC +@testable import SwiftDocCUtilities + +class TransformForStaticHostingActionTests: StaticHostingBaseTests { + + /// Creates a DocC archive and then archive then executes and TransformForStaticHostingAction on it to produce static content which is then validated. + func testTransformForStaticHostingTestExternalOutput() throws { + + // Convert a test bundle as input for the TransformForStaticHostingAction + let bundleURL = Bundle.module.url(forResource: "TestBundle", withExtension: "docc", subdirectory: "Test Bundles")! + let targetURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + + let fileManager = FileManager.default + try fileManager.createDirectory(at: targetURL, withIntermediateDirectories: true, attributes: nil) + + defer { try? fileManager.removeItem(at: targetURL) } + + let templateURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try Folder.emptyHTMLTemplateDirectory.write(to: templateURL) + defer { try? fileManager.removeItem(at: templateURL) } + + let targetBundleURL = targetURL.appendingPathComponent("Result.doccarchive") + defer { try? fileManager.removeItem(at: targetBundleURL) } + + var action = try ConvertAction( + documentationBundleURL: bundleURL, + outOfProcessResolver: nil, + analyze: false, + targetDirectory: targetBundleURL, + htmlTemplateDirectory: templateURL, + emitDigest: false, + currentPlatforms: nil + ) + + _ = try action.perform(logHandle: .standardOutput) + + let outputURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + defer { try? fileManager.removeItem(at: outputURL) } + + let basePath = "test/folder" + + let testTemplateURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + let templateFolder = Folder.testHTMLTemplateDirectory + try templateFolder.write(to: testTemplateURL) + + let indexHTML = Folder.testHTMLTemplate(basePath: basePath) + + defer { try? fileManager.removeItem(at: testTemplateURL) } + + var transformAction = try TransformForStaticHostingAction(documentationBundleURL: targetBundleURL, outputURL: outputURL, hostingBasePath: basePath, htmlTemplateDirectory: testTemplateURL) + + _ = try transformAction.perform(logHandle: .standardOutput) + + var isDirectory: ObjCBool = false + + // Test an output folder exists + guard fileManager.fileExists(atPath: outputURL.path, isDirectory: &isDirectory) else { + XCTFail("TransformForStaticHostingAction failed to create output folder") + return + } + + // Test the output folder really is a folder. + XCTAssert(isDirectory.boolValue) + + // Test the content of the output folder. + var expectedContent = try fileManager.contentsOfDirectory(atPath: targetBundleURL.path) + expectedContent += templateFolder.content.filter { $0 is Folder }.map{ $0.name } + expectedContent += ["documentation", "tutorials"] + + let output = try fileManager.contentsOfDirectory(atPath: outputURL.path) + XCTAssertEqual(Set(output), Set(expectedContent), "Unexpect output") + + for item in output { + + // Test the content of the documentation and tutorial folders match the expected content from the doccarchive. + switch item { + case "documentation": + compareJSONFolder(fileManager: fileManager, + output: outputURL.appendingPathComponent(NodeURLGenerator.Path.documentationFolderName), + input: targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName).appendingPathComponent(NodeURLGenerator.Path.documentationFolderName), + indexHTML: indexHTML) + case "tutorials": + compareJSONFolder(fileManager: fileManager, + output: outputURL.appendingPathComponent(NodeURLGenerator.Path.tutorialsFolderName), + input: targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName).appendingPathComponent(NodeURLGenerator.Path.tutorialsFolderName), + indexHTML: indexHTML) + default: + continue + } + } + + } + + + // Creates a DocC archive and then archive then executes and TransformForStaticHostingAction on it to produce static content which is then validated. + func testTransformForStaticHostingActionTestInPlaceOutput() throws { + + // Convert a test bundle as input for the TransformForStaticHostingAction + let bundleURL = Bundle.module.url(forResource: "TestBundle", withExtension: "docc", subdirectory: "Test Bundles")! + let targetURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + + let fileManager = FileManager.default + try fileManager.createDirectory(at: targetURL, withIntermediateDirectories: true, attributes: nil) + defer { try? fileManager.removeItem(at: targetURL) } + + let templateURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try Folder.emptyHTMLTemplateDirectory.write(to: templateURL) + defer { try? fileManager.removeItem(at: templateURL) } + + let targetBundleURL = targetURL.appendingPathComponent("Result.doccarchive") + + var action = try ConvertAction( + documentationBundleURL: bundleURL, + outOfProcessResolver: nil, + analyze: false, + targetDirectory: targetBundleURL, + htmlTemplateDirectory: templateURL, + emitDigest: false, + currentPlatforms: nil + ) + + _ = try action.perform(logHandle: .standardOutput) + + + let basePath = "test/folder" + let testTemplateURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + let templateFolder = Folder.testHTMLTemplateDirectory + try templateFolder.write(to: testTemplateURL) + + let indexHTML = Folder.testHTMLTemplate(basePath: basePath) + var expectedContent = try fileManager.contentsOfDirectory(atPath: targetBundleURL.path) + + defer { try? fileManager.removeItem(at: testTemplateURL) } + + var transformAction = try TransformForStaticHostingAction(documentationBundleURL: targetBundleURL, outputURL: nil, hostingBasePath: basePath, htmlTemplateDirectory: testTemplateURL) + + _ = try transformAction.perform(logHandle: .standardOutput) + + var isDirectory: ObjCBool = false + + // Test an output folder exists + guard fileManager.fileExists(atPath: targetBundleURL.path, isDirectory: &isDirectory) else { + XCTFail("TransformForStaticHostingAction - Output Folder not Found") + return + } + + // Test the output folder really is a folder. + XCTAssert(isDirectory.boolValue) + + // Test the content of the output folder. + expectedContent += templateFolder.content.filter { $0 is Folder }.map{ $0.name } + expectedContent += ["documentation", "tutorials"] + + let output = try fileManager.contentsOfDirectory(atPath: targetBundleURL.path) + XCTAssertEqual(Set(output), Set(expectedContent), "Unexpect output") + + for item in output { + + // Test the content of the documentation and tutorial folders match the expected content from the doccarchive. + switch item { + case "documentation": + compareJSONFolder(fileManager: fileManager, + output: targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.documentationFolderName), + input: targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName).appendingPathComponent(NodeURLGenerator.Path.documentationFolderName), + indexHTML: indexHTML) + case "tutorials": + compareJSONFolder(fileManager: fileManager, + output: targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.tutorialsFolderName), + input: targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName).appendingPathComponent(NodeURLGenerator.Path.tutorialsFolderName), + indexHTML: indexHTML) + default: + continue + } + } + } +} + diff --git a/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystem.swift b/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystem.swift index 85f3802187..fcc7138b8d 100644 --- a/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystem.swift +++ b/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystem.swift @@ -9,6 +9,7 @@ */ import Foundation +import XCTest @testable import SwiftDocCUtilities @testable import SwiftDocC @@ -278,6 +279,49 @@ class TestFileSystem: FileManagerProtocol, DocumentationWorkspaceDataProvider { return files[atPath] } + func contentsOfDirectory(atPath path: String) throws -> [String] { + filesLock.lock() + defer { filesLock.unlock() } + + var results = Set() + + let paths = files.keys.filter { $0.hasPrefix(path) } + for p in paths { + let endOfPath = String(p.dropFirst(path.count)) + guard !endOfPath.isEmpty else { continue } + let pathParts = endOfPath.components(separatedBy: "/") + if pathParts.count == 1 { + results.insert(pathParts[0]) + } + } + return Array(results) + } + + + + func contentsOfDirectory(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?, options mask: FileManager.DirectoryEnumerationOptions) throws -> [URL] { + + if let keys = keys { + XCTAssertTrue( + keys.isEmpty, + "includingPropertiesForKeys is not implemented in contentsOfDirectory in TestFileSystem" + ) + } + + if mask != .skipsHiddenFiles && mask.isEmpty { + XCTFail("The given directory enumeration option(s) have not been implemented in the test file system: \(mask)") + } + + let skipHiddenFiles = mask == .skipsHiddenFiles + let contents = try contentsOfDirectory(atPath: url.path) + let output: [URL] = contents.filter({ skipHiddenFiles ? !$0.hasPrefix(".") : true}).map { + url.appendingPathComponent($0) + } + + return output + } + + enum Errors: DescribedError { case invalidPath(String) var errorDescription: String {