├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── Directory.Build.props ├── LICENSE ├── README.md ├── Tailwindcss.DotnetTool.sln ├── build.cmd ├── build.sh ├── build ├── build.cs └── build.csproj └── src └── tailwindcss-dotnet ├── AppInvocationContext.cs ├── Cli ├── ExecutableNotFoundException.cs ├── TailwindCli.cs ├── TailwindCliDownloader.cs ├── UnsupportedPlatformException.cs └── Upstream.cs ├── CommandLineBuilderExtensions.cs ├── Commands ├── AppCommands.cs ├── BuildCommand.cs ├── ExecCommand.cs ├── ICommand.cs ├── InstallCommand.cs └── WatchCommand.cs ├── DotnetTool.cs ├── Hosting └── TailwindcssHostedService.cs ├── Infrastructure ├── FileOperations.cs ├── ProcessExtensions.cs └── ProcessUtil.cs ├── Install ├── ProjectInstaller.cs └── app.tailwind.css ├── Program.cs ├── ProjectInfo.cs ├── Properties └── launchSettings.json └── tailwindcss-dotnet.csproj /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | 5 | push: 6 | branches: [ main ] 7 | paths-ignore: 8 | - 'doc/**' 9 | - '**.md' 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | env: 16 | DOTNET_NOLOGO: true 17 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 18 | 19 | steps: 20 | - name: Setup .NET 9 21 | uses: actions/setup-dotnet@v3 22 | with: 23 | dotnet-version: 9.0.x 24 | 25 | - name: Checkout source code 26 | uses: actions/checkout@v3 27 | with: 28 | fetch-depth: 0 29 | 30 | - name: Test 31 | run: ./build.sh --no-color ci 32 | 33 | - name: Pack with dotnet 34 | run: ./build.sh --no-color pack 35 | 36 | - name: Publish CI Packages 37 | run: | 38 | for package in $(find -name "*.nupkg"); do 39 | echo "${0##*/}": Pushing $package to Feedz... 40 | dotnet nuget push $package --source https://f.feedz.io/osnova/tailwindcss-dotnet/nuget/index.json --api-key ${{ secrets.FEEDZ_KEY }} --skip-duplicate 41 | done 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish nugets 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | env: 12 | DOTNET_NOLOGO: true 13 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 14 | 15 | steps: 16 | - name: Setup .NET 17 | uses: actions/setup-dotnet@v3 18 | with: 19 | dotnet-version: 9.0.x 20 | 21 | - name: Checkout source code 22 | uses: actions/checkout@v3 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Pack with dotnet 27 | run: ./build.sh --no-color pack 28 | shell: bash 29 | 30 | - name: Publish to NuGet 31 | run: | 32 | for package in $(find -name "*.nupkg"); do 33 | echo "${0##*/}": Pushing $package to NuGet... 34 | dotnet nuget push $package --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} --skip-duplicate 35 | done 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | #Ignore thumbnails created by Windows 3 | Thumbs.db 4 | #Ignore files built by Visual Studio 5 | *.obj 6 | *.exe 7 | *.pdb 8 | *.user 9 | *.aps 10 | *.pch 11 | *.vspscc 12 | *_i.c 13 | *_p.c 14 | *.ncb 15 | *.suo 16 | *.tlb 17 | *.tlh 18 | *.bak 19 | *.cache 20 | *.ilk 21 | *.log 22 | [Bb]in 23 | [Dd]ebug*/ 24 | *.lib 25 | *.sbr 26 | obj/ 27 | [Rr]elease*/ 28 | _ReSharper*/ 29 | [Tt]est[Rr]esult* 30 | .vs/ 31 | #Nuget packages folder 32 | packages/ 33 | artifacts/ 34 | .vscode/ 35 | # JetBrains Rider 36 | .idea/ 37 | *.sln.iml 38 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 5 | 1570;1571;1572;1573;1574;1587;1591;1701;1702;1711;1735;0618 6 | 7 | Max Rozborskyi 8 | https://github.com/rozumak/tailwindcss-dotnet 9 | MIT 10 | true 11 | enable 12 | 13 | $(PackageProjectUrl) 14 | true 15 | true 16 | embedded 17 | README.md 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Max Rozborskyi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tailwind CSS for ASP.NET Core 2 | 3 | A [dotnet tool](https://www.nuget.org/packages/tailwindcss-dotnet) that simplifies the installation and usage of [Tailwind CSS](https://tailwindcss.com) in ASP.NET Core projects by utilizing the stand-alone [Tailwind CSS CLI](https://github.com/tailwindlabs/tailwindcss/tree/master/standalone-cli). 4 | 5 | ## Getting Started 6 | 7 | ### Step 1: Create your project 8 | 9 | Start by creating a new ASP.NET Core project if you don’t have one set up already. You can use any web app template of your choice. 10 | 11 | ``` 12 | dotnet new blazorserver -o MyProject 13 | cd MyProject 14 | ``` 15 | 16 | ### Step 2: Install Tailwind CSS 17 | 18 | Install [dotnet tool](https://www.nuget.org/packages/tailwindcss-dotnet) globally, and then run the `tailwind install` command to generate the `tailwind.config.js` and `styles\app.tailwind.css` files: 19 | 20 | ``` 21 | dotnet tool install --global tailwindcss-dotnet 22 | tailwind install 23 | ``` 24 | 25 | ### Step 3: Start your build process 26 | 27 | Run your tailwind build process with: 28 | 29 | ``` 30 | tailwind watch 31 | ``` 32 | 33 | And start the app hot-reload dev server: 34 | 35 | ``` 36 | dotnet run watch 37 | ``` 38 | 39 | ### Step 4: Start using Tailwind in your project 40 | 41 | Start using Tailwind’s utility classes to style your content. 42 | 43 | ```csrazor 44 |

45 | Hello world! 46 |

