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
15 changes: 5 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,8 @@ jobs:
- name: Run tests
run: dotnet test ShellUI.sln --no-restore --no-build --configuration Release --verbosity normal

# Guards against the template-escape bug class from shellui-fixes-for-lib.md
# (Fixes 2, 9, 10). TemplateCompileTests verifies generated content parses;
# this end-to-end build catches anything the syntactic check misses
# (e.g. missing using directives).
# End-to-end build of a scaffolded project — catches anything the in-process
# TemplateCompileTests miss (missing usings, dependency resolution).
- name: Smoke-test CLI scaffolding
shell: bash
run: |
Expand All @@ -53,13 +51,10 @@ jobs:
dotnet new blazor -o SmokeApp --no-restore
cd SmokeApp
shellui init --tailwind standalone --yes
# Hit the three components that regressed last time:
shellui add chart pie-chart dashboard-02 --force || true
# Chart components reference ApexCharts.* types — `shellui add chart`
# does NOT auto-install Blazor-ApexCharts today (tracked as Fix 12.3:
# `nugetDependencies` field on component manifests). Add it explicitly
# here so the smoke test isolates THIS PR's bug class (template escapes)
# from that one. When Fix 12.3 lands, remove this line.
# Chart components reference ApexCharts.* types but the CLI does not yet
# auto-add the runtime NuGet dep. Drop this line once `shellui add chart`
# declares Blazor-ApexCharts as a nuget dependency.
dotnet add package Blazor-ApexCharts --version 6.0.2
dotnet build -c Debug

Expand Down
2 changes: 1 addition & 1 deletion NET9/BlazorInteractiveServer/Components/UI/InputOTP.razor
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@
{
try
{
await JS.InvokeVoidAsync("eval", $"document.getElementById('otp-input-{_id}-{index}')?.focus()");
await JS.InvokeVoidAsync("ShellUI.focusElement", $"otp-input-{_id}-{index}");
}
catch
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
Class)"
@onclick="HandleClick"
@attributes="AdditionalAttributes">
<i class="fa-solid fa-bars-staggered text-sm"></i>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="15" y2="12" />
<line x1="3" y1="18" x2="18" y2="18" />
</svg>
<span class="sr-only">Toggle Sidebar</span>
</button>

Expand Down
30 changes: 15 additions & 15 deletions NET9/BlazorInteractiveServer/Components/UI/ThemeToggle.razor
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,21 @@
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }

protected override async Task OnInitializedAsync()
protected override void OnInitialized()
{
_instances.Add(this);

}

// localStorage and document mutation must run after first render — JSRuntime
// is unavailable during prerender on Blazor Server.
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
try
{
var theme = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "theme");
_isDark = string.IsNullOrEmpty(theme) ? true : theme == "dark";
StateHasChanged();
}
catch
{
Expand All @@ -53,21 +60,14 @@
{
_isDark = !_isDark;
var theme = _isDark ? "dark" : "light";

try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "theme", theme);

if (_isDark)
{
await JSRuntime.InvokeVoidAsync("eval", "document.documentElement.classList.add('dark')");
}
else
{
await JSRuntime.InvokeVoidAsync("eval", "document.documentElement.classList.remove('dark')");
}

// Update all ThemeToggle instances
await JSRuntime.InvokeVoidAsync(
_isDark ? "ShellUI.addClassToDocument" : "ShellUI.removeClassFromDocument",
"dark");

foreach (var instance in _instances)
{
if (instance != this)
Expand All @@ -76,7 +76,7 @@
instance.StateHasChanged();
}
}

StateHasChanged();
}
catch
Expand Down
7 changes: 3 additions & 4 deletions ShellUI.Tests/TemplateCompileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@
namespace ShellUI.Tests;

