First working version.

This commit is contained in:
Shibo Lyu 2025-08-17 17:06:15 +08:00
parent 2f70b79493
commit 8fbeb20900
6 changed files with 227 additions and 2 deletions

View file

@ -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"]),
]
)

View file

@ -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 \"<path>\";`."
}
}
}
}

View file

@ -0,0 +1,57 @@
import Foundation
public struct Example {
public var htmlInSwiftCode =
"""
<html>
<head>
<title>Example</title>
</head>
<body>
<h1 class="text-2xl">Hello, World!</h1>
<p>This is an example of HTML in Swift code.</p>
</body>
</html>
"""
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.")
}
}
}

View file

@ -0,0 +1,7 @@
<html>
<!-- Generated CSS will be avilable in tw.css file in a TailwindCSS.bundle that can be accessed under `Bundle.module`. -->
<link rel="stylesheet" href="/TailwindCSS.bundle/tw.css" />
<body>
<p class="text-[#f05138] font-bold">Hello, Swift!</p>
</body>
</html>

View file

@ -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";

View file

@ -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."
)
}
}