diff --git a/Package.swift b/Package.swift index 9b78795..eb249dd 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.6 +// swift-tools-version: 6.1 import PackageDescription @@ -9,6 +9,18 @@ let package = Package( .plugin(name: "TailwindCSS", targets: ["TailwindCSS"]) ], targets: [ - .plugin(name: "TailwindCSS", capability: .buildTool()) + .plugin(name: "TailwindCSS", capability: .buildTool(), dependencies: ["TailwindCSSCLI"]), + .binaryTarget( + name: "TailwindCSSCLI", + url: + "https://github.com/laosb/SwiftTailwind/releases/download/4.1.12-test-manual.1/tailwindcss.artifactbundle.zip", + checksum: "bfa96ef1d4d1b665bb40c89ec906044c9532b3cabf866fbe2bd3e5a95bf40bea" + ), + .target( + name: "SwiftTailwindExample", + resources: [.copy("Folder")], + plugins: ["TailwindCSS"] + ), + .testTarget(name: "SwiftTailwindTests", dependencies: ["SwiftTailwindExample"]), ] ) diff --git a/Plugins/TailwindCSS/TailwindCSSBuildPlugin.swift b/Plugins/TailwindCSS/TailwindCSSBuildPlugin.swift new file mode 100644 index 0000000..a1263cf --- /dev/null +++ b/Plugins/TailwindCSS/TailwindCSSBuildPlugin.swift @@ -0,0 +1,117 @@ +import Foundation +import PackagePlugin +import RegexBuilder + +@main +struct TailwindCSSBuildPlugin: BuildToolPlugin { + let inputCSSFilename = "Tailwind.css" + let outputBundleName = "TailwindCSS.bundle" + let outputCSSFilename = "tw.css" + + let importStatementRegex = Regex { + Anchor.startOfLine + ZeroOrMore(.whitespace) + "@import" + ZeroOrMore(.whitespace) + "\"tailwindcss\"" + ZeroOrMore(.whitespace) + "source(" + ZeroOrMore(.whitespace) + "none" + ZeroOrMore(.whitespace) + ")" + ZeroOrMore(.whitespace) + ";" + }.anchorsMatchLineEndings() + + let sourceDeclarationRegex = Regex { + Anchor.startOfLine + ZeroOrMore(.whitespace) + "@source" + ZeroOrMore(.whitespace) + "\"" + Capture(ZeroOrMore(.word)) + "\"" + ZeroOrMore(.whitespace) + ";" + } + + let sourceNotDeclarationRegex = Regex { + Anchor.startOfLine + ZeroOrMore(.whitespace) + "@source" + ZeroOrMore(.whitespace) + "not" + }.anchorsMatchLineEndings() + + func createBuildCommands( + context: PluginContext, + target: Target + ) throws -> [Command] { + let tailwindCSSURL: URL = target.directoryURL.appending(component: "Tailwind.css") + guard let cssContent = try? String(contentsOf: tailwindCSSURL) else { + throw BuildError.missingTailwindCSSFile + } + let matches = cssContent.matches(of: importStatementRegex) + guard !matches.isEmpty else { + throw BuildError.missingImportStatement + } + if (try? sourceNotDeclarationRegex.firstMatch(in: cssContent)) != nil { + throw BuildError.sourceNotDeclarationUnsupported + } + + let sourcePaths = + cssContent + .matches(of: sourceDeclarationRegex) + .compactMap { String($0.output.1) } + let sourceURLs: [URL] = sourcePaths.map { path in + target.directoryURL + .appending(component: path, directoryHint: .inferFromPath) + .resolvingSymlinksInPath() + } + + let tailwindCLIURL: URL = try context.tool(named: "tailwindcss").url + let outputBundleURL = context.pluginWorkDirectoryURL + .appending(component: outputBundleName, directoryHint: .isDirectory) + let outputURL = outputBundleURL.appending( + component: outputCSSFilename, directoryHint: .notDirectory) + + print("Tailwind CSS Build Plugin: \(tailwindCSSURL.path)") + print("Tailwind CSS File: \(tailwindCSSURL.path)") + print("Source files: \n -\(sourceURLs.map(\.path).joined(separator: "\n -"))") + print("Output: \(outputURL.path)") + + return [ + .buildCommand( + displayName: "Building Tailwind CSS", + executable: tailwindCLIURL, + arguments: [ + "--input", tailwindCSSURL.path, + "--output", outputURL.path, + "--minify", + ], + inputFiles: [tailwindCSSURL] + sourceURLs, + outputFiles: [outputBundleURL] + ) + ] + } +} + +extension TailwindCSSBuildPlugin { + enum BuildError: Error { + case missingTailwindCSSFile + case missingImportStatement + case sourceNotDeclarationUnsupported + + var localizedDescription: String { + switch self { + case .missingTailwindCSSFile: + "Tailwind.css file not found in the target directory." + case .missingImportStatement: + "No `@import \"tailwind\"` statement found, or `source(none)` is missing." + case .sourceNotDeclarationUnsupported: + "`@source not` declarations are not supported. Please explicitly declare sources with `@source \"\";`." + } + } + } +} diff --git a/Sources/SwiftTailwindExample/Example.swift b/Sources/SwiftTailwindExample/Example.swift new file mode 100644 index 0000000..6181381 --- /dev/null +++ b/Sources/SwiftTailwindExample/Example.swift @@ -0,0 +1,57 @@ +import Foundation + +public struct Example { + public var htmlInSwiftCode = + """ + + + Example + + +