/// Verifies that the *generated* content of each template parses as valid C#.
/// For pure-.cs templates (variants), parse the whole content.
/// For .razor templates, extract the @code { ... } block and parse its body.
/// This catches the exact class of bug from shellui-fixes-for-lib.md (Fixes 2, 9, 10):
/// unescaped quotes inside C# verbatim strings that ship as compile errors to consumers.
/// Pure-.cs templates (variants) parse the whole content; .razor templates parse
/// the body of the @code block. Catches unescaped quotes inside C# verbatim
/// strings — the kind of error that ships as a compile failure to consumers.
public class TemplateCompileTests
{
[Theory]
Expand Down
156 changes: 156 additions & 0 deletions ShellUI.Tests/TemplateSyncTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
using System;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using ShellUI.Templates;
using Xunit;

namespace ShellUI.Tests;

/// Asserts the @code block of each live src/ShellUI.Components/Components/*.razor
/// matches the corresponding CLI template's emitted Content. Compares after stripping
/// comments, blank lines, and whitespace differences so the legitimate divergence
/// (namespace, formatting) doesn't fire, but real divergence (parameter list, JS
/// interop calls, lifecycle methods) does.
public class TemplateSyncTests
{
// Component name → reason. Empty by default — fix the drift instead of adding entries.
private static readonly Dictionary<string, string> AllowedDrift = new()
{
};

[Theory]
[InlineData("sidebar-trigger", "SidebarTrigger.razor")]
[InlineData("theme-toggle", "ThemeToggle.razor")]
[InlineData("input-otp", "InputOTP.razor")]
public void TemplateCodeBlock_MatchesLiveLibrary(string templateName, string razorFileName)
{
if (AllowedDrift.ContainsKey(templateName)) return;

var liveContent = File.ReadAllText(GetLiveRazorPath(razorFileName));
var templateContent = ComponentRegistry.GetComponentContent(templateName)
?? throw new InvalidOperationException($"Template '{templateName}' not found in registry");

var liveCode = ExtractCodeBlock(liveContent)
?? throw new InvalidOperationException($"Live {razorFileName} has no @code block");
var templateCode = ExtractCodeBlock(templateContent)
?? throw new InvalidOperationException($"Template {templateName} has no @code block");

var normalizedLive = Normalize(liveCode);
var normalizedTemplate = Normalize(templateCode);

Assert.True(normalizedLive == normalizedTemplate,
$"Drift detected between live {razorFileName} and template {templateName}.\n" +
$"This usually means someone updated one but not the other. Sync them, or add " +
$"\"{templateName}\" to AllowedDrift in TemplateSyncTests with a reason.\n\n" +
DiffSummary(normalizedLive, normalizedTemplate));
}

// [CallerFilePath] captures the absolute path of this source file at compile time,
// so the test resolves the live components directory regardless of cwd on CI.
private static string GetLiveRazorPath(string razorFileName, [CallerFilePath] string thisFile = "")
{
var testDir = Path.GetDirectoryName(thisFile) ?? throw new InvalidOperationException("CallerFilePath is empty");
var repoRoot = Path.GetFullPath(Path.Combine(testDir, ".."));
return Path.Combine(repoRoot, "src", "ShellUI.Components", "Components", razorFileName);
}

private static string Normalize(string code)
{
var withoutBlock = Regex.Replace(code, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
var lines = withoutBlock.Split('\n')
.Select(l => Regex.Replace(l, @"//.*$", string.Empty))
.Select(l => Regex.Replace(l.Trim(), @"\s+", " "))
.Where(l => !string.IsNullOrWhiteSpace(l));
return string.Join("\n", lines);
}

private static string DiffSummary(string live, string template)
{
var liveLines = live.Split('\n');
var tmplLines = template.Split('\n');
var max = Math.Max(liveLines.Length, tmplLines.Length);
var diffs = new System.Text.StringBuilder();
var shown = 0;
for (var i = 0; i < max && shown < 5; i++)
{
var l = i < liveLines.Length ? liveLines[i] : "<missing>";
var t = i < tmplLines.Length ? tmplLines[i] : "<missing>";
if (l != t)
{
diffs.AppendLine($" line {i + 1}");
diffs.AppendLine($" live: {l}");
diffs.AppendLine($" template: {t}");
shown++;
}
}
return diffs.Length == 0 ? "(no per-line diff — file lengths differ)" : diffs.ToString();
}

/// Extracts the body of the first `@code { ... }` block, balancing braces while
/// respecting strings, verbatim strings, char literals, line comments, and block comments.
/// Returns null if no `@code` block is found or braces are unbalanced.
private static string? ExtractCodeBlock(string razor)
{
var match = Regex.Match(razor, @"@code\s*\{");
if (!match.Success) return null;

var start = match.Index + match.Length;
var depth = 1;
var inString = false;
var inVerbatimString = false;
var inCharLiteral = false;
var inLineComment = false;
var inBlockComment = false;

for (var i = start; i < razor.Length; i++)
{
var c = razor[i];
var next = i + 1 < razor.Length ? razor[i + 1] : '\0';

if (inLineComment)
{
if (c == '\n') inLineComment = false;
continue;
}
if (inBlockComment)
{
if (c == '*' && next == '/') { inBlockComment = false; i++; }
continue;
}
if (inVerbatimString)
{
if (c == '"' && next == '"') { i++; continue; }
if (c == '"') inVerbatimString = false;
continue;
}
if (inString)
{
if (c == '\\' && next != '\0') { i++; continue; }
if (c == '"') inString = false;
continue;
}
if (inCharLiteral)
{
if (c == '\\' && next != '\0') { i++; continue; }
if (c == '\'') inCharLiteral = false;
continue;
}

if (c == '/' && next == '/') { inLineComment = true; i++; continue; }
if (c == '/' && next == '*') { inBlockComment = true; i++; continue; }
if (c == '@' && next == '"') { inVerbatimString = true; i++; continue; }
if (c == '"') { inString = true; continue; }
if (c == '\'') { inCharLiteral = true; continue; }

if (c == '{') depth++;
else if (c == '}')
{
depth--;
if (depth == 0) return razor.Substring(start, i - start);
}
}
return null;
}
}
2 changes: 1 addition & 1 deletion src/ShellUI.Components/Components/InputOTP.razor
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
{
try
{
await JS.InvokeVoidAsync("eval", $"document.getElementById('otp-input-{_id}-{index}')?.focus()");
await JS.InvokeVoidAsync("ShellUI.focusElement", $"otp-input-{_id}-{index}");
}
catch
{
Expand Down
6 changes: 5 additions & 1 deletion src/ShellUI.Components/Components/SidebarTrigger.razor
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
Class)"
@onclick="HandleClick"
@attributes="AdditionalAttributes">
<i class="fa-solid fa-bars-staggered text-sm"></i>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="15" y2="12" />
<line x1="3" y1="18" x2="18" y2="18" />
</svg>
<span class="sr-only">Toggle Sidebar</span>
</button>

Expand Down
30 changes: 15 additions & 15 deletions src/ShellUI.Components/Components/ThemeToggle.razor
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,21 @@
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }

protected override async Task OnInitializedAsync()
protected override void OnInitialized()
{
_instances.Add(this);

}

// localStorage and document mutation must run after first render — JSRuntime
// is unavailable during prerender on Blazor Server.
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
try
{
var theme = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "theme");
_isDark = string.IsNullOrEmpty(theme) ? true : theme == "dark";
StateHasChanged();
}
catch
{
Expand All @@ -52,21 +59,14 @@
{
_isDark = !_isDark;
var theme = _isDark ? "dark" : "light";

try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "theme", theme);

if (_isDark)
{
await JSRuntime.InvokeVoidAsync("eval", "document.documentElement.classList.add('dark')");
}
else
{
await JSRuntime.InvokeVoidAsync("eval", "document.documentElement.classList.remove('dark')");
}

// Update all ThemeToggle instances
await JSRuntime.InvokeVoidAsync(
_isDark ? "ShellUI.addClassToDocument" : "ShellUI.removeClassFromDocument",
"dark");

foreach (var instance in _instances)
{
if (instance != this)
Expand All @@ -75,7 +75,7 @@
instance.StateHasChanged();
}
}

StateHasChanged();
}
catch
Expand Down
14 changes: 4 additions & 10 deletions src/ShellUI.Components/Services/ThemeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,13 @@ public async Task<string> GetThemeAsync()
public async Task SetThemeAsync(string theme)
{
_currentTheme = theme;

try
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "theme", theme);

if (theme == "dark")
{
await _jsRuntime.InvokeVoidAsync("eval", "document.documentElement.classList.add('dark')");
}
else
{
await _jsRuntime.InvokeVoidAsync("eval", "document.documentElement.classList.remove('dark')");
}
await _jsRuntime.InvokeVoidAsync(
theme == "dark" ? "ShellUI.addClassToDocument" : "ShellUI.removeClassFromDocument",
"dark");
}
catch
{
Expand Down
Loading
Loading