From d0b388b49c2300ca5de72b974e8007cd5d126cc6 Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Wed, 22 Jul 2026 19:15:54 -0400 Subject: [PATCH] Add the @property at-rule @property (CSS Properties and Values API 1 3) registers a typed custom property with syntax, initial-value and inherits descriptors. Add PropertyRule (a DeclarationRule like FontFaceRule, with a Name captured from the prelude), IPropertyRule, RuleNames.Property, RuleType.Property, the three descriptor names, PropertyFactory.CreatePropertyDescriptor (storing each raw via UnknownProperty, since their values have no fixed grammar), and StylesheetComposer.CreateProperty with its dispatch. Includes the "--foo" lexer fix from the custom-properties change (also submitted standalone), needed here to lex the "--name" prelude; if that merges first, this rebases cleanly. --- src/ExCSS.Tests/AtPropertyTests.cs | 66 +++++++++++++++++++++++++ src/ExCSS/Enumerations/PropertyNames.cs | 4 ++ src/ExCSS/Enumerations/RuleNames.cs | 1 + src/ExCSS/Enumerations/RuleType.cs | 3 +- src/ExCSS/Factories/PropertyFactory.cs | 14 ++++++ src/ExCSS/Parser/Lexer.cs | 21 ++++++-- src/ExCSS/Parser/StylesheetComposer.cs | 24 +++++++++ src/ExCSS/Rules/IPropertyRule.cs | 21 ++++++++ src/ExCSS/Rules/PropertyRule.cs | 50 +++++++++++++++++++ 9 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 src/ExCSS.Tests/AtPropertyTests.cs create mode 100644 src/ExCSS/Rules/IPropertyRule.cs create mode 100644 src/ExCSS/Rules/PropertyRule.cs diff --git a/src/ExCSS.Tests/AtPropertyTests.cs b/src/ExCSS.Tests/AtPropertyTests.cs new file mode 100644 index 00000000..2f7b6178 --- /dev/null +++ b/src/ExCSS.Tests/AtPropertyTests.cs @@ -0,0 +1,66 @@ +using System.Linq; +using Xunit; + +namespace ExCSS.Tests +{ + public class AtPropertyTests : CssConstructionFunctions + { + private static PropertyRule Parse(string source) + { + var sheet = ParseStyleSheet(source); + return (PropertyRule)sheet.Rules.First(); + } + + [Fact] + public void AtPropertyCapturesNameAndDescriptors() + { + var rule = Parse("@property --my-color { syntax: ''; initial-value: red; inherits: false; }"); + + Assert.Equal(RuleType.Property, rule.Type); + Assert.Equal("--my-color", rule.Name); + Assert.Equal("\"\"", rule.Syntax); + Assert.Equal("red", rule.InitialValue); + Assert.Equal("false", rule.Inherits); + } + + [Fact] + public void AtPropertyRoundTrips() + { + var rule = Parse("@property --gap { syntax: ''; initial-value: 0px; inherits: true; }"); + + Assert.Equal("@property --gap { syntax: \"\"; initial-value: 0px; inherits: true }", + rule.ToCss()); + } + + [Fact] + public void AtPropertyWithUniversalSyntax() + { + var rule = Parse("@property --x { syntax: '*'; inherits: false; }"); + + Assert.Equal("\"*\"", rule.Syntax); + Assert.Equal("false", rule.Inherits); + Assert.Equal(string.Empty, rule.InitialValue); + } + + [Fact] + public void AtPropertyIsExposedAsIPropertyRule() + { + var sheet = ParseStyleSheet("@property --c { syntax: ''; inherits: false; }"); + var rule = Assert.IsAssignableFrom(sheet.Rules.First()); + + Assert.Equal("--c", rule.Name); + } + + [Fact] + public void AtPropertyAmongOtherRules() + { + var sheet = ParseStyleSheet( + ".a { color: red } @property --c { syntax: ''; inherits: false; } .b { color: blue }"); + + Assert.Equal(3, sheet.Rules.Length); + Assert.IsType(sheet.Rules[0]); + Assert.IsType(sheet.Rules[1]); + Assert.IsType(sheet.Rules[2]); + } + } +} diff --git a/src/ExCSS/Enumerations/PropertyNames.cs b/src/ExCSS/Enumerations/PropertyNames.cs index 854654d9..07450acd 100644 --- a/src/ExCSS/Enumerations/PropertyNames.cs +++ b/src/ExCSS/Enumerations/PropertyNames.cs @@ -237,6 +237,10 @@ public static class PropertyNames public static readonly string Zoom = "zoom"; public static readonly string UnicodeRange = "unicode-range"; public static readonly string Src = "src"; + // @property descriptors (CSS Properties and Values API 1 3) + public static readonly string Syntax = "syntax"; + public static readonly string InitialValue = "initial-value"; + public static readonly string Inherits = "inherits"; public static readonly string ObjectFit = "object-fit"; public static readonly string ObjectPosition = "object-position"; } diff --git a/src/ExCSS/Enumerations/RuleNames.cs b/src/ExCSS/Enumerations/RuleNames.cs index c4d6bce2..06934227 100644 --- a/src/ExCSS/Enumerations/RuleNames.cs +++ b/src/ExCSS/Enumerations/RuleNames.cs @@ -13,5 +13,6 @@ public static class RuleNames public static readonly string Namespace = "namespace"; public static readonly string Page = "page"; public static readonly string Container = "container"; + public static readonly string Property = "property"; } } \ No newline at end of file diff --git a/src/ExCSS/Enumerations/RuleType.cs b/src/ExCSS/Enumerations/RuleType.cs index c24b9cbe..358e8df6 100644 --- a/src/ExCSS/Enumerations/RuleType.cs +++ b/src/ExCSS/Enumerations/RuleType.cs @@ -19,6 +19,7 @@ public enum RuleType : byte FontFeatureValues, Viewport, RegionStyle, - Container + Container, + Property } } \ No newline at end of file diff --git a/src/ExCSS/Factories/PropertyFactory.cs b/src/ExCSS/Factories/PropertyFactory.cs index 22316405..9a30c099 100644 --- a/src/ExCSS/Factories/PropertyFactory.cs +++ b/src/ExCSS/Factories/PropertyFactory.cs @@ -367,6 +367,20 @@ public Property CreateFont(string name) return _fonts.TryGetValue(name, out var propertyCreator) ? propertyCreator() : null; } + // The @property descriptors (syntax / initial-value / inherits). Their values have no fixed grammar + // - initial-value depends on the syntax, syntax is an arbitrary string - so each is stored raw via + // an UnknownProperty (Converters.Any). + public Property CreatePropertyDescriptor(string name) + { + return PropertyFactory.IsPropertyDescriptor(name) ? new UnknownProperty(name) : null; + } + + private static bool IsPropertyDescriptor(string name) + { + return name.Is(PropertyNames.Syntax) || name.Is(PropertyNames.InitialValue) || + name.Is(PropertyNames.Inherits); + } + public Property CreateViewport(string name) { var feature = MediaFeatureFactory.Instance.Create(name); diff --git a/src/ExCSS/Parser/Lexer.cs b/src/ExCSS/Parser/Lexer.cs index 177fc564..84369431 100644 --- a/src/ExCSS/Parser/Lexer.cs +++ b/src/ExCSS/Parser/Lexer.cs @@ -94,10 +94,21 @@ private Token Data(char current) if (c1.IsNameStart()) return IdentStart(current); if (c1 == Symbols.ReverseSolidus && !c2.IsLineBreak() && c2 != Symbols.EndOfFile) return IdentStart(current); - if (c1 != Symbols.Minus || c2 != Symbols.GreaterThan) return NewDelimiter(current); - Advance(2); - return NewCloseComment(); + if (c1 == Symbols.Minus) + { + // "-->" closes an HTML-style comment, but any other "--" starts an ident, so a + // custom property name "--foo" (CSS Variables 1 2) is one ident. + if (c2 == Symbols.GreaterThan) + { + Advance(2); + return NewCloseComment(); + } + + return IdentStart(current); + } + + return NewDelimiter(current); } Back(); @@ -451,7 +462,9 @@ private Token IdentStart(char current) if (current == Symbols.Minus) { current = GetNext(); - if (current.IsNameStart() || IsValidEscape(current)) + // A second '-' also starts an ident, so a custom property name "--foo" (CSS Variables 1 2) + // lexes as one ident rather than a '-' delimiter followed by "-foo". + if (current.IsNameStart() || current == Symbols.Minus || IsValidEscape(current)) { StringBuffer.Append(Symbols.Minus); return IdentRest(current); diff --git a/src/ExCSS/Parser/StylesheetComposer.cs b/src/ExCSS/Parser/StylesheetComposer.cs index 25c7eb38..93afe506 100644 --- a/src/ExCSS/Parser/StylesheetComposer.cs +++ b/src/ExCSS/Parser/StylesheetComposer.cs @@ -39,6 +39,8 @@ public Rule CreateAtRule(Token token) if (token.Data.Is(RuleNames.Container)) return CreateContainer(token); + if (token.Data.Is(RuleNames.Property)) return CreateProperty(token); + return token.Data.Is(RuleNames.Document) ? CreateDocument(token) : CreateUnknown(token); } @@ -147,6 +149,28 @@ public Rule CreateFontFace(Token current) return SkipDeclarations(token); } + public Rule CreateProperty(Token current) + { + var rule = new PropertyRule(_parser); + var start = current.Position; + var token = NextToken(); + _nodes.Push(rule); + ParseComments(ref token); + rule.Name = GetRuleName(ref token); + ParseComments(ref token); + + if (token.Type == TokenType.CurlyBracketOpen) + { + var end = FillDeclarations(rule, PropertyFactory.Instance.CreatePropertyDescriptor); + rule.StylesheetText = CreateView(start, end); + _nodes.Pop(); + return rule; + } + + _nodes.Pop(); + return SkipDeclarations(token); + } + public Rule CreateImport(Token current) { var rule = new ImportRule(_parser); diff --git a/src/ExCSS/Rules/IPropertyRule.cs b/src/ExCSS/Rules/IPropertyRule.cs new file mode 100644 index 00000000..328c2d32 --- /dev/null +++ b/src/ExCSS/Rules/IPropertyRule.cs @@ -0,0 +1,21 @@ +namespace ExCSS +{ + /// + /// A registered custom property declared with an @property at-rule (CSS Properties and Values + /// API 1 §3). Exposes the three descriptors. + /// + public interface IPropertyRule : IRule, IProperties + { + /// The registered custom property name, e.g. --my-color. + string Name { get; set; } + + /// The raw syntax descriptor value (e.g. "<color>" or "*"). + string Syntax { get; set; } + + /// The raw initial-value descriptor value. + string InitialValue { get; set; } + + /// The raw inherits descriptor value (true or false). + string Inherits { get; set; } + } +} diff --git a/src/ExCSS/Rules/PropertyRule.cs b/src/ExCSS/Rules/PropertyRule.cs new file mode 100644 index 00000000..72f95f98 --- /dev/null +++ b/src/ExCSS/Rules/PropertyRule.cs @@ -0,0 +1,50 @@ +using System.IO; +using System.Linq; + +namespace ExCSS +{ + /// + /// An @property at-rule (CSS Properties and Values API 1 §3): registers a typed custom property + /// with syntax, initial-value and inherits descriptors. The block is stored like a + /// ; the registered name (a dashed-ident such as --my-color) is + /// captured from the prelude like . + /// + internal sealed class PropertyRule : DeclarationRule, IPropertyRule + { + internal PropertyRule(StylesheetParser parser) + : base(RuleType.Property, RuleNames.Property, parser) + { + } + + protected override Property CreateNewProperty(string name) + { + return PropertyFactory.Instance.CreatePropertyDescriptor(name); + } + + public string Name { get; set; } + + public string Syntax + { + get => GetValue(PropertyNames.Syntax); + set => SetValue(PropertyNames.Syntax, value); + } + + public string InitialValue + { + get => GetValue(PropertyNames.InitialValue); + set => SetValue(PropertyNames.InitialValue, value); + } + + public string Inherits + { + get => GetValue(PropertyNames.Inherits); + set => SetValue(PropertyNames.Inherits, value); + } + + public override void ToCss(TextWriter writer, IStyleFormatter formatter) + { + var declarations = formatter.Declarations(Declarations.Where(d => d.HasValue).Select(d => d.ToCss(formatter))); + writer.Write(string.Concat("@property ", Name, " { ", declarations, " }")); + } + } +}