diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 55157fe10..8ca3a240e 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -50,6 +50,33 @@ jobs: - name: Restore run: dotnet restore + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install SPA dependencies + working-directory: web + run: npm ci + + - name: Lint SPA + working-directory: web + run: npm run lint + + - name: Typecheck SPA + working-directory: web + run: npm run typecheck + + - name: Test SPA + working-directory: web + run: npm test -- --run + + - name: Build SPA + working-directory: web + run: npm run build + - name: Strip Scanner project ref (matches Docker build) run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj diff --git a/.gitignore b/.gitignore index f276affa0..2a77b0fcc 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ scripts/download-test-content.sh docker-compose.override.yml ErsatzTV/wwwroot/v2/ +ErsatzTV/wwwroot/app/ +web/dist/ +web/node_modules/ diff --git a/ErsatzTV.Tests/StartupSpaHostingTests.cs b/ErsatzTV.Tests/StartupSpaHostingTests.cs new file mode 100644 index 000000000..715f861d1 --- /dev/null +++ b/ErsatzTV.Tests/StartupSpaHostingTests.cs @@ -0,0 +1,45 @@ +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests; + +[TestFixture] +public class StartupSpaHostingTests +{ + private static readonly string StartupSource = File.ReadAllText(FindStartupPath()); + + [Test] + public void Startup_Should_Mount_ChicoryTv_Spa_At_App_Path() + { + StartupSource.ShouldContain("ctx.Request.Path.StartsWithSegments(\"/app\")"); + StartupSource.ShouldContain("MapWhen("); + StartupSource.ShouldContain("SpaStaticFileRoot"); + StartupSource.ShouldContain("RequestPath = \"/app\""); + StartupSource.ShouldContain("appIndexFile"); + } + + [Test] + public void Startup_Should_Not_Send_App_Routes_To_Blazor_Fallback() + { + StartupSource.ShouldContain("!IsSpaPath(ctx.Request.Path)"); + StartupSource.ShouldContain("bool IsSpaPath(PathString path)"); + } + + private static string FindStartupPath() + { + DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory); + + while (directory is not null) + { + string candidate = Path.Combine(directory.FullName, "ErsatzTV", "Startup.cs"); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new FileNotFoundException("Could not find ErsatzTV/Startup.cs"); + } +} diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 0e333f292..71f265cca 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -681,7 +681,42 @@ public class Startup }); app.MapWhen( - ctx => !IsIptvPath(ctx.Request.Path), + ctx => ctx.Request.Path.StartsWithSegments("/app"), + spa => + { + string spaStaticFileRoot = SpaStaticFileRoot(); + IFileProvider spaFileProvider = Directory.Exists(spaStaticFileRoot) + ? new PhysicalFileProvider(spaStaticFileRoot) + : new NullFileProvider(); + + spa.UseStaticFiles( + new StaticFileOptions + { + FileProvider = spaFileProvider, + RequestPath = "/app" + }); + + spa.Run( + async context => + { + Microsoft.Extensions.FileProviders.IFileInfo appIndexFile = + spaFileProvider.GetFileInfo("index.html"); + if (!appIndexFile.Exists) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.ContentLength = appIndexFile.Length; + + await using Stream stream = appIndexFile.CreateReadStream(); + await stream.CopyToAsync(context.Response.Body); + }); + }); + + app.MapWhen( + ctx => !IsIptvPath(ctx.Request.Path) && !IsSpaPath(ctx.Request.Path), blazor => { blazor.UseRouting(); @@ -736,6 +771,16 @@ public class Startup path.StartsWithSegments("/lineup.json") || path.StartsWithSegments("/lineup_status.json"); } + + bool IsSpaPath(PathString path) => path.StartsWithSegments("/app"); + + string SpaStaticFileRoot() + { + string webRootPath = CurrentEnvironment.WebRootPath ?? + Path.Combine(CurrentEnvironment.ContentRootPath, "wwwroot"); + + return Path.Combine(webRootPath, "app"); + } } private static void CustomServices(IServiceCollection services) diff --git a/design-system/Canvas.dc.html b/design-system/Canvas.dc.html new file mode 100644 index 000000000..c795a2fcd --- /dev/null +++ b/design-system/Canvas.dc.html @@ -0,0 +1,13 @@ + + +
+ + + + + +