├── demos ├── net8 │ ├── Components │ │ ├── Pages │ │ │ ├── Error.razor │ │ │ └── Home.razor │ │ ├── Routes.razor │ │ ├── Layout │ │ │ └── MainLayout.razor │ │ ├── _Imports.razor │ │ └── App.razor │ ├── wwwroot │ │ ├── favicon.png │ │ ├── app.css │ │ └── css │ │ │ └── output.css │ ├── appsettings.json │ ├── appsettings.Development.json │ ├── SampleApp.csproj │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ └── Styles │ │ └── input.css └── Tailwind.Extensions.DemoApps.sln ├── src ├── Tailwind.Extensions.AspNetCore.Tests │ ├── GlobalUsings.cs │ ├── TailwindProcessInteropTests.cs │ ├── Tailwind.Extensions.AspNetCore.Tests.csproj │ └── TailwindCliHostedServiceTests.cs └── Tailwind.Extensions.AspNetCore │ ├── Guard.cs │ ├── Util │ ├── TcpPortFinder.cs │ ├── LoggerFinder.cs │ ├── TaskTimeoutExtensions.cs │ ├── EventedStreamStringReader.cs │ └── EventedStreamReader.cs │ ├── Tailwind.Extensions.AspNetCore.csproj │ ├── TailwindMiddlewareExtensions.cs │ ├── TailwindMiddleware.cs │ ├── TailwindHostedService.cs │ ├── TailwindProcessInterop.cs │ └── Npm │ └── NodeScriptRunner.cs ├── NuGet.config ├── .gitignore ├── Push.ps1 ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── LICENSE ├── Build.ps1 ├── Tailwind.Extensions.AspNetCore.sln ├── Tailwind.Extensions.AspNetCore.sln.DotSettings.user ├── FINDINGS.md ├── README.md └── .junie └── guidelines.md /demos/net8/Components/Pages/Error.razor: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore.Tests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/net8/wwwroot/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Practical-ASP-NET/Tailwind.Extensions.AspNetCore/HEAD/demos/net8/wwwroot/favicon.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | /packages/ 4 | riderModule.iml 5 | /_ReSharper.Caches/ 6 | /.idea/ 7 | /artifacts 8 | **/node_modules/ 9 | /src/SampleApps/BlazorServer/wwwroot/css/output.css 10 | .vs 11 | .idea -------------------------------------------------------------------------------- /demos/net8/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /demos/net8/Components/Pages/Home.razor: -------------------------------------------------------------------------------- 1 | @page "/" 2 | 3 | Home 4 | 5 |

Hello, world!

6 | 7 |

Welcome to your new app.

