diff --git a/ShellUI.Tests/ShellCnTests.cs b/ShellUI.Tests/ShellCnTests.cs
new file mode 100644
index 0000000..e62f1de
--- /dev/null
+++ b/ShellUI.Tests/ShellCnTests.cs
@@ -0,0 +1,65 @@
+using ShellUI.Components;
+using ShellUI.Templates;
+using Xunit;
+
+namespace ShellUI.Tests;
+
+public class ShellCnTests
+{
+ [Theory]
+ [InlineData("max-w-md p-4", "max-w-lg", "p-4 max-w-lg")]
+ [InlineData("px-4 py-2", "px-6", "py-2 px-6")]
+ [InlineData("px-2", "p-4", "p-4")]
+ [InlineData("p-4", "px-2", "p-4 px-2")]
+ [InlineData("bg-primary text-primary-foreground", "bg-destructive", "text-primary-foreground bg-destructive")]
+ [InlineData("text-sm text-muted-foreground", "text-lg", "text-muted-foreground text-lg")]
+ [InlineData("text-sm text-muted-foreground", "text-red-500", "text-sm text-red-500")]
+ [InlineData("border border-input", "border-2", "border-input border-2")]
+ [InlineData("border border-input", "border-destructive", "border border-destructive")]
+ [InlineData("rounded-md", "rounded-none", "rounded-none")]
+ [InlineData("rounded-t-md", "rounded-lg", "rounded-lg")]
+ [InlineData("flex items-center", "hidden", "items-center hidden")]
+ [InlineData("hover:bg-accent", "hover:bg-muted", "hover:bg-muted")]
+ [InlineData("md:hover:bg-accent", "hover:md:bg-muted", "hover:md:bg-muted")]
+ [InlineData("data-[state=open]:bg-accent", "data-[state=open]:bg-muted", "data-[state=open]:bg-muted")]
+ [InlineData("mt-2", "-mt-4", "-mt-4")]
+ [InlineData("w-10 h-10", "size-8", "size-8")]
+ [InlineData("shadow-sm", "shadow-lg", "shadow-lg")]
+ [InlineData("ring-2 ring-ring", "ring-0", "ring-ring ring-0")]
+ [InlineData("font-medium font-sans", "font-semibold", "font-sans font-semibold")]
+ [InlineData("text-sm", "text-[13px]", "text-[13px]")]
+ [InlineData("text-foreground", "text-[#ff0000]", "text-[#ff0000]")]
+ [InlineData("[--sidebar-width:16rem]", "[--sidebar-width:20rem]", "[--sidebar-width:20rem]")]
+ public void LaterClassWins(string defaults, string overrides, string expected) =>
+ Assert.Equal(expected, Shell.Cn(defaults, overrides));
+
+ [Theory]
+ [InlineData("p-4 md:p-6")]
+ [InlineData("bg-primary hover:bg-primary/90")]
+ [InlineData("ring-2 ring-ring ring-offset-2 ring-offset-background")]
+ [InlineData("border-b border-border")]
+ [InlineData("text-sm leading-none")]
+ [InlineData("shadow-sm shadow-black/5")]
+ [InlineData("max-w-lg max-w-lg!")]
+ [InlineData("translate-x-1/2 -translate-y-1/2")]
+ [InlineData("group peer custom-class another-custom")]
+ [InlineData("flex flex-col gap-2 overflow-hidden overflow-y-auto")]
+ public void DifferentPropertiesOrVariants_AreKept(string classes) =>
+ Assert.Equal(classes, Shell.Cn(classes));
+
+ [Fact]
+ public void SkipsEmptyParts() =>
+ Assert.Equal("a b", Shell.Cn(null, "a", "", " ", "b"));
+
+ [Fact]
+ public void CliTemplate_IsThePackageHelper()
+ {
+ var root = new DirectoryInfo(AppContext.BaseDirectory);
+ while (!File.Exists(Path.Combine(root.FullName, "ShellUI.slnx"))) root = root.Parent!;
+ var package = File.ReadAllText(Path.Combine(root.FullName, "src", "ShellUI.Components", "Shell.cs")).Replace("\r\n", "\n");
+ var template = ComponentRegistry.GetComponentContent("shell")!
+ .Replace("YourProjectNamespace.Components.UI", "ShellUI.Components").Replace("\r\n", "\n");
+
+ Assert.Equal(package, template);
+ }
+}
diff --git a/docs/FAQ.md b/docs/FAQ.md
index 6a36012..5b0c8ea 100644
--- a/docs/FAQ.md
+++ b/docs/FAQ.md
@@ -185,6 +185,16 @@ shellui theme update
## Existing projects and troubleshooting
+### How do I override a component's classes?
+
+Pass them in `Class`. Components combine their own classes with yours through `Shell.Cn`, and when two classes set the same CSS property under the same variants, yours win:
+
+```razor
+
+```
+
+The button's own `px-4` and `bg-primary` are dropped, so you don't need Tailwind's `!` important modifier. Variants are separate: `hover:bg-primary/90` stays unless you also pass a `hover:bg-…` class. Classes `Cn` doesn't recognise are always kept.
+
### Can I use ShellUI in an existing Blazor app?
Yes. Run the CLI from the project root:
diff --git a/src/ShellUI.Components/Shell.cs b/src/ShellUI.Components/Shell.cs
index c1201af..4e91e3b 100644
--- a/src/ShellUI.Components/Shell.cs
+++ b/src/ShellUI.Components/Shell.cs
@@ -1,10 +1,217 @@
+using System.Collections.Concurrent;
using System.Linq;
namespace ShellUI.Components;
public static class Shell
{
+ // Joins class lists. When two classes set the same CSS property under the same variants, the later one wins
+ // (like tailwind-merge), so a component's Class parameter overrides its defaults. Unknown classes are kept.
public static string Cn(params string?[] classes)
- => string.Join(" ", classes.Where(c => !string.IsNullOrWhiteSpace(c)));
-}
+ {
+ var joined = string.Join(" ", classes.Where(c => !string.IsNullOrWhiteSpace(c)));
+ if (joined.Length == 0) return "";
+ if (Cache.TryGetValue(joined, out var cached)) return cached;
+ var merged = Merge(joined);
+ if (Cache.Count > 2000) Cache.Clear();
+ Cache[joined] = merged;
+ return merged;
+ }
+
+ private static readonly ConcurrentDictionary Cache = new();
+
+ private static string Merge(string classes)
+ {
+ var tokens = classes.Split(' ', System.StringSplitOptions.RemoveEmptyEntries);
+ var keep = new bool[tokens.Length];
+ var taken = new System.Collections.Generic.HashSet();
+ for (var i = tokens.Length - 1; i >= 0; i--)
+ {
+ var (variants, important, utility) = Parse(tokens[i]);
+ var group = Group(utility);
+ if (group == null) { keep[i] = true; continue; }
+ var scope = variants + (important ? "!" : "") + "|";
+ if (!taken.Add(scope + group)) continue;
+ keep[i] = true;
+ if (Covers.TryGetValue(group, out var covered))
+ foreach (var g in covered) taken.Add(scope + g);
+ }
+ return string.Join(" ", tokens.Where((_, i) => keep[i]));
+ }
+
+ private static (string Variants, bool Important, string Utility) Parse(string token)
+ {
+ var depth = 0;
+ var cut = -1;
+ for (var i = 0; i < token.Length; i++)
+ {
+ var c = token[i];
+ if (c is '[' or '(') depth++;
+ else if (c is ']' or ')') depth--;
+ else if (c == ':' && depth == 0) cut = i;
+ }
+ var variants = cut < 0 ? "" : string.Join(":", token[..cut].Split(':').OrderBy(v => v, System.StringComparer.Ordinal));
+ var utility = token[(cut + 1)..];
+ var important = false;
+ if (utility.StartsWith('!')) { important = true; utility = utility[1..]; }
+ if (utility.EndsWith('!')) { important = true; utility = utility[..^1]; }
+ if (utility.StartsWith('-')) utility = utility[1..];
+ return (variants, important, utility);
+ }
+
+ private static string? Group(string u)
+ {
+ if (u.Length == 0) return null;
+ if (u[0] == '[') return u.IndexOf(':') is > 1 and var colon ? "prop:" + u[1..colon] : null;
+ if (Keywords.TryGetValue(u, out var keyword)) return keyword;
+
+ foreach (var prefix in Prefixes)
+ {
+ if (u != prefix && !u.StartsWith(prefix + "-", System.StringComparison.Ordinal)) continue;
+ var value = u.Length == prefix.Length ? "" : u[(prefix.Length + 1)..];
+ return Classify(prefix, value);
+ }
+ return null;
+ }
+ private static string? Classify(string prefix, string value)
+ {
+ var bare = value.Split('/')[0];
+ switch (prefix)
+ {
+ case "text":
+ if (TextSizes.Contains(bare) || IsArbitraryLength(value)) return "font-size";
+ if (value is "left" or "center" or "right" or "justify" or "start" or "end") return "text-align";
+ if (value is "wrap" or "nowrap" or "balance" or "pretty") return "text-wrap";
+ if (value is "ellipsis" or "clip") return "text-overflow";
+ return value.Length == 0 ? null : "text-color";
+ case "font":
+ if (FontWeights.Contains(value) || (IsArbitrary(value) && char.IsDigit(value[1]))) return "font-weight";
+ return value.Length == 0 ? null : "font-family";
+ case "bg":
+ if (value.Length == 0) return null;
+ if (value is "none" || value.StartsWith("linear") || value.StartsWith("gradient") || value.StartsWith("radial") || value.StartsWith("conic") || value.StartsWith("[url(")) return "bg-image";
+ if (value is "auto" or "cover" or "contain") return "bg-size";
+ if (value is "fixed" or "local" or "scroll") return "bg-attachment";
+ if (value.StartsWith("repeat") || value == "no-repeat") return "bg-repeat";
+ if (value is "center" or "top" or "bottom" or "left" or "right" or "left-top" or "left-bottom" or "right-top" or "right-bottom") return "bg-position";
+ if (value.StartsWith("clip-") || value.StartsWith("origin-") || value.StartsWith("blend-")) return "bg-" + value.Split('-')[0];
+ return "bg-color";
+ case "border" or "border-x" or "border-y" or "border-t" or "border-r" or "border-b" or "border-l" or "border-s" or "border-e":
+ var side = prefix == "border" ? "" : prefix[6..];
+ if (value.Length == 0 || IsNumber(value) || IsArbitraryLength(value)) return "border-w" + side;
+ if (prefix == "border" && value is "solid" or "dashed" or "dotted" or "double" or "hidden" or "none") return "border-style";
+ return "border-color" + side;
+ case "ring":
+ if (value.Length == 0 || IsNumber(value) || IsArbitraryLength(value)) return "ring-w";
+ return value == "inset" ? "ring-inset" : "ring-color";
+ case "ring-offset":
+ return IsNumber(value) || IsArbitraryLength(value) ? "ring-offset-w" : "ring-offset-color";
+ case "outline":
+ if (value.Length == 0 || IsNumber(value) || IsArbitraryLength(value)) return "outline-w";
+ if (value is "none" or "hidden" or "solid" or "dashed" or "dotted" or "double") return "outline-style";
+ return "outline-color";
+ case "shadow":
+ if (value.Length == 0 || ShadowSizes.Contains(value) || IsArbitraryLength(value)) return "shadow";
+ return "shadow-color";
+ case "stroke":
+ return IsNumber(value) || IsArbitraryLength(value) ? "stroke-w" : "stroke";
+ case "from" or "via" or "to":
+ return prefix + (value.EndsWith('%') ? "-position" : "-color");
+ case "object":
+ return value is "contain" or "cover" or "fill" or "none" or "scale-down" ? "object-fit" : "object-position";
+ case "flex":
+ return value.Length == 0 ? null : "flex";
+ case "inset":
+ return value.StartsWith("ring") || value.StartsWith("shadow") ? null : "inset";
+ default:
+ return prefix;
+ }
+ }
+
+ private static bool IsNumber(string v) => v.Length > 0 && v.All(c => char.IsDigit(c) || c == '.');
+ private static bool IsArbitrary(string v) => v.Length > 2 && v[0] == '[' && v[^1] == ']';
+ private static bool IsArbitraryLength(string v) =>
+ IsArbitrary(v) && (v.StartsWith("[length:") || char.IsDigit(v[1]) || v[1] == '.');
+
+ private static readonly System.Collections.Generic.HashSet TextSizes =
+ new() { "xs", "sm", "base", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl", "7xl", "8xl", "9xl" };
+ private static readonly System.Collections.Generic.HashSet FontWeights =
+ new() { "thin", "extralight", "light", "normal", "medium", "semibold", "bold", "extrabold", "black" };
+ private static readonly System.Collections.Generic.HashSet ShadowSizes =
+ new() { "2xs", "xs", "sm", "md", "lg", "xl", "2xl", "inner", "none" };
+
+ private static readonly System.Collections.Generic.Dictionary Keywords = new()
+ {
+ ["block"] = "display", ["inline-block"] = "display", ["inline"] = "display", ["flex"] = "display",
+ ["inline-flex"] = "display", ["grid"] = "display", ["inline-grid"] = "display", ["hidden"] = "display",
+ ["contents"] = "display", ["table"] = "display", ["table-row"] = "display", ["table-cell"] = "display",
+ ["flow-root"] = "display", ["list-item"] = "display",
+ ["static"] = "position", ["fixed"] = "position", ["absolute"] = "position", ["relative"] = "position", ["sticky"] = "position",
+ ["visible"] = "visibility", ["invisible"] = "visibility", ["collapse"] = "visibility",
+ ["uppercase"] = "text-transform", ["lowercase"] = "text-transform", ["capitalize"] = "text-transform", ["normal-case"] = "text-transform",
+ ["italic"] = "font-style", ["not-italic"] = "font-style",
+ ["underline"] = "text-decoration", ["overline"] = "text-decoration", ["line-through"] = "text-decoration", ["no-underline"] = "text-decoration",
+ ["flex-row"] = "flex-direction", ["flex-row-reverse"] = "flex-direction", ["flex-col"] = "flex-direction", ["flex-col-reverse"] = "flex-direction",
+ ["flex-wrap"] = "flex-wrap", ["flex-wrap-reverse"] = "flex-wrap", ["flex-nowrap"] = "flex-wrap",
+ ["border-collapse"] = "border-collapse", ["border-separate"] = "border-collapse",
+ ["sr-only"] = "sr-only", ["not-sr-only"] = "sr-only",
+ };
+
+ // Longest first, so "border-t" wins over "border" and "ring-offset" over "ring".
+ private static readonly string[] Prefixes = new[]
+ {
+ "inset-x", "inset-y", "inset", "top", "right", "bottom", "left", "start", "end", "z",
+ "order", "grow", "shrink", "basis", "flex",
+ "grid-cols", "grid-rows", "col-span", "col-start", "col-end", "row-span", "row-start", "row-end",
+ "gap-x", "gap-y", "gap",
+ "justify-items", "justify-self", "justify", "items", "self", "place-items", "place-content", "place-self",
+ "p", "px", "py", "pt", "pr", "pb", "pl", "ps", "pe",
+ "m", "mx", "my", "mt", "mr", "mb", "ml", "ms", "me", "space-x", "space-y",
+ "w", "min-w", "max-w", "h", "min-h", "max-h", "size",
+ "overflow-x", "overflow-y", "overflow",
+ "leading", "tracking", "line-clamp", "whitespace", "align",
+ "opacity", "cursor", "pointer-events", "select", "resize",
+ "rounded", "rounded-s", "rounded-e", "rounded-t", "rounded-r", "rounded-b", "rounded-l",
+ "rounded-ss", "rounded-se", "rounded-ee", "rounded-es", "rounded-tl", "rounded-tr", "rounded-br", "rounded-bl",
+ "border", "border-x", "border-y", "border-t", "border-r", "border-b", "border-l", "border-s", "border-e",
+ "ring-offset", "ring", "outline-offset", "outline", "shadow",
+ "text", "font", "bg", "fill", "stroke", "from", "via", "to", "accent", "caret",
+ "transition", "duration", "ease", "delay", "animate",
+ "translate-x", "translate-y", "translate", "scale-x", "scale-y", "scale", "rotate", "skew-x", "skew-y", "origin",
+ "object", "aspect", "blur", "backdrop-blur",
+ }.OrderByDescending(p => p.Length).ToArray();
+
+ // A wider utility replaces earlier narrower ones (p-4 after px-2), but not the other way round.
+ private static readonly System.Collections.Generic.Dictionary Covers = new()
+ {
+ ["inset"] = new[] { "inset-x", "inset-y", "top", "right", "bottom", "left", "start", "end" },
+ ["inset-x"] = new[] { "left", "right", "start", "end" },
+ ["inset-y"] = new[] { "top", "bottom" },
+ ["flex"] = new[] { "grow", "shrink", "basis" },
+ ["gap"] = new[] { "gap-x", "gap-y" },
+ ["p"] = new[] { "px", "py", "pt", "pr", "pb", "pl", "ps", "pe" },
+ ["px"] = new[] { "pr", "pl", "ps", "pe" },
+ ["py"] = new[] { "pt", "pb" },
+ ["m"] = new[] { "mx", "my", "mt", "mr", "mb", "ml", "ms", "me" },
+ ["mx"] = new[] { "mr", "ml", "ms", "me" },
+ ["my"] = new[] { "mt", "mb" },
+ ["size"] = new[] { "w", "h" },
+ ["overflow"] = new[] { "overflow-x", "overflow-y" },
+ ["rounded"] = new[] { "rounded-s", "rounded-e", "rounded-t", "rounded-r", "rounded-b", "rounded-l", "rounded-ss", "rounded-se", "rounded-ee", "rounded-es", "rounded-tl", "rounded-tr", "rounded-br", "rounded-bl" },
+ ["rounded-t"] = new[] { "rounded-tl", "rounded-tr" },
+ ["rounded-r"] = new[] { "rounded-tr", "rounded-br" },
+ ["rounded-b"] = new[] { "rounded-br", "rounded-bl" },
+ ["rounded-l"] = new[] { "rounded-tl", "rounded-bl" },
+ ["rounded-s"] = new[] { "rounded-ss", "rounded-es" },
+ ["rounded-e"] = new[] { "rounded-se", "rounded-ee" },
+ ["border-w"] = new[] { "border-w-x", "border-w-y", "border-w-t", "border-w-r", "border-w-b", "border-w-l", "border-w-s", "border-w-e" },
+ ["border-w-x"] = new[] { "border-w-r", "border-w-l", "border-w-s", "border-w-e" },
+ ["border-w-y"] = new[] { "border-w-t", "border-w-b" },
+ ["border-color"] = new[] { "border-color-x", "border-color-y", "border-color-t", "border-color-r", "border-color-b", "border-color-l", "border-color-s", "border-color-e" },
+ ["border-color-x"] = new[] { "border-color-r", "border-color-l", "border-color-s", "border-color-e" },
+ ["border-color-y"] = new[] { "border-color-t", "border-color-b" },
+ ["translate"] = new[] { "translate-x", "translate-y" },
+ ["scale"] = new[] { "scale-x", "scale-y" },
+ };
+}
diff --git a/src/ShellUI.Templates/Templates/ShellTemplate.cs b/src/ShellUI.Templates/Templates/ShellTemplate.cs
index 13629c9..4fcc60f 100644
--- a/src/ShellUI.Templates/Templates/ShellTemplate.cs
+++ b/src/ShellUI.Templates/Templates/ShellTemplate.cs
@@ -16,14 +16,222 @@ public static class ShellTemplate
IsAvailable = false
};
- public static string Content => @"using System.Linq;
+ public static string Content => @"using System.Collections.Concurrent;
+using System.Linq;
namespace YourProjectNamespace.Components.UI;
public static class Shell
{
+ // Joins class lists. When two classes set the same CSS property under the same variants, the later one wins
+ // (like tailwind-merge), so a component's Class parameter overrides its defaults. Unknown classes are kept.
public static string Cn(params string?[] classes)
- => string.Join("" "", classes.Where(c => !string.IsNullOrWhiteSpace(c)));
+ {
+ var joined = string.Join("" "", classes.Where(c => !string.IsNullOrWhiteSpace(c)));
+ if (joined.Length == 0) return """";
+ if (Cache.TryGetValue(joined, out var cached)) return cached;
+ var merged = Merge(joined);
+ if (Cache.Count > 2000) Cache.Clear();
+ Cache[joined] = merged;
+ return merged;
+ }
+
+ private static readonly ConcurrentDictionary Cache = new();
+
+ private static string Merge(string classes)
+ {
+ var tokens = classes.Split(' ', System.StringSplitOptions.RemoveEmptyEntries);
+ var keep = new bool[tokens.Length];
+ var taken = new System.Collections.Generic.HashSet();
+ for (var i = tokens.Length - 1; i >= 0; i--)
+ {
+ var (variants, important, utility) = Parse(tokens[i]);
+ var group = Group(utility);
+ if (group == null) { keep[i] = true; continue; }
+ var scope = variants + (important ? ""!"" : """") + ""|"";
+ if (!taken.Add(scope + group)) continue;
+ keep[i] = true;
+ if (Covers.TryGetValue(group, out var covered))
+ foreach (var g in covered) taken.Add(scope + g);
+ }
+ return string.Join("" "", tokens.Where((_, i) => keep[i]));
+ }
+
+ private static (string Variants, bool Important, string Utility) Parse(string token)
+ {
+ var depth = 0;
+ var cut = -1;
+ for (var i = 0; i < token.Length; i++)
+ {
+ var c = token[i];
+ if (c is '[' or '(') depth++;
+ else if (c is ']' or ')') depth--;
+ else if (c == ':' && depth == 0) cut = i;
+ }
+ var variants = cut < 0 ? """" : string.Join("":"", token[..cut].Split(':').OrderBy(v => v, System.StringComparer.Ordinal));
+ var utility = token[(cut + 1)..];
+ var important = false;
+ if (utility.StartsWith('!')) { important = true; utility = utility[1..]; }
+ if (utility.EndsWith('!')) { important = true; utility = utility[..^1]; }
+ if (utility.StartsWith('-')) utility = utility[1..];
+ return (variants, important, utility);
+ }
+
+ private static string? Group(string u)
+ {
+ if (u.Length == 0) return null;
+ if (u[0] == '[') return u.IndexOf(':') is > 1 and var colon ? ""prop:"" + u[1..colon] : null;
+ if (Keywords.TryGetValue(u, out var keyword)) return keyword;
+
+ foreach (var prefix in Prefixes)
+ {
+ if (u != prefix && !u.StartsWith(prefix + ""-"", System.StringComparison.Ordinal)) continue;
+ var value = u.Length == prefix.Length ? """" : u[(prefix.Length + 1)..];
+ return Classify(prefix, value);
+ }
+ return null;
+ }
+
+ private static string? Classify(string prefix, string value)
+ {
+ var bare = value.Split('/')[0];
+ switch (prefix)
+ {
+ case ""text"":
+ if (TextSizes.Contains(bare) || IsArbitraryLength(value)) return ""font-size"";
+ if (value is ""left"" or ""center"" or ""right"" or ""justify"" or ""start"" or ""end"") return ""text-align"";
+ if (value is ""wrap"" or ""nowrap"" or ""balance"" or ""pretty"") return ""text-wrap"";
+ if (value is ""ellipsis"" or ""clip"") return ""text-overflow"";
+ return value.Length == 0 ? null : ""text-color"";
+ case ""font"":
+ if (FontWeights.Contains(value) || (IsArbitrary(value) && char.IsDigit(value[1]))) return ""font-weight"";
+ return value.Length == 0 ? null : ""font-family"";
+ case ""bg"":
+ if (value.Length == 0) return null;
+ if (value is ""none"" || value.StartsWith(""linear"") || value.StartsWith(""gradient"") || value.StartsWith(""radial"") || value.StartsWith(""conic"") || value.StartsWith(""[url("")) return ""bg-image"";
+ if (value is ""auto"" or ""cover"" or ""contain"") return ""bg-size"";
+ if (value is ""fixed"" or ""local"" or ""scroll"") return ""bg-attachment"";
+ if (value.StartsWith(""repeat"") || value == ""no-repeat"") return ""bg-repeat"";
+ if (value is ""center"" or ""top"" or ""bottom"" or ""left"" or ""right"" or ""left-top"" or ""left-bottom"" or ""right-top"" or ""right-bottom"") return ""bg-position"";
+ if (value.StartsWith(""clip-"") || value.StartsWith(""origin-"") || value.StartsWith(""blend-"")) return ""bg-"" + value.Split('-')[0];
+ return ""bg-color"";
+ case ""border"" or ""border-x"" or ""border-y"" or ""border-t"" or ""border-r"" or ""border-b"" or ""border-l"" or ""border-s"" or ""border-e"":
+ var side = prefix == ""border"" ? """" : prefix[6..];
+ if (value.Length == 0 || IsNumber(value) || IsArbitraryLength(value)) return ""border-w"" + side;
+ if (prefix == ""border"" && value is ""solid"" or ""dashed"" or ""dotted"" or ""double"" or ""hidden"" or ""none"") return ""border-style"";
+ return ""border-color"" + side;
+ case ""ring"":
+ if (value.Length == 0 || IsNumber(value) || IsArbitraryLength(value)) return ""ring-w"";
+ return value == ""inset"" ? ""ring-inset"" : ""ring-color"";
+ case ""ring-offset"":
+ return IsNumber(value) || IsArbitraryLength(value) ? ""ring-offset-w"" : ""ring-offset-color"";
+ case ""outline"":
+ if (value.Length == 0 || IsNumber(value) || IsArbitraryLength(value)) return ""outline-w"";
+ if (value is ""none"" or ""hidden"" or ""solid"" or ""dashed"" or ""dotted"" or ""double"") return ""outline-style"";
+ return ""outline-color"";
+ case ""shadow"":
+ if (value.Length == 0 || ShadowSizes.Contains(value) || IsArbitraryLength(value)) return ""shadow"";
+ return ""shadow-color"";
+ case ""stroke"":
+ return IsNumber(value) || IsArbitraryLength(value) ? ""stroke-w"" : ""stroke"";
+ case ""from"" or ""via"" or ""to"":
+ return prefix + (value.EndsWith('%') ? ""-position"" : ""-color"");
+ case ""object"":
+ return value is ""contain"" or ""cover"" or ""fill"" or ""none"" or ""scale-down"" ? ""object-fit"" : ""object-position"";
+ case ""flex"":
+ return value.Length == 0 ? null : ""flex"";
+ case ""inset"":
+ return value.StartsWith(""ring"") || value.StartsWith(""shadow"") ? null : ""inset"";
+ default:
+ return prefix;
+ }
+ }
+
+ private static bool IsNumber(string v) => v.Length > 0 && v.All(c => char.IsDigit(c) || c == '.');
+ private static bool IsArbitrary(string v) => v.Length > 2 && v[0] == '[' && v[^1] == ']';
+ private static bool IsArbitraryLength(string v) =>
+ IsArbitrary(v) && (v.StartsWith(""[length:"") || char.IsDigit(v[1]) || v[1] == '.');
+
+ private static readonly System.Collections.Generic.HashSet TextSizes =
+ new() { ""xs"", ""sm"", ""base"", ""lg"", ""xl"", ""2xl"", ""3xl"", ""4xl"", ""5xl"", ""6xl"", ""7xl"", ""8xl"", ""9xl"" };
+ private static readonly System.Collections.Generic.HashSet FontWeights =
+ new() { ""thin"", ""extralight"", ""light"", ""normal"", ""medium"", ""semibold"", ""bold"", ""extrabold"", ""black"" };
+ private static readonly System.Collections.Generic.HashSet ShadowSizes =
+ new() { ""2xs"", ""xs"", ""sm"", ""md"", ""lg"", ""xl"", ""2xl"", ""inner"", ""none"" };
+
+ private static readonly System.Collections.Generic.Dictionary Keywords = new()
+ {
+ [""block""] = ""display"", [""inline-block""] = ""display"", [""inline""] = ""display"", [""flex""] = ""display"",
+ [""inline-flex""] = ""display"", [""grid""] = ""display"", [""inline-grid""] = ""display"", [""hidden""] = ""display"",
+ [""contents""] = ""display"", [""table""] = ""display"", [""table-row""] = ""display"", [""table-cell""] = ""display"",
+ [""flow-root""] = ""display"", [""list-item""] = ""display"",
+ [""static""] = ""position"", [""fixed""] = ""position"", [""absolute""] = ""position"", [""relative""] = ""position"", [""sticky""] = ""position"",
+ [""visible""] = ""visibility"", [""invisible""] = ""visibility"", [""collapse""] = ""visibility"",
+ [""uppercase""] = ""text-transform"", [""lowercase""] = ""text-transform"", [""capitalize""] = ""text-transform"", [""normal-case""] = ""text-transform"",
+ [""italic""] = ""font-style"", [""not-italic""] = ""font-style"",
+ [""underline""] = ""text-decoration"", [""overline""] = ""text-decoration"", [""line-through""] = ""text-decoration"", [""no-underline""] = ""text-decoration"",
+ [""flex-row""] = ""flex-direction"", [""flex-row-reverse""] = ""flex-direction"", [""flex-col""] = ""flex-direction"", [""flex-col-reverse""] = ""flex-direction"",
+ [""flex-wrap""] = ""flex-wrap"", [""flex-wrap-reverse""] = ""flex-wrap"", [""flex-nowrap""] = ""flex-wrap"",
+ [""border-collapse""] = ""border-collapse"", [""border-separate""] = ""border-collapse"",
+ [""sr-only""] = ""sr-only"", [""not-sr-only""] = ""sr-only"",
+ };
+
+ // Longest first, so ""border-t"" wins over ""border"" and ""ring-offset"" over ""ring"".
+ private static readonly string[] Prefixes = new[]
+ {
+ ""inset-x"", ""inset-y"", ""inset"", ""top"", ""right"", ""bottom"", ""left"", ""start"", ""end"", ""z"",
+ ""order"", ""grow"", ""shrink"", ""basis"", ""flex"",
+ ""grid-cols"", ""grid-rows"", ""col-span"", ""col-start"", ""col-end"", ""row-span"", ""row-start"", ""row-end"",
+ ""gap-x"", ""gap-y"", ""gap"",
+ ""justify-items"", ""justify-self"", ""justify"", ""items"", ""self"", ""place-items"", ""place-content"", ""place-self"",
+ ""p"", ""px"", ""py"", ""pt"", ""pr"", ""pb"", ""pl"", ""ps"", ""pe"",
+ ""m"", ""mx"", ""my"", ""mt"", ""mr"", ""mb"", ""ml"", ""ms"", ""me"", ""space-x"", ""space-y"",
+ ""w"", ""min-w"", ""max-w"", ""h"", ""min-h"", ""max-h"", ""size"",
+ ""overflow-x"", ""overflow-y"", ""overflow"",
+ ""leading"", ""tracking"", ""line-clamp"", ""whitespace"", ""align"",
+ ""opacity"", ""cursor"", ""pointer-events"", ""select"", ""resize"",
+ ""rounded"", ""rounded-s"", ""rounded-e"", ""rounded-t"", ""rounded-r"", ""rounded-b"", ""rounded-l"",
+ ""rounded-ss"", ""rounded-se"", ""rounded-ee"", ""rounded-es"", ""rounded-tl"", ""rounded-tr"", ""rounded-br"", ""rounded-bl"",
+ ""border"", ""border-x"", ""border-y"", ""border-t"", ""border-r"", ""border-b"", ""border-l"", ""border-s"", ""border-e"",
+ ""ring-offset"", ""ring"", ""outline-offset"", ""outline"", ""shadow"",
+ ""text"", ""font"", ""bg"", ""fill"", ""stroke"", ""from"", ""via"", ""to"", ""accent"", ""caret"",
+ ""transition"", ""duration"", ""ease"", ""delay"", ""animate"",
+ ""translate-x"", ""translate-y"", ""translate"", ""scale-x"", ""scale-y"", ""scale"", ""rotate"", ""skew-x"", ""skew-y"", ""origin"",
+ ""object"", ""aspect"", ""blur"", ""backdrop-blur"",
+ }.OrderByDescending(p => p.Length).ToArray();
+
+ // A wider utility replaces earlier narrower ones (p-4 after px-2), but not the other way round.
+ private static readonly System.Collections.Generic.Dictionary Covers = new()
+ {
+ [""inset""] = new[] { ""inset-x"", ""inset-y"", ""top"", ""right"", ""bottom"", ""left"", ""start"", ""end"" },
+ [""inset-x""] = new[] { ""left"", ""right"", ""start"", ""end"" },
+ [""inset-y""] = new[] { ""top"", ""bottom"" },
+ [""flex""] = new[] { ""grow"", ""shrink"", ""basis"" },
+ [""gap""] = new[] { ""gap-x"", ""gap-y"" },
+ [""p""] = new[] { ""px"", ""py"", ""pt"", ""pr"", ""pb"", ""pl"", ""ps"", ""pe"" },
+ [""px""] = new[] { ""pr"", ""pl"", ""ps"", ""pe"" },
+ [""py""] = new[] { ""pt"", ""pb"" },
+ [""m""] = new[] { ""mx"", ""my"", ""mt"", ""mr"", ""mb"", ""ml"", ""ms"", ""me"" },
+ [""mx""] = new[] { ""mr"", ""ml"", ""ms"", ""me"" },
+ [""my""] = new[] { ""mt"", ""mb"" },
+ [""size""] = new[] { ""w"", ""h"" },
+ [""overflow""] = new[] { ""overflow-x"", ""overflow-y"" },
+ [""rounded""] = new[] { ""rounded-s"", ""rounded-e"", ""rounded-t"", ""rounded-r"", ""rounded-b"", ""rounded-l"", ""rounded-ss"", ""rounded-se"", ""rounded-ee"", ""rounded-es"", ""rounded-tl"", ""rounded-tr"", ""rounded-br"", ""rounded-bl"" },
+ [""rounded-t""] = new[] { ""rounded-tl"", ""rounded-tr"" },
+ [""rounded-r""] = new[] { ""rounded-tr"", ""rounded-br"" },
+ [""rounded-b""] = new[] { ""rounded-br"", ""rounded-bl"" },
+ [""rounded-l""] = new[] { ""rounded-tl"", ""rounded-bl"" },
+ [""rounded-s""] = new[] { ""rounded-ss"", ""rounded-es"" },
+ [""rounded-e""] = new[] { ""rounded-se"", ""rounded-ee"" },
+ [""border-w""] = new[] { ""border-w-x"", ""border-w-y"", ""border-w-t"", ""border-w-r"", ""border-w-b"", ""border-w-l"", ""border-w-s"", ""border-w-e"" },
+ [""border-w-x""] = new[] { ""border-w-r"", ""border-w-l"", ""border-w-s"", ""border-w-e"" },
+ [""border-w-y""] = new[] { ""border-w-t"", ""border-w-b"" },
+ [""border-color""] = new[] { ""border-color-x"", ""border-color-y"", ""border-color-t"", ""border-color-r"", ""border-color-b"", ""border-color-l"", ""border-color-s"", ""border-color-e"" },
+ [""border-color-x""] = new[] { ""border-color-r"", ""border-color-l"", ""border-color-s"", ""border-color-e"" },
+ [""border-color-y""] = new[] { ""border-color-t"", ""border-color-b"" },
+ [""translate""] = new[] { ""translate-x"", ""translate-y"" },
+ [""scale""] = new[] { ""scale-x"", ""scale-y"" },
+ };
}
";
}
diff --git a/tools/ShellUI.SafelistGenerator/Program.cs b/tools/ShellUI.SafelistGenerator/Program.cs
index c4a7242..1ad88ea 100644
--- a/tools/ShellUI.SafelistGenerator/Program.cs
+++ b/tools/ShellUI.SafelistGenerator/Program.cs
@@ -49,6 +49,8 @@ public static (string[] razorFiles, string[] csFiles) EnumerateSources(string ra
var csFiles = Directory.GetFiles(componentsRoot, "*.cs", SearchOption.AllDirectories)
.Where(p => !p.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")
&& !p.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}"))
+ // Shell.cs holds Cn's lookup tables, not classes any component renders.
+ .Where(p => Path.GetFileName(p) != "Shell.cs")
.ToArray();
return (razorFiles, csFiles);
}