47 | ``` 48 | 49 | ## Developing with Tailwind CSS 50 | 51 | ### Overview 52 | 53 | [Tailwind CSS](https://tailwindcss.com) is a CSS framework that uses a utility-first approach to styling elements. It allows you to apply pre-existing classes directly in your templates. The `tailwindcss-dotnet` tool wraps the standalone executable of the Tailwind CSS v3 framework, which is platform-specific and not bundled within the tool. When you run the tool for the first time, it downloads and saves the required executables automatically. 54 | 55 | It is supports the same platforms as the native Tailwind CLI executable, including Windows, macOS, and Linux. 56 | 57 | Starting from version 3, Tailwind CSS uses the Just-In-Time (JIT) technique to generate styles. It scans specified template files for class names and generates corresponding styles only for the names it finds. This means that you cannot generate class names programmatically. If you need styles for names that don't exist in your content files, you can use the [safelist option](https://tailwindcss.com/docs/content-configuration#safelisting-classes). However, it is not recommended to do so. 58 | 59 | ### Installation 60 | 61 | To use Tailwind CSS in your dotnet project, follow these steps: 62 | 63 | 1. Install the tool by running the command `dotnet tool install --global tailwindcss-dotnet`. 64 | 2. Run the command `tailwind install`. 65 | 66 | To run tool commands for a specific project from any location, you can use the `--project` option followed by the relative file location of the project. This option applies to all tool commands. For example, `tailwind build --project MyProject\MyProject.csproj` will generate the output CSS file for the specified project. 67 | 68 | When you install the tool, it creates default `styles\app.tailwind.css` and `tailwind.config.js` input files, which are configured to be used within your ASP.NET Core project. 69 | 70 | You can specify the imports you want to use, set up your `@apply` rules and custom CSS, customize the Tailwind build, just like in a traditional Node installation. Note that only first-party plugins are supported. 71 | 72 | ### Building for Production 73 | 74 | Run the `tailwind build` command to generate the output CSS file at `wwwroot\css\app.css`. This file should be included in your app HTML layout (the installer configures this automatically). 75 | 76 | It is recommended to not include the output CSS file in source control. Instead, run this command in your CI environment. 77 | 78 | ### Building for Development 79 | 80 | To automatically reflect changes in the generated CSS output while developing your application, run Tailwind in "watch" mode by executing the command `tailwind watch` in a separate process. 81 | 82 | If file system events are unreliable or not supported, pass the `--poll` argument to use polling instead: `tailwind watch --poll`. 83 | 84 | ### Configuring CSS minification 85 | 86 | By default, minified assets will be generated. If you want to change this behavior, pass a `--debug` argument to the command, for example, `tailwind build --debug` or `tailwind watch --debug`. 87 | 88 | ### Customizing Tailwind inputs or outputs 89 | 90 | The default paths are opinionated. If you need to use custom file paths or make other changes not covered by default behavior, you can access the platform-specific executable directly by running the command `tailwind exec` and passing any necessary command line arguments to the Tailwind CLI. 91 | 92 | ### Override Tailwind CLI version 93 | 94 | If you need to use a specific version of Tailwind CLI for your project, you can do so by specifying the version using the `--tailwindcss` option. If nothing is specified then the latest release of the tailwindcss executable is pulled. 95 | 96 | For example, to use Tailwind CLI of version 3.2.1, you can use the following command: 97 | `tailwind build --tailwindcss v3.2.1` 98 | -------------------------------------------------------------------------------- /Tailwindcss.DotnetTool.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.4.33103.184 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "tailwindcss-dotnet", "src\tailwindcss-dotnet\tailwindcss-dotnet.csproj", "{5A59404E-5598-48AD-AB79-B2A18EC33A7E}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".SolutionItems", ".SolutionItems", "{19CAD813-D078-480C-ACF4-80DB1E44A917}" 9 | ProjectSection(SolutionItems) = preProject 10 | Directory.Build.props = Directory.Build.props 11 | README.md = README.md 12 | EndProjectSection 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Release|Any CPU = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 20 | {5A59404E-5598-48AD-AB79-B2A18EC33A7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {5A59404E-5598-48AD-AB79-B2A18EC33A7E}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {5A59404E-5598-48AD-AB79-B2A18EC33A7E}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {5A59404E-5598-48AD-AB79-B2A18EC33A7E}.Release|Any CPU.Build.0 = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ExtensibilityGlobals) = postSolution 29 | SolutionGuid = {5E9A9CA8-35EE-43D9-AC94-DED4C3DE87A3} 30 | EndGlobalSection 31 | EndGlobal 32 | -------------------------------------------------------------------------------- /build.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | FOR /f %%v IN ('dotnet --version') DO set version=%%v 3 | set target_framework= 4 | IF "%version:~0,2%"=="9." (set target_framework=net8.0) 5 | 6 | IF [%target_framework%]==[] ( 7 | echo "BUILD FAILURE: .NET 9 SDK required to run build" 8 | exit /b 1 9 | ) 10 | 11 | dotnet run --project build/build.csproj -f %target_framework% -c Release -- %* 12 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | dotnet run --project build/build.csproj -c Release -- "$@" 5 | -------------------------------------------------------------------------------- /build/build.cs: -------------------------------------------------------------------------------- 1 | using static Bullseye.Targets; 2 | using static SimpleExec.Command; 3 | 4 | namespace build 5 | { 6 | internal class Build 7 | { 8 | private const string InstallMinver = "install-minver"; 9 | private const string Minver = "minver"; 10 | 11 | private const string Clean = "clean"; 12 | private const string Restore = "restore"; 13 | private const string Compile = "compile"; 14 | private const string Test = "test"; 15 | 16 | private const string Pack = "pack"; 17 | private const string Publish = "publish"; 18 | 19 | private static async Task Main(string[] args) 20 | { 21 | const string solutionName = "Tailwindcss.DotnetTool.sln"; 22 | 23 | Target("default", DependsOn(Compile)); 24 | 25 | Target(InstallMinver, IgnoreIfFailed(() => 26 | { 27 | Run("dotnet", "tool install --global minver-cli --version 4.2.0"); 28 | })); 29 | 30 | string? version = null; 31 | Target(Minver, DependsOn(InstallMinver), async () => 32 | { 33 | (version, _) = await ReadAsync("minver", "-t v"); 34 | Console.WriteLine("Version: {0}", version); 35 | }); 36 | 37 | Target(Clean, () => 38 | { 39 | EnsureDirectoriesDeleted("./artifacts"); 40 | Run("dotnet", $"clean {solutionName}"); 41 | }); 42 | 43 | Target(Restore, () => 44 | { 45 | Run("dotnet", $"restore {solutionName}"); 46 | }); 47 | 48 | Target(Compile, DependsOn(Restore), () => 49 | { 50 | Run("dotnet", 51 | $"build {solutionName} --no-restore"); 52 | }); 53 | 54 | Target("ci", DependsOn("default")); 55 | 56 | string[] nugetProjects = { 57 | "./src/tailwindcss-dotnet" 58 | }; 59 | 60 | Target(Pack, DependsOn(Compile, Minver), ForEach(nugetProjects), project => 61 | Run("dotnet", $"pack {project} -o ./artifacts --configuration Release -p:PackageVersion={version}")); 62 | 63 | await RunTargetsAndExitAsync(args); 64 | } 65 | 66 | private static void EnsureDirectoriesDeleted(params string[] paths) 67 | { 68 | foreach (var path in paths) 69 | { 70 | if (Directory.Exists(path)) 71 | { 72 | var dir = new DirectoryInfo(path); 73 | DeleteDirectory(dir); 74 | } 75 | } 76 | } 77 | 78 | private static void DeleteDirectory(DirectoryInfo baseDir) 79 | { 80 | baseDir.Attributes = FileAttributes.Normal; 81 | foreach (var childDir in baseDir.GetDirectories()) 82 | DeleteDirectory(childDir); 83 | 84 | foreach (var file in baseDir.GetFiles()) 85 | file.IsReadOnly = false; 86 | 87 | baseDir.Delete(true); 88 | } 89 | 90 | private static Action IgnoreIfFailed(Action action) 91 | { 92 | return () => 93 | { 94 | try 95 | { 96 | action(); 97 | } 98 | catch (Exception exception) 99 | { 100 | Console.WriteLine(exception.Message); 101 | } 102 | }; 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /build/build.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net9.0 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/AppInvocationContext.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.IO; 3 | using Tailwindcss.DotNetTool.Cli; 4 | 5 | namespace Tailwindcss.DotNetTool; 6 | 7 | public class AppInvocationContext 8 | { 9 | public TailwindCli Cli { get; } = new (); 10 | 11 | public IConsole Console { get; } = new SystemConsole(); 12 | 13 | public ProjectInfo? Project { get; set; } 14 | 15 | public string GetProjectRoot() 16 | { 17 | if (Project == null || Project.ProjectRoot == null) 18 | throw new InvalidOperationException("Project info is not initialized."); 19 | 20 | return Project.ProjectRoot; 21 | } 22 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Cli/ExecutableNotFoundException.cs: -------------------------------------------------------------------------------- 1 | namespace Tailwindcss.DotNetTool.Cli; 2 | 3 | public class ExecutableNotFoundException : Exception 4 | { 5 | public ExecutableNotFoundException(string message) : base(message) 6 | { 7 | 8 | } 9 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Cli/TailwindCli.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | using Tailwindcss.DotNetTool.Infrastructure; 3 | 4 | namespace Tailwindcss.DotNetTool.Cli; 5 | 6 | public class TailwindCli 7 | { 8 | private bool _initialized; 9 | private string? _binPath; 10 | 11 | public async Task InitializeAsync(string? version = null) 12 | { 13 | string useVersion = version ?? Upstream.Version; 14 | string? binName = Upstream.GetNativeExecutableName(); 15 | 16 | if (binName == null) 17 | { 18 | throw new UnsupportedPlatformException( 19 | $"dotnet-tailwind does not support the {RuntimeInformation.RuntimeIdentifier} platform\r\n" + 20 | "Please install Tailwind CSS following instructions at https://tailwindcss.com/docs/installation"); 21 | } 22 | 23 | string storeFolderPath = Path.Combine(DotnetTool.InstallationFolder, "runtimes"); 24 | 25 | _binPath = Path.GetFullPath(GetStoreBinName(binName, useVersion), storeFolderPath); 26 | 27 | if (!File.Exists(_binPath)) 28 | { 29 | if (Directory.Exists(storeFolderPath)) 30 | { 31 | // Remove all temp files if any 32 | foreach (string filePath in Directory.GetFiles(storeFolderPath, "*.tmp")) 33 | { 34 | File.Delete(filePath); 35 | } 36 | } 37 | else 38 | { 39 | Directory.CreateDirectory(storeFolderPath); 40 | } 41 | 42 | string tempBinPath = Path.GetFullPath($"{Guid.NewGuid():N}.tmp", storeFolderPath); 43 | 44 | // Download native tailwind cli and save to temp file 45 | var client = new TailwindCliDownloader(); 46 | await client.DownloadAsync(useVersion, binName, tempBinPath); 47 | 48 | // If running on a Unix-based platform give file permission to be executed 49 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || 50 | RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 51 | { 52 | await ProcessUtil.ExecuteAsync("chmod", "+x " + tempBinPath); 53 | 54 | if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 55 | { 56 | await ProcessUtil.ExecuteAsync("xattr", "-d com.apple.quarantine " + tempBinPath); 57 | } 58 | } 59 | 60 | // Rename file 61 | File.Move(tempBinPath, _binPath); 62 | } 63 | 64 | _initialized = true; 65 | } 66 | 67 | private string GetStoreBinName(string binName, string version) 68 | { 69 | string fileName = Path.GetFileNameWithoutExtension(binName); 70 | string extension = Path.GetExtension(binName) ?? ""; 71 | version = version.Replace('.', '_'); 72 | 73 | return $"{fileName}-{version}{extension}"; 74 | } 75 | 76 | public string Executable() 77 | { 78 | if (!_initialized) 79 | { 80 | throw new InvalidOperationException("Must be initialized before any other action."); 81 | } 82 | 83 | return _binPath!; 84 | } 85 | 86 | public string[] CompileCommand(string rootPath, bool debug = false) 87 | { 88 | IEnumerable args = new[] 89 | { 90 | Executable(), 91 | "-i", Path.GetFullPath(Path.Combine("styles", "app.tailwind.css"), rootPath), 92 | "-o", Path.GetFullPath(Path.Combine("wwwroot", "css", "app.css"), rootPath), 93 | }; 94 | 95 | if (!debug) 96 | { 97 | args = args.Append("--minify"); 98 | } 99 | 100 | return args.ToArray(); 101 | } 102 | 103 | public string[] WatchCommand(string rootPath, bool debug = false, bool poll = false) 104 | { 105 | var compileCommand = CompileCommand(rootPath, debug); 106 | 107 | IEnumerable result = compileCommand; 108 | result = result.Append("-w"); 109 | 110 | if (poll) 111 | { 112 | result = result.Append("-p"); 113 | } 114 | 115 | return result.ToArray(); 116 | } 117 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Cli/TailwindCliDownloader.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Net; 3 | using System.Text; 4 | 5 | namespace Tailwindcss.DotNetTool.Cli; 6 | 7 | public class TailwindCliDownloader 8 | { 9 | public async Task DownloadAsync(string version, string binName, string saveBinPath) 10 | { 11 | string url = $"https://github.com/tailwindlabs/tailwindcss/releases/download/{version}/{binName}"; 12 | 13 | Console.WriteLine($"Downloading tailwind cli {version} from {url}."); 14 | 15 | try 16 | { 17 | await DownloadFileAsync(url, saveBinPath); 18 | } 19 | catch 20 | { 21 | Console.WriteLine("Failed to download and save native tailwind cli."); 22 | throw; 23 | } 24 | } 25 | 26 | private async Task DownloadFileAsync(string url, string saveBinPath) 27 | { 28 | using HttpClient client = new HttpClient(); 29 | using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url); 30 | using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); 31 | 32 | if (response.StatusCode == HttpStatusCode.NotFound) 33 | { 34 | throw new ExecutableNotFoundException($"Cannot find the Tailwind CSS executable via url {url}."); 35 | } 36 | 37 | response.EnsureSuccessStatusCode(); 38 | 39 | long? contentLength = response.Content.Headers.ContentLength; 40 | var reporter = new ConsoleProgressReporter(contentLength); 41 | 42 | await using var stream = await client.GetStreamAsync(url); 43 | 44 | try 45 | { 46 | await using var output = File.Open(saveBinPath, FileMode.Create); 47 | await CopyToAsync(stream, output, 4096 * 100, reporter); 48 | 49 | reporter.Report(output.Length, true); 50 | 51 | await output.FlushAsync(); 52 | } 53 | catch 54 | { 55 | // Silently try remove created temp file when failed to download 56 | try 57 | { 58 | File.Delete(saveBinPath); 59 | } 60 | catch 61 | { 62 | // ignored 63 | } 64 | 65 | throw; 66 | } 67 | } 68 | 69 | static async Task CopyToAsync(Stream source, Stream destination, int bufferSize, ConsoleProgressReporter? reporter) 70 | { 71 | var buffer = new byte[bufferSize]; 72 | long totalBytesRead = 0; 73 | int bytesRead; 74 | 75 | while ((bytesRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) 76 | { 77 | await destination.WriteAsync(buffer, 0, bytesRead); 78 | totalBytesRead += bytesRead; 79 | reporter?.Report(totalBytesRead); 80 | } 81 | } 82 | 83 | private class ConsoleProgressReporter 84 | { 85 | private readonly long? _totalSize; 86 | private readonly Stopwatch _stopwatch; 87 | 88 | private int _lastMessageSize; 89 | 90 | public ConsoleProgressReporter(long? totalSize) 91 | { 92 | _totalSize = totalSize; 93 | _stopwatch = Stopwatch.StartNew(); 94 | } 95 | 96 | public void Report(long totalBytesRead, bool final = false) 97 | { 98 | if (!final && !Console.IsOutputRedirected) 99 | { 100 | double speed = CalculateSpeed(); 101 | 102 | if (_totalSize.HasValue) 103 | { 104 | double percentage = (double)totalBytesRead / _totalSize.Value * 100; 105 | WriteCore( 106 | $"Downloaded {totalBytesRead} bytes out of {_totalSize} ({percentage:F2}%), speed: {speed:F2} MB/s"); 107 | } 108 | else 109 | { 110 | WriteCore($"Downloaded {totalBytesRead} bytes, speed: {speed:F2} MB/s"); 111 | } 112 | } 113 | else 114 | { 115 | double speed = CalculateSpeed(); 116 | _stopwatch.Stop(); 117 | 118 | WriteCore( 119 | $"Download completed in {_stopwatch.Elapsed.TotalSeconds:F2} seconds, average speed: {speed:F2} MB/s", 120 | true); 121 | } 122 | 123 | double CalculateSpeed() 124 | { 125 | // Calculate speed in MB/s 126 | double speed = totalBytesRead / _stopwatch.Elapsed.TotalSeconds / 1024 / 1024; 127 | return speed; 128 | } 129 | } 130 | 131 | private void WriteCore(string message, bool newLine = false) 132 | { 133 | int overlapCount = _lastMessageSize - message.Length; 134 | if (overlapCount > 0) 135 | { 136 | var builder = new StringBuilder(message); 137 | builder.Append(' ', overlapCount); 138 | 139 | message = builder.ToString(); 140 | } 141 | 142 | _lastMessageSize = message.Length; 143 | 144 | if (newLine) 145 | { 146 | message += Environment.NewLine; 147 | } 148 | 149 | Console.SetCursorPosition(0, Console.CursorTop); 150 | Console.Write(message); 151 | } 152 | } 153 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Cli/UnsupportedPlatformException.cs: -------------------------------------------------------------------------------- 1 | namespace Tailwindcss.DotNetTool.Cli; 2 | 3 | public class UnsupportedPlatformException : Exception 4 | { 5 | public UnsupportedPlatformException(string message) : base(message) 6 | { 7 | 8 | } 9 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Cli/Upstream.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | using System.Net.Http; 3 | using System.Text.Json; 4 | using System.Text.Json.Serialization; 5 | using System.Threading.Tasks; 6 | 7 | namespace Tailwindcss.DotNetTool.Cli; 8 | 9 | public class Upstream 10 | { 11 | private static readonly HttpClient httpClient = new HttpClient(); 12 | private record Release 13 | { 14 | [JsonPropertyName("tag_name")] public string Version { get; set; } 15 | [JsonPropertyName("prerelease")] public bool IsPrerelease { get; set; } 16 | [JsonPropertyName("published_at")] public DateTime PublishedAt { get; set; } 17 | 18 | } 19 | public static async Task GetLatestReleaseVersionAsync() 20 | { 21 | var fallbackVersion = "v4.0.12"; 22 | try 23 | { 24 | httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("dotnet-tailwindcss"); 25 | var response = await httpClient.GetAsync("https://api.github.com/repos/tailwindlabs/tailwindcss/releases"); 26 | response.EnsureSuccessStatusCode(); 27 | 28 | 29 | 30 | var responseBody = await response.Content.ReadAsStringAsync(); 31 | var releases = JsonSerializer.Deserialize(responseBody); 32 | var latestMinorRelease = 33 | releases? 34 | .Where(r => !r.IsPrerelease) 35 | .Where(r => r.Version.StartsWith("v4.")) 36 | .OrderByDescending(r => r.PublishedAt) 37 | .FirstOrDefault(); 38 | return latestMinorRelease?.Version ?? fallbackVersion; 39 | } 40 | 41 | catch 42 | { 43 | return fallbackVersion; 44 | } 45 | } 46 | private static readonly Lazy _version = new(() => GetLatestReleaseVersionAsync().GetAwaiter().GetResult()); 47 | public static string Version => _version.Value; 48 | 49 | public static string? GetNativeExecutableName() 50 | { 51 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 52 | { 53 | return RuntimeInformation.ProcessArchitecture is Architecture.Arm64 or Architecture.X64 ? "tailwindcss-windows-x64.exe" : null; 54 | } 55 | 56 | if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 57 | { 58 | return RuntimeInformation.ProcessArchitecture switch 59 | { 60 | Architecture.Arm64 => "tailwindcss-macos-arm64", 61 | Architecture.X64 => "tailwindcss-macos-x64", 62 | _ => null 63 | }; 64 | } 65 | 66 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 67 | { 68 | return RuntimeInformation.ProcessArchitecture switch 69 | { 70 | Architecture.Arm64 => "tailwindcss-linux-arm64", 71 | Architecture.Arm => "tailwindcss-linux-armv7", 72 | Architecture.X64 => "tailwindcss-linux-x64", 73 | _ => null 74 | }; 75 | } 76 | 77 | return null; 78 | } 79 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/CommandLineBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Builder; 3 | using System.CommandLine.IO; 4 | using System.CommandLine.Parsing; 5 | using System.Text.RegularExpressions; 6 | using Tailwindcss.DotNetTool.Cli; 7 | using Tailwindcss.DotNetTool.Commands; 8 | 9 | namespace Tailwindcss.DotNetTool; 10 | 11 | internal static class CommandLineBuilderExtensions 12 | { 13 | public static CommandLineBuilder UseContextInitializer( 14 | this CommandLineBuilder builder, AppInvocationContext appContext) 15 | { 16 | const string pattern = @"^v4\.\d+\.\d+$"; 17 | 18 | string? tailwindVersion = null; 19 | var tailwindVersionOption = new Option("--tailwindcss", 20 | "Specify tailwind cli version to use in format 'v4.x.x'."); 21 | builder.Command.AddGlobalOption(tailwindVersionOption); 22 | 23 | builder.AddMiddleware(async (context, next) => 24 | { 25 | OptionResult? optionResult = context.ParseResult.FindResultFor(tailwindVersionOption); 26 | if (optionResult != null) 27 | { 28 | if (context.ParseResult.Errors.Any(e => e.SymbolResult?.Symbol == tailwindVersionOption)) 29 | { 30 | // Error will be show via ErrorMiddleware just passing it further 31 | await next(context); 32 | return; 33 | } 34 | 35 | tailwindVersion = optionResult.GetValueOrDefault(); 36 | if (string.IsNullOrWhiteSpace(tailwindVersion) || !Regex.IsMatch(tailwindVersion, pattern)) 37 | { 38 | context.Console.Error.WriteLine("Invalid Tailwind CSS version format."); 39 | context.ExitCode = 1; 40 | 41 | return; 42 | } 43 | } 44 | 45 | try 46 | { 47 | // Initialize cli by downloading required runtime files if needed 48 | await appContext.Cli.InitializeAsync(tailwindVersion); 49 | } 50 | catch (Exception e) when (e is ExecutableNotFoundException || 51 | e is UnsupportedPlatformException) 52 | { 53 | context.Console.Error.WriteLine(e.Message); 54 | context.ExitCode = 1; 55 | return; 56 | } 57 | 58 | await next(context); 59 | }); 60 | 61 | return builder; 62 | } 63 | 64 | public static CommandLineBuilder UseProjectOption( 65 | this CommandLineBuilder builder, AppInvocationContext appContext) 66 | { 67 | var projectOption = new Option("--project", 68 | "Relative path to the project folder of the target project. Default value is the current folder."); 69 | 70 | builder.Command.AddGlobalOption(projectOption); 71 | 72 | builder.AddMiddleware(async (context, next) => 73 | { 74 | string? projectPath = null; 75 | OptionResult? projectOptionResult = context.ParseResult.FindResultFor(projectOption); 76 | if (projectOptionResult != null) 77 | { 78 | if (context.ParseResult.Errors.Any(e => e.SymbolResult?.Symbol == projectOption)) 79 | { 80 | // Error will be show via ErrorMiddleware just passing it further 81 | await next(context); 82 | return; 83 | } 84 | 85 | projectPath = projectOptionResult.GetValueOrDefault(); 86 | } 87 | 88 | var projectInfo = DotnetTool.ResolveProject(projectPath); 89 | if (projectInfo?.ProjectRoot == null) 90 | { 91 | context.Console.Error.WriteLine("Project file was not found. Use --project option or change current folder."); 92 | context.ExitCode = 1; 93 | return; 94 | } 95 | 96 | appContext.Project = projectInfo; 97 | 98 | await next(context); 99 | }); 100 | 101 | return builder; 102 | } 103 | 104 | public static CommandLineBuilder UseExecRun( 105 | this CommandLineBuilder builder, AppInvocationContext appContext) 106 | { 107 | builder.AddMiddleware(async (context, next) => 108 | { 109 | // Pass control directly to tailwind cli and return after execution completed 110 | if (context.ParseResult.CommandResult.Command.Name == "exec") 111 | { 112 | // Project option is ignored 113 | string[] args = Environment.GetCommandLineArgs().Skip(2).ToArray(); 114 | int result = await AppCommands.Instance.Exec.Execute(appContext.Cli, args); 115 | context.ExitCode = result; 116 | 117 | return; 118 | } 119 | 120 | await next(context); 121 | }); 122 | 123 | return builder; 124 | } 125 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Commands/AppCommands.cs: -------------------------------------------------------------------------------- 1 | namespace Tailwindcss.DotNetTool.Commands; 2 | 3 | public class AppCommands 4 | { 5 | public static AppCommands Instance { get; } = new(); 6 | 7 | public ICommand[] All { get; } 8 | 9 | public InstallCommand Install { get; } = new(); 10 | 11 | public BuildCommand Build { get; } = new(); 12 | 13 | public WatchCommand Watch { get; } = new(); 14 | 15 | public ExecCommand Exec { get; } = new(); 16 | 17 | private AppCommands() 18 | { 19 | All = new ICommand[] {Install, Build, Watch, Exec}; 20 | } 21 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Commands/BuildCommand.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Builder; 3 | using Tailwindcss.DotNetTool.Cli; 4 | using Tailwindcss.DotNetTool.Infrastructure; 5 | 6 | namespace Tailwindcss.DotNetTool.Commands; 7 | 8 | public class BuildCommand : ICommand 9 | { 10 | public string Description => "Build your Tailwind CSS"; 11 | 12 | public string Name => "build"; 13 | 14 | public void Setup(CommandLineBuilder builder, AppInvocationContext appContext) 15 | { 16 | var command = new Command(Name, Description); 17 | 18 | var optionDebug = new Option("--debug"); 19 | command.AddOption(optionDebug); 20 | 21 | builder.Command.Add(command); 22 | command.SetHandler(async ctx => 23 | { 24 | bool debug = ctx.ParseResult.GetValueForOption(optionDebug); 25 | 26 | int result = await Execute(appContext, debug); 27 | ctx.ExitCode = result; 28 | }); 29 | } 30 | 31 | public async Task Execute(AppInvocationContext context, bool debug) 32 | { 33 | string rootPath = context.GetProjectRoot(); 34 | var command = context.Cli.CompileCommand(rootPath, debug); 35 | 36 | Console.WriteLine("Starting Build command..."); 37 | Console.WriteLine("Execute command: {0}.", string.Join(" ", command)); 38 | 39 | return await ProcessUtil.ExecuteAsync(command, rootPath); 40 | } 41 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Commands/ExecCommand.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Builder; 3 | using Tailwindcss.DotNetTool.Cli; 4 | using Tailwindcss.DotNetTool.Infrastructure; 5 | 6 | namespace Tailwindcss.DotNetTool.Commands; 7 | 8 | public class ExecCommand : ICommand 9 | { 10 | public string Description => "Execute Tailwind CSS platform-specific executable with your own build options"; 11 | 12 | public string Name => "exec"; 13 | 14 | public void Setup(CommandLineBuilder builder, AppInvocationContext appContext) 15 | { 16 | var command = new Command(Name, Description); 17 | 18 | builder.Command.Add(command); 19 | } 20 | 21 | public Task Execute(TailwindCli cli, string[] args) 22 | { 23 | string binPath = cli.Executable(); 24 | 25 | return ProcessUtil.ExecuteAsync(binPath, string.Join(' ', args)); 26 | } 27 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Commands/ICommand.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine.Builder; 2 | 3 | namespace Tailwindcss.DotNetTool.Commands; 4 | 5 | public interface ICommand 6 | { 7 | public string Description { get; } 8 | 9 | public string Name { get; } 10 | 11 | public void Setup(CommandLineBuilder builder, AppInvocationContext appContext); 12 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Commands/InstallCommand.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Builder; 3 | using Tailwindcss.DotNetTool.Install; 4 | 5 | namespace Tailwindcss.DotNetTool.Commands 6 | { 7 | public class InstallCommand : ICommand 8 | { 9 | public string Description => "Install Tailwind CSS into the app"; 10 | 11 | public string Name => "install"; 12 | 13 | public void Setup(CommandLineBuilder builder, AppInvocationContext appContext) 14 | { 15 | var command = new Command(Name, Description); 16 | 17 | builder.Command.Add(command); 18 | command.SetHandler(async ctx => 19 | { 20 | int result = await Execute(appContext); 21 | ctx.ExitCode = result; 22 | }); 23 | } 24 | 25 | public async Task Execute(AppInvocationContext context) 26 | { 27 | context.Console.WriteLine("Starting Install command..."); 28 | 29 | var installer = new ProjectInstaller(); 30 | await installer.Run(context.GetProjectRoot()); 31 | 32 | context.Console.WriteLine("Run initial Tailwind build"); 33 | 34 | return await AppCommands.Instance.Build.Execute(context, false); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Commands/WatchCommand.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Builder; 3 | using Tailwindcss.DotNetTool.Cli; 4 | using Tailwindcss.DotNetTool.Infrastructure; 5 | 6 | namespace Tailwindcss.DotNetTool.Commands; 7 | 8 | public class WatchCommand : ICommand 9 | { 10 | public string Description => "Watch and build your Tailwind CSS on file changes"; 11 | 12 | public string Name => "watch"; 13 | 14 | public void Setup(CommandLineBuilder builder, AppInvocationContext appContext) 15 | { 16 | var command = new Command(Name, Description); 17 | 18 | var optionDebug = new Option("--debug"); 19 | var optionPoll = new Option("--poll"); 20 | command.AddOption(optionDebug); 21 | command.AddOption(optionPoll); 22 | 23 | builder.Command.Add(command); 24 | command.SetHandler(async (ctx) => 25 | { 26 | bool debug = ctx.ParseResult.GetValueForOption(optionDebug); 27 | bool poll = ctx.ParseResult.GetValueForOption(optionPoll); 28 | 29 | int result = await Execute(appContext, debug, poll); 30 | ctx.ExitCode = result; 31 | }); 32 | } 33 | 34 | public async Task Execute(AppInvocationContext context, bool debug, bool poll) 35 | { 36 | var command = context.Cli.WatchCommand(context.GetProjectRoot(), debug, poll); 37 | 38 | Console.WriteLine("Starting Watch command..."); 39 | Console.WriteLine("Execute command: {0}.", string.Join(" ", command)); 40 | 41 | return await ProcessUtil.ExecuteAsync(command); 42 | } 43 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/DotnetTool.cs: -------------------------------------------------------------------------------- 1 | namespace Tailwindcss.DotNetTool; 2 | 3 | public class DotnetTool 4 | { 5 | public static string InstallationFolder { get; } 6 | 7 | static DotnetTool() 8 | { 9 | InstallationFolder = Path.GetDirectoryName(typeof(Program).Assembly.Location)!; 10 | } 11 | 12 | public static ProjectInfo? ResolveProject(string? path) 13 | { 14 | if (path == null) 15 | { 16 | path = Directory.GetCurrentDirectory(); 17 | } 18 | else 19 | { 20 | path = Path.GetFullPath(path); 21 | 22 | if (File.Exists(path)) 23 | { 24 | // It's not a directory 25 | return new ProjectInfo 26 | { 27 | ProjectFilePath = Path.GetDirectoryName(path)!, 28 | ProjectRoot = path 29 | }; 30 | } 31 | } 32 | 33 | if (!Directory.Exists(path)) 34 | return null; 35 | 36 | var projectFile = Directory 37 | .EnumerateFiles(path, "*.*proj", SearchOption.TopDirectoryOnly) 38 | .FirstOrDefault(f => !string.Equals(Path.GetExtension(f), ".xproj", StringComparison.OrdinalIgnoreCase)); 39 | 40 | if (projectFile == null) 41 | return null; 42 | 43 | return new ProjectInfo 44 | { 45 | ProjectFilePath = projectFile, 46 | ProjectRoot = path 47 | }; 48 | } 49 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Hosting/TailwindcssHostedService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Hosting; 2 | using Microsoft.Extensions.Logging; 3 | using Tailwindcss.DotNetTool.Infrastructure; 4 | 5 | namespace Tailwindcss.DotNetTool.Hosting; 6 | 7 | public class TailwindcssHostedService : IHostedService 8 | { 9 | private readonly ILogger _logger; 10 | 11 | private readonly IHostApplicationLifetime _hostApplicationLifetime; 12 | private IAsyncDisposable? _tailwindDisposable; 13 | 14 | public TailwindcssHostedService(ILogger logger, 15 | IHostApplicationLifetime hostApplicationLifetime) 16 | { 17 | _hostApplicationLifetime = hostApplicationLifetime; 18 | _logger = logger; 19 | } 20 | 21 | public async Task StartAsync(CancellationToken cancellationToken) 22 | { 23 | // Blocking app start until all deps are downloaded and initialized 24 | ProjectInfo? projectInfo = DotnetTool.ResolveProject(null); 25 | 26 | if (projectInfo == null) 27 | { 28 | throw new ArgumentNullException(nameof(projectInfo)); 29 | } 30 | 31 | var appContext = new AppInvocationContext 32 | { 33 | Project = projectInfo 34 | }; 35 | await appContext.Cli.InitializeAsync(); 36 | 37 | EnsureTailwindProcessRunning(appContext); 38 | } 39 | 40 | public async Task StopAsync(CancellationToken cancellationToken) 41 | { 42 | _logger.LogInformation("Stopping tailwind..."); 43 | 44 | if (_tailwindDisposable is { } disposable) 45 | { 46 | _tailwindDisposable = null; 47 | 48 | try 49 | { 50 | await disposable.DisposeAsync().AsTask().ConfigureAwait(false); 51 | } 52 | catch (OperationCanceledException) 53 | { 54 | // Failed with timeout, ignore 55 | } 56 | catch (Exception ex) 57 | { 58 | _logger.LogCritical(ex, "Tailwindcss process termination failed with an error."); 59 | } 60 | } 61 | } 62 | 63 | private void EnsureTailwindProcessRunning(AppInvocationContext appContext) 64 | { 65 | var watchCommand = appContext.Cli.WatchCommand(appContext.GetProjectRoot(), true); 66 | _logger.LogInformation("Starting tailwind. Execute command: {Command}.", string.Join(" ", watchCommand)); 67 | 68 | var procSpec = CreateTailwindProcSpec(watchCommand, appContext.GetProjectRoot()); 69 | (var resultTask, _tailwindDisposable) = ProcessUtil.Run(procSpec); 70 | 71 | resultTask.ContinueWith(task => 72 | { 73 | if (!_hostApplicationLifetime.ApplicationStopping.IsCancellationRequested) 74 | { 75 | _logger.LogCritical("Tailwind process died. Exit code {ExitCode}. Stopping the application...", 76 | task.Result.ExitCode); 77 | _hostApplicationLifetime.StopApplication(); 78 | } 79 | }); 80 | } 81 | 82 | private ProcessSpec CreateTailwindProcSpec(string[] command, string workingDirectory) 83 | { 84 | ProcessSpec procSpec = new ProcessSpec(command[0]) 85 | { 86 | WorkingDirectory = workingDirectory, 87 | Arguments = string.Join(" ", command.Skip(1)), 88 | OnOutputData = Console.Out.WriteLine, 89 | OnErrorData = Console.Error.WriteLine, 90 | InheritEnv = true, 91 | ThrowOnNonZeroReturnCode = false, 92 | }; 93 | 94 | return procSpec; 95 | } 96 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Infrastructure/FileOperations.cs: -------------------------------------------------------------------------------- 1 | namespace Tailwindcss.DotNetTool.Infrastructure; 2 | 3 | internal static class FileOperations 4 | { 5 | public static async Task InsertBeforeAsync(string fileName, string insertText, string beforeText) 6 | { 7 | var text = await File.ReadAllTextAsync(fileName); 8 | 9 | int index = text.IndexOf(beforeText, StringComparison.Ordinal); 10 | if (index == -1) 11 | { 12 | return false; 13 | } 14 | 15 | text = text.Insert(index, insertText); 16 | await File.WriteAllTextAsync(fileName, text); 17 | 18 | return true; 19 | } 20 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Infrastructure/ProcessExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Tailwindcss.DotNetTool.Infrastructure; 2 | 3 | internal static partial class ProcessUtil 4 | { 5 | public static Task ExecuteAsync(string[] command, string? workingDirectory = null) 6 | { 7 | return ExecuteAsync(command[0], string.Join(" ", command.Skip(1)), workingDirectory); 8 | } 9 | 10 | public static async Task ExecuteAsync(string fileName, string? arguments = null, string? workingDirectory = null) 11 | { 12 | ProcessSpec procSpec = new ProcessSpec(fileName) 13 | { 14 | WorkingDirectory = workingDirectory ?? Directory.GetCurrentDirectory(), 15 | Arguments = arguments ?? "", 16 | OnOutputData = Console.Out.WriteLine, 17 | OnErrorData = Console.Error.WriteLine, 18 | InheritEnv = true, 19 | }; 20 | 21 | var (result, _) = ProcessUtil.Run(procSpec); 22 | return (await result).ExitCode; 23 | } 24 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Infrastructure/ProcessUtil.cs: -------------------------------------------------------------------------------- 1 | // Copied from project Aspire with small modifications 2 | // https://github.com/dotnet/aspire/blob/main/src/Aspire.Hosting/Dcp/Process/ProcessResult.cs 3 | // https://github.com/dotnet/aspire/blob/main/src/Aspire.Hosting/Dcp/Process/ProcessSpec.cs 4 | // https://github.com/dotnet/aspire/blob/main/src/Aspire.Hosting/Dcp/Process/ProcessUtil.cs 5 | 6 | // Licensed to the .NET Foundation under one or more agreements. 7 | // The .NET Foundation licenses this file to you under the MIT license. 8 | 9 | using System.CommandLine; 10 | using System.Diagnostics; 11 | using System.Runtime.InteropServices; 12 | 13 | namespace Tailwindcss.DotNetTool.Infrastructure; 14 | 15 | internal sealed class ProcessResult 16 | { 17 | public ProcessResult(int exitCode) 18 | { 19 | ExitCode = exitCode; 20 | } 21 | 22 | public int ExitCode { get; } 23 | } 24 | 25 | internal sealed class ProcessSpec 26 | { 27 | public string ExecutablePath { get; } 28 | public string? WorkingDirectory { get; init; } 29 | public IDictionary EnvironmentVariables { get; init; } = new Dictionary(); 30 | public bool InheritEnv { get; init; } = true; 31 | public string? Arguments { get; init; } 32 | public Action? OnOutputData { get; init; } 33 | public Action? OnErrorData { get; init; } 34 | public Action? OnStart { get; init; } 35 | public Action? OnStop { get; init; } 36 | public bool KillEntireProcessTree { get; init; } = true; 37 | public bool ThrowOnNonZeroReturnCode { get; init; } = true; 38 | 39 | public ProcessSpec(string executablePath) 40 | { 41 | ExecutablePath = executablePath; 42 | } 43 | } 44 | 45 | internal static partial class ProcessUtil 46 | { 47 | #region Native Methods 48 | 49 | [DllImport("libc", SetLastError = true, EntryPoint = "kill")] 50 | private static extern int sys_kill(int pid, int sig); 51 | 52 | #endregion 53 | 54 | private static readonly TimeSpan s_processExitTimeout = TimeSpan.FromSeconds(5); 55 | 56 | public static (Task, IAsyncDisposable) Run(ProcessSpec processSpec) 57 | { 58 | var process = new System.Diagnostics.Process() 59 | { 60 | StartInfo = 61 | { 62 | FileName = processSpec.ExecutablePath, 63 | WorkingDirectory = processSpec.WorkingDirectory ?? string.Empty, 64 | Arguments = processSpec.Arguments, 65 | RedirectStandardOutput = true, 66 | RedirectStandardError = true, 67 | UseShellExecute = false, 68 | WindowStyle = ProcessWindowStyle.Hidden, 69 | }, 70 | EnableRaisingEvents = true 71 | }; 72 | 73 | if (!processSpec.InheritEnv) 74 | { 75 | process.StartInfo.Environment.Clear(); 76 | } 77 | 78 | foreach (var (key, value) in processSpec.EnvironmentVariables) 79 | { 80 | process.StartInfo.Environment[key] = value; 81 | } 82 | 83 | // Use a reset event to prevent output processing and exited events from running until OnStart is complete. 84 | // OnStart might have logic that sets up data structures that then are used by these events. 85 | var startupComplete = new ManualResetEventSlim(false); 86 | 87 | // Note: even though the child process has exited, its children may be alive and still producing output. 88 | // See https://github.com/dotnet/runtime/issues/29232#issuecomment-1451584094 for how this might affect waiting for process exit. 89 | // We are going to discard that (grandchild) output by checking process.HasExited. 90 | 91 | if (processSpec.OnOutputData != null) 92 | { 93 | process.OutputDataReceived += (_, e) => 94 | { 95 | startupComplete.Wait(); 96 | 97 | if (String.IsNullOrEmpty(e.Data)) 98 | { 99 | return; 100 | } 101 | 102 | processSpec.OnOutputData.Invoke(e.Data); 103 | }; 104 | } 105 | 106 | if (processSpec.OnErrorData != null) 107 | { 108 | process.ErrorDataReceived += (_, e) => 109 | { 110 | startupComplete.Wait(); 111 | if (String.IsNullOrEmpty(e.Data)) 112 | { 113 | return; 114 | } 115 | 116 | processSpec.OnErrorData.Invoke(e.Data); 117 | }; 118 | } 119 | 120 | var processLifetimeTcs = new TaskCompletionSource(); 121 | 122 | try 123 | { 124 | process.Start(); 125 | process.BeginOutputReadLine(); 126 | process.BeginErrorReadLine(); 127 | processSpec.OnStart?.Invoke(process.Id); 128 | 129 | process.WaitForExitAsync().ContinueWith(t => 130 | { 131 | startupComplete.Wait(); 132 | 133 | if (processSpec.ThrowOnNonZeroReturnCode && process.ExitCode != 0) 134 | { 135 | processLifetimeTcs.TrySetException(new InvalidOperationException( 136 | $"Command {processSpec.ExecutablePath} {processSpec.Arguments} returned non-zero exit code {process.ExitCode}")); 137 | } 138 | else 139 | { 140 | processLifetimeTcs.TrySetResult(new ProcessResult(process.ExitCode)); 141 | } 142 | }, TaskScheduler.Default); 143 | } 144 | finally 145 | { 146 | startupComplete.Set(); // Allow output/error/exit handlers to start processing data. 147 | } 148 | 149 | return (processLifetimeTcs.Task, new ProcessDisposable(process, processLifetimeTcs.Task, processSpec.KillEntireProcessTree)); 150 | } 151 | 152 | private sealed class ProcessDisposable : IAsyncDisposable 153 | { 154 | private readonly System.Diagnostics.Process _process; 155 | private readonly Task _processLifetimeTask; 156 | private readonly bool _entireProcessTree; 157 | 158 | public ProcessDisposable(System.Diagnostics.Process process, Task processLifetimeTask, bool entireProcessTree) 159 | { 160 | _process = process; 161 | _processLifetimeTask = processLifetimeTask; 162 | _entireProcessTree = entireProcessTree; 163 | } 164 | 165 | public async ValueTask DisposeAsync() 166 | { 167 | if (_process.HasExited) 168 | { 169 | return; // nothing to do 170 | } 171 | 172 | if (OperatingSystem.IsWindows()) 173 | { 174 | if (!_process.CloseMainWindow()) 175 | { 176 | _process.Kill(_entireProcessTree); 177 | } 178 | } 179 | else 180 | { 181 | sys_kill(_process.Id, sig: 2); // SIGINT 182 | } 183 | 184 | await _processLifetimeTask.WaitAsync(s_processExitTimeout).ConfigureAwait(false); 185 | if (!_process.HasExited) 186 | { 187 | // Always try to kill the entire process tree here if all of the above has failed. 188 | _process.Kill(entireProcessTree: true); 189 | } 190 | } 191 | } 192 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Install/ProjectInstaller.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Tailwindcss.DotNetTool.Infrastructure; 3 | 4 | namespace Tailwindcss.DotNetTool.Install; 5 | 6 | public class ProjectInstaller 7 | { 8 | private readonly Assembly _executingAssembly = Assembly.GetExecutingAssembly(); 9 | 10 | public async Task Run(string projectRoot) 11 | { 12 | var tailwindCss = Path.GetFullPath(Path.Combine("styles", "app.tailwind.css"), projectRoot); 13 | if (!File.Exists(tailwindCss)) 14 | { 15 | Console.WriteLine("Add default style\\app.tailwind.css"); 16 | 17 | Directory.CreateDirectory(Path.GetDirectoryName(tailwindCss)!); 18 | await CopyTo("app.tailwind.css", tailwindCss); 19 | } 20 | 21 | string? layoutFile = FindDefaultLayoutPath(projectRoot); 22 | string stylesheetLink = @""; 23 | 24 | if (layoutFile != null) 25 | { 26 | //TODO: check if already installed in layout? 27 | //TODO: change layout file to contain tailwind?? 28 | await FileOperations.InsertBeforeAsync(layoutFile, 29 | stylesheetLink + Environment.NewLine + Indent(4), " tag in your custom layout."); 35 | } 36 | 37 | //TODO: do we need delete this file? we can't empty directory 38 | string builtCss = Path.GetFullPath(Path.Combine("wwwroot", "css", "app.css"), projectRoot); 39 | if (File.Exists(builtCss)) 40 | { 41 | Console.WriteLine("Clean wwwroot\\css"); 42 | File.Delete(builtCss); 43 | } 44 | 45 | //TODO: write .gitignore to ignore style\\tailwind.css file? 46 | } 47 | 48 | private async Task CopyTo(string sourceName, string destFileName) 49 | { 50 | var resourceName = $"Tailwindcss.DotNetTool.Install.{sourceName}"; 51 | 52 | await using Stream? sourceStream = _executingAssembly.GetManifestResourceStream(resourceName); 53 | if (sourceStream == null) 54 | { 55 | throw new Exception($"Cant find '{sourceName}' in embedded resources."); 56 | } 57 | 58 | await using FileStream destStream = new FileStream(destFileName, FileMode.OpenOrCreate); 59 | await sourceStream.CopyToAsync(destStream); 60 | } 61 | 62 | private string? FindDefaultLayoutPath(string projectRoot) 63 | { 64 | // "razor" template 65 | var layoutPath = Path.GetFullPath(Path.Combine("Pages", "Shared", "_Layout.cshtml"), projectRoot); 66 | if (File.Exists(layoutPath)) 67 | { 68 | return layoutPath; 69 | } 70 | 71 | // "mvc" template 72 | layoutPath = Path.GetFullPath(Path.Combine("Views", "Shared", "_Layout.cshtml"), projectRoot); 73 | if (File.Exists(layoutPath)) 74 | { 75 | return layoutPath; 76 | } 77 | 78 | // "blazorserver" template 79 | layoutPath = Path.GetFullPath(Path.Combine("Pages", "_Host.cshtml"), projectRoot); 80 | if (File.Exists(layoutPath)) 81 | { 82 | return layoutPath; 83 | } 84 | 85 | // It's named same way don't need to include app.css reference in layout 86 | // "blazorwasm" template 87 | //layoutPath = Path.GetFullPath(Path.Combine("wwwroot", "index.html"), projectRoot); 88 | //if (File.Exists(layoutPath)) 89 | //{ 90 | // return layoutPath; 91 | //} 92 | 93 | return null; 94 | } 95 | 96 | private static string Indent(int count) 97 | { 98 | var indent = new string(' ', count); 99 | return indent; 100 | } 101 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Install/app.tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Program.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine.Builder; 2 | using System.CommandLine.Parsing; 3 | using Tailwindcss.DotNetTool; 4 | using Tailwindcss.DotNetTool.Cli; 5 | using Tailwindcss.DotNetTool.Commands; 6 | 7 | Console.OutputEncoding = System.Text.Encoding.UTF8; 8 | 9 | var appContext = new AppInvocationContext(); 10 | CommandLineBuilder builder = new CommandLineBuilder 11 | { 12 | Command = 13 | { 14 | Description = $"A dotnet tool for installing and invoking Tailwind CSS. Default version of Tailwind CSS CLI is {Upstream.Version}" 15 | } 16 | }; 17 | foreach (var command in AppCommands.Instance.All) 18 | { 19 | command.Setup(builder, appContext); 20 | } 21 | 22 | builder.UseDefaults(); 23 | 24 | builder.UseContextInitializer(appContext); 25 | builder.UseExecRun(appContext); 26 | builder.UseProjectOption(appContext); 27 | 28 | var parser = builder.Build(); 29 | 30 | return await parser.InvokeAsync(args, appContext.Console); 31 | -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/ProjectInfo.cs: -------------------------------------------------------------------------------- 1 | namespace Tailwindcss.DotNetTool; 2 | 3 | public class ProjectInfo 4 | { 5 | public string? ProjectRoot { get; set; } 6 | 7 | public string? ProjectFilePath { get; set; } 8 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Tailwindcss.DotNetTool": { 4 | "commandName": "Project", 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /src/tailwindcss-dotnet/tailwindcss-dotnet.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | 6 | true 7 | tailwind 8 | Tailwindcss.DotNetTool 9 | 10 | A dotnet tool for installing and invoking Tailwind CSS. 11 | 12 | 13 | net8.0;net9.0 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | --------------------------------------------------------------------------------