Skip to content

Commit 9714a8b

Browse files
authored
fix(init): bootstrap host with render mode, theme script, full theme CSS (#15)
## Summary `shellui init` produced a non-functional project: no `@rendermode` so interactive components were inert, no theme script so light/dark caused FOUC, only `@import "tailwindcss";` in `input.css` so component CSS variables were undefined (everything rendered unstyled black/white), and a stale `hsl(var(--x))` color block in the Tailwind v4 npm config. Users had to manually patch four files before any component worked. This PR makes `shellui init` produce a working app out of the box and adds tests + CI assertions so the next regression here fails fast. ## Changes ### Init now patches the host `src/ShellUI.CLI/Services/InitService.cs` — new `BootstrapHostAsync` step between component install and MSBuild targets. Dispatches to one of two patchers based on detected project type: **Blazor Web App / Server / SSR** (`Components/App.razor`): - `<HeadOutlet />` → `<HeadOutlet @rendermode="InteractiveServer" />` - `<Routes />` → `<Routes @rendermode="InteractiveServer" />` - Injects a small theme bootstrap `<script>` into `<head>` that reads `localStorage.theme` and sets `documentElement.classList.add('dark')` before paint — prevents the light flash on dark pages - Injects `<script src="shellui.js"></script>` immediately before `_framework/blazor.web.js` so `window.ShellUI.*` (used by `ThemeToggle`, `CopyButton`, `InputOTP`, `FileUpload`, `Command`) is defined before Blazor calls into it **Blazor WebAssembly standalone** (`wwwroot/index.html`): same theme bootstrap + `shellui.js` injection (no `@rendermode` since WASM uses `<Router>` not `<Routes />`). The "remember to add `<script src=\"shellui.js\">`" reminder in the success message is gone — it's automatic now. ### Render-mode injection is conservative The regex only matches the bare default form (`<HeadOutlet />`, `<Routes />`). If the user (or another tool) already set `@rendermode="InteractiveAuto"` or any other attribute, the regex doesn't match and the tag is left alone. Idempotent — a second `shellui init` is a no-op. `shellui-sidebar.js` is **never** injected as a script tag. It's a `type=module` loaded by `SidebarProvider.razor` via `JSRuntime.InvokeAsync<IJSObjectReference>("import", "./shellui-sidebar.js")` — a `<script>` tag would either double-load it or fail silently because the file uses `export function`. CI now asserts it's absent. ### Default theme ships, not just `@import "tailwindcss"` `src/ShellUI.Templates/CssTemplates.cs` — `InputCss` and `InputCssNpm` now emit the full default theme: `:root` light vars, `.dark` dark vars, `@theme inline` mapping for Tailwind v4, `@custom-variant dark (&:is(.dark *));`, `@layer base` defaults, and the Loading-component animation keyframes. Theme values are tweakcn-compatible — users can paste a new `:root` / `.dark` block over this to retheme. ### Tailwind v4 npm config no longer carries the v3 wrapper `TailwindConfigJsNpm` no longer has the `colors: { primary: { DEFAULT: 'hsl(var(--primary))' } }` block. Tailwind v4 reads the palette via `@theme inline` in `input.css` — duplicating it in the config file did nothing useful and was confusing. ### Tests New `ShellUI.Tests/InitBootstrapTests.cs` — 7 tests covering the rewriter directly: - Render mode added to both `HeadOutlet` and `Routes` - Theme bootstrap injected inside `<head>` (positionally verified, not just "contains") - `shellui.js` script tag precedes `blazor.web.js` (ordering matters) - Running `RewriteAppRazor` twice produces identical output (idempotent) - Existing `@rendermode="InteractiveAuto"` is preserved — we don't overwrite - WASM `index.html` gets theme bootstrap + `shellui.js` ordered correctly - WASM rewriter is also idempotent Plumbing: `ShellUI.CLI.csproj` gets `InternalsVisibleTo("ShellUI.Tests")` so the rewriter methods stay `internal` instead of bloating the public CLI API surface. ### CI smoke step extended `.github/workflows/ci.yml` — after `shellui init`, the workflow now `grep`s the produced `Components/App.razor` and `wwwroot/input.css` for the expected markers and fails loudly if any are missing: - `HeadOutlet @rendermode="InteractiveServer"` - `Routes @rendermode="InteractiveServer"` - `ShellUI theme bootstrap` - `<script src="shellui.js"></script>` - `shellui-sidebar.js` **must not** appear in App.razor - `input.css` contains `@theme inline`, `:root`, and `.dark` blocks Then proceeds with the existing `shellui add chart pie-chart dashboard-02` + `dotnet build` end-to-end. ## Verification - `dotnet test ShellUI.Tests` — **25/25 passing** (7 new bootstrap tests + 18 from previous branches) - Local manual: scaffolded a fresh `dotnet new blazor`, ran the CLI from this branch's `bin/Release`, confirmed `App.razor` was patched, `dotnet build` clean, theme toggle flips light/dark and persists across reload, no FOUC on a dark-themed page ## Test plan - [ ] CI green (tests + extended smoke step) - [ ] Manual: `dotnet new blazor` → install the prerelease CLI tool → `shellui init` → diff `Components/App.razor` against the pre-init version; confirm all four markers landed - [ ] Manual: `shellui init --force` on the same project — confirm App.razor is unchanged (idempotency) - [ ] Manual: pre-set `<HeadOutlet @rendermode="InteractiveAuto" />` in `App.razor`, run `shellui init`, confirm `InteractiveAuto` is preserved - [ ] Manual: `dotnet new blazorwasm` → `shellui init` → confirm `wwwroot/index.html` patched, App.razor untouched
2 parents ef11e20 + febd171 commit 9714a8b

6 files changed

Lines changed: 456 additions & 48 deletions

File tree

.github/workflows/ci.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ jobs:
5151
dotnet new blazor -o SmokeApp --no-restore
5252
cd SmokeApp
5353
shellui init --tailwind standalone --yes
54+
55+
# Assert init produced a working host: App.razor patched with render mode,
56+
# theme bootstrap, and shellui.js script tag.
57+
grep -q 'HeadOutlet @rendermode="InteractiveServer"' Components/App.razor || (echo "init did not patch HeadOutlet @rendermode"; exit 1)
58+
grep -q 'Routes @rendermode="InteractiveServer"' Components/App.razor || (echo "init did not patch Routes @rendermode"; exit 1)
59+
grep -q 'ShellUI theme bootstrap' Components/App.razor || (echo "init did not inject theme bootstrap"; exit 1)
60+
grep -q '<script src="shellui.js"></script>' Components/App.razor || (echo "init did not inject shellui.js script tag"; exit 1)
61+
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
62+
63+
# Assert input.css has the full theme, not just @import "tailwindcss";
64+
grep -q '@theme inline' wwwroot/input.css || (echo "init did not write full theme to input.css"; exit 1)
65+
grep -q ':root' wwwroot/input.css || (echo "init did not write :root variables to input.css"; exit 1)
66+
grep -q '\.dark' wwwroot/input.css || (echo "init did not write .dark variables to input.css"; exit 1)
67+
5468
shellui add chart pie-chart dashboard-02 --force || true
5569
# Chart components reference ApexCharts.* types but the CLI does not yet
5670
# auto-add the runtime NuGet dep. Drop this line once `shellui add chart`
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
using ShellUI.CLI.Services;
2+
using Xunit;
3+
4+
namespace ShellUI.Tests;
5+
6+
public class InitBootstrapTests
7+
{
8+
// Mirrors the App.razor that `dotnet new blazor` (net9) produces, including the
9+
// @Assets[] asset-fingerprinting wrapper around the Blazor script. The wrapper
10+
// is what tripped the smoke test on the first CI run of this branch.
11+
private const string FreshAppRazor = @"<!DOCTYPE html>
12+
<html lang=""en"">
13+
14+
<head>
15+
<meta charset=""utf-8"" />
16+
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"" />
17+
<base href=""/"" />
18+
<link rel=""stylesheet"" href=""@Assets[""app.css""]"" />
19+
<ImportMap />
20+
<HeadOutlet />
21+
</head>
22+
23+
<body>
24+
<Routes />
25+
<script src=""@Assets[""_framework/blazor.web.js""]""></script>
26+
</body>
27+
28+
</html>
29+
";
30+
31+
[Fact]
32+
public void RewriteAppRazor_AddsRenderModeToHeadOutletAndRoutes()
33+
{
34+
var result = InitService.RewriteAppRazor(FreshAppRazor);
35+
36+
Assert.Contains(@"<HeadOutlet @rendermode=""InteractiveServer"" />", result);
37+
Assert.Contains(@"<Routes @rendermode=""InteractiveServer"" />", result);
38+
}
39+
40+
[Fact]
41+
public void RewriteAppRazor_InjectsThemeBootstrapInHead()
42+
{
43+
var result = InitService.RewriteAppRazor(FreshAppRazor);
44+
45+
Assert.Contains("ShellUI theme bootstrap", result);
46+
Assert.Contains("classList.add('dark')", result);
47+
// Theme script must be inside <head>, before </head>.
48+
var themeIdx = result.IndexOf("ShellUI theme bootstrap");
49+
var headCloseIdx = result.IndexOf("</head>");
50+
Assert.True(themeIdx > 0 && themeIdx < headCloseIdx);
51+
}
52+
53+
[Fact]
54+
public void RewriteAppRazor_InjectsShelluiJsBeforeBlazorScript()
55+
{
56+
var result = InitService.RewriteAppRazor(FreshAppRazor);
57+
58+
var shelluiIdx = result.IndexOf(@"<script src=""shellui.js""></script>");
59+
var blazorIdx = result.IndexOf("blazor.web.js");
60+
61+
Assert.True(shelluiIdx > 0, "shellui.js script tag was not injected");
62+
Assert.True(shelluiIdx < blazorIdx, "shellui.js must precede blazor.web.js so window.ShellUI.* is defined before Blazor calls into it");
63+
}
64+
65+
[Fact]
66+
public void RewriteAppRazor_HandlesBareBlazorScriptTag()
67+
{
68+
// Older templates ship the bare form without @Assets[]. The patcher must handle both.
69+
const string bare = @"<head><HeadOutlet /></head><body><Routes /><script src=""_framework/blazor.web.js""></script></body>";
70+
71+
var result = InitService.RewriteAppRazor(bare);
72+
73+
Assert.Contains(@"<script src=""shellui.js""></script>", result);
74+
Assert.True(result.IndexOf(@"shellui.js") < result.IndexOf(@"blazor.web.js"));
75+
}
76+
77+
[Fact]
78+
public void RewriteAppRazor_IsIdempotent()
79+
{
80+
var once = InitService.RewriteAppRazor(FreshAppRazor);
81+
var twice = InitService.RewriteAppRazor(once);
82+
83+
Assert.Equal(once, twice);
84+
}
85+
86+
[Fact]
87+
public void RewriteAppRazor_PreservesExistingRenderMode()
88+
{
89+
// If the user (or another tool) already set a different render mode (e.g. Auto),
90+
// we must not overwrite it.
91+
const string custom =
92+
@"<HeadOutlet @rendermode=""InteractiveAuto"" />" + "\n" +
93+
@"<Routes @rendermode=""InteractiveAuto"" />";
94+
95+
var result = InitService.RewriteAppRazor(custom);
96+
97+
Assert.Contains(@"<HeadOutlet @rendermode=""InteractiveAuto"" />", result);
98+
Assert.Contains(@"<Routes @rendermode=""InteractiveAuto"" />", result);
99+
Assert.DoesNotContain(@"InteractiveServer", result);
100+
}
101+
102+
[Fact]
103+
public void RewriteWasmIndexHtml_InjectsThemeAndShelluiJs()
104+
{
105+
const string indexHtml = @"<!DOCTYPE html>
106+
<html>
107+
<head>
108+
<title>App</title>
109+
</head>
110+
<body>
111+
<div id=""app""></div>
112+
<script src=""_framework/blazor.webassembly.js""></script>
113+
</body>
114+
</html>";
115+
116+
var result = InitService.RewriteWasmIndexHtml(indexHtml);
117+
118+
Assert.Contains("ShellUI theme bootstrap", result);
119+
var shelluiIdx = result.IndexOf(@"<script src=""shellui.js""></script>");
120+
var blazorIdx = result.IndexOf(@"<script src=""_framework/blazor.webassembly.js""");
121+
Assert.True(shelluiIdx > 0 && shelluiIdx < blazorIdx);
122+
}
123+
124+
[Fact]
125+
public void RewriteWasmIndexHtml_IsIdempotent()
126+
{
127+
const string indexHtml = @"<!DOCTYPE html><html><head></head><body><script src=""_framework/blazor.webassembly.js""></script></body></html>";
128+
129+
var once = InitService.RewriteWasmIndexHtml(indexHtml);
130+
var twice = InitService.RewriteWasmIndexHtml(once);
131+
132+
Assert.Equal(once, twice);
133+
}
134+
}

ShellUI.Tests/ShellUI.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
<ItemGroup>
2323
<ProjectReference Include="..\src\ShellUI.Core\ShellUI.Core.csproj" />
2424
<ProjectReference Include="..\src\ShellUI.Templates\ShellUI.Templates.csproj" />
25+
<ProjectReference Include="..\src\ShellUI.CLI\ShellUI.CLI.csproj" />
2526
</ItemGroup>
2627

2728
</Project>

src/ShellUI.CLI/Services/InitService.cs

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using ShellUI.Core.Models;
22
using ShellUI.Templates;
33
using System.Text.Json;
4+
using System.Text.RegularExpressions;
45
using Spectre.Console;
56

67
namespace ShellUI.CLI.Services;
@@ -147,6 +148,10 @@ await AnsiConsole.Status()
147148
await SetupTailwindStandaloneAsync();
148149
}
149150

