diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5efff76 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test-linux: + name: Test on Linux + runs-on: ubuntu-latest + strategy: + matrix: + swift-version: ["6.1", "6.2"] + container: + image: swift:${{ matrix.swift-version }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Cache Swift packages + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-${{ matrix.swift-version }}-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm-${{ matrix.swift-version }}- + ${{ runner.os }}-spm- + + - name: Build package + run: swift build --verbose + + - name: Run tests + run: swift test --verbose + + test-macos: + name: Test on macOS + runs-on: macos-latest + strategy: + matrix: + swift-version: ["6.1", "6.2"] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Swift ${{ matrix.swift-version }} + uses: swift-actions/setup-swift@v2 + with: + swift-version: ${{ matrix.swift-version }} + + - name: Cache Swift packages + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-${{ matrix.swift-version }}-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm-${{ matrix.swift-version }}- + ${{ runner.os }}-spm- + + - name: Build package + run: swift build --verbose + + - name: Run tests + run: swift test --verbose diff --git a/.github/workflows/release-tailwindcss-cli.yml b/.github/workflows/release-tailwindcss-cli.yml new file mode 100644 index 0000000..130cee9 --- /dev/null +++ b/.github/workflows/release-tailwindcss-cli.yml @@ -0,0 +1,104 @@ +name: Release TailwindCSS CLI Artifacts + +on: + workflow_dispatch: + inputs: + tailwind_version: + description: "TailwindCSS release version (e.g., v4.1.14)" + required: true + type: string + +jobs: + build-and-release: + name: Build and Release Artifacts + runs-on: macos-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Swift + uses: swift-actions/setup-swift@v2 + with: + swift-version: "6.1" + + - name: Cache Swift packages + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-release-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm-release- + ${{ runner.os }}-spm- + + - name: Validate version format + run: | + if [[ ! "${{ inputs.tailwind_version }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in format vX.Y.Z (e.g., v4.1.14)" + exit 1 + fi + echo "Version format is valid: ${{ inputs.tailwind_version }}" + + - name: Build artifact bundler + run: | + echo "Building TailwindCSSCLIArtifactBundler..." + swift build -c release --product TailwindCSSCLIArtifactBundler + + - name: Create artifact bundles + run: | + echo "Creating artifact bundles for TailwindCSS ${{ inputs.tailwind_version }}..." + mkdir -p ./artifacts + .build/release/TailwindCSSCLIArtifactBundler \ + --version "${{ inputs.tailwind_version }}" \ + --work-dir "${{ runner.temp }}/tailwindcss-bundles" \ + --output-dir ./artifacts + + - name: List generated artifacts + run: | + echo "Generated artifacts:" + ls -lh artifacts/ + + - name: Compute checksum + id: checksum + run: | + CHECKSUM=$(shasum -a 256 artifacts/tailwindcss.artifactbundleindex | awk '{print $1}') + echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT + echo "Artifact bundle index checksum: $CHECKSUM" + + - name: Create release + uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 + with: + tag_name: TailwindCSSCLI-${{ inputs.tailwind_version }} + name: TailwindCSS CLI ${{ inputs.tailwind_version }} + body: | + ## TailwindCSS CLI Artifact Bundles + + This release contains Swift Package Manager artifact bundles for TailwindCSS CLI version `${{ inputs.tailwind_version }}`. + + ### Included Artifacts + - Linux x64 (`x86_64-unknown-linux-gnu`) + - macOS x64 (`x86_64-apple-darwin`) + - macOS ARM64 (`aarch64-apple-darwin`) + + ### Usage + Add the artifact bundle to your `Package.swift`: + ```swift + .binaryTarget( + name: "TailwindCSSCLI", + url: "https://github.com/${{ github.repository }}/releases/download/TailwindCSSCLI@${{ inputs.tailwind_version }}/tailwindcss.artifactbundleindex", + checksum: "${{ steps.checksum.outputs.checksum }}" + ) + ``` + + ### Checksum + ``` + ${{ steps.checksum.outputs.checksum }} + ``` + draft: false + prerelease: false + make_latest: false + files: | + artifacts/*.zip + artifacts/tailwindcss.artifactbundleindex diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..7c708be --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,11 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "languages": { + "CSS": { + "language_servers": ["!vscode-css-language-server"] + } + } +} diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..e8c11be --- /dev/null +++ b/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "8e39da950f9cbe4c8126b1fabf0d3d6945ab547f7564fd7bb0921701f3269c14", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3", + "version" : "1.6.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index b15c952..ea0e1a8 100644 --- a/Package.swift +++ b/Package.swift @@ -6,19 +6,31 @@ let package = Package( name: "SwiftTailwind", platforms: [.macOS(.v12)], products: [ - .plugin(name: "TailwindCSS", targets: ["TailwindCSS"]) + .plugin(name: "TailwindCSS", targets: ["TailwindCSS"]), + .executable(name: "TailwindCSSCLIArtifactBundler", targets: ["TailwindCSSCLIArtifactBundler"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), ], targets: [ + .executableTarget( + name: "TailwindCSSCLIArtifactBundler", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Crypto", package: "swift-crypto"), + ] + ), .plugin(name: "TailwindCSS", capability: .buildTool(), dependencies: ["TailwindCSSCLI"]), .binaryTarget( name: "TailwindCSSCLI", url: - "https://github.com/laosb/SwiftTailwind/releases/download/1.0.0+tw.4.1.12/tailwindcss.artifactbundle.zip", - checksum: "bfa96ef1d4d1b665bb40c89ec906044c9532b3cabf866fbe2bd3e5a95bf40bea" + "https://github.com/laosb/SwiftTailwind/releases/download/1.1.0-test.4+tw.4.1.14/tailwindcss.artifactbundleindex", + checksum: "ec4df49e361db5fc3159c431e5661af2e1b22d8575c64ce0482a8e84cfd93d04" ), .target( name: "SwiftTailwindExample", - resources: [.copy("Folder")], + resources: [.copy("Views/Test.html")], plugins: ["TailwindCSS"] ), .testTarget(name: "SwiftTailwindTests", dependencies: ["SwiftTailwindExample"]), diff --git a/Plugins/TailwindCSS/TailwindCSSBuildPlugin.swift b/Plugins/TailwindCSS/TailwindCSSBuildPlugin.swift index 03ff51b..fb3e4c7 100644 --- a/Plugins/TailwindCSS/TailwindCSSBuildPlugin.swift +++ b/Plugins/TailwindCSS/TailwindCSSBuildPlugin.swift @@ -48,8 +48,12 @@ struct TailwindCSSBuildPlugin: BuildToolPlugin { context: PluginContext, target: Target ) throws -> [Command] { + guard let sourceFileURLs = target.sourceModule?.sourceFiles.map({ $0.url }) else { + throw BuildError.notASourceModule + } + let tailwindCSSURL: URL = target.directoryURL.appending(component: "Tailwind.css") - guard let cssContent = try? String(contentsOf: tailwindCSSURL) else { + guard let cssContent = try? String(contentsOf: tailwindCSSURL, encoding: .utf8) else { throw BuildError.missingTailwindCSSFile } let matches = cssContent.matches(of: importStatementRegex) @@ -60,18 +64,25 @@ struct TailwindCSSBuildPlugin: BuildToolPlugin { throw BuildError.sourceNotDeclarationUnsupported } - let sourcePaths = + let sourcePatterns = cssContent .matches(of: sourceDeclarationRegex) .compactMap { String($0.output.1) } - let sourceURLs: [URL] = sourcePaths.map { path in - // Simplified handling: If ** is used, we just include everything in the directory. + let sourcePatternURLs: [URL] = sourcePatterns.map { path in + // Simplified handling: If `**` is used, we just include everything in the directory. + // It's unlikely we will have the same glob processing logic as Tailwind CSS CLI, + // so we may as well just expand the coverage. + // This only affects SwiftPM change detection: Tailwind CSS CLI will handle the globbing correctly. let globlessPath = path.replacing(/\*\*.*/, with: "") return target.directoryURL .appending(component: globlessPath, directoryHint: .inferFromPath) .resolvingSymlinksInPath() } + let includedSourceURLs = sourceFileURLs.filter { file in + sourcePatternURLs.contains { file.isOrIsDescendant(of: $0) } + } + let tailwindCLIURL: URL = try context.tool(named: "tailwindcss").url let outputBundleURL = context.pluginWorkDirectoryURL .appending(component: outputBundleName, directoryHint: .isDirectory) @@ -79,9 +90,10 @@ struct TailwindCSSBuildPlugin: BuildToolPlugin { component: outputCSSFilename, directoryHint: .notDirectory) print("Tailwind CSS Build Plugin") - print("Tailwind CSS File: \(tailwindCSSURL.path)") - print("@source declarations: \(sourcePaths)") - print("Source files: \(sourceURLs.map(\.path))") + print("Tailwind.css: \(tailwindCSSURL.path)") + print("@source declarations: \(sourcePatterns)") + print("All source files: \(sourceFileURLs.map(\.path))") + print("Input files: \(includedSourceURLs.map(\.path))") print("Output: \(outputURL.path)") return [ @@ -93,7 +105,7 @@ struct TailwindCSSBuildPlugin: BuildToolPlugin { "--output", outputURL.path, "--minify", ], - inputFiles: [tailwindCSSURL] + sourceURLs, + inputFiles: [tailwindCSSURL] + includedSourceURLs, outputFiles: [outputBundleURL] ) ] @@ -102,12 +114,15 @@ struct TailwindCSSBuildPlugin: BuildToolPlugin { extension TailwindCSSBuildPlugin { enum BuildError: Error { + case notASourceModule case missingTailwindCSSFile case missingImportStatement case sourceNotDeclarationUnsupported var localizedDescription: String { switch self { + case .notASourceModule: + "The target is not a source module." case .missingTailwindCSSFile: "Tailwind.css file not found in the target directory." case .missingImportStatement: diff --git a/Plugins/TailwindCSS/Utils.swift b/Plugins/TailwindCSS/Utils.swift new file mode 100644 index 0000000..f18bcdc --- /dev/null +++ b/Plugins/TailwindCSS/Utils.swift @@ -0,0 +1,24 @@ +import Foundation + +extension URL { + func isOrIsDescendant(of ancestor: URL) -> Bool { + guard ancestor.isFileURL, self.isFileURL else { + return false + } + + let ancestorComponents = ancestor.pathComponents + let selfComponents = self.pathComponents + + guard selfComponents.count >= ancestorComponents.count else { + return false + } + + for (index, component) in ancestorComponents.enumerated() { + if selfComponents[index] != component { + return false + } + } + + return true + } +} diff --git a/Scripts/buildArtifactBundle.sh b/Scripts/buildArtifactBundle.sh deleted file mode 100755 index 3893187..0000000 --- a/Scripts/buildArtifactBundle.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -# Check if TAILWINDCSS_VERSION environment variable is set -if [[ -z "${TAILWINDCSS_VERSION:-}" ]]; then - echo "Error: TAILWINDCSS_VERSION environment variable is not set" - echo "Usage: TAILWINDCSS_VERSION=v3.4.0 $0" - exit 1 -fi - -VERSION="$TAILWINDCSS_VERSION" -echo "Building artifact bundle for TailwindCSS version: $VERSION" - -# Get the directory containing this script to find the template -TEMPLATE_FILE="$PWD/Scripts/info.template.json" - -# Check if template file exists -if [[ ! -f "$TEMPLATE_FILE" ]]; then - echo "Error: Template file not found at $TEMPLATE_FILE" - exit 1 -fi - -# Create working directory -WORK_DIR="/tmp/tailwindcss.artifactbundle" -echo "Creating working directory: $WORK_DIR" -rm -rf "$WORK_DIR" -mkdir -p "$WORK_DIR" -cd "$WORK_DIR" - -# GitHub release base URL -BASE_URL="https://github.com/tailwindlabs/tailwindcss/releases/download/$VERSION" - -# Download and place binaries -download_binary() { - local binary_name="$1" - local target_path="$2" - local target_dir - target_dir=$(dirname "$target_path") - - echo "Downloading $binary_name..." - mkdir -p "$target_dir" - - if curl -L "$BASE_URL/$binary_name" > "$target_path"; then - chmod +x "$target_path" - echo "✓ Downloaded and made executable: $target_path" - else - echo "✗ Failed to download $binary_name from $BASE_URL/$binary_name" - exit 1 - fi -} - -# Download each binary to its target location -download_binary "tailwindcss-linux-x64" "tailwindcss-$VERSION-linux-x64/bin/tailwindcss" -download_binary "tailwindcss-macos-x64" "tailwindcss-$VERSION-macos-x64/bin/tailwindcss" -download_binary "tailwindcss-macos-arm64" "tailwindcss-$VERSION-macos-arm64/bin/tailwindcss" - -# Create info.json from template, replacing %VERSION% with actual version -echo "Creating info.json from template..." -sed "s/%VERSION%/$VERSION/g" "$TEMPLATE_FILE" > "info.json" -echo "✓ Created info.json with version $VERSION" - -# Create ZIP file -ZIP_FILE="/tmp/tailwindcss.artifactbundle.zip" -echo "Creating ZIP file: $ZIP_FILE" - -# Remove existing ZIP file if it exists -rm -f "$ZIP_FILE" - -# Create ZIP with the artifact bundle as the only child in root -cd /tmp -zip -r "tailwindcss.artifactbundle.zip" "tailwindcss.artifactbundle" - -echo "✓ Created ZIP file: $ZIP_FILE" - -# Compute checksum using Swift Package Manager -echo "Computing checksum..." -CHECKSUM=$(swift package compute-checksum "$ZIP_FILE") - -echo "" -echo "=== BUILD COMPLETE ===" -echo "ZIP file path: $ZIP_FILE" -echo "Checksum: $CHECKSUM" diff --git a/Scripts/info.template.json b/Scripts/info.template.json deleted file mode 100644 index ecdafef..0000000 --- a/Scripts/info.template.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "schemaVersion": "1.0", - "artifacts": { - "tailwindcss": { - "version": "%VERSION%", - "type": "executable", - "variants": [ - { - "path": "tailwindcss-%VERSION%-macos-x64/bin/tailwindcss", - "supportedTriples": ["x86_64-apple-macosx"] - }, - { - "path": "tailwindcss-%VERSION%-macos-arm64/bin/tailwindcss", - "supportedTriples": ["arm64-apple-macosx"] - }, - { - "path": "tailwindcss-%VERSION%-linux-x64/bin/tailwindcss", - "supportedTriples": ["x86_64-unknown-linux-gnu"] - } - ] - } - } -} diff --git a/Sources/SwiftTailwindExample/NotIncluded.swift b/Sources/SwiftTailwindExample/NotIncluded.swift new file mode 100644 index 0000000..3cb7406 --- /dev/null +++ b/Sources/SwiftTailwindExample/NotIncluded.swift @@ -0,0 +1,5 @@ +// This file is not included in Tailwind.css, so TW classes here will not be generated. +let html = + """ + Swift + """ diff --git a/Sources/SwiftTailwindExample/Tailwind.css b/Sources/SwiftTailwindExample/Tailwind.css index 8fd853b..6b5a2bc 100644 --- a/Sources/SwiftTailwindExample/Tailwind.css +++ b/Sources/SwiftTailwindExample/Tailwind.css @@ -4,5 +4,5 @@ /** 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"; -@source "./Folder/**/*.swift"; +@source "./Views"; +@source "./Views/**/*.swift"; diff --git a/Sources/SwiftTailwindExample/Folder/Template.swift b/Sources/SwiftTailwindExample/Views/Template.swift similarity index 100% rename from Sources/SwiftTailwindExample/Folder/Template.swift rename to Sources/SwiftTailwindExample/Views/Template.swift diff --git a/Sources/SwiftTailwindExample/Folder/Test.html b/Sources/SwiftTailwindExample/Views/Test.html similarity index 100% rename from Sources/SwiftTailwindExample/Folder/Test.html rename to Sources/SwiftTailwindExample/Views/Test.html diff --git a/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder+File Operations.swift b/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder+File Operations.swift new file mode 100644 index 0000000..19f43fd --- /dev/null +++ b/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder+File Operations.swift @@ -0,0 +1,41 @@ +import Foundation + +extension ArtifactBundleBuilder { + func downloadFile(from urlString: String, to destination: String) throws { + guard let url = URL(string: urlString) else { + throw ArtifactBundleError.invalidURL(urlString) + } + + let data = try Data(contentsOf: url) + try data.write(to: URL(fileURLWithPath: destination)) + } + + func makeExecutable(path: String) throws { + let attributes = [FileAttributeKey.posixPermissions: 0o755] + try fileManager.setAttributes(attributes, ofItemAtPath: path) + } + + func createZipFile(bundleDir: String, zipPath: String) throws { + // Remove existing ZIP file if it exists + if fileManager.fileExists(atPath: zipPath) { + try fileManager.removeItem(atPath: zipPath) + } + + let bundleDirURL = URL(fileURLWithPath: bundleDir) + let workDirURL = bundleDirURL.deletingLastPathComponent() + let bundleName = bundleDirURL.lastPathComponent + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/zip") + process.arguments = ["-r", zipPath, bundleName] + process.currentDirectoryURL = workDirURL + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw ArtifactBundleError.zipCreationFailed + } + } + +} diff --git a/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder+computeChecksum.swift b/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder+computeChecksum.swift new file mode 100644 index 0000000..7a5f906 --- /dev/null +++ b/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder+computeChecksum.swift @@ -0,0 +1,46 @@ +import Crypto +import Foundation + +extension ArtifactBundleBuilder { + /// Computes the SHA256 checksum of a file. + /// + /// If `usingSHA256Directly` is true, it uses Swift Crypto's SHA256 implementation. + /// This is to workaround https://github.com/swiftlang/swift-package-manager/issues/9219. + func computeChecksum( + filePath: String, + usingSHA256Directly: Bool = false + ) throws -> String { + if usingSHA256Directly { + // Use swift-crypto's SHA256 implementation + let fileURL = URL(fileURLWithPath: filePath) + let data = try Data(contentsOf: fileURL) + let hash = SHA256.hash(data: data) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } else { + // Use swift package compute-checksum command + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/swift") + process.arguments = ["package", "compute-checksum", filePath] + + let pipe = Pipe() + process.standardOutput = pipe + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw ArtifactBundleError.checksumComputationFailed + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8)?.trimmingCharacters( + in: .whitespacesAndNewlines) + + guard let checksum = output, !checksum.isEmpty else { + throw ArtifactBundleError.checksumComputationFailed + } + + return checksum + } + } +} diff --git a/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder+expandingTriple.swift b/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder+expandingTriple.swift new file mode 100644 index 0000000..77146b1 --- /dev/null +++ b/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder+expandingTriple.swift @@ -0,0 +1,29 @@ +extension ArtifactBundleBuilder { + /// Expands a list of triples into a stricter list of triples. + /// + /// To workaround https://github.com/swiftlang/swift-package-manager/issues/7362. + func expandingTriple(_ triple: String) -> [String] { + switch triple { + case "aarch64-apple-darwin": + [ + "aarch64-apple-darwin", + "arm64-apple-macosx12.0", + "arm64-apple-macosx13.0", + "arm64-apple-macosx14.0", + "arm64-apple-macosx15.0", + "arm64-apple-macosx26.0", + ] + case "x86_64-apple-darwin": + [ + "x86_64-apple-darwin", + "x86_64-apple-macosx12.0", + "x86_64-apple-macosx13.0", + "x86_64-apple-macosx14.0", + "x86_64-apple-macosx15.0", + "x86_64-apple-macosx26.0", + ] + // TODO: Does linux need more detailed triple variants? + default: [triple] + } + } +} diff --git a/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder.swift b/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder.swift new file mode 100644 index 0000000..eef4027 --- /dev/null +++ b/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder.swift @@ -0,0 +1,158 @@ +import Foundation + +class ArtifactBundleBuilder { + private let version: String + private let workDir: String + private let outputDir: String + let fileManager = FileManager.default + + private let binaryConfigurations: [BinaryConfiguration] = [ + BinaryConfiguration(binaryName: "tailwindcss-linux-x64", triple: "x86_64-unknown-linux-gnu"), + BinaryConfiguration(binaryName: "tailwindcss-macos-x64", triple: "x86_64-apple-darwin"), + BinaryConfiguration(binaryName: "tailwindcss-macos-arm64", triple: "aarch64-apple-darwin"), + ] + + init(version: String, workDir: String, outputDir: String) { + self.version = version + self.workDir = workDir + self.outputDir = outputDir + } + + func buildArtifactBundles() throws { + try setupWorkingDirectory() + + var bundleInfos: [BundleInfo] = [] + + print("Creating individual bundles...") + for config in binaryConfigurations { + let bundleInfo = try createBundle(for: config) + bundleInfos.append(bundleInfo) + } + + try generateArtifactBundleIndex(bundleInfos: bundleInfos) + + print("=== BUILD COMPLETE ===") + print("All bundles created successfully:") + print("") + print("Generated artifact bundle index: \(outputDir)/tailwindcss.artifactbundleindex") + print("") + + let indexChecksum = try computeChecksum( + filePath: "\(outputDir)/tailwindcss.artifactbundleindex", usingSHA256Directly: true) + print("Index checksum: \(indexChecksum)") + print("") + + for bundleInfo in bundleInfos { + print("Bundle: \(bundleInfo.fileName)") + print(" Checksum: \(bundleInfo.checksum)") + print(" Triple: \(bundleInfo.triple)") + print("") + } + } + + private func setupWorkingDirectory() throws { + print("Creating working directory: \(workDir)") + + if fileManager.fileExists(atPath: workDir) { + try fileManager.removeItem(atPath: workDir) + } + + try fileManager.createDirectory(atPath: workDir, withIntermediateDirectories: true) + } + + private func createBundle(for config: BinaryConfiguration) throws -> BundleInfo { + let bundleDirName = "tailwindcss-\(version)-\(config.triple).artifactbundle" + let bundleDir = "\(workDir)/\(bundleDirName)" + let binaryPath = "bin/tailwindcss" + + print("Creating bundle for \(config.triple)...") + + // Create bundle directory structure + let binDir = "\(bundleDir)/bin" + try fileManager.createDirectory(atPath: binDir, withIntermediateDirectories: true) + + // Download binary + print(" Downloading \(config.binaryName)...") + let binaryURL = + "https://github.com/tailwindlabs/tailwindcss/releases/download/\(version)/\(config.binaryName)" + let binaryDestination = "\(bundleDir)/\(binaryPath)" + + try downloadFile(from: binaryURL, to: binaryDestination) + try makeExecutable(path: binaryDestination) + print(" ✓ Downloaded and made executable: \(binaryDestination)") + + // Create info.json + print(" Creating info.json...") + try createInfoJSON(bundleDir: bundleDir, binaryPath: binaryPath, triple: config.triple) + print( + " ✓ Created info.json with version \(version), path \(binaryPath), triple \(config.triple)") + + // Create ZIP file + let zipFileName = "\(bundleDirName).zip" + let zipPath = "\(outputDir)/\(zipFileName)" + print(" Creating ZIP file: \(zipPath)") + + try createZipFile(bundleDir: bundleDir, zipPath: zipPath) + + // Compute checksum + print(" Computing checksum...") + let checksum = try computeChecksum(filePath: zipPath) + + print(" ✓ Bundle created: \(zipPath)") + print(" ✓ Checksum: \(checksum)") + print("") + + return BundleInfo(fileName: zipFileName, checksum: checksum, triple: config.triple) + } + + private func createInfoJSON(bundleDir: String, binaryPath: String, triple: String) throws { + let artifact = Artifact( + version: version, + type: "executable", + variants: [ + ArtifactVariant(path: binaryPath, supportedTriples: expandingTriple(triple)) + ] + ) + + let info = ArtifactBundleInfo( + schemaVersion: "1.0", + artifacts: ["tailwindcss": artifact] + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + let jsonData = try encoder.encode(info) + let infoPath = "\(bundleDir)/info.json" + try jsonData.write(to: URL(fileURLWithPath: infoPath)) + } + + private func generateArtifactBundleIndex(bundleInfos: [BundleInfo]) throws { + print("Generating tailwindcss.artifactbundleindex...") + + // Create output directory if it doesn't exist + if !fileManager.fileExists(atPath: outputDir) { + try fileManager.createDirectory(atPath: outputDir, withIntermediateDirectories: true) + } + + let bundles = bundleInfos.map { bundleInfo in + Bundle( + fileName: bundleInfo.fileName, + checksum: bundleInfo.checksum, + supportedTriples: expandingTriple(bundleInfo.triple) + ) + } + + let index = ArtifactBundleIndex( + schemaVersion: "1.0", + archives: bundles + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + let jsonData = try encoder.encode(index) + let indexPath = "\(outputDir)/tailwindcss.artifactbundleindex" + try jsonData.write(to: URL(fileURLWithPath: indexPath)) + } +} diff --git a/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleError.swift b/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleError.swift new file mode 100644 index 0000000..4bb59e2 --- /dev/null +++ b/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleError.swift @@ -0,0 +1,18 @@ +import Foundation + +enum ArtifactBundleError: Error, LocalizedError { + case invalidURL(String) + case zipCreationFailed + case checksumComputationFailed + + var errorDescription: String? { + switch self { + case .invalidURL(let url): + return "Invalid URL: \(url)" + case .zipCreationFailed: + return "Failed to create ZIP file" + case .checksumComputationFailed: + return "Failed to compute checksum" + } + } +} diff --git a/Sources/TailwindCSSCLIArtifactBundler/Models.swift b/Sources/TailwindCSSCLIArtifactBundler/Models.swift new file mode 100644 index 0000000..79214ad --- /dev/null +++ b/Sources/TailwindCSSCLIArtifactBundler/Models.swift @@ -0,0 +1,53 @@ +import Foundation + +// MARK: - Artifact Bundle Info Models + +/// Represents the info.json file structure for an artifact bundle +struct ArtifactBundleInfo: Codable { + let schemaVersion: String + let artifacts: [String: Artifact] + + enum CodingKeys: String, CodingKey { + case schemaVersion, artifacts + } +} + +struct Artifact: Codable { + let version: String + let type: String + let variants: [ArtifactVariant] +} + +struct ArtifactVariant: Codable { + let path: String + let supportedTriples: [String] +} + +// MARK: - Artifact Bundle Index Models + +/// Represents the .artifactbundleindex file structure +struct ArtifactBundleIndex: Codable { + let schemaVersion: String + let archives: [Bundle] // The proposal says it's "bundles" but the actual implementation uses "archives" +} + +struct Bundle: Codable { + let fileName: String + let checksum: String + let supportedTriples: [String] +} + +// MARK: - Internal Data Models + +/// Configuration for a binary platform +struct BinaryConfiguration { + let binaryName: String + let triple: String +} + +/// Information about a created bundle +struct BundleInfo { + let fileName: String + let checksum: String + let triple: String +} diff --git a/Sources/TailwindCSSCLIArtifactBundler/TailwindCSSCLIArtifactBundler.swift b/Sources/TailwindCSSCLIArtifactBundler/TailwindCSSCLIArtifactBundler.swift new file mode 100644 index 0000000..ca14ef0 --- /dev/null +++ b/Sources/TailwindCSSCLIArtifactBundler/TailwindCSSCLIArtifactBundler.swift @@ -0,0 +1,32 @@ +import ArgumentParser +import Foundation + +@main +struct TailwindCSSCLIArtifactBundler: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "TailwindCSSCLIArtifactBundler", + abstract: "Build TailwindCSS CLI artifact bundles for Swift Package Manager", + version: "1.0.0" + ) + + @Option(name: .shortAndLong, help: "TailwindCSS version to build (e.g., v4.1.14)") + var version: String + + @Option(name: .shortAndLong, help: "Working directory for temporary files") + var workDir: String = "/tmp/tailwindcss-bundles" + + @Option(name: .shortAndLong, help: "Output directory for the artifact bundle index") + var outputDir: String = "." + + func run() throws { + print("Building artifact bundles for TailwindCSS version: \(version)") + + let bundler = ArtifactBundleBuilder( + version: version, + workDir: workDir, + outputDir: outputDir + ) + + try bundler.buildArtifactBundles() + } +} diff --git a/Tests/SwiftTailwindTests/Tests.swift b/Tests/SwiftTailwindTests/Tests.swift index 008a9cf..8a69f4e 100644 --- a/Tests/SwiftTailwindTests/Tests.swift +++ b/Tests/SwiftTailwindTests/Tests.swift @@ -21,6 +21,10 @@ struct SwiftTailwindExampleTests { generatedCSS?.contains("bg-red-100") == true, "Arbitary value class used in Folder/Template.swift is generated." ) + #expect( + generatedCSS?.contains("text-sm") == false, + "Class used in other non-included Swift code is not generated." + ) #expect( generatedCSS?.contains("bg-blue-500") == false, "Class not used is not generated."