├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── Build.ps1 ├── LICENSE ├── NuGet.config ├── Push.ps1 ├── README.md ├── Tailwind.Extensions.AspNetCore.sln ├── Tailwind.Extensions.AspNetCore.sln.DotSettings.user └── src ├── SampleApps └── BlazorServer │ ├── .dockerignore │ ├── App.razor │ ├── BlazorServer.csproj │ ├── Dockerfile │ ├── Pages │ ├── Error.cshtml │ ├── Error.cshtml.cs │ ├── Index.razor │ ├── _Host.cshtml │ └── _Layout.cshtml │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── Shared │ ├── MainLayout.razor │ ├── MainLayout.razor.css │ ├── NavMenu.razor │ ├── NavMenu.razor.css │ └── SurveyPrompt.razor │ ├── Styles │ └── input.css │ ├── _Imports.razor │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── tailwind.config.js │ └── wwwroot │ ├── css │ └── open-iconic │ │ ├── FONT-LICENSE │ │ ├── ICON-LICENSE │ │ ├── README.md │ │ └── font │ │ ├── css │ │ └── open-iconic-bootstrap.min.css │ │ └── fonts │ │ ├── open-iconic.eot │ │ ├── open-iconic.otf │ │ ├── open-iconic.svg │ │ ├── open-iconic.ttf │ │ └── open-iconic.woff │ └── favicon.ico ├── Tailwind.Extensions.AspNetCore.Tests ├── GlobalUsings.cs ├── Tailwind.Extensions.AspNetCore.Tests.csproj └── TailwindCliHostedServiceTests.cs └── Tailwind.Extensions.AspNetCore ├── Guard.cs ├── Npm └── NodeScriptRunner.cs ├── Tailwind.Extensions.AspNetCore.csproj ├── TailwindHostedService.cs ├── TailwindMiddleware.cs ├── TailwindMiddlewareExtensions.cs ├── TailwindProcessInterop.cs └── Util ├── EventedStreamReader.cs ├── EventedStreamStringReader.cs ├── LoggerFinder.cs ├── TaskTimeoutExtensions.cs └── TcpPortFinder.cs /.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@v1 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@v2 32 | with: 33 | name: artifacts 34 | path: artifacts/**/* -------------------------------------------------------------------------------- /.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@v1 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@v2 28 | with: 29 | name: artifacts 30 | path: artifacts/**/* -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 -c Release -r $artifacts --no-build -l trx --verbosity=normal } 34 | 35 | exec { dotnet pack .\src\Tailwind.Extensions.AspNetCore -c Release -o $artifacts --no-build } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 the new "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 it doesn't work with Blazor WASM apps that aren't hosted via ASP.NET Core. 10 | 11 | ## Usage 12 | 13 | Create a new Hosted Blazor WASM, or Blazor Server project. 14 | 15 | CD to the Client App's folder (if Blazor WASM) or Blazor Server App's folder. 16 | 17 | Run these commands: 18 | ``` powershell 19 | npm install -D tailwindcss cross-env 20 | npx tailwindcss init 21 | ``` 22 | 23 | This will install Tailwind and the handy cross-env utility via NPM, then create a `tailwind.config.js` file. 24 | 25 | Now update the `tailwind.config.js` file to include all your .razor and .cshtml files. 26 | 27 | ``` javascript 28 | module.exports = { 29 | content: ["**/*.razor", "**/*.cshtml", "**/*.html"], 30 | theme: { 31 | extend: {}, 32 | }, 33 | plugins: [], 34 | } 35 | ``` 36 | 37 | Now you'll want to create the Tailwind input stylesheet. This is the stylesheet that Tailwind will then pick up and build. 38 | 39 | Here's the minimum you'll need... 40 | 41 | **Styles\input.css** 42 | 43 | ``` css 44 | @tailwind base; 45 | @tailwind components; 46 | @tailwind utilities; 47 | ``` 48 | 49 | Finally, update your `package.json` file to add this script. 50 | 51 | ``` json 52 | "scripts": { 53 | "tailwind": "cross-env NODE_ENV=development ./node_modules/tailwindcss/lib/cli.js -i ./Styles/input.css -o ./wwwroot/css/output.css --watch" 54 | }, 55 | ``` 56 | 57 | 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. 58 | 59 | 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. 60 | 61 | Make sure you switch to the Server App's folder if you're using Blazor WASM (hosted). 62 | 63 | Run this command to install the Tailwind AspNetCore NuGet package. 64 | 65 | ``` powershell 66 | dotnet add package Tailwind.Extensions.AspNetCore --version 1.0.0-beta2 67 | ``` 68 | 69 | Now head over to `Program.cs` and add this code before `app.Run()`; 70 | 71 | ``` csharp 72 | if (app.Environment.IsDevelopment()) 73 | { 74 | _ = app.RunTailwind("tailwind", "./"); 75 | } 76 | ``` 77 | 78 | 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... 79 | 80 | ``` csharp 81 | if (app.Environment.IsDevelopment()) 82 | { 83 | _ = app.RunTailwind("tailwind", "../Client/"); 84 | } 85 | ``` 86 | 87 | You'll also need to add this `using` statement: 88 | 89 | ``` csharp 90 | using Tailwind; 91 | ``` 92 | 93 | 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 😀 94 | 95 | Now, run `dotnet watch run` and try modifying your Razor components (using Tailwind's utility classes). 96 | 97 | You should see logs indicating that tailwind has rebuilt the CSS stylesheet successfully. 98 | -------------------------------------------------------------------------------- /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}") = "BlazorServer", "src\SampleApps\BlazorServer\BlazorServer.csproj", "{AA1AF6B6-6B5B-45B4-867C-9B04CAFDFB84}" 6 | EndProject 7 | 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}" 8 | EndProject 9 | Global 10 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 11 | Debug|Any CPU = Debug|Any CPU 12 | Release|Any CPU = Release|Any CPU 13 | EndGlobalSection 14 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 15 | {3A29D45C-D8D5-4D52-8E4E-F295F3EF14C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 16 | {3A29D45C-D8D5-4D52-8E4E-F295F3EF14C3}.Debug|Any CPU.Build.0 = Debug|Any CPU 17 | {3A29D45C-D8D5-4D52-8E4E-F295F3EF14C3}.Release|Any CPU.ActiveCfg = Release|Any CPU 18 | {3A29D45C-D8D5-4D52-8E4E-F295F3EF14C3}.Release|Any CPU.Build.0 = Release|Any CPU 19 | {AA1AF6B6-6B5B-45B4-867C-9B04CAFDFB84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 20 | {AA1AF6B6-6B5B-45B4-867C-9B04CAFDFB84}.Debug|Any CPU.Build.0 = Debug|Any CPU 21 | {AA1AF6B6-6B5B-45B4-867C-9B04CAFDFB84}.Release|Any CPU.ActiveCfg = Release|Any CPU 22 | {AA1AF6B6-6B5B-45B4-867C-9B04CAFDFB84}.Release|Any CPU.Build.0 = Release|Any CPU 23 | {FF0BEEBB-F708-4F4B-9839-A82AE71835C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {FF0BEEBB-F708-4F4B-9839-A82AE71835C4}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {FF0BEEBB-F708-4F4B-9839-A82AE71835C4}.Release|Any CPU.ActiveCfg = Release|Any CPU 26 | {FF0BEEBB-F708-4F4B-9839-A82AE71835C4}.Release|Any CPU.Build.0 = Release|Any CPU 27 | EndGlobalSection 28 | EndGlobal 29 | -------------------------------------------------------------------------------- /Tailwind.Extensions.AspNetCore.sln.DotSettings.user: -------------------------------------------------------------------------------- 1 |  2 | <SessionState ContinuousTestingMode="0" IsActive="True" Name="MissingOptionsThrowsError" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> 3 | <Project Location="C:\Users\hilto\RiderProjects\Tailwind.Extensions.AspNetCore\Tailwind.Extensions.AspNetCore.Tests" Presentation="&lt;Tailwind.Extensions.AspNetCore.Tests&gt;" /> 4 | </SessionState> 5 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.dockerignore 2 | **/.env 3 | **/.git 4 | **/.gitignore 5 | **/.project 6 | **/.settings 7 | **/.toolstarget 8 | **/.vs 9 | **/.vscode 10 | **/.idea 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/App.razor: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | Not found 8 | 9 |

