diff --git a/ShellUI.Tests/ThemeServiceTests.cs b/ShellUI.Tests/ThemeServiceTests.cs new file mode 100644 index 0000000..59309f3 --- /dev/null +++ b/ShellUI.Tests/ThemeServiceTests.cs @@ -0,0 +1,289 @@ +using System.IO; +using System.Linq; +using ShellUI.CLI.Services; +using Xunit; + +namespace ShellUI.Tests; + +public class ThemeService_UrlNormalization +{ + [Theory] + [InlineData("cmgy5f2vg000504l42pep9ob1", "https://tweakcn.com/r/themes/cmgy5f2vg000504l42pep9ob1")] + [InlineData("https://tweakcn.com/themes/cmgy5f2vg000504l42pep9ob1", "https://tweakcn.com/r/themes/cmgy5f2vg000504l42pep9ob1")] + [InlineData("https://tweakcn.com/themes/cmgy5f2vg000504l42pep9ob1?tab=colors", "https://tweakcn.com/r/themes/cmgy5f2vg000504l42pep9ob1")] + [InlineData("https://tweakcn.com/r/themes/cmgy5f2vg000504l42pep9ob1", "https://tweakcn.com/r/themes/cmgy5f2vg000504l42pep9ob1")] + [InlineData("http://tweakcn.com/themes/cmgy5f2vg000504l42pep9ob1", "https://tweakcn.com/r/themes/cmgy5f2vg000504l42pep9ob1")] + public void NormalizeTweakcnUrl_HandlesAcceptedForms(string input, string expected) + { + Assert.Equal(expected, ThemeService.NormalizeTweakcnUrl(input)); + } + + [Theory] + [InlineData("https://example.com/themes/abc")] + [InlineData("")] + [InlineData(" ")] + [InlineData("https://tweakcn.com/dashboard")] + public void NormalizeTweakcnUrl_RejectsUnrecognizedInputs(string input) + { + Assert.Throws(() => ThemeService.NormalizeTweakcnUrl(input)); + } +} + +public class ThemeService_JsonParsing +{ + private const string SampleTweakcnPayload = """ + { + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "test-theme", + "type": "registry:theme", + "cssVars": { + "theme": { + "font-sans": "Inter, sans-serif", + "radius": "0.75rem" + }, + "light": { + "background": "oklch(0.99 0 0)", + "foreground": "oklch(0.10 0 0)", + "primary": "oklch(0.55 0.15 250)" + }, + "dark": { + "background": "oklch(0.10 0 0)", + "foreground": "oklch(0.95 0 0)" + } + } + } + """; + + [Fact] + public void ParseTheme_ExtractsNameAndAllThreeVarDictionaries() + { + var theme = ThemeService.ParseTheme(SampleTweakcnPayload); + + Assert.Equal("test-theme", theme.Name); + Assert.Equal(2, theme.ThemeVars.Count); + Assert.Equal(3, theme.LightVars.Count); + Assert.Equal(2, theme.DarkVars.Count); + Assert.Equal("oklch(0.55 0.15 250)", theme.LightVars["primary"]); + Assert.Equal("Inter, sans-serif", theme.ThemeVars["font-sans"]); + } + + [Fact] + public void ParseTheme_HandlesMissingSections() + { + var json = """{ "name": "minimal", "cssVars": { "light": { "background": "#fff" } } }"""; + var theme = ThemeService.ParseTheme(json); + + Assert.Equal("minimal", theme.Name); + Assert.Empty(theme.ThemeVars); + Assert.Empty(theme.DarkVars); + Assert.Single(theme.LightVars); + } +} + +public class ThemeService_CssGeneration +{ + private static readonly TweakcnTheme Sample = new() + { + Name = "test", + ThemeVars = new() { ["font-sans"] = "Inter, sans-serif", ["radius"] = "0.75rem" }, + LightVars = new() { ["background"] = "oklch(0.99 0 0)", ["foreground"] = "oklch(0.10 0 0)" }, + DarkVars = new() { ["background"] = "oklch(0.10 0 0)", ["foreground"] = "oklch(0.95 0 0)" }, + }; + + [Fact] + public void BuildThemeCss_EmitsSentinelMarkersAndBothScopes() + { + var css = ThemeService.BuildThemeCss(Sample); + + Assert.Contains(ThemeService.BeginMarker, css); + Assert.Contains(ThemeService.EndMarker, css); + Assert.Contains(":root {", css); + Assert.Contains(".dark {", css); + Assert.Contains("@theme inline {", css); + Assert.Contains("--background: oklch(0.99 0 0);", css); + Assert.Contains("--font-sans: Inter, sans-serif;", css); + } + + [Fact] + public void BuildThemeCss_OrdersVariablesDeterministically() + { + var css1 = ThemeService.BuildThemeCss(Sample); + var css2 = ThemeService.BuildThemeCss(Sample); + Assert.Equal(css1, css2); + } + + [Fact] + public void BuildThemeCss_SkipsSectionsWithNoVars() + { + var themeOnlyLight = new TweakcnTheme + { + Name = "x", + ThemeVars = new(), + LightVars = new() { ["background"] = "#fff" }, + DarkVars = new(), + }; + var css = ThemeService.BuildThemeCss(themeOnlyLight); + + Assert.Contains(":root {", css); + Assert.DoesNotContain(".dark {", css); + Assert.DoesNotContain("@theme inline {", css); + } +} + +public class ThemeService_ApplyToInputCss +{ + private static TweakcnTheme MakeTheme(string tag = "themed") => new() + { + Name = tag, + ThemeVars = new(), + LightVars = new() { ["background"] = $"var(--{tag}-bg)" }, + DarkVars = new(), + }; + + [Fact] + public void Apply_CreatesInputCssWithStarterContent_WhenFileMissing() + { + var dir = TempDir(); + var path = Path.Combine(dir, "input.css"); + try + { + ThemeService.ApplyToInputCss(path, MakeTheme()); + var content = File.ReadAllText(path); + Assert.Contains("@import \"tailwindcss\";", content); + Assert.Contains("@custom-variant dark", content); + Assert.Contains(ThemeService.BeginMarker, content); + Assert.Contains("var(--themed-bg)", content); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Apply_PreservesUserContentAboveAndBelow_WhenSentinelExists() + { + var dir = TempDir(); + var path = Path.Combine(dir, "input.css"); + try + { + var original = "@import \"tailwindcss\";\n" + + "@source \"./shellui-classes.txt\";\n" + + "\n" + + "/* user's custom utilities */\n" + + ".my-custom { color: red; }\n" + + "\n" + + ThemeService.BeginMarker + "\n" + + ":root { --background: oklch(0.99 0 0); }\n" + + ThemeService.EndMarker + "\n" + + "\n" + + "/* more user content below */\n" + + ".another-custom { color: blue; }\n"; + File.WriteAllText(path, original); + + ThemeService.ApplyToInputCss(path, MakeTheme("swapped")); + var updated = File.ReadAllText(path); + + Assert.Contains("@source \"./shellui-classes.txt\";", updated); + Assert.Contains(".my-custom { color: red; }", updated); + Assert.Contains(".another-custom { color: blue; }", updated); + Assert.Contains("var(--swapped-bg)", updated); + Assert.DoesNotContain("oklch(0.99 0 0)", updated); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Apply_AppendsBlock_WhenExistingFileHasNoSentinel() + { + var dir = TempDir(); + var path = Path.Combine(dir, "input.css"); + try + { + var original = "@import \"tailwindcss\";\n:root { --background: #fff; }\n"; + File.WriteAllText(path, original); + + ThemeService.ApplyToInputCss(path, MakeTheme()); + var updated = File.ReadAllText(path); + + Assert.Contains("@import \"tailwindcss\";", updated); + Assert.Contains("--background: #fff", updated); + Assert.Contains(ThemeService.BeginMarker, updated); + Assert.Contains("var(--themed-bg)", updated); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Apply_IsIdempotent_WithinSentinelRegion() + { + var dir = TempDir(); + var path = Path.Combine(dir, "input.css"); + try + { + ThemeService.ApplyToInputCss(path, MakeTheme()); + var first = File.ReadAllText(path); + ThemeService.ApplyToInputCss(path, MakeTheme()); + var second = File.ReadAllText(path); + + Assert.Equal(first, second); + } + finally + { + Directory.Delete(dir, true); + } + } + + private static string TempDir() + { + var d = Path.Combine(Path.GetTempPath(), "shellui-theme-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(d); + return d; + } +} + +public class ThemeService_LockFile +{ + [Fact] + public void WriteAndRead_RoundTrip() + { + var dir = Path.Combine(Path.GetTempPath(), "shellui-lock-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + ThemeService.WriteLockFile(dir, "https://tweakcn.com/r/themes/abc", "{\"name\":\"x\"}", "x"); + var lockFile = ThemeService.ReadLockFile(dir); + + Assert.NotNull(lockFile); + Assert.Equal("https://tweakcn.com/r/themes/abc", lockFile!.SourceUrl); + Assert.Equal("x", lockFile.ThemeName); + Assert.Equal(64, lockFile.ContentSha256.Length); // SHA-256 hex is 64 chars + Assert.Matches("^[a-f0-9]+$", lockFile.ContentSha256); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void ReadLockFile_ReturnsNull_WhenMissing() + { + var dir = Path.Combine(Path.GetTempPath(), "shellui-lock-empty-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + Assert.Null(ThemeService.ReadLockFile(dir)); + } + finally + { + Directory.Delete(dir, true); + } + } +} diff --git a/src/ShellUI.CLI/Program.cs b/src/ShellUI.CLI/Program.cs index 103d1a3..a12de1a 100644 --- a/src/ShellUI.CLI/Program.cs +++ b/src/ShellUI.CLI/Program.cs @@ -15,16 +15,113 @@ static async Task Main(string[] args) Description = "Add beautiful, accessible components to your Blazor app. Inspired by shadcn/ui." }; - // Add commands rootCommand.AddCommand(CreateInitCommand()); rootCommand.AddCommand(CreateAddCommand()); rootCommand.AddCommand(CreateListCommand()); rootCommand.AddCommand(CreateRemoveCommand()); rootCommand.AddCommand(CreateUpdateCommand()); + rootCommand.AddCommand(CreateThemeCommand()); return await rootCommand.InvokeAsync(args); } + static Command CreateThemeCommand() + { + var theme = new Command("theme", "Fetch and apply themes from tweakcn.com at build time."); + theme.AddCommand(CreateThemeApplyCommand()); + theme.AddCommand(CreateThemeUpdateCommand()); + return theme; + } + + static Command CreateThemeApplyCommand() + { + var command = new Command("apply", "Fetch a theme from tweakcn and bake it into wwwroot/input.css"); + + var urlArg = new Argument("url", + "tweakcn URL or theme id. Accepts https://tweakcn.com/themes/, the raw , or the public https://tweakcn.com/r/themes/ endpoint."); + command.AddArgument(urlArg); + + var emitOverrideOpt = new Option("--emit-override", + "Write the theme block to a standalone CSS file (Path A/D users link this after shellui-all.css). If omitted, updates wwwroot/input.css in-place."); + command.AddOption(emitOverrideOpt); + + command.SetHandler(async (url, emitOverride) => + { + try + { + await ApplyThemeAsync(url, emitOverride); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message.Replace("[", "[[").Replace("]", "]]")}"); + Environment.Exit(1); + } + }, urlArg, emitOverrideOpt); + + return command; + } + + static Command CreateThemeUpdateCommand() + { + var command = new Command("update", "Re-fetch the theme recorded in shellui.theme.lock and re-apply it."); + + command.SetHandler(async () => + { + try + { + var cwd = Directory.GetCurrentDirectory(); + var lockFile = ThemeService.ReadLockFile(cwd); + if (lockFile is null) + { + AnsiConsole.MarkupLine("[yellow]No shellui.theme.lock in this directory.[/] Run [bold]shellui theme apply [/] first."); + Environment.Exit(2); + return; + } + AnsiConsole.MarkupLine($"[cyan]Updating theme[/] [bold]{lockFile!.ThemeName}[/] from [dim]{lockFile.SourceUrl}[/]"); + await ApplyThemeAsync(lockFile.SourceUrl, emitOverride: null); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message.Replace("[", "[[").Replace("]", "]]")}"); + Environment.Exit(1); + } + }); + + return command; + } + + private static async Task ApplyThemeAsync(string url, string? emitOverride) + { + var cwd = Directory.GetCurrentDirectory(); + + AnsiConsole.MarkupLine($"[cyan]Fetching theme:[/] {url.Replace("[", "[[").Replace("]", "]]")}"); + var json = await ThemeService.FetchThemeJsonAsync(url); + var theme = ThemeService.ParseTheme(json); + AnsiConsole.MarkupLine($"[green]✓[/] Fetched: [bold]{theme.Name}[/] ({theme.LightVars.Count} light + {theme.DarkVars.Count} dark vars)"); + + if (!string.IsNullOrEmpty(emitOverride)) + { + var outPath = Path.IsPathRooted(emitOverride) ? emitOverride : Path.Combine(cwd, emitOverride); + ThemeService.EmitOverride(outPath, theme); + AnsiConsole.MarkupLine($"[green]✓[/] Wrote override: [dim]{outPath}[/]"); + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine("[yellow]Next step:[/] link the override AFTER shellui-all.css in your App.razor :"); + AnsiConsole.MarkupLine($" [dim][/]"); + AnsiConsole.MarkupLine($" [bold][/]"); + } + else + { + var inputCss = Path.Combine(cwd, "wwwroot", "input.css"); + ThemeService.ApplyToInputCss(inputCss, theme); + AnsiConsole.MarkupLine($"[green]✓[/] Updated: [dim]wwwroot/input.css[/]"); + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine("[yellow]Next step:[/] rebuild Tailwind (or run [bold]dotnet build[/] if the CLI wired that up during init)."); + } + + ThemeService.WriteLockFile(cwd, url, json, theme.Name); + AnsiConsole.MarkupLine($"[dim]Lock file written: shellui.theme.lock[/]"); + } + static Command CreateInitCommand() { var command = new Command("init", "Initialize ShellUI in your Blazor project"); @@ -53,7 +150,6 @@ static Command CreateInitCommand() { try { - // Beautiful ASCII art logo for SHELLUI var logo = @" ███████╗██╗ ██╗███████╗██╗ ██╗ ██╗ ██╗██╗ ██╔════╝██║ ██║██╔════╝██║ ██║ ██║ ██║██║ diff --git a/src/ShellUI.CLI/Services/ThemeService.cs b/src/ShellUI.CLI/Services/ThemeService.cs new file mode 100644 index 0000000..18ad88f --- /dev/null +++ b/src/ShellUI.CLI/Services/ThemeService.cs @@ -0,0 +1,210 @@ +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using ShellUI.Core.Models; +using Spectre.Console; + +namespace ShellUI.CLI.Services; + +/// Fetches themes from tweakcn.com and bakes them into a project's input.css at build time. +public static class ThemeService +{ + private const string LockFileName = "shellui.theme.lock"; + + // User content OUTSIDE these markers survives re-applies. + internal const string BeginMarker = "/* BEGIN shellui theme — managed by `shellui theme apply` */"; + internal const string EndMarker = "/* END shellui theme */"; + + public static string NormalizeTweakcnUrl(string input) + { + input = input.Trim(); + if (string.IsNullOrEmpty(input)) + throw new ArgumentException("theme URL or id is empty", nameof(input)); + + if (!input.Contains('/') && !input.Contains(':')) + return $"https://tweakcn.com/r/themes/{input}"; + + var editorMatch = Regex.Match(input, @"^https?://tweakcn\.com/themes/([^/?#]+)"); + if (editorMatch.Success) + return $"https://tweakcn.com/r/themes/{editorMatch.Groups[1].Value}"; + + if (Regex.IsMatch(input, @"^https?://tweakcn\.com/r/themes/")) + return input; + + throw new ArgumentException( + $"Unrecognized tweakcn URL: '{input}'. Expected https://tweakcn.com/themes/ or a bare theme id.", + nameof(input)); + } + + // Kept separate from ParseTheme so callers can hash the exact bytes for the lock file. + public static async Task FetchThemeJsonAsync(string url, HttpClient? httpClient = null) + { + var normalized = NormalizeTweakcnUrl(url); + var owned = httpClient is null; + var client = httpClient ?? new HttpClient(); + try + { + client.Timeout = TimeSpan.FromSeconds(30); + using var response = await client.GetAsync(normalized); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"tweakcn returned {(int)response.StatusCode} for {normalized}. " + + $"Check the theme id — is the URL correct? Is the theme public?"); + } + return await response.Content.ReadAsStringAsync(); + } + finally + { + if (owned) client.Dispose(); + } + } + + public static TweakcnTheme ParseTheme(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var name = root.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? "theme" : "theme"; + var cssVars = root.GetProperty("cssVars"); + + return new TweakcnTheme + { + Name = name, + ThemeVars = ExtractDict(cssVars, "theme"), + LightVars = ExtractDict(cssVars, "light"), + DarkVars = ExtractDict(cssVars, "dark"), + }; + } + + private static Dictionary ExtractDict(JsonElement parent, string prop) + { + var result = new Dictionary(StringComparer.Ordinal); + if (!parent.TryGetProperty(prop, out var el) || el.ValueKind != JsonValueKind.Object) + return result; + foreach (var kv in el.EnumerateObject()) + { + var value = kv.Value.ValueKind switch + { + JsonValueKind.String => kv.Value.GetString(), + JsonValueKind.Number => kv.Value.GetRawText(), + _ => null, + }; + if (!string.IsNullOrEmpty(value)) result[kv.Name] = value!; + } + return result; + } + + public static string BuildThemeCss(TweakcnTheme theme) + { + var sb = new StringBuilder(); + sb.AppendLine(BeginMarker); + sb.AppendLine($"/* Theme: {theme.Name} — fetched from tweakcn */"); + + WriteBlock(sb, ":root", theme.LightVars); + WriteBlock(sb, ".dark", theme.DarkVars); + + if (theme.ThemeVars.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("@theme inline {"); + foreach (var (k, v) in theme.ThemeVars.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + sb.AppendLine($" --{k}: {v};"); + sb.AppendLine("}"); + } + + sb.AppendLine(EndMarker); + return sb.ToString(); + } + + private static void WriteBlock(StringBuilder sb, string selector, Dictionary vars) + { + if (vars.Count == 0) return; + sb.AppendLine(); + sb.AppendLine($"{selector} {{"); + foreach (var (k, v) in vars.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + sb.AppendLine($" --{k}: {v};"); + sb.AppendLine("}"); + } + + /// Idempotent — the sentinel-marked region is replaced entirely; content outside it is preserved verbatim. + public static void ApplyToInputCss(string inputCssPath, TweakcnTheme theme) + { + var newBlock = BuildThemeCss(theme).TrimEnd() + Environment.NewLine; + + if (!File.Exists(inputCssPath)) + { + var starter = "@import \"tailwindcss\";" + Environment.NewLine + + "@custom-variant dark (&:is(.dark *));" + Environment.NewLine + + Environment.NewLine + + newBlock; + Directory.CreateDirectory(Path.GetDirectoryName(inputCssPath)!); + File.WriteAllText(inputCssPath, starter); + return; + } + + var content = File.ReadAllText(inputCssPath); + var beginIdx = content.IndexOf(BeginMarker, StringComparison.Ordinal); + var endIdx = content.IndexOf(EndMarker, StringComparison.Ordinal); + + if (beginIdx >= 0 && endIdx > beginIdx) + { + var before = content.Substring(0, beginIdx); + var after = content.Substring(endIdx + EndMarker.Length); + File.WriteAllText(inputCssPath, before + newBlock.TrimEnd() + after); + return; + } + + var trailing = content.EndsWith(Environment.NewLine) ? "" : Environment.NewLine; + File.WriteAllText(inputCssPath, content + trailing + Environment.NewLine + newBlock); + } + + /// For Path A/D consumers: writes only the theme block so they can link it AFTER the precompiled bundle. + public static void EmitOverride(string outputPath, TweakcnTheme theme) + { + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); + File.WriteAllText(outputPath, BuildThemeCss(theme)); + } + + public static void WriteLockFile(string projectRoot, string sourceUrl, string jsonBody, string themeName) + { + var lockFile = new ThemeLockFile + { + SourceUrl = sourceUrl, + ContentSha256 = Sha256Hex(jsonBody), + ThemeName = themeName, + AppliedAt = DateTime.UtcNow, + }; + var path = Path.Combine(projectRoot, LockFileName); + File.WriteAllText(path, JsonSerializer.Serialize(lockFile, new JsonSerializerOptions { WriteIndented = true })); + } + + public static ThemeLockFile? ReadLockFile(string projectRoot) + { + var path = Path.Combine(projectRoot, LockFileName); + if (!File.Exists(path)) return null; + try + { + return JsonSerializer.Deserialize(File.ReadAllText(path)); + } + catch (JsonException) + { + return null; + } + } + + private static string Sha256Hex(string input) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} + +public class TweakcnTheme +{ + public required string Name { get; init; } + public required Dictionary ThemeVars { get; init; } + public required Dictionary LightVars { get; init; } + public required Dictionary DarkVars { get; init; } +} diff --git a/src/ShellUI.Components/build/ShellUI.Components.targets b/src/ShellUI.Components/build/ShellUI.Components.targets index b0ab1a7..6ecc333 100644 --- a/src/ShellUI.Components/build/ShellUI.Components.targets +++ b/src/ShellUI.Components/build/ShellUI.Components.targets @@ -1,13 +1,5 @@ - + diff --git a/src/ShellUI.Core/Models/ThemeLockFile.cs b/src/ShellUI.Core/Models/ThemeLockFile.cs new file mode 100644 index 0000000..a62b79c --- /dev/null +++ b/src/ShellUI.Core/Models/ThemeLockFile.cs @@ -0,0 +1,10 @@ +namespace ShellUI.Core.Models; + +/// Records the tweakcn theme applied by `shellui theme apply`, so `shellui theme update` knows where to re-fetch from. +public class ThemeLockFile +{ + public required string SourceUrl { get; set; } + public required string ContentSha256 { get; set; } + public required string ThemeName { get; set; } + public required DateTime AppliedAt { get; set; } +} diff --git a/tools/ShellUI.SafelistGenerator/Program.cs b/tools/ShellUI.SafelistGenerator/Program.cs index 824618e..117157d 100644 --- a/tools/ShellUI.SafelistGenerator/Program.cs +++ b/tools/ShellUI.SafelistGenerator/Program.cs @@ -4,17 +4,10 @@ namespace ShellUI.SafelistGenerator; -/// Scans .razor files for Tailwind utility classes and emits two artifacts: -/// -/// 1. wwwroot/shellui-classes.txt — flat sorted list (committed for drift detection -/// and used by the live ShellUI demo's own Tailwind build). -/// -/// 2. build/ShellUI.Components.targets — NuGet-auto-imported MSBuild file that -/// embeds every class in an and writes them out to the consumer's -/// wwwroot/ during build. Self-contained — no second NuGet file to extract. -/// -/// Usage: -/// dotnet run --project tools/ShellUI.SafelistGenerator -- +/* Scans .razor and .cs files for Tailwind utility classes and emits: + - wwwroot/shellui-classes.txt (committed, used for drift detection + demo build) + - build/ShellUI.Components.targets (NuGet auto-imports; embeds the list inline so we don't + depend on NuGet extracting a separate data file — behavior varies by client) */ public static class Program { public static int Main(string[] args) @@ -50,10 +43,7 @@ public static int Main(string[] args) return 0; } - /// Public — enumerates the source files the generator scans. Reused by tests - /// so the drift check compares against the same file list the CLI would use. - /// Both .razor components and .cs helpers get scanned (Variants, Services); - /// bin/ and obj/ are excluded so contributors' build outputs don't taint output. + // Shared with tests so the drift check scans the same file set as the CLI. public static (string[] razorFiles, string[] csFiles) EnumerateSources(string razorDir) { var razorFiles = Directory.GetFiles(razorDir, "*.razor", SearchOption.AllDirectories); @@ -66,7 +56,6 @@ public static (string[] razorFiles, string[] csFiles) EnumerateSources(string ra return (razorFiles, csFiles); } - /// Public for in-process testing — runs the same extraction the CLI invocation does. public static SortedSet GenerateSafelist(IEnumerable sourceFilePaths) { var classes = new SortedSet(StringComparer.Ordinal); @@ -85,11 +74,7 @@ public static SortedSet GenerateSafelist(IEnumerable sourceFileP return classes; } - // For .cs files (Variants, Services), pull tokens out of every string literal - // — but only from literals that LOOK like a class-list string. Filters out - // JS/HTML fragments, URLs, CSS property strings, error messages, and other - // non-Tailwind literals that would otherwise pollute the safelist with tokens - // like `background-color:`, `series[idx]`, `shadcn/ui.`, etc. + // .cs literals need filtering — without it, JS/CSS/error-message strings pollute the safelist. private static void ExtractFromCSharp(string content, SortedSet sink) { foreach (Match lit in StringLiteralRegex.Matches(content)) @@ -100,18 +85,13 @@ private static void ExtractFromCSharp(string content, SortedSet sink) } } - // Heuristic: a class-list string is space-separated tokens, each looking like - // a Tailwind utility. Reject literals that contain characters/patterns that - // never appear in a real class list. private static bool LooksLikeClassList(string s) { if (s.Length == 0 || s.Length > 500) return false; - // URLs, HTML/XML tags, JS fragments, C# format placeholders, CSS property syntax + // `=` outside `[…]` implies key=value pairs, not class strings. if (s.Contains("://") || s.Contains('<') || s.Contains('>') || s.Contains('{') - || s.Contains(';') || s.Contains('=') && !s.Contains('[') // `=` outside `[…]` = probably not a class + || s.Contains(';') || s.Contains('=') && !s.Contains('[') || s.Contains('\n')) return false; - // Must contain at least one token that looks Tailwindy: contains `-` or `:` or `[` - // AND is composed of tokens that all satisfy the "utility class" shape. var tokens = s.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); if (tokens.Length == 0) return false; var anyUtility = false; @@ -121,7 +101,6 @@ private static bool LooksLikeClassList(string s) var t = tok.TrimEnd(',', ';', ')', '"'); if (t.Length < 2) return false; if (!char.IsLower(t[0]) && t[0] != '[' && t[0] != '!') return false; - // Reject tokens with characters that never appear in a class name foreach (var c in t) { if (!(char.IsLetterOrDigit(c) || c is '-' or '_' or ':' or '/' or '.' or '[' or ']' @@ -134,25 +113,13 @@ private static bool LooksLikeClassList(string s) return anyUtility; } - /// Public for in-process testing — produces the same .targets content the CLI writes. + // XML comments in the generated .targets must not contain `--` (XML 1.0 spec); MSBuild on + // Linux .NET 10.301+ rejects the file with MSB4024. Keep the emitted comment text safe. public static string BuildTargetsFileContent(IEnumerable classes) { - // XML comments cannot contain a `--` sequence (XML 1.0 spec). Some MSBuild - // versions accept it; others (Linux .NET 10.301+) reject the whole file - // with MSB4024. Avoid `--` anywhere in the comment text — that's why we - // don't show example CLI flags here. Regen instructions live in the tool's - // own help output: `dotnet run --project tools/ShellUI.SafelistGenerator`. var sb = new StringBuilder(); sb.AppendLine(""); - sb.AppendLine(" "); + sb.AppendLine(" "); sb.AppendLine(" "); foreach (var cls in classes) { @@ -176,17 +143,12 @@ public static string BuildTargetsFileContent(IEnumerable classes) private static string XmlEscape(string s) { - // We control the input (extracted Tailwind class strings) but `&` and `"` show - // up in arbitrary values like data-[state=on] — escape conservatively. return s.Replace("&", "&") .Replace("\"", """) .Replace("<", "<") .Replace(">", ">"); } - // Class-attribute extraction — handles literal strings, Razor expressions, nested - // Shell.Cn("foo", …) literals. Razor variable parts (`@Class`) are dropped because - // Tailwind would not see those at build time anyway. private static readonly Regex ClassAttributeRegex = new( "class\\s*=\\s*\"(?[^\"]*)\"", RegexOptions.Compiled); @@ -208,9 +170,6 @@ private static void ExtractClasses(string content, SortedSet sink) } } - // A token qualifies as a Tailwind utility class if it contains at least one of - // `-`, `:`, `[`, `/` AND starts with a lowercase letter. Excludes C# identifiers - // (`Class`, `Variant`) caught by our literal extraction. private static void HarvestTokens(string text, SortedSet sink) { var tokens = text.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);