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); }