Sorry, there's nothing at this address.

10 |
11 |
12 |
-------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/BlazorServer.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | Linux 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | <_ContentIncludedByDefault Remove="wwwroot\css\bootstrap\bootstrap.min.css" /> 16 | <_ContentIncludedByDefault Remove="wwwroot\css\bootstrap\bootstrap.min.css.map" /> 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base 2 | WORKDIR /app 3 | EXPOSE 80 4 | EXPOSE 443 5 | 6 | FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build 7 | WORKDIR /src 8 | COPY ["src/SampleApps/BlazorServer/BlazorServer.csproj", "BlazorServer/"] 9 | RUN dotnet restore "src/SampleApps/BlazorServer/BlazorServer.csproj" 10 | COPY . . 11 | WORKDIR "/src/BlazorServer" 12 | RUN dotnet build "BlazorServer.csproj" -c Release -o /app/build 13 | 14 | FROM build AS publish 15 | RUN dotnet publish "BlazorServer.csproj" -c Release -o /app/publish 16 | 17 | FROM base AS final 18 | WORKDIR /app 19 | COPY --from=publish /app/publish . 20 | ENTRYPOINT ["dotnet", "BlazorServer.dll"] 21 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/Pages/Error.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model BlazorServer.Pages.ErrorModel 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Error 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |

Error.

19 |

An error occurred while processing your request.

20 | 21 | @if (Model.ShowRequestId) 22 | { 23 |

24 | Request ID: @Model.RequestId 25 |

26 | } 27 | 28 |

Development Mode

29 |

30 | Swapping to the Development environment displays detailed information about the error that occurred. 31 |

32 |

33 | The Development environment shouldn't be enabled for deployed applications. 34 | It can result in displaying sensitive information from exceptions to end users. 35 | For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development 36 | and restarting the app. 37 |

38 |
39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/Pages/Error.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.AspNetCore.Mvc.RazorPages; 4 | 5 | namespace BlazorServer.Pages; 6 | 7 | [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] 8 | [IgnoreAntiforgeryToken] 9 | public class ErrorModel : PageModel 10 | { 11 | public string? RequestId { get; set; } 12 | 13 | public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); 14 | 15 | private readonly ILogger _logger; 16 | 17 | public ErrorModel(ILogger logger) 18 | { 19 | _logger = logger; 20 | } 21 | 22 | public void OnGet() 23 | { 24 | RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; 25 | } 26 | } -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/Pages/Index.razor: -------------------------------------------------------------------------------- 1 | @page "/" 2 | 3 |
4 |

Hello World

