Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ jobs:
dotnet new blazor -o SmokeApp --no-restore
cd SmokeApp
shellui init --tailwind standalone --yes

# Assert init produced a working host: App.razor patched with render mode,
# theme bootstrap, and shellui.js script tag.
grep -q 'HeadOutlet @rendermode="InteractiveServer"' Components/App.razor || (echo "init did not patch HeadOutlet @rendermode"; exit 1)
grep -q 'Routes @rendermode="InteractiveServer"' Components/App.razor || (echo "init did not patch Routes @rendermode"; exit 1)
grep -q 'ShellUI theme bootstrap' Components/App.razor || (echo "init did not inject theme bootstrap"; exit 1)
grep -q '<script src="shellui.js"></script>' Components/App.razor || (echo "init did not inject shellui.js script tag"; exit 1)
grep -q 'shellui-sidebar.js' Components/App.razor && (echo "init incorrectly injected shellui-sidebar.js script tag (sidebar JS is dynamically imported)"; exit 1) || true

# Assert input.css has the full theme, not just @import "tailwindcss";
grep -q '@theme inline' wwwroot/input.css || (echo "init did not write full theme to input.css"; exit 1)
grep -q ':root' wwwroot/input.css || (echo "init did not write :root variables to input.css"; exit 1)
grep -q '\.dark' wwwroot/input.css || (echo "init did not write .dark variables to input.css"; exit 1)

shellui add chart pie-chart dashboard-02 --force || true
# Chart components reference ApexCharts.* types but the CLI does not yet
# auto-add the runtime NuGet dep. Drop this line once `shellui add chart`
Expand Down
134 changes: 134 additions & 0 deletions ShellUI.Tests/InitBootstrapTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using ShellUI.CLI.Services;
using Xunit;

namespace ShellUI.Tests;