Hello, World!

+

This is an example of HTML in Swift code.

+ + + """ + + public func getTestHTML() throws -> String? { + guard let fileURL = Bundle.module.path(forResource: "Test", ofType: "html") else { + return nil + } + + return try String(contentsOfFile: fileURL, encoding: .utf8) + } + + public func getGeneratedCSS() throws -> String? { + guard + let tailwindCSSBundleURL = Bundle.module.url( + forResource: "TailwindCSS", withExtension: "bundle") + else { + return nil + } + + let cssFileURL = tailwindCSSBundleURL.appendingPathComponent("tw.css") + return try String(contentsOf: cssFileURL, encoding: .utf8) + } + + public func printAll() { + print("HTML in Swift Code:") + print(htmlInSwiftCode) + + print() + print("Test.html:") + if let testHTML = try! getTestHTML() { + print(testHTML) + } else { + print("Test.html not found.") + } + + print() + print("Output Tailwind CSS (minified):") + if let generatedCSS = try! getGeneratedCSS() { + print(generatedCSS) + } else { + print("Tailwind CSS not found.") + } + } +} diff --git a/Sources/SwiftTailwindExample/Folder/Test.html b/Sources/SwiftTailwindExample/Folder/Test.html new file mode 100644 index 0000000..ffdba09 --- /dev/null +++ b/Sources/SwiftTailwindExample/Folder/Test.html @@ -0,0 +1,7 @@ + + + + +

Hello, Swift!

+ + diff --git a/Sources/SwiftTailwindExample/Tailwind.css b/Sources/SwiftTailwindExample/Tailwind.css new file mode 100644 index 0000000..8655d00 --- /dev/null +++ b/Sources/SwiftTailwindExample/Tailwind.css @@ -0,0 +1,7 @@ +/** SwiftTailwind assumes a Tailwind.css file is present in the root of target directory. **/ + +/** To align with Swift Package Manager's build plugin capability, SwiftTailwind requires explicitly register all inputs. */ +/** You must specify `source(none)` after `tailwindcss` import and add `@source` for each source file / folder you want to include. */ +@import "tailwindcss" source(none); +@source "./Example.swift"; +@source "./Folder"; diff --git a/Tests/SwiftTailwindTests/Tests.swift b/Tests/SwiftTailwindTests/Tests.swift new file mode 100644 index 0000000..48f2835 --- /dev/null +++ b/Tests/SwiftTailwindTests/Tests.swift @@ -0,0 +1,25 @@ +import Testing + +@testable import SwiftTailwindExample + +@Suite("SwiftTailwindExample") +struct SwiftTailwindExampleTests { + @Test + func example() throws { + let example = Example() + let generatedCSS = try example.getGeneratedCSS() + #expect(generatedCSS != nil) + #expect( + generatedCSS?.contains("text-2xl") == true, + "Class used in Swift code is generated." + ) + #expect( + generatedCSS?.contains("text-\\[\\#f05138\\]") == true, + "Arbitary value class used in Test.html is generated." + ) + #expect( + generatedCSS?.contains("bg-blue-500") == false, + "Class not used is not generated." + ) + } +}