5 |
-------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/Pages/_Host.cshtml: -------------------------------------------------------------------------------- 1 | @page "/" 2 | @namespace BlazorServer.Pages 3 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 4 | @{ 5 | Layout = "_Layout"; 6 | } 7 | 8 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/Pages/_Layout.cshtml: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Components.Web 2 | @namespace BlazorServer.Pages 3 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | @RenderBody() 17 | 18 |
19 | 20 | An error has occurred. This application may no longer respond until reloaded. 21 | 22 | 23 | An unhandled exception has occurred. See browser dev tools for details. 24 | 25 | Reload 26 | 🗙 27 |
28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/Program.cs: -------------------------------------------------------------------------------- 1 | using Tailwind; 2 | 3 | var builder = WebApplication.CreateBuilder(args); 4 | 5 | // Add services to the container. 6 | builder.Services.AddRazorPages(); 7 | builder.Services.AddServerSideBlazor(); 8 | 9 | // use this to automatically watch for css changes using Tailwind CLI 10 | // configure via appsettings "Tailwind" 11 | builder.UseTailwindCli(); 12 | 13 | var app = builder.Build(); 14 | 15 | // Configure the HTTP request pipeline. 16 | if (!app.Environment.IsDevelopment()) 17 | { 18 | app.UseExceptionHandler("/Error"); 19 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. 20 | app.UseHsts(); 21 | } 22 | 23 | app.UseHttpsRedirection(); 24 | 25 | app.UseStaticFiles(); 26 | 27 | app.UseRouting(); 28 | 29 | app.MapBlazorHub(); 30 | app.MapFallbackToPage("/_Host"); 31 | 32 | if (app.Environment.IsDevelopment()) 33 | { 34 | // app.RunTailwind("tailwind", "./"); 35 | } 36 | 37 | app.Run(); 38 | 39 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:17460", 7 | "sslPort": 44325 8 | } 9 | }, 10 | "profiles": { 11 | "BlazorServer": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "applicationUrl": "https://localhost:7076;http://localhost:5076", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "IIS Express": { 21 | "commandName": "IISExpress", 22 | "launchBrowser": true, 23 | "environmentVariables": { 24 | "ASPNETCORE_ENVIRONMENT": "Development" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/Shared/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | 3 | Blazor Server Tailwind Demo 4 | 5 |
6 |
7 | @Body 8 |
9 |
-------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/Shared/MainLayout.razor.css: -------------------------------------------------------------------------------- 1 | .page { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | main { 8 | flex: 1; 9 | } 10 | 11 | .sidebar { 12 | background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); 13 | } 14 | 15 | .top-row { 16 | background-color: #f7f7f7; 17 | border-bottom: 1px solid #d6d5d5; 18 | justify-content: flex-end; 19 | height: 3.5rem; 20 | display: flex; 21 | align-items: center; 22 | } 23 | 24 | .top-row ::deep a, .top-row .btn-link { 25 | white-space: nowrap; 26 | margin-left: 1.5rem; 27 | } 28 | 29 | .top-row a:first-child { 30 | overflow: hidden; 31 | text-overflow: ellipsis; 32 | } 33 | 34 | @media (max-width: 640.98px) { 35 | .top-row:not(.auth) { 36 | display: none; 37 | } 38 | 39 | .top-row.auth { 40 | justify-content: space-between; 41 | } 42 | 43 | .top-row a, .top-row .btn-link { 44 | margin-left: 0; 45 | } 46 | } 47 | 48 | @media (min-width: 641px) { 49 | .page { 50 | flex-direction: row; 51 | } 52 | 53 | .sidebar { 54 | width: 250px; 55 | height: 100vh; 56 | position: sticky; 57 | top: 0; 58 | } 59 | 60 | .top-row { 61 | position: sticky; 62 | top: 0; 63 | z-index: 1; 64 | } 65 | 66 | .top-row, article { 67 | padding-left: 2rem !important; 68 | padding-right: 1.5rem !important; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/Shared/NavMenu.razor: -------------------------------------------------------------------------------- 1 |  9 | 10 |
11 | 28 |
29 | 30 | @code { 31 | private bool collapseNavMenu = true; 32 | 33 | private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null; 34 | 35 | private void ToggleNavMenu() 36 | { 37 | collapseNavMenu = !collapseNavMenu; 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/Shared/NavMenu.razor.css: -------------------------------------------------------------------------------- 1 | .navbar-toggler { 2 | background-color: rgba(255, 255, 255, 0.1); 3 | } 4 | 5 | .top-row { 6 | height: 3.5rem; 7 | background-color: rgba(0,0,0,0.4); 8 | } 9 | 10 | .navbar-brand { 11 | font-size: 1.1rem; 12 | } 13 | 14 | .oi { 15 | width: 2rem; 16 | font-size: 1.1rem; 17 | vertical-align: text-top; 18 | top: -2px; 19 | } 20 | 21 | .nav-item { 22 | font-size: 0.9rem; 23 | padding-bottom: 0.5rem; 24 | } 25 | 26 | .nav-item:first-of-type { 27 | padding-top: 1rem; 28 | } 29 | 30 | .nav-item:last-of-type { 31 | padding-bottom: 1rem; 32 | } 33 | 34 | .nav-item ::deep a { 35 | color: #d7d7d7; 36 | border-radius: 4px; 37 | height: 3rem; 38 | display: flex; 39 | align-items: center; 40 | line-height: 3rem; 41 | } 42 | 43 | .nav-item ::deep a.active { 44 | background-color: rgba(255,255,255,0.25); 45 | color: white; 46 | } 47 | 48 | .nav-item ::deep a:hover { 49 | background-color: rgba(255,255,255,0.1); 50 | color: white; 51 | } 52 | 53 | @media (min-width: 641px) { 54 | .navbar-toggler { 55 | display: none; 56 | } 57 | 58 | .collapse { 59 | /* Never collapse the sidebar for wide screens */ 60 | display: block; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/Shared/SurveyPrompt.razor: -------------------------------------------------------------------------------- 1 |
2 | 3 | @Title 4 | 5 | 6 | Please take our 7 | brief survey 8 | 9 | and tell us what you think. 10 |
11 | 12 | @code { 13 | // Demonstrates how a parent component can supply parameters 14 | [Parameter] 15 | public string? Title { get; set; } 16 | 17 | } -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/Styles/input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .valid.modified:not([type=checkbox]) { 6 | outline: 1px solid #26b050; 7 | } 8 | 9 | .invalid { 10 | outline: 1px solid red; 11 | } 12 | 13 | .validation-message { 14 | color: red; 15 | } 16 | 17 | #blazor-error-ui { 18 | background: lightyellow; 19 | bottom: 0; 20 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); 21 | display: none; 22 | left: 0; 23 | padding: 0.6rem 1.25rem 0.7rem 1.25rem; 24 | position: fixed; 25 | width: 100%; 26 | z-index: 1000; 27 | } 28 | 29 | #blazor-error-ui .dismiss { 30 | cursor: pointer; 31 | position: absolute; 32 | right: 0.75rem; 33 | top: 0.5rem; 34 | } 35 | 36 | .blazor-error-boundary { 37 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; 38 | padding: 1rem 1rem 1rem 3.7rem; 39 | color: white; 40 | } 41 | 42 | .blazor-error-boundary::after { 43 | content: "An error has occurred." 44 | } -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using Microsoft.AspNetCore.Authorization 3 | @using Microsoft.AspNetCore.Components.Authorization 4 | @using Microsoft.AspNetCore.Components.Forms 5 | @using Microsoft.AspNetCore.Components.Routing 6 | @using Microsoft.AspNetCore.Components.Web 7 | @using Microsoft.AspNetCore.Components.Web.Virtualization 8 | @using Microsoft.JSInterop 9 | @using BlazorServer 10 | @using BlazorServer.Shared -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "DetailedErrors": true, 3 | "Logging": { 4 | "LogLevel": { 5 | "Default": "Information", 6 | "Microsoft.AspNetCore": "Warning" 7 | } 8 | }, 9 | "Tailwind": { 10 | "InputFile": "./Styles/input.css", 11 | "OutputFile": "./wwwroot/css/output.css", 12 | "TailwindCliPath": "" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["**/*.razor", "**/*.cshtml", "**/*.html"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | } 8 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/wwwroot/css/open-iconic/FONT-LICENSE: -------------------------------------------------------------------------------- 1 | SIL OPEN FONT LICENSE Version 1.1 2 | 3 | Copyright (c) 2014 Waybury 4 | 5 | PREAMBLE 6 | The goals of the Open Font License (OFL) are to stimulate worldwide 7 | development of collaborative font projects, to support the font creation 8 | efforts of academic and linguistic communities, and to provide a free and 9 | open framework in which fonts may be shared and improved in partnership 10 | with others. 11 | 12 | The OFL allows the licensed fonts to be used, studied, modified and 13 | redistributed freely as long as they are not sold by themselves. The 14 | fonts, including any derivative works, can be bundled, embedded, 15 | redistributed and/or sold with any software provided that any reserved 16 | names are not used by derivative works. The fonts and derivatives, 17 | however, cannot be released under any other type of license. The 18 | requirement for fonts to remain under this license does not apply 19 | to any document created using the fonts or their derivatives. 20 | 21 | DEFINITIONS 22 | "Font Software" refers to the set of files released by the Copyright 23 | Holder(s) under this license and clearly marked as such. This may 24 | include source files, build scripts and documentation. 25 | 26 | "Reserved Font Name" refers to any names specified as such after the 27 | copyright statement(s). 28 | 29 | "Original Version" refers to the collection of Font Software components as 30 | distributed by the Copyright Holder(s). 31 | 32 | "Modified Version" refers to any derivative made by adding to, deleting, 33 | or substituting -- in part or in whole -- any of the components of the 34 | Original Version, by changing formats or by porting the Font Software to a 35 | new environment. 36 | 37 | "Author" refers to any designer, engineer, programmer, technical 38 | writer or other person who contributed to the Font Software. 39 | 40 | PERMISSION & CONDITIONS 41 | Permission is hereby granted, free of charge, to any person obtaining 42 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 43 | redistribute, and sell modified and unmodified copies of the Font 44 | Software, subject to the following conditions: 45 | 46 | 1) Neither the Font Software nor any of its individual components, 47 | in Original or Modified Versions, may be sold by itself. 48 | 49 | 2) Original or Modified Versions of the Font Software may be bundled, 50 | redistributed and/or sold with any software, provided that each copy 51 | contains the above copyright notice and this license. These can be 52 | included either as stand-alone text files, human-readable headers or 53 | in the appropriate machine-readable metadata fields within text or 54 | binary files as long as those fields can be easily viewed by the user. 55 | 56 | 3) No Modified Version of the Font Software may use the Reserved Font 57 | Name(s) unless explicit written permission is granted by the corresponding 58 | Copyright Holder. This restriction only applies to the primary font name as 59 | presented to the users. 60 | 61 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 62 | Software shall not be used to promote, endorse or advertise any 63 | Modified Version, except to acknowledge the contribution(s) of the 64 | Copyright Holder(s) and the Author(s) or with their explicit written 65 | permission. 66 | 67 | 5) The Font Software, modified or unmodified, in part or in whole, 68 | must be distributed entirely under this license, and must not be 69 | distributed under any other license. The requirement for fonts to 70 | remain under this license does not apply to any document created 71 | using the Font Software. 72 | 73 | TERMINATION 74 | This license becomes null and void if any of the above conditions are 75 | not met. 76 | 77 | DISCLAIMER 78 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 79 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 80 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 81 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 82 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 83 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 84 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 85 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 86 | OTHER DEALINGS IN THE FONT SOFTWARE. 87 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/wwwroot/css/open-iconic/ICON-LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Waybury 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/wwwroot/css/open-iconic/README.md: -------------------------------------------------------------------------------- 1 | [Open Iconic v1.1.1](http://useiconic.com/open) 2 | =========== 3 | 4 | ### Open Iconic is the open source sibling of [Iconic](http://useiconic.com). It is a hyper-legible collection of 223 icons with a tiny footprint—ready to use with Bootstrap and Foundation. [View the collection](http://useiconic.com/open#icons) 5 | 6 | 7 | 8 | ## What's in Open Iconic? 9 | 10 | * 223 icons designed to be legible down to 8 pixels 11 | * Super-light SVG files - 61.8 for the entire set 12 | * SVG sprite—the modern replacement for icon fonts 13 | * Webfont (EOT, OTF, SVG, TTF, WOFF), PNG and WebP formats 14 | * Webfont stylesheets (including versions for Bootstrap and Foundation) in CSS, LESS, SCSS and Stylus formats 15 | * PNG and WebP raster images in 8px, 16px, 24px, 32px, 48px and 64px. 16 | 17 | 18 | ## Getting Started 19 | 20 | #### For code samples and everything else you need to get started with Open Iconic, check out our [Icons](http://useiconic.com/open#icons) and [Reference](http://useiconic.com/open#reference) sections. 21 | 22 | ### General Usage 23 | 24 | #### Using Open Iconic's SVGs 25 | 26 | We like SVGs and we think they're the way to display icons on the web. Since Open Iconic are just basic SVGs, we suggest you display them like you would any other image (don't forget the `alt` attribute). 27 | 28 | ``` 29 | icon name 30 | ``` 31 | 32 | #### Using Open Iconic's SVG Sprite 33 | 34 | Open Iconic also comes in a SVG sprite which allows you to display all the icons in the set with a single request. It's like an icon font, without being a hack. 35 | 36 | Adding an icon from an SVG sprite is a little different than what you're used to, but it's still a piece of cake. *Tip: To make your icons easily style able, we suggest adding a general class to the* `` *tag and a unique class name for each different icon in the* `` *tag.* 37 | 38 | ``` 39 | 40 | 41 | 42 | ``` 43 | 44 | Sizing icons only needs basic CSS. All the icons are in a square format, so just set the `` tag with equal width and height dimensions. 45 | 46 | ``` 47 | .icon { 48 | width: 16px; 49 | height: 16px; 50 | } 51 | ``` 52 | 53 | Coloring icons is even easier. All you need to do is set the `fill` rule on the `` tag. 54 | 55 | ``` 56 | .icon-account-login { 57 | fill: #f00; 58 | } 59 | ``` 60 | 61 | To learn more about SVG Sprites, read [Chris Coyier's guide](http://css-tricks.com/svg-sprites-use-better-icon-fonts/). 62 | 63 | #### Using Open Iconic's Icon Font... 64 | 65 | 66 | ##### …with Bootstrap 67 | 68 | You can find our Bootstrap stylesheets in `font/css/open-iconic-bootstrap.{css, less, scss, styl}` 69 | 70 | 71 | ``` 72 | 73 | ``` 74 | 75 | 76 | ``` 77 | 78 | ``` 79 | 80 | ##### …with Foundation 81 | 82 | You can find our Foundation stylesheets in `font/css/open-iconic-foundation.{css, less, scss, styl}` 83 | 84 | ``` 85 | 86 | ``` 87 | 88 | 89 | ``` 90 | 91 | ``` 92 | 93 | ##### …on its own 94 | 95 | You can find our default stylesheets in `font/css/open-iconic.{css, less, scss, styl}` 96 | 97 | ``` 98 | 99 | ``` 100 | 101 | ``` 102 | 103 | ``` 104 | 105 | 106 | ## License 107 | 108 | ### Icons 109 | 110 | All code (including SVG markup) is under the [MIT License](http://opensource.org/licenses/MIT). 111 | 112 | ### Fonts 113 | 114 | All fonts are under the [SIL Licensed](http://scripts.sil.org/cms/scripts/page.php?item_id=OFL_web). 115 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/wwwroot/css/open-iconic/font/css/open-iconic-bootstrap.min.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:Icons;src:url(../fonts/open-iconic.eot);src:url(../fonts/open-iconic.eot?#iconic-sm) format('embedded-opentype'),url(../fonts/open-iconic.woff) format('woff'),url(../fonts/open-iconic.ttf) format('truetype'),url(../fonts/open-iconic.otf) format('opentype'),url(../fonts/open-iconic.svg#iconic-sm) format('svg');font-weight:400;font-style:normal}.oi{position:relative;top:1px;display:inline-block;speak:none;font-family:Icons;font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.oi:empty:before{width:1em;text-align:center;box-sizing:content-box}.oi.oi-align-center:before{text-align:center}.oi.oi-align-left:before{text-align:left}.oi.oi-align-right:before{text-align:right}.oi.oi-flip-horizontal:before{-webkit-transform:scale(-1,1);-ms-transform:scale(-1,1);transform:scale(-1,1)}.oi.oi-flip-vertical:before{-webkit-transform:scale(1,-1);-ms-transform:scale(-1,1);transform:scale(1,-1)}.oi.oi-flip-horizontal-vertical:before{-webkit-transform:scale(-1,-1);-ms-transform:scale(-1,1);transform:scale(-1,-1)}.oi-account-login:before{content:'\e000'}.oi-account-logout:before{content:'\e001'}.oi-action-redo:before{content:'\e002'}.oi-action-undo:before{content:'\e003'}.oi-align-center:before{content:'\e004'}.oi-align-left:before{content:'\e005'}.oi-align-right:before{content:'\e006'}.oi-aperture:before{content:'\e007'}.oi-arrow-bottom:before{content:'\e008'}.oi-arrow-circle-bottom:before{content:'\e009'}.oi-arrow-circle-left:before{content:'\e00a'}.oi-arrow-circle-right:before{content:'\e00b'}.oi-arrow-circle-top:before{content:'\e00c'}.oi-arrow-left:before{content:'\e00d'}.oi-arrow-right:before{content:'\e00e'}.oi-arrow-thick-bottom:before{content:'\e00f'}.oi-arrow-thick-left:before{content:'\e010'}.oi-arrow-thick-right:before{content:'\e011'}.oi-arrow-thick-top:before{content:'\e012'}.oi-arrow-top:before{content:'\e013'}.oi-audio-spectrum:before{content:'\e014'}.oi-audio:before{content:'\e015'}.oi-badge:before{content:'\e016'}.oi-ban:before{content:'\e017'}.oi-bar-chart:before{content:'\e018'}.oi-basket:before{content:'\e019'}.oi-battery-empty:before{content:'\e01a'}.oi-battery-full:before{content:'\e01b'}.oi-beaker:before{content:'\e01c'}.oi-bell:before{content:'\e01d'}.oi-bluetooth:before{content:'\e01e'}.oi-bold:before{content:'\e01f'}.oi-bolt:before{content:'\e020'}.oi-book:before{content:'\e021'}.oi-bookmark:before{content:'\e022'}.oi-box:before{content:'\e023'}.oi-briefcase:before{content:'\e024'}.oi-british-pound:before{content:'\e025'}.oi-browser:before{content:'\e026'}.oi-brush:before{content:'\e027'}.oi-bug:before{content:'\e028'}.oi-bullhorn:before{content:'\e029'}.oi-calculator:before{content:'\e02a'}.oi-calendar:before{content:'\e02b'}.oi-camera-slr:before{content:'\e02c'}.oi-caret-bottom:before{content:'\e02d'}.oi-caret-left:before{content:'\e02e'}.oi-caret-right:before{content:'\e02f'}.oi-caret-top:before{content:'\e030'}.oi-cart:before{content:'\e031'}.oi-chat:before{content:'\e032'}.oi-check:before{content:'\e033'}.oi-chevron-bottom:before{content:'\e034'}.oi-chevron-left:before{content:'\e035'}.oi-chevron-right:before{content:'\e036'}.oi-chevron-top:before{content:'\e037'}.oi-circle-check:before{content:'\e038'}.oi-circle-x:before{content:'\e039'}.oi-clipboard:before{content:'\e03a'}.oi-clock:before{content:'\e03b'}.oi-cloud-download:before{content:'\e03c'}.oi-cloud-upload:before{content:'\e03d'}.oi-cloud:before{content:'\e03e'}.oi-cloudy:before{content:'\e03f'}.oi-code:before{content:'\e040'}.oi-cog:before{content:'\e041'}.oi-collapse-down:before{content:'\e042'}.oi-collapse-left:before{content:'\e043'}.oi-collapse-right:before{content:'\e044'}.oi-collapse-up:before{content:'\e045'}.oi-command:before{content:'\e046'}.oi-comment-square:before{content:'\e047'}.oi-compass:before{content:'\e048'}.oi-contrast:before{content:'\e049'}.oi-copywriting:before{content:'\e04a'}.oi-credit-card:before{content:'\e04b'}.oi-crop:before{content:'\e04c'}.oi-dashboard:before{content:'\e04d'}.oi-data-transfer-download:before{content:'\e04e'}.oi-data-transfer-upload:before{content:'\e04f'}.oi-delete:before{content:'\e050'}.oi-dial:before{content:'\e051'}.oi-document:before{content:'\e052'}.oi-dollar:before{content:'\e053'}.oi-double-quote-sans-left:before{content:'\e054'}.oi-double-quote-sans-right:before{content:'\e055'}.oi-double-quote-serif-left:before{content:'\e056'}.oi-double-quote-serif-right:before{content:'\e057'}.oi-droplet:before{content:'\e058'}.oi-eject:before{content:'\e059'}.oi-elevator:before{content:'\e05a'}.oi-ellipses:before{content:'\e05b'}.oi-envelope-closed:before{content:'\e05c'}.oi-envelope-open:before{content:'\e05d'}.oi-euro:before{content:'\e05e'}.oi-excerpt:before{content:'\e05f'}.oi-expand-down:before{content:'\e060'}.oi-expand-left:before{content:'\e061'}.oi-expand-right:before{content:'\e062'}.oi-expand-up:before{content:'\e063'}.oi-external-link:before{content:'\e064'}.oi-eye:before{content:'\e065'}.oi-eyedropper:before{content:'\e066'}.oi-file:before{content:'\e067'}.oi-fire:before{content:'\e068'}.oi-flag:before{content:'\e069'}.oi-flash:before{content:'\e06a'}.oi-folder:before{content:'\e06b'}.oi-fork:before{content:'\e06c'}.oi-fullscreen-enter:before{content:'\e06d'}.oi-fullscreen-exit:before{content:'\e06e'}.oi-globe:before{content:'\e06f'}.oi-graph:before{content:'\e070'}.oi-grid-four-up:before{content:'\e071'}.oi-grid-three-up:before{content:'\e072'}.oi-grid-two-up:before{content:'\e073'}.oi-hard-drive:before{content:'\e074'}.oi-header:before{content:'\e075'}.oi-headphones:before{content:'\e076'}.oi-heart:before{content:'\e077'}.oi-home:before{content:'\e078'}.oi-image:before{content:'\e079'}.oi-inbox:before{content:'\e07a'}.oi-infinity:before{content:'\e07b'}.oi-info:before{content:'\e07c'}.oi-italic:before{content:'\e07d'}.oi-justify-center:before{content:'\e07e'}.oi-justify-left:before{content:'\e07f'}.oi-justify-right:before{content:'\e080'}.oi-key:before{content:'\e081'}.oi-laptop:before{content:'\e082'}.oi-layers:before{content:'\e083'}.oi-lightbulb:before{content:'\e084'}.oi-link-broken:before{content:'\e085'}.oi-link-intact:before{content:'\e086'}.oi-list-rich:before{content:'\e087'}.oi-list:before{content:'\e088'}.oi-location:before{content:'\e089'}.oi-lock-locked:before{content:'\e08a'}.oi-lock-unlocked:before{content:'\e08b'}.oi-loop-circular:before{content:'\e08c'}.oi-loop-square:before{content:'\e08d'}.oi-loop:before{content:'\e08e'}.oi-magnifying-glass:before{content:'\e08f'}.oi-map-marker:before{content:'\e090'}.oi-map:before{content:'\e091'}.oi-media-pause:before{content:'\e092'}.oi-media-play:before{content:'\e093'}.oi-media-record:before{content:'\e094'}.oi-media-skip-backward:before{content:'\e095'}.oi-media-skip-forward:before{content:'\e096'}.oi-media-step-backward:before{content:'\e097'}.oi-media-step-forward:before{content:'\e098'}.oi-media-stop:before{content:'\e099'}.oi-medical-cross:before{content:'\e09a'}.oi-menu:before{content:'\e09b'}.oi-microphone:before{content:'\e09c'}.oi-minus:before{content:'\e09d'}.oi-monitor:before{content:'\e09e'}.oi-moon:before{content:'\e09f'}.oi-move:before{content:'\e0a0'}.oi-musical-note:before{content:'\e0a1'}.oi-paperclip:before{content:'\e0a2'}.oi-pencil:before{content:'\e0a3'}.oi-people:before{content:'\e0a4'}.oi-person:before{content:'\e0a5'}.oi-phone:before{content:'\e0a6'}.oi-pie-chart:before{content:'\e0a7'}.oi-pin:before{content:'\e0a8'}.oi-play-circle:before{content:'\e0a9'}.oi-plus:before{content:'\e0aa'}.oi-power-standby:before{content:'\e0ab'}.oi-print:before{content:'\e0ac'}.oi-project:before{content:'\e0ad'}.oi-pulse:before{content:'\e0ae'}.oi-puzzle-piece:before{content:'\e0af'}.oi-question-mark:before{content:'\e0b0'}.oi-rain:before{content:'\e0b1'}.oi-random:before{content:'\e0b2'}.oi-reload:before{content:'\e0b3'}.oi-resize-both:before{content:'\e0b4'}.oi-resize-height:before{content:'\e0b5'}.oi-resize-width:before{content:'\e0b6'}.oi-rss-alt:before{content:'\e0b7'}.oi-rss:before{content:'\e0b8'}.oi-script:before{content:'\e0b9'}.oi-share-boxed:before{content:'\e0ba'}.oi-share:before{content:'\e0bb'}.oi-shield:before{content:'\e0bc'}.oi-signal:before{content:'\e0bd'}.oi-signpost:before{content:'\e0be'}.oi-sort-ascending:before{content:'\e0bf'}.oi-sort-descending:before{content:'\e0c0'}.oi-spreadsheet:before{content:'\e0c1'}.oi-star:before{content:'\e0c2'}.oi-sun:before{content:'\e0c3'}.oi-tablet:before{content:'\e0c4'}.oi-tag:before{content:'\e0c5'}.oi-tags:before{content:'\e0c6'}.oi-target:before{content:'\e0c7'}.oi-task:before{content:'\e0c8'}.oi-terminal:before{content:'\e0c9'}.oi-text:before{content:'\e0ca'}.oi-thumb-down:before{content:'\e0cb'}.oi-thumb-up:before{content:'\e0cc'}.oi-timer:before{content:'\e0cd'}.oi-transfer:before{content:'\e0ce'}.oi-trash:before{content:'\e0cf'}.oi-underline:before{content:'\e0d0'}.oi-vertical-align-bottom:before{content:'\e0d1'}.oi-vertical-align-center:before{content:'\e0d2'}.oi-vertical-align-top:before{content:'\e0d3'}.oi-video:before{content:'\e0d4'}.oi-volume-high:before{content:'\e0d5'}.oi-volume-low:before{content:'\e0d6'}.oi-volume-off:before{content:'\e0d7'}.oi-warning:before{content:'\e0d8'}.oi-wifi:before{content:'\e0d9'}.oi-wrench:before{content:'\e0da'}.oi-x:before{content:'\e0db'}.oi-yen:before{content:'\e0dc'}.oi-zoom-in:before{content:'\e0dd'}.oi-zoom-out:before{content:'\e0de'} -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/wwwroot/css/open-iconic/font/fonts/open-iconic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Practical-ASP-NET/Tailwind.Extensions.AspNetCore/0f01d2bfe18ebe6fa32bc16e0b525e7755bc00d6/src/SampleApps/BlazorServer/wwwroot/css/open-iconic/font/fonts/open-iconic.eot -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/wwwroot/css/open-iconic/font/fonts/open-iconic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Practical-ASP-NET/Tailwind.Extensions.AspNetCore/0f01d2bfe18ebe6fa32bc16e0b525e7755bc00d6/src/SampleApps/BlazorServer/wwwroot/css/open-iconic/font/fonts/open-iconic.otf -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/wwwroot/css/open-iconic/font/fonts/open-iconic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | Created by FontForge 20120731 at Tue Jul 1 20:39:22 2014 9 | By P.J. Onori 10 | Created by P.J. Onori with FontForge 2.0 (http://fontforge.sf.net) 11 | 12 | 13 | 14 | 27 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 45 | 47 | 49 | 51 | 53 | 55 | 57 | 59 | 61 | 63 | 65 | 67 | 69 | 71 | 74 | 76 | 79 | 81 | 84 | 86 | 88 | 91 | 93 | 95 | 98 | 100 | 102 | 104 | 106 | 109 | 112 | 115 | 117 | 121 | 123 | 125 | 127 | 130 | 132 | 134 | 136 | 138 | 141 | 143 | 145 | 147 | 149 | 151 | 153 | 155 | 157 | 159 | 162 | 165 | 167 | 169 | 172 | 174 | 177 | 179 | 181 | 183 | 185 | 189 | 191 | 194 | 196 | 198 | 200 | 202 | 205 | 207 | 209 | 211 | 213 | 215 | 218 | 220 | 222 | 224 | 226 | 228 | 230 | 232 | 234 | 236 | 238 | 241 | 243 | 245 | 247 | 249 | 251 | 253 | 256 | 259 | 261 | 263 | 265 | 267 | 269 | 272 | 274 | 276 | 280 | 282 | 285 | 287 | 289 | 292 | 295 | 298 | 300 | 302 | 304 | 306 | 309 | 312 | 314 | 316 | 318 | 320 | 322 | 324 | 326 | 330 | 334 | 338 | 340 | 343 | 345 | 347 | 349 | 351 | 353 | 355 | 358 | 360 | 363 | 365 | 367 | 369 | 371 | 373 | 375 | 377 | 379 | 381 | 383 | 386 | 388 | 390 | 392 | 394 | 396 | 399 | 401 | 404 | 406 | 408 | 410 | 412 | 414 | 416 | 419 | 421 | 423 | 425 | 428 | 431 | 435 | 438 | 440 | 442 | 444 | 446 | 448 | 451 | 453 | 455 | 457 | 460 | 462 | 464 | 466 | 468 | 471 | 473 | 477 | 479 | 481 | 483 | 486 | 488 | 490 | 492 | 494 | 496 | 499 | 501 | 504 | 506 | 509 | 512 | 515 | 517 | 520 | 522 | 524 | 526 | 529 | 532 | 534 | 536 | 539 | 542 | 543 | 544 | -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Practical-ASP-NET/Tailwind.Extensions.AspNetCore/0f01d2bfe18ebe6fa32bc16e0b525e7755bc00d6/src/SampleApps/BlazorServer/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/wwwroot/css/open-iconic/font/fonts/open-iconic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Practical-ASP-NET/Tailwind.Extensions.AspNetCore/0f01d2bfe18ebe6fa32bc16e0b525e7755bc00d6/src/SampleApps/BlazorServer/wwwroot/css/open-iconic/font/fonts/open-iconic.woff -------------------------------------------------------------------------------- /src/SampleApps/BlazorServer/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Practical-ASP-NET/Tailwind.Extensions.AspNetCore/0f01d2bfe18ebe6fa32bc16e0b525e7755bc00d6/src/SampleApps/BlazorServer/wwwroot/favicon.ico -------------------------------------------------------------------------------- /src/Tailwind.Extensions.AspNetCore.Tests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/TailwindHostedService.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.Hosting; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace Tailwind; 8 | 9 | public class TailwindOptions 10 | { 11 | public string? InputFile { get; set; } 12 | public string? OutputFile { get; set; } 13 | public string? TailwindCliPath { get; set; } 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 | 46 | var input = _options.InputFile; 47 | var output = _options.OutputFile; 48 | 49 | Guard.AgainstNull(input, "check Tailwind configuration"); 50 | Guard.AgainstNull(output, "check Tailwind configuration"); 51 | 52 | _logger.LogInformation($"tailwind -i {input} -o {output} --watch"); 53 | 54 | var processName = string.IsNullOrEmpty(_options.TailwindCliPath) ? "tailwind" : _options.TailwindCliPath; 55 | _process = _tailwindProcess.StartProcess(processName, $"-i {input} -o {output} --watch"); 56 | 57 | return Task.CompletedTask; 58 | } 59 | 60 | public Task StopAsync(CancellationToken cancellationToken) 61 | { 62 | _process?.Kill(); 63 | return Task.CompletedTask; 64 | } 65 | 66 | public void Dispose() 67 | { 68 | _process?.Dispose(); 69 | } 70 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 startInfo = new ProcessStartInfo 26 | { 27 | FileName = cmdName, 28 | Arguments = arguments, 29 | RedirectStandardOutput = true, 30 | RedirectStandardError = true, 31 | UseShellExecute = false, 32 | CreateNoWindow = true 33 | }; 34 | 35 | var process = Process.Start(startInfo); 36 | 37 | if (process == null) 38 | { 39 | Console.WriteLine($"Could not start process: {cmdName}"); 40 | return null; 41 | } 42 | 43 | process.OutputDataReceived += (sender, data) => 44 | { 45 | if (!string.IsNullOrEmpty(data.Data)) 46 | { 47 | Console.WriteLine("tailwind: " + data.Data); 48 | } 49 | }; 50 | 51 | process.ErrorDataReceived += (sender, data) => 52 | { 53 | if (!string.IsNullOrEmpty(data.Data)) 54 | { 55 | Console.WriteLine("tailwind: " +data.Data); 56 | } 57 | }; 58 | 59 | process.BeginOutputReadLine(); 60 | process.BeginErrorReadLine(); 61 | 62 | return process; 63 | } 64 | catch (Exception ex) 65 | { 66 | Console.WriteLine($"Error starting process: {ex.Message}"); 67 | throw; 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------