public class InitBootstrapTests
{
// Mirrors the App.razor that `dotnet new blazor` (net9) produces, including the
// @Assets[] asset-fingerprinting wrapper around the Blazor script. The wrapper
// is what tripped the smoke test on the first CI run of this branch.
private const string FreshAppRazor = @"<!DOCTYPE html>
<html lang=""en"">

<head>
<meta charset=""utf-8"" />
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"" />
<base href=""/"" />
<link rel=""stylesheet"" href=""@Assets[""app.css""]"" />
<ImportMap />
<HeadOutlet />
</head>

<body>
<Routes />
<script src=""@Assets[""_framework/blazor.web.js""]""></script>
</body>

</html>
";

[Fact]
public void RewriteAppRazor_AddsRenderModeToHeadOutletAndRoutes()
{
var result = InitService.RewriteAppRazor(FreshAppRazor);

Assert.Contains(@"<HeadOutlet @rendermode=""InteractiveServer"" />", result);
Assert.Contains(@"<Routes @rendermode=""InteractiveServer"" />", result);
}

[Fact]
public void RewriteAppRazor_InjectsThemeBootstrapInHead()
{
var result = InitService.RewriteAppRazor(FreshAppRazor);

Assert.Contains("ShellUI theme bootstrap", result);
Assert.Contains("classList.add('dark')", result);
// Theme script must be inside <head>, before </head>.
var themeIdx = result.IndexOf("ShellUI theme bootstrap");
var headCloseIdx = result.IndexOf("</head>");
Assert.True(themeIdx > 0 && themeIdx < headCloseIdx);
}

[Fact]
public void RewriteAppRazor_InjectsShelluiJsBeforeBlazorScript()
{
var result = InitService.RewriteAppRazor(FreshAppRazor);

var shelluiIdx = result.IndexOf(@"<script src=""shellui.js""></script>");
var blazorIdx = result.IndexOf("blazor.web.js");

Assert.True(shelluiIdx > 0, "shellui.js script tag was not injected");
Assert.True(shelluiIdx < blazorIdx, "shellui.js must precede blazor.web.js so window.ShellUI.* is defined before Blazor calls into it");
}

[Fact]
public void RewriteAppRazor_HandlesBareBlazorScriptTag()
{
// Older templates ship the bare form without @Assets[]. The patcher must handle both.
const string bare = @"<head><HeadOutlet /></head><body><Routes /><script src=""_framework/blazor.web.js""></script></body>";

var result = InitService.RewriteAppRazor(bare);

Assert.Contains(@"<script src=""shellui.js""></script>", result);
Assert.True(result.IndexOf(@"shellui.js") < result.IndexOf(@"blazor.web.js"));
}

[Fact]
public void RewriteAppRazor_IsIdempotent()
{
var once = InitService.RewriteAppRazor(FreshAppRazor);
var twice = InitService.RewriteAppRazor(once);

Assert.Equal(once, twice);
}

[Fact]
public void RewriteAppRazor_PreservesExistingRenderMode()
{
// If the user (or another tool) already set a different render mode (e.g. Auto),
// we must not overwrite it.
const string custom =
@"<HeadOutlet @rendermode=""InteractiveAuto"" />" + "\n" +
@"<Routes @rendermode=""InteractiveAuto"" />";

var result = InitService.RewriteAppRazor(custom);

Assert.Contains(@"<HeadOutlet @rendermode=""InteractiveAuto"" />", result);
Assert.Contains(@"<Routes @rendermode=""InteractiveAuto"" />", result);
Assert.DoesNotContain(@"InteractiveServer", result);
}

[Fact]
public void RewriteWasmIndexHtml_InjectsThemeAndShelluiJs()
{
const string indexHtml = @"<!DOCTYPE html>
<html>
<head>
<title>App</title>
</head>
<body>
<div id=""app""></div>
<script src=""_framework/blazor.webassembly.js""></script>
</body>
</html>";

var result = InitService.RewriteWasmIndexHtml(indexHtml);

Assert.Contains("ShellUI theme bootstrap", result);
var shelluiIdx = result.IndexOf(@"<script src=""shellui.js""></script>");
var blazorIdx = result.IndexOf(@"<script src=""_framework/blazor.webassembly.js""");
Assert.True(shelluiIdx > 0 && shelluiIdx < blazorIdx);
}

[Fact]
public void RewriteWasmIndexHtml_IsIdempotent()
{
const string indexHtml = @"<!DOCTYPE html><html><head></head><body><script src=""_framework/blazor.webassembly.js""></script></body></html>";

var once = InitService.RewriteWasmIndexHtml(indexHtml);
var twice = InitService.RewriteWasmIndexHtml(once);

Assert.Equal(once, twice);
}
}
1 change: 1 addition & 0 deletions ShellUI.Tests/ShellUI.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<ItemGroup>
<ProjectReference Include="..\src\ShellUI.Core\ShellUI.Core.csproj" />
<ProjectReference Include="..\src\ShellUI.Templates\ShellUI.Templates.csproj" />
<ProjectReference Include="..\src\ShellUI.CLI\ShellUI.CLI.csproj" />
</ItemGroup>

</Project>
107 changes: 106 additions & 1 deletion src/ShellUI.CLI/Services/InitService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using ShellUI.Core.Models;
using ShellUI.Templates;
using System.Text.Json;
using System.Text.RegularExpressions;
using Spectre.Console;

namespace ShellUI.CLI.Services;
Expand Down Expand Up @@ -147,6 +148,10 @@ await AnsiConsole.Status()
await SetupTailwindStandaloneAsync();
}

// Step 6.5: Patch App.razor / index.html — render mode + theme bootstrap + shellui.js
ctx.Status("Wiring up theme and render mode...");
await BootstrapHostAsync(projectInfo);

// Step 7: Create MSBuild targets file
ctx.Status("Setting up MSBuild integration...");
var buildPath = Path.Combine(Directory.GetCurrentDirectory(), "Build");
Expand Down Expand Up @@ -181,7 +186,6 @@ await AnsiConsole.Status()
AnsiConsole.MarkupLine("\n[blue]Next steps:[/]");
AnsiConsole.MarkupLine(" [dim]1. Add components:[/] dotnet shellui add button");
AnsiConsole.MarkupLine(" [dim]2. Browse all:[/] dotnet shellui list");
AnsiConsole.MarkupLine(" [dim]3. CopyButton/FileUpload/Command:[/] Add [yellow]<script src=\"shellui.js\"></script>[/] before Blazor script in App.razor or index.html");
}

