├── 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="<Tailwind.Extensions.AspNetCore.Tests>" />
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