From bb029ec4e6f443b06bc9650014d1f82c3e42fa8b Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Fri, 24 Jul 2026 15:49:55 -0400 Subject: [PATCH] Add @layer cascade layers (block and statement forms) Parse the @layer at-rule (CSS Cascade 5): the block form `@layer name { rules }` (incl. anonymous `@layer { rules }`) as a grouping rule (LayerRule/ILayerRule), and the order-declaring statement form `@layer a, b, c;` (LayerStatementRule). Adds the Layer/LayerStatement rule types and a StylesheetComposer.CreateLayer dispatch with a comma- separated, dotted-ident name reader. Cascade precedence between layers is left to the consumer; this adds the object-model representation only. Ported from PeachPDF, which vendors ExCSS. --- src/ExCSS.Tests/CascadeLayerTests.cs | 95 ++++++++++++++++++++++++++ src/ExCSS/Enumerations/RuleNames.cs | 1 + src/ExCSS/Enumerations/RuleType.cs | 4 +- src/ExCSS/Parser/StylesheetComposer.cs | 74 ++++++++++++++++++++ src/ExCSS/Rules/ILayerRule.cs | 13 ++++ src/ExCSS/Rules/LayerRule.cs | 26 +++++++ src/ExCSS/Rules/LayerStatementRule.cs | 26 +++++++ 7 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/ExCSS.Tests/CascadeLayerTests.cs create mode 100644 src/ExCSS/Rules/ILayerRule.cs create mode 100644 src/ExCSS/Rules/LayerRule.cs create mode 100644 src/ExCSS/Rules/LayerStatementRule.cs diff --git a/src/ExCSS.Tests/CascadeLayerTests.cs b/src/ExCSS.Tests/CascadeLayerTests.cs new file mode 100644 index 0000000..9bb9d63 --- /dev/null +++ b/src/ExCSS.Tests/CascadeLayerTests.cs @@ -0,0 +1,95 @@ +using System.Linq; +using Xunit; + +namespace ExCSS.Tests +{ + /// + /// @layer cascade layers (CSS + /// Cascade 5): the block form (@layer name { … }, incl. anonymous) and the + /// order-declaring statement form (@layer a, b, c;). These assert the parsed object model; + /// cascade precedence between layers is left to the consumer. + /// + public class CascadeLayerTests : CssConstructionFunctions + { + [Fact] + public void NamedBlockLayerCapturesNameAndRules() + { + var sheet = ParseStyleSheet("@layer utilities { .a { color: red } }"); + var rule = Assert.IsAssignableFrom(sheet.Rules.First()); + + Assert.Equal(RuleType.Layer, rule.Type); + Assert.Equal("utilities", rule.Name); + var inner = Assert.Single(rule.Rules); + Assert.Equal(".a", ((StyleRule)inner).SelectorText); + } + + [Fact] + public void AnonymousBlockLayerHasEmptyName() + { + var sheet = ParseStyleSheet("@layer { .a { color: red } }"); + var rule = Assert.IsAssignableFrom(sheet.Rules.First()); + + Assert.Equal(string.Empty, rule.Name); + Assert.Single(rule.Rules); + } + + [Fact] + public void DottedLayerNameIsPreserved() + { + var sheet = ParseStyleSheet("@layer framework.utilities { .a { color: red } }"); + var rule = Assert.IsAssignableFrom(sheet.Rules.First()); + + Assert.Equal("framework.utilities", rule.Name); + } + + [Fact] + public void StatementLayerCapturesOrderedNames() + { + var sheet = ParseStyleSheet("@layer reset, base, utilities;"); + var rule = (LayerStatementRule)sheet.Rules.First(); + + Assert.Equal(RuleType.LayerStatement, rule.Type); + Assert.Equal(new[] { "reset", "base", "utilities" }, rule.Names.ToArray()); + } + + [Fact] + public void StatementLayerWithDottedNames() + { + var sheet = ParseStyleSheet("@layer a.b, c;"); + var rule = (LayerStatementRule)sheet.Rules.First(); + + Assert.Equal(new[] { "a.b", "c" }, rule.Names.ToArray()); + } + + [Fact] + public void BlockLayerRoundTrips() + { + var sheet = ParseStyleSheet("@layer utilities { .a { color: red } }"); + Assert.Equal("@layer utilities { .a { color: rgb(255, 0, 0) } }", sheet.Rules.First().ToCss()); + } + + [Fact] + public void AnonymousBlockLayerRoundTrips() + { + var sheet = ParseStyleSheet("@layer { .a { color: red } }"); + Assert.Equal("@layer { .a { color: rgb(255, 0, 0) } }", sheet.Rules.First().ToCss()); + } + + [Fact] + public void StatementLayerRoundTrips() + { + var sheet = ParseStyleSheet("@layer reset, base, utilities;"); + Assert.Equal("@layer reset, base, utilities;", sheet.Rules.First().ToCss()); + } + + [Fact] + public void LayerAmongOtherRules() + { + var sheet = ParseStyleSheet( + ".a { color: red } @layer utils { .b { color: blue } } .c { color: green }"); + + Assert.Equal(3, sheet.Rules.Length); + Assert.IsAssignableFrom(sheet.Rules.Skip(1).First()); + } + } +} diff --git a/src/ExCSS/Enumerations/RuleNames.cs b/src/ExCSS/Enumerations/RuleNames.cs index 0693422..2b9ffea 100644 --- a/src/ExCSS/Enumerations/RuleNames.cs +++ b/src/ExCSS/Enumerations/RuleNames.cs @@ -14,5 +14,6 @@ public static class RuleNames public static readonly string Page = "page"; public static readonly string Container = "container"; public static readonly string Property = "property"; + public static readonly string Layer = "layer"; } } \ No newline at end of file diff --git a/src/ExCSS/Enumerations/RuleType.cs b/src/ExCSS/Enumerations/RuleType.cs index 358e8df..95647df 100644 --- a/src/ExCSS/Enumerations/RuleType.cs +++ b/src/ExCSS/Enumerations/RuleType.cs @@ -20,6 +20,8 @@ public enum RuleType : byte Viewport, RegionStyle, Container, - Property + Property, + Layer, + LayerStatement } } \ No newline at end of file diff --git a/src/ExCSS/Parser/StylesheetComposer.cs b/src/ExCSS/Parser/StylesheetComposer.cs index 5ab8cce..30b0151 100644 --- a/src/ExCSS/Parser/StylesheetComposer.cs +++ b/src/ExCSS/Parser/StylesheetComposer.cs @@ -41,6 +41,8 @@ public Rule CreateAtRule(Token token) if (token.Data.Is(RuleNames.Property)) return CreateProperty(token); + if (token.Data.Is(RuleNames.Layer)) return CreateLayer(token); + return token.Data.Is(RuleNames.Document) ? CreateDocument(token) : CreateUnknown(token); } @@ -171,6 +173,78 @@ public Rule CreateProperty(Token current) return SkipDeclarations(token); } + public Rule CreateLayer(Token current) + { + var start = current.Position; + var token = NextToken(); + ParseComments(ref token); + + // Read the (optional) comma-separated layer name list, stopping at '{' (block form) or + // ';'/EOF (statement form). + var names = ReadLayerNames(ref token); + + if (token.Type == TokenType.CurlyBracketOpen) + { + // Block form: `@layer name { rules }` (at most one name; anonymous if none). + var rule = new LayerRule(_parser) + { + Name = names.Count > 0 ? names[0] : string.Empty + }; + _nodes.Push(rule); + var end = FillRules(rule); + rule.StylesheetText = CreateView(start, end); + _nodes.Pop(); + return rule; + } + + // Statement form: `@layer a, b, c;` — declares layer order only, no rules. token is at ';'/EOF. + var statement = new LayerStatementRule(_parser); + statement.Names.AddRange(names); + statement.StylesheetText = CreateView(start, token.Position); + return statement; + } + + /// + /// Reads a @layer prelude: zero or more comma-separated layer names, each a dotted + /// ident sequence (e.g. framework.utilities). Advances up to + /// (but not past) the terminating {, ;, or end of file. + /// + private List ReadLayerNames(ref Token token) + { + var names = new List(); + var current = new System.Text.StringBuilder(); + + void Flush() + { + if (current.Length <= 0) return; + names.Add(current.ToString()); + current.Clear(); + } + + while (token.IsNot(TokenType.EndOfFile, TokenType.CurlyBracketOpen, TokenType.Semicolon)) + { + switch (token.Type) + { + case TokenType.Ident: + current.Append(token.Data); + break; + case TokenType.Delim when token.Data == ".": + current.Append('.'); + break; + case TokenType.Comma: + Flush(); + break; + // Whitespace/comments and anything else separating names are ignored. + } + + token = NextToken(); + ParseComments(ref token); + } + + Flush(); + return names; + } + public Rule CreateImport(Token current) { var rule = new ImportRule(_parser); diff --git a/src/ExCSS/Rules/ILayerRule.cs b/src/ExCSS/Rules/ILayerRule.cs new file mode 100644 index 0000000..af95b93 --- /dev/null +++ b/src/ExCSS/Rules/ILayerRule.cs @@ -0,0 +1,13 @@ +namespace ExCSS +{ + /// + /// The block form of the @layer cascade-layer at-rule (@layer name { … }, or an + /// anonymous @layer { … }). Groups the style rules that belong to a named cascade layer, + /// per CSS Cascade 5. + /// + public interface ILayerRule : IGroupingRule + { + /// The layer's (optionally dotted) name; empty for an anonymous layer. + string Name { get; } + } +} diff --git a/src/ExCSS/Rules/LayerRule.cs b/src/ExCSS/Rules/LayerRule.cs new file mode 100644 index 0000000..f838b3e --- /dev/null +++ b/src/ExCSS/Rules/LayerRule.cs @@ -0,0 +1,26 @@ +using System.IO; + +namespace ExCSS +{ + /// + /// The block form of @layer (@layer name { rules } or anonymous @layer { rules }). + /// A grouping rule holding the style rules assigned to a cascade layer; cascade precedence between + /// layers is resolved by the consumer when the rules are indexed, not here. + /// + internal sealed class LayerRule : GroupingRule, ILayerRule + { + internal LayerRule(StylesheetParser parser) + : base(RuleType.Layer, parser) + { + } + + public string Name { get; set; } = string.Empty; + + public override void ToCss(TextWriter writer, IStyleFormatter formatter) + { + var rules = formatter.Block(Rules); + var name = string.IsNullOrEmpty(Name) ? "@layer" : $"@layer {Name}"; + writer.Write(formatter.Rule(name, null, rules)); + } + } +} diff --git a/src/ExCSS/Rules/LayerStatementRule.cs b/src/ExCSS/Rules/LayerStatementRule.cs new file mode 100644 index 0000000..4422d80 --- /dev/null +++ b/src/ExCSS/Rules/LayerStatementRule.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.IO; + +namespace ExCSS +{ + /// + /// The statement form of @layer (@layer a, b, c;). It declares the relative order of + /// one or more named cascade layers without providing any rules; a later block-form + /// attaches rules to a layer already ordered by this statement, per + /// CSS Cascade 5. + /// + internal sealed class LayerStatementRule : Rule + { + internal LayerStatementRule(StylesheetParser parser) + : base(RuleType.LayerStatement, parser) + { + } + + public List Names { get; } = new List(); + + public override void ToCss(TextWriter writer, IStyleFormatter formatter) + { + writer.Write(formatter.Rule("@layer", string.Join(", ", Names))); + } + } +}