private static async Task SetupTailwindNpmAsync()
Expand Down Expand Up @@ -435,6 +439,107 @@ private static async Task UpdateProjectFileAsync(string projectFilePath, string
}
}

private static async Task BootstrapHostAsync(ProjectInfo projectInfo)
{
var cwd = Directory.GetCurrentDirectory();

// Blazor Web App / Server / SSR: patch Components/App.razor
var appRazor = Path.Combine(cwd, "Components", "App.razor");
if (File.Exists(appRazor))
{
var original = await File.ReadAllTextAsync(appRazor);
var patched = RewriteAppRazor(original);
if (patched != original)
{
await File.WriteAllTextAsync(appRazor, patched);
AnsiConsole.MarkupLine("[green]Patched:[/] Components/App.razor");
}
return;
}

// Blazor WASM (standalone): patch wwwroot/index.html instead — no Routes/HeadOutlet
// render-mode pattern there; just inject theme bootstrap + shellui.js script tag.
var indexHtml = Path.Combine(cwd, "wwwroot", "index.html");
if (File.Exists(indexHtml))
{
var original = await File.ReadAllTextAsync(indexHtml);
var patched = RewriteWasmIndexHtml(original);
if (patched != original)
{
await File.WriteAllTextAsync(indexHtml, patched);
AnsiConsole.MarkupLine("[green]Patched:[/] wwwroot/index.html");
}
return;
}

AnsiConsole.MarkupLine("[yellow]No Components/App.razor or wwwroot/index.html found — skipped host bootstrap.[/]");
}

// Idempotent: a second `shellui init` won't double-inject. Tags that already
// carry @rendermode or any other attribute aren't matched, so user customizations
// are preserved (they'll need to set @rendermode manually).
internal static string RewriteAppRazor(string content)
{
content = Regex.Replace(content, @"<HeadOutlet\s*/>", @"<HeadOutlet @rendermode=""InteractiveServer"" />");
content = Regex.Replace(content, @"<Routes\s*/>", @"<Routes @rendermode=""InteractiveServer"" />");

// 3. Theme bootstrap in <head> — sets `dark` class before paint to avoid FOUC.
if (!content.Contains("ShellUI theme bootstrap"))
{
const string themeScript =
@" <script>
// ShellUI theme bootstrap — runs before Blazor mounts to avoid a light-flash on dark pages.
if (!localStorage.getItem('theme')) { localStorage.setItem('theme', 'light'); }
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.classList.add('dark');
}
</script>
";
content = Regex.Replace(content, @"</head>", themeScript + "</head>", RegexOptions.IgnoreCase);
}

// 4. <script src="shellui.js"></script> immediately before blazor.web.js — provides
// window.ShellUI.* (addClassToDocument, focusElement, copyToClipboard, …) for
// ThemeToggle, CopyButton, InputOTP, FileUpload, Command. The pattern matches
// both the modern `@Assets["_framework/blazor.web.js"]` and the bare form.
if (!content.Contains("shellui.js"))
{
content = Regex.Replace(
content,
@"(<script\s+src=[^>]*_framework/blazor\.web\.js[^>]*>)",
"<script src=\"shellui.js\"></script>\n $1");
}

return content;
}

internal static string RewriteWasmIndexHtml(string content)
{
if (!content.Contains("ShellUI theme bootstrap"))
{
const string themeScript =
@" <script>
// ShellUI theme bootstrap — runs before Blazor mounts to avoid a light-flash on dark pages.
if (!localStorage.getItem('theme')) { localStorage.setItem('theme', 'light'); }
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.classList.add('dark');
}
</script>
";
content = Regex.Replace(content, @"</head>", themeScript + "</head>", RegexOptions.IgnoreCase);
}

if (!content.Contains("shellui.js"))
{
content = Regex.Replace(
content,
@"(<script\s+src=[^>]*_framework/blazor\.webassembly\.js[^>]*>)",
"<script src=\"shellui.js\"></script>\n $1");
}

return content;
}

private static void RemoveBootstrapFiles()
{
try
Expand Down
1 change: 1 addition & 0 deletions src/ShellUI.CLI/ShellUI.CLI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
<InternalsVisibleTo Include="ShellUI.Tests" />
</ItemGroup>

</Project>
Loading
Loading