diff --git a/src/ExCSS.Tests/PropertyTests/CustomPropertyTests.cs b/src/ExCSS.Tests/PropertyTests/CustomPropertyTests.cs new file mode 100644 index 0000000..246f87a --- /dev/null +++ b/src/ExCSS.Tests/PropertyTests/CustomPropertyTests.cs @@ -0,0 +1,98 @@ +using System.Linq; +using Xunit; + +namespace ExCSS.Tests.PropertyTests +{ + public class CustomPropertyTests : CssConstructionFunctions + { + private static StyleDeclaration Style(string declarations) + { + var sheet = ParseStyleSheet(".a { " + declarations + " }"); + return ((StyleRule)sheet.Rules[0]).Style; + } + + [Theory] + // A custom property (CSS Variables 1 2) is a first-class declaration - no unknown-declaration flag + // needed - and its value is preserved verbatim. + [InlineData("--my-color: red", "--my-color", "red")] + [InlineData("--gap: 10px", "--gap", "10px")] + [InlineData("--ratio: 16 / 9", "--ratio", "16/9")] + [InlineData("--x: anything you like !", "--x", "anything you like !")] + public void CustomPropertyIsStored(string declaration, string name, string value) + { + var style = Style(declaration); + + Assert.Equal(1, style.Length); + Assert.Equal(name, style[0]); + Assert.Equal(value, style[name]); + } + + [Fact] + public void CustomPropertyProducesCustomPropertyType() + { + var property = ParseDeclaration("--main: blue"); + + Assert.IsType(property); + Assert.Equal("--main", property.Name); + Assert.True(property.HasValue); + } + + [Theory] + // "--" alone (fewer than one code point after the dashes) is reserved, not a custom property. + [InlineData("-")] + [InlineData("--")] + public void NotACustomPropertyName(string name) + { + Assert.False(PropertyFactory.IsCustomPropertyName(name)); + } + + [Fact] + public void CustomPropertyNameCheck() + { + Assert.True(PropertyFactory.IsCustomPropertyName("--x")); + Assert.True(PropertyFactory.IsCustomPropertyName("--main-color")); + } + + [Theory] + // A var() reference is accepted on any property and kept verbatim; the property's own grammar is + // not applied, since the referenced value is only known at cascade time (CSS Variables 1 3). + [InlineData("color: var(--c)", "color", "var(--c)")] + [InlineData("color: var(--c, blue)", "color", "var(--c, blue)")] + [InlineData("width: calc(var(--w) + 1px)", "width", "calc(var(--w) + 1px)")] + public void VarReferenceIsAcceptedOnLonghand(string declaration, string name, string value) + { + var style = Style(declaration); + + Assert.Equal(value, style[name]); + } + + [Theory] + // A shorthand whose value contains var() is kept whole - not sliced into longhands - because the + // reference can only be split after substitution (CSS Variables 1 3.2). + [InlineData("margin: var(--gap) 5px", "margin", "var(--gap) 5px")] + [InlineData("margin: var(--all)", "margin", "var(--all)")] + [InlineData("border: 1px solid var(--c)", "border", "1px solid var(--c)")] + public void VarBearingShorthandIsKeptWhole(string declaration, string name, string value) + { + var style = Style(declaration); + + Assert.Equal(1, style.Length); + Assert.Equal(name, style[0]); + Assert.Equal(value, style[name]); + // The longhands are not populated at parse time. + Assert.Equal(string.Empty, style["margin-left"]); + } + + [Fact] + public void CustomPropertyDashDashLexesAsOneIdent() + { + // Regression for the lexer: "--foo" must be a single ident, not '-' delimiter + "-foo". + var lexer = new Lexer(new TextSource("--foo")) { IsInValue = false }; + var token = lexer.Get(); + + Assert.Equal(TokenType.Ident, token.Type); + Assert.Equal("--foo", token.Data); + Assert.Equal(TokenType.EndOfFile, lexer.Get().Type); + } + } +} diff --git a/src/ExCSS/Enumerations/FunctionNames.cs b/src/ExCSS/Enumerations/FunctionNames.cs index afd8eb8..fe7c012 100644 --- a/src/ExCSS/Enumerations/FunctionNames.cs +++ b/src/ExCSS/Enumerations/FunctionNames.cs @@ -29,6 +29,7 @@ public static class FunctionNames public static readonly string Min = "min"; public static readonly string Max = "max"; public static readonly string Clamp = "clamp"; + public static readonly string Var = "var"; public static readonly string Toggle = "toggle"; public static readonly string Translate = "translate"; public static readonly string TranslateX = "translateX"; diff --git a/src/ExCSS/Extensions/ValueExtensions.cs b/src/ExCSS/Extensions/ValueExtensions.cs index 4afdb11..dc23a42 100644 --- a/src/ExCSS/Extensions/ValueExtensions.cs +++ b/src/ExCSS/Extensions/ValueExtensions.cs @@ -483,6 +483,22 @@ public static string ToText(this IEnumerable value) return string.Join(string.Empty, value.Select(m => m.ToValue())); } + // Whether the value contains a call to the named function anywhere, including nested inside another + // function's arguments - e.g. var() inside calc(var(--x) + 1px). + public static bool ContainsFunction(this IEnumerable tokens, string functionName) + { + foreach (var token in tokens) + { + if (token is FunctionToken function && + (function.Data.Isi(functionName) || function.ArgumentTokens.ContainsFunction(functionName))) + { + return true; + } + } + + return false; + } + public static Color? ToColor(this IEnumerable value) { var element = value.OnlyOrDefault(); diff --git a/src/ExCSS/Factories/PropertyFactory.cs b/src/ExCSS/Factories/PropertyFactory.cs index 20c0a6a..67974c2 100644 --- a/src/ExCSS/Factories/PropertyFactory.cs +++ b/src/ExCSS/Factories/PropertyFactory.cs @@ -365,7 +365,19 @@ private void AddLonghand(string name, LonghandCreator creator, bool animatable = public Property Create(string name) { - return CreateLonghand(name) ?? CreateShorthand(name); + return CreateLonghand(name) ?? CreateShorthand(name) ?? CreateCustomProperty(name); + } + + private static Property CreateCustomProperty(string name) + { + return IsCustomPropertyName(name) ? new CustomProperty(name) : null; + } + + // A custom property name is at least two dashes followed by at least one code point (CSS Custom + // Properties 1 2). "--" alone is reserved and not a valid custom property. + internal static bool IsCustomPropertyName(string name) + { + return name != null && name.Length > 2 && name[0] == '-' && name[1] == '-'; } public Property CreateFont(string name) diff --git a/src/ExCSS/Model/StyleDeclaration.cs b/src/ExCSS/Model/StyleDeclaration.cs index ac68868..c042365 100644 --- a/src/ExCSS/Model/StyleDeclaration.cs +++ b/src/ExCSS/Model/StyleDeclaration.cs @@ -274,6 +274,16 @@ private void SetLonghand(Property property) private void SetShorthand(ShorthandProperty shorthand) { + if (shorthand.DeclaredValue.Original.ContainsFunction(FunctionNames.Var)) + { + // A var() reference can't be split into per-longhand slices here: the referenced custom + // property's value is only known per-element, at cascade time. Keep the shorthand + // declaration whole so substitution and expansion can happen once it is resolved (CSS + // Variables 1 3.2). + SetLonghand(shorthand); + return; + } + var properties = PropertyFactory.Instance.CreateLonghandsFor(shorthand.Name); shorthand.Export(properties); diff --git a/src/ExCSS/Parser/StylesheetComposer.cs b/src/ExCSS/Parser/StylesheetComposer.cs index 5ab8cce..3184d3c 100644 --- a/src/ExCSS/Parser/StylesheetComposer.cs +++ b/src/ExCSS/Parser/StylesheetComposer.cs @@ -680,8 +680,19 @@ public TextPosition FillDeclarations(StyleDeclaration style) // Example 2: "margin: 5px !important; text-align:center; margin-left: 3px;"; if (sourceProperty is ShorthandProperty shorthandProperty) { - resolvedProperties = PropertyFactory.Instance.CreateLonghandsFor(shorthandProperty.Name); - shorthandProperty.Export(resolvedProperties); + if (shorthandProperty.DeclaredValue.Original.ContainsFunction(FunctionNames.Var)) + { + // A var() reference can't be split into per-longhand slices at parse time - + // the referenced custom property's value is only known per-element, at cascade + // time. Keep the shorthand declaration whole so substitution and expansion can + // happen once it is resolved (CSS Variables 1 3.2). + resolvedProperties = new Property[] { shorthandProperty }; + } + else + { + resolvedProperties = PropertyFactory.Instance.CreateLonghandsFor(shorthandProperty.Name); + shorthandProperty.Export(resolvedProperties); + } } foreach (var resolvedProperty in resolvedProperties) diff --git a/src/ExCSS/StyleProperties/CustomProperty.cs b/src/ExCSS/StyleProperties/CustomProperty.cs new file mode 100644 index 0000000..e5d9a5c --- /dev/null +++ b/src/ExCSS/StyleProperties/CustomProperty.cs @@ -0,0 +1,16 @@ +namespace ExCSS +{ + /// + /// A custom property - a declaration whose name begins with two dashes, e.g. --main-color + /// (CSS Custom Properties 1 §2). Its value has no fixed grammar, so it is stored as-is. + /// + internal sealed class CustomProperty : Property + { + internal CustomProperty(string name) + : base(name) + { + } + + internal override IValueConverter Converter => Converters.Any; + } +} diff --git a/src/ExCSS/StyleProperties/Property.cs b/src/ExCSS/StyleProperties/Property.cs index f216d44..100e4a1 100644 --- a/src/ExCSS/StyleProperties/Property.cs +++ b/src/ExCSS/StyleProperties/Property.cs @@ -22,7 +22,13 @@ public override void ToCss(TextWriter writer, IStyleFormatter formatter) internal bool TrySetValue(TokenValue newTokenValue) { - var value = Converter.Convert(newTokenValue ?? TokenValue.Initial); + var tokenValue = newTokenValue ?? TokenValue.Initial; + + // A value containing var() can't be validated against this property's grammar here - the + // referenced custom property is only known per-element, at cascade time - so keep it verbatim + // via Converters.Any and let substitution resolve it later (CSS Variables 1 3). + var converter = tokenValue.ContainsFunction(FunctionNames.Var) ? Converters.Any : Converter; + var value = converter.Convert(tokenValue); if (value == null) return false; DeclaredValue = value;