diff --git a/src/ExCSS.Tests/GridLineGrammarTests.cs b/src/ExCSS.Tests/GridLineGrammarTests.cs
new file mode 100644
index 0000000..3df3242
--- /dev/null
+++ b/src/ExCSS.Tests/GridLineGrammarTests.cs
@@ -0,0 +1,99 @@
+using System.Collections.Generic;
+using Xunit;
+
+namespace ExCSS.Tests
+{
+ /// Tests for the shared — the grid <grid-line> value
+ /// (auto | <integer> | span <integer>).
+ public class GridLineGrammarTests : CssConstructionFunctions
+ {
+ private static IReadOnlyList Tokens(string value)
+ {
+ var lexer = new Lexer(new TextSource(value));
+ var tokens = new List();
+ var token = lexer.Get();
+ while (token.Type != TokenType.EndOfFile)
+ {
+ tokens.Add(token);
+ token = lexer.Get();
+ }
+ return tokens;
+ }
+
+ private static GridLine Parse(string value) =>
+ GridLineGrammar.TryParse(Tokens(value));
+
+ [Fact]
+ public void Auto_Parses()
+ {
+ var line = Parse("auto");
+ Assert.NotNull(line);
+ Assert.True(line.IsAuto);
+ }
+
+ [Theory]
+ [InlineData("1", 1)]
+ [InlineData("3", 3)]
+ [InlineData("-1", -1)]
+ public void IntegerLine_Parses(string value, int expected)
+ {
+ var line = Parse(value);
+ Assert.NotNull(line);
+ Assert.False(line.IsAuto);
+ Assert.False(line.IsSpan);
+ Assert.Equal(expected, line.Value);
+ }
+
+ [Theory]
+ [InlineData("span 1", 1)]
+ [InlineData("span 3", 3)]
+ public void Span_Parses(string value, int expected)
+ {
+ var line = Parse(value);
+ Assert.NotNull(line);
+ Assert.True(line.IsSpan);
+ Assert.Equal(expected, line.Value);
+ }
+
+ [Theory]
+ [InlineData("sidebar")]
+ [InlineData("main-start")]
+ public void NamedLine_Parses(string value)
+ {
+ var line = Parse(value);
+ Assert.NotNull(line);
+ Assert.False(line.IsAuto);
+ Assert.False(line.IsSpan);
+ Assert.Equal(value, line.Name);
+ Assert.Equal(1, line.Value);
+ }
+
+ [Theory]
+ [InlineData("col 2", "col", 2)]
+ [InlineData("2 col", "col", 2)]
+ [InlineData("col -1", "col", -1)]
+ public void NamedNthLine_Parses(string value, string name, int nth)
+ {
+ var line = Parse(value);
+ Assert.NotNull(line);
+ Assert.Equal(name, line.Name);
+ Assert.Equal(nth, line.Value);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("0")] // line 0 is invalid
+ [InlineData("span 0")] // span must be >= 1
+ [InlineData("span")] // span needs a count
+ [InlineData("span auto")]
+ [InlineData("1.5")] // not an integer
+ [InlineData("[name]")] // bracketed line names belong in a track list, not a
+ [InlineData("none")] // a CSS-wide/reserved keyword is not a custom-ident line name
+ [InlineData("initial")]
+ [InlineData("span foo")] // span is out of v1 scope
+ public void Invalid_ReturnsNull(string value)
+ {
+ Assert.Null(Parse(value));
+ }
+ }
+}
diff --git a/src/ExCSS.Tests/GridTemplateAreasGrammarTests.cs b/src/ExCSS.Tests/GridTemplateAreasGrammarTests.cs
new file mode 100644
index 0000000..f01e5bd
--- /dev/null
+++ b/src/ExCSS.Tests/GridTemplateAreasGrammarTests.cs
@@ -0,0 +1,74 @@
+using System.Collections.Generic;
+using Xunit;
+
+namespace ExCSS.Tests
+{
+ /// Tests for the shared — the
+ /// grid-template-areas value (a rectangular grid of named cells).
+ public class GridTemplateAreasGrammarTests : CssConstructionFunctions
+ {
+ private static IReadOnlyList Tokens(string value)
+ {
+ var lexer = new Lexer(new TextSource(value));
+ var tokens = new List();
+ var token = lexer.Get();
+ while (token.Type != TokenType.EndOfFile)
+ {
+ tokens.Add(token);
+ token = lexer.Get();
+ }
+ return tokens;
+ }
+
+ private static GridAreas Parse(string value) =>
+ GridTemplateAreasGrammar.TryParse(Tokens(value));
+
+ [Fact]
+ public void RectangularGrid_ParsesWithAreaBounds()
+ {
+ var areas = Parse("\"header header header\" \"nav main main\" \"footer footer footer\"");
+ Assert.NotNull(areas);
+ Assert.Equal(3, areas.RowCount);
+ Assert.Equal(3, areas.ColCount);
+ Assert.Equal((0, 0, 0, 2), areas.Areas["header"]); // whole top row
+ Assert.Equal((1, 0, 1, 0), areas.Areas["nav"]); // row 1, col 0
+ Assert.Equal((1, 1, 1, 2), areas.Areas["main"]); // row 1, cols 1-2
+ Assert.Equal((2, 0, 2, 2), areas.Areas["footer"]);
+ }
+
+ [Fact]
+ public void DotCells_AreEmpty_AndNotAreas()
+ {
+ var areas = Parse("\"a . b\" \". . .\"");
+ Assert.NotNull(areas);
+ Assert.Equal(2, areas.RowCount);
+ Assert.Equal(3, areas.ColCount);
+ Assert.True(areas.Areas.ContainsKey("a"));
+ Assert.True(areas.Areas.ContainsKey("b"));
+ Assert.Null(areas.Cells[0, 1]);
+ Assert.Null(areas.Cells[1, 0]);
+ }
+
+ [Fact]
+ public void TripleDot_IsAlsoAnEmptyCell()
+ {
+ var areas = Parse("\"a ... b\"");
+ Assert.NotNull(areas);
+ Assert.Null(areas.Cells[0, 1]);
+ }
+
+ [Theory]
+ [InlineData("\"a a\" \"a a a\"")] // ragged rows (2 vs 3 columns)
+ [InlineData("\"a b\" \"b a\"")] // 'a' is not a rectangle (diagonal)
+ [InlineData("\"a a\" \"a .\"")] // 'a' bounding box includes an empty cell → not filled
+ [InlineData("\"a b a\"")] // 'a' occupies cols 0 and 2 with b between → not rectangular
+ [InlineData("100px")] // not a string list
+ [InlineData("\"\"")] // an empty string row
+ [InlineData("\"a.b c\"")] // a cell mixing a name and a dot is not a valid cell token
+ [InlineData("")]
+ public void Invalid_ReturnsNull(string value)
+ {
+ Assert.Null(Parse(value));
+ }
+ }
+}
diff --git a/src/ExCSS.Tests/GridTemplateShorthandTests.cs b/src/ExCSS.Tests/GridTemplateShorthandTests.cs
new file mode 100644
index 0000000..47caa32
--- /dev/null
+++ b/src/ExCSS.Tests/GridTemplateShorthandTests.cs
@@ -0,0 +1,153 @@
+using System.Linq;
+using Xunit;
+
+namespace ExCSS.Tests
+{
+ ///
+ /// The grid / grid-template mega-shorthands (CSS Grid §7.4 / §7.8) parse and expand to the
+ /// grid longhands at parse time, and — like the other reconstruction-excluded shorthands — are never
+ /// re-collapsed when a declaration block is serialized.
+ ///
+ public class GridTemplateShorthandTests : CssConstructionFunctions
+ {
+ private static StyleDeclaration Style(string declaration) =>
+ (StyleDeclaration)ParseStyleSheet($"div {{ {declaration} }}").Rules.OfType().Single().Style;
+
+ // grid-template
+
+ [Fact]
+ public void GridTemplate_None_ResetsAllThree()
+ {
+ var style = Style("grid-template: none;");
+ Assert.Equal("none", style.GetPropertyValue("grid-template-rows"));
+ Assert.Equal("none", style.GetPropertyValue("grid-template-columns"));
+ Assert.Equal("none", style.GetPropertyValue("grid-template-areas"));
+ }
+
+ [Fact]
+ public void GridTemplate_RowsSlashColumns_SetsBothAxesAreasNone()
+ {
+ var style = Style("grid-template: 1fr 2fr / 100px 200px;");
+ Assert.Equal("1fr 2fr", style.GetPropertyValue("grid-template-rows"));
+ Assert.Equal("100px 200px", style.GetPropertyValue("grid-template-columns"));
+ // An omitted axis is reset to its initial value (via the CSS-wide `initial` keyword).
+ Assert.Equal("initial", style.GetPropertyValue("grid-template-areas"));
+ }
+
+ [Fact]
+ public void GridTemplate_AreasForm_SynthesizesRowsAndColumns()
+ {
+ var style = Style("grid-template: \"a a\" 40px \"b b\" / 1fr 1fr;");
+ Assert.Equal("\"a a\" \"b b\"", style.GetPropertyValue("grid-template-areas"));
+ Assert.Equal("40px auto", style.GetPropertyValue("grid-template-rows"));
+ Assert.Equal("1fr 1fr", style.GetPropertyValue("grid-template-columns"));
+ }
+
+ [Fact]
+ public void GridTemplate_AreasForm_WithLineNames_PreservesThem()
+ {
+ var style = Style("grid-template: [r1] \"a\" 10px [r2] \"b\" [r3];");
+ Assert.Equal("\"a\" \"b\"", style.GetPropertyValue("grid-template-areas"));
+ Assert.Equal("[r1] 10px [r2] auto [r3]", style.GetPropertyValue("grid-template-rows"));
+ // No explicit column track list → columns reset to its initial value.
+ Assert.Equal("initial", style.GetPropertyValue("grid-template-columns"));
+ }
+
+ [Theory]
+ // `none` is a valid / value on either side of the slash.
+ [InlineData("grid-template: none / 1fr 1fr;", "none", "1fr 1fr")]
+ [InlineData("grid-template: 1fr 2fr / none;", "1fr 2fr", "none")]
+ public void GridTemplate_NoneOnOneAxis_IsAccepted(string declaration, string expectedRows, string expectedColumns)
+ {
+ var style = Style(declaration);
+ Assert.Equal(expectedRows, style.GetPropertyValue("grid-template-rows"));
+ Assert.Equal(expectedColumns, style.GetPropertyValue("grid-template-columns"));
+ }
+
+ // grid
+
+ [Fact]
+ public void Grid_TemplateForm_ResetsAutoProperties()
+ {
+ var style = Style("grid: \"a\" / 1fr;");
+ Assert.Equal("\"a\"", style.GetPropertyValue("grid-template-areas"));
+ Assert.Equal("auto", style.GetPropertyValue("grid-template-rows"));
+ Assert.Equal("1fr", style.GetPropertyValue("grid-template-columns"));
+ // The grid-auto-* longhands are reset to their initial values (via the CSS-wide `initial`).
+ Assert.Equal("initial", style.GetPropertyValue("grid-auto-flow"));
+ Assert.Equal("initial", style.GetPropertyValue("grid-auto-rows"));
+ Assert.Equal("initial", style.GetPropertyValue("grid-auto-columns"));
+ }
+
+ [Fact]
+ public void Grid_ColumnAutoFlowForm_SetsRowsFlowAndAutoColumns()
+ {
+ var style = Style("grid: 1fr / auto-flow 2fr;");
+ Assert.Equal("1fr", style.GetPropertyValue("grid-template-rows"));
+ Assert.Equal("column", style.GetPropertyValue("grid-auto-flow"));
+ Assert.Equal("2fr", style.GetPropertyValue("grid-auto-columns"));
+ Assert.Equal("initial", style.GetPropertyValue("grid-template-columns"));
+ }
+
+ [Fact]
+ public void Grid_RowAutoFlowForm_WithDense_SetsColumnsFlowAndAutoRows()
+ {
+ var style = Style("grid: auto-flow dense 10px / 1fr;");
+ Assert.Equal("1fr", style.GetPropertyValue("grid-template-columns"));
+ Assert.Equal("row dense", style.GetPropertyValue("grid-auto-flow"));
+ Assert.Equal("10px", style.GetPropertyValue("grid-auto-rows"));
+ }
+
+ [Fact]
+ public void Grid_NoneRows_WithColumnAutoFlow_IsAccepted()
+ {
+ // The side of the auto-flow form also accepts `none`.
+ var style = Style("grid: none / auto-flow 1fr;");
+ Assert.Equal("none", style.GetPropertyValue("grid-template-rows"));
+ Assert.Equal("column", style.GetPropertyValue("grid-auto-flow"));
+ Assert.Equal("1fr", style.GetPropertyValue("grid-auto-columns"));
+ }
+
+ // rejection (whole declaration dropped)
+
+ [Theory]
+ [InlineData("grid-template: 10px \"a\";")] // track-size before the first string
+ [InlineData("grid-template: \"a\" repeat(2, 1fr);")] // repeat() is not a valid row
+ [InlineData("grid-template: \"a\" \"b\" / repeat(2, 1fr);")] // trailing columns are an (no repeat())
+ [InlineData("grid-template: \"a a\" \"b\";")] // ragged area rows
+ [InlineData("grid-template: 1fr / 2fr / 3fr;")] // two top-level slashes
+ [InlineData("grid-template: 1fr 2fr;")] // a bare track list is not a valid grid-template
+ [InlineData("grid: auto-flow / auto-flow;")] // auto-flow on both sides
+ [InlineData("grid: dense 10px / 1fr;")] // dense without auto-flow
+ public void InvalidValue_IsDropped(string declaration)
+ {
+ var style = Style(declaration);
+ Assert.Equal(string.Empty, style.GetPropertyValue("grid-template-rows"));
+ Assert.Equal(string.Empty, style.GetPropertyValue("grid-template-columns"));
+ }
+
+ // serialization: never reconstructed
+
+ [Fact]
+ public void Expanded_DoesNotReconstructMegaShorthand()
+ {
+ var sheet = ParseStyleSheet("div { grid: auto-flow dense 10px / 1fr; }");
+ var css = sheet.ToCss();
+
+ Assert.DoesNotContain("grid:", css);
+ Assert.DoesNotContain("grid-template:", css);
+ Assert.Contains("grid-auto-flow", css);
+ Assert.Contains("grid-template-columns", css);
+ }
+
+ // var() is kept whole and deferred to cascade time
+
+ [Fact]
+ public void GridTemplate_WithVar_IsNotExpandedAtParseTime()
+ {
+ var style = Style("grid-template: var(--t);");
+ // The shorthand stays whole (var() can't be sliced at parse time), so the longhands are not set.
+ Assert.Equal(string.Empty, style.GetPropertyValue("grid-template-rows"));
+ }
+ }
+}
diff --git a/src/ExCSS.Tests/GridTrackListGrammarTests.cs b/src/ExCSS.Tests/GridTrackListGrammarTests.cs
new file mode 100644
index 0000000..44962f4
--- /dev/null
+++ b/src/ExCSS.Tests/GridTrackListGrammarTests.cs
@@ -0,0 +1,188 @@
+using System.Collections.Generic;
+using Xunit;
+
+namespace ExCSS.Tests
+{
+ ///
+ /// Tests for the shared — the grid <track-list>
+ /// (grid-template-columns/-rows) and <track-size>+ (grid-auto-columns/
+ /// -rows) value grammars.
+ ///
+ public class GridTrackListGrammarTests : CssConstructionFunctions
+ {
+ private static IReadOnlyList Tokens(string value)
+ {
+ var lexer = new Lexer(new TextSource(value));
+ var tokens = new List();
+ var token = lexer.Get();
+ while (token.Type != TokenType.EndOfFile)
+ {
+ tokens.Add(token);
+ token = lexer.Get();
+ }
+ return tokens;
+ }
+
+ private static GridTemplate Parse(string value) =>
+ GridTrackListGrammar.TryParse(Tokens(value));
+
+ [Theory]
+ [InlineData("100px")]
+ [InlineData("100px 200px")]
+ [InlineData("1fr 2fr")]
+ [InlineData("25% 75%")]
+ [InlineData("auto")]
+ [InlineData("auto 1fr auto")]
+ [InlineData("min-content max-content")]
+ [InlineData("minmax(100px, 1fr)")]
+ [InlineData("minmax(min-content, max-content)")]
+ [InlineData("fit-content(200px)")]
+ [InlineData("repeat(3, 100px)")]
+ [InlineData("repeat(2, 1fr 2fr)")]
+ [InlineData("repeat(auto-fill, minmax(200px, 1fr))")]
+ [InlineData("repeat(auto-fit, 100px)")]
+ [InlineData("100px repeat(2, 1fr) 100px")]
+ public void ValidTrackLists_Parse(string value)
+ {
+ Assert.NotNull(Parse(value));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("none")] // 'none' is accepted by the property, not the grammar
+ [InlineData("banana")]
+ [InlineData("minmax(1fr, 2fr)")] // a flex min is invalid in minmax()
+ [InlineData("minmax(100px)")] // minmax needs two args
+ [InlineData("repeat(0, 100px)")] // repeat count must be >= 1
+ [InlineData("repeat(auto-fill)")] // repeat needs a track body
+ [InlineData("-1fr")] // negative flex
+ [InlineData("repeat(2, [x] 1fr)")] // named lines inside repeat() are out of v1 scope
+ [InlineData("[unclosed 100px")] // an unclosed [ is invalid
+ public void InvalidTrackLists_ReturnNull(string value)
+ {
+ Assert.Null(Parse(value));
+ }
+
+ [Theory]
+ [InlineData("[sidebar-start] 200px [sidebar-end] 1fr")]
+ [InlineData("[a b] 100px [c]")]
+ [InlineData("[start] repeat(3, 100px) [end]")]
+ public void NamedLines_Parse(string value)
+ {
+ Assert.NotNull(Parse(value));
+ }
+
+ [Fact]
+ public void NamedLines_RecordSortedLineNumbers()
+ {
+ // [a] 100px [b] 100px [a] — 'a' labels lines 1 and 3, 'b' labels line 2.
+ var template = Parse("[a] 100px [b] 100px [a]");
+ Assert.NotNull(template);
+ Assert.Equal(new[] { 1, 3 }, template.LineNames["a"]);
+ Assert.Equal(new[] { 2 }, template.LineNames["b"]);
+ }
+
+ [Fact]
+ public void RepeatFixed_ExpandsInline()
+ {
+ var template = Parse("repeat(3, 100px)");
+ Assert.NotNull(template);
+ Assert.Equal(3, template.Tracks.Count);
+ Assert.All(template.Tracks, t => Assert.Equal(GridTrackKind.Length, t.Kind));
+ }
+
+ [Fact]
+ public void Fr_ParsesAsFlexFactor()
+ {
+ var template = Parse("1fr 2fr");
+ Assert.NotNull(template);
+ Assert.Equal(GridTrackKind.Flex, template.Tracks[0].Kind);
+ Assert.Equal(1.0, template.Tracks[0].Flex, 5);
+ Assert.Equal(2.0, template.Tracks[1].Flex, 5);
+ }
+
+ [Fact]
+ public void Minmax_CapturesBothBreadths()
+ {
+ var template = Parse("minmax(100px, 1fr)");
+ Assert.NotNull(template);
+ var track = Assert.Single(template.Tracks);
+ Assert.Equal(GridTrackKind.Minmax, track.Kind);
+ Assert.Equal(GridTrackKind.Length, track.Min.Kind);
+ Assert.Equal(GridTrackKind.Flex, track.Max.Kind);
+ }
+
+ [Fact]
+ public void AutoRepeat_RecordedWithKindAndInsertIndex()
+ {
+ var template = Parse("100px repeat(auto-fill, 50px) 100px");
+ Assert.NotNull(template);
+ Assert.Equal(GridAutoRepeatKind.AutoFill, template.AutoRepeat);
+ Assert.Equal(2, template.Tracks.Count); // the two fixed 100px tracks
+ Assert.Equal(1, template.AutoRepeatInsertIndex); // between them
+ var repeated = Assert.Single(template.AutoRepeatTracks);
+ Assert.Equal(GridTrackKind.Length, repeated.Kind);
+ }
+
+ [Fact]
+ public void TwoAutoRepeats_AreRejected()
+ {
+ Assert.Null(Parse("repeat(auto-fill, 50px) repeat(auto-fit, 50px)"));
+ }
+
+ [Theory]
+ [InlineData("subgrid")]
+ [InlineData("subgrid [a]")]
+ [InlineData("subgrid [a b] [c]")]
+ public void Subgrid_Parses(string value)
+ {
+ var template = Parse(value);
+ Assert.NotNull(template);
+ Assert.True(template.IsSubgrid);
+ Assert.False(template.IsNone);
+ Assert.Empty(template.Tracks);
+ }
+
+ [Fact]
+ public void Subgrid_RecordsLineNamesAtSequentialLines()
+ {
+ // subgrid [a] [b c] [a] — the line-name list positions 'a' at lines 1 and 3, 'b'/'c' at line 2.
+ var template = Parse("subgrid [a] [b c] [a]");
+ Assert.NotNull(template);
+ Assert.True(template.IsSubgrid);
+ Assert.Equal(new[] { 1, 3 }, template.LineNames["a"]);
+ Assert.Equal(new[] { 2 }, template.LineNames["b"]);
+ Assert.Equal(new[] { 2 }, template.LineNames["c"]);
+ }
+
+ [Theory]
+ [InlineData("subgrid 1fr")] // no track size may follow subgrid
+ [InlineData("1fr subgrid")] // subgrid must be the first token
+ [InlineData("subgrid 100px [a]")] // a track size mixed into the line-name list
+ [InlineData("subgrid repeat(2, [a])")] // repeat() in a subgrid line-name list is a v1 deferral
+ [InlineData("subgrid [unclosed")] // an unclosed [ is invalid
+ public void Subgrid_InvalidForms_ReturnNull(string value)
+ {
+ Assert.Null(Parse(value));
+ }
+
+ [Theory]
+ [InlineData("100px", 1)]
+ [InlineData("100px 200px auto", 3)]
+ public void TrackSizeList_ParsesForAutoColumns(string value, int expected)
+ {
+ var list = GridTrackListGrammar.TryParseTrackSizeList(Tokens(value));
+ Assert.NotNull(list);
+ Assert.Equal(expected, list.Count);
+ }
+
+ [Theory]
+ [InlineData("repeat(2, 100px)")] // no repeat() allowed in a track-size list
+ [InlineData("")]
+ [InlineData("banana")]
+ public void TrackSizeList_RejectsInvalid(string value)
+ {
+ Assert.Null(GridTrackListGrammar.TryParseTrackSizeList(Tokens(value)));
+ }
+ }
+}
diff --git a/src/ExCSS.Tests/PropertyTests/GridBoxAlignmentProperties.cs b/src/ExCSS.Tests/PropertyTests/GridBoxAlignmentProperties.cs
new file mode 100644
index 0000000..29bad1d
--- /dev/null
+++ b/src/ExCSS.Tests/PropertyTests/GridBoxAlignmentProperties.cs
@@ -0,0 +1,52 @@
+using Xunit;
+
+namespace ExCSS.Tests.PropertyTests
+{
+ public class GridBoxAlignmentPropertyTests : CssConstructionFunctions
+ {
+ [Theory]
+ [InlineData("justify-items", "center")]
+ [InlineData("justify-items", "start")]
+ [InlineData("justify-items", "end")]
+ [InlineData("justify-items", "stretch")]
+ [InlineData("justify-self", "center")]
+ [InlineData("justify-self", "auto")]
+ [InlineData("place-items", "center")]
+ [InlineData("place-items", "start end")]
+ [InlineData("place-content", "center")]
+ [InlineData("place-content", "space-between center")]
+ [InlineData("place-self", "center")]
+ [InlineData("place-self", "start end")]
+ public void BoxAlignmentLegalValues(string name, string value)
+ {
+ var property = ParseDeclaration($"{name}: {value}");
+ Assert.Equal(name, property.Name);
+ Assert.True(property.HasValue);
+ }
+
+ [Fact]
+ public void PlaceItems_ExpandsToAlignAndJustifyItems()
+ {
+ var declaration = ParseDeclarations("place-items: center start");
+ Assert.Equal("center", declaration.GetPropertyValue("align-items"));
+ Assert.Equal("start", declaration.GetPropertyValue("justify-items"));
+ }
+
+ [Fact]
+ public void PlaceSelf_SingleValueAppliesToBothAxes()
+ {
+ var declaration = ParseDeclarations("place-self: center");
+ Assert.Equal("center", declaration.GetPropertyValue("align-self"));
+ Assert.Equal("center", declaration.GetPropertyValue("justify-self"));
+ }
+
+ [Theory]
+ [InlineData("justify-items", "banana")]
+ [InlineData("place-content", "center start end")]
+ public void BoxAlignmentIllegalValues(string name, string value)
+ {
+ var property = ParseDeclaration($"{name}: {value}");
+ Assert.False(property.HasValue);
+ }
+ }
+}
diff --git a/src/ExCSS.Tests/PropertyTests/GridNamedLinesProperties.cs b/src/ExCSS.Tests/PropertyTests/GridNamedLinesProperties.cs
new file mode 100644
index 0000000..d857191
--- /dev/null
+++ b/src/ExCSS.Tests/PropertyTests/GridNamedLinesProperties.cs
@@ -0,0 +1,59 @@
+using Xunit;
+
+namespace ExCSS.Tests.PropertyTests
+{
+ public class GridNamedLinesPropertyTests : CssConstructionFunctions
+ {
+ [Theory]
+ // End-to-end: the [name] line-name groups must survive strict value parsing (ValueBuilder must
+ // accept the square-bracket tokens) and then validate through the track-list grammar.
+ [InlineData("grid-template-columns", "[sidebar-start] 200px [sidebar-end] 1fr")]
+ [InlineData("grid-template-columns", "[a b] 100px [c]")]
+ [InlineData("grid-template-rows", "[start] repeat(3, 100px) [end]")]
+ public void NamedLineTrackLists_AreAccepted(string name, string value)
+ {
+ var property = ParseDeclaration($"{name}: {value}");
+ Assert.Equal(name, property.Name);
+ Assert.True(property.HasValue);
+ }
+
+ [Theory]
+ [InlineData("grid-template-columns", "[unclosed 100px")]
+ [InlineData("grid-template-columns", "repeat(2, [x] 1fr)")]
+ public void InvalidNamedLineTrackLists_AreDropped(string name, string value)
+ {
+ var property = ParseDeclaration($"{name}: {value}");
+ Assert.False(property.HasValue);
+ }
+
+ [Theory]
+ [InlineData("grid-column-start", "sidebar")]
+ [InlineData("grid-row-end", "main-start")]
+ [InlineData("grid-column-start", "col 2")]
+ public void NamedGridLinePlacement_IsAccepted(string name, string value)
+ {
+ var property = ParseDeclaration($"{name}: {value}");
+ Assert.True(property.HasValue);
+ Assert.Equal(value, property.Value);
+ }
+
+ [Fact]
+ public void GridColumn_BareCustomIdent_CopiesToEnd()
+ {
+ // CSS Grid 8.3.1: a bare propagates to the omitted end edge.
+ var declaration = ParseDeclarations("grid-column: main");
+ Assert.Equal("main", declaration.GetPropertyValue("grid-column-start"));
+ Assert.Equal("main", declaration.GetPropertyValue("grid-column-end"));
+ }
+
+ [Fact]
+ public void GridArea_BareCustomIdent_CopiesToAllFourEdges()
+ {
+ var declaration = ParseDeclarations("grid-area: main");
+ Assert.Equal("main", declaration.GetPropertyValue("grid-row-start"));
+ Assert.Equal("main", declaration.GetPropertyValue("grid-column-start"));
+ Assert.Equal("main", declaration.GetPropertyValue("grid-row-end"));
+ Assert.Equal("main", declaration.GetPropertyValue("grid-column-end"));
+ }
+ }
+}
diff --git a/src/ExCSS.Tests/PropertyTests/GridPlacementProperties.cs b/src/ExCSS.Tests/PropertyTests/GridPlacementProperties.cs
new file mode 100644
index 0000000..e7b2ef8
--- /dev/null
+++ b/src/ExCSS.Tests/PropertyTests/GridPlacementProperties.cs
@@ -0,0 +1,74 @@
+using Xunit;
+
+namespace ExCSS.Tests.PropertyTests
+{
+ public class GridPlacementPropertyTests : CssConstructionFunctions
+ {
+ [Theory]
+ [InlineData("grid-column-start", "auto", "auto")]
+ [InlineData("grid-column-start", "3", "3")]
+ [InlineData("grid-column-start", "-1", "-1")]
+ [InlineData("grid-column-end", "span 2", "span 2")]
+ [InlineData("grid-row-start", "1", "1")]
+ [InlineData("grid-row-end", "span 3", "span 3")]
+ public void PlacementLonghandLegalValues(string name, string value, string expected)
+ {
+ var property = ParseDeclaration($"{name}: {value}");
+ Assert.Equal(name, property.Name);
+ Assert.True(property.HasValue);
+ Assert.Equal(expected, property.Value);
+ }
+
+ [Theory]
+ [InlineData("grid-column-start", "0")]
+ [InlineData("grid-column-start", "span 0")]
+ [InlineData("grid-row-end", "none")]
+ [InlineData("grid-row-end", "1.5")]
+ public void PlacementLonghandIllegalValues(string name, string value)
+ {
+ var property = ParseDeclaration($"{name}: {value}");
+ Assert.False(property.HasValue);
+ }
+
+ [Theory]
+ [InlineData("grid-column", "1 / 3")]
+ [InlineData("grid-column", "2 / span 2")]
+ [InlineData("grid-row", "1 / -1")]
+ [InlineData("grid-area", "1 / 1 / 3 / 3")]
+ [InlineData("grid-area", "2 / 2")]
+ public void PlacementShorthandLegalValues(string name, string value)
+ {
+ var property = ParseDeclaration($"{name}: {value}");
+ Assert.Equal(name, property.Name);
+ Assert.True(property.HasValue);
+ }
+
+ [Fact]
+ public void GridColumnShorthand_ExpandsToLonghands()
+ {
+ var declaration = ParseDeclarations("grid-column: 2 / span 3");
+ Assert.Equal("2", declaration.GetPropertyValue("grid-column-start"));
+ Assert.Equal("span 3", declaration.GetPropertyValue("grid-column-end"));
+ }
+
+ [Fact]
+ public void GridArea_ExpandsToFourLonghands()
+ {
+ var declaration = ParseDeclarations("grid-area: 1 / 2 / 3 / 4");
+ Assert.Equal("1", declaration.GetPropertyValue("grid-row-start"));
+ Assert.Equal("2", declaration.GetPropertyValue("grid-column-start"));
+ Assert.Equal("3", declaration.GetPropertyValue("grid-row-end"));
+ Assert.Equal("4", declaration.GetPropertyValue("grid-column-end"));
+ }
+
+ [Theory]
+ [InlineData("grid-column", "1 / 2 / 3")]
+ [InlineData("grid-area", "1 / 2 / 3 / 4 / 5")]
+ [InlineData("grid-column", "none / 2")]
+ public void PlacementShorthandIllegalValues(string name, string value)
+ {
+ var property = ParseDeclaration($"{name}: {value}");
+ Assert.False(property.HasValue);
+ }
+ }
+}
diff --git a/src/ExCSS.Tests/PropertyTests/GridTemplateAreasProperty.cs b/src/ExCSS.Tests/PropertyTests/GridTemplateAreasProperty.cs
new file mode 100644
index 0000000..82cf010
--- /dev/null
+++ b/src/ExCSS.Tests/PropertyTests/GridTemplateAreasProperty.cs
@@ -0,0 +1,32 @@
+using Xunit;
+
+namespace ExCSS.Tests.PropertyTests
+{
+ public class GridTemplateAreasPropertyTests : CssConstructionFunctions
+ {
+ [Theory]
+ [InlineData("none")]
+ [InlineData("\"a b\" \"c d\"")]
+ [InlineData("\"header header\" \"nav main\" \"footer footer\"")]
+ [InlineData("\"a . b\"")]
+ public void GridTemplateAreasLegalValues(string value)
+ {
+ var property = ParseDeclaration($"grid-template-areas: {value}");
+ Assert.Equal("grid-template-areas", property.Name);
+ Assert.IsType(property);
+ Assert.True(property.HasValue);
+ }
+
+ [Theory]
+ [InlineData("\"a a\" \"a a a\"")]
+ [InlineData("\"a b a\"")]
+ [InlineData("100px")]
+ [InlineData("\"\"")]
+ public void GridTemplateAreasIllegalValues(string value)
+ {
+ var property = ParseDeclaration($"grid-template-areas: {value}");
+ Assert.IsType(property);
+ Assert.False(property.HasValue);
+ }
+ }
+}
diff --git a/src/ExCSS.Tests/PropertyTests/GridTrackProperties.cs b/src/ExCSS.Tests/PropertyTests/GridTrackProperties.cs
new file mode 100644
index 0000000..f4ae20e
--- /dev/null
+++ b/src/ExCSS.Tests/PropertyTests/GridTrackProperties.cs
@@ -0,0 +1,43 @@
+using Xunit;
+
+namespace ExCSS.Tests.PropertyTests
+{
+ public class GridTrackPropertyTests : CssConstructionFunctions
+ {
+ [Theory]
+ [InlineData("grid-template-columns", "none", "none")]
+ [InlineData("grid-template-columns", "100px 200px", "100px 200px")]
+ [InlineData("grid-template-columns", "1fr 2fr", "1fr 2fr")]
+ [InlineData("grid-template-columns", "repeat(3, 1fr)", "repeat(3, 1fr)")]
+ [InlineData("grid-template-columns", "minmax(100px, 1fr)", "minmax(100px, 1fr)")]
+ [InlineData("grid-template-columns", "repeat(auto-fill, minmax(200px, 1fr))", "repeat(auto-fill, minmax(200px, 1fr))")]
+ [InlineData("grid-template-columns", "subgrid", "subgrid")]
+ [InlineData("grid-template-columns", "subgrid [a] [b]", "subgrid [a] [b]")]
+ [InlineData("grid-template-rows", "auto 1fr auto", "auto 1fr auto")]
+ [InlineData("grid-auto-columns", "min-content", "min-content")]
+ [InlineData("grid-auto-rows", "100px 200px", "100px 200px")]
+ [InlineData("grid-auto-flow", "row", "row")]
+ [InlineData("grid-auto-flow", "column dense", "column dense")]
+ [InlineData("grid-auto-flow", "dense", "dense")]
+ public void GridTrackLegalValues(string name, string value, string expected)
+ {
+ var property = ParseDeclaration($"{name}: {value}");
+ Assert.Equal(name, property.Name);
+ Assert.True(property.HasValue);
+ Assert.Equal(expected, property.Value);
+ }
+
+ [Theory]
+ [InlineData("grid-template-columns", "banana")]
+ [InlineData("grid-template-columns", "minmax(1fr, 2fr)")]
+ [InlineData("grid-template-columns", "repeat(0, 100px)")]
+ [InlineData("grid-auto-columns", "repeat(2, 100px)")]
+ [InlineData("grid-auto-flow", "row column")]
+ [InlineData("grid-auto-flow", "dense dense")]
+ public void GridTrackIllegalValues(string name, string value)
+ {
+ var property = ParseDeclaration($"{name}: {value}");
+ Assert.False(property.HasValue);
+ }
+ }
+}
diff --git a/src/ExCSS/Enumerations/FunctionNames.cs b/src/ExCSS/Enumerations/FunctionNames.cs
index afd8eb8..0cdc68a 100644
--- a/src/ExCSS/Enumerations/FunctionNames.cs
+++ b/src/ExCSS/Enumerations/FunctionNames.cs
@@ -15,6 +15,9 @@ public static class FunctionNames
public static readonly string Circle = "circle";
public static readonly string Ellipse = "ellipse";
public static readonly string Inset = "inset";
+ public static readonly string Repeat = "repeat";
+ public static readonly string Minmax = "minmax";
+ public static readonly string FitContent = "fit-content";
public static readonly string Attr = "attr";
public static readonly string LinearGradient = "linear-gradient";
public static readonly string RadialGradient = "radial-gradient";
diff --git a/src/ExCSS/Enumerations/Keywords.cs b/src/ExCSS/Enumerations/Keywords.cs
index 0c20112..dd08be0 100644
--- a/src/ExCSS/Enumerations/Keywords.cs
+++ b/src/ExCSS/Enumerations/Keywords.cs
@@ -299,6 +299,12 @@ internal static class Keywords
public static readonly string MaxContent = "max-content";
public static readonly string MinContent = "min-content";
public static readonly string FitContent = "fit-content";
+ public static readonly string AutoFill = "auto-fill";
+ public static readonly string AutoFit = "auto-fit";
+ public static readonly string AutoFlow = "auto-flow";
+ public static readonly string Subgrid = "subgrid";
+ public static readonly string Dense = "dense";
+ public static readonly string Span = "span";
public static readonly string Content = "content";
public static readonly string Revert = "revert";
public static readonly string RevertLayer = "revert-layer";
diff --git a/src/ExCSS/Enumerations/PropertyNames.cs b/src/ExCSS/Enumerations/PropertyNames.cs
index 3156b5a..6c5c060 100644
--- a/src/ExCSS/Enumerations/PropertyNames.cs
+++ b/src/ExCSS/Enumerations/PropertyNames.cs
@@ -126,12 +126,32 @@ public static class PropertyNames
public static readonly string FontWeight = "font-weight";
public static readonly string Font = "font";
public static readonly string Gap = "gap";
+ public static readonly string Grid = "grid";
+ public static readonly string GridTemplate = "grid-template";
+ public static readonly string GridTemplateColumns = "grid-template-columns";
+ public static readonly string GridTemplateRows = "grid-template-rows";
+ public static readonly string GridTemplateAreas = "grid-template-areas";
+ public static readonly string GridAutoColumns = "grid-auto-columns";
+ public static readonly string GridAutoRows = "grid-auto-rows";
+ public static readonly string GridAutoFlow = "grid-auto-flow";
+ public static readonly string GridColumn = "grid-column";
+ public static readonly string GridColumnStart = "grid-column-start";
+ public static readonly string GridColumnEnd = "grid-column-end";
+ public static readonly string GridRow = "grid-row";
+ public static readonly string GridRowStart = "grid-row-start";
+ public static readonly string GridRowEnd = "grid-row-end";
+ public static readonly string GridArea = "grid-area";
public static readonly string GlyphOrientationHorizontal = "glyph-orientation-horizontal";
public static readonly string GlyphOrientationVertical = "glyph-orientation-vertical";
public static readonly string Height = "height";
public static readonly string Hyphens = "hyphens";
public static readonly string ImeMode = "ime-mode";
public static readonly string JustifyContent = "justify-content";
+ public static readonly string JustifyItems = "justify-items";
+ public static readonly string JustifySelf = "justify-self";
+ public static readonly string PlaceItems = "place-items";
+ public static readonly string PlaceContent = "place-content";
+ public static readonly string PlaceSelf = "place-self";
public static readonly string LayoutGrid = "layout-grid";
public static readonly string LayoutGridChar = "layout-grid-char";
public static readonly string LayoutGridType = "layout-grid-type";
diff --git a/src/ExCSS/Factories/PropertyFactory.cs b/src/ExCSS/Factories/PropertyFactory.cs
index 20c0a6a..f0e5918 100644
--- a/src/ExCSS/Factories/PropertyFactory.cs
+++ b/src/ExCSS/Factories/PropertyFactory.cs
@@ -18,6 +18,12 @@ internal sealed class PropertyFactory
private readonly Dictionary _shorthands = new(StringComparer.OrdinalIgnoreCase);
+ // Shorthands that parse and expand like any other, but are NOT used to reconstruct a shorthand when
+ // serializing a declaration block: the grid mega-shorthands (grid, grid-template), whose multi-slash /
+ // areas grammar isn't worth reconstructing. Excluded from GetShorthands (the serialization query) only;
+ // CreateShorthand/GetLonghands/IsShorthand still work.
+ private readonly HashSet _logicalShorthands = new(StringComparer.OrdinalIgnoreCase);
+
private PropertyFactory()
{
AddLonghand(PropertyNames.AlignContent, () => new AlignContentProperty());
@@ -162,6 +168,44 @@ private PropertyFactory()
AddLonghand(PropertyNames.ColumnGap, () => new ColumnGapProperty(), true);
AddLonghand(PropertyNames.ColumnSpan, () => new ColumnSpanProperty());
+ AddLonghand(PropertyNames.GridTemplateColumns, () => new GridTemplateColumnsProperty());
+ AddLonghand(PropertyNames.GridTemplateRows, () => new GridTemplateRowsProperty());
+ AddLonghand(PropertyNames.GridTemplateAreas, () => new GridTemplateAreasProperty());
+ AddLonghand(PropertyNames.GridAutoColumns, () => new GridAutoColumnsProperty());
+ AddLonghand(PropertyNames.GridAutoRows, () => new GridAutoRowsProperty());
+ AddLonghand(PropertyNames.GridAutoFlow, () => new GridAutoFlowProperty());
+
+ // The grid mega-shorthands parse/expand like any other, but are excluded from serialization
+ // reconstruction (via _logicalShorthands): reconstructing a `grid`/`grid-template` from its
+ // longhands is not worth the complexity and could change existing output.
+ AddLogicalShorthand(PropertyNames.GridTemplate, () => new GridTemplateProperty(),
+ PropertyNames.GridTemplateRows, PropertyNames.GridTemplateColumns, PropertyNames.GridTemplateAreas);
+ AddLogicalShorthand(PropertyNames.Grid, () => new GridProperty(),
+ PropertyNames.GridTemplateRows, PropertyNames.GridTemplateColumns, PropertyNames.GridTemplateAreas,
+ PropertyNames.GridAutoFlow, PropertyNames.GridAutoRows, PropertyNames.GridAutoColumns);
+
+ AddLonghand(PropertyNames.GridColumnStart, () => new GridColumnStartProperty());
+ AddLonghand(PropertyNames.GridColumnEnd, () => new GridColumnEndProperty());
+ AddLonghand(PropertyNames.GridRowStart, () => new GridRowStartProperty());
+ AddLonghand(PropertyNames.GridRowEnd, () => new GridRowEndProperty());
+
+ AddShorthand(PropertyNames.GridColumn, () => new GridColumnProperty(),
+ PropertyNames.GridColumnStart, PropertyNames.GridColumnEnd);
+ AddShorthand(PropertyNames.GridRow, () => new GridRowProperty(),
+ PropertyNames.GridRowStart, PropertyNames.GridRowEnd);
+ AddShorthand(PropertyNames.GridArea, () => new GridAreaProperty(),
+ PropertyNames.GridRowStart, PropertyNames.GridColumnStart,
+ PropertyNames.GridRowEnd, PropertyNames.GridColumnEnd);
+
+ AddLonghand(PropertyNames.JustifyItems, () => new JustifyItemsProperty());
+ AddLonghand(PropertyNames.JustifySelf, () => new JustifySelfProperty());
+ AddShorthand(PropertyNames.PlaceItems, () => new PlaceItemsProperty(),
+ PropertyNames.AlignItems, PropertyNames.JustifyItems);
+ AddShorthand(PropertyNames.PlaceContent, () => new PlaceContentProperty(),
+ PropertyNames.AlignContent, PropertyNames.JustifyContent);
+ AddShorthand(PropertyNames.PlaceSelf, () => new PlaceSelfProperty(),
+ PropertyNames.AlignSelf, PropertyNames.JustifySelf);
+
AddShorthand(PropertyNames.ColumnRule, () => new ColumnRuleProperty(),
PropertyNames.ColumnRuleWidth,
PropertyNames.ColumnRuleStyle,
@@ -354,6 +398,12 @@ private void AddShorthand(string name, ShorthandCreator creator, params string[]
_mappings.Add(name, longhands);
}
+ private void AddLogicalShorthand(string name, ShorthandCreator creator, params string[] longhands)
+ {
+ AddShorthand(name, creator, longhands);
+ _logicalShorthands.Add(name);
+ }
+
private void AddLonghand(string name, LonghandCreator creator, bool animatable = false, bool font = false)
{
_longhands.Add(name, creator);
@@ -432,7 +482,10 @@ public string[] GetLonghands(string name)
public IEnumerable GetShorthands(string name)
{
- return from mapping in _mappings where mapping.Value.Contains(name, StringComparison.OrdinalIgnoreCase) select mapping.Key;
+ return from mapping in _mappings
+ where !_logicalShorthands.Contains(mapping.Key)
+ && mapping.Value.Contains(name, StringComparison.OrdinalIgnoreCase)
+ select mapping.Key;
}
private delegate Property LonghandCreator();
diff --git a/src/ExCSS/GridLineGrammar.cs b/src/ExCSS/GridLineGrammar.cs
new file mode 100644
index 0000000..48c587f
--- /dev/null
+++ b/src/ExCSS/GridLineGrammar.cs
@@ -0,0 +1,82 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ExCSS
+{
+ ///
+ /// A parsed grid <grid-line> (one edge of grid-column/grid-row/grid-area):
+ /// auto, an integer line number (may be negative — counting from the end edge), or span N.
+ /// Named lines (<custom-ident>) are populated by the named-line grammar extension.
+ ///
+ internal sealed class GridLine
+ {
+ public bool IsAuto { get; private set; }
+ public bool IsSpan { get; private set; }
+
+ /// The line number (for a line reference), the span count (when ), or
+ /// the 1-based Nth index for a named line (defaults to 1).
+ public int Value { get; private set; } = 1;
+
+ /// The custom-ident of a named line reference, or null when this is not a named line.
+ public string Name { get; private set; }
+
+ public static readonly GridLine Auto = new() { IsAuto = true };
+ public static GridLine Span(int n) => new() { IsSpan = true, Value = n };
+ public static GridLine Line(int n) => new() { Value = n };
+ public static GridLine Named(string name) => new() { Name = name, Value = 1 };
+ public static GridLine NamedNth(string name, int n) => new() { Name = name, Value = n };
+ }
+
+ ///
+ /// Shared grammar for a single grid <grid-line> value. Used to validate the
+ /// grid-column-start/-end/grid-row-start/-end longhands and the placement
+ /// shorthands.
+ ///
+ internal static class GridLineGrammar
+ {
+ internal static GridLine TryParse(IReadOnlyList tokens)
+ {
+ var toks = tokens.Where(t => t.Type != TokenType.Whitespace).ToArray();
+ if (toks.Length == 0) return null;
+
+ // auto
+ if (toks.Length == 1 && toks[0].Type == TokenType.Ident && toks[0].Data.Isi(Keywords.Auto))
+ return GridLine.Auto;
+
+ //
+ if (toks.Length == 1 && toks[0] is NumberToken { IsInteger: true } number && number.IntegerValue != 0)
+ return GridLine.Line(number.IntegerValue);
+
+ // span
+ if (toks.Length == 2 && toks[0].Type == TokenType.Ident && toks[0].Data.Isi(Keywords.Span)
+ && toks[1] is NumberToken { IsInteger: true } spanCount && spanCount.IntegerValue >= 1)
+ return GridLine.Span(spanCount.IntegerValue);
+
+ // — a named line reference.
+ if (toks.Length == 1 && IsCustomIdent(toks[0]))
+ return GridLine.Named(toks[0].Data);
+
+ // / — the Nth line with that name (order-independent).
+ if (toks.Length == 2)
+ {
+ if (IsCustomIdent(toks[0]) && toks[1] is NumberToken { IsInteger: true } n1 && n1.IntegerValue != 0)
+ return GridLine.NamedNth(toks[0].Data, n1.IntegerValue);
+ if (IsCustomIdent(toks[1]) && toks[0] is NumberToken { IsInteger: true } n2 && n2.IntegerValue != 0)
+ return GridLine.NamedNth(toks[1].Data, n2.IntegerValue);
+ }
+
+ return null;
+ }
+
+ /// Whether the token is a <custom-ident> usable as a grid line name — an ident
+ /// that is not one of the reserved keywords a grid-line value or the CSS-wide keywords may take.
+ private static bool IsCustomIdent(Token token)
+ {
+ if (token.Type != TokenType.Ident) return false;
+ var d = token.Data;
+ return !d.Isi(Keywords.Auto) && !d.Isi(Keywords.Span) && !d.Isi(Keywords.None)
+ && !d.Isi(Keywords.Inherit) && !d.Isi(Keywords.Initial) && !d.Isi(Keywords.Unset)
+ && !d.Isi(Keywords.Revert) && !d.Isi(Keywords.RevertLayer) && !d.Isi("default");
+ }
+ }
+}
diff --git a/src/ExCSS/GridTemplateAreasGrammar.cs b/src/ExCSS/GridTemplateAreasGrammar.cs
new file mode 100644
index 0000000..c809132
--- /dev/null
+++ b/src/ExCSS/GridTemplateAreasGrammar.cs
@@ -0,0 +1,92 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ExCSS
+{
+ ///
+ /// A parsed grid-template-areas value (CSS Grid §7.3): a rectangular grid of named cells. Each
+ /// named area's 0-based inclusive bounding rectangle is recorded; a . (or a run of only dots) is
+ /// an unnamed empty cell.
+ ///
+ internal sealed class GridAreas
+ {
+ public int RowCount { get; internal set; }
+ public int ColCount { get; internal set; }
+
+ /// The area name at each cell ([row, col]), or null for an empty . cell.
+ public string[,] Cells { get; internal set; }
+
+ /// Each named area's 0-based inclusive rectangle (rowStart, colStart, rowEnd, colEnd).
+ public IReadOnlyDictionary Areas { get; internal set; }
+ }
+
+ ///
+ /// Shared grammar for a grid-template-areas value (a series of quoted strings). Returns null for
+ /// anything invalid — including the none keyword, which the property accepts separately.
+ ///
+ internal static class GridTemplateAreasGrammar
+ {
+ internal static GridAreas TryParse(IReadOnlyList tokens)
+ {
+ var toks = tokens.Where(t => t.Type != TokenType.Whitespace).ToArray();
+ if (toks.Length == 0) return null;
+
+ // Every token must be a string; each string is one row.
+ if (toks.Any(t => t.Type != TokenType.String)) return null;
+
+ var rows = new List(toks.Length);
+ int colCount = -1;
+ foreach (var t in toks)
+ {
+ var cells = ((StringToken)t).Data
+ .Split((char[])null, System.StringSplitOptions.RemoveEmptyEntries);
+ if (cells.Length == 0) return null; // an empty string is invalid
+ if (colCount < 0) colCount = cells.Length;
+ else if (cells.Length != colCount) return null; // ragged rows are invalid
+
+ var normalized = new string[colCount];
+ for (var c = 0; c < colCount; c++)
+ {
+ if (IsEmptyCell(cells[c])) { normalized[c] = null; continue; }
+ // A named cell is a — a mixed "a.b" is invalid, not a name.
+ if (cells[c].IndexOf('.') >= 0) return null;
+ normalized[c] = cells[c];
+ }
+ rows.Add(normalized);
+ }
+
+ var rowCount = rows.Count;
+ var grid = new string[rowCount, colCount];
+ var bounds = new Dictionary();
+
+ for (var r = 0; r < rowCount; r++)
+ for (var c = 0; c < colCount; c++)
+ {
+ var name = rows[r][c];
+ grid[r, c] = name;
+ if (name is null) continue;
+ if (bounds.TryGetValue(name, out var b))
+ bounds[name] = (System.Math.Min(b.R1, r), System.Math.Min(b.C1, c),
+ System.Math.Max(b.R2, r), System.Math.Max(b.C2, c));
+ else
+ bounds[name] = (r, c, r, c);
+ }
+
+ // Each named area must be a single filled rectangle: every cell in its bounding box carries the
+ // name (which also guarantees no cell with the name lies outside the box).
+ foreach (var pair in bounds)
+ {
+ var name = pair.Key;
+ var b = pair.Value;
+ for (var r = b.R1; r <= b.R2; r++)
+ for (var c = b.C1; c <= b.C2; c++)
+ if (grid[r, c] != name) return null;
+ }
+
+ return new GridAreas { RowCount = rowCount, ColCount = colCount, Cells = grid, Areas = bounds };
+ }
+
+ /// A cell of only U+002E FULL STOP characters (., ...) is an empty cell.
+ private static bool IsEmptyCell(string cell) => cell.All(ch => ch == '.');
+ }
+}
diff --git a/src/ExCSS/GridTrackListGrammar.cs b/src/ExCSS/GridTrackListGrammar.cs
new file mode 100644
index 0000000..00c941d
--- /dev/null
+++ b/src/ExCSS/GridTrackListGrammar.cs
@@ -0,0 +1,388 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ExCSS
+{
+ /// The kind of a single grid <track-size>/<track-breadth>
+ /// (CSS Grid Layout Module Level 1/2 §7.2).
+ internal enum GridTrackKind
+ {
+ Length, // a (raw component string in Value)
+ Percent, // a (raw component string in Value)
+ Flex, // a / fr value (Flex holds the fr number)
+ Auto,
+ MinContent,
+ MaxContent,
+ FitContent, // fit-content() — the argument is in Value
+ Minmax // minmax(Min, Max)
+ }
+
+ /// Which flavour of layout-time repeat() a template carries (CSS Grid §7.2.3.2).
+ internal enum GridAutoRepeatKind { None, AutoFill, AutoFit }
+
+ ///
+ /// A single grid <track-size>. Fixed/percentage/fit-content values keep their authored
+ /// component string (); fr keeps its numeric factor ();
+ /// minmax() keeps its two sub-breadths.
+ ///
+ internal sealed class GridTrackSize
+ {
+ public GridTrackKind Kind { get; private set; }
+ public string Value { get; private set; }
+ public double Flex { get; private set; }
+ public GridTrackSize Min { get; private set; }
+ public GridTrackSize Max { get; private set; }
+
+ public static GridTrackSize Length(string v) => new() { Kind = GridTrackKind.Length, Value = v };
+ public static GridTrackSize Percent(string v) => new() { Kind = GridTrackKind.Percent, Value = v };
+ public static GridTrackSize FlexFactor(double fr) => new() { Kind = GridTrackKind.Flex, Flex = fr };
+ public static readonly GridTrackSize Auto = new() { Kind = GridTrackKind.Auto };
+ public static readonly GridTrackSize MinContent = new() { Kind = GridTrackKind.MinContent };
+ public static readonly GridTrackSize MaxContent = new() { Kind = GridTrackKind.MaxContent };
+ public static GridTrackSize FitContentTo(string v) => new() { Kind = GridTrackKind.FitContent, Value = v };
+ public static GridTrackSize Minmax(GridTrackSize min, GridTrackSize max) =>
+ new() { Kind = GridTrackKind.Minmax, Min = min, Max = max };
+ }
+
+ ///
+ /// A parsed grid-template-columns/grid-template-rows value: the fixed (fully
+ /// repeat(N,…)-expanded) tracks, plus at most one layout-time repeat(auto-fill|auto-fit,…)
+ /// section recorded as an insertion point since its count is resolved during layout.
+ ///
+ internal sealed class GridTemplate
+ {
+ /// The fixed tracks (with the auto-repeat section, if any, NOT included here — it is
+ /// spliced in at at layout time).
+ public IReadOnlyList Tracks { get; internal set; } = new List();
+
+ public GridAutoRepeatKind AutoRepeat { get; internal set; } = GridAutoRepeatKind.None;
+
+ /// The repeated track template for an auto-fill/auto-fit section.
+ public IReadOnlyList AutoRepeatTracks { get; internal set; }
+
+ /// The index into at which the auto-repeat section is inserted.
+ public int AutoRepeatInsertIndex { get; internal set; }
+
+ /// Named lines declared with [name] in the top-level track list: name → the sorted,
+ /// deduped 1-based line numbers it labels (line 1 is before the first track). Empty when none.
+ public IReadOnlyDictionary> LineNames { get; internal set; }
+ = new Dictionary>();
+
+ /// The subgrid keyword (CSS Grid Layout Module Level 2 §9): this axis adopts the parent
+ /// grid's tracks instead of defining its own. A subgrid template carries no — only
+ /// any explicit [name] line names declared after the keyword (in ).
+ public bool IsSubgrid { get; internal set; }
+
+ public bool IsNone => !IsSubgrid && Tracks.Count == 0 && AutoRepeat == GridAutoRepeatKind.None;
+ }
+
+ ///
+ /// Shared grammar for the grid <track-list> (grid-template-columns/
+ /// grid-template-rows) and <track-size>+ (grid-auto-columns/
+ /// grid-auto-rows). Mirrors the ExCSS grammar-helper precedent so the grammar is defined once.
+ /// Named lines ([name]) and grid-template-areas are out of the fixed track-list scope
+ /// here and rejected.
+ ///
+ internal static class GridTrackListGrammar
+ {
+ ///
+ /// Parses a <track-list> (the grid-template-columns/-rows value). Returns
+ /// null when the value is not a valid track list — including the literal none, which
+ /// the property accepts separately.
+ ///
+ internal static GridTemplate TryParse(IReadOnlyList tokens)
+ {
+ var toks = tokens.Where(t => t.Type != TokenType.Whitespace).ToArray();
+ if (toks.Length == 0) return null;
+
+ // subgrid (CSS Grid Level 2 §9): the axis adopts the parent grid's tracks. The keyword may be
+ // followed by an optional ([name] groups only; repeat() of line names is a v1
+ // deferral, mirroring the "named lines inside repeat()" restriction below). No is
+ // allowed after subgrid.
+ if (toks[0].Type == TokenType.Ident && toks[0].Data.Isi(Keywords.Subgrid))
+ return TryParseSubgrid(toks);
+
+ var fixedTracks = new List();
+ var autoRepeat = GridAutoRepeatKind.None;
+ List autoRepeatTracks = null;
+ var autoRepeatIndex = -1;
+ var lineNames = new Dictionary>();
+
+ var i = 0;
+ while (i < toks.Length)
+ {
+ // [name …] — one or more named lines at the current line position (1-based; line 1 is before
+ // the first track). Whitespace is already stripped, so the bracket group is contiguous.
+ if (toks[i].Type == TokenType.SquareBracketOpen)
+ {
+ var lineIndex = fixedTracks.Count + 1;
+ i++;
+ while (i < toks.Length && toks[i].Type != TokenType.SquareBracketClose)
+ {
+ if (toks[i].Type != TokenType.Ident) return null; // only idents inside [ ]
+ var name = toks[i].Data;
+ if (!lineNames.TryGetValue(name, out var set))
+ lineNames[name] = set = new SortedSet();
+ set.Add(lineIndex);
+ i++;
+ }
+ if (i >= toks.Length) return null; // unclosed [
+ i++; // consume ]
+ continue;
+ }
+
+ if (toks[i] is FunctionToken fn && fn.Data.Isi(FunctionNames.Repeat))
+ {
+ if (!TryParseRepeat(fn, out var repeatKind, out var repeatTracks)) return null;
+
+ if (repeatKind == GridAutoRepeatKind.None)
+ {
+ // repeat(N, …) — expand inline.
+ fixedTracks.AddRange(repeatTracks);
+ }
+ else
+ {
+ // repeat(auto-fill|auto-fit, …) — at most one per track list.
+ if (autoRepeat != GridAutoRepeatKind.None) return null;
+ autoRepeat = repeatKind;
+ autoRepeatTracks = repeatTracks;
+ autoRepeatIndex = fixedTracks.Count;
+ }
+
+ i++;
+ continue;
+ }
+
+ if (!TryParseTrackSize(toks[i], out var track)) return null;
+ fixedTracks.Add(track);
+ i++;
+ }
+
+ if (fixedTracks.Count == 0 && autoRepeat == GridAutoRepeatKind.None) return null;
+
+ return new GridTemplate
+ {
+ Tracks = fixedTracks,
+ AutoRepeat = autoRepeat,
+ AutoRepeatTracks = autoRepeatTracks,
+ AutoRepeatInsertIndex = autoRepeatIndex < 0 ? 0 : autoRepeatIndex,
+ LineNames = lineNames.ToDictionary(
+ kv => kv.Key,
+ kv => (IReadOnlyList)kv.Value.ToList())
+ };
+ }
+
+ ///
+ /// Parses a subgrid [ <line-name-list> ]? value (the leading subgrid ident already
+ /// matched by the caller). The optional line-name list is a run of [name …] bracket groups, one
+ /// group per grid line (line 1 is before the first adopted track); each group's names are recorded at
+ /// that 1-based line index. Anything else after subgrid (a track size, an unclosed/ill-formed
+ /// bracket, a non-ident inside [ ]) is invalid.
+ ///
+ private static GridTemplate TryParseSubgrid(Token[] toks)
+ {
+ var lineNames = new Dictionary>();
+ var lineIndex = 1;
+ var i = 1; // skip the subgrid ident
+
+ while (i < toks.Length)
+ {
+ if (toks[i].Type != TokenType.SquareBracketOpen) return null; // only [name] groups may follow
+ i++;
+ while (i < toks.Length && toks[i].Type != TokenType.SquareBracketClose)
+ {
+ if (toks[i].Type != TokenType.Ident) return null;
+ var name = toks[i].Data;
+ if (!lineNames.TryGetValue(name, out var set))
+ lineNames[name] = set = new SortedSet();
+ set.Add(lineIndex);
+ i++;
+ }
+ if (i >= toks.Length) return null; // unclosed [
+ i++; // consume ]
+ lineIndex++;
+ }
+
+ return new GridTemplate
+ {
+ IsSubgrid = true,
+ LineNames = lineNames.ToDictionary(
+ kv => kv.Key,
+ kv => (IReadOnlyList)kv.Value.ToList())
+ };
+ }
+
+ ///
+ /// Parses a <track-size>+ list (grid-auto-columns/grid-auto-rows) — one or
+ /// more track sizes, no repeat(). Returns null on any invalid token.
+ ///
+ internal static IReadOnlyList TryParseTrackSizeList(IReadOnlyList tokens)
+ {
+ var toks = tokens.Where(t => t.Type != TokenType.Whitespace).ToArray();
+ if (toks.Length == 0) return null;
+
+ var result = new List();
+ foreach (var token in toks)
+ {
+ if (!TryParseTrackSize(token, out var track)) return null;
+ result.Add(track);
+ }
+
+ return result;
+ }
+
+ private static bool TryParseRepeat(FunctionToken fn, out GridAutoRepeatKind kind, out List tracks)
+ {
+ kind = GridAutoRepeatKind.None;
+ tracks = null;
+
+ var args = fn.ArgumentTokens.Where(t => t.Type != TokenType.Whitespace).ToArray();
+ var groups = SplitByComma(args);
+ if (groups.Count < 2) return false;
+
+ // First group is the repetition count: an integer, or auto-fill / auto-fit.
+ var first = groups[0];
+ if (first.Count != 1) return false;
+
+ var countTokens = 0;
+ if (IsIdent(first[0], Keywords.AutoFill)) kind = GridAutoRepeatKind.AutoFill;
+ else if (IsIdent(first[0], Keywords.AutoFit)) kind = GridAutoRepeatKind.AutoFit;
+ else if (first[0] is NumberToken { IsInteger: true } n && n.IntegerValue >= 1) countTokens = n.IntegerValue;
+ else return false;
+
+ // Remaining groups are the repeated s. (A repeat body may not itself contain a
+ // repeat(), which SplitByComma naturally enforces since a nested function is one token.)
+ var body = new List();
+ for (var gi = 1; gi < groups.Count; gi++)
+ {
+ foreach (var token in groups[gi])
+ {
+ if (!TryParseTrackSize(token, out var track)) return false;
+ body.Add(track);
+ }
+ }
+
+ if (body.Count == 0) return false;
+
+ if (kind != GridAutoRepeatKind.None)
+ {
+ tracks = body;
+ return true;
+ }
+
+ // repeat(N, body) — expand N copies now.
+ tracks = new List(body.Count * countTokens);
+ for (var r = 0; r < countTokens; r++)
+ tracks.AddRange(body);
+ return true;
+ }
+
+ private static bool TryParseTrackSize(Token token, out GridTrackSize track)
+ {
+ track = null;
+
+ // minmax(, ) and fit-content().
+ if (token is FunctionToken fn)
+ {
+ if (fn.Data.Isi(FunctionNames.Minmax)) return TryParseMinmax(fn, out track);
+ if (fn.Data.Isi(FunctionNames.FitContent)) return TryParseFitContent(fn, out track);
+ return false;
+ }
+
+ if (TryParseBreadth(token, out track)) return true;
+ return false;
+ }
+
+ private static bool TryParseBreadth(Token token, out GridTrackSize track)
+ {
+ track = null;
+
+ if (token.Type == TokenType.Ident)
+ {
+ if (token.Data.Isi(Keywords.Auto)) { track = GridTrackSize.Auto; return true; }
+ if (token.Data.Isi(Keywords.MinContent)) { track = GridTrackSize.MinContent; return true; }
+ if (token.Data.Isi(Keywords.MaxContent)) { track = GridTrackSize.MaxContent; return true; }
+ return false;
+ }
+
+ if (token.Type == TokenType.Percentage) { track = GridTrackSize.Percent(token.ToValue()); return true; }
+
+ if (token is UnitToken unit && token.Type == TokenType.Dimension)
+ {
+ if (unit.Unit.Isi("fr"))
+ {
+ if (unit.Value < 0) return false;
+ track = GridTrackSize.FlexFactor(unit.Value);
+ return true;
+ }
+
+ // Any other dimension is a — the raw string is preserved.
+ track = GridTrackSize.Length(token.ToValue());
+ return true;
+ }
+
+ // Unitless zero is a valid length.
+ if (token is NumberToken { Value: 0f }) { track = GridTrackSize.Length("0"); return true; }
+
+ return false;
+ }
+
+ private static bool TryParseMinmax(FunctionToken fn, out GridTrackSize track)
+ {
+ track = null;
+ var groups = SplitByComma(fn.ArgumentTokens.Where(t => t.Type != TokenType.Whitespace).ToArray());
+ if (groups.Count != 2 || groups[0].Count != 1 || groups[1].Count != 1) return false;
+
+ if (!TryParseBreadth(groups[0][0], out var min)) return false;
+ if (!TryParseBreadth(groups[1][0], out var max)) return false;
+
+ // The min of a minmax() is an — a flex value is invalid there.
+ if (min.Kind == GridTrackKind.Flex) return false;
+
+ track = GridTrackSize.Minmax(min, max);
+ return true;
+ }
+
+ private static bool TryParseFitContent(FunctionToken fn, out GridTrackSize track)
+ {
+ track = null;
+ var args = fn.ArgumentTokens.Where(t => t.Type != TokenType.Whitespace).ToArray();
+ if (args.Length != 1) return false;
+
+ // fit-content().
+ var t = args[0];
+ if (t.Type is TokenType.Percentage) { track = GridTrackSize.FitContentTo(t.ToValue()); return true; }
+ if (t is UnitToken && t.Type == TokenType.Dimension && !((UnitToken)t).Unit.Isi("fr"))
+ {
+ track = GridTrackSize.FitContentTo(t.ToValue());
+ return true;
+ }
+ if (t is NumberToken { Value: 0f }) { track = GridTrackSize.FitContentTo("0"); return true; }
+
+ return false;
+ }
+
+ private static bool IsIdent(Token token, string keyword) =>
+ token.Type == TokenType.Ident && token.Data.Isi(keyword);
+
+ private static List> SplitByComma(IReadOnlyList tokens)
+ {
+ var groups = new List>();
+ var current = new List();
+ foreach (var token in tokens)
+ {
+ if (token.Type == TokenType.Comma)
+ {
+ groups.Add(current);
+ current = new List();
+ }
+ else
+ {
+ current.Add(token);
+ }
+ }
+ groups.Add(current);
+ return groups;
+ }
+ }
+}
diff --git a/src/ExCSS/Model/Converters.cs b/src/ExCSS/Model/Converters.cs
index 0cd8c44..e350f6a 100644
--- a/src/ExCSS/Model/Converters.cs
+++ b/src/ExCSS/Model/Converters.cs
@@ -331,6 +331,19 @@ public static readonly IValueConverter
public static readonly IValueConverter AlignSelfConverter = AlignItemsConverter.OrAuto();
+ // justify-items / justify-self share align-items/align-self's value grammar for the keywords the
+ // grid engine honors (start/end/center/stretch/normal; baseline falls back to start at layout).
+ public static readonly IValueConverter JustifyItemsConverter = AlignItemsConverter;
+ public static readonly IValueConverter JustifySelfConverter = AlignSelfConverter;
+
+ // place-items / place-content / place-self: ? — one value applies to both axes.
+ public static readonly IValueConverter PlaceItemsConverter =
+ AlignItemsConverter.Periodic(PropertyNames.AlignItems, PropertyNames.JustifyItems);
+ public static readonly IValueConverter PlaceContentConverter =
+ JustifyContentConverter.Periodic(PropertyNames.AlignContent, PropertyNames.JustifyContent);
+ public static readonly IValueConverter PlaceSelfConverter =
+ AlignSelfConverter.Periodic(PropertyNames.AlignSelf, PropertyNames.JustifySelf);
+
#region Specific
public static readonly IValueConverter OptionalIntegerConverter = IntegerConverter.OrAuto();
@@ -437,6 +450,24 @@ public static readonly IValueConverter
IntegerConverter.Required(),
IntegerConverter.StartsWithDelimiter().Required());
+ // A single grid (auto | | span ), validated by GridLineGrammar.
+ public static readonly IValueConverter GridLineConverter = new GridLineValueConverter();
+
+ // grid-column / grid-row / grid-area: slash-separated components with the CSS Grid
+ // §8.3.1 omitted-value copy rule (a bare propagates to the paired/all edges) — the
+ // generic WithOrder(...).Option() DSL resets omitted slots to auto, which is wrong for named areas.
+ public static readonly IValueConverter GridColumnConverter =
+ new GridColumnRowShorthandValueConverter(PropertyNames.GridColumnStart, PropertyNames.GridColumnEnd);
+
+ public static readonly IValueConverter GridRowConverter =
+ new GridColumnRowShorthandValueConverter(PropertyNames.GridRowStart, PropertyNames.GridRowEnd);
+
+ public static readonly IValueConverter GridAreaConverter = new GridAreaShorthandValueConverter();
+
+ public static readonly IValueConverter GridTemplateConverter = new GridTemplateShorthandValueConverter();
+
+ public static readonly IValueConverter GridConverter = new GridShorthandValueConverter();
+
public static readonly IValueConverter ShadowConverter = WithAny(
Assign(Keywords.Inset, true).Option(false),
LengthConverter.Many(2, 4).Required(),
diff --git a/src/ExCSS/Model/ValueBuilder.cs b/src/ExCSS/Model/ValueBuilder.cs
index cb3bcb2..0996e3b 100644
--- a/src/ExCSS/Model/ValueBuilder.cs
+++ b/src/ExCSS/Model/ValueBuilder.cs
@@ -65,6 +65,14 @@ public void Apply(Token token)
// descriptor is dropped entirely under strict (non-tolerant) parsing.
Add(token);
break;
+ case TokenType.SquareBracketOpen:
+ case TokenType.SquareBracketClose:
+ // A grid group is written as [name ...] inside a track list. Like the
+ // unicode-range Range tokens above, these brackets would otherwise hit the default arm
+ // and drop the whole value under strict parsing; keep them so the grid grammars can
+ // validate the line-name groups.
+ Add(token);
+ break;
case TokenType.Comment:
break;
default:
diff --git a/src/ExCSS/StyleProperties/Grid/GridAreaProperty.cs b/src/ExCSS/StyleProperties/Grid/GridAreaProperty.cs
new file mode 100644
index 0000000..2f0ca58
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridAreaProperty.cs
@@ -0,0 +1,19 @@
+namespace ExCSS
+{
+ ///
+ /// The grid-area shorthand (CSS Grid §8.3.1): <grid-line> [ / <grid-line> ]{0,3},
+ /// expanding to grid-row-start / grid-column-start / grid-row-end /
+ /// grid-column-end.
+ ///
+ internal sealed class GridAreaProperty : ShorthandProperty
+ {
+ private static readonly IValueConverter StyleConverter = Converters.GridAreaConverter.OrGlobalValue();
+
+ internal GridAreaProperty()
+ : base(PropertyNames.GridArea)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/GridAutoColumnsProperty.cs b/src/ExCSS/StyleProperties/Grid/GridAutoColumnsProperty.cs
new file mode 100644
index 0000000..58b2d44
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridAutoColumnsProperty.cs
@@ -0,0 +1,15 @@
+namespace ExCSS
+{
+ /// The grid-auto-columns property (CSS Grid §7.6): the size of implicitly-created columns.
+ internal sealed class GridAutoColumnsProperty : Property
+ {
+ private static readonly IValueConverter StyleConverter = new GridAutoTracksValueConverter().OrDefault();
+
+ internal GridAutoColumnsProperty()
+ : base(PropertyNames.GridAutoColumns)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/GridAutoFlowProperty.cs b/src/ExCSS/StyleProperties/Grid/GridAutoFlowProperty.cs
new file mode 100644
index 0000000..930b4cd
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridAutoFlowProperty.cs
@@ -0,0 +1,15 @@
+namespace ExCSS
+{
+ /// The grid-auto-flow property (CSS Grid §7.7): the auto-placement direction and packing.
+ internal sealed class GridAutoFlowProperty : Property
+ {
+ private static readonly IValueConverter StyleConverter = new GridAutoFlowValueConverter().OrDefault();
+
+ internal GridAutoFlowProperty()
+ : base(PropertyNames.GridAutoFlow)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/GridAutoRowsProperty.cs b/src/ExCSS/StyleProperties/Grid/GridAutoRowsProperty.cs
new file mode 100644
index 0000000..6280b81
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridAutoRowsProperty.cs
@@ -0,0 +1,15 @@
+namespace ExCSS
+{
+ /// The grid-auto-rows property (CSS Grid §7.6): the size of implicitly-created rows.
+ internal sealed class GridAutoRowsProperty : Property
+ {
+ private static readonly IValueConverter StyleConverter = new GridAutoTracksValueConverter().OrDefault();
+
+ internal GridAutoRowsProperty()
+ : base(PropertyNames.GridAutoRows)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/GridColumnEndProperty.cs b/src/ExCSS/StyleProperties/Grid/GridColumnEndProperty.cs
new file mode 100644
index 0000000..16fbcbd
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridColumnEndProperty.cs
@@ -0,0 +1,18 @@
+namespace ExCSS
+{
+ ///
+ /// The grid-column-end longhand (CSS Grid §8.3), one edge of a grid item's placement.
+ /// Validated by the shared .
+ ///
+ internal sealed class GridColumnEndProperty : Property
+ {
+ private static readonly IValueConverter StyleConverter = Converters.GridLineConverter.OrDefault();
+
+ internal GridColumnEndProperty()
+ : base(PropertyNames.GridColumnEnd)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/GridColumnProperty.cs b/src/ExCSS/StyleProperties/Grid/GridColumnProperty.cs
new file mode 100644
index 0000000..0c2b795
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridColumnProperty.cs
@@ -0,0 +1,18 @@
+namespace ExCSS
+{
+ ///
+ /// The grid-column shorthand (CSS Grid §8.3.1): <grid-line> [ / <grid-line> ]?,
+ /// expanding to grid-column-start / grid-column-end.
+ ///
+ internal sealed class GridColumnProperty : ShorthandProperty
+ {
+ private static readonly IValueConverter StyleConverter = Converters.GridColumnConverter.OrGlobalValue();
+
+ internal GridColumnProperty()
+ : base(PropertyNames.GridColumn)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/GridColumnStartProperty.cs b/src/ExCSS/StyleProperties/Grid/GridColumnStartProperty.cs
new file mode 100644
index 0000000..0ce5902
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridColumnStartProperty.cs
@@ -0,0 +1,18 @@
+namespace ExCSS
+{
+ ///
+ /// The grid-column-start longhand (CSS Grid §8.3), one edge of a grid item's placement.
+ /// Validated by the shared .
+ ///
+ internal sealed class GridColumnStartProperty : Property
+ {
+ private static readonly IValueConverter StyleConverter = Converters.GridLineConverter.OrDefault();
+
+ internal GridColumnStartProperty()
+ : base(PropertyNames.GridColumnStart)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/GridProperty.cs b/src/ExCSS/StyleProperties/Grid/GridProperty.cs
new file mode 100644
index 0000000..38efbde
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridProperty.cs
@@ -0,0 +1,18 @@
+namespace ExCSS
+{
+ ///
+ /// The grid shorthand (CSS Grid §7.8): expands to the three grid-template-* longhands plus
+ /// grid-auto-flow / grid-auto-rows / grid-auto-columns.
+ ///
+ internal sealed class GridProperty : ShorthandProperty
+ {
+ private static readonly IValueConverter StyleConverter = Converters.GridConverter.OrGlobalValue();
+
+ internal GridProperty()
+ : base(PropertyNames.Grid)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/GridRowEndProperty.cs b/src/ExCSS/StyleProperties/Grid/GridRowEndProperty.cs
new file mode 100644
index 0000000..7dec7ab
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridRowEndProperty.cs
@@ -0,0 +1,18 @@
+namespace ExCSS
+{
+ ///
+ /// The grid-row-end longhand (CSS Grid §8.3), one edge of a grid item's placement.
+ /// Validated by the shared .
+ ///
+ internal sealed class GridRowEndProperty : Property
+ {
+ private static readonly IValueConverter StyleConverter = Converters.GridLineConverter.OrDefault();
+
+ internal GridRowEndProperty()
+ : base(PropertyNames.GridRowEnd)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/GridRowProperty.cs b/src/ExCSS/StyleProperties/Grid/GridRowProperty.cs
new file mode 100644
index 0000000..288d6bc
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridRowProperty.cs
@@ -0,0 +1,18 @@
+namespace ExCSS
+{
+ ///
+ /// The grid-row shorthand (CSS Grid §8.3.1): <grid-line> [ / <grid-line> ]?,
+ /// expanding to grid-row-start / grid-row-end.
+ ///
+ internal sealed class GridRowProperty : ShorthandProperty
+ {
+ private static readonly IValueConverter StyleConverter = Converters.GridRowConverter.OrGlobalValue();
+
+ internal GridRowProperty()
+ : base(PropertyNames.GridRow)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/GridRowStartProperty.cs b/src/ExCSS/StyleProperties/Grid/GridRowStartProperty.cs
new file mode 100644
index 0000000..b672ac0
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridRowStartProperty.cs
@@ -0,0 +1,18 @@
+namespace ExCSS
+{
+ ///
+ /// The grid-row-start longhand (CSS Grid §8.3), one edge of a grid item's placement.
+ /// Validated by the shared .
+ ///
+ internal sealed class GridRowStartProperty : Property
+ {
+ private static readonly IValueConverter StyleConverter = Converters.GridLineConverter.OrDefault();
+
+ internal GridRowStartProperty()
+ : base(PropertyNames.GridRowStart)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/GridTemplateAreasProperty.cs b/src/ExCSS/StyleProperties/Grid/GridTemplateAreasProperty.cs
new file mode 100644
index 0000000..4724f93
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridTemplateAreasProperty.cs
@@ -0,0 +1,18 @@
+namespace ExCSS
+{
+ ///
+ /// The grid-template-areas property (CSS Grid §7.3). Validated by the shared
+ /// ; the authored text is preserved.
+ ///
+ internal sealed class GridTemplateAreasProperty : Property
+ {
+ private static readonly IValueConverter StyleConverter = new GridTemplateAreasValueConverter().OrDefault();
+
+ internal GridTemplateAreasProperty()
+ : base(PropertyNames.GridTemplateAreas)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/GridTemplateColumnsProperty.cs b/src/ExCSS/StyleProperties/Grid/GridTemplateColumnsProperty.cs
new file mode 100644
index 0000000..29af661
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridTemplateColumnsProperty.cs
@@ -0,0 +1,18 @@
+namespace ExCSS
+{
+ ///
+ /// The grid-template-columns property (CSS Grid Layout Module Level 1/2 §7.2). Validated by the
+ /// shared ; the authored text is preserved.
+ ///
+ internal sealed class GridTemplateColumnsProperty : Property
+ {
+ private static readonly IValueConverter StyleConverter = new GridTemplateValueConverter().OrDefault();
+
+ internal GridTemplateColumnsProperty()
+ : base(PropertyNames.GridTemplateColumns)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/GridTemplateProperty.cs b/src/ExCSS/StyleProperties/Grid/GridTemplateProperty.cs
new file mode 100644
index 0000000..3a8576f
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridTemplateProperty.cs
@@ -0,0 +1,18 @@
+namespace ExCSS
+{
+ ///
+ /// The grid-template shorthand (CSS Grid §7.4): expands to grid-template-rows /
+ /// grid-template-columns / grid-template-areas.
+ ///
+ internal sealed class GridTemplateProperty : ShorthandProperty
+ {
+ private static readonly IValueConverter StyleConverter = Converters.GridTemplateConverter.OrGlobalValue();
+
+ internal GridTemplateProperty()
+ : base(PropertyNames.GridTemplate)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/GridTemplateRowsProperty.cs b/src/ExCSS/StyleProperties/Grid/GridTemplateRowsProperty.cs
new file mode 100644
index 0000000..cbb556d
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/GridTemplateRowsProperty.cs
@@ -0,0 +1,18 @@
+namespace ExCSS
+{
+ ///
+ /// The grid-template-rows property (CSS Grid Layout Module Level 1/2 §7.2). Validated by the
+ /// shared ; the authored text is preserved.
+ ///
+ internal sealed class GridTemplateRowsProperty : Property
+ {
+ private static readonly IValueConverter StyleConverter = new GridTemplateValueConverter().OrDefault();
+
+ internal GridTemplateRowsProperty()
+ : base(PropertyNames.GridTemplateRows)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/JustifyItemsProperty.cs b/src/ExCSS/StyleProperties/Grid/JustifyItemsProperty.cs
new file mode 100644
index 0000000..dfb0d6c
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/JustifyItemsProperty.cs
@@ -0,0 +1,15 @@
+namespace ExCSS
+{
+ /// The justify-items property (CSS Box Alignment): default inline-axis alignment of grid items.
+ internal sealed class JustifyItemsProperty : Property
+ {
+ private static readonly IValueConverter StyleConverter = Converters.JustifyItemsConverter;
+
+ internal JustifyItemsProperty()
+ : base(PropertyNames.JustifyItems)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/JustifySelfProperty.cs b/src/ExCSS/StyleProperties/Grid/JustifySelfProperty.cs
new file mode 100644
index 0000000..89c637c
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/JustifySelfProperty.cs
@@ -0,0 +1,15 @@
+namespace ExCSS
+{
+ /// The justify-self property (CSS Box Alignment): a grid item's own inline-axis alignment.
+ internal sealed class JustifySelfProperty : Property
+ {
+ private static readonly IValueConverter StyleConverter = Converters.JustifySelfConverter;
+
+ internal JustifySelfProperty()
+ : base(PropertyNames.JustifySelf)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/PlaceContentProperty.cs b/src/ExCSS/StyleProperties/Grid/PlaceContentProperty.cs
new file mode 100644
index 0000000..9af0aa2
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/PlaceContentProperty.cs
@@ -0,0 +1,15 @@
+namespace ExCSS
+{
+ /// The place-content shorthand (CSS Box Alignment): <align-content> <justify-content>?.
+ internal sealed class PlaceContentProperty : ShorthandProperty
+ {
+ private static readonly IValueConverter StyleConverter = Converters.PlaceContentConverter.OrGlobalValue();
+
+ internal PlaceContentProperty()
+ : base(PropertyNames.PlaceContent)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/PlaceItemsProperty.cs b/src/ExCSS/StyleProperties/Grid/PlaceItemsProperty.cs
new file mode 100644
index 0000000..85abb88
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/PlaceItemsProperty.cs
@@ -0,0 +1,15 @@
+namespace ExCSS
+{
+ /// The place-items shorthand (CSS Box Alignment): <align-items> <justify-items>?.
+ internal sealed class PlaceItemsProperty : ShorthandProperty
+ {
+ private static readonly IValueConverter StyleConverter = Converters.PlaceItemsConverter.OrGlobalValue();
+
+ internal PlaceItemsProperty()
+ : base(PropertyNames.PlaceItems)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/StyleProperties/Grid/PlaceSelfProperty.cs b/src/ExCSS/StyleProperties/Grid/PlaceSelfProperty.cs
new file mode 100644
index 0000000..5e7ff76
--- /dev/null
+++ b/src/ExCSS/StyleProperties/Grid/PlaceSelfProperty.cs
@@ -0,0 +1,15 @@
+namespace ExCSS
+{
+ /// The place-self shorthand (CSS Box Alignment): <align-self> <justify-self>?.
+ internal sealed class PlaceSelfProperty : ShorthandProperty
+ {
+ private static readonly IValueConverter StyleConverter = Converters.PlaceSelfConverter.OrGlobalValue();
+
+ internal PlaceSelfProperty()
+ : base(PropertyNames.PlaceSelf)
+ {
+ }
+
+ internal override IValueConverter Converter => StyleConverter;
+ }
+}
diff --git a/src/ExCSS/ValueConverters/GridAreaShorthandValueConverter.cs b/src/ExCSS/ValueConverters/GridAreaShorthandValueConverter.cs
new file mode 100644
index 0000000..da5ccf0
--- /dev/null
+++ b/src/ExCSS/ValueConverters/GridAreaShorthandValueConverter.cs
@@ -0,0 +1,41 @@
+using System.Collections.Generic;
+
+namespace ExCSS
+{
+ ///
+ /// The grid-area shorthand: <grid-line> [ / <grid-line> ]{0,3} in the order
+ /// row-start / column-start / row-end / column-end. Implements the CSS Grid §8.3.1 omitted-value copy
+ /// rule — a bare <custom-ident> propagates to the paired/all edges, so grid-area: main
+ /// fills the whole main area, while grid-area: 2 leaves the other edges auto.
+ ///
+ internal sealed class GridAreaShorthandValueConverter : IValueConverter
+ {
+ public IPropertyValue Convert(IEnumerable value)
+ {
+ var c = GridPlacementShorthand.Split(value, maxComponents: 4);
+ if (c is null) return null;
+
+ var rowStart = c[0];
+ // column-start omitted → if row-start is a bare custom-ident, all four take it; else auto.
+ var colStart = c.Count > 1 ? c[1]
+ : rowStart.IsBareCustomIdent ? rowStart : (GridPlacementShorthand.Component?)null;
+ // row-end omitted → copy a bare custom-ident row-start, else auto.
+ var rowEnd = c.Count > 2 ? c[2]
+ : rowStart.IsBareCustomIdent ? rowStart : (GridPlacementShorthand.Component?)null;
+ // column-end omitted → copy a bare custom-ident column-start, else auto.
+ var colEnd = c.Count > 3 ? c[3]
+ : colStart is { IsBareCustomIdent: true } ? colStart : (GridPlacementShorthand.Component?)null;
+
+ var map = new Dictionary>
+ {
+ [PropertyNames.GridRowStart] = rowStart.Tokens,
+ [PropertyNames.GridColumnStart] = GridPlacementShorthand.Or(colStart),
+ [PropertyNames.GridRowEnd] = GridPlacementShorthand.Or(rowEnd),
+ [PropertyNames.GridColumnEnd] = GridPlacementShorthand.Or(colEnd),
+ };
+ return new MappedShorthandValue(map, value);
+ }
+
+ public IPropertyValue Construct(Property[] properties) => properties.Guard();
+ }
+}
diff --git a/src/ExCSS/ValueConverters/GridAutoFlowValueConverter.cs b/src/ExCSS/ValueConverters/GridAutoFlowValueConverter.cs
new file mode 100644
index 0000000..d505056
--- /dev/null
+++ b/src/ExCSS/ValueConverters/GridAutoFlowValueConverter.cs
@@ -0,0 +1,57 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ExCSS
+{
+ ///
+ /// Validates a grid-auto-flow value (CSS Grid §7.7): [ row | column ] || dense — one of
+ /// row/column optionally combined with dense, in either order. The authored text is
+ /// preserved.
+ ///
+ internal sealed class GridAutoFlowValueConverter : IValueConverter
+ {
+ public IPropertyValue Convert(IEnumerable value)
+ {
+ var tokens = value.ToArray();
+ var idents = tokens.Where(t => t.Type != TokenType.Whitespace).ToArray();
+ if (idents.Length is < 1 or > 2 || idents.Any(t => t.Type != TokenType.Ident))
+ return null;
+
+ var hasAxis = false;
+ var hasDense = false;
+ foreach (var t in idents)
+ {
+ if (t.Data.Isi(Keywords.Row) || t.Data.Isi(Keywords.Column))
+ {
+ if (hasAxis) return null; // row and column are mutually exclusive
+ hasAxis = true;
+ }
+ else if (t.Data.Isi(Keywords.Dense))
+ {
+ if (hasDense) return null;
+ hasDense = true;
+ }
+ else
+ {
+ return null;
+ }
+ }
+
+ return new GridAutoFlowValue(tokens);
+ }
+
+ public IPropertyValue Construct(Property[] properties) => properties.Guard();
+
+ private sealed class GridAutoFlowValue : IPropertyValue
+ {
+ public GridAutoFlowValue(IEnumerable tokens)
+ {
+ Original = new TokenValue(tokens);
+ }
+
+ public string CssText => Original.Text;
+ public TokenValue Original { get; }
+ public TokenValue ExtractFor(string name) => Original;
+ }
+ }
+}
diff --git a/src/ExCSS/ValueConverters/GridAutoTracksValueConverter.cs b/src/ExCSS/ValueConverters/GridAutoTracksValueConverter.cs
new file mode 100644
index 0000000..c02bc71
--- /dev/null
+++ b/src/ExCSS/ValueConverters/GridAutoTracksValueConverter.cs
@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ExCSS
+{
+ ///
+ /// Validates a grid-auto-columns/grid-auto-rows value: a <track-size>+ list
+ /// (no repeat()) via the shared . The authored text is preserved.
+ ///
+ internal sealed class GridAutoTracksValueConverter : IValueConverter
+ {
+ public IPropertyValue Convert(IEnumerable value)
+ {
+ var tokens = value.ToArray();
+ return GridTrackListGrammar.TryParseTrackSizeList(tokens.Where(t => t.Type != TokenType.Whitespace).ToArray()) is null
+ ? null
+ : new GridAutoTracksValue(tokens);
+ }
+
+ public IPropertyValue Construct(Property[] properties) => properties.Guard();
+
+ private sealed class GridAutoTracksValue : IPropertyValue
+ {
+ public GridAutoTracksValue(IEnumerable tokens)
+ {
+ Original = new TokenValue(tokens);
+ }
+
+ public string CssText => Original.Text;
+ public TokenValue Original { get; }
+ public TokenValue ExtractFor(string name) => Original;
+ }
+ }
+}
diff --git a/src/ExCSS/ValueConverters/GridColumnRowShorthandValueConverter.cs b/src/ExCSS/ValueConverters/GridColumnRowShorthandValueConverter.cs
new file mode 100644
index 0000000..4dbdff4
--- /dev/null
+++ b/src/ExCSS/ValueConverters/GridColumnRowShorthandValueConverter.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+
+namespace ExCSS
+{
+ ///
+ /// The grid-column / grid-row shorthand: <grid-line> [ / <grid-line> ]?
+ /// expanding to the axis's -start/-end longhands. Implements the CSS Grid §8.3.1
+ /// omitted-value copy rule that the generic WithOrder(...).Option() DSL cannot: when the end is
+ /// omitted, it copies a bare <custom-ident> start (so grid-column: main spans the
+ /// whole main area) but leaves a line number / span as auto.
+ ///
+ internal sealed class GridColumnRowShorthandValueConverter : IValueConverter
+ {
+ private readonly string _startName;
+ private readonly string _endName;
+
+ public GridColumnRowShorthandValueConverter(string startName, string endName)
+ {
+ _startName = startName;
+ _endName = endName;
+ }
+
+ public IPropertyValue Convert(IEnumerable value)
+ {
+ var components = GridPlacementShorthand.Split(value, maxComponents: 2);
+ if (components is null) return null;
+
+ var start = components[0];
+ // end omitted → copy a bare custom-ident start, else auto.
+ var end = components.Count > 1 ? components[1]
+ : start.IsBareCustomIdent ? start : (GridPlacementShorthand.Component?)null;
+
+ var map = new Dictionary>
+ {
+ [_startName] = start.Tokens,
+ [_endName] = GridPlacementShorthand.Or(end),
+ };
+ return new MappedShorthandValue(map, value);
+ }
+
+ public IPropertyValue Construct(Property[] properties) => properties.Guard();
+ }
+}
diff --git a/src/ExCSS/ValueConverters/GridLineValueConverter.cs b/src/ExCSS/ValueConverters/GridLineValueConverter.cs
new file mode 100644
index 0000000..a9c6fcb
--- /dev/null
+++ b/src/ExCSS/ValueConverters/GridLineValueConverter.cs
@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ExCSS
+{
+ ///
+ /// Validates a single grid <grid-line> (grid-column-start/-end,
+ /// grid-row-start/-end) through the shared — auto, an
+ /// integer line, or span N. The authored text is preserved.
+ ///
+ internal sealed class GridLineValueConverter : IValueConverter
+ {
+ public IPropertyValue Convert(IEnumerable value)
+ {
+ var tokens = value.ToArray();
+ return GridLineGrammar.TryParse(tokens.Where(t => t.Type != TokenType.Whitespace).ToArray()) is null
+ ? null
+ : new GridLineValue(tokens);
+ }
+
+ public IPropertyValue Construct(Property[] properties)
+ {
+ return properties.Guard();
+ }
+
+ private sealed class GridLineValue : IPropertyValue
+ {
+ public GridLineValue(IEnumerable tokens)
+ {
+ Original = new TokenValue(tokens);
+ }
+
+ public string CssText => Original.Text;
+
+ public TokenValue Original { get; }
+
+ public TokenValue ExtractFor(string name) => Original;
+ }
+ }
+}
diff --git a/src/ExCSS/ValueConverters/GridPlacementShorthand.cs b/src/ExCSS/ValueConverters/GridPlacementShorthand.cs
new file mode 100644
index 0000000..2c636f7
--- /dev/null
+++ b/src/ExCSS/ValueConverters/GridPlacementShorthand.cs
@@ -0,0 +1,86 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ExCSS
+{
+ /// Shared helpers for the grid placement shorthands (grid-column/grid-row/
+ /// grid-area): splitting a value on / into <grid-line> components, validating
+ /// each, and building the omitted-value copy result (CSS Grid §8.3.1).
+ internal static class GridPlacementShorthand
+ {
+ /// A parsed <grid-line> component: its authored tokens and whether it is a bare
+ /// <custom-ident> (the only form the omitted-value copy rule propagates).
+ internal struct Component
+ {
+ public IReadOnlyList Tokens { get; set; }
+ public bool IsBareCustomIdent { get; set; }
+ }
+
+ private static readonly Token AutoToken = new(TokenType.Ident, Keywords.Auto, TextPosition.Empty);
+
+ /// Splits the value on top-level / delimiters into <grid-line> components,
+ /// validating each via . Returns null on any invalid component or an
+ /// out-of-range component count.
+ internal static List Split(IEnumerable value, int maxComponents)
+ {
+ var groups = new List> { new List() };
+ foreach (var token in value)
+ {
+ if (token.Type == TokenType.Whitespace) { groups[groups.Count - 1].Add(token); continue; }
+ if (token.Type == TokenType.Delim && token.Data == "/") { groups.Add(new List()); continue; }
+ groups[groups.Count - 1].Add(token);
+ }
+
+ var components = new List(groups.Count);
+ foreach (var group in groups)
+ {
+ var significant = group.Where(t => t.Type != TokenType.Whitespace).ToArray();
+ var parsed = GridLineGrammar.TryParse(significant);
+ if (parsed is null) return null;
+ // Preserve internal whitespace in the stored tokens (only trim the ends) so a two-token
+ // component like "span 2" round-trips as "span 2", not "span2" (which would re-lex as one
+ // ident). A bare is a single ident token that parsed to a named (non-Nth) line.
+ components.Add(new Component
+ {
+ Tokens = Trim(group),
+ IsBareCustomIdent = significant.Length == 1 && significant[0].Type == TokenType.Ident && parsed.Name != null
+ });
+ }
+
+ return components.Count >= 1 && components.Count <= maxComponents ? components : null;
+ }
+
+ /// The tokens for a longhand: the specified component's tokens, or auto when omitted.
+ internal static IReadOnlyList Or(Component? component) =>
+ component is { } c ? c.Tokens : new[] { AutoToken };
+
+ /// Drops leading and trailing whitespace tokens, keeping internal whitespace.
+ private static IReadOnlyList Trim(List group)
+ {
+ var start = 0;
+ var end = group.Count - 1;
+ while (start <= end && group[start].Type == TokenType.Whitespace) start++;
+ while (end >= start && group[end].Type == TokenType.Whitespace) end--;
+ return group.GetRange(start, end - start + 1);
+ }
+ }
+
+ /// A shorthand that emits a precomputed token stream for each of
+ /// its longhands (via ), used by the grid placement shorthands.
+ internal sealed class MappedShorthandValue : IPropertyValue
+ {
+ private readonly IReadOnlyDictionary> _map;
+
+ public MappedShorthandValue(IReadOnlyDictionary> map, IEnumerable original)
+ {
+ _map = map;
+ Original = new TokenValue(original);
+ }
+
+ public string CssText => Original.Text;
+ public TokenValue Original { get; }
+
+ public TokenValue ExtractFor(string name) =>
+ _map.TryGetValue(name, out var tokens) ? new TokenValue(tokens) : TokenValue.Empty;
+ }
+}
diff --git a/src/ExCSS/ValueConverters/GridShorthandValueConverter.cs b/src/ExCSS/ValueConverters/GridShorthandValueConverter.cs
new file mode 100644
index 0000000..8933cca
--- /dev/null
+++ b/src/ExCSS/ValueConverters/GridShorthandValueConverter.cs
@@ -0,0 +1,76 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ExCSS
+{
+ ///
+ /// The grid shorthand (CSS Grid §7.8): either a <grid-template>, or one of the two
+ /// auto-flow forms
+ /// <grid-template-rows> / [ auto-flow && dense? ] <grid-auto-columns>? and
+ /// [ auto-flow && dense? ] <grid-auto-rows>? / <grid-template-columns>. Expands to
+ /// the three grid-template-* longhands plus grid-auto-flow/-rows/-columns;
+ /// every longhand it doesn't set is reset to its initial value (an absent slice — §7.8).
+ ///
+ internal sealed class GridShorthandValueConverter : IValueConverter
+ {
+ public IPropertyValue Convert(IEnumerable value)
+ {
+ // NB: the domain-specific IEnumerable.ToList() extension splits on commas; materialize the
+ // flat token list explicitly instead.
+ var tokens = new List(value);
+
+ // Form 1: . The three template longhands are set; grid-auto-flow/-rows/-columns
+ // are omitted here so they reset to their initial values (row / auto / auto).
+ if (GridTemplateShorthand.TryParseTemplate(tokens, out var rows, out var columns, out var areas))
+ {
+ var templateMap = new Dictionary>();
+ if (rows is not null) templateMap[PropertyNames.GridTemplateRows] = rows;
+ if (columns is not null) templateMap[PropertyNames.GridTemplateColumns] = columns;
+ if (areas is not null) templateMap[PropertyNames.GridTemplateAreas] = areas;
+ return new MappedShorthandValue(templateMap, tokens);
+ }
+
+ // Forms 2/3: an auto-flow form — requires exactly one top-level '/'.
+ var groups = GridTemplateShorthand.SplitTopLevelSlash(tokens);
+ if (groups.Count != 2) return null;
+
+ var leftRaw = groups[0];
+ var rightRaw = groups[1];
+ var left = GridTemplateShorthand.Significant(leftRaw);
+ var right = GridTemplateShorthand.Significant(rightRaw);
+
+ var leftHasFlow = left.Any(t => t.Type == TokenType.Ident && t.Data.Isi(Keywords.AutoFlow));
+ var rightHasFlow = right.Any(t => t.Type == TokenType.Ident && t.Data.Isi(Keywords.AutoFlow));
+ if (leftHasFlow == rightHasFlow) return null; // exactly one side carries auto-flow
+
+ var map = new Dictionary>();
+
+ if (rightHasFlow)
+ {
+ // / [ auto-flow && dense? ] ?
+ var rowsSlice = GridTemplateShorthand.TryTemplateAxis(leftRaw);
+ if (rowsSlice is null) return null;
+ if (!GridTemplateShorthand.TryParseAutoFlowSide(rightRaw, out var dense, out var autoColumns)) return null;
+
+ map[PropertyNames.GridTemplateRows] = rowsSlice;
+ map[PropertyNames.GridAutoFlow] = GridTemplateShorthand.BuildAutoFlow(column: true, dense);
+ if (autoColumns is not null) map[PropertyNames.GridAutoColumns] = autoColumns;
+ }
+ else
+ {
+ // [ auto-flow && dense? ] ? /
+ var columnsSlice = GridTemplateShorthand.TryTemplateAxis(rightRaw);
+ if (columnsSlice is null) return null;
+ if (!GridTemplateShorthand.TryParseAutoFlowSide(leftRaw, out var dense, out var autoRows)) return null;
+
+ map[PropertyNames.GridTemplateColumns] = columnsSlice;
+ map[PropertyNames.GridAutoFlow] = GridTemplateShorthand.BuildAutoFlow(column: false, dense);
+ if (autoRows is not null) map[PropertyNames.GridAutoRows] = autoRows;
+ }
+
+ return new MappedShorthandValue(map, tokens);
+ }
+
+ public IPropertyValue Construct(Property[] properties) => properties.Guard();
+ }
+}
diff --git a/src/ExCSS/ValueConverters/GridTemplateAreasValueConverter.cs b/src/ExCSS/ValueConverters/GridTemplateAreasValueConverter.cs
new file mode 100644
index 0000000..693f12e
--- /dev/null
+++ b/src/ExCSS/ValueConverters/GridTemplateAreasValueConverter.cs
@@ -0,0 +1,39 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ExCSS
+{
+ ///
+ /// Validates a grid-template-areas value: the keyword none or a rectangular grid of quoted
+ /// strings accepted by the shared (equal columns per row, each
+ /// named area a single filled rectangle). The authored text is preserved.
+ ///
+ internal sealed class GridTemplateAreasValueConverter : IValueConverter
+ {
+ public IPropertyValue Convert(IEnumerable value)
+ {
+ var tokens = value.Where(t => t.Type != TokenType.Whitespace).ToArray();
+
+ var isNone = tokens.Length == 1 && tokens[0].Type == TokenType.Ident && tokens[0].Data.Isi(Keywords.None);
+
+ if (!isNone && GridTemplateAreasGrammar.TryParse(tokens) is null)
+ return null;
+
+ return new GridTemplateAreasValue(value);
+ }
+
+ public IPropertyValue Construct(Property[] properties) => properties.Guard();
+
+ private sealed class GridTemplateAreasValue : IPropertyValue
+ {
+ public GridTemplateAreasValue(IEnumerable tokens)
+ {
+ Original = new TokenValue(tokens);
+ }
+
+ public string CssText => Original.Text;
+ public TokenValue Original { get; }
+ public TokenValue ExtractFor(string name) => Original;
+ }
+ }
+}
diff --git a/src/ExCSS/ValueConverters/GridTemplateShorthand.cs b/src/ExCSS/ValueConverters/GridTemplateShorthand.cs
new file mode 100644
index 0000000..4e706ea
--- /dev/null
+++ b/src/ExCSS/ValueConverters/GridTemplateShorthand.cs
@@ -0,0 +1,263 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ExCSS
+{
+ ///
+ /// Shared parsing for the CSS Grid mega-shorthands grid-template (CSS Grid 2 §7.4) and
+ /// grid (§7.8): splitting the value on the top-level /, running the areas-form row scanner,
+ /// and validating each partitioned slice against the existing longhand grammars
+ /// (/). The two converters stay
+ /// thin over this helper.
+ ///
+ internal static class GridTemplateShorthand
+ {
+ internal static readonly Token AutoToken = new(TokenType.Ident, Keywords.Auto, TextPosition.Empty);
+ private static readonly Token NoneToken = new(TokenType.Ident, Keywords.None, TextPosition.Empty);
+
+ ///
+ /// Splits the flat token stream on top-level / delimiters, preserving each group's
+ /// whitespace. No bracket/paren depth counter is needed: functions are single
+ /// s (their args nested) and [name] groups hold only idents, so a
+ /// / never appears nested at the flat level (same reasoning as
+ /// ).
+ ///
+ internal static List> SplitTopLevelSlash(IEnumerable value)
+ {
+ var groups = new List> { new List() };
+
+ foreach (var token in value)
+ {
+ if (token.Type == TokenType.Delim && token.Data == "/") groups.Add(new List());
+ else groups[groups.Count - 1].Add(token);
+ }
+
+ return groups;
+ }
+
+ internal static Token[] Significant(IEnumerable tokens) =>
+ tokens.Where(t => t.Type != TokenType.Whitespace).ToArray();
+
+ ///
+ /// Validates one axis of the <rows> / <columns> form — a <track-list> or
+ /// the keyword none (both accepted by the grid-template-rows/-columns longhands) —
+ /// returning the edge-trimmed slice, or if invalid.
+ ///
+ internal static IReadOnlyList TryTemplateAxis(IReadOnlyList raw)
+ {
+ var significant = Significant(raw);
+ if (significant.Length == 0) return null;
+ if (significant.Length == 1 && significant[0].Type == TokenType.Ident && significant[0].Data.Isi(Keywords.None))
+ return Trim(raw);
+ return GridTrackListGrammar.TryParse(raw) is not null ? Trim(raw) : null;
+ }
+
+ private static bool ContainsRepeat(IEnumerable significant) =>
+ significant.Any(t => t is FunctionToken fn && fn.Data.Isi(FunctionNames.Repeat));
+
+ /// Drops leading/trailing whitespace tokens, keeping internal whitespace.
+ internal static IReadOnlyList Trim(IReadOnlyList tokens)
+ {
+ var start = 0;
+ var end = tokens.Count - 1;
+ while (start <= end && tokens[start].Type == TokenType.Whitespace) start++;
+ while (end >= start && tokens[end].Type == TokenType.Whitespace) end--;
+
+ var result = new List(end - start + 1);
+ for (var i = start; i <= end; i++) result.Add(tokens[i]);
+ return result;
+ }
+
+ /// The grid-auto-flow token slice for the grid auto-flow forms.
+ internal static IReadOnlyList BuildAutoFlow(bool column, bool dense)
+ {
+ var flow = new List { new Token(TokenType.Ident, column ? Keywords.Column : Keywords.Row, TextPosition.Empty) };
+ if (dense)
+ {
+ flow.Add(Token.Whitespace);
+ flow.Add(new Token(TokenType.Ident, Keywords.Dense, TextPosition.Empty));
+ }
+ return flow;
+ }
+
+ ///
+ /// Parses a grid-template value (§7.4) into the three longhand slices. A
+ /// out-slice means "omit it" (the longhand resets to its initial value). Returns
+ /// for an invalid value (the whole declaration is then dropped).
+ ///
+ internal static bool TryParseTemplate(IEnumerable value,
+ out IReadOnlyList rows, out IReadOnlyList columns, out IReadOnlyList areas)
+ {
+ rows = columns = areas = null;
+
+ var groups = SplitTopLevelSlash(value);
+ if (groups.Count > 2) return false; // at most one top-level '/'
+
+ var leftRaw = groups[0];
+ var rightRaw = groups.Count == 2 ? groups[1] : null;
+ var left = Significant(leftRaw);
+ var right = rightRaw is null ? null : Significant(rightRaw);
+
+ // grid-template: none → all three longhands none.
+ if (right is null && left.Length == 1 && left[0].Type == TokenType.Ident && left[0].Data.Isi(Keywords.None))
+ {
+ rows = columns = areas = new[] { NoneToken };
+ return true;
+ }
+
+ if (left.Length == 0) return false;
+
+ if (left.Any(t => t.Type == TokenType.String))
+ {
+ // Areas form: [ ? ? ? ]+ [ / ]?
+ if (!TryScanAreasForm(left, out var rowsTokens, out var areaTokens)) return false;
+ if (GridTemplateAreasGrammar.TryParse(areaTokens) is null) return false;
+ if (GridTrackListGrammar.TryParse(rowsTokens) is null) return false;
+
+ rows = rowsTokens;
+ areas = areaTokens;
+
+ if (rightRaw is not null)
+ {
+ // The trailing columns are an — a with no repeat().
+ if (right.Length == 0 || ContainsRepeat(right) || GridTrackListGrammar.TryParse(rightRaw) is null)
+ return false;
+ columns = Trim(rightRaw);
+ }
+
+ return true;
+ }
+
+ // rows / columns form: requires the slash; each side is a or `none`.
+ if (rightRaw is null) return false;
+ var rowsSlice = TryTemplateAxis(leftRaw);
+ var columnsSlice = TryTemplateAxis(rightRaw);
+ if (rowsSlice is null || columnsSlice is null) return false;
+
+ rows = rowsSlice;
+ columns = columnsSlice;
+ return true;
+ }
+
+ ///
+ /// Scans the areas-form left side, synthesizing the grid-template-rows track list
+ /// () and collecting the ordered <string> area tokens
+ /// (). Every emitted token is whitespace-separated so both round-trip
+ /// serialization (via ToText) and re-parsing are valid.
+ ///
+ private static bool TryScanAreasForm(Token[] toks, out List rowsOut, out List areaStrings)
+ {
+ rowsOut = new List();
+ areaStrings = new List();
+ var sawString = false;
+
+ var i = 0;
+ while (i < toks.Length)
+ {
+ var t = toks[i];
+
+ if (t.Type == TokenType.SquareBracketOpen)
+ {
+ // Copy the [ name … ] line-name group (idents only) at the current line position, kept
+ // compact as `[a b]` (edge spaces omitted, single space between names).
+ if (rowsOut.Count > 0) rowsOut.Add(Token.Whitespace);
+ rowsOut.Add(t);
+ i++;
+ var firstName = true;
+ while (i < toks.Length && toks[i].Type != TokenType.SquareBracketClose)
+ {
+ if (toks[i].Type != TokenType.Ident) return false;
+ if (!firstName) rowsOut.Add(Token.Whitespace);
+ rowsOut.Add(toks[i]);
+ firstName = false;
+ i++;
+ }
+ if (i >= toks.Length) return false; // unclosed '['
+ rowsOut.Add(toks[i]); // ']'
+ i++;
+ continue;
+ }
+
+ if (t.Type == TokenType.String)
+ {
+ Emit(areaStrings, t);
+ sawString = true;
+ i++;
+
+ // Optional single-token for this row (else the row track is `auto`).
+ if (i < toks.Length && toks[i].Type != TokenType.SquareBracketOpen && toks[i].Type != TokenType.String)
+ {
+ if (GridTrackListGrammar.TryParseTrackSizeList(new[] { toks[i] }) is null) return false;
+ Emit(rowsOut, toks[i]);
+ i++;
+ }
+ else
+ {
+ Emit(rowsOut, AutoToken);
+ }
+
+ continue;
+ }
+
+ // A track-size or stray token with no preceding string at this position is invalid.
+ return false;
+ }
+
+ return sawString;
+ }
+
+ private static void Emit(List tokens, Token token)
+ {
+ if (tokens.Count > 0) tokens.Add(Token.Whitespace);
+ tokens.Add(token);
+ }
+
+ ///
+ /// Parses the [ auto-flow && dense? ] <track-size>* side of a grid auto-flow
+ /// form. Returns when auto-flow is absent or the value is malformed;
+ /// is null when the optional track-size list is omitted (reset to initial).
+ ///
+ internal static bool TryParseAutoFlowSide(IReadOnlyList raw, out bool dense, out IReadOnlyList autoTracks)
+ {
+ dense = false;
+ autoTracks = null;
+ var hasAutoFlow = false;
+
+ var i = 0;
+ while (i < raw.Count)
+ {
+ var t = raw[i];
+ if (t.Type == TokenType.Whitespace) { i++; continue; }
+ if (t.Type == TokenType.Ident && t.Data.Isi(Keywords.AutoFlow))
+ {
+ if (hasAutoFlow) return false;
+ hasAutoFlow = true;
+ i++;
+ continue;
+ }
+ if (t.Type == TokenType.Ident && t.Data.Isi(Keywords.Dense))
+ {
+ if (dense) return false;
+ dense = true;
+ i++;
+ continue;
+ }
+ break;
+ }
+
+ if (!hasAutoFlow) return false;
+
+ var rest = new List();
+ for (; i < raw.Count; i++) rest.Add(raw[i]);
+ var trimmed = Trim(rest);
+
+ if (trimmed.Count > 0)
+ {
+ if (GridTrackListGrammar.TryParseTrackSizeList(trimmed) is null) return false;
+ autoTracks = trimmed;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/src/ExCSS/ValueConverters/GridTemplateShorthandValueConverter.cs b/src/ExCSS/ValueConverters/GridTemplateShorthandValueConverter.cs
new file mode 100644
index 0000000..b091ee7
--- /dev/null
+++ b/src/ExCSS/ValueConverters/GridTemplateShorthandValueConverter.cs
@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+
+namespace ExCSS
+{
+ ///
+ /// The grid-template shorthand (CSS Grid §7.4), expanding to grid-template-rows /
+ /// grid-template-columns / grid-template-areas. An omitted axis is reset to its initial
+ /// value (none) by when its slice is absent.
+ ///
+ internal sealed class GridTemplateShorthandValueConverter : IValueConverter
+ {
+ public IPropertyValue Convert(IEnumerable value)
+ {
+ if (!GridTemplateShorthand.TryParseTemplate(value, out var rows, out var columns, out var areas))
+ return null;
+
+ var map = new Dictionary>();
+ if (rows is not null) map[PropertyNames.GridTemplateRows] = rows;
+ if (columns is not null) map[PropertyNames.GridTemplateColumns] = columns;
+ if (areas is not null) map[PropertyNames.GridTemplateAreas] = areas;
+
+ return new MappedShorthandValue(map, value);
+ }
+
+ public IPropertyValue Construct(Property[] properties) => properties.Guard();
+ }
+}
diff --git a/src/ExCSS/ValueConverters/GridTemplateValueConverter.cs b/src/ExCSS/ValueConverters/GridTemplateValueConverter.cs
new file mode 100644
index 0000000..ff08a21
--- /dev/null
+++ b/src/ExCSS/ValueConverters/GridTemplateValueConverter.cs
@@ -0,0 +1,47 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ExCSS
+{
+ ///
+ /// Validates a grid-template-columns/grid-template-rows value: accepts the keyword
+ /// none or any <track-list> the shared recognizes
+ /// (lengths, %, fr, auto, min-content/max-content, minmax(),
+ /// fit-content(), repeat()), rejecting everything else so an invalid template is dropped
+ /// at parse time. The authored text is preserved verbatim (mirrors
+ /// ).
+ ///
+ internal sealed class GridTemplateValueConverter : IValueConverter
+ {
+ public IPropertyValue Convert(IEnumerable value)
+ {
+ var tokens = value.Where(t => t.Type != TokenType.Whitespace).ToArray();
+
+ var isNone = tokens.Length == 1 && tokens[0].Type == TokenType.Ident && tokens[0].Data.Isi(Keywords.None);
+
+ if (!isNone && GridTrackListGrammar.TryParse(tokens) is null)
+ return null;
+
+ return new GridTemplateValue(value);
+ }
+
+ public IPropertyValue Construct(Property[] properties)
+ {
+ return properties.Guard();
+ }
+
+ private sealed class GridTemplateValue : IPropertyValue
+ {
+ public GridTemplateValue(IEnumerable tokens)
+ {
+ Original = new TokenValue(tokens);
+ }
+
+ public string CssText => Original.Text;
+
+ public TokenValue Original { get; }
+
+ public TokenValue ExtractFor(string name) => Original;
+ }
+ }
+}