From 5d4a4eace9aaaea046c72a5746338c2ea81ced2c Mon Sep 17 00:00:00 2001 From: "Steve Scott (Scotty)" Date: Thu, 18 Nov 2021 17:27:30 +0000 Subject: [PATCH 1/5] Add Static Hosting Support rdar://70800606 --- .../Actions/Convert/ConvertAction.swift | 44 +++- .../TransformForStaticHostingAction.swift | 110 ++++++++++ .../ConvertAction+CommandInitialization.swift | 4 +- ...cHostingAction+CommandInitialization.swift | 32 +++ .../Options/DocumentationArchiveOption.swift | 35 +++- .../Options/TemplateOption.swift | 5 +- .../ArgumentParsing/Subcommands/Convert.swift | 41 +++- .../ArgumentParsing/Subcommands/Index.swift | 2 +- .../Subcommands/ProcessArchive.swift | 24 +++ .../TransformForStaticHosting.swift | 76 +++++++ Sources/SwiftDocCUtilities/Docc.swift | 2 +- .../StaticHostableTransformer.swift | 169 ++++++++++++++++ .../Utility/FileManagerProtocol.swift | 4 + .../ConvertActionStaticHostableTests.swift | 84 ++++++++ .../EmptyHTMLTemplateDirectory.swift | 19 -- .../HTMLTemplateDirectory.swift | 48 +++++ .../StaticHostableTransformerTests.swift | 187 +++++++++++++++++ .../StaticHostingBaseTest.swift | 64 ++++++ ...TransformForStaticHostingActionTests.swift | 189 ++++++++++++++++++ .../Utility/TestFileSystem.swift | 42 ++++ 20 files changed, 1141 insertions(+), 40 deletions(-) create mode 100644 Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift create mode 100644 Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift create mode 100644 Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/ProcessArchive.swift create mode 100644 Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/TransformForStaticHosting.swift create mode 100644 Sources/SwiftDocCUtilities/Transformers/StaticHostableTransformer.swift create mode 100644 Tests/SwiftDocCUtilitiesTests/ConvertActionStaticHostableTests.swift delete mode 100644 Tests/SwiftDocCUtilitiesTests/EmptyHTMLTemplateDirectory.swift create mode 100644 Tests/SwiftDocCUtilitiesTests/HTMLTemplateDirectory.swift create mode 100644 Tests/SwiftDocCUtilitiesTests/StaticHostableTransformerTests.swift create mode 100644 Tests/SwiftDocCUtilitiesTests/StaticHostingBaseTest.swift create mode 100644 Tests/SwiftDocCUtilitiesTests/TransformForStaticHostingActionTests.swift diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift index a74fc2f780..00dee05aa4 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 staticHostingBasePath: 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, + staticHostingBasePath: 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.staticHostingBasePath = staticHostingBasePath + 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, + staticHostingBasePath: 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, + staticHostingBasePath: staticHostingBasePath ) } @@ -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 @@ -277,6 +290,15 @@ public struct ConvertAction: Action, RecreatingContext { let temporaryFolder = try createTempFolder( with: htmlTemplateDirectory) + + // The `template-index.html` is a duplicate version of `index.html` with extra template + // tokens that allow for customizing the base-path used when transforming + // for a static hosting environment. We don't want to include it when copying over + // the base template. + let templateURL: URL = temporaryFolder.appendingPathComponent(HTMLTemplate.templateFileName.rawValue) + if fileManager.fileExists(atPath: templateURL.path) { + try fileManager.removeItem(at: templateURL) + } defer { try? fileManager.removeItem(at: temporaryFolder) @@ -330,13 +352,20 @@ public struct ConvertAction: Action, RecreatingContext { allProblems.append(contentsOf: indexerProblems) } + // Process Static Hosting is needed. + if transformForStaticHosting, let templateDirectory = htmlTemplateDirectory { + let dataProvider = try LocalFileSystemDataProvider(rootURL: temporaryFolder.appendingPathComponent("data")) + let transformer = try StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: temporaryFolder, htmlTemplate: templateDirectory, staticHostingBasePath: staticHostingBasePath) + 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"))) @@ -363,6 +392,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..0eb1cf8108 --- /dev/null +++ b/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift @@ -0,0 +1,110 @@ +/* + 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 staticHostingBasePath: 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?, + staticHostingBasePath: 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.staticHostingBasePath = staticHostingBasePath + self.htmlTemplateDirectory = htmlTemplateDirectory + self.fileManager = fileManager + self.diagnosticEngine = diagnosticEngine + self.diagnosticEngine.add(DiagnosticConsoleWriter(formattingOptions: [])) + } + + /// Converts each eligable file from the source archive, + /// 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 do it item 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) + } + + // Create a StaticHostableTransformer targeted at the archive data folder + let dataProvider = try LocalFileSystemDataProvider(rootURL: rootURL.appendingPathComponent("data")) + let transformer = try StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: outputURL, htmlTemplate: htmlTemplateDirectory, staticHostingBasePath: staticHostingBasePath) + try transformer.transform() + + } + + /// Create ouput 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..f3f93251a7 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, + staticHostingBasePath: convert.staticHostingBasePath ) } } diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift new file mode 100644 index 0000000000..583399ecbe --- /dev/null +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift @@ -0,0 +1,32 @@ +/* + 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 ValidationError("No valid html Template folder has been provided") + } + + try self.init( + documentationBundleURL: cmd.documentationArchive.urlOrFallback, + outputURL: cmd.outputURL, + staticHostingBasePath: cmd.staticHostingBasePath, + htmlTemplateDirectory: htmlTemplateFolder ) + } +} diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift index 6aaa28607a..1f039a4aff 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift @@ -12,21 +12,40 @@ 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. + /// The name of the command line argument used to specify a source bundle 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 a 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 == true else { + throw ValidationError("'\(urlOrFallback.path)' is not a valid DocC Archive.") + } + + var archiveContents: [String] + do { + archiveContents = try FileManager.default.contentsOfDirectory(atPath: urlOrFallback.path) + } catch { + throw ValidationError("'\(urlOrFallback.path)' is not a valid DocC Archive: \(error)") + } + + guard DocCArchiveOption.expectedContent.isSubset(of: Set(archiveContents)) else { + let missing = Array(Set(DocCArchiveOption.expectedContent).subtracting(archiveContents)) + throw ValidationError("'\(urlOrFallback.path)' is not a valid DocC Archive. Missing: \(missing)") + } + + } } diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Options/TemplateOption.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Options/TemplateOption.swift index 4fa68396fc..07753fa8dd 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Options/TemplateOption.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Options/TemplateOption.swift @@ -65,7 +65,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,7 +73,8 @@ 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 { + guard FileManager.default.fileExists(atPath: templateURL.appendingPathComponent(HTMLTemplate.indexFileName.rawValue).path) + else { throw ValidationError( """ Invalid HTML template directory configuration provided via the '\(TemplateOption.environmentVariableKey)' environment variable. diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift index 05b8b5e7a5..8861da6365 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("static-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 staticHostingBasePath: String? + // MARK: - Property Validation @@ -234,6 +248,31 @@ extension Docc { throw ValidationError("No directory exist at '\(outputParent.path)'.") } } + + if transformForStaticHosting { + if let templateURL = templateOption.templateURL { + let neededFileName: String + + if staticHostingBasePath != nil { + neededFileName = HTMLTemplate.templateFileName.rawValue + }else { + neededFileName = HTMLTemplate.indexFileName.rawValue + } + + let indexTemplate = templateURL.appendingPathComponent(neededFileName) + if !FileManager.default.fileExists(atPath: indexTemplate.path) { + throw ValidationError("You cannot Transform for Static Hosting as the provided template (\(TemplateOption.environmentVariableKey)) does not contain a valid \(neededFileName) file.") + } + + } else { + throw ValidationError( + """ + Invalid or missing HTML template directory, relative to the docc executable, at: \(templateOption.defaultTemplateURL.path) + Set the '\(TemplateOption.environmentVariableKey)' environment variable to use a custom HTML template. + """) + } + } + } // 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/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..c1ad458b6d --- /dev/null +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/TransformForStaticHosting.swift @@ -0,0 +1,76 @@ +/* + 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("static-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 staticHostingBasePath: 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 ValidationError("You cannot Transform for Static Hosting as the provided template (\(TemplateOption.environmentVariableKey)) does not contain a \(HTMLTemplate.templateFileName) file.") + } + } else { + throw ValidationError( + """ + Invalid or missing HTML template directory, relative to the docc executable, at: \(templateOption.defaultTemplateURL.path) + Set the '\(TemplateOption.environmentVariableKey)' environment variable to use a custom HTML template. + """) + } + } + + // 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..bbd590ba13 --- /dev/null +++ b/Sources/SwiftDocCUtilities/Transformers/StaticHostableTransformer.swift @@ -0,0 +1,169 @@ +/* + 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: Error { + case dataProviderDoesNotReferenceValidInput +} + +/// Navigates the contents of a FileSystemProvider pointing at the data folder of a .doccarchive to emit a static hostable website. +class 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 + /// - indexHTML: The HTML to be used in the generated index.html file. + init(dataProvider: FileSystemProvider, fileManager: FileManagerProtocol, outputURL: URL, htmlTemplate: URL, staticHostingBasePath: String?) throws { + self.dataProvider = dataProvider + self.fileManager = fileManager + self.outputURL = outputURL + + let indexFileName = staticHostingBasePath != nil ? HTMLTemplate.templateFileName.rawValue : HTMLTemplate.indexFileName.rawValue + let indexFileURL = htmlTemplate.appendingPathComponent(indexFileName) + var indexHTML = try String(contentsOfFile: indexFileURL.path) + + + if let staticHostingBasePath = staticHostingBasePath { + + var replacementString = staticHostingBasePath + + // 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) + } + self.indexHTMLData = Data(indexHTML.utf8) + } + + /// Creates a static hostable version of the documention in the data folder of an archive pointed to by the `dataProvider` + /// - Parameters: + /// - outputURL: The folder where the output will be placed + /// - basePath: The path to be prefix to all href and src parameters in generated html + /// - Returns: An array if problems encounter during the archive. + 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 processDirectoryContents(directoryRoot: outputURL, relativeSubPath: "", directoryContents: dir.children) + case .file(_): + throw StaticHostableTransformerError.dataProviderDoesNotReferenceValidInput + } + } + + + /// 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: [:]) + } + } + + /// Processes 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 occured during processing + private func processDirectoryContents(directoryRoot: URL, relativeSubPath: String, directoryContents: [FSNode]) throws { + + for node in directoryContents { + switch node { + case .directory(let dir): + try processDirectory(directoryRoot: directoryRoot, currentDirectoryNode: dir, directorySubPath: relativeSubPath) + case .file(let file): + let outputURL = directoryRoot.appendingPathComponent(relativeSubPath) + try processFile(file: file, outputURL: outputURL) + } + } + + } + + /// Processes 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 + /// - Returns: An array of problems that may have occured during processing + private func processDirectory(directoryRoot: URL, currentDirectoryNode: FSNode.Directory, directorySubPath: String) throws { + + // Create the path for the new directory + var newDirectory = directorySubPath + let newPathComponent = currentDirectoryNode.url.lastPathComponent + if !newDirectory.isEmpty { + newDirectory += "/" + } + newDirectory += newPathComponent + + + // Create the HTML output directory + + let htmlOutputURL = directoryRoot.appendingPathComponent(newDirectory) + try createDirectory(url: htmlOutputURL) + + // Process the direcorty contents + try processDirectoryContents(directoryRoot: directoryRoot, relativeSubPath: newDirectory, directoryContents: currentDirectoryNode.children) + + } + + /// Processes the given File + /// - Parameters: + /// - file: The FSNode that represents the file + /// - outputURL: The directory the need to be placed in + /// - Returns: An array of problems that may have occured during processing + private func processFile(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) + } +} 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/SwiftDocCUtilitiesTests/ConvertActionStaticHostableTests.swift b/Tests/SwiftDocCUtilitiesTests/ConvertActionStaticHostableTests.swift new file mode 100644 index 0000000000..3e87a98d57 --- /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 archive woth 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, + staticHostingBasePath: 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("documentation"), + input: targetBundleURL.appendingPathComponent("data").appendingPathComponent("documentation"), + indexHTML: indexHTML) + case "tutorials": + compareJSONFolder(fileManager: fileManager, + output: targetBundleURL.appendingPathComponent("tutorials"), + input: targetBundleURL.appendingPathComponent("data").appendingPathComponent("tutorials"), + indexHTML: indexHTML) + default: + continue + } + } + + } +} + 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..a2e08add16 --- /dev/null +++ b/Tests/SwiftDocCUtilitiesTests/StaticHostableTransformerTests.swift @@ -0,0 +1,187 @@ +/* + 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 dataURL = targetBundleURL.appendingPathComponent("data") + let dataProvider = try LocalFileSystemDataProvider(rootURL: dataURL) + let transformer = try StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: outputURL, htmlTemplate: testTemplateURL, staticHostingBasePath: basePath) + + 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 { + + // 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("data") + 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 transformer = try StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: outputURL, htmlTemplate: testTemplateURL, staticHostingBasePath: basePath) + + 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..a6ef7d66dc --- /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, staticHostingBasePath: 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("documentation"), + input: targetBundleURL.appendingPathComponent("data").appendingPathComponent("documentation"), + indexHTML: indexHTML) + case "tutorials": + compareJSONFolder(fileManager: fileManager, + output: outputURL.appendingPathComponent("tutorials"), + input: targetBundleURL.appendingPathComponent("data").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 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, staticHostingBasePath: 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("documentation"), + input: targetBundleURL.appendingPathComponent("data").appendingPathComponent("documentation"), + indexHTML: indexHTML) + case "tutorials": + compareJSONFolder(fileManager: fileManager, + output: targetBundleURL.appendingPathComponent("tutorials"), + input: targetBundleURL.appendingPathComponent("data").appendingPathComponent("tutorials"), + indexHTML: indexHTML) + default: + continue + } + } + } +} + diff --git a/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystem.swift b/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystem.swift index b04822b6b9..f396cf3db2 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,47 @@ 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 { + XCTAssert(keys.isEmpty, "includingPropertiesForKeys is not implemented in contentsOfDirectory in TestFileSystem") + } + + // This code will become incomplete should `FileManager.DirectoryEnumerationOptions` change. + if mask.contains(.skipsPackageDescendants) || mask.contains(.skipsPackageDescendants) || mask.contains(.includesDirectoriesPostOrder) || mask.contains(.producesRelativePathURLs) { + XCTFail("contentsOfDirectory in TestFileSystem call with a mask option that has not been implented") + } + + let skipHiddenFiles = mask.contains(.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 { From 2bc5a8645bfa62733e25b29a1e08bf51a11a54c2 Mon Sep 17 00:00:00 2001 From: "Steve Scott (Scotty)" Date: Thu, 2 Dec 2021 13:17:42 +0000 Subject: [PATCH 2/5] Fixes for PR#44 --- .../Infrastructure/NodeURLGenerator.swift | 1 + .../Actions/Convert/ConvertAction.swift | 41 +++++-- .../TransformForStaticHostingAction.swift | 22 ++-- .../ConvertAction+CommandInitialization.swift | 2 +- ...cHostingAction+CommandInitialization.swift | 2 +- .../Options/DocumentationArchiveOption.swift | 14 +-- .../ArgumentParsing/Subcommands/Convert.swift | 13 +- .../TransformForStaticHosting.swift | 4 +- .../StaticHostableTransformer.swift | 116 ++++++++++-------- .../Benchmark/OutputSizeTests.swift | 2 +- .../ConvertActionStaticHostableTests.swift | 12 +- .../ConvertActionTests.swift | 4 +- .../StaticHostableTransformerTests.swift | 39 +++++- ...TransformForStaticHostingActionTests.swift | 20 +-- 14 files changed, 180 insertions(+), 112 deletions(-) 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 00dee05aa4..6465c0ccd3 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift @@ -40,7 +40,7 @@ public struct ConvertAction: Action, RecreatingContext { let diagnosticEngine: DiagnosticEngine let transformForStaticHosting: Bool - let staticHostingBasePath: String? + let hostingBasePath: String? private(set) var context: DocumentationContext { @@ -94,7 +94,7 @@ public struct ConvertAction: Action, RecreatingContext { inheritDocs: Bool = false, experimentalEnableCustomTemplates: Bool = false, transformForStaticHosting: Bool = false, - staticHostingBasePath: String? = nil + hostingBasePath: String? = nil ) throws { self.rootURL = documentationBundleURL @@ -109,7 +109,7 @@ public struct ConvertAction: Action, RecreatingContext { self.fileManager = fileManager self.documentationCoverageOptions = documentationCoverageOptions self.transformForStaticHosting = transformForStaticHosting - self.staticHostingBasePath = staticHostingBasePath + self.hostingBasePath = hostingBasePath let filterLevel: DiagnosticSeverity if analyze { @@ -200,7 +200,7 @@ public struct ConvertAction: Action, RecreatingContext { inheritDocs: Bool = false, experimentalEnableCustomTemplates: Bool = false, transformForStaticHosting: Bool, - staticHostingBasePath: String? + 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 @@ -230,7 +230,7 @@ public struct ConvertAction: Action, RecreatingContext { inheritDocs: inheritDocs, experimentalEnableCustomTemplates: experimentalEnableCustomTemplates, transformForStaticHosting: transformForStaticHosting, - staticHostingBasePath: staticHostingBasePath + hostingBasePath: hostingBasePath ) } @@ -290,13 +290,26 @@ 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 used when transforming - // for a static hosting environment. We don't want to include it when copying over - // the base 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) } @@ -352,10 +365,14 @@ public struct ConvertAction: Action, RecreatingContext { allProblems.append(contentsOf: indexerProblems) } - // Process Static Hosting is needed. + // Process Static Hosting as needed. if transformForStaticHosting, let templateDirectory = htmlTemplateDirectory { - let dataProvider = try LocalFileSystemDataProvider(rootURL: temporaryFolder.appendingPathComponent("data")) - let transformer = try StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: temporaryFolder, htmlTemplate: templateDirectory, staticHostingBasePath: staticHostingBasePath) + 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() } @@ -367,7 +384,7 @@ public struct ConvertAction: Action, RecreatingContext { } // 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. diff --git a/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift b/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift index 0eb1cf8108..7039e0daf5 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift @@ -16,7 +16,7 @@ struct TransformForStaticHostingAction: Action { let rootURL: URL let outputURL: URL - let staticHostingBasePath: String? + let hostingBasePath: String? let outputIsExternal: Bool let htmlTemplateDirectory: URL @@ -27,7 +27,7 @@ struct TransformForStaticHostingAction: Action { /// Initializes the action with the given validated options, creates or uses the given action workspace & context. init(documentationBundleURL: URL, outputURL:URL?, - staticHostingBasePath: String?, + hostingBasePath: String?, htmlTemplateDirectory: URL, fileManager: FileManagerProtocol = FileManager.default, diagnosticEngine: DiagnosticEngine = .init()) throws @@ -36,15 +36,15 @@ struct TransformForStaticHostingAction: Action { self.rootURL = documentationBundleURL self.outputURL = outputURL ?? documentationBundleURL self.outputIsExternal = outputURL != nil - self.staticHostingBasePath = staticHostingBasePath + self.hostingBasePath = hostingBasePath self.htmlTemplateDirectory = htmlTemplateDirectory self.fileManager = fileManager self.diagnosticEngine = diagnosticEngine self.diagnosticEngine.add(DiagnosticConsoleWriter(formattingOptions: [])) } - /// Converts each eligable file from the source archive, - /// saves the results in the given output folder. + /// 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]) @@ -61,7 +61,8 @@ struct TransformForStaticHostingAction: Action { try setupOutputDirectory(outputURL: outputURL) // Copy the appropriate folders from the archive. - // We will do it item as we want to preserve anything intentionally left in the output URL by `setupOutputDirectory` + // 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) @@ -86,15 +87,18 @@ struct TransformForStaticHostingAction: Action { } 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("data")) - let transformer = try StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: outputURL, htmlTemplate: htmlTemplateDirectory, staticHostingBasePath: staticHostingBasePath) + 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 ouput directory or empty its contents if it already exists. + /// Create output directory or empty its contents if it already exists. private func setupOutputDirectory(outputURL: URL) throws { var isDirectory: ObjCBool = false diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift index f3f93251a7..1d3357a485 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift @@ -81,7 +81,7 @@ extension ConvertAction { inheritDocs: convert.enableInheritedDocs, experimentalEnableCustomTemplates: convert.experimentalEnableCustomTemplates, transformForStaticHosting: convert.transformForStaticHosting, - staticHostingBasePath: convert.staticHostingBasePath + hostingBasePath: convert.hostingBasePath ) } } diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift index 583399ecbe..b5646dfbf2 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift @@ -26,7 +26,7 @@ extension TransformForStaticHostingAction { try self.init( documentationBundleURL: cmd.documentationArchive.urlOrFallback, outputURL: cmd.outputURL, - staticHostingBasePath: cmd.staticHostingBasePath, + hostingBasePath: cmd.hostingBasePath, htmlTemplateDirectory: htmlTemplateFolder ) } } diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift index 1f039a4aff..67dc63a4ed 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift @@ -16,11 +16,11 @@ public struct DocCArchiveOption: DirectoryPathOption { public init(){} - /// The name of the command line argument used to specify a source bundle path. + /// 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 a archive to be used by DocC. + /// The path to an archive to be used by DocC. @Argument( help: ArgumentHelp( "Path to the DocC Archive ('.doccarchive') that should be processed.", @@ -31,8 +31,8 @@ public struct DocCArchiveOption: DirectoryPathOption { public mutating func validate() throws { // Validate that the URL represents a directory - guard urlOrFallback.hasDirectoryPath == true else { - throw ValidationError("'\(urlOrFallback.path)' is not a valid DocC Archive.") + 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] @@ -42,9 +42,9 @@ public struct DocCArchiveOption: DirectoryPathOption { throw ValidationError("'\(urlOrFallback.path)' is not a valid DocC Archive: \(error)") } - guard DocCArchiveOption.expectedContent.isSubset(of: Set(archiveContents)) else { - let missing = Array(Set(DocCArchiveOption.expectedContent).subtracting(archiveContents)) - throw ValidationError("'\(urlOrFallback.path)' is not a valid DocC Archive. Missing: \(missing)") + let missingContents = Array(Set(DocCArchiveOption.expectedContent).subtracting(archiveContents)) + guard missingContents.isEmpty else { + throw ValidationError("'\(urlOrFallback.path)' is not a valid DocC Archive. Missing: \(missingContents)") } } diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift index 8861da6365..20907c2aee 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift @@ -224,12 +224,12 @@ extension Docc { /// A user-provided relative path to be used in the archived output @Option( - name: [.customLong("static-hosting-base-path")], + 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 staticHostingBasePath: String? + var hostingBasePath: String? // MARK: - Property Validation @@ -249,11 +249,12 @@ extension Docc { } } - if transformForStaticHosting { + if transformForStaticHosting { + if let templateURL = templateOption.templateURL { let neededFileName: String - if staticHostingBasePath != nil { + if hostingBasePath != nil { neededFileName = HTMLTemplate.templateFileName.rawValue }else { neededFileName = HTMLTemplate.indexFileName.rawValue @@ -261,13 +262,13 @@ extension Docc { let indexTemplate = templateURL.appendingPathComponent(neededFileName) if !FileManager.default.fileExists(atPath: indexTemplate.path) { - throw ValidationError("You cannot Transform for Static Hosting as the provided template (\(TemplateOption.environmentVariableKey)) does not contain a valid \(neededFileName) file.") + throw ValidationError("You cannot Transform for Static Hosting as the provided template (\(TemplateOption.environmentVariableKey)) does not contain a valid \(neededFileName) file.") } } else { throw ValidationError( """ - Invalid or missing HTML template directory, relative to the docc executable, at: \(templateOption.defaultTemplateURL.path) + Invalid or missing HTML template directory, relative to the docc executable, at: \(templateOption.defaultTemplateURL.path). Set the '\(TemplateOption.environmentVariableKey)' environment variable to use a custom HTML template. """) } diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/TransformForStaticHosting.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/TransformForStaticHosting.swift index c1ad458b6d..1c11bf6c0a 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/TransformForStaticHosting.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/TransformForStaticHosting.swift @@ -35,12 +35,12 @@ extension Docc.ProcessArchive { /// A user-provided relative path to be used in the archived output @Option( - name: [.customLong("static-hosting-base-path")], + 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 staticHostingBasePath: String? + var hostingBasePath: String? /// The user-provided path to an HTML documentation template. @OptionGroup() diff --git a/Sources/SwiftDocCUtilities/Transformers/StaticHostableTransformer.swift b/Sources/SwiftDocCUtilities/Transformers/StaticHostableTransformer.swift index bbd590ba13..7552332c18 100644 --- a/Sources/SwiftDocCUtilities/Transformers/StaticHostableTransformer.swift +++ b/Sources/SwiftDocCUtilities/Transformers/StaticHostableTransformer.swift @@ -17,11 +17,20 @@ enum HTMLTemplate: String { case tag = "{{BASE_PATH}}" } -enum StaticHostableTransformerError: Error { - case dataProviderDoesNotReferenceValidInput +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. +/// Navigates the contents of a FileSystemProvider pointing at the data folder of a `.doccarchive` to emit a static hostable website. class StaticHostableTransformer { /// The internal `FileSystemProvider` reference. @@ -38,44 +47,18 @@ class StaticHostableTransformer { /// Initialise with a dataProvider to the source doccarchive. /// - Parameters: - /// - dataProvider: Should point to the data folder in a docc archive. + /// - 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 - /// - indexHTML: The HTML to be used in the generated index.html file. - init(dataProvider: FileSystemProvider, fileManager: FileManagerProtocol, outputURL: URL, htmlTemplate: URL, staticHostingBasePath: String?) throws { + /// - 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 - - let indexFileName = staticHostingBasePath != nil ? HTMLTemplate.templateFileName.rawValue : HTMLTemplate.indexFileName.rawValue - let indexFileURL = htmlTemplate.appendingPathComponent(indexFileName) - var indexHTML = try String(contentsOfFile: indexFileURL.path) - - - if let staticHostingBasePath = staticHostingBasePath { - - var replacementString = staticHostingBasePath - - // 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) - } - self.indexHTMLData = Data(indexHTML.utf8) + self.indexHTMLData = indexHTMLData } - /// Creates a static hostable version of the documention in the data folder of an archive pointed to by the `dataProvider` - /// - Parameters: - /// - outputURL: The folder where the output will be placed - /// - basePath: The path to be prefix to all href and src parameters in generated html - /// - Returns: An array if problems encounter during the archive. + /// 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 @@ -83,9 +66,9 @@ class StaticHostableTransformer { // We should be starting at the data folder of a .doccarchive. switch node { case .directory(let dir): - try processDirectoryContents(directoryRoot: outputURL, relativeSubPath: "", directoryContents: dir.children) - case .file(_): - throw StaticHostableTransformerError.dataProviderDoesNotReferenceValidInput + try transformDirectoryContents(directoryRoot: outputURL, relativeSubPath: "", directoryContents: dir.children) + case .file(let file): + throw StaticHostableTransformerError.dataProviderDoesNotReferenceValidInput(url: file.url) } } @@ -98,38 +81,39 @@ class StaticHostableTransformer { } } - /// Processes the contents of a given directory + /// 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 occured during processing - private func processDirectoryContents(directoryRoot: URL, relativeSubPath: String, directoryContents: [FSNode]) throws { + /// - 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 processDirectory(directoryRoot: directoryRoot, currentDirectoryNode: dir, directorySubPath: relativeSubPath) + try transformDirectory(directoryRoot: directoryRoot, currentDirectoryNode: dir, directorySubPath: relativeSubPath) case .file(let file): let outputURL = directoryRoot.appendingPathComponent(relativeSubPath) - try processFile(file: file, outputURL: outputURL) + try transformFile(file: file, outputURL: outputURL) } } } - /// Processes the given directory + /// 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 - /// - Returns: An array of problems that may have occured during processing - private func processDirectory(directoryRoot: URL, currentDirectoryNode: FSNode.Directory, directorySubPath: String) throws { + 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 - if !newDirectory.isEmpty { + + // We need to ensure the new directory component, if not empty, ends with / + if !newDirectory.isEmpty && !newDirectory.hasSuffix("/") { newDirectory += "/" } newDirectory += newPathComponent @@ -140,17 +124,16 @@ class StaticHostableTransformer { let htmlOutputURL = directoryRoot.appendingPathComponent(newDirectory) try createDirectory(url: htmlOutputURL) - // Process the direcorty contents - try processDirectoryContents(directoryRoot: directoryRoot, relativeSubPath: newDirectory, directoryContents: currentDirectoryNode.children) + // Process the directory contents + try transformDirectoryContents(directoryRoot: directoryRoot, relativeSubPath: newDirectory, directoryContents: currentDirectoryNode.children) } - /// Processes the given File + /// Transform the given File /// - Parameters: /// - file: The FSNode that represents the file /// - outputURL: The directory the need to be placed in - /// - Returns: An array of problems that may have occured during processing - private func processFile(file: FSNode.File, outputURL: URL) throws { + 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 } @@ -167,3 +150,34 @@ class StaticHostableTransformer { 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/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 index 3e87a98d57..05f75401f8 100644 --- a/Tests/SwiftDocCUtilitiesTests/ConvertActionStaticHostableTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/ConvertActionStaticHostableTests.swift @@ -14,7 +14,7 @@ import Foundation @testable import SwiftDocCUtilities class ConvertActionStaticHostableTests: StaticHostingBaseTests { - /// Creates a DocC archive and then archive woth options to produce static content which is then validated. + /// 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")! @@ -47,7 +47,7 @@ class ConvertActionStaticHostableTests: StaticHostingBaseTests { emitDigest: false, currentPlatforms: nil, transformForStaticHosting: true, - staticHostingBasePath: basePath + hostingBasePath: basePath ) _ = try action.perform(logHandle: .standardOutput) @@ -66,13 +66,13 @@ class ConvertActionStaticHostableTests: StaticHostingBaseTests { switch item { case "documentation": compareJSONFolder(fileManager: fileManager, - output: targetBundleURL.appendingPathComponent("documentation"), - input: targetBundleURL.appendingPathComponent("data").appendingPathComponent("documentation"), + 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("tutorials"), - input: targetBundleURL.appendingPathComponent("data").appendingPathComponent("tutorials"), + 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 cceebe744b..6ab4a239cc 100644 --- a/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift @@ -2089,14 +2089,14 @@ class ConvertActionTests: XCTestCase { try action.performAndHandleResult() XCTAssertFalse( - testDataProvider.fileExists(atPath: targetDirectory.appendingPathComponent("data").path) + testDataProvider.fileExists(atPath: targetDirectory.appendingPathComponent(NodeURLGenerator.Path.dataFolderName).path) ) FeatureFlags.current.isExperimentalObjectiveCSupportEnabled = true 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/StaticHostableTransformerTests.swift b/Tests/SwiftDocCUtilitiesTests/StaticHostableTransformerTests.swift index a2e08add16..86ecf8082e 100644 --- a/Tests/SwiftDocCUtilitiesTests/StaticHostableTransformerTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/StaticHostableTransformerTests.swift @@ -56,9 +56,11 @@ class StaticHostableTransformerTests: StaticHostingBaseTests { let basePath = "test/folder" let indexHTML = Folder.testHTMLTemplate(basePath: basePath) - let dataURL = targetBundleURL.appendingPathComponent("data") + let indexHTMLData = try StaticHostableTransformer.transformHTMLTemplate(htmlTemplate: testTemplateURL, hostingBasePath: basePath) + + let dataURL = targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName) let dataProvider = try LocalFileSystemDataProvider(rootURL: dataURL) - let transformer = try StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: outputURL, htmlTemplate: testTemplateURL, staticHostingBasePath: basePath) + let transformer = StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: outputURL, indexHTMLData: indexHTMLData) try transformer.transform() @@ -102,6 +104,33 @@ 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 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")! @@ -131,7 +160,7 @@ class StaticHostableTransformerTests: StaticHostingBaseTests { _ = try action.perform(logHandle: .standardOutput) - let dataURL = targetBundleURL.appendingPathComponent("data") + let dataURL = targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName) let dataProvider = try LocalFileSystemDataProvider(rootURL: dataURL) let testTemplateURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) @@ -152,7 +181,9 @@ class StaticHostableTransformerTests: StaticHostingBaseTests { let outputURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) defer { try? fileManager.removeItem(at: outputURL) } - let transformer = try StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: outputURL, htmlTemplate: testTemplateURL, staticHostingBasePath: basePath) + let indexHTMLData = try StaticHostableTransformer.transformHTMLTemplate(htmlTemplate: testTemplateURL, hostingBasePath: basePath) + + let transformer = StaticHostableTransformer(dataProvider: dataProvider, fileManager: fileManager, outputURL: outputURL, indexHTMLData: indexHTMLData) try transformer.transform() diff --git a/Tests/SwiftDocCUtilitiesTests/TransformForStaticHostingActionTests.swift b/Tests/SwiftDocCUtilitiesTests/TransformForStaticHostingActionTests.swift index a6ef7d66dc..6409388bfb 100644 --- a/Tests/SwiftDocCUtilitiesTests/TransformForStaticHostingActionTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/TransformForStaticHostingActionTests.swift @@ -59,7 +59,7 @@ class TransformForStaticHostingActionTests: StaticHostingBaseTests { defer { try? fileManager.removeItem(at: testTemplateURL) } - var transformAction = try TransformForStaticHostingAction(documentationBundleURL: targetBundleURL, outputURL: outputURL, staticHostingBasePath: basePath, htmlTemplateDirectory: testTemplateURL) + var transformAction = try TransformForStaticHostingAction(documentationBundleURL: targetBundleURL, outputURL: outputURL, hostingBasePath: basePath, htmlTemplateDirectory: testTemplateURL) _ = try transformAction.perform(logHandle: .standardOutput) @@ -88,13 +88,13 @@ class TransformForStaticHostingActionTests: StaticHostingBaseTests { switch item { case "documentation": compareJSONFolder(fileManager: fileManager, - output: outputURL.appendingPathComponent("documentation"), - input: targetBundleURL.appendingPathComponent("data").appendingPathComponent("documentation"), + 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("tutorials"), - input: targetBundleURL.appendingPathComponent("data").appendingPathComponent("tutorials"), + output: outputURL.appendingPathComponent(NodeURLGenerator.Path.tutorialsFolderName), + input: targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName).appendingPathComponent(NodeURLGenerator.Path.tutorialsFolderName), indexHTML: indexHTML) default: continue @@ -144,7 +144,7 @@ class TransformForStaticHostingActionTests: StaticHostingBaseTests { defer { try? fileManager.removeItem(at: testTemplateURL) } - var transformAction = try TransformForStaticHostingAction(documentationBundleURL: targetBundleURL, outputURL: nil, staticHostingBasePath: basePath, htmlTemplateDirectory: testTemplateURL) + var transformAction = try TransformForStaticHostingAction(documentationBundleURL: targetBundleURL, outputURL: nil, hostingBasePath: basePath, htmlTemplateDirectory: testTemplateURL) _ = try transformAction.perform(logHandle: .standardOutput) @@ -172,13 +172,13 @@ class TransformForStaticHostingActionTests: StaticHostingBaseTests { switch item { case "documentation": compareJSONFolder(fileManager: fileManager, - output: targetBundleURL.appendingPathComponent("documentation"), - input: targetBundleURL.appendingPathComponent("data").appendingPathComponent("documentation"), + 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("tutorials"), - input: targetBundleURL.appendingPathComponent("data").appendingPathComponent("tutorials"), + output: targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.tutorialsFolderName), + input: targetBundleURL.appendingPathComponent(NodeURLGenerator.Path.dataFolderName).appendingPathComponent(NodeURLGenerator.Path.tutorialsFolderName), indexHTML: indexHTML) default: continue From 9b054eb3674b28a37bfe6f16c09d8c81f2e949fa Mon Sep 17 00:00:00 2001 From: Ethan Kusters Date: Thu, 9 Dec 2021 12:38:37 -0800 Subject: [PATCH 3/5] Fix test failures on linux `includesDirectoriesPostOrder` is not available on Linux. --- .../Utility/TestFileSystem.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystem.swift b/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystem.swift index fefef2b7c8..fcc7138b8d 100644 --- a/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystem.swift +++ b/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystem.swift @@ -302,15 +302,17 @@ class TestFileSystem: FileManagerProtocol, DocumentationWorkspaceDataProvider { func contentsOfDirectory(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?, options mask: FileManager.DirectoryEnumerationOptions) throws -> [URL] { if let keys = keys { - XCTAssert(keys.isEmpty, "includingPropertiesForKeys is not implemented in contentsOfDirectory in TestFileSystem") + XCTAssertTrue( + keys.isEmpty, + "includingPropertiesForKeys is not implemented in contentsOfDirectory in TestFileSystem" + ) } - - // This code will become incomplete should `FileManager.DirectoryEnumerationOptions` change. - if mask.contains(.skipsPackageDescendants) || mask.contains(.skipsPackageDescendants) || mask.contains(.includesDirectoriesPostOrder) || mask.contains(.producesRelativePathURLs) { - XCTFail("contentsOfDirectory in TestFileSystem call with a mask option that has not been implented") + + 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.contains(.skipsHiddenFiles) + let skipHiddenFiles = mask == .skipsHiddenFiles let contents = try contentsOfDirectory(atPath: url.path) let output: [URL] = contents.filter({ skipHiddenFiles ? !$0.hasPrefix(".") : true}).map { url.appendingPathComponent($0) From 2d3b228c10923a6a8a58cc66de9c1874ad8c942b Mon Sep 17 00:00:00 2001 From: Ethan Kusters Date: Thu, 9 Dec 2021 14:28:19 -0800 Subject: [PATCH 4/5] Make TemplateOption error messsages more consistent --- ...cHostingAction+CommandInitialization.swift | 4 ++- .../Options/DocumentationArchiveOption.swift | 7 ++++- .../Options/TemplateOption.swift | 30 +++++++++++++++---- .../ArgumentParsing/Subcommands/Convert.swift | 18 +++++------ .../ArgumentParsing/Subcommands/Preview.swift | 8 ++--- .../TransformForStaticHosting.swift | 15 +++++----- 6 files changed, 54 insertions(+), 28 deletions(-) diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift index b5646dfbf2..a768726d62 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/TransformForStaticHostingAction+CommandInitialization.swift @@ -20,7 +20,9 @@ extension TransformForStaticHostingAction { // Initialize the `TransformForStaticHostingAction` from the options provided by the `EmitStaticHostable` command guard let htmlTemplateFolder = cmd.templateOption.templateURL ?? fallbackTemplateURL else { - throw ValidationError("No valid html Template folder has been provided") + throw TemplateOption.missingHTMLTemplateError( + path: cmd.templateOption.defaultTemplateURL.path + ) } try self.init( diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift index 67dc63a4ed..411c188923 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Options/DocumentationArchiveOption.swift @@ -44,7 +44,12 @@ public struct DocCArchiveOption: DirectoryPathOption { let missingContents = Array(Set(DocCArchiveOption.expectedContent).subtracting(archiveContents)) guard missingContents.isEmpty else { - throw ValidationError("'\(urlOrFallback.path)' is not a valid DocC Archive. Missing: \(missingContents)") + 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 07753fa8dd..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) } @@ -75,11 +99,7 @@ public struct TemplateOption: ParsableArguments { // an HTML template for docc. guard FileManager.default.fileExists(atPath: templateURL.appendingPathComponent(HTMLTemplate.indexFileName.rawValue).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. - """) + 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 20907c2aee..299dfc6fac 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift @@ -245,13 +245,12 @@ 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 { + if let templateURL = templateOption.templateURL { let neededFileName: String if hostingBasePath != nil { @@ -262,15 +261,16 @@ extension Docc { let indexTemplate = templateURL.appendingPathComponent(neededFileName) if !FileManager.default.fileExists(atPath: indexTemplate.path) { - throw ValidationError("You cannot Transform for Static Hosting as the provided template (\(TemplateOption.environmentVariableKey)) does not contain a valid \(neededFileName) file.") + throw TemplateOption.invalidHTMLTemplateError( + path: templateURL.path, + expectedFile: neededFileName + ) } } else { - throw ValidationError( - """ - Invalid or missing HTML template directory, relative to the docc executable, at: \(templateOption.defaultTemplateURL.path). - Set the '\(TemplateOption.environmentVariableKey)' environment variable to use a custom HTML template. - """) + throw TemplateOption.missingHTMLTemplateError( + path: templateOption.defaultTemplateURL.path + ) } } 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/TransformForStaticHosting.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/TransformForStaticHosting.swift index 1c11bf6c0a..419bfc0b72 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/TransformForStaticHosting.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/TransformForStaticHosting.swift @@ -48,17 +48,18 @@ extension Docc.ProcessArchive { mutating func validate() throws { - if let templateURL = templateOption.templateURL { + if let templateURL = templateOption.templateURL { let indexTemplate = templateURL.appendingPathComponent(HTMLTemplate.templateFileName.rawValue) if !FileManager.default.fileExists(atPath: indexTemplate.path) { - throw ValidationError("You cannot Transform for Static Hosting as the provided template (\(TemplateOption.environmentVariableKey)) does not contain a \(HTMLTemplate.templateFileName) file.") + throw TemplateOption.invalidHTMLTemplateError( + path: templateURL.path, + expectedFile: HTMLTemplate.templateFileName.rawValue + ) } } else { - throw ValidationError( - """ - Invalid or missing HTML template directory, relative to the docc executable, at: \(templateOption.defaultTemplateURL.path) - Set the '\(TemplateOption.environmentVariableKey)' environment variable to use a custom HTML template. - """) + throw TemplateOption.missingHTMLTemplateError( + path: templateOption.defaultTemplateURL.path + ) } } From 60120e40271b189a0b7a88d199c6c9195e9b2e5f Mon Sep 17 00:00:00 2001 From: Ethan Kusters Date: Thu, 9 Dec 2021 14:31:51 -0800 Subject: [PATCH 5/5] Make 'StaticHostableTransformer' a struct --- .../Transformers/StaticHostableTransformer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftDocCUtilities/Transformers/StaticHostableTransformer.swift b/Sources/SwiftDocCUtilities/Transformers/StaticHostableTransformer.swift index 7552332c18..e84628b319 100644 --- a/Sources/SwiftDocCUtilities/Transformers/StaticHostableTransformer.swift +++ b/Sources/SwiftDocCUtilities/Transformers/StaticHostableTransformer.swift @@ -31,7 +31,7 @@ enum StaticHostableTransformerError: DescribedError { } /// Navigates the contents of a FileSystemProvider pointing at the data folder of a `.doccarchive` to emit a static hostable website. -class StaticHostableTransformer { +struct StaticHostableTransformer { /// The internal `FileSystemProvider` reference. /// This should be the data folder of an archive.