151+
// Step 6.5: Patch App.razor / index.html — render mode + theme bootstrap + shellui.js
152+
ctx.Status("Wiring up theme and render mode...");
153+
await BootstrapHostAsync(projectInfo);
154+
150155
// Step 7: Create MSBuild targets file
151156
ctx.Status("Setting up MSBuild integration...");
152157
var buildPath = Path.Combine(Directory.GetCurrentDirectory(), "Build");
@@ -181,7 +186,6 @@ await AnsiConsole.Status()
181186
AnsiConsole.MarkupLine("\n[blue]Next steps:[/]");
182187
AnsiConsole.MarkupLine(" [dim]1. Add components:[/] dotnet shellui add button");
183188
AnsiConsole.MarkupLine(" [dim]2. Browse all:[/] dotnet shellui list");
184-
AnsiConsole.MarkupLine(" [dim]3. CopyButton/FileUpload/Command:[/] Add [yellow]<script src=\"shellui.js\"></script>[/] before Blazor script in App.razor or index.html");
185189
}
186190

187191
private static async Task SetupTailwindNpmAsync()
@@ -435,6 +439,107 @@ private static async Task UpdateProjectFileAsync(string projectFilePath, string
435439
}
436440
}
437441

442+
private static async Task BootstrapHostAsync(ProjectInfo projectInfo)
443+
{
444+
var cwd = Directory.GetCurrentDirectory();
445+
446+
// Blazor Web App / Server / SSR: patch Components/App.razor
447+
var appRazor = Path.Combine(cwd, "Components", "App.razor");
448+
if (File.Exists(appRazor))
449+
{
450+
var original = await File.ReadAllTextAsync(appRazor);
451+
var patched = RewriteAppRazor(original);
452+
if (patched != original)
453+
{
454+
await File.WriteAllTextAsync(appRazor, patched);
455+
AnsiConsole.MarkupLine("[green]Patched:[/] Components/App.razor");
456+
}
457+
return;
458+
}
459+
460+
// Blazor WASM (standalone): patch wwwroot/index.html instead — no Routes/HeadOutlet
461+
// render-mode pattern there; just inject theme bootstrap + shellui.js script tag.
462+
var indexHtml = Path.Combine(cwd, "wwwroot", "index.html");
463+
if (File.Exists(indexHtml))
464+
{
465+
var original = await File.ReadAllTextAsync(indexHtml);
466+
var patched = RewriteWasmIndexHtml(original);
467+
if (patched != original)
468+
{
469+
await File.WriteAllTextAsync(indexHtml, patched);
470+
AnsiConsole.MarkupLine("[green]Patched:[/] wwwroot/index.html");
471+
}
472+
return;
473+
}
474+
475+
AnsiConsole.MarkupLine("[yellow]No Components/App.razor or wwwroot/index.html found — skipped host bootstrap.[/]");
476+
}
477+
478+
// Idempotent: a second `shellui init` won't double-inject. Tags that already
479+
// carry @rendermode or any other attribute aren't matched, so user customizations
480+
// are preserved (they'll need to set @rendermode manually).
481+
internal static string RewriteAppRazor(string content)
482+
{
483+
content = Regex.Replace(content, @"<HeadOutlet\s*/>", @"<HeadOutlet @rendermode=""InteractiveServer"" />");
484+
content = Regex.Replace(content, @"<Routes\s*/>", @"<Routes @rendermode=""InteractiveServer"" />");
485+
486+
// 3. Theme bootstrap in <head> — sets `dark` class before paint to avoid FOUC.
487+
if (!content.Contains("ShellUI theme bootstrap"))
488+
{
489+
const string themeScript =
490+
@" <script>
491+
// ShellUI theme bootstrap — runs before Blazor mounts to avoid a light-flash on dark pages.
492+
if (!localStorage.getItem('theme')) { localStorage.setItem('theme', 'light'); }
493+
if (localStorage.getItem('theme') === 'dark') {
494+
document.documentElement.classList.add('dark');
495+
}
496+
</script>
497+
";
498+
content = Regex.Replace(content, @"</head>", themeScript + "</head>", RegexOptions.IgnoreCase);
499+
}
500+
501+
// 4. <script src="shellui.js"></script> immediately before blazor.web.js — provides
502+
// window.ShellUI.* (addClassToDocument, focusElement, copyToClipboard, …) for
503+
// ThemeToggle, CopyButton, InputOTP, FileUpload, Command. The pattern matches
504+
// both the modern `@Assets["_framework/blazor.web.js"]` and the bare form.
505+
if (!content.Contains("shellui.js"))
506+
{
507+
content = Regex.Replace(
508+
content,
509+
@"(<script\s+src=[^>]*_framework/blazor\.web\.js[^>]*>)",
510+
"<script src=\"shellui.js\"></script>\n $1");
511+
}
512+
513+
return content;
514+
}
515+
516+
internal static string RewriteWasmIndexHtml(string content)
517+
{
518+
if (!content.Contains("ShellUI theme bootstrap"))
519+
{
520+
const string themeScript =
521+
@" <script>
522+
// ShellUI theme bootstrap — runs before Blazor mounts to avoid a light-flash on dark pages.
523+
if (!localStorage.getItem('theme')) { localStorage.setItem('theme', 'light'); }
524+
if (localStorage.getItem('theme') === 'dark') {
525+
document.documentElement.classList.add('dark');
526+
}
527+
</script>
528+
";
529+
content = Regex.Replace(content, @"</head>", themeScript + "</head>", RegexOptions.IgnoreCase);
530+
}
531+
532+
if (!content.Contains("shellui.js"))
533+
{
534+
content = Regex.Replace(
535+
content,
536+
@"(<script\s+src=[^>]*_framework/blazor\.webassembly\.js[^>]*>)",
537+
"<script src=\"shellui.js\"></script>\n $1");
538+
}
539+
540+
return content;
541+
}
542+
438543
private static void RemoveBootstrapFiles()
439544
{
440545
try

src/ShellUI.CLI/ShellUI.CLI.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
<ItemGroup>
3434
<None Include="README.md" Pack="true" PackagePath="\" />
35+
<InternalsVisibleTo Include="ShellUI.Tests" />
3536
</ItemGroup>
3637

3738
</Project>

0 commit comments

Comments
 (0)