diff --git a/.csharpierrc b/.csharpierrc new file mode 100644 index 0000000..7dd4c1f --- /dev/null +++ b/.csharpierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 100, + "useTabs": false, + "tabWidth": 4 +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7d3fa4f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 80 + +[*.cs] +indent_size = 4 +max_line_length = 100 diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml new file mode 100644 index 0000000..0b461bf --- /dev/null +++ b/.github/workflows/continuous.yaml @@ -0,0 +1,41 @@ +name: Continuous tests/builds/prerelease + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: "8.x" + + - name: Pack ViteFest + working-directory: src/ViteFest + run: dotnet pack -c Release -o ../../dist + + - name: Pack ViteFest.AspNetCore + working-directory: src/ViteFest.AspNetCore + run: dotnet pack -c Release -o ../../dist + + - name: Push Nuget packages + working-directory: dist + env: + NUGET_API: https://api.nuget.org/v3/index.json + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: dotnet nuget push ./*.nupkg -s $NUGET_API -k $NUGET_API_KEY + + - name: Upload Artifacts + uses: actions/upload-artifact@v2 + with: + name: dist + path: dist/**/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8267c93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +bin/ +obj/ +dist/ + +.vs/ +.idea/ +*.user + +.DS_Store +Thumbs.db diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3049222 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,24 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.idea": true, + "**/.vs": true, + "**/bin": true, + "**/obj": true, + "**/*.user": true, + "**/.DS_Store": true, + "**/Thumbs.db": true + }, + "editor.rulers": [ + 80, + 100 + ], + "[csharp]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "csharpier.csharpier-vscode", + }, + "[json][jsonc]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "vscode.json-language-features" + } +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1d9ea43 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,9 @@ + + + + enable + true + $(NoWarn);CS0436;CS1591;NETSDK1138; + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1956d9f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Robert Macfie + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e147e05..c51b24a 100644 --- a/README.md +++ b/README.md @@ -1 +1,40 @@ -# vitefest \ No newline at end of file +# ViteFest + +Utilize Vite build assets in ASP.NET Core and other .NET applications. + + +## ASP.NET Core + +`vite.config.ts`: +```typescript +import { defineConfig } from 'vite'; + +export default defineConfig({ + base: '/dist/', + build: { + outDir: 'wwwroot/dist', + manifest: 'vite-manifest.json', + rollupOptions: { + input: ['ClientApp/shared.ts', 'ClientApp/login.ts'] + } + } +}); +``` + +`Program.cs`: +```csharp +builder.Services.AddViteFest(o => +{ + o.ManifestFile = "dist/vite-manifest.json"; + o.BaseUrl = "/dist/"; +}); +``` + + +`Login.cshtml`: +```html +@inject ViteFest.IVite Vite + + + +``` diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..8743ce6 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "0.28.2", + "commands": [ + "dotnet-csharpier" + ] + } + } +} diff --git a/src/ViteFest.AspNetCore/Assembly.cs b/src/ViteFest.AspNetCore/Assembly.cs new file mode 100644 index 0000000..fe9447b --- /dev/null +++ b/src/ViteFest.AspNetCore/Assembly.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ViteFest.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/ViteFest.AspNetCore/ViteFest.AspNetCore.csproj b/src/ViteFest.AspNetCore/ViteFest.AspNetCore.csproj new file mode 100644 index 0000000..fdd59cf --- /dev/null +++ b/src/ViteFest.AspNetCore/ViteFest.AspNetCore.csproj @@ -0,0 +1,38 @@ + + + + net6.0 + 12 + + true + snupkg + true + + Robert Macfie + Copyright Robert Macfie + MIT + A simple extension to encapsulate DI configuration per .NET + project + vite + https://github.com/rmacfie/vitefest + README.md + + + + + + + + + + + + + + + + + + + + diff --git a/src/ViteFest.AspNetCore/ViteHostedService.cs b/src/ViteFest.AspNetCore/ViteHostedService.cs new file mode 100644 index 0000000..0f20698 --- /dev/null +++ b/src/ViteFest.AspNetCore/ViteHostedService.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; + +namespace ViteFest.AspNetCore; + +internal class ViteHostedService(IViteState state) : BackgroundService +{ + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + state.Initialize(); + return Task.CompletedTask; + } +} diff --git a/src/ViteFest.AspNetCore/ViteServiceExtensions.cs b/src/ViteFest.AspNetCore/ViteServiceExtensions.cs new file mode 100644 index 0000000..899b143 --- /dev/null +++ b/src/ViteFest.AspNetCore/ViteServiceExtensions.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using ViteFest; +using ViteFest.AspNetCore; + +#pragma warning disable IDE0130 // ReSharper disable CheckNamespace + +namespace Microsoft.Extensions.DependencyInjection; + +public static class ViteServiceExtensions +{ + public static IServiceCollection AddViteFest( + this IServiceCollection services, + Action configure + ) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + if (services.Any(x => x.ServiceType == typeof(Vite))) + { + throw new InvalidOperationException( + "ViteFest has already been added to the service collection." + ); + } + + var options = new ViteOptions(); + configure(options); + options.Validate(); + + services.AddSingleton(x => + { + var hostEnvironment = x.GetRequiredService(); + return new ViteEnvironment( + options, + hostEnvironment.WebRootPath, + hostEnvironment.IsDevelopment() + ); + }); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddHostedService(); + + return services; + } +} diff --git a/src/ViteFest.Tests/Stub.cs b/src/ViteFest.Tests/Stub.cs new file mode 100644 index 0000000..c4898e7 --- /dev/null +++ b/src/ViteFest.Tests/Stub.cs @@ -0,0 +1,27 @@ +namespace ViteFest.Tests; + +internal static class Stub +{ + internal static ViteResource Resource( + string key, + string? path = null, + bool isEntry = false, + bool isDynamicEntry = false, + string[]? imports = null, + string[]? dynamicImports = null, + string[]? assetPaths = null, + string[]? cssPaths = null + ) + { + return new ViteResource( + key, + path ?? "dir/" + key, + isEntry, + isDynamicEntry, + imports ?? [], + dynamicImports ?? [], + assetPaths ?? [], + cssPaths ?? [] + ); + } +} diff --git a/src/ViteFest.Tests/TestFiles/manifest-empty.json b/src/ViteFest.Tests/TestFiles/manifest-empty.json new file mode 100644 index 0000000..e69de29 diff --git a/src/ViteFest.Tests/TestFiles/manifest-invalid.json b/src/ViteFest.Tests/TestFiles/manifest-invalid.json new file mode 100644 index 0000000..3776ed5 --- /dev/null +++ b/src/ViteFest.Tests/TestFiles/manifest-invalid.json @@ -0,0 +1 @@ +This is not valid JSON syntax. diff --git a/src/ViteFest.Tests/TestFiles/manifest.json b/src/ViteFest.Tests/TestFiles/manifest.json new file mode 100644 index 0000000..729e76b --- /dev/null +++ b/src/ViteFest.Tests/TestFiles/manifest.json @@ -0,0 +1,27 @@ +{ + "../../node_modules/@fontsource-variable/inter/files/inter-latin-slnt-normal.woff2": { + "file": "assets/inter-latin-slnt-normal-BpMivaJw.woff2", + "src": "../../node_modules/@fontsource-variable/inter/files/inter-latin-slnt-normal.woff2" + }, + "Components/Common/Logotype.svg": { + "file": "assets/Logotype-BQihiMnY.svg", + "src": "Components/Common/Logotype.svg" + }, + "Components/Home.ts": { + "file": "assets/Home-B2lgNECc.js", + "name": "Home", + "src": "Components/Home.ts", + "isEntry": true, + "css": [ + "assets/Home-Bs7ZzuBZ.css" + ], + "assets": [ + "assets/Logotype-BQihiMnY.svg" + ] + }, + "Components/Root.css": { + "file": "assets/Root-B-NYCzS9.css", + "src": "Components/Root.css", + "isEntry": true + } +} diff --git a/src/ViteFest.Tests/ViteEnvironmentTests.cs b/src/ViteFest.Tests/ViteEnvironmentTests.cs new file mode 100644 index 0000000..1688845 --- /dev/null +++ b/src/ViteFest.Tests/ViteEnvironmentTests.cs @@ -0,0 +1,76 @@ +using System.IO; +using NUnit.Framework; + +namespace ViteFest.Tests; + +public class ViteEnvironmentTests +{ + [Test] + public void It_uses_complete_values_from_options() + { + var env = new ViteEnvironment( + new ViteOptions + { + ManifestFile = "/dev/foo/wwwroot/dist/manifest.json", + BaseUrl = "/dist/", + Watch = true + } + ); + + Assert.Multiple(() => + { + Assert.That(env.ManifestFile, Is.EqualTo("/dev/foo/wwwroot/dist/manifest.json")); + Assert.That(env.BaseUrl, Is.EqualTo("/dist/")); + Assert.That(env.Watch, Is.True); + }); + } + + [Test] + public void It_combines_web_root_with_relative_manifest_path() + { + var env = new ViteEnvironment( + new ViteOptions { ManifestFile = "dist/manifest.json" }, + "/dev/foo/wwwroot" + ); + + Assert.That(env.ManifestFile, Is.EqualTo("/dev/foo/wwwroot/dist/manifest.json")); + } + + [Test] + public void It_defaults_to_root_base_url() + { + var env = new ViteEnvironment(new ViteOptions { ManifestFile = "/dev/proj/manifest.json" }); + + Assert.That(env.BaseUrl, Is.EqualTo("/")); + } + + [Test] + public void It_defaults_to_disable_watch() + { + var env = new ViteEnvironment(new ViteOptions { ManifestFile = "/dev/proj/manifest.json" }); + + Assert.That(env.Watch, Is.False); + } + + [Test] + public void It_falls_back_on_dev_mode_for_watch() + { + var env = new ViteEnvironment( + new ViteOptions { ManifestFile = "/dev/proj/manifest.json" }, + isDevelopment: true + ); + + Assert.That(env.Watch, Is.True); + } + + [Test] + public void It_falls_back_on_current_dir_for_web_root() + { + var env = new ViteEnvironment(new ViteOptions { ManifestFile = "dist/manifest.json" }); + + Assert.That( + env.ManifestFile, + Is.EqualTo(Path.Combine(Directory.GetCurrentDirectory(), "dist/manifest.json")) + ); + } +} diff --git a/src/ViteFest.Tests/ViteFest.Tests.csproj b/src/ViteFest.Tests/ViteFest.Tests.csproj new file mode 100644 index 0000000..145b20b --- /dev/null +++ b/src/ViteFest.Tests/ViteFest.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + false + true + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/src/ViteFest.Tests/ViteManifestReaderTests.cs b/src/ViteFest.Tests/ViteManifestReaderTests.cs new file mode 100644 index 0000000..c89d453 --- /dev/null +++ b/src/ViteFest.Tests/ViteManifestReaderTests.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using NUnit.Framework; + +namespace ViteFest.Tests; + +public class ViteManifestReaderTests +{ + private const string ManifestFile = "TestFiles/manifest.json"; + private const string ManifestEmptyFile = "TestFiles/manifest-empty.json"; + private const string ManifestInvalidFile = "TestFiles/manifest-invalid.json"; + private const string ManifestNonExistentFile = "TestFiles/manifest-not-exists.json"; + + private ViteManifestReader _sut; + + [SetUp] + public void Setup() + { + _sut = new ViteManifestReader(); + } + + [Test] + public void It_reads_a_manifest_file() + { + // act + var manifest = _sut.ReadManifest(ManifestFile); + + // assert + Assert.That(manifest, Is.Not.Null); + } + + [Test] + public void It_maps_an_entry_script_chunk() + { + var manifest = _sut.ReadManifest(ManifestFile); + + // act + var chunk = manifest.FirstOrDefault(x => x.Src == "Components/Home.ts")!; + + // assert + Assert.Multiple(() => + { + Assert.That(chunk, Is.Not.Null); + Assert.That(chunk.File, Is.EqualTo("assets/Home-B2lgNECc.js")); + Assert.That(chunk.IsEntry, Is.True); + Assert.That(chunk.IsDynamicEntry, Is.Null); + Assert.That(chunk.Imports, Is.Null); + Assert.That(chunk.DynamicImports, Is.Null); + Assert.That(chunk.Assets, Is.EquivalentTo(new[] { "assets/Logotype-BQihiMnY.svg" })); + Assert.That(chunk.Css, Is.EquivalentTo(new[] { "assets/Home-Bs7ZzuBZ.css" })); + }); + } + + [Test] + public void It_maps_an_entry_css_chunk() + { + var manifest = _sut.ReadManifest(ManifestFile); + + // act + var chunk = manifest.FirstOrDefault(x => x.Src == "Components/Root.css")!; + + // assert + Assert.Multiple(() => + { + Assert.That(chunk, Is.Not.Null); + Assert.That(chunk.File, Is.EqualTo("assets/Root-B-NYCzS9.css")); + Assert.That(chunk.IsEntry, Is.True); + Assert.That(chunk.IsDynamicEntry, Is.Null); + Assert.That(chunk.Imports, Is.Null); + Assert.That(chunk.DynamicImports, Is.Null); + Assert.That(chunk.Assets, Is.Null); + Assert.That(chunk.Css, Is.Null); + }); + } + + [Test] + public void It_maps_an_asset_chunk() + { + var manifest = _sut.ReadManifest(ManifestFile); + + // act + var chunk = manifest.FirstOrDefault(x => x.Src == "Components/Common/Logotype.svg")!; + + // assert + Assert.Multiple(() => + { + Assert.That(chunk, Is.Not.Null); + Assert.That(chunk.File, Is.EqualTo("assets/Logotype-BQihiMnY.svg")); + Assert.That(chunk.IsEntry, Is.Null); + Assert.That(chunk.IsDynamicEntry, Is.Null); + Assert.That(chunk.Imports, Is.Null); + Assert.That(chunk.DynamicImports, Is.Null); + Assert.That(chunk.Assets, Is.Null); + Assert.That(chunk.Css, Is.Null); + }); + } + + [Test] + public void It_throws_when_manifest_file_is_empty() + { + // act + var act = new Action(() => _sut.ReadManifest(ManifestEmptyFile)); + + // assert + Assert.That(act, Throws.TypeOf()); + } + + [Test] + public void It_throws_when_manifest_file_is_invalid() + { + // act + var act = new Action(() => _sut.ReadManifest(ManifestInvalidFile)); + + // assert + Assert.That(act, Throws.TypeOf()); + } + + [Test] + public void It_throws_when_manifest_file_is_missing() + { + // act + var act = new Action(() => _sut.ReadManifest(ManifestNonExistentFile)); + + // assert + Assert.That(act, Throws.TypeOf()); + } +} diff --git a/src/ViteFest.Tests/ViteResourceMapperTests.cs b/src/ViteFest.Tests/ViteResourceMapperTests.cs new file mode 100644 index 0000000..e7b690a --- /dev/null +++ b/src/ViteFest.Tests/ViteResourceMapperTests.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Linq; +using FakeItEasy; +using NUnit.Framework; + +namespace ViteFest.Tests; + +public class ViteResourceMapperTests +{ + private IViteEnvironment _environment; + private ViteResourceMapper _sut; + + [SetUp] + public void SetUp() + { + _environment = A.Fake(); + A.CallTo(() => _environment.BaseUrl).Returns("/dist/"); + + _sut = new ViteResourceMapper(_environment); + } + + [Test] + public void It_maps_all_values() + { + var resources = _sut.Map( + new[] + { + new ViteManifestChunk + { + Src = "a.ts", + File = "assets/a.js", + IsEntry = true, + IsDynamicEntry = true, + Imports = new List { "b.ts" }, + DynamicImports = new List { "c.ts" }, + Css = new List { "assets/styles.css" }, + Assets = new List { "assets/image.png" } + } + } + ); + + Assert.Multiple(() => + { + Assert.That(resources, Has.Count.EqualTo(1)); + + var resource = resources.Single(); + + Assert.That(resource.Key, Is.EqualTo("a.ts")); + Assert.That(resource.Url, Is.EqualTo("/dist/assets/a.js")); + Assert.That(resource.IsEntry, Is.True); + Assert.That(resource.IsDynamicEntry, Is.True); + Assert.That(resource.Imports, Is.EquivalentTo(new[] { "b.ts" })); + Assert.That(resource.DynamicImports, Is.EquivalentTo(new[] { "c.ts" })); + Assert.That(resource.CssUrls, Is.EquivalentTo(new[] { "/dist/assets/styles.css" })); + Assert.That(resource.AssetUrls, Is.EquivalentTo(new[] { "/dist/assets/image.png" })); + }); + } + + [Test] + public void It_maps_empty_optional_values() + { + var resources = _sut.Map( + new[] + { + new ViteManifestChunk { Src = "a.ts", File = "assets/a.js" } + } + ); + + Assert.Multiple(() => + { + Assert.That(resources, Has.Count.EqualTo(1)); + + var resource = resources.Single(); + + Assert.That(resource.Key, Is.EqualTo("a.ts")); + Assert.That(resource.Url, Is.EqualTo("/dist/assets/a.js")); + Assert.That(resource.IsEntry, Is.False); + Assert.That(resource.IsDynamicEntry, Is.False); + Assert.That(resource.Imports, Is.Empty); + Assert.That(resource.DynamicImports, Is.Empty); + Assert.That(resource.CssUrls, Is.Empty); + Assert.That(resource.AssetUrls, Is.Empty); + }); + } +} diff --git a/src/ViteFest.Tests/ViteStateTests.cs b/src/ViteFest.Tests/ViteStateTests.cs new file mode 100644 index 0000000..347e2a9 --- /dev/null +++ b/src/ViteFest.Tests/ViteStateTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using FakeItEasy; +using NUnit.Framework; + +// ReSharper disable CollectionNeverUpdated.Local + +namespace ViteFest.Tests; + +public class ViteStateTests +{ + private List _resources; + private ViteState _sut; + + [TearDown] + public void TearDown() + { + _sut.Dispose(); + } + + [SetUp] + public void Setup() + { + var chunks = new List(); + _resources = []; + + var environment = A.Fake(); + var manifestReader = A.Fake(); + var resourceMapper = A.Fake(); + + A.CallTo(() => environment.ManifestFile).Returns("/tmp/manifest.json"); + A.CallTo(() => manifestReader.ReadManifest("/tmp/manifest.json")).Returns(chunks); + A.CallTo(() => resourceMapper.Map(chunks)).Returns(_resources); + + _sut = new ViteState(environment, manifestReader, resourceMapper); + } + + [Test] + public void TryGet_finds_existing_chunk() + { + var chunkA = Stub.Resource("a.ts"); + var chunkB = Stub.Resource("b.ts"); + _resources.AddRange(new[] { chunkA, chunkB }); + _sut.Initialize(); + + var actualReturn = _sut.TryGet("b.ts", out var actualChunk); + + Assert.Multiple(() => + { + Assert.That(actualReturn, Is.True); + Assert.That(actualChunk, Is.SameAs(chunkB)); + }); + } + + [Test] + public void TryGet_is_case_insensitive() + { + var chunk = Stub.Resource("CHUNKNaME.ts"); + _resources.AddRange(new[] { chunk }); + _sut.Initialize(); + + var actualReturn = _sut.TryGet("chunknAme.ts", out var actualChunk); + + Assert.Multiple(() => + { + Assert.That(actualReturn, Is.True); + Assert.That(actualChunk, Is.SameAs(chunk)); + }); + } + + [Test] + public void TryGet_returns_null_when_chunk_is_not_loaded() + { + var chunk = Stub.Resource("a.ts"); + _resources.AddRange(new[] { chunk }); + _sut.Initialize(); + + var actualReturn = _sut.TryGet("b.ts", out var actualChunk); + + Assert.Multiple(() => + { + Assert.That(actualReturn, Is.False); + Assert.That(actualChunk, Is.Null); + }); + } + + [Test] + public void Initialize_throws_when_called_twice() + { + _sut.Initialize(); + + var act = new Action(() => _sut.Initialize()); + + Assert.That(act, Throws.InvalidOperationException); + } + + [Test] + public void TryGet_throws_if_not_initialized() + { + var act = new Action(() => _sut.TryGet("a.ts", out _)); + + Assert.That(act, Throws.InvalidOperationException); + } +} diff --git a/src/ViteFest/Assembly.cs b/src/ViteFest/Assembly.cs new file mode 100644 index 0000000..6447c90 --- /dev/null +++ b/src/ViteFest/Assembly.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ViteFest.AspNetCore")] +[assembly: InternalsVisibleTo("ViteFest.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/ViteFest/IVite.cs b/src/ViteFest/IVite.cs new file mode 100644 index 0000000..29b16bb --- /dev/null +++ b/src/ViteFest/IVite.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace ViteFest; + +public interface IVite : IDisposable +{ + string? GetUrl(string key); + + IReadOnlyCollection GetImports(string key); + + IReadOnlyCollection GetDynamicImports(string key); + + IReadOnlyCollection GetAssetUrls(string key); + + IReadOnlyCollection GetCssUrls(string key); + + bool TryGetUrl(string key, [NotNullWhen(true)] out string? path); + + bool TryGetImports(string key, [NotNullWhen(true)] out IReadOnlyCollection? imports); + + bool TryGetDynamicImports( + string key, + [NotNullWhen(true)] out IReadOnlyCollection? dynamicImports + ); + + bool TryGetAssetUrls( + string key, + [NotNullWhen(true)] out IReadOnlyCollection? assetPaths + ); + + bool TryGetCssUrls(string key, [NotNullWhen(true)] out IReadOnlyCollection? cssPaths); +} + +public sealed class Vite : IVite +{ + private readonly IViteState _state; + + internal Vite(IViteState state) + { + _state = state; + } + + public string? GetUrl(string key) + { + return TryGetUrl(key, out var path) ? path : default; + } + + public IReadOnlyCollection GetImports(string key) + { + return TryGetImports(key, out var imports) ? imports : Array.Empty(); + } + + public IReadOnlyCollection GetDynamicImports(string key) + { + return TryGetDynamicImports(key, out var imports) ? imports : Array.Empty(); + } + + public IReadOnlyCollection GetAssetUrls(string key) + { + return TryGetAssetUrls(key, out var paths) ? paths : Array.Empty(); + } + + public IReadOnlyCollection GetCssUrls(string key) + { + return TryGetCssUrls(key, out var paths) ? paths : Array.Empty(); + } + + public bool TryGetUrl(string key, [NotNullWhen(true)] out string? path) + { + if (!_state.TryGet(key, out var chunk)) + { + path = default; + return false; + } + + path = chunk.Url; + return true; + } + + public bool TryGetImports( + string key, + [NotNullWhen(true)] out IReadOnlyCollection? imports + ) + { + if (!_state.TryGet(key, out var chunk)) + { + imports = default; + return false; + } + + imports = chunk.Imports; + return true; + } + + public bool TryGetDynamicImports( + string key, + [NotNullWhen(true)] out IReadOnlyCollection? dynamicImports + ) + { + if (!_state.TryGet(key, out var chunk)) + { + dynamicImports = default; + return false; + } + + dynamicImports = chunk.DynamicImports; + return true; + } + + public bool TryGetAssetUrls( + string key, + [NotNullWhen(true)] out IReadOnlyCollection? assetPaths + ) + { + if (!_state.TryGet(key, out var chunk)) + { + assetPaths = default; + return false; + } + + assetPaths = chunk.AssetUrls; + return true; + } + + public bool TryGetCssUrls( + string key, + [NotNullWhen(true)] out IReadOnlyCollection? cssPaths + ) + { + if (!_state.TryGet(key, out var chunk)) + { + cssPaths = default; + return false; + } + + cssPaths = chunk.CssUrls; + return true; + } + + public void Dispose() + { + _state.Dispose(); + } + + public static IVite Create(Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + var options = new ViteOptions(); + configure(options); + return Create(options); + } + + public static IVite Create(ViteOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + options.Validate(); + + var env = new ViteEnvironment(options); + var manifestReader = new ViteManifestReader(); + var resourceMapper = new ViteResourceMapper(env); + var state = new ViteState(env, manifestReader, resourceMapper); + + state.Initialize(); + + return new Vite(state); + } +} diff --git a/src/ViteFest/IViteEnvironment.cs b/src/ViteFest/IViteEnvironment.cs new file mode 100644 index 0000000..e6699df --- /dev/null +++ b/src/ViteFest/IViteEnvironment.cs @@ -0,0 +1,42 @@ +using System; +using System.IO; + +namespace ViteFest +{ + public interface IViteEnvironment + { + string ManifestFile { get; } + + string BaseUrl { get; } + + bool Watch { get; } + } + + public class ViteEnvironment : IViteEnvironment + { + public ViteEnvironment( + ViteOptions options, + string? webRootPath = null, + bool isDevelopment = false + ) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + ManifestFile = Path.IsPathRooted(options.ManifestFile) + ? options.ManifestFile + : Path.Combine( + webRootPath ?? Directory.GetCurrentDirectory(), + options.ManifestFile + ); + BaseUrl = options.BaseUrl ?? "/"; + Watch = options.Watch ?? isDevelopment; + } + + public string ManifestFile { get; } + public string BaseUrl { get; } + public bool Watch { get; } + } +} diff --git a/src/ViteFest/IViteManifestReader.cs b/src/ViteFest/IViteManifestReader.cs new file mode 100644 index 0000000..fe7552f --- /dev/null +++ b/src/ViteFest/IViteManifestReader.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; + +namespace ViteFest +{ + internal interface IViteManifestReader + { + IReadOnlyCollection ReadManifest(string manifestPath); + } + + internal class ViteManifestReader : IViteManifestReader + { + private static JsonSerializerOptions JsonOptions { get; } = new(); + + public IReadOnlyCollection ReadManifest(string path) + { + var absolutePath = Path.GetFullPath(path); + var json = File.ReadAllText(absolutePath); + var chunks = JsonSerializer.Deserialize>( + json, + JsonOptions + ); + + if (chunks is null) + { + throw new Exception($"The manifest file was empty ('{absolutePath}')"); + } + + return chunks.Values; + } + } +} diff --git a/src/ViteFest/IViteResourceMapper.cs b/src/ViteFest/IViteResourceMapper.cs new file mode 100644 index 0000000..307c6c8 --- /dev/null +++ b/src/ViteFest/IViteResourceMapper.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ViteFest +{ + internal interface IViteResourceMapper + { + IReadOnlyCollection Map(IEnumerable chunks); + } + + internal class ViteResourceMapper : IViteResourceMapper + { + private readonly IViteEnvironment _env; + + public ViteResourceMapper(IViteEnvironment env) + { + _env = env; + } + + public IReadOnlyCollection Map(IEnumerable chunks) + { + return chunks.Select(x => Map(x, _env.BaseUrl)).ToArray(); + } + + private static ViteResource Map(ViteManifestChunk chunk, string baseUrl) + { + return new ViteResource( + chunk.Src, + $"{baseUrl}{chunk.File}", + chunk.IsEntry ?? false, + chunk.IsDynamicEntry ?? false, + chunk.Imports?.ToArray() ?? Array.Empty(), + chunk.DynamicImports?.ToArray() ?? Array.Empty(), + chunk.Assets?.Select(path => $"{baseUrl}{path}").ToArray() ?? Array.Empty(), + chunk.Css?.Select(path => $"{baseUrl}{path}").ToArray() ?? Array.Empty() + ); + } + } +} diff --git a/src/ViteFest/IViteState.cs b/src/ViteFest/IViteState.cs new file mode 100644 index 0000000..9991bdb --- /dev/null +++ b/src/ViteFest/IViteState.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading; + +namespace ViteFest; + +internal interface IViteState : IDisposable +{ + void Initialize(); + bool TryGet(string key, [NotNullWhen(true)] out ViteResource? resource); +} + +internal sealed class ViteState : IViteState +{ + private readonly IViteEnvironment _environment; + private readonly IViteManifestReader _manifestReader; + private readonly IViteResourceMapper _resourceMapper; + + private Timer? _debouncer; + private Dictionary? _state; + private FileSystemWatcher? _watcher; + + public ViteState( + IViteEnvironment environment, + IViteManifestReader manifestReader, + IViteResourceMapper resourceMapper + ) + { + _environment = environment; + _manifestReader = manifestReader; + _resourceMapper = resourceMapper; + } + + public void Initialize() + { + if (_state != null) + { + throw new InvalidOperationException( + "The registry can't be initialized more than once." + ); + } + + Load(); + + if (_environment.Watch) + { + _debouncer = new Timer(_ => Load()); + _watcher = new FileSystemWatcher( + Path.GetDirectoryName(_environment.ManifestFile)!, + Path.GetFileName(_environment.ManifestFile) + ) + { + NotifyFilter = NotifyFilters.LastWrite + }; + + _watcher.Changed += (_, _) => + _debouncer.Change(TimeSpan.FromMilliseconds(200), Timeout.InfiniteTimeSpan); + + _watcher.EnableRaisingEvents = true; + } + } + + public bool TryGet(string key, [NotNullWhen(true)] out ViteResource? resource) + { + if (_state == null) + { + throw new InvalidOperationException("The registry must be initialized before use."); + } + + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + var normalizedKey = key.TrimStart('/'); + return _state.TryGetValue(normalizedKey, out resource); + } + + public void Dispose() + { + _watcher?.Dispose(); + _debouncer?.Dispose(); + } + + private void Load() + { + var chunks = _manifestReader.ReadManifest(_environment.ManifestFile); + var resources = _resourceMapper.Map(chunks); + _state = resources.ToDictionary(x => x.Key, x => x, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/ViteFest/ViteFest.csproj b/src/ViteFest/ViteFest.csproj new file mode 100644 index 0000000..8dab94b --- /dev/null +++ b/src/ViteFest/ViteFest.csproj @@ -0,0 +1,33 @@ + + + netstandard2.0 + 10 + + true + snupkg + true + + Robert Macfie + Copyright Robert Macfie + MIT + A simple extension to encapsulate DI configuration per .NET + project + vite + https://github.com/rmacfie/vitefest + README.md + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/src/ViteFest/ViteManifestChunk.cs b/src/ViteFest/ViteManifestChunk.cs new file mode 100644 index 0000000..81ed253 --- /dev/null +++ b/src/ViteFest/ViteManifestChunk.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ViteFest; + +/// +/// Represents a raw chunk from the Vite manifest file. +/// See https://vitejs.dev/guide/backend-integration +/// +internal class ViteManifestChunk +{ + [JsonPropertyName("src")] + public string Src { get; set; } = default!; + + [JsonPropertyName("file")] + public string File { get; set; } = default!; + + [JsonPropertyName("isEntry")] + public bool? IsEntry { get; set; } + + [JsonPropertyName("isDynamicEntry")] + public bool? IsDynamicEntry { get; set; } + + [JsonPropertyName("imports")] + public List? Imports { get; set; } + + [JsonPropertyName("dynamicImports")] + public List? DynamicImports { get; set; } + + [JsonPropertyName("assets")] + public List? Assets { get; set; } + + [JsonPropertyName("css")] + public List? Css { get; set; } +} diff --git a/src/ViteFest/ViteOptions.cs b/src/ViteFest/ViteOptions.cs new file mode 100644 index 0000000..1219e8c --- /dev/null +++ b/src/ViteFest/ViteOptions.cs @@ -0,0 +1,34 @@ +using System; + +namespace ViteFest +{ + public class ViteOptions + { + /// + /// The file system path of the manifest file. + /// When a relative path is provided, it is resolved relative to the application's + /// content root path. + /// + public string ManifestFile { get; set; } = default!; + + /// + /// The base public path, or base URL, to the Vite output directory. + /// Defaults to "/". + /// + public string? BaseUrl { get; set; } + + /// + /// Indicates whether to watch and reload the manifest file when it changes. + /// Defaults to false in Production environments and true in Development. + /// + public bool? Watch { get; set; } + + public void Validate() + { + if (string.IsNullOrEmpty(ManifestFile)) + { + throw new Exception($"The {nameof(ManifestFile)} option is required"); + } + } + } +} diff --git a/src/ViteFest/ViteResource.cs b/src/ViteFest/ViteResource.cs new file mode 100644 index 0000000..27ba599 --- /dev/null +++ b/src/ViteFest/ViteResource.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; + +namespace ViteFest +{ + internal sealed class ViteResource + { + public ViteResource( + string key, + string url, + bool isEntry, + bool isDynamicEntry, + IReadOnlyCollection imports, + IReadOnlyCollection dynamicImports, + IReadOnlyCollection assetUrls, + IReadOnlyCollection cssUrls + ) + { + Key = key; + Url = url; + IsEntry = isEntry; + IsDynamicEntry = isDynamicEntry; + Imports = imports; + DynamicImports = dynamicImports; + AssetUrls = assetUrls; + CssUrls = cssUrls; + } + + /// + /// The key of the chunk, which usually is the path of the source file, + /// relative to the project root. + /// + /// + /// "Client/main.ts" + /// + public string Key { get; } + + /// + /// The public absolute path to the chunk, for use in the client. + /// + /// + /// This corresponds to the file property in the Vite manifest. + /// + /// + /// "/dist/assets/main.oauo411t.js" + /// + public string Url { get; } + + public bool IsEntry { get; } + + public bool IsDynamicEntry { get; } + + /// + /// Scripts imported by this source. The values are the keys of the chunks + /// that details the imported scripts. + /// + /// + /// ["Components/Foo.ts", "Components/Bar.ts"] + /// + public IReadOnlyCollection Imports { get; } + + /// + /// Scripts dynamically imported by this source. The values are the keys of the chunks + /// that details the imported scripts. + /// + /// + /// ["Components/Foo.ts", "Components/Bar.ts"] + /// + public IReadOnlyCollection DynamicImports { get; } + + /// + /// Asset files imported by this source. The values are the public paths of the files, + /// relative to base, for use in the client. + /// This corresponds to the assets property in the Vite manifest. + /// + /// + /// ["/dist/assets/Photo1.nsha62mx.jpg", "/dist/assets/Logotype.9adf52l.svg"] + /// + public IReadOnlyCollection AssetUrls { get; } + + /// + /// CSS files imported by this source. The values are the public paths of the files, + /// relative to base, for use in the client. + /// This corresponds to the css property in the Vite manifest. + /// + /// + /// ["/dist/assets/main.nsha62mx.css", "/dist/assets/main.9adf52l.css"] + /// + public IReadOnlyCollection CssUrls { get; } + } +} diff --git a/version.json b/version.json new file mode 100644 index 0000000..393dddd --- /dev/null +++ b/version.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", + "version": "1.0-beta", + "publicReleaseRefSpec": [ + "^refs/heads/main$", + "^refs/heads/v\\d+(?:\\.\\d+)?$" + ], + "cloudBuild": { + "buildNumber": { + "enabled": true + } + } +} diff --git a/vitefest.sln b/vitefest.sln new file mode 100644 index 0000000..1498c2e --- /dev/null +++ b/vitefest.sln @@ -0,0 +1,57 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{21FE8480-F9F2-406C-9935-8DC9F3AEC5A4}" +EndProject + +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ViteFest", "src\ViteFest\ViteFest.csproj", "{EB9E73CA-24F9-4AC4-AC28-ED33F7328481}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ViteFest.AspNetCore", "src\ViteFest.AspNetCore\ViteFest.AspNetCore.csproj", "{27E24FD1-84D7-4D18-8036-F65A20A1A001}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ViteFest.Tests", "src\ViteFest.Tests\ViteFest.Tests.csproj", "{21B96868-6B6F-44E0-BBFE-058F1C430C1B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "root", "root", "{CFF044DD-C879-4F71-830E-356AAF662F9F}" + ProjectSection(SolutionItems) = preProject + .csharpierrc = .csharpierrc + .editorconfig = .editorconfig + .gitignore = .gitignore + Directory.Build.props = Directory.Build.props + LICENSE = LICENSE + README.md = README.md + version.json = version.json + EndProjectSection +EndProject + +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {EB9E73CA-24F9-4AC4-AC28-ED33F7328481}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB9E73CA-24F9-4AC4-AC28-ED33F7328481}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB9E73CA-24F9-4AC4-AC28-ED33F7328481}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB9E73CA-24F9-4AC4-AC28-ED33F7328481}.Release|Any CPU.Build.0 = Release|Any CPU + {27E24FD1-84D7-4D18-8036-F65A20A1A001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {27E24FD1-84D7-4D18-8036-F65A20A1A001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {27E24FD1-84D7-4D18-8036-F65A20A1A001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {27E24FD1-84D7-4D18-8036-F65A20A1A001}.Release|Any CPU.Build.0 = Release|Any CPU + {21B96868-6B6F-44E0-BBFE-058F1C430C1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21B96868-6B6F-44E0-BBFE-058F1C430C1B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21B96868-6B6F-44E0-BBFE-058F1C430C1B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21B96868-6B6F-44E0-BBFE-058F1C430C1B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {EB9E73CA-24F9-4AC4-AC28-ED33F7328481} = {21FE8480-F9F2-406C-9935-8DC9F3AEC5A4} + {27E24FD1-84D7-4D18-8036-F65A20A1A001} = {21FE8480-F9F2-406C-9935-8DC9F3AEC5A4} + {21B96868-6B6F-44E0-BBFE-058F1C430C1B} = {21FE8480-F9F2-406C-9935-8DC9F3AEC5A4} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {E6514A26-A7DE-40E6-8373-791D63CCDC39} + EndGlobalSection +EndGlobal