-------------------------------------------------------------------------------- /demos/net8/Components/Routes.razor: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /demos/net8/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "Tailwind": { 9 | "InputFile": "./Styles/input.css", 10 | "OutputFile": "./wwwroot/css/output.css", 11 | "TailwindCliPath": "" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /demos/net8/Components/Layout/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | 3 |
4 |
5 |
6 | @Body 7 |
8 |
9 |
10 | 11 |
12 | An unhandled error has occurred. 13 | Reload 14 | 🗙 15 |
-------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore/Guard.cs: -------------------------------------------------------------------------------- 1 | namespace Tailwind; 2 | 3 | public static class Guard 4 | { 5 | public static void AgainstNull(string? parameterName, string? errorMessage = "") 6 | { 7 | if (string.IsNullOrEmpty(parameterName)) 8 | { 9 | throw new ArgumentNullException($"{parameterName} must not be null or white space. ${errorMessage}"); 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /demos/net8/Components/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using System.Net.Http.Json 3 | @using Microsoft.AspNetCore.Components.Forms 4 | @using Microsoft.AspNetCore.Components.Routing 5 | @using Microsoft.AspNetCore.Components.Web 6 | @using static Microsoft.AspNetCore.Components.Web.RenderMode 7 | @using Microsoft.AspNetCore.Components.Web.Virtualization 8 | @using Microsoft.JSInterop 9 | @using BlazorApp1 10 | @using BlazorApp1.Components -------------------------------------------------------------------------------- /demos/net8/SampleApp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | BlazorApp1 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /demos/net8/Components/App.razor: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Push.ps1: -------------------------------------------------------------------------------- 1 | # Taken from MediatR.Extensions.DependencyInjection https://github.com/jbogard/MediatR.Extensions.Microsoft.DependencyInjection/ 2 | 3 | $scriptName = $MyInvocation.MyCommand.Name 4 | $artifacts = "./artifacts" 5 | 6 | if ([string]::IsNullOrEmpty($Env:NUGET_API_KEY)) { 7 | Write-Host "${scriptName}: NUGET_API_KEY is empty or not set. Skipped pushing package(s)." 8 | } else { 9 | Get-ChildItem $artifacts -Filter "*.nupkg" | ForEach-Object { 10 | Write-Host "$($scriptName): Pushing $($_.Name)" 11 | dotnet nuget push $_ --source $Env:NUGET_URL --api-key $Env:NUGET_API_KEY 12 | if ($lastexitcode -ne 0) { 13 | throw ("Exec: " + $errorMessage) 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore/Util/TcpPortFinder.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | using System.Net; 5 | using System.Net.Sockets; 6 | 7 | namespace Microsoft.AspNetCore.SpaServices.Util; 8 | 9 | internal static class TcpPortFinder 10 | { 11 | public static int FindAvailablePort() 12 | { 13 | var listener = new TcpListener(IPAddress.Loopback, 0); 14 | listener.Start(); 15 | try 16 | { 17 | return ((IPEndPoint)listener.LocalEndpoint).Port; 18 | } 19 | finally 20 | { 21 | listener.Stop(); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | - dev 9 | pull_request: 10 | branches: 11 | - master 12 | - main 13 | - dev 14 | 15 | jobs: 16 | build: 17 | runs-on: windows-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v2 21 | with: 22 | fetch-depth: 0 23 | - name: Setup .NET 6 24 | uses: actions/setup-dotnet@v4 25 | with: 26 | dotnet-version: '6.0.x' 27 | - name: Build 28 | run: ./Build.ps1 29 | shell: pwsh 30 | - name: Artifacts 31 | uses: actions/upload-artifact@v4.6.2 32 | with: 33 | name: artifacts 34 | path: artifacts/**/* 35 | -------------------------------------------------------------------------------- /demos/net8/Program.cs: -------------------------------------------------------------------------------- 1 | using BlazorApp1.Components; 2 | using Tailwind; 3 | 4 | var builder = WebApplication.CreateBuilder(args); 5 | 6 | // Add services to the container. 7 | builder.Services.AddRazorComponents() 8 | .AddInteractiveServerComponents(); 9 | 10 | builder.UseTailwindCli(); 11 | 12 | var app = builder.Build(); 13 | 14 | // Configure the HTTP request pipeline. 15 | if (!app.Environment.IsDevelopment()) 16 | { 17 | app.UseExceptionHandler("/Error", createScopeForErrors: true); 18 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. 19 | app.UseHsts(); 20 | } 21 | 22 | app.UseHttpsRedirection(); 23 | 24 | app.UseStaticFiles(); 25 | app.UseAntiforgery(); 26 | 27 | app.MapRazorComponents() 28 | .AddInteractiveServerRenderMode(); 29 | 30 | app.Run(); -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - '*.*.*' 5 | jobs: 6 | build: 7 | runs-on: windows-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v2 11 | with: 12 | fetch-depth: 0 13 | - name: Setup .NET 6 14 | uses: actions/setup-dotnet@v4 15 | with: 16 | dotnet-version: '6.0.x' 17 | - name: Build 18 | run: ./Build.ps1 19 | shell: pwsh 20 | - name: Push to NuGet 21 | env: 22 | NUGET_URL: https://api.nuget.org/v3/index.json 23 | NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} 24 | run: ./Push.ps1 25 | shell: pwsh 26 | - name: Artifacts 27 | uses: actions/upload-artifact@v4.6.2 28 | with: 29 | name: artifacts 30 | path: artifacts/**/* 31 | -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore/Util/LoggerFinder.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Logging; 7 | using Microsoft.Extensions.Logging.Abstractions; 8 | 9 | namespace Microsoft.AspNetCore.SpaServices.Util; 10 | 11 | internal static class LoggerFinder 12 | { 13 | public static ILogger GetOrCreateLogger( 14 | IApplicationBuilder appBuilder, 15 | string logCategoryName) 16 | { 17 | // If the DI system gives us a logger, use it. Otherwise, set up a default one 18 | var loggerFactory = appBuilder.ApplicationServices.GetService(); 19 | var logger = loggerFactory != null 20 | ? loggerFactory.CreateLogger(logCategoryName) 21 | : NullLogger.Instance; 22 | return logger; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore/Util/TaskTimeoutExtensions.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | namespace Microsoft.AspNetCore.SpaServices.Extensions.Util; 5 | 6 | internal static class TaskTimeoutExtensions 7 | { 8 | public static async Task WithTimeout(this Task task, TimeSpan timeoutDelay, string message) 9 | { 10 | if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay))) 11 | { 12 | task.Wait(); // Allow any errors to propagate 13 | } 14 | else 15 | { 16 | throw new TimeoutException(message); 17 | } 18 | } 19 | 20 | public static async Task WithTimeout(this Task task, TimeSpan timeoutDelay, string message) 21 | { 22 | if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay))) 23 | { 24 | return task.Result; 25 | } 26 | else 27 | { 28 | throw new TimeoutException(message); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jon Hilton 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 | -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore.Tests/TailwindProcessInteropTests.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | 3 | namespace Tailwind.Extensions.AspNetCore.Tests; 4 | 5 | public class TailwindProcessInteropTests 6 | { 7 | [Fact] 8 | public void StartProcess_ShouldRedirectStandardInput_ToAvoidConsoleHotkeyInterference() 9 | { 10 | // Arrange 11 | var interop = new TailwindProcessInterop(); 12 | 13 | string fileName; 14 | string args; 15 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 16 | { 17 | fileName = "cmd"; 18 | args = "/c echo hello"; 19 | } 20 | else 21 | { 22 | fileName = "sh"; 23 | args = "-lc 'echo hello'"; 24 | } 25 | 26 | // Act 27 | using var process = interop.StartProcess(fileName, args); 28 | 29 | // Assert 30 | Assert.NotNull(process); 31 | Assert.True(process!.StartInfo.RedirectStandardInput, "Child process should not inherit console stdin"); 32 | 33 | // Cleanup: ensure the process exits (it should be short-lived) 34 | process.WaitForExit(5000); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /demos/net8/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:1599", 8 | "sslPort": 44388 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "applicationUrl": "http://localhost:5143", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | }, 21 | "https": { 22 | "commandName": "Project", 23 | "dotnetRunMessages": true, 24 | "launchBrowser": true, 25 | "applicationUrl": "https://localhost:7159;http://localhost:5143", 26 | "environmentVariables": { 27 | "ASPNETCORE_ENVIRONMENT": "Development" 28 | } 29 | }, 30 | "IIS Express": { 31 | "commandName": "IISExpress", 32 | "launchBrowser": true, 33 | "environmentVariables": { 34 | "ASPNETCORE_ENVIRONMENT": "Development" 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore/Tailwind.Extensions.AspNetCore.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | Tailwind extensions for ASP.NET Core 8 | Tailwind 9 | Tailwind.Extensions.AspNetCore 10 | Tailwind.Extensions.AspNetCore 11 | tailwind;devtools; 12 | latest 13 | v 14 | https://github.com/Practical-ASP-NET/Tailwind.Extensions.AspNetCore 15 | https://github.com/Practical-ASP-NET/Tailwind.Extensions.AspNetCore/blob/main/LICENSE 16 | https://github.com/Practical-ASP-NET/Tailwind.Extensions.AspNetCore 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore.Tests/Tailwind.Extensions.AspNetCore.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | all 19 | 20 | 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | all 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /demos/Tailwind.Extensions.DemoApps.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleApp", "net8\SampleApp.csproj", "{84F443F1-A31B-45E6-859D-A907E36D7B87}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tailwind.Extensions.AspNetCore", "..\src\Tailwind.Extensions.AspNetCore\Tailwind.Extensions.AspNetCore.csproj", "{DD1C002C-4E93-462A-BD42-EE2CD0815FC1}" 6 | EndProject 7 | Global 8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 9 | Debug|Any CPU = Debug|Any CPU 10 | Release|Any CPU = Release|Any CPU 11 | EndGlobalSection 12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 13 | {84F443F1-A31B-45E6-859D-A907E36D7B87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 14 | {84F443F1-A31B-45E6-859D-A907E36D7B87}.Debug|Any CPU.Build.0 = Debug|Any CPU 15 | {84F443F1-A31B-45E6-859D-A907E36D7B87}.Release|Any CPU.ActiveCfg = Release|Any CPU 16 | {84F443F1-A31B-45E6-859D-A907E36D7B87}.Release|Any CPU.Build.0 = Release|Any CPU 17 | {DD1C002C-4E93-462A-BD42-EE2CD0815FC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {DD1C002C-4E93-462A-BD42-EE2CD0815FC1}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {DD1C002C-4E93-462A-BD42-EE2CD0815FC1}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {DD1C002C-4E93-462A-BD42-EE2CD0815FC1}.Release|Any CPU.Build.0 = Release|Any CPU 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /Build.ps1: -------------------------------------------------------------------------------- 1 | # Taken from MediatR.Extensions.DependencyInjection https://github.com/jbogard/MediatR.Extensions.Microsoft.DependencyInjection/ 2 | 3 | <# 4 | .SYNOPSIS 5 | This is a helper function that runs a scriptblock and checks the PS variable $lastexitcode 6 | to see if an error occcured. If an error is detected then an exception is thrown. 7 | This function allows you to run command-line programs without having to 8 | explicitly check the $lastexitcode variable. 9 | .EXAMPLE 10 | exec { svn info $repository_trunk } "Error executing SVN. Please verify SVN command-line client is installed" 11 | #> 12 | function Exec 13 | { 14 | [CmdletBinding()] 15 | param( 16 | [Parameter(Position=0,Mandatory=1)][scriptblock]$cmd, 17 | [Parameter(Position=1,Mandatory=0)][string]$errorMessage = ($msgs.error_bad_command -f $cmd) 18 | ) 19 | & $cmd 20 | if ($lastexitcode -ne 0) { 21 | throw ("Exec: " + $errorMessage) 22 | } 23 | } 24 | 25 | $artifacts = ".\artifacts" 26 | 27 | if(Test-Path $artifacts) { Remove-Item $artifacts -Force -Recurse } 28 | 29 | exec { & dotnet clean -c Release } 30 | 31 | exec { & dotnet build -c Release } 32 | 33 | exec { & dotnet test .\src\Tailwind.Extensions.AspNetCore.Tests\Tailwind.Extensions.AspNetCore.Tests.csproj -c Release --no-build -l trx --verbosity=normal } 34 | 35 | exec { dotnet pack .\src\Tailwind.Extensions.AspNetCore -c Release -o $artifacts --no-build } -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore/Util/EventedStreamStringReader.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | using System.Text; 5 | 6 | namespace Microsoft.AspNetCore.NodeServices.Util; 7 | 8 | /// 9 | /// Captures the completed-line notifications from a , 10 | /// combining the data into a single . 11 | /// 12 | internal class EventedStreamStringReader : IDisposable 13 | { 14 | private readonly EventedStreamReader _eventedStreamReader; 15 | private bool _isDisposed; 16 | private readonly StringBuilder _stringBuilder = new StringBuilder(); 17 | 18 | public EventedStreamStringReader(EventedStreamReader eventedStreamReader) 19 | { 20 | _eventedStreamReader = eventedStreamReader 21 | ?? throw new ArgumentNullException(nameof(eventedStreamReader)); 22 | _eventedStreamReader.OnReceivedLine += OnReceivedLine; 23 | } 24 | 25 | public string ReadAsString() => _stringBuilder.ToString(); 26 | 27 | private void OnReceivedLine(string line) => _stringBuilder.AppendLine(line); 28 | 29 | public void Dispose() 30 | { 31 | if (!_isDisposed) 32 | { 33 | _eventedStreamReader.OnReceivedLine -= OnReceivedLine; 34 | _isDisposed = true; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tailwind.Extensions.AspNetCore.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tailwind.Extensions.AspNetCore", "src\Tailwind.Extensions.AspNetCore\Tailwind.Extensions.AspNetCore.csproj", "{3A29D45C-D8D5-4D52-8E4E-F295F3EF14C3}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tailwind.Extensions.AspNetCore.Tests", "src\Tailwind.Extensions.AspNetCore.Tests\Tailwind.Extensions.AspNetCore.Tests.csproj", "{FF0BEEBB-F708-4F4B-9839-A82AE71835C4}" 6 | EndProject 7 | Global 8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 9 | Debug|Any CPU = Debug|Any CPU 10 | Release|Any CPU = Release|Any CPU 11 | EndGlobalSection 12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 13 | {3A29D45C-D8D5-4D52-8E4E-F295F3EF14C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 14 | {3A29D45C-D8D5-4D52-8E4E-F295F3EF14C3}.Debug|Any CPU.Build.0 = Debug|Any CPU 15 | {3A29D45C-D8D5-4D52-8E4E-F295F3EF14C3}.Release|Any CPU.ActiveCfg = Release|Any CPU 16 | {3A29D45C-D8D5-4D52-8E4E-F295F3EF14C3}.Release|Any CPU.Build.0 = Release|Any CPU 17 | {FF0BEEBB-F708-4F4B-9839-A82AE71835C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {FF0BEEBB-F708-4F4B-9839-A82AE71835C4}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {FF0BEEBB-F708-4F4B-9839-A82AE71835C4}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {FF0BEEBB-F708-4F4B-9839-A82AE71835C4}.Release|Any CPU.Build.0 = Release|Any CPU 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /Tailwind.Extensions.AspNetCore.sln.DotSettings.user: -------------------------------------------------------------------------------- 1 | 2 | ForceIncluded 3 | <SessionState ContinuousTestingMode="0" IsActive="True" Name="MissingOptionsThrowsError" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> 4 | <Project Location="C:\Users\hilto\RiderProjects\Tailwind.Extensions.AspNetCore\src\Tailwind.Extensions.AspNetCore.Tests" Presentation="&lt;Tailwind.Extensions.AspNetCore.Tests&gt;" /> 5 | </SessionState> 6 | <SessionState ContinuousTestingMode="0" Name="Junie Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> 7 | <Namespace>Tailwind.Extensions.AspNetCore.Tests</Namespace> 8 | </SessionState> 9 | -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore/TailwindMiddlewareExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace Tailwind; 5 | 6 | /// 7 | /// Extension methods for enabling tailwind middleware support. 8 | /// 9 | public static class TailwindMiddlewareExtensions 10 | { 11 | /// 12 | /// Automatically runs an npm script 13 | /// This feature should probably only be used in development. 14 | /// 15 | /// The . 16 | /// The name of the script in your package.json file that launches Tailwind. 17 | /// The directory where your package.json file resides 18 | public static Task RunTailwind( 19 | this IApplicationBuilder applicationBuilder, 20 | string npmScript, string workingDir = "./") 21 | { 22 | if (applicationBuilder == null) 23 | { 24 | throw new ArgumentNullException(nameof(applicationBuilder)); 25 | } 26 | 27 | return TailwindMiddleware.Attach(applicationBuilder, npmScript, workingDir); 28 | } 29 | 30 | /// 31 | /// Run the Tailwind Cli in watch mode in the background (Development environments only 32 | /// 33 | /// 34 | public static void UseTailwindCli(this WebApplicationBuilder webApplicationBuilder) 35 | { 36 | webApplicationBuilder.Services.Configure(webApplicationBuilder.Configuration.GetSection("Tailwind")); 37 | webApplicationBuilder.Services.AddSingleton(); 38 | webApplicationBuilder.Services.AddHostedService(); 39 | } 40 | } -------------------------------------------------------------------------------- /demos/net8/Styles/input.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | .valid.modified:not([type=checkbox]) { 4 | outline: 1px solid #26b050; 5 | } 6 | 7 | .invalid { 8 | outline: 1px solid red; 9 | } 10 | 11 | .validation-message { 12 | color: red; 13 | } 14 | 15 | #blazor-error-ui { 16 | background: lightyellow; 17 | bottom: 0; 18 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); 19 | display: none; 20 | left: 0; 21 | padding: 0.6rem 1.25rem 0.7rem 1.25rem; 22 | position: fixed; 23 | width: 100%; 24 | z-index: 1000; 25 | } 26 | 27 | #blazor-error-ui .dismiss { 28 | cursor: pointer; 29 | position: absolute; 30 | right: 0.75rem; 31 | top: 0.5rem; 32 | } 33 | 34 | .blazor-error-boundary { 35 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; 36 | padding: 1rem 1rem 1rem 3.7rem; 37 | color: white; 38 | } 39 | 40 | .blazor-error-boundary::after { 41 | content: "An error has occurred." 42 | } -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore/TailwindMiddleware.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | using System.Diagnostics; 5 | using System.Text.RegularExpressions; 6 | using Microsoft.AspNetCore.Builder; 7 | using Microsoft.AspNetCore.NodeServices.Npm; 8 | using Microsoft.AspNetCore.NodeServices.Util; 9 | using Microsoft.AspNetCore.SpaServices.Util; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using Microsoft.Extensions.Hosting; 12 | using Microsoft.Extensions.Logging; 13 | 14 | 15 | namespace Tailwind; 16 | 17 | internal static class TailwindMiddleware 18 | { 19 | private const string LogCategoryName = "NodeServices"; 20 | 21 | private static readonly TimeSpan 22 | RegexMatchTimeout = 23 | TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine 24 | 25 | public static async Task Attach(IApplicationBuilder appBuilder, string scriptName, string workingDir) 26 | { 27 | var applicationStoppingToken = appBuilder.ApplicationServices.GetRequiredService() 28 | .ApplicationStopping; 29 | var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName); 30 | var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService(); 31 | await ExecuteScript(workingDir, scriptName, logger, diagnosticSource, applicationStoppingToken); 32 | } 33 | 34 | private static async Task ExecuteScript( 35 | string sourcePath, string scriptName, ILogger logger, 36 | DiagnosticSource diagnosticSource, CancellationToken applicationStoppingToken) 37 | { 38 | var envVars = new Dictionary() { }; 39 | 40 | var scriptRunner = new NodeScriptRunner( 41 | sourcePath, scriptName, null, envVars, "npm", diagnosticSource, applicationStoppingToken); 42 | scriptRunner.AttachToLogger(logger, true); 43 | 44 | using var stdErrReader = new EventedStreamStringReader(scriptRunner.StdErr); 45 | try 46 | { 47 | await scriptRunner.StdOut.WaitForMatch( 48 | new Regex("Done in", RegexOptions.None, RegexMatchTimeout)); 49 | } 50 | catch (EndOfStreamException ex) 51 | { 52 | throw new InvalidOperationException( 53 | $"The npm script '{scriptName}' exited without indicating that the " + 54 | "Tailwind CLI had finished. The error output was: " + 55 | $"{stdErrReader.ReadAsString()}", ex); 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /demos/net8/wwwroot/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | } 4 | 5 | a, .btn-link { 6 | color: #006bb7; 7 | } 8 | 9 | .btn-primary { 10 | color: #fff; 11 | background-color: #1b6ec2; 12 | border-color: #1861ac; 13 | } 14 | 15 | .btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { 16 | box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; 17 | } 18 | 19 | .content { 20 | padding-top: 1.1rem; 21 | } 22 | 23 | h1:focus { 24 | outline: none; 25 | } 26 | 27 | .valid.modified:not([type=checkbox]) { 28 | outline: 1px solid #26b050; 29 | } 30 | 31 | .invalid { 32 | outline: 1px solid #e50000; 33 | } 34 | 35 | .validation-message { 36 | color: #e50000; 37 | } 38 | 39 | .blazor-error-boundary { 40 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; 41 | padding: 1rem 1rem 1rem 3.7rem; 42 | color: white; 43 | } 44 | 45 | .blazor-error-boundary::after { 46 | content: "An error has occurred." 47 | } 48 | 49 | .darker-border-checkbox.form-check-input { 50 | border-color: #929292; 51 | } 52 | -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore/TailwindHostedService.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using Microsoft.Extensions.Hosting; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.Extensions.Options; 5 | 6 | namespace Tailwind; 7 | 8 | public class TailwindOptions 9 | { 10 | public string? InputFile { get; set; } 11 | public string? OutputFile { get; set; } 12 | public string? TailwindCliPath { get; set; } 13 | public string AdditionalArguments { get; set; } = string.Empty; 14 | } 15 | 16 | /// 17 | /// Launches the Tailwind CLI in watch mode (if the CLI is installed and exists in the system Path) 18 | /// Runs as a background service (will be shut down when the app is stopped) 19 | /// Only works in development environments 20 | /// 21 | public class TailwindHostedService : IHostedService, IDisposable 22 | { 23 | private Process? _process; 24 | private readonly IHostEnvironment _hostEnvironment; 25 | private readonly TailwindOptions _options; 26 | private readonly ITailwindProcessInterop _tailwindProcess; 27 | private readonly ILogger _logger; 28 | 29 | public TailwindHostedService(IOptions options, IHostEnvironment hostEnvironment, 30 | ITailwindProcessInterop tailwindProcess, ILogger logger) 31 | { 32 | _logger = logger; 33 | _tailwindProcess = tailwindProcess; 34 | _options = options.Value; 35 | _hostEnvironment = hostEnvironment; 36 | } 37 | 38 | public Task StartAsync(CancellationToken cancellationToken) 39 | { 40 | // don't want this running in production, only useful for development time generation of the Tailwind file 41 | // use another mechanism such as CI/CD to generate the production-ready CSS style sheet 42 | if (!_hostEnvironment.IsDevelopment()) 43 | return Task.CompletedTask; 44 | 45 | var input = _options.InputFile; 46 | var output = _options.OutputFile; 47 | 48 | Guard.AgainstNull(input, "check Tailwind configuration"); 49 | Guard.AgainstNull(output, "check Tailwind configuration"); 50 | 51 | var args = $"-i {input} -o {output} --watch"; 52 | if (!string.IsNullOrEmpty(_options.AdditionalArguments)) 53 | { 54 | args += $" {_options.AdditionalArguments}"; 55 | } 56 | 57 | _logger.LogInformation($"tailwind {args}"); 58 | 59 | var processName = string.IsNullOrEmpty(_options.TailwindCliPath) ? "tailwind" : _options.TailwindCliPath; 60 | _process = _tailwindProcess.StartProcess(processName, args); 61 | 62 | return Task.CompletedTask; 63 | } 64 | 65 | public Task StopAsync(CancellationToken cancellationToken) 66 | { 67 | _process?.Kill(); 68 | return Task.CompletedTask; 69 | } 70 | 71 | public void Dispose() 72 | { 73 | _process?.Dispose(); 74 | } 75 | } -------------------------------------------------------------------------------- /FINDINGS.md: -------------------------------------------------------------------------------- 1 | # Findings: Console hotkeys blocked by Tailwind CLI background process 2 | 3 | Date: 2025-09-14 4 | 5 | ## Summary 6 | When running an ASP.NET Core app with `dotnet watch`, console hotkeys such as `Ctrl+R` (restart) were not working after enabling the Tailwind hosted service. The Tailwind process was started in the background, but it inherited the parent console's standard input (stdin). As a result, keyboard input could be observed by the child process and not by `dotnet watch`, effectively blocking or interfering with hotkeys. 7 | 8 | ## Root cause 9 | - The hosted service launches Tailwind via `TailwindProcessInterop.StartProcess`. 10 | - That method configured `ProcessStartInfo` to redirect standard output and error, but not standard input. 11 | - With `UseShellExecute = false` and without `RedirectStandardInput = true`, the child process inherits the console stdin handle from the parent process. Some CLIs (including Node/Tailwind) can attach to or read from stdin in watch mode, which prevents `dotnet watch` from receiving hotkey keystrokes. 12 | 13 | ## Fix 14 | - Set `RedirectStandardInput = true` when starting the Tailwind process. This connects the child process stdin to an anonymous pipe instead of the console, ensuring that the parent process (`dotnet watch`) remains the sole consumer of console keyboard input. 15 | 16 | Code change (TailwindProcessInterop.cs): 17 | - Added `RedirectStandardInput = true` to `ProcessStartInfo` alongside the existing stdout/stderr redirection. 18 | 19 | This preserves the existing behavior (background process, output piped back to the app console) while ensuring keyboard hotkeys keep working for the main .NET process. 20 | 21 | ## Tests 22 | - Added `TailwindProcessInteropTests.StartProcess_ShouldRedirectStandardInput_ToAvoidConsoleHotkeyInterference` to verify the interop starts a process with `RedirectStandardInput` enabled. This is a minimal, platform-aware test that executes a short-lived command and asserts the configuration is correct. 23 | - Existing tests for `TailwindHostedService` remain intact and continue to validate option handling and CLI path selection. 24 | 25 | Note: Running tests requires the .NET 6 runtime (TargetFramework=net6.0). See repository guidelines for installation or temporary multi-targeting if needed. 26 | 27 | ## How to verify manually 28 | 1. Ensure Tailwind CLI is installed and resolvable (either via PATH or `TailwindCliPath`). 29 | 2. Run your ASP.NET Core app using `dotnet watch run` with the Tailwind hosted service enabled. 30 | 3. Confirm that Tailwind output appears in the console as before. 31 | 4. Press `Ctrl+R` or other `dotnet watch` hotkeys. 32 | - Expected: hotkeys are recognized by `dotnet watch` (e.g., app restarts) while Tailwind continues to run in the background. 33 | 34 | ## Considerations 35 | - We kept `UseShellExecute = false` to allow stream redirection and `CreateNoWindow = true` so the Tailwind process does not spawn a separate console window. 36 | - No breaking changes to the public API were introduced. The fix is internal to process start configuration. 37 | -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore.Tests/TailwindCliHostedServiceTests.cs: -------------------------------------------------------------------------------- 1 | using FakeItEasy; 2 | using Microsoft.Extensions.Hosting; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.Extensions.Options; 5 | 6 | namespace Tailwind.Extensions.AspNetCore.Tests; 7 | 8 | public class TailwindCliHostedServiceTests 9 | { 10 | [Fact] 11 | public async Task MissingOptionsShouldThrowException() 12 | { 13 | var tailwindOptions = new TailwindOptions 14 | { 15 | InputFile = "", // empty InputFile 16 | OutputFile = "", // empty OutputFile 17 | TailwindCliPath = null // null TailwindCliPath 18 | }; 19 | var options = Options.Create(tailwindOptions); 20 | var tailwindProcess = A.Fake(); 21 | var hostEnvironment = A.Fake(); 22 | var logger = A.Fake>(); 23 | 24 | A.CallTo(() => hostEnvironment.EnvironmentName).Returns(Environments.Development); 25 | var tailwindHostedService = new TailwindHostedService(options, hostEnvironment, tailwindProcess, logger); 26 | 27 | await Assert.ThrowsAsync(async () => 28 | await tailwindHostedService.StartAsync(CancellationToken.None)); 29 | } 30 | 31 | [Theory] 32 | [InlineData(null, "tailwind")] 33 | [InlineData("", "tailwind")] 34 | [InlineData("tailwindoverride.exe", "tailwindoverride.exe")] 35 | [InlineData("tailwindoverride", "tailwindoverride")] 36 | public async Task SpecifiedTailwindCliPathOverridesDefault(string tailwindCliPath, string expectedCliPath) 37 | { 38 | var tailwindOptions = new TailwindOptions 39 | { 40 | InputFile = "input.css", // empty InputFile 41 | OutputFile = "output.css", // empty OutputFile 42 | TailwindCliPath = tailwindCliPath // null TailwindCliPath 43 | }; 44 | var options = Options.Create(tailwindOptions); 45 | var tailwindProcess = A.Fake(); 46 | var hostEnvironment = A.Fake(); 47 | var logger = A.Fake>(); 48 | 49 | A.CallTo(() => hostEnvironment.EnvironmentName).Returns(Environments.Development); 50 | 51 | var tailwindHostedService = new TailwindHostedService(options, hostEnvironment, tailwindProcess, logger); 52 | await tailwindHostedService.StartAsync(CancellationToken.None); 53 | 54 | A.CallTo(() => tailwindProcess.StartProcess(expectedCliPath,A.Ignored)).MustHaveHappened(); 55 | } 56 | 57 | [Fact] 58 | public async Task AdditionalArgumentsAreIncludedInProcessStart() 59 | { 60 | var tailwindOptions = new TailwindOptions 61 | { 62 | InputFile = "input.css", 63 | OutputFile = "output.css", 64 | TailwindCliPath = null, 65 | AdditionalArguments = "--verbose" 66 | }; 67 | var options = Options.Create(tailwindOptions); 68 | var tailwindProcess = A.Fake(); 69 | var hostEnvironment = A.Fake(); 70 | var logger = A.Fake>(); 71 | 72 | A.CallTo(() => hostEnvironment.EnvironmentName).Returns(Environments.Development); 73 | 74 | var tailwindHostedService = new TailwindHostedService(options, hostEnvironment, tailwindProcess, logger); 75 | await tailwindHostedService.StartAsync(CancellationToken.None); 76 | 77 | A.CallTo(() => tailwindProcess.StartProcess( 78 | A.Ignored, 79 | A.That.Contains("--verbose"))).MustHaveHappened(); 80 | } 81 | } -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore/Util/EventedStreamReader.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | using System.Text; 5 | using System.Text.RegularExpressions; 6 | 7 | namespace Microsoft.AspNetCore.NodeServices.Util; 8 | 9 | /// 10 | /// Wraps a to expose an evented API, issuing notifications 11 | /// when the stream emits partial lines, completed lines, or finally closes. 12 | /// 13 | internal class EventedStreamReader 14 | { 15 | public delegate void OnReceivedChunkHandler(ArraySegment chunk); 16 | public delegate void OnReceivedLineHandler(string line); 17 | public delegate void OnStreamClosedHandler(); 18 | 19 | public event OnReceivedChunkHandler? OnReceivedChunk; 20 | public event OnReceivedLineHandler? OnReceivedLine; 21 | public event OnStreamClosedHandler? OnStreamClosed; 22 | 23 | private readonly StreamReader _streamReader; 24 | private readonly StringBuilder _linesBuffer; 25 | 26 | public EventedStreamReader(StreamReader streamReader) 27 | { 28 | _streamReader = streamReader ?? throw new ArgumentNullException(nameof(streamReader)); 29 | _linesBuffer = new StringBuilder(); 30 | Task.Factory.StartNew(Run, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); 31 | } 32 | 33 | public Task WaitForMatch(Regex regex) 34 | { 35 | var tcs = new TaskCompletionSource(); 36 | var completionLock = new object(); 37 | 38 | OnReceivedLineHandler? onReceivedLineHandler = null; 39 | OnStreamClosedHandler? onStreamClosedHandler = null; 40 | 41 | void ResolveIfStillPending(Action applyResolution) 42 | { 43 | lock (completionLock) 44 | { 45 | if (!tcs.Task.IsCompleted) 46 | { 47 | OnReceivedLine -= onReceivedLineHandler; 48 | OnStreamClosed -= onStreamClosedHandler; 49 | applyResolution(); 50 | } 51 | } 52 | } 53 | 54 | onReceivedLineHandler = line => 55 | { 56 | var match = regex.Match(line); 57 | if (match.Success) 58 | { 59 | ResolveIfStillPending(() => tcs.SetResult(match)); 60 | } 61 | }; 62 | 63 | onStreamClosedHandler = () => 64 | { 65 | ResolveIfStillPending(() => tcs.SetException(new EndOfStreamException())); 66 | }; 67 | 68 | OnReceivedLine += onReceivedLineHandler; 69 | OnStreamClosed += onStreamClosedHandler; 70 | 71 | return tcs.Task; 72 | } 73 | 74 | private async Task Run() 75 | { 76 | var buf = new char[8 * 1024]; 77 | while (true) 78 | { 79 | var chunkLength = await _streamReader.ReadAsync(buf, 0, buf.Length); 80 | if (chunkLength == 0) 81 | { 82 | if (_linesBuffer.Length > 0) 83 | { 84 | OnCompleteLine(_linesBuffer.ToString()); 85 | _linesBuffer.Clear(); 86 | } 87 | 88 | OnClosed(); 89 | break; 90 | } 91 | 92 | OnChunk(new ArraySegment(buf, 0, chunkLength)); 93 | 94 | int lineBreakPos; 95 | var startPos = 0; 96 | 97 | // get all the newlines 98 | while ((lineBreakPos = Array.IndexOf(buf, '\n', startPos, chunkLength - startPos)) >= 0 && startPos < chunkLength) 99 | { 100 | var length = (lineBreakPos + 1) - startPos; 101 | _linesBuffer.Append(buf, startPos, length); 102 | OnCompleteLine(_linesBuffer.ToString()); 103 | _linesBuffer.Clear(); 104 | startPos = lineBreakPos + 1; 105 | } 106 | 107 | // get the rest 108 | if (lineBreakPos < 0 && startPos < chunkLength) 109 | { 110 | _linesBuffer.Append(buf, startPos, chunkLength - startPos); 111 | } 112 | } 113 | } 114 | 115 | private void OnChunk(ArraySegment chunk) 116 | { 117 | var dlg = OnReceivedChunk; 118 | dlg?.Invoke(chunk); 119 | } 120 | 121 | private void OnCompleteLine(string line) 122 | { 123 | var dlg = OnReceivedLine; 124 | dlg?.Invoke(line); 125 | } 126 | 127 | private void OnClosed() 128 | { 129 | var dlg = OnStreamClosed; 130 | dlg?.Invoke(); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore/TailwindProcessInterop.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Runtime.InteropServices; 3 | 4 | namespace Tailwind; 5 | 6 | public interface ITailwindProcessInterop 7 | { 8 | Process? StartProcess(string command, string arguments); 9 | } 10 | 11 | public class TailwindProcessInterop : ITailwindProcessInterop 12 | { 13 | public Process? StartProcess(string command, string arguments) 14 | { 15 | string cmdName; 16 | if (!command.EndsWith(".exe") && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 17 | { 18 | cmdName = $"{command}.exe"; 19 | } 20 | else 21 | cmdName = command; 22 | 23 | try 24 | { 25 | var process = Start(arguments, cmdName); 26 | 27 | if (process == null) 28 | { 29 | Console.WriteLine($"Could not start process: {cmdName}"); 30 | return null; 31 | } 32 | 33 | process.OutputDataReceived += (sender, data) => 34 | { 35 | if (!string.IsNullOrEmpty(data.Data)) 36 | { 37 | Console.WriteLine("tailwind: " + data.Data); 38 | } 39 | }; 40 | 41 | process.ErrorDataReceived += (sender, data) => 42 | { 43 | if (!string.IsNullOrEmpty(data.Data)) 44 | { 45 | Console.WriteLine("tailwind: " + data.Data); 46 | } 47 | }; 48 | 49 | process.BeginOutputReadLine(); 50 | process.BeginErrorReadLine(); 51 | 52 | return process; 53 | } 54 | catch (Exception ex) 55 | { 56 | Console.WriteLine($"Error starting process: {ex.Message}"); 57 | throw; 58 | } 59 | } 60 | 61 | private static IEnumerable FindExecutablesInPath(string pattern) 62 | { 63 | var executables = new List(); 64 | var pathEnv = Environment.GetEnvironmentVariable("PATH"); 65 | if (string.IsNullOrEmpty(pathEnv)) return executables; 66 | var pathDirs = pathEnv.Split(Path.PathSeparator); 67 | foreach (var dir in pathDirs) 68 | { 69 | if (!Directory.Exists(dir)) continue; 70 | try 71 | { 72 | var files = Directory.GetFiles(dir, pattern, SearchOption.TopDirectoryOnly); 73 | foreach (var file in files) 74 | { 75 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !file.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) 76 | continue; 77 | executables.Add(file); 78 | } 79 | } 80 | catch { /* ignore access issues */ } 81 | } 82 | return executables; 83 | } 84 | 85 | private static IEnumerable FindExecutablesInCurrentDirectory(string pattern) 86 | { 87 | var executables = new List(); 88 | var dir = Directory.GetCurrentDirectory(); 89 | try 90 | { 91 | var files = Directory.GetFiles(dir, pattern, SearchOption.TopDirectoryOnly); 92 | foreach (var file in files) 93 | { 94 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !file.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) 95 | continue; 96 | executables.Add(file); 97 | } 98 | } 99 | catch { /* ignore access issues */ } 100 | return executables; 101 | } 102 | 103 | private static Process? Start(string arguments, string cmdName) 104 | { 105 | var startInfo = new ProcessStartInfo 106 | { 107 | Arguments = arguments, 108 | RedirectStandardOutput = true, 109 | RedirectStandardError = true, 110 | RedirectStandardInput = true, 111 | UseShellExecute = false, 112 | CreateNoWindow = true, 113 | WorkingDirectory = null 114 | }; 115 | 116 | var commandsToTry = new List { cmdName }; 117 | 118 | commandsToTry.AddRange(FindExecutablesInPath("*tailwind*")); 119 | commandsToTry.AddRange(FindExecutablesInCurrentDirectory(cmdName)); 120 | 121 | if (cmdName.EndsWith(".exe")) 122 | { 123 | var baseCommand = cmdName.Substring(0, cmdName.Length - 4); 124 | commandsToTry.Add(baseCommand); 125 | commandsToTry.AddRange(FindExecutablesInCurrentDirectory(baseCommand)); 126 | } 127 | 128 | foreach (var command in commandsToTry.Distinct()) 129 | { 130 | try 131 | { 132 | startInfo.FileName = command; 133 | var process = Process.Start(startInfo); 134 | if (process == null) continue; 135 | 136 | Console.WriteLine("Started process: " + command); 137 | return process; 138 | } 139 | catch (Exception) 140 | { 141 | // Continue to next command variant 142 | } 143 | } 144 | 145 | return null; 146 | } 147 | 148 | } -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore/Npm/NodeScriptRunner.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | using System.Diagnostics; 5 | using System.Text.RegularExpressions; 6 | using Microsoft.AspNetCore.NodeServices.Util; 7 | using Microsoft.Extensions.Logging; 8 | 9 | // This is under the NodeServices namespace because post 2.1 it will be moved to that package 10 | namespace Microsoft.AspNetCore.NodeServices.Npm; 11 | 12 | /// 13 | /// Executes the script entries defined in a package.json file, 14 | /// capturing any output written to stdio. 15 | /// 16 | internal class NodeScriptRunner : IDisposable 17 | { 18 | private Process? _npmProcess; 19 | public EventedStreamReader StdOut { get; } 20 | public EventedStreamReader StdErr { get; } 21 | 22 | private static readonly Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1)); 23 | 24 | public NodeScriptRunner(string workingDirectory, string scriptName, string? arguments, IDictionary? envVars, string pkgManagerCommand, DiagnosticSource diagnosticSource, CancellationToken applicationStoppingToken) 25 | { 26 | if (string.IsNullOrEmpty(workingDirectory)) 27 | { 28 | throw new ArgumentException("Cannot be null or empty.", nameof(workingDirectory)); 29 | } 30 | 31 | if (string.IsNullOrEmpty(scriptName)) 32 | { 33 | throw new ArgumentException("Cannot be null or empty.", nameof(scriptName)); 34 | } 35 | 36 | if (string.IsNullOrEmpty(pkgManagerCommand)) 37 | { 38 | throw new ArgumentException("Cannot be null or empty.", nameof(pkgManagerCommand)); 39 | } 40 | 41 | var exeToRun = pkgManagerCommand; 42 | var completeArguments = $"run {scriptName} -- {arguments ?? string.Empty}"; 43 | if (OperatingSystem.IsWindows()) 44 | { 45 | // On Windows, the node executable is a .cmd file, so it can't be executed 46 | // directly (except with UseShellExecute=true, but that's no good, because 47 | // it prevents capturing stdio). So we need to invoke it via "cmd /c". 48 | exeToRun = "cmd"; 49 | completeArguments = $"/c {pkgManagerCommand} {completeArguments}"; 50 | } 51 | 52 | var processStartInfo = new ProcessStartInfo(exeToRun) 53 | { 54 | Arguments = completeArguments, 55 | UseShellExecute = false, 56 | RedirectStandardInput = true, 57 | RedirectStandardOutput = true, 58 | RedirectStandardError = true, 59 | WorkingDirectory = workingDirectory 60 | }; 61 | 62 | if (envVars != null) 63 | { 64 | foreach (var keyValuePair in envVars) 65 | { 66 | processStartInfo.Environment[keyValuePair.Key] = keyValuePair.Value; 67 | } 68 | } 69 | 70 | _npmProcess = LaunchNodeProcess(processStartInfo, pkgManagerCommand); 71 | StdOut = new EventedStreamReader(_npmProcess.StandardOutput); 72 | StdErr = new EventedStreamReader(_npmProcess.StandardError); 73 | 74 | applicationStoppingToken.Register(((IDisposable)this).Dispose); 75 | 76 | if (diagnosticSource.IsEnabled("Microsoft.AspNetCore.NodeServices.Npm.NpmStarted")) 77 | { 78 | diagnosticSource.Write( 79 | "Microsoft.AspNetCore.NodeServices.Npm.NpmStarted", 80 | new 81 | { 82 | processStartInfo = processStartInfo, 83 | process = _npmProcess 84 | }); 85 | } 86 | } 87 | 88 | public void AttachToLogger(ILogger logger, bool treatErrorsAsInfo = false) 89 | { 90 | // When the node task emits complete lines, pass them through to the real logger 91 | StdOut.OnReceivedLine += line => 92 | { 93 | if (!string.IsNullOrWhiteSpace(line)) 94 | { 95 | // Node tasks commonly emit ANSI colors, but it wouldn't make sense to forward 96 | // those to loggers (because a logger isn't necessarily any kind of terminal) 97 | logger.LogInformation(StripAnsiColors(line)); 98 | } 99 | }; 100 | 101 | StdErr.OnReceivedLine += line => 102 | { 103 | if (!string.IsNullOrWhiteSpace(line)) 104 | { 105 | if(treatErrorsAsInfo) 106 | logger.LogInformation(StripAnsiColors(line)); 107 | else 108 | logger.LogError(StripAnsiColors(line)); 109 | } 110 | }; 111 | 112 | // But when it emits incomplete lines, assume this is progress information and 113 | // hence just pass it through to StdOut regardless of logger config. 114 | StdErr.OnReceivedChunk += chunk => 115 | { 116 | Debug.Assert(chunk.Array != null); 117 | 118 | var containsNewline = Array.IndexOf( 119 | chunk.Array, '\n', chunk.Offset, chunk.Count) >= 0; 120 | if (!containsNewline) 121 | { 122 | Console.Write(chunk.Array, chunk.Offset, chunk.Count); 123 | } 124 | }; 125 | } 126 | 127 | private static string StripAnsiColors(string line) 128 | => AnsiColorRegex.Replace(line, string.Empty); 129 | 130 | private static Process LaunchNodeProcess(ProcessStartInfo startInfo, string commandName) 131 | { 132 | try 133 | { 134 | var process = Process.Start(startInfo)!; 135 | 136 | // See equivalent comment in OutOfProcessNodeInstance.cs for why 137 | process.EnableRaisingEvents = true; 138 | 139 | return process; 140 | } 141 | catch (Exception ex) 142 | { 143 | var message = $"Failed to start '{commandName}'. To resolve this:.\n\n" 144 | + $"[1] Ensure that '{commandName}' is installed and can be found in one of the PATH directories.\n" 145 | + $" Current PATH enviroment variable is: { Environment.GetEnvironmentVariable("PATH") }\n" 146 | + " Make sure the executable is in one of those directories, or update your PATH.\n\n" 147 | + "[2] See the InnerException for further details of the cause."; 148 | throw new InvalidOperationException(message, ex); 149 | } 150 | } 151 | 152 | void IDisposable.Dispose() 153 | { 154 | if (_npmProcess != null && !_npmProcess.HasExited) 155 | { 156 | _npmProcess.Kill(entireProcessTree: true); 157 | _npmProcess = null; 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | _Disclaimer: This project is not affiliated with or supported by Tailwind Labs._ 2 | 3 | # Use Tailwind's JIT mode with `dotnet run` and `dotnet watch run` 4 | 5 | Makes it possible to use "Just In Time" builds for Tailwind (Tailwind 3+) with ASP.NET Core. 6 | 7 | Works for Blazor WASM applications that are hosted via ASP.NET Core, and Blazor Server applications. 8 | 9 | Note this doesn't work with Blazor WASM apps that aren't hosted via ASP.NET Core. 10 | 11 | ## Recommended: Use the Tailwind CLI (Hosted Service) 12 | 13 | The simplest way to integrate Tailwind during Development is to use the Tailwind CLI directly via this library's hosted service. 14 | 15 | Simple path (recommended): 16 | - Download the official Tailwind CSS standalone CLI for your OS and add it to your system PATH so the command `tailwind` is available, OR 17 | - Download it and set the full path in configuration via `Tailwind:TailwindCliPath`. 18 | 19 | There is no auto-download of the Tailwind CLI. The CLI must be present on PATH or referenced by a full path. 20 | 21 | 1) Install the NuGet package in your ASP.NET Core app: 22 | 23 | ```powershell 24 | dotnet add package Tailwind.Extensions.AspNetCore 25 | ``` 26 | 27 | 2) Configure Program.cs before `Build()`: 28 | 29 | ```csharp 30 | using Tailwind; 31 | 32 | var builder = WebApplication.CreateBuilder(args); 33 | 34 | // other services... 35 | 36 | // Enable Tailwind CLI watcher (Development only) 37 | builder.UseTailwindCli(); 38 | 39 | var app = builder.Build(); 40 | ``` 41 | 42 | 3) Add configuration (appsettings.Development.json): 43 | 44 | ```json 45 | { 46 | "Tailwind": { 47 | "InputFile": "./Styles/input.css", 48 | "OutputFile": "./wwwroot/css/output.css", 49 | "TailwindCliPath": "" // optional: set full path if not on PATH 50 | } 51 | } 52 | ``` 53 | 54 | Notes: 55 | - If `TailwindCliPath` is empty, the service runs a command named `tailwind` from PATH. 56 | - Secondary option (if you use npm-installed CLI): on Windows you can point to the npm shim, e.g. `.\\node_modules\\.bin\\tailwind.cmd`. 57 | - The hosted service is a no-op outside Development and stops the CLI when the app stops. 58 | 59 | 4) Ensure Tailwind CLI is available: 60 | - Preferred: use the standalone Tailwind CLI (downloaded binary) and add it to PATH or set `TailwindCliPath`. 61 | - Alternative: install via npm in your project (`npm install -D tailwindcss`) and ensure the CLI is resolvable (PATH or `TailwindCliPath`). 62 | 63 | 5) Run your app: 64 | Run `dotnet watch run`. In Development, the service will run `tailwind -i -o --watch` and log the exact command. 65 | 66 | ### Sample 67 | See [demos/net8](https://github.com/Practical-ASP-NET/Tailwind.Extensions.AspNetCore/tree/main/demos/net8) for a working setup (Program.cs calls `builder.UseTailwindCli();`, appsettings.Development.json contains the Tailwind section). 68 | 69 | --- 70 | 71 | ## Alternative: Use an npm script 72 | 73 | If you prefer to keep Tailwind as an npm script, you can use this extension to run your npm script during Development. 74 | 75 | Create a new Hosted Blazor WASM, or Blazor Server project. 76 | 77 | CD to the Client App's folder (if Blazor WASM) or Blazor Server App's folder. 78 | 79 | Run these commands: 80 | ``` powershell 81 | npm install -D tailwindcss cross-env 82 | npx tailwindcss init 83 | ``` 84 | 85 | Create a new Hosted Blazor WASM, or Blazor Server project. 86 | 87 | CD to the Client App's folder (if Blazor WASM) or Blazor Server App's folder. 88 | 89 | Run these commands: 90 | ``` powershell 91 | npm install -D tailwindcss cross-env 92 | npx tailwindcss init 93 | ``` 94 | 95 | This will install Tailwind and the handy cross-env utility via NPM, then create a `tailwind.config.js` file. 96 | 97 | Now update the `tailwind.config.js` file to include all your .razor and .cshtml files. 98 | 99 | ``` javascript 100 | module.exports = { 101 | content: ["**/*.razor", "**/*.cshtml", "**/*.html"], 102 | theme: { 103 | extend: {}, 104 | }, 105 | plugins: [], 106 | } 107 | ``` 108 | 109 | Now you'll want to create the Tailwind input stylesheet. This is the stylesheet that Tailwind will then pick up and build. 110 | 111 | Here's the minimum you'll need... 112 | 113 | **Styles\input.css** 114 | 115 | ``` css 116 | @import "tailwindcss"; 117 | ``` 118 | 119 | Finally, update your `package.json` file to add this script. 120 | 121 | ``` json 122 | "scripts": { 123 | "tailwind": "cross-env NODE_ENV=development ./node_modules/tailwindcss/lib/cli.js -i ./Styles/input.css -o ./wwwroot/css/output.css --watch" 124 | }, 125 | ``` 126 | 127 | Make sure **.Styles/input.css** is pointing to the css file you created in the last step. You can control where the resulting css file is created by specifying your own value for the `-o` parameter. 128 | 129 | That takes care of the Tailwind setup, now we just need to make this run when you run your ASP.NET Core project during Development. 130 | 131 | Make sure you switch to the Server App's folder if you're using Blazor WASM (hosted). 132 | 133 | Run this command to install the Tailwind AspNetCore NuGet package. 134 | 135 | ``` powershell 136 | dotnet add package Tailwind.Extensions.AspNetCore 137 | ``` 138 | 139 | Now head over to `Program.cs` and add this code before `app.Run()`; 140 | 141 | ``` csharp 142 | if (app.Environment.IsDevelopment()) 143 | { 144 | _ = app.RunTailwind("tailwind", "./"); 145 | } 146 | ``` 147 | 148 | The second argument is the path to the folder containing your package.json file. If you're using Blazor WASM you'll probably need something like this... 149 | 150 | ``` csharp 151 | if (app.Environment.IsDevelopment()) 152 | { 153 | _ = app.RunTailwind("tailwind", "../Client/"); 154 | } 155 | ``` 156 | 157 | You'll also need to add this `using` statement: 158 | 159 | ``` csharp 160 | using Tailwind; 161 | ``` 162 | 163 | Note we're using the discard parameter `_` when we call `app.RunTailwind`. This is because the method is async, but we don't want to wait for it to complete (as this would cause your app to wait for it to finish, and we want it continue running in the background alongside your app). Using the `_` parameter here stops your IDE nagging at you to await the call 😀 164 | 165 | Now, run `dotnet watch run` and try modifying your Razor components (using Tailwind's utility classes). 166 | 167 | You should see logs indicating that tailwind has rebuilt the CSS stylesheet successfully. 168 | 169 | --- 170 | 171 | ## Choosing an approach 172 | 173 | You can use either approach depending on your preference: 174 | 175 | - Tailwind CLI hosted service (recommended): configure via appsettings and call `builder.UseTailwindCli();`. No npm script required. Prefer this with the standalone Tailwind CLI on PATH or set via `TailwindCliPath`. 176 | - npm script middleware (alternative): keep your Tailwind build as an npm script and call `_ = app.RunTailwind("tailwind", "./");` in Development. This remains fully supported. 177 | -------------------------------------------------------------------------------- /.junie/guidelines.md: -------------------------------------------------------------------------------- 1 | Project: Tailwind.Extensions.AspNetCore 2 | Last updated: 2025-09-08 3 | 4 | Scope 5 | - Library: src/Tailwind.Extensions.AspNetCore (ASP.NET Core extension to run Tailwind CLI in watch mode during Development) 6 | - Tests: src/Tailwind.Extensions.AspNetCore.Tests (xUnit) 7 | - Sample app: src/SampleApps/BlazorServer (demonstrates usage) 8 | - Build/pack scripts: Build.ps1, Push.ps1 9 | 10 | Build and configuration instructions 11 | 1) Requirements 12 | - .NET SDK: project targets net6.0. A .NET 6 Runtime is required to run tests and sample apps; building the code with newer SDKs works but running requires a matching runtime. 13 | • Verified: Building with a .NET 10 preview SDK succeeds; running tests failed due to missing Microsoft.NETCore.App 6.0 runtime. Install .NET 6 Runtime when executing tests: https://dotnet.microsoft.com/en-us/download/dotnet/6.0 14 | - Windows PowerShell (Build.ps1 uses PowerShell). 15 | - For the sample Blazor app only: Node.js + npm to install and run Tailwind CLI locally. 16 | 17 | 2) Build via CLI 18 | - Restore and build everything: 19 | dotnet build -c Release 20 | - Scripted build (cleans, builds, tests, packs): 21 | PowerShell: ./Build.ps1 22 | Behavior of Build.ps1: 23 | • Cleans solution in Release. 24 | • Builds in Release. 25 | • Runs tests in Release, outputs TRX to ./artifacts. 26 | • Packs the library project to ./artifacts. 27 | 28 | 3) Packaging 29 | - Create a NuGet package (Release): 30 | dotnet pack .\src\Tailwind.Extensions.AspNetCore -c Release -o .\artifacts --no-build 31 | - Push.ps1 is available for publishing (inspect/update it as needed for your feed credentials). 32 | 33 | Testing information 34 | 1) Test framework and structure 35 | - Framework: xUnit with Microsoft.NET.Test.Sdk and coverlet.collector. 36 | - Location: src/Tailwind.Extensions.AspNetCore.Tests 37 | - GlobalUsings includes Xunit, so test files can omit using Xunit. 38 | 39 | 2) Running tests 40 | - Prerequisite: .NET 6 runtime must be installed to execute tests (TargetFramework=net6.0). Without it, dotnet test will abort with a message about missing Microsoft.NETCore.App 6.0. 41 | - Commands: 42 | • All tests, Debug: 43 | dotnet test -c Debug 44 | • All tests, Release, with TRX logs to ./artifacts (no rebuild): 45 | dotnet test -c Release -r .\artifacts --no-build -l trx 46 | • Single project: 47 | dotnet test .\src\Tailwind.Extensions.AspNetCore.Tests\Tailwind.Extensions.AspNetCore.Tests.csproj -c Debug 48 | 49 | 3) Adding new tests 50 | - Create a new .cs file under src/Tailwind.Extensions.AspNetCore.Tests with [Fact]/[Theory] tests. 51 | - Example (minimal): 52 | using Tailwind; // if testing internals in the library 53 | public class ExampleTests { 54 | [Fact] 55 | public void Sanity() => Assert.True(true); 56 | } 57 | - If you need fakes/mocks, FakeItEasy is already referenced. 58 | 59 | 4) Demo test run (validated process) 60 | - We added a trivial xUnit test file and compiled successfully. Running tests failed on this machine because the .NET 6 runtime is not installed. This confirms: 61 | • The test project compiles and is discovered by dotnet test. 62 | • Execution requires Microsoft.NETCore.App 6.0. Install it to run tests locally, or multi-target the test project to a runtime available on your machine. 63 | 64 | Guidance for multi-targeting tests (optional for local dev) 65 | - If your environment only has newer runtimes (e.g., .NET 10 preview) and you need to execute tests without installing .NET 6, you can temporarily multi-target the test project: 66 | • Change to net6.0;net10.0 in src/Tailwind.Extensions.AspNetCore.Tests.csproj. 67 | • Ensure Microsoft.NET.Test.Sdk and xunit packages support the newer TFM (you may need newer package versions when moving beyond LTS). Run: dotnet restore, then dotnet test -f net10.0. 68 | • Revert this change before committing if you want to keep the official target at net6.0. 69 | 70 | Additional development information 71 | 1) Library responsibilities (TailwindHostedService) 72 | - Namespace Tailwind; primary types: 73 | • TailwindOptions: InputFile, OutputFile, TailwindCliPath. 74 | • TailwindHostedService: IHostedService that spawns the Tailwind CLI in watch mode during Development only. 75 | • ITailwindProcessInterop and TailwindProcessInterop: process abstraction to start CLI (StartProcess(name, args)). 76 | - Behavior: 77 | • Service is a no-op outside Development (IHostEnvironment.IsDevelopment()). 78 | • Validates InputFile/OutputFile via Guard.AgainstNull (throws ArgumentNullException with context message). 79 | • Chooses CLI executable: TailwindCliPath if specified and non-empty; otherwise defaults to "tailwind". 80 | • Starts CLI: tailwind -i -o --watch, logs the command, and keeps the process until app stops. 81 | • StopAsync kills the process; Dispose disposes it. 82 | 83 | 2) Integration in apps 84 | - In Program.cs (server app): 85 | if (app.Environment.IsDevelopment()) 86 | { 87 | _ = app.RunTailwind("tailwind", "./"); // or alternative path to client folder 88 | } 89 | Add using Tailwind; and configure options appropriately (see README.md for full setup, including package.json script alternative). 90 | 91 | 3) Sample app specifics (src/SampleApps/BlazorServer) 92 | - Contains a minimal Tailwind config and Styles/input.css. To see Tailwind in action: 93 | • Install Node.js and npm. 94 | • In the BlazorServer folder, ensure tailwindcss is installed in the project (npm install -D tailwindcss cross-env) and tailwind.config.js content globs include .razor/.cshtml. 95 | • Run dotnet watch run for ASP.NET host; the hosted service will launch the Tailwind CLI in watch mode (Development only), provided Tailwind CLI is available in PATH or TailwindCliPath points to it. 96 | 97 | 4) Node/Tailwind expectations 98 | - The service runs a CLI executable named "tailwind" (or TailwindCliPath). If you only have npx, either: 99 | • Install tailwindcss locally (node_modules/.bin/tailwind) and set TailwindCliPath accordingly (e.g., .\node_modules\.bin\tailwind.cmd on Windows), or 100 | • Install the tailwindcss standalone CLI and add it to PATH. 101 | 102 | 5) Logging and diagnostics 103 | - TailwindHostedService logs the exact command it will run. If CSS isn’t being rebuilt: 104 | • Confirm app.Environment is Development. 105 | • Validate TailwindOptions.InputFile/OutputFile paths exist. 106 | • Ensure Tailwind CLI is resolvable (PATH or explicit TailwindCliPath). 107 | • Check port/process conflicts are unlikely here, but the repository includes utility classes (TcpPortFinder, EventedStreamReader) used by process plumbing. 108 | 109 | 6) Code style and conventions 110 | - C# 10, nullable enabled, implicit usings enabled. 111 | - Guard pattern for argument validation (see Guard.AgainstNull in src/Tailwind.Extensions.AspNetCore/Guard.cs). 112 | - Prefer dependency abstractions for external processes (ITailwindProcessInterop) to enable unit testing with fakes. 113 | - Tests use Arrange/Act/Assert with FakeItEasy for fakes and xUnit for assertions. 114 | 115 | 7) Common troubleshooting 116 | - Tests won’t run: Install .NET 6 Runtime (LTS) to match TargetFramework, or multi-target as described above. 117 | - Tailwind CLI not found: Set TailwindCliPath to the full path to the CLI (Windows often requires the .cmd shim in node_modules/.bin). 118 | - No CSS updates: Ensure globs in tailwind.config.js include .razor and .cshtml files as shown in README.md. 119 | 120 | Quick command reference 121 | - Build: dotnet build -c Release 122 | - Full pipeline: PowerShell ./Build.ps1 123 | - Tests (requires .NET 6 runtime): dotnet test -c Debug 124 | - Pack: dotnet pack .\src\Tailwind.Extensions.AspNetCore -c Release -o .\artifacts --no-build 125 | -------------------------------------------------------------------------------- /demos/net8/wwwroot/css/output.css: -------------------------------------------------------------------------------- 1 | /*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */ 2 | @layer properties; 3 | @layer theme, base, components, utilities; 4 | @layer theme { 5 | :root, :host { 6 | --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 7 | 'Noto Color Emoji'; 8 | --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', 9 | monospace; 10 | --color-indigo-500: oklch(58.5% 0.233 277.117); 11 | --spacing: 0.25rem; 12 | --text-3xl: 1.875rem; 13 | --text-3xl--line-height: calc(2.25 / 1.875); 14 | --font-weight-semibold: 600; 15 | --default-font-family: var(--font-sans); 16 | --default-mono-font-family: var(--font-mono); 17 | } 18 | } 19 | @layer base { 20 | *, ::after, ::before, ::backdrop, ::file-selector-button { 21 | box-sizing: border-box; 22 | margin: 0; 23 | padding: 0; 24 | border: 0 solid; 25 | } 26 | html, :host { 27 | line-height: 1.5; 28 | -webkit-text-size-adjust: 100%; 29 | tab-size: 4; 30 | font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'); 31 | font-feature-settings: var(--default-font-feature-settings, normal); 32 | font-variation-settings: var(--default-font-variation-settings, normal); 33 | -webkit-tap-highlight-color: transparent; 34 | } 35 | hr { 36 | height: 0; 37 | color: inherit; 38 | border-top-width: 1px; 39 | } 40 | abbr:where([title]) { 41 | -webkit-text-decoration: underline dotted; 42 | text-decoration: underline dotted; 43 | } 44 | h1, h2, h3, h4, h5, h6 { 45 | font-size: inherit; 46 | font-weight: inherit; 47 | } 48 | a { 49 | color: inherit; 50 | -webkit-text-decoration: inherit; 51 | text-decoration: inherit; 52 | } 53 | b, strong { 54 | font-weight: bolder; 55 | } 56 | code, kbd, samp, pre { 57 | font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace); 58 | font-feature-settings: var(--default-mono-font-feature-settings, normal); 59 | font-variation-settings: var(--default-mono-font-variation-settings, normal); 60 | font-size: 1em; 61 | } 62 | small { 63 | font-size: 80%; 64 | } 65 | sub, sup { 66 | font-size: 75%; 67 | line-height: 0; 68 | position: relative; 69 | vertical-align: baseline; 70 | } 71 | sub { 72 | bottom: -0.25em; 73 | } 74 | sup { 75 | top: -0.5em; 76 | } 77 | table { 78 | text-indent: 0; 79 | border-color: inherit; 80 | border-collapse: collapse; 81 | } 82 | :-moz-focusring { 83 | outline: auto; 84 | } 85 | progress { 86 | vertical-align: baseline; 87 | } 88 | summary { 89 | display: list-item; 90 | } 91 | ol, ul, menu { 92 | list-style: none; 93 | } 94 | img, svg, video, canvas, audio, iframe, embed, object { 95 | display: block; 96 | vertical-align: middle; 97 | } 98 | img, video { 99 | max-width: 100%; 100 | height: auto; 101 | } 102 | button, input, select, optgroup, textarea, ::file-selector-button { 103 | font: inherit; 104 | font-feature-settings: inherit; 105 | font-variation-settings: inherit; 106 | letter-spacing: inherit; 107 | color: inherit; 108 | border-radius: 0; 109 | background-color: transparent; 110 | opacity: 1; 111 | } 112 | :where(select:is([multiple], [size])) optgroup { 113 | font-weight: bolder; 114 | } 115 | :where(select:is([multiple], [size])) optgroup option { 116 | padding-inline-start: 20px; 117 | } 118 | ::file-selector-button { 119 | margin-inline-end: 4px; 120 | } 121 | ::placeholder { 122 | opacity: 1; 123 | } 124 | @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { 125 | ::placeholder { 126 | color: currentcolor; 127 | @supports (color: color-mix(in lab, red, red)) { 128 | color: color-mix(in oklab, currentcolor 50%, transparent); 129 | } 130 | } 131 | } 132 | textarea { 133 | resize: vertical; 134 | } 135 | ::-webkit-search-decoration { 136 | -webkit-appearance: none; 137 | } 138 | ::-webkit-date-and-time-value { 139 | min-height: 1lh; 140 | text-align: inherit; 141 | } 142 | ::-webkit-datetime-edit { 143 | display: inline-flex; 144 | } 145 | ::-webkit-datetime-edit-fields-wrapper { 146 | padding: 0; 147 | } 148 | ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { 149 | padding-block: 0; 150 | } 151 | ::-webkit-calendar-picker-indicator { 152 | line-height: 1; 153 | } 154 | :-moz-ui-invalid { 155 | box-shadow: none; 156 | } 157 | button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { 158 | appearance: button; 159 | } 160 | ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { 161 | height: auto; 162 | } 163 | [hidden]:where(:not([hidden='until-found'])) { 164 | display: none !important; 165 | } 166 | } 167 | @layer utilities { 168 | .static { 169 | position: static; 170 | } 171 | .my-4 { 172 | margin-block: calc(var(--spacing) * 4); 173 | } 174 | .\!mb-4 { 175 | margin-bottom: calc(var(--spacing) * 4) !important; 176 | } 177 | .px-4 { 178 | padding-inline: calc(var(--spacing) * 4); 179 | } 180 | .text-3xl { 181 | font-size: var(--text-3xl); 182 | line-height: var(--tw-leading, var(--text-3xl--line-height)); 183 | } 184 | .font-semibold { 185 | --tw-font-weight: var(--font-weight-semibold); 186 | font-weight: var(--font-weight-semibold); 187 | } 188 | .text-indigo-500 { 189 | color: var(--color-indigo-500); 190 | } 191 | } 192 | .valid.modified:not([type=checkbox]) { 193 | outline: 1px solid #26b050; 194 | } 195 | .invalid { 196 | outline: 1px solid red; 197 | } 198 | .validation-message { 199 | color: red; 200 | } 201 | #blazor-error-ui { 202 | background: lightyellow; 203 | bottom: 0; 204 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); 205 | display: none; 206 | left: 0; 207 | padding: 0.6rem 1.25rem 0.7rem 1.25rem; 208 | position: fixed; 209 | width: 100%; 210 | z-index: 1000; 211 | } 212 | #blazor-error-ui .dismiss { 213 | cursor: pointer; 214 | position: absolute; 215 | right: 0.75rem; 216 | top: 0.5rem; 217 | } 218 | .blazor-error-boundary { 219 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; 220 | padding: 1rem 1rem 1rem 3.7rem; 221 | color: white; 222 | } 223 | .blazor-error-boundary::after { 224 | content: "An error has occurred."; 225 | } 226 | @property --tw-font-weight { 227 | syntax: "*"; 228 | inherits: false; 229 | } 230 | @layer properties { 231 | @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { 232 | *, ::before, ::after, ::backdrop { 233 | --tw-font-weight: initial; 234 | } 235 | } 236 | } 237 | --------------------------------------------------------------------------------