From f9ecc5ce6ffd294e12ac4bf23326443fefee80c9 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Mon, 17 Nov 2025 10:59:02 -0500 Subject: [PATCH] fix: Fixes generating html conversion. --- Sources/CommandClient/CommandClient.swift | 49 +++--- Sources/PandocClient/PandocClient+Run.swift | 153 ++++++++++++++---- Sources/PandocClient/PandocClient.swift | 4 +- .../PandocClientTests/PandocClientTests.swift | 96 ++++++----- 4 files changed, 206 insertions(+), 96 deletions(-) diff --git a/Sources/CommandClient/CommandClient.swift b/Sources/CommandClient/CommandClient.swift index f283ff8..b43ce89 100644 --- a/Sources/CommandClient/CommandClient.swift +++ b/Sources/CommandClient/CommandClient.swift @@ -4,10 +4,10 @@ import DependenciesMacros import Foundation import ShellClient -public extension DependencyValues { +extension DependencyValues { /// Runs shell commands. - var commandClient: CommandClient { + public var commandClient: CommandClient { get { self[CommandClient.self] } set { self[CommandClient.self] = newValue } } @@ -67,12 +67,13 @@ public struct CommandClient: Sendable { in workingDirectory: String? = nil, _ arguments: [String] ) async throws { - try await runCommand(.init( - arguments: arguments, - quiet: quiet, - shell: shell, - workingDirectory: workingDirectory - )) + try await runCommand( + .init( + arguments: arguments, + quiet: quiet, + shell: shell, + workingDirectory: workingDirectory + )) } /// Runs a shell command. @@ -161,19 +162,21 @@ extension CommandClient: DependencyKey { .init { options in @Dependency(\.asyncShellClient) var shellClient if !options.quiet { - try await shellClient.foreground(.init( - shell: .init(options.shell), - environment: environment, - in: options.workingDirectory, - options.arguments - )) + try await shellClient.foreground( + .init( + shell: .init(options.shell), + environment: environment, + in: options.workingDirectory, + options.arguments + )) } else { - try await shellClient.background(.init( - shell: .init(options.shell), - environment: environment, - in: options.workingDirectory, - options.arguments - )) + try await shellClient.background( + .init( + shell: .init(options.shell), + environment: environment, + in: options.workingDirectory, + options.arguments + )) } } } @@ -184,12 +187,12 @@ extension CommandClient: DependencyKey { } @_spi(Internal) -public extension CommandClient { +extension CommandClient { /// Create a command client that can capture the arguments / options. /// /// This is used for testing. - static func capturing(_ client: CapturingClient) -> Self { + public static func capturing(_ client: CapturingClient) -> Self { .init { options in await client.set(options) } @@ -198,7 +201,7 @@ public extension CommandClient { /// Captures the arguments / options passed into the command client's run commands. /// @dynamicMemberLookup - actor CapturingClient: Sendable { + public actor CapturingClient: Sendable { public private(set) var options: RunCommandOptions? public init() {} diff --git a/Sources/PandocClient/PandocClient+Run.swift b/Sources/PandocClient/PandocClient+Run.swift index 8836342..09eb6d2 100644 --- a/Sources/PandocClient/PandocClient+Run.swift +++ b/Sources/PandocClient/PandocClient+Run.swift @@ -6,26 +6,72 @@ import PlaybookClient extension PandocClient.RunOptions { + /// Runs a pandoc conversion on the project generating the given file type. + /// + /// - Parameters: + /// - fileType: The file type to convert to. + /// - environment: The environment variables. + /// + /// - Returns: File path to the converted output file. func run( _ fileType: PandocClient.FileType, _ environment: [String: String] + ) async throws -> String { + @Dependency(\.logger) var logger + + let ensuredOptions = try await self.ensuredOptions(fileType) + + let projectDirectory = self.projectDirectory ?? environment["PWD"] + + guard let projectDirectory else { + throw ProjectDirectoryNotSpecified() + } + + try await buildProject(projectDirectory, ensuredOptions) + + let outputDirectory = self.outputDirectory ?? projectDirectory + let outputPath = "\(outputDirectory)/\(ensuredOptions.ensuredFilename)" + + let arguments = ensuredOptions.makeArguments( + outputPath: outputPath, + projectDirectory: projectDirectory + ) + + logger.debug("Pandoc arguments: \(arguments)") + return try await runCommand(arguments, outputPath) + + } + + /// Runs a shell command with the given arguments, returning the passed in output path + /// so the command can be chained, if needed. + /// + @discardableResult + func runCommand( + _ arguments: [String], + _ outputPath: String ) async throws -> String { @Dependency(\.commandClient) var commandClient + @Dependency(\.logger) var logger + logger.debug("Running shell command with arguments: \(arguments)") + return try await commandClient.run(logging: loggingOptions, quiet: quiet, shell: shell) { + (arguments, outputPath) + } + } + + /// Build the project if necessary, before running the shell command that builds the final + /// output file(s). + /// + func buildProject( + _ projectDirectory: String, + _ ensuredOptions: EnsuredPandocOptions + ) async throws { @Dependency(\.logger) var logger @Dependency(\.playbookClient) var playbookClient - return try await commandClient.run(logging: loggingOptions, quiet: quiet, shell: shell) { - let ensuredOptions = try await self.ensuredOptions(fileType) - - let projectDirectory = self.projectDirectory ?? environment["PWD"] - - guard let projectDirectory else { - throw ProjectDirectoryNotSpecified() - } - - if shouldBuildProject { - logger.debug("Building project...") - try await playbookClient.run.buildProject(.init( + if shouldBuildProject { + logger.debug("Building project...") + try await playbookClient.run.buildProject( + .init( projectDirectory: projectDirectory, shared: .init( extraOptions: nil, @@ -34,23 +80,28 @@ extension PandocClient.RunOptions { quiet: quiet, shell: shell ) - )) - } + ) + ) + } - let outputDirectory = self.outputDirectory ?? projectDirectory - let outputPath = "\(outputDirectory)/\(ensuredOptions.ensuredExtensionFileName)" - - let arguments = ensuredOptions.makeArguments( + // Build latex file pre-html, so that we can properly convert the latex document + // into an html document. + if ensuredOptions.outputFileType == .html { + logger.debug("Building latex, pre-html conversion...") + let outputPath = "\(ensuredOptions.buildDirectory)/\(EnsuredPandocOptions.latexFilename)" + let arguments = ensuredOptions.preHtmlLatexOptions.makeArguments( outputPath: outputPath, projectDirectory: projectDirectory ) - - logger.debug("Pandoc arguments: \(arguments)") - - return (arguments, outputPath) + try await runCommand(arguments, outputPath) } } + /// Generates the ensured/parsed options for a pandoc conversion. + /// + /// - Parameter fileType: The file type we're converting to. + /// + /// - Returns: The ensured options. func ensuredOptions( _ fileType: PandocClient.FileType ) async throws -> EnsuredPandocOptions { @@ -69,6 +120,7 @@ extension PandocClient.RunOptions { } extension PandocClient.FileType { + /// Represents the appropriate file extension for a file type. var fileExtension: String { switch self { case .html: return "html" @@ -78,8 +130,14 @@ extension PandocClient.FileType { } } +/// Represents pandoc options that get parsed based on the given run options, configuration, etc. +/// +/// This set's potentially optional values into real values that are required for pandoc to run +/// properly and convert files for the given file type conversion. @_spi(Internal) public struct EnsuredPandocOptions: Equatable, Sendable { + public static let latexFilename = "Report.tex" + public let buildDirectory: String public let extraOptions: [String]? public let files: [String] @@ -88,7 +146,9 @@ public struct EnsuredPandocOptions: Equatable, Sendable { public let outputFileType: PandocClient.FileType public let pdfEngine: String? - public var ensuredExtensionFileName: String { + /// Ensures the output filename includes the file extension, so that pandoc + /// can properly convert the files. + public var ensuredFilename: String { let extensionString = ".\(outputFileType.fileExtension)" if !outputFileName.hasSuffix(extensionString) { @@ -97,14 +157,33 @@ public struct EnsuredPandocOptions: Equatable, Sendable { return outputFileName } + /// Generates the options required for building the latex file that is needed + /// to convert the project to an html output file. + var preHtmlLatexOptions: Self { + .init( + buildDirectory: buildDirectory, + extraOptions: extraOptions, + files: files, + includeInHeader: includeInHeader, + outputFileName: Self.latexFilename, + outputFileType: .latex, + pdfEngine: nil + ) + } + + /// Generate arguments for the pandoc shell command based on the parsed options + /// for a given conversion. + /// func makeArguments( outputPath: String, projectDirectory: String ) -> [String] { var arguments = [PandocClient.Constants.pandocCommand] - arguments += includeInHeader.map { - "--include-in-header=\(projectDirectory)/\(buildDirectory)/\($0)" + if outputFileType != .html { + arguments += includeInHeader.map { + "--include-in-header=\(projectDirectory)/\(buildDirectory)/\($0)" + } } if let pdfEngine { @@ -117,8 +196,12 @@ public struct EnsuredPandocOptions: Equatable, Sendable { arguments.append(contentsOf: extraOptions) } - arguments += files.map { - "\(projectDirectory)/\(buildDirectory)/\($0)" + if outputFileType != .html { + arguments += files.map { + "\(projectDirectory)/\(buildDirectory)/\($0)" + } + } else { + arguments.append("\(projectDirectory)/\(buildDirectory)/\(Self.latexFilename)") } return arguments @@ -145,15 +228,15 @@ public func ensurePandocOptions( } @_spi(Internal) -public extension PandocClient.FileType { - func parsePdfEngine( +extension PandocClient.FileType { + public func parsePdfEngine( _ configuration: Configuration.Generate?, _ defaults: Configuration.Generate ) -> String? { switch self { case .html, .latex: return nil - case let .pdf(engine: engine): + case .pdf(let engine): if let engine { return engine } else if let engine = configuration?.pdfEngine { @@ -168,8 +251,8 @@ public extension PandocClient.FileType { } @_spi(Internal) -public extension PandocClient.RunOptions { - func parseFiles( +extension PandocClient.RunOptions { + public func parseFiles( _ configuration: Configuration.Generate?, _ defaults: Configuration.Generate ) -> [String] { @@ -187,7 +270,7 @@ public extension PandocClient.RunOptions { } } - func parseIncludeInHeader( + public func parseIncludeInHeader( _ configuration: Configuration.Generate?, _ defaults: Configuration.Generate ) -> [String] { @@ -205,7 +288,7 @@ public extension PandocClient.RunOptions { } } - func parseOutputFileName( + public func parseOutputFileName( _ configuration: Configuration.Generate?, _ defaults: Configuration.Generate ) -> String { @@ -223,7 +306,7 @@ public extension PandocClient.RunOptions { } } - func parseBuildDirectory( + public func parseBuildDirectory( _ configuration: Configuration.Generate?, _ defaults: Configuration.Generate ) -> String { diff --git a/Sources/PandocClient/PandocClient.swift b/Sources/PandocClient/PandocClient.swift index c79089d..b8be760 100644 --- a/Sources/PandocClient/PandocClient.swift +++ b/Sources/PandocClient/PandocClient.swift @@ -4,14 +4,14 @@ import Dependencies import DependenciesMacros import Foundation -public extension DependencyValues { +extension DependencyValues { /// Represents interactions with the `pandoc` command line application. /// /// The `pandoc` command line application is used to generate the final output /// documents from a home performance assessment project. /// - var pandocClient: PandocClient { + public var pandocClient: PandocClient { get { self[PandocClient.self] } set { self[PandocClient.self] = newValue } } diff --git a/Tests/PandocClientTests/PandocClientTests.swift b/Tests/PandocClientTests/PandocClientTests.swift index 9537c28..ef4549f 100644 --- a/Tests/PandocClientTests/PandocClientTests.swift +++ b/Tests/PandocClientTests/PandocClientTests.swift @@ -1,8 +1,8 @@ @_spi(Internal) import ConfigurationClient @_spi(Internal) import PandocClient import PlaybookClient -import Testing import TestSupport +import Testing @Suite("PandocClientTests") struct PandocClientTests: TestCase { @@ -13,12 +13,12 @@ struct PandocClientTests: TestCase { static let expectedIncludeInHeaders = [ "--include-in-header=/project/.build/head.tex", - "--include-in-header=/project/.build/footer.tex" + "--include-in-header=/project/.build/footer.tex", ] static let expectedFiles = [ "/project/.build/Report.md", - "/project/.build/Definitions.md" + "/project/.build/Definitions.md", ] static var sharedRunOptions: PandocClient.RunOptions { @@ -49,7 +49,8 @@ struct PandocClientTests: TestCase { #expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).tex") } assert: { output in - let expected = ["pandoc"] + let expected = + ["pandoc"] + Self.expectedIncludeInHeaders + ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).tex"] + Self.expectedFiles @@ -71,10 +72,11 @@ struct PandocClientTests: TestCase { #expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).html") } assert: { output in - let expected = ["pandoc"] - + Self.expectedIncludeInHeaders - + ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).html"] - + Self.expectedFiles + let expected = [ + "pandoc", + "--output=\(Self.outputDirectory)/\(Self.defaultFileName).html", + "\(Self.projectDirectory)/.build/Report.tex", + ] #expect(output.arguments == expected) } @@ -83,7 +85,7 @@ struct PandocClientTests: TestCase { @Test( arguments: [ nil, - "lualatex" + "lualatex", ] ) func generatePdf(pdfEngine: String?) async throws { @@ -94,11 +96,13 @@ struct PandocClientTests: TestCase { } run: { @Dependency(\.pandocClient) var pandocClient - let output = try await pandocClient.run.generatePdf(Self.sharedRunOptions, pdfEngine: pdfEngine) + let output = try await pandocClient.run.generatePdf( + Self.sharedRunOptions, pdfEngine: pdfEngine) #expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).pdf") } assert: { output in - let expected = ["pandoc"] + let expected = + ["pandoc"] + Self.expectedIncludeInHeaders + ["--pdf-engine=\(pdfEngine ?? "xelatex")"] + ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).pdf"] @@ -147,10 +151,18 @@ struct TestPdfEngine: Sendable { static let testCases: [Self] = [ .init(fileType: .html, expectedEngine: nil, configuration: .init(), defaults: .default), .init(fileType: .latex, expectedEngine: nil, configuration: .init(), defaults: .default), - .init(fileType: .pdf(engine: "lualatex"), expectedEngine: "lualatex", configuration: .init(), defaults: .default), - .init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), defaults: .default), - .init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), defaults: .init()), - .init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(generate: .default), defaults: .init()) + .init( + fileType: .pdf(engine: "lualatex"), expectedEngine: "lualatex", configuration: .init(), + defaults: .default), + .init( + fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), + defaults: .default), + .init( + fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), + defaults: .init()), + .init( + fileType: .pdf(engine: nil), expectedEngine: "xelatex", + configuration: .init(generate: .default), defaults: .init()), ] } @@ -174,19 +186,23 @@ struct TestParseFiles: Sendable { } var parsedFiles: [String] { - let runOptions = self.runOptions ?? PandocClient.RunOptions( - loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), - projectDirectory: nil, - quiet: true, - shouldBuild: false - ) + let runOptions = + self.runOptions + ?? PandocClient.RunOptions( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), + projectDirectory: nil, + quiet: true, + shouldBuild: false + ) return runOptions.parseFiles(configuration.generate, defaults) } static let testCases: [Self] = [ .init(expectedFiles: ["Report.md", "Definitions.md"]), - .init(expectedFiles: ["Report.md", "Definitions.md"], configuration: .init(generate: .default), defaults: .init()), + .init( + expectedFiles: ["Report.md", "Definitions.md"], configuration: .init(generate: .default), + defaults: .init()), .init(expectedFiles: [], defaults: .init()), .init( expectedFiles: ["custom.md"], @@ -199,7 +215,7 @@ struct TestParseFiles: Sendable { quiet: true, shouldBuild: false ) - ) + ), ] } @@ -223,16 +239,20 @@ struct TestParseIncludeInHeaderFiles: Sendable { } var parsedFiles: [String] { - let runOptions = self.runOptions ?? PandocClient.RunOptions( - loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) - ) + let runOptions = + self.runOptions + ?? PandocClient.RunOptions( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) + ) return runOptions.parseIncludeInHeader(configuration.generate, defaults) } static let testCases: [Self] = [ .init(expectedHeaderFiles: ["head.tex", "footer.tex"]), - .init(expectedHeaderFiles: ["head.tex", "footer.tex"], configuration: .init(generate: .default), defaults: .init()), + .init( + expectedHeaderFiles: ["head.tex", "footer.tex"], configuration: .init(generate: .default), + defaults: .init()), .init(expectedHeaderFiles: [], defaults: .init()), .init( expectedHeaderFiles: ["custom.tex"], @@ -242,7 +262,7 @@ struct TestParseIncludeInHeaderFiles: Sendable { loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), includeInHeader: ["custom.tex"] ) - ) + ), ] } @@ -266,9 +286,11 @@ struct TestParseOutputFileName: Sendable { } var parsedFileName: String { - let runOptions = self.runOptions ?? PandocClient.RunOptions( - loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) - ) + let runOptions = + self.runOptions + ?? PandocClient.RunOptions( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) + ) return runOptions.parseOutputFileName(configuration.generate, defaults) } @@ -285,7 +307,7 @@ struct TestParseOutputFileName: Sendable { loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), outputFileName: "custom" ) - ) + ), ] } @@ -309,9 +331,11 @@ struct TestParseBuildDirectory: Sendable { } var parsedBuildDirectory: String { - let runOptions = self.runOptions ?? PandocClient.RunOptions( - loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) - ) + let runOptions = + self.runOptions + ?? PandocClient.RunOptions( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) + ) return runOptions.parseBuildDirectory(configuration.generate, defaults) } @@ -328,6 +352,6 @@ struct TestParseBuildDirectory: Sendable { buildDirectory: "custom", loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) ) - ) + ), ] }