diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa73aed..3350350 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: | @@ -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 diff --git a/NET9/BlazorInteractiveServer/Components/UI/InputOTP.razor b/NET9/BlazorInteractiveServer/Components/UI/InputOTP.razor index fb934da..185b7c1 100644 --- a/NET9/BlazorInteractiveServer/Components/UI/InputOTP.razor +++ b/NET9/BlazorInteractiveServer/Components/UI/InputOTP.razor @@ -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 { diff --git a/NET9/BlazorInteractiveServer/Components/UI/SidebarTrigger.razor b/NET9/BlazorInteractiveServer/Components/UI/SidebarTrigger.razor index bfadb4e..f936468 100644 --- a/NET9/BlazorInteractiveServer/Components/UI/SidebarTrigger.razor +++ b/NET9/BlazorInteractiveServer/Components/UI/SidebarTrigger.razor @@ -7,7 +7,11 @@ Class)" @onclick="HandleClick" @attributes="AdditionalAttributes"> - + + + + + Toggle Sidebar diff --git a/NET9/BlazorInteractiveServer/Components/UI/ThemeToggle.razor b/NET9/BlazorInteractiveServer/Components/UI/ThemeToggle.razor index f67dc66..05206ae 100644 --- a/NET9/BlazorInteractiveServer/Components/UI/ThemeToggle.razor +++ b/NET9/BlazorInteractiveServer/Components/UI/ThemeToggle.razor @@ -34,14 +34,21 @@ [Parameter(CaptureUnmatchedValues = true)] public Dictionary? 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("localStorage.getItem", "theme"); _isDark = string.IsNullOrEmpty(theme) ? true : theme == "dark"; + StateHasChanged(); } catch { @@ -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) @@ -76,7 +76,7 @@ instance.StateHasChanged(); } } - + StateHasChanged(); } catch diff --git a/ShellUI.Tests/TemplateCompileTests.cs b/ShellUI.Tests/TemplateCompileTests.cs index ecb3898..80338f3 100644 --- a/ShellUI.Tests/TemplateCompileTests.cs +++ b/ShellUI.Tests/TemplateCompileTests.cs @@ -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] diff --git a/ShellUI.Tests/TemplateSyncTests.cs b/ShellUI.Tests/TemplateSyncTests.cs new file mode 100644 index 0000000..3bb9978 --- /dev/null +++ b/ShellUI.Tests/TemplateSyncTests.cs @@ -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 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] : ""; + var t = i < tmplLines.Length ? tmplLines[i] : ""; + 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; + } +} diff --git a/src/ShellUI.Components/Components/InputOTP.razor b/src/ShellUI.Components/Components/InputOTP.razor index 1b85db4..c304de1 100644 --- a/src/ShellUI.Components/Components/InputOTP.razor +++ b/src/ShellUI.Components/Components/InputOTP.razor @@ -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 { diff --git a/src/ShellUI.Components/Components/SidebarTrigger.razor b/src/ShellUI.Components/Components/SidebarTrigger.razor index e6606b9..046f5d2 100644 --- a/src/ShellUI.Components/Components/SidebarTrigger.razor +++ b/src/ShellUI.Components/Components/SidebarTrigger.razor @@ -7,7 +7,11 @@ Class)" @onclick="HandleClick" @attributes="AdditionalAttributes"> - + + + + + Toggle Sidebar diff --git a/src/ShellUI.Components/Components/ThemeToggle.razor b/src/ShellUI.Components/Components/ThemeToggle.razor index fcca493..f285373 100644 --- a/src/ShellUI.Components/Components/ThemeToggle.razor +++ b/src/ShellUI.Components/Components/ThemeToggle.razor @@ -33,14 +33,21 @@ [Parameter(CaptureUnmatchedValues = true)] public Dictionary? 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("localStorage.getItem", "theme"); _isDark = string.IsNullOrEmpty(theme) ? true : theme == "dark"; + StateHasChanged(); } catch { @@ -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) @@ -75,7 +75,7 @@ instance.StateHasChanged(); } } - + StateHasChanged(); } catch diff --git a/src/ShellUI.Components/Services/ThemeService.cs b/src/ShellUI.Components/Services/ThemeService.cs index 9eb610c..b9eebab 100644 --- a/src/ShellUI.Components/Services/ThemeService.cs +++ b/src/ShellUI.Components/Services/ThemeService.cs @@ -37,19 +37,13 @@ public async Task 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 { diff --git a/src/ShellUI.Templates/Templates/InputOTPTemplate.cs b/src/ShellUI.Templates/Templates/InputOTPTemplate.cs index c5f5dab..a955ee5 100644 --- a/src/ShellUI.Templates/Templates/InputOTPTemplate.cs +++ b/src/ShellUI.Templates/Templates/InputOTPTemplate.cs @@ -11,7 +11,9 @@ public class InputOTPTemplate Description = "One-time password input component", Category = ComponentCategory.Form, FilePath = "InputOTP.razor", - + // shellui-js provides ShellUI.focusElement for moving focus between OTP digit inputs. + // Shell.Cn is installed by `shellui init` via the shell template, so it is not listed here. + Dependencies = new List { "shellui-js" }, Tags = new List { "form", "input", "otp", "password", "verification" } }; @@ -19,18 +21,16 @@ public class InputOTPTemplate @using Microsoft.JSInterop @inject IJSRuntime JS -
+
@for (int i = 0; i < Length; i++) { var index = i; - var isGroupStart = GroupBy > 0 && i % GroupBy == 0; - var isGroupEnd = GroupBy > 0 && (i + 1) % GroupBy == 0; - - @if (isGroupStart && i > 0) + + @if (GroupBy > 0 && i > 0 && i % GroupBy == 0) { - - + - } - + _focusedIndex = index)"" disabled=""@Disabled"" - class=""@(""w-10 h-12 text-center text-lg font-semibold border-2 rounded-md transition-colors "" + (Disabled ? ""opacity-50 cursor-not-allowed bg-muted"" : ""bg-background"") + "" "" + (_focusedIndex == index ? ""border-ring ring-2 ring-ring ring-offset-2"" : ""border-input"") + "" focus:outline-none focus:border-ring focus:ring-2 focus:ring-ring focus:ring-offset-2 "" + (isGroupEnd && i < Length - 1 ? ""mr-0"" : ""mr-1""))"" /> + class=""@Shell.Cn(""w-10 h-12 text-center text-lg font-semibold border-2 rounded-md transition-colors focus:outline-none focus:border-ring focus:ring-2 focus:ring-ring focus:ring-offset-2"", Disabled ? ""opacity-50 cursor-not-allowed bg-muted"" : ""bg-background"", _focusedIndex == index ? ""border-ring ring-2 ring-ring ring-offset-2"" : ""border-input"")"" /> }
@code { - [Parameter] - public string Value { get; set; } = """"; - - [Parameter] - public EventCallback ValueChanged { get; set; } - - [Parameter] - public EventCallback OnComplete { get; set; } - - [Parameter] - public int Length { get; set; } = 6; - - [Parameter] - public int GroupBy { get; set; } = 3; - - [Parameter] - public bool Disabled { get; set; } - - [Parameter] - public string ClassName { get; set; } = """"; - + [Parameter] public string Value { get; set; } = """"; + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public EventCallback OnComplete { get; set; } + [Parameter] public int Length { get; set; } = 6; + [Parameter] public int GroupBy { get; set; } = 3; + [Parameter] public bool Disabled { get; set; } + [Parameter] public string? Class { get; set; } [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } - + private int _focusedIndex = -1; private ElementReference[] _inputRefs = null!; private string _id = Guid.NewGuid().ToString(""N"")[..8]; - + protected override void OnInitialized() { _inputRefs = new ElementReference[Length]; } - + private string GetDigit(int index) => index < Value.Length ? Value[index].ToString() : """"; - + private async Task HandleInput(int index, ChangeEventArgs e) { var input = e.Value?.ToString() ?? """"; - if (string.IsNullOrEmpty(input) || !char.IsDigit(input[0])) + if (string.IsNullOrEmpty(input) || !char.IsDigit(input[0])) { return; } - + var newValue = Value.PadRight(Length, ' '); var chars = newValue.ToCharArray(); chars[index] = input[0]; Value = new string(chars).TrimEnd(); - + await ValueChanged.InvokeAsync(Value); - + if (index < Length - 1) { await FocusInput(index + 1); } - + if (Value.Replace("" "", """").Length == Length) { await OnComplete.InvokeAsync(Value); } } - + private async Task HandleKeyDown(int index, KeyboardEventArgs e) { if (e.Key == ""Backspace"") @@ -140,27 +126,27 @@ private async Task HandleKeyDown(int index, KeyboardEventArgs e) await FocusInput(index + 1); } } - + private async Task HandlePaste(ClipboardEventArgs e) { + // Paste handling will be done via JS in a real implementation await Task.CompletedTask; } - + private async Task FocusInput(int index) { if (index >= 0 && index < Length) { try { - await JS.InvokeVoidAsync(""eval"", $""document.getElementById('otp-input-{_id}-{index}')?.focus()""); + await JS.InvokeVoidAsync(""ShellUI.focusElement"", $""otp-input-{_id}-{index}""); } catch { + // Ignore JS interop errors } } } } "; } - - diff --git a/src/ShellUI.Templates/Templates/SidebarTriggerTemplate.cs b/src/ShellUI.Templates/Templates/SidebarTriggerTemplate.cs index 0b3316e..7cded5c 100644 --- a/src/ShellUI.Templates/Templates/SidebarTriggerTemplate.cs +++ b/src/ShellUI.Templates/Templates/SidebarTriggerTemplate.cs @@ -23,7 +23,11 @@ public static class SidebarTriggerTemplate Class)"" @onclick=""HandleClick"" @attributes=""AdditionalAttributes""> - + + + + + Toggle Sidebar diff --git a/src/ShellUI.Templates/Templates/ThemeToggleTemplate.cs b/src/ShellUI.Templates/Templates/ThemeToggleTemplate.cs index 1b5cdd7..73b2a7b 100644 --- a/src/ShellUI.Templates/Templates/ThemeToggleTemplate.cs +++ b/src/ShellUI.Templates/Templates/ThemeToggleTemplate.cs @@ -12,7 +12,9 @@ public static class ThemeToggleTemplate Category = ComponentCategory.Utility, FilePath = "ThemeToggle.razor", - Dependencies = new List() + // shellui-js provides ShellUI.addClassToDocument / removeClassFromDocument + // used by the toggle action. Without it the toggle silently no-ops. + Dependencies = new List { "shellui-js" } }; public static string Content => @"@namespace YourProjectNamespace.Components.UI @@ -22,7 +24,7 @@ @implements IAsyncDisposable