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