Skip to content

Commit 5556bcd

Browse files
Add support for @layer declarations ending with semicolon (#1624)
1 parent 66a42ab commit 5556bcd

5 files changed

Lines changed: 430 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ Please also have a look at our
2020

2121
### Fixed
2222

23+
- Parse semicolon-terminated `@layer` statements (and other statement at-rules)
24+
instead of consuming until the next `{` (#1624)
25+
2326
### Documentation
2427

2528
## 9.4.0: Deprecations and bugfixes

src/CSSList/CSSList.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Sabberworm\CSS\Position\Position;
1515
use Sabberworm\CSS\Position\Positionable;
1616
use Sabberworm\CSS\Property\AtRule;
17+
use Sabberworm\CSS\Property\AtRuleStatement;
1718
use Sabberworm\CSS\Property\Charset;
1819
use Sabberworm\CSS\Property\CSSNamespace;
1920
use Sabberworm\CSS\Property\Import;
@@ -210,8 +211,13 @@ private static function parseAtRule(ParserState $parserState): ?CSSListItem
210211
}
211212
return new CSSNamespace($url, $prefix, $identifierLineNumber);
212213
} else {
213-
// Unknown other at rule (font-face or such)
214-
$arguments = \trim($parserState->consumeUntil('{', false, true));
214+
// Unknown other at rule (font-face, @layer, or such)
215+
$arguments = \trim($parserState->consumeUntil(['{', ';'], false, false));
216+
if ($parserState->comes(';')) {
217+
$parserState->consume(';');
218+
return new AtRuleStatement($identifier, $arguments, $identifierLineNumber);
219+
}
220+
$parserState->consume('{');
215221
if (\substr_count($arguments, '(') !== \substr_count($arguments, ')')) {
216222
if ($parserState->getSettings()->usesLenientParsing()) {
217223
return null;

src/Property/AtRuleStatement.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sabberworm\CSS\Property;
6+
7+
use Sabberworm\CSS\Comment\CommentContainer;
8+
use Sabberworm\CSS\OutputFormat;
9+
use Sabberworm\CSS\Position\Position;
10+
use Sabberworm\CSS\Position\Positionable;
11+
use Sabberworm\CSS\ShortClassNameProvider;
12+
13+
/**
14+
* A generic semicolon-terminated at-rule, such as `@layer reset;` or `@layer base, components;`.
15+
*
16+
* Block at-rules (`@media`, `@layer theme { … }`, `@font-face`, …) are represented by
17+
* `AtRuleBlockList` or `AtRuleSet` instead.
18+
*/
19+
class AtRuleStatement implements AtRule, Positionable
20+
{
21+
use CommentContainer;
22+
use Position;
23+
use ShortClassNameProvider;
24+
25+
/**
26+
* @var non-empty-string
27+
*/
28+
private $type;
29+
30+
/**
31+
* @var string
32+
*/
33+
private $arguments;
34+
35+
/**
36+
* @param non-empty-string $type
37+
* @param int<1, max>|null $lineNumber
38+
*/
39+
public function __construct(string $type, string $arguments = '', ?int $lineNumber = null)
40+
{
41+
$this->type = $type;
42+
$this->arguments = $arguments;
43+
$this->setPosition($lineNumber);
44+
}
45+
46+
/**
47+
* @return non-empty-string
48+
*/
49+
public function atRuleName(): string
50+
{
51+
return $this->type;
52+
}
53+
54+
public function atRuleArgs(): string
55+
{
56+
return $this->arguments;
57+
}
58+
59+
/**
60+
* @return non-empty-string
61+
*/
62+
public function render(OutputFormat $outputFormat): string
63+
{
64+
$formatter = $outputFormat->getFormatter();
65+
$result = $formatter->comments($this);
66+
$arguments = $this->arguments;
67+
if ($arguments !== '') {
68+
$arguments = ' ' . $arguments;
69+
}
70+
$result .= "@{$this->type}$arguments;";
71+
return $result;
72+
}
73+
74+
/**
75+
* @return array<string, bool|int|float|string|array<mixed>|null>
76+
*
77+
* @internal
78+
*/
79+
public function getArrayRepresentation(): array
80+
{
81+
return [
82+
'class' => $this->getShortClassName(),
83+
'atRuleName' => $this->type,
84+
'arguments' => $this->arguments,
85+
];
86+
}
87+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sabberworm\CSS\Tests\Functional\Property;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Sabberworm\CSS\CSSList\AtRuleBlockList;
9+
use Sabberworm\CSS\Parser;
10+
use Sabberworm\CSS\Property\AtRuleStatement;
11+
use Sabberworm\CSS\RuleSet\AtRuleSet;
12+
use Sabberworm\CSS\Settings;
13+
14+
/**
15+
* @covers \Sabberworm\CSS\CSSList\CSSList
16+
* @covers \Sabberworm\CSS\Property\AtRuleStatement
17+
*/
18+
final class AtRuleStatementTest extends TestCase
19+
{
20+
/**
21+
* @return array<non-empty-string, array{0: non-empty-string, 1: non-empty-string, 2: string}>
22+
*/
23+
public static function provideAtRuleStatementParsingData(): array
24+
{
25+
return [
26+
'layer with single name' => [
27+
'@layer reset;',
28+
'layer',
29+
'reset',
30+
],
31+
'layer with multiple names' => [
32+
'@layer base, components;',
33+
'layer',
34+
'base, components',
35+
],
36+
'custom statement at-rule' => [
37+
'@custom foo;',
38+
'custom',
39+
'foo',
40+
],
41+
];
42+
}
43+
44+
/**
45+
* @test
46+
*
47+
* @param non-empty-string $css
48+
* @param non-empty-string $expectedName
49+
*
50+
* @dataProvider provideAtRuleStatementParsingData
51+
*/
52+
public function parsesAtRuleStatement(string $css, string $expectedName, string $expectedArgs): void
53+
{
54+
$contents = (new Parser($css))->parse()->getContents();
55+
$atRuleStatement = $contents[0];
56+
57+
self::assertInstanceOf(AtRuleStatement::class, $atRuleStatement);
58+
self::assertSame($expectedName, $atRuleStatement->atRuleName());
59+
self::assertSame($expectedArgs, $atRuleStatement->atRuleArgs());
60+
}
61+
62+
/**
63+
* @test
64+
*
65+
* @param non-empty-string $css
66+
*
67+
* @dataProvider provideAtRuleStatementParsingData
68+
*/
69+
public function parsesAtRuleStatementInStrictMode(string $css): void
70+
{
71+
$contents = (new Parser($css, Settings::create()->beStrict()))->parse()->getContents();
72+
73+
self::assertNotEmpty($contents, 'Failing CSS: `' . $css . '`');
74+
self::assertInstanceOf(AtRuleStatement::class, $contents[0]);
75+
}
76+
77+
/**
78+
* @test
79+
*/
80+
public function doesNotConsumeFollowingRulesWhenParsingLayerStatements(): void
81+
{
82+
$css = "@layer reset;\n"
83+
. "@layer base, components;\n"
84+
. "@property --tw-scale-x {\n"
85+
. " syntax: \"*\";\n"
86+
. " inherits: false;\n"
87+
. " initial-value: 1;\n"
88+
. "}\n"
89+
. "@property --tw-scale-y {\n"
90+
. " syntax: \"*\";\n"
91+
. " inherits: false;\n"
92+
. " initial-value: 1;\n"
93+
. "}\n";
94+
95+
$contents = (new Parser($css))->parse()->getContents();
96+
97+
self::assertCount(4, $contents);
98+
self::assertInstanceOf(AtRuleStatement::class, $contents[0]);
99+
self::assertSame('reset', $contents[0]->atRuleArgs());
100+
self::assertInstanceOf(AtRuleStatement::class, $contents[1]);
101+
self::assertSame('base, components', $contents[1]->atRuleArgs());
102+
self::assertInstanceOf(AtRuleSet::class, $contents[2]);
103+
self::assertSame('property', $contents[2]->atRuleName());
104+
self::assertInstanceOf(AtRuleSet::class, $contents[3]);
105+
self::assertSame('property', $contents[3]->atRuleName());
106+
}
107+
108+
/**
109+
* @test
110+
*/
111+
public function parsesLayerStatementThenLayerBlockInTheSameDocument(): void
112+
{
113+
$css = '@layer reset; @layer theme { .button { color: blue; } }';
114+
115+
$contents = (new Parser($css))->parse()->getContents();
116+
117+
self::assertCount(2, $contents);
118+
self::assertInstanceOf(AtRuleStatement::class, $contents[0]);
119+
self::assertSame('reset', $contents[0]->atRuleArgs());
120+
self::assertInstanceOf(AtRuleBlockList::class, $contents[1]);
121+
self::assertSame('theme', $contents[1]->atRuleArgs());
122+
}
123+
124+
/**
125+
* @test
126+
*/
127+
public function rendersParsedLayerStatementWithTrailingSemicolon(): void
128+
{
129+
$rendered = (new Parser('@layer reset;'))->parse()->render();
130+
131+
self::assertStringContainsString('@layer reset;', $rendered);
132+
}
133+
}

0 commit comments

Comments
 (0)