Stand: SMTP-Test, Admin-Mail-Tab, Notifiable-Fix, Lazy-Quill

- Fix: Notifiable-Trait zum User-Model hinzugefuegt (behebt notify()-500er)
- Installer: SMTP-Verbindungstest mit EsmtpTransport + Ueberspringen-Link
- Admin: Neuer E-Mail-Tab mit SMTP-Konfiguration + Verbindungstest
- Admin: Lazy Quill-Initialisierung (nur sichtbare Locale wird geladen)
- Uebersetzungen: 17 neue Mail-Keys in allen 6 Sprachen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Rhino
2026-03-02 07:30:37 +01:00
commit 2e24a40d68
9633 changed files with 1300799 additions and 0 deletions

View File

@@ -0,0 +1,612 @@
# Changelog
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/).
Please also have a look at our
[API and deprecation policy](docs/API-and-deprecation-policy.md).
## x.y.z
### Added
### Changed
### Deprecated
### Removed
### Fixed
### Documentation
## 9.2.0: New features and deprecations
### Added
- Add `OutputFormat::setSpaceAroundSelectorCombinator()` (#1504)
- Add support for escaped quotes in the selectors (#1485, #1489)
- Provide line number in exception message for mismatched parentheses in
selector (#1435)
- Add support for CSS container queries (#1400)
### Changed
- `RuleSet\RuleContainer` is renamed to `RuleSet\DeclarationList` (#1530, #1539)
- Methods like `setRule()` in `RuleSet` and `DeclarationBlock` have been renamed
to `setDeclaration()`, etc. (#1521)
- `Rule\Rule` class is renamed to `Property\Declaration`
(#1508, #1512, #1513, #1522)
- `Rule::setRule()` and `getRule()` are replaced with `setPropertyName()` and
`getPropertyName()` (#1506)
- `Selector` is now represented as a sequence of `Selector\Component` objects
which can be accessed via `getComponents()`, manipulated individually, or set
via `setComponents()` (#1478, #1486, #1487, #1488, #1494, #1496, #1536, #1537)
- `Selector::setSelector()` and `Selector` constructor will now throw exception
upon provision of an invalid selectior (#1498, #1502)
- Clean up extra whitespace in CSS selector (#1398)
- The array keys passed to `DeclarationBlock::setSelectors()` are no longer
preserved (#1407)
### Deprecated
- `RuleSet\RuleContainer` is deprecated; use `RuleSet\DeclarationList` instead
(#1530)
- Methods like `setRule()` in `RuleSet` and `DeclarationBlock` are deprecated;
there are direct replacements such as `setDeclaration()` (#1521)
- `Rule\Rule` class is deprecated; `Property\Declaration` is a direct
replacement (#1508)
- `Rule::setRule()` and `getRule()` are deprecated and replaced with
`setPropertyName()` and `getPropertyName()` (#1506, #1519)
### Fixed
- Do not escape characters that do not need escaping in CSS string (#1444)
- Reject selector comprising only whitespace (#1433)
- Improve recovery parsing when a rogue `}` is encountered (#1425, #1426)
- Parse comment(s) immediately preceding a selector (#1421, #1424)
- Parse consecutive comments (#1421)
- Support attribute selectors with values containing commas in
`DeclarationBlock::setSelectors()` (#1419)
- Allow `removeDeclarationBlockBySelector()` to be order-insensitve (#1406)
- Fix parsing of `calc` expressions when a newline immediately precedes or
follows a `+` or `-` operator (#1399)
- Use typesafe versions of PHP functions (#1379, #1380, #1382, #1383, #1384)
## 9.1.0: Add support for PHP 8.5
### Added
- Add support for PHP 8.5 (#1355)
### Fixed
- Improve performance of selector validation
(avoiding silent PCRE catastrophic failure) (#1372)
- Use typesafe versions of PHP functions (#1368, #1370)
## 9.0.0: New features, deprecation removals and bug fixes
### Added
- Interface `RuleContainer` for `RuleSet` `Rule` manipulation methods (#1256)
- Partial support for CSS Color Module Level 4:
- `rgb` and `rgba`, and `hsl` and `hsla` are now aliases (#797)
- Parse color functions that use the "modern" syntax (#800)
- Render RGB functions with "modern" syntax when required (#840)
- Support `none` as color function component value (#859)
- Add a class diagram to the README (#482)
- Add more tests (#449)
### Changed
- `DeclarationBlock` no longer extends `RuleSet` and instead has a `RuleSet` as
a property; use `getRuleSet()` to access it directly (#1194)
- The default line (and column) number is now `null` (not zero) (#1288)
- `setPosition()` (in `Rule` and other classes) now has fluent interface,
returning itself (#1259)
- `RuleSet::removeRule()` now only allows `Rule` as the parameter
(implementing classes are `AtRuleSet` and `DeclarationBlock`);
use `removeMatchingRules()` or `removeAllRules()` for other functions (#1255)
- `RuleSet::getRules()` and `getRulesAssoc()` now only allow `string` or `null`
as the parameter (implementing classes are `AtRuleSet` and `DeclarationBlock`)
(#1253)
- Initialize `KeyFrame` properties to sensible defaults (#1146)
- Make `OutputFormat` `final` (#1128)
- Make `Selector` a `Renderable` (#1017)
- Only allow `string` for some `OutputFormat` properties (#885)
- Use more native type declarations and strict mode
(#641, #772, #774, #778, #804, #841, #873, #875, #891, #922, #923, #933, #958,
#964, #967, #1000, #1044, #1134, #1136, #1137, #1139, #1140, #1141, #1145,
#1162, #1163, #1166, #1172, #1174, #1178, #1179, #1181, #1183, #1184, #1186,
#1187, #1190, #1192, #1193, #1203)
- Add visibility to all class/interface constants (#469)
### Removed
- Remove `getLineNo()` from these classes (use `getLineNumber()` instead):
`Comment`, `CSSList`, `SourceException`, `Charset`, `CSSNamespace`, `Import`,
`Rule`, `DeclarationBlock`, `RuleSet`, `CSSFunction`, `Value` (#1258)
- Remove `Rule::getColNo()` (use `getColumnNumber()` instead) (#1287)
- Passing a string as the first argument to `getAllValues()` is no longer
supported and will not work;
the search pattern should now be passed as the second argument (#1243)
- Passing a Boolean as the second argument to `getAllValues()` is no longer
supported and will not work; the flag for searching in function arguments
should now be passed as the third argument (#1243)
- Remove `__toString()` (#1046)
- Drop magic method forwarding in `OutputFormat` (#898)
- Drop `atRuleArgs()` from the `AtRule` interface (#1141)
- Remove `OutputFormat::get()` and `::set()` (#1108, #1110)
- Drop special support for vendor prefixes (#1083)
- Remove the IE hack in `Rule` (#995)
- Drop `getLineNo()` from the `Renderable` interface (#1038)
- Remove `OutputFormat::level()` (#874)
- Remove expansion of shorthand properties (#838)
- Remove `Parser::setCharset/getCharset` (#808)
- Remove `Rule::getValues()` (#582)
- Remove `Rule::setValues()` (#562)
- Remove `Document::getAllSelectors()` (#561)
- Remove `DeclarationBlock::getSelector()` (#559)
- Remove `DeclarationBlock::setSelector()` (#560)
- Drop support for PHP < 7.2 (#420)
### Fixed
- Remove trailing semicolon from declaration blocks with 'compact'
`OutputFormat` (#1345)
- Parse selector functions (like `:not`) with comma-separated arguments (#1292)
- Parse quoted attribute selector value containing comma (#1323)
- Allow comma in selectors (e.g. `:not(html, body)`) (#1293)
- Insert `Rule` before sibling even with different property name
(in `RuleSet::addRule()`) (#1270)
- Ensure `RuleSet::addRule()` sets non-negative column number when sibling
provided (#1268)
- Don't render `rgb` colors with percentage values using hex notation (#803)
### Documentation
- Add an API and deprecation policy (#720)
@ziegenberg is a new contributor to this release and did a lot of the heavy
lifting. Thanks! :heart:
## 8.9.0: New features, bug fixes and deprecations
### Added
- `RuleSet::removeMatchingRules()` method
(for the implementing classes `AtRuleSet` and `DeclarationBlock`) (#1249)
- `RuleSet::removeAllRules()` method
(for the implementing classes `AtRuleSet` and `DeclarationBlock`) (#1249)
- Add Interface `CSSElement` (#1231)
- Methods `getLineNumber` and `getColumnNumber` which return a nullable `int`
for the following classes:
`Comment`, `CSSList`, `SourceException`, `Charset`, `CSSNamespace`, `Import`,
`Rule`, `DeclarationBlock`, `RuleSet`, `CSSFunction`, `Value` (#1225, #1263)
- `Positionable` interface for CSS items that may have a position
(line and perhaps column number) in the parsed CSS (#1221)
### Changed
- Parameters for `getAllValues()` are deconflated, so it now takes three (all
optional), allowing `$element` and `$ruleSearchPattern` to be specified
separately (#1241)
- Implement `Positionable` in the following CSS item classes:
`Comment`, `CSSList`, `SourceException`, `Charset`, `CSSNamespace`, `Import`,
`Rule`, `DeclarationBlock`, `RuleSet`, `CSSFunction`, `Value` (#1225)
### Deprecated
- Support for PHP < 7.2 is deprecated; version 9.0 will require PHP 7.2 or later
(#1264)
- Passing a `string` or `null` to `RuleSet::removeRule()` is deprecated
(implementing classes are `AtRuleSet` and `DeclarationBlock`);
use `removeMatchingRules()` or `removeAllRules()` instead (#1249)
- Passing a `Rule` to `RuleSet::getRules()` or `getRulesAssoc()` is deprecated,
affecting the implementing classes `AtRuleSet` and `DeclarationBlock`
(call e.g. `getRules($rule->getRule())` instead) (#1248)
- Passing a string as the first argument to `getAllValues()` is deprecated;
the search pattern should now be passed as the second argument (#1241)
- Passing a Boolean as the second argument to `getAllValues()` is deprecated;
the flag for searching in function arguments should now be passed as the third
argument (#1241)
- `getLineNo()` is deprecated in these classes (use `getLineNumber()` instead):
`Comment`, `CSSList`, `SourceException`, `Charset`, `CSSNamespace`, `Import`,
`Rule`, `DeclarationBlock`, `RuleSet`, `CSSFunction`, `Value` (#1225, #1233)
- `Rule::getColNo()` is deprecated (use `getColumnNumber()` instead)
(#1225, #1233)
- Providing zero as the line number argument to `Rule::setPosition()` is
deprecated (pass `null` instead if there is no line number) (#1225, #1233)
### Fixed
- Set line number when `RuleSet::addRule()` called with only column number set
(#1265)
- Ensure first rule added with `RuleSet::addRule()` has valid position (#1262)
## 8.8.0: Bug fixes and deprecations
### Added
- `OutputFormat` properties for space around specific list separators (#880)
### Changed
- Mark the `OutputFormat` constructor as `@internal` (#1131)
- Mark `OutputFormatter` as `@internal` (#896)
- Mark `Selector::isValid()` as `@internal` (#1037)
- Mark parsing-related methods of most CSS elements as `@internal` (#908)
- Mark `OutputFormat::nextLevel()` as `@internal` (#901)
- Make all non-private properties `@internal` (#886)
### Deprecated
- Deprecate extending `OutputFormat` (#1131)
- Deprecate `OutputFormat::get()` and `::set()` (#1107)
- Deprecate support for `-webkit-calc` and `-moz-calc` (#1086)
- Deprecate magic method forwarding from `OutputFormat` to `OutputFormatter`
(#894)
- Deprecate `__toString()` (#1006)
- Deprecate greedy calculation of selector specificity (#1018)
- Deprecate the IE hack in `Rule` (#993, #1003)
- `OutputFormat` properties for space around list separators as an array (#880)
- Deprecate `OutputFormat::level()` (#870)
### Fixed
- Include comments for all rules in declaration block (#1169)
- Render rules in line and column number order (#1059)
- Create `Size` with correct types in `expandBackgroundShorthand` (#814)
- Parse `@font-face` `src` property as comma-delimited list (#794)
## 8.7.0: Add support for PHP 8.4
### Added
- Add support for PHP 8.4 (#643, #657)
### Changed
- Mark parsing-internal classes and methods as `@internal` (#674)
- Block installations on unsupported higher PHP versions (#691)
### Deprecated
- Deprecate the expansion of shorthand properties
(#578, #580, #579, #577, #576, #575, #574, #573, #572, #571, #570, #569, #566,
#567, #558, #714)
- Deprecate `Parser::setCharset()` and `Parser::getCharset()` (#688)
### Fixed
- Fix type errors in PHP strict mode (#664)
## 8.6.0
### Added
- Support arithmetic operators in CSS function arguments (#607)
- Add support for inserting an item in a CSS list (#545)
- Add support for the `dvh`, `lvh` and `svh` length units (#415)
### Changed
- Improve performance of `Value::parseValue` with many delimiters by refactoring
to remove `array_search()` (#413)
## 8.5.2
### Changed
- Mark all class constants as `@internal` (#472)
### Fixed
- Fix undefined local variable in `CalcFunction::parse()` (#593)
## 8.5.1
### Fixed
- Fix PHP notice caused by parsing invalid color values having less than
6 characters (#485)
- Fix (regression) failure to parse at-rules with strict parsing (#456)
## 8.5.0
### Added
- Add a method to get an import's media queries (#384)
- Add more unit tests (#381, #382)
### Fixed
- Retain CSSList and Rule comments when rendering CSS (#351)
- Replace invalid `turns` unit with `turn` (#350)
- Also allow string values for rules (#348)
- Fix invalid calc parsing (#169)
- Handle scientific notation when parsing sizes (#179)
- Fix PHP 8.1 compatibility in `ParserState::strsplit()` (#344)
## 8.4.0
### Features
* Support for PHP 8.x
* PHPDoc annotations
* Allow usage of CSS variables inside color functions (by parsing them as
regular functions)
* Use PSR-12 code style
* *No deprecations*
### Bugfixes
* Improved handling of whitespace in `calc()`
* Fix parsing units whose prefix is also a valid unit, like `vmin`
* Allow passing an object to `CSSList#replace`
* Fix PHP 7.3 warnings
* Correctly parse keyframes with `%`
* Dont convert large numbers to scientific notation
* Allow a file to end after an `@import`
* Preserve case of CSS variables as specced
* Allow identifiers to use escapes the same way as strings
* No longer use `eval` for the comparison in `getSelectorsBySpecificity`, in
case it gets passed untrusted input (CVE-2020-13756). Also fixed in 8.3.1,
8.2.1, 8.1.1, 8.0.1, 7.0.4, 6.0.2, 5.2.1, 5.1.3, 5.0.9, 4.0.1, 3.0.1, 2.0.1,
1.0.1.
* Prevent an infinite loop when parsing invalid grid line names
* Remove invalid unit `vm`
* Retain rule order after expanding shorthands
### Backwards-incompatible changes
* PHP ≥ 5.6 is now required
* HHVM compatibility target dropped
## 8.3.0 (2019-02-22)
* Refactor parsing logic to mostly reside in the class files whose data
structure is to be parsed (this should eventually allow us to unit-test
specific parts of the parsing logic individually).
* Fix error in parsing `calc` expessions when the first operand is a negative
number, thanks to @raxbg.
* Support parsing CSS4 colors in hex notation with alpha values, thanks to
@raxbg.
* Swallow more errors in lenient mode, thanks to @raxbg.
* Allow specifying arbitrary strings to output before and after declaration
blocks, thanks to @westonruter.
* *No backwards-incompatible changes*
* *No deprecations*
## 8.2.0 (2018-07-13)
* Support parsing `calc()`, thanks to @raxbg.
* Support parsing grid-lines, again thanks to @raxbg.
* Support parsing legacy IE filters (`progid:`) in lenient mode, thanks to
@FMCorz
* Performance improvements parsing large files, again thanks to @FMCorz
* *No backwards-incompatible changes*
* *No deprecations*
## 8.1.0 (2016-07-19)
* Comments are no longer silently ignored but stored with the object with which
they appear (no render support, though). Thanks to @FMCorz.
* The IE hacks using `\0` and `\9` can now be parsed (and rendered) in lenient
mode. Thanks (again) to @FMCorz.
* Media queries with or without spaces before the query are parsed. Still no
*real* parsing support, though. Sorry…
* PHPUnit is now listed as a dev-dependency in composer.json.
* *No backwards-incompatible changes*
* *No deprecations*
## 8.0.0 (2016-06-30)
* Store source CSS line numbers in tokens and parsing exceptions.
* *No deprecations*
### Backwards-incompatible changes
* Unrecoverable parser errors throw an exception of type
`Sabberworm\CSS\Parsing\SourceException` instead of `\Exception`.
## 7.0.3 (2016-04-27)
* Fixed parsing empty CSS when multibyte is off
* *No backwards-incompatible changes*
* *No deprecations*
## 7.0.2 (2016-02-11)
* 150 time performance boost thanks
to @[ossinkine](https://github.com/ossinkine)
* *No backwards-incompatible changes*
* *No deprecations*
## 7.0.1 (2015-12-25)
* No more suppressed `E_NOTICE`
* *No backwards-incompatible changes*
* *No deprecations*
## 7.0.0 (2015-08-24)
* Compatibility with PHP 7. Well timed, eh?
* *No deprecations*
### Backwards-incompatible changes
* The `Sabberworm\CSS\Value\String` class has been renamed to
`Sabberworm\CSS\Value\CSSString`.
## 6.0.1 (2015-08-24)
* Remove some declarations in interfaces incompatible with PHP 5.3 (< 5.3.9)
* *No deprecations*
## 6.0.0 (2014-07-03)
* Format output using Sabberworm\CSS\OutputFormat
* *No backwards-incompatible changes*
### Deprecations
* The parse() method replaces __toString with an optional argument (instance of
the OutputFormat class)
## 5.2.0 (2014-06-30)
* Support removing a selector from a declaration block using
`$oBlock->removeSelector($mSelector)`
* Introduce a specialized exception (Sabberworm\CSS\Parsing\OuputException) for
exceptions during output rendering
* *No deprecations*
#### Backwards-incompatible changes
* Outputting a declaration block that has no selectors throws an OuputException
instead of outputting an invalid ` {…}` into the CSS document.
## 5.1.2 (2013-10-30)
* Remove the use of consumeUntil in comment parsing. This makes it possible to
parse comments such as `/** Perfectly valid **/`
* Add fr relative size unit
* Fix some issues with HHVM
* *No backwards-incompatible changes*
* *No deprecations*
## 5.1.1 (2013-10-28)
* Updated CHANGELOG.md to reflect changes since 5.0.4
* *No backwards-incompatible changes*
* *No deprecations*
## 5.1.0 (2013-10-24)
* Performance enhancements by Michael M Slusarz
* More rescue entry points for lenient parsing (unexpected tokens between
declaration blocks and unclosed comments)
* *No backwards-incompatible changes*
* *No deprecations*
## 5.0.8 (2013-08-15)
* Make default settings multibyte parsing option dependent on whether or not
the mbstring extension is actually installed.
* *No backwards-incompatible changes*
* *No deprecations*
## 5.0.7 (2013-08-04)
* Fix broken decimal point output optimization
* *No backwards-incompatible changes*
* *No deprecations*
## 5.0.6 (2013-05-31)
* Fix broken unit test
* *No backwards-incompatible changes*
* *No deprecations*
## 5.0.5 (2013-04-17)
* Initial support for lenient parsing (setting this parser option will catch
some exceptions internally and recover the parsers state as neatly as
possible).
* *No backwards-incompatible changes*
* *No deprecations*
## 5.0.4 (2013-03-21)
* Dont output floats with locale-aware separator chars
* *No backwards-incompatible changes*
* *No deprecations*
## 5.0.3 (2013-03-21)
* More size units recognized
* *No backwards-incompatible changes*
* *No deprecations*
## 5.0.2 (2013-03-21)
* CHANGELOG.md file added to distribution
* *No backwards-incompatible changes*
* *No deprecations*
## 5.0.1 (2013-03-20)
* Internal cleanup
* *No backwards-incompatible changes*
* *No deprecations*
## 5.0.0 (2013-03-20)
* Correctly parse all known CSS 3 units (including Hz and kHz).
* Output RGB colors in short (#aaa or #ababab) notation
* Be case-insensitive when parsing identifiers.
* *No deprecations*
### Backwards-incompatible changes
* `Sabberworm\CSS\Value\Color`s `__toString` method overrides `CSSList`s to
maybe return something other than `type(value, …)` (see above).
## 4.0.0 (2013-03-19)
* Support for more @-rules
* Generic interface `Sabberworm\CSS\Property\AtRule`, implemented by all @-rule
classes
* *No deprecations*
### Backwards-incompatible changes
* `Sabberworm\CSS\RuleSet\AtRule` renamed to `Sabberworm\CSS\RuleSet\AtRuleSet`
* `Sabberworm\CSS\CSSList\MediaQuery` renamed to
`Sabberworm\CSS\RuleSet\CSSList\AtRuleBlockList` with differing semantics and
API (which also works for other block-list-based @-rules like `@supports`).
## 3.0.0 (2013-03-06)
* Support for lenient parsing (on by default)
* *No deprecations*
### Backwards-incompatible changes
* All properties (like whether or not to use `mb_`-functions, which default
charset to use and new whether or not to be forgiving when parsing) are
now encapsulated in an instance of `Sabberworm\CSS\Settings` which can be
passed as the second argument to `Sabberworm\CSS\Parser->__construct()`.
* Specifying a charset as the second argument to
`Sabberworm\CSS\Parser->__construct()` is no longer supported. Use
`Sabberworm\CSS\Settings::create()->withDefaultCharset('some-charset')`
instead.
* Setting `Sabberworm\CSS\Parser->bUseMbFunctions` has no effect. Use
`Sabberworm\CSS\Settings::create()->withMultibyteSupport(true/false)` instead.
* `Sabberworm\CSS\Parser->parse()` may throw a
`Sabberworm\CSS\Parsing\UnexpectedTokenException` when in strict parsing mode.
## 2.0.0 (2013-01-29)
* Allow multiple rules of the same type per rule set
### Backwards-incompatible changes
* `Sabberworm\CSS\RuleSet->getRules()` returns an index-based array instead of
an associative array. Use `Sabberworm\CSS\RuleSet->getRulesAssoc()` (which
eliminates duplicate rules and lets the later rule of the same name win).
* `Sabberworm\CSS\RuleSet->removeRule()` works as it did before except when
passed an instance of `Sabberworm\CSS\Rule\Rule`, in which case it would only
remove the exact rule given instead of all the rules of the same type. To get
the old behaviour, use `Sabberworm\CSS\RuleSet->removeRule($oRule->getRule()`;
## 1.0
Initial release of a stable public API.
## 0.9
Last version not to use PSR-0 project organization semantics.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2011 Raphael Schweikert, https://www.sabberworm.com/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,841 @@
# PHP CSS Parser
[![Build Status](https://github.com/MyIntervals/PHP-CSS-Parser/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/MyIntervals/PHP-CSS-Parser/actions/)
[![Coverage Status](https://coveralls.io/repos/github/MyIntervals/PHP-CSS-Parser/badge.svg?branch=main)](https://coveralls.io/github/MyIntervals/PHP-CSS-Parser?branch=main)
A Parser for CSS Files written in PHP. Allows extraction of CSS files into a data structure, manipulation of said structure and output as (optimized) CSS.
## Usage
### Installation using Composer
```bash
composer require sabberworm/php-css-parser
```
### Extraction
To use the CSS Parser, create a new instance. The constructor takes the following form:
```php
new \Sabberworm\CSS\Parser($css);
```
To read a file, for example, youd do the following:
```php
$parser = new \Sabberworm\CSS\Parser(file_get_contents('somefile.css'));
$cssDocument = $parser->parse();
```
The resulting CSS document structure can be manipulated prior to being output.
### Options
#### Charset
The charset option will only be used if the CSS file does not contain an `@charset` declaration. UTF-8 is the default, so you wont have to create a settings object at all if you dont intend to change that.
```php
$settings = \Sabberworm\CSS\Settings::create()
->withDefaultCharset('windows-1252');
$parser = new \Sabberworm\CSS\Parser($css, $settings);
```
#### Strict parsing
To have the parser throw an exception when encountering invalid/unknown constructs (as opposed to trying to ignore them and carry on parsing), supply a thusly configured `\Sabberworm\CSS\Settings` object:
```php
$parser = new \Sabberworm\CSS\Parser(
file_get_contents('somefile.css'),
\Sabberworm\CSS\Settings::create()->beStrict()
);
```
Note that this will also disable a workaround for parsing the unquoted variant of the legacy IE-specific `filter` rule.
#### Disable multibyte functions
To achieve faster parsing, you can choose to have PHP-CSS-Parser use regular string functions instead of `mb_*` functions. This should work fine in most cases, even for UTF-8 files, as all the multibyte characters are in string literals. Still its not recommended using this with input you have no control over as its not thoroughly covered by test cases.
```php
$settings = \Sabberworm\CSS\Settings::create()->withMultibyteSupport(false);
$parser = new \Sabberworm\CSS\Parser($css, $settings);
```
### Manipulation
The resulting data structure consists mainly of five basic types: `CSSList`, `RuleSet`, `Rule`, `Selector` and `Value`. There are two additional types used: `Import` and `Charset`, which you wont use often.
#### CSSList
`CSSList` represents a generic CSS container, most likely containing declaration blocks (rule sets with a selector), but it may also contain at-rules, charset declarations, etc.
To access the items stored in a `CSSList` like the document you got back when calling `$parser->parse()` , use `getContents()`, then iterate over that collection and use `instanceof` to check whether youre dealing with another `CSSList`, a `RuleSet`, a `Import` or a `Charset`.
To append a new item (selector, media query, etc.) to an existing `CSSList`, construct it using the constructor for this class and use the `append($oItem)` method.
#### RuleSet
`RuleSet` is a container for individual rules. The most common form of a rule set is one constrained by a selector. The following concrete subtypes exist:
* `AtRuleSet` for generic at-rules for generic at-rules which are not covered by specific classes, i.e., not `@import`, `@charset` or `@media`. A common example for this is `@font-face`.
* `DeclarationBlock` a `RuleSet` constrained by a `Selector`; contains an array of selector objects (comma-separated in the CSS) as well as the rules to be applied to the matching elements.
Note: A `CSSList` can contain other `CSSList`s (and `Import`s as well as a `Charset`), while a `RuleSet` can only contain `Rule`s.
If you want to manipulate a `RuleSet`, use the methods `addRule(Rule $rule)`, `getRules()` and `removeRule($rule)` (which accepts either a `Rule` or a rule name; optionally suffixed by a dash to remove all related rules).
#### Rule
`Rule`s just have a string key (the rule) and a `Value`.
#### Value
`Value` is an abstract class that only defines the `render` method. The concrete subclasses for atomic value types are:
* `Size` consists of a numeric `size` value and a unit.
* `Color` colors can be input in the form #rrggbb, #rgb or schema(val1, val2, …) but are always stored as an array of ('s' => val1, 'c' => val2, 'h' => val3, …) and output in the second form.
* `CSSString` this is just a wrapper for quoted strings to distinguish them from keywords; always output with double quotes.
* `URL` URLs in CSS; always output in `URL("")` notation.
There is another abstract subclass of `Value`, `ValueList`: A `ValueList` represents a lists of `Value`s, separated by some separation character (mostly `,`, whitespace, or `/`).
There are two types of `ValueList`s:
* `RuleValueList` The default type, used to represent all multivalued rules like `font: bold 12px/3 Helvetica, Verdana, sans-serif;` (where the value would be a whitespace-separated list of the primitive value `bold`, a slash-separated list and a comma-separated list).
* `CSSFunction` A special kind of value that also contains a function name and where the values are the functions arguments. Also handles equals-sign-separated argument lists like `filter: alpha(opacity=90);`.
#### Convenience methods
There are a few convenience methods on `Document` to ease finding, manipulating and deleting rules:
* `getAllDeclarationBlocks()` does what it says; no matter how deeply nested the selectors are. Aliased as `getAllSelectors()`.
* `getAllRuleSets()` does what it says; no matter how deeply nested the rule sets are.
* `getAllValues()` finds all `Value` objects inside `Rule`s.
## To-Do
* More convenience methods (like `selectorsWithElement($sId/Class/TagName)`, `attributesOfType($type)`, `removeAttributesOfType($type)`)
* Real multibyte support. Currently, only multibyte charsets whose first 255 code points take up only one byte and are identical with ASCII are supported (yes, UTF-8 fits this description).
* Named color support (using `Color` instead of an anonymous string literal)
## Use cases
### Use `Parser` to prepend an ID to all selectors
```php
$myId = "#my_id";
$parser = new \Sabberworm\CSS\Parser($css);
$cssDocument = $parser->parse();
foreach ($cssDocument->getAllDeclarationBlocks() as $block) {
foreach ($block->getSelectors() as $selector) {
// Loop over all selector parts (the comma-separated strings in a
// selector) and prepend the ID.
$selector->setSelector($myId.' '.$selector->getSelector());
}
}
```
### Shrink all absolute sizes to half
```php
$parser = new \Sabberworm\CSS\Parser($css);
$cssDocument = $parser->parse();
foreach ($cssDocument->getAllValues() as $value) {
if ($value instanceof CSSSize && !$value->isRelative()) {
$value->setSize($value->getSize() / 2);
}
}
```
### Remove unwanted rules
```php
$parser = new \Sabberworm\CSS\Parser($css);
$cssDocument = $parser->parse();
foreach($cssDocument->getAllRuleSets() as $oRuleSet) {
// Note that the added dash will make this remove all rules starting with
// `font-` (like `font-size`, `font-weight`, etc.) as well as a potential
// `font` rule.
$oRuleSet->removeRule('font-');
$oRuleSet->removeRule('cursor');
}
```
### Output
To output the entire CSS document into a variable, just use `->render()`:
```php
$parser = new \Sabberworm\CSS\Parser(file_get_contents('somefile.css'));
$cssDocument = $parser->parse();
print $cssDocument->render();
```
If you want to format the output, pass an instance of type `\Sabberworm\CSS\OutputFormat`:
```php
$format = \Sabberworm\CSS\OutputFormat::create()
->indentWithSpaces(4)->setSpaceBetweenRules("\n");
print $cssDocument->render($format);
```
Or use one of the predefined formats:
```php
print $cssDocument->render(Sabberworm\CSS\OutputFormat::createPretty());
print $cssDocument->render(Sabberworm\CSS\OutputFormat::createCompact());
```
To see what you can do with output formatting, look at the tests in `tests/OutputFormatTest.php`.
## Examples
### Example 1 (At-Rules)
#### Input
```css
@charset "utf-8";
@font-face {
font-family: "CrassRoots";
src: url("../media/cr.ttf");
}
html, body {
font-size: 1.6em;
}
@keyframes mymove {
from { top: 0px; }
to { top: 200px; }
}
```
<details>
<summary><b>Structure (<code>var_dump()</code>)</b></summary>
```php
class Sabberworm\CSS\CSSList\Document#4 (2) {
protected $contents =>
array(4) {
[0] =>
class Sabberworm\CSS\Property\Charset#6 (2) {
private $charset =>
class Sabberworm\CSS\Value\CSSString#5 (2) {
private $string =>
string(5) "utf-8"
protected $lineNumber =>
int(1)
}
protected $lineNumber =>
int(1)
}
[1] =>
class Sabberworm\CSS\RuleSet\AtRuleSet#7 (4) {
private $type =>
string(9) "font-face"
private $arguments =>
string(0) ""
private $rules =>
array(2) {
'font-family' =>
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#8 (4) {
private $rule =>
string(11) "font-family"
private $value =>
class Sabberworm\CSS\Value\CSSString#9 (2) {
private $string =>
string(10) "CrassRoots"
protected $lineNumber =>
int(4)
}
private $isImportant =>
bool(false)
protected $lineNumber =>
int(4)
}
}
'src' =>
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#10 (4) {
private $rule =>
string(3) "src"
private $value =>
class Sabberworm\CSS\Value\URL#11 (2) {
private $url =>
class Sabberworm\CSS\Value\CSSString#12 (2) {
private $string =>
string(15) "../media/cr.ttf"
protected $lineNumber =>
int(5)
}
protected $lineNumber =>
int(5)
}
private $isImportant =>
bool(false)
protected $lineNumber =>
int(5)
}
}
}
protected $lineNumber =>
int(3)
}
[2] =>
class Sabberworm\CSS\RuleSet\DeclarationBlock#13 (3) {
private $selectors =>
array(2) {
[0] =>
class Sabberworm\CSS\Property\Selector#14 (2) {
private $selector =>
string(4) "html"
private $specificity =>
NULL
}
[1] =>
class Sabberworm\CSS\Property\Selector#15 (2) {
private $selector =>
string(4) "body"
private $specificity =>
NULL
}
}
private $rules =>
array(1) {
'font-size' =>
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#16 (4) {
private $rule =>
string(9) "font-size"
private $value =>
class Sabberworm\CSS\Value\Size#17 (4) {
private $size =>
double(1.6)
private $unit =>
string(2) "em"
private $isColorComponent =>
bool(false)
protected $lineNumber =>
int(9)
}
private $isImportant =>
bool(false)
protected $lineNumber =>
int(9)
}
}
}
protected $lineNumber =>
int(8)
}
[3] =>
class Sabberworm\CSS\CSSList\KeyFrame#18 (4) {
private $vendorKeyFrame =>
string(9) "keyframes"
private $animationName =>
string(6) "mymove"
protected $contents =>
array(2) {
[0] =>
class Sabberworm\CSS\RuleSet\DeclarationBlock#19 (3) {
private $selectors =>
array(1) {
[0] =>
class Sabberworm\CSS\Property\Selector#20 (2) {
private $selector =>
string(4) "from"
private $specificity =>
NULL
}
}
private $rules =>
array(1) {
'top' =>
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#21 (4) {
private $rule =>
string(3) "top"
private $value =>
class Sabberworm\CSS\Value\Size#22 (4) {
private $size =>
double(0)
private $unit =>
string(2) "px"
private $isColorComponent =>
bool(false)
protected $lineNumber =>
int(13)
}
private $isImportant =>
bool(false)
protected $lineNumber =>
int(13)
}
}
}
protected $lineNumber =>
int(13)
}
[1] =>
class Sabberworm\CSS\RuleSet\DeclarationBlock#23 (3) {
private $selectors =>
array(1) {
[0] =>
class Sabberworm\CSS\Property\Selector#24 (2) {
private $selector =>
string(2) "to"
private $specificity =>
NULL
}
}
private $rules =>
array(1) {
'top' =>
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#25 (4) {
private $rule =>
string(3) "top"
private $value =>
class Sabberworm\CSS\Value\Size#26 (4) {
private $size =>
double(200)
private $unit =>
string(2) "px"
private $isColorComponent =>
bool(false)
protected $lineNumber =>
int(14)
}
private $isImportant =>
bool(false)
protected $lineNumber =>
int(14)
}
}
}
protected $lineNumber =>
int(14)
}
}
protected $lineNumber =>
int(12)
}
}
protected $lineNumber =>
int(1)
}
```
</details>
#### Output (`render()`)
```css
@charset "utf-8";
@font-face {font-family: "CrassRoots";src: url("../media/cr.ttf");}
html, body {font-size: 1.6em;}
@keyframes mymove {from {top: 0px;} to {top: 200px;}}
```
### Example 2 (Values)
#### Input
```css
#header {
margin: 10px 2em 1cm 2%;
font-family: Verdana, Helvetica, "Gill Sans", sans-serif;
color: red !important;
}
```
<details>
<summary><b>Structure (<code>var_dump()</code>)</b></summary>
```php
class Sabberworm\CSS\CSSList\Document#4 (2) {
protected $contents =>
array(1) {
[0] =>
class Sabberworm\CSS\RuleSet\DeclarationBlock#5 (3) {
private $selectors =>
array(1) {
[0] =>
class Sabberworm\CSS\Property\Selector#6 (2) {
private $selector =>
string(7) "#header"
private $specificity =>
NULL
}
}
private $rules =>
array(3) {
'margin' =>
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#7 (4) {
private $rule =>
string(6) "margin"
private $value =>
class Sabberworm\CSS\Value\RuleValueList#12 (3) {
protected $components =>
array(4) {
[0] =>
class Sabberworm\CSS\Value\Size#8 (4) {
private $size =>
double(10)
private $unit =>
string(2) "px"
private $isColorComponent =>
bool(false)
protected $lineNumber =>
int(2)
}
[1] =>
class Sabberworm\CSS\Value\Size#9 (4) {
private $size =>
double(2)
private $unit =>
string(2) "em"
private $isColorComponent =>
bool(false)
protected $lineNumber =>
int(2)
}
[2] =>
class Sabberworm\CSS\Value\Size#10 (4) {
private $size =>
double(1)
private $unit =>
string(2) "cm"
private $isColorComponent =>
bool(false)
protected $lineNumber =>
int(2)
}
[3] =>
class Sabberworm\CSS\Value\Size#11 (4) {
private $size =>
double(2)
private $unit =>
string(1) "%"
private $isColorComponent =>
bool(false)
protected $lineNumber =>
int(2)
}
}
protected $separator =>
string(1) " "
protected $lineNumber =>
int(2)
}
private $isImportant =>
bool(false)
protected $lineNumber =>
int(2)
}
}
'font-family' =>
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#13 (4) {
private $rule =>
string(11) "font-family"
private $value =>
class Sabberworm\CSS\Value\RuleValueList#15 (3) {
protected $components =>
array(4) {
[0] =>
string(7) "Verdana"
[1] =>
string(9) "Helvetica"
[2] =>
class Sabberworm\CSS\Value\CSSString#14 (2) {
private $string =>
string(9) "Gill Sans"
protected $lineNumber =>
int(3)
}
[3] =>
string(10) "sans-serif"
}
protected $sSeparator =>
string(1) ","
protected $lineNumber =>
int(3)
}
private $isImportant =>
bool(false)
protected $lineNumber =>
int(3)
}
}
'color' =>
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#16 (4) {
private $rule =>
string(5) "color"
private $value =>
string(3) "red"
private $isImportant =>
bool(true)
protected $lineNumber =>
int(4)
}
}
}
protected $lineNumber =>
int(1)
}
}
protected $lineNumber =>
int(1)
}
```
</details>
#### Output (`render()`)
```css
#header {margin: 10px 2em 1cm 2%;font-family: Verdana,Helvetica,"Gill Sans",sans-serif;color: red !important;}
```
## Class diagram
```mermaid
classDiagram
direction LR
class Anchor {
}
class AtRule {
<<interface>>
}
class AtRuleBlockList {
}
class AtRuleSet {
}
class CSSBlockList {
<<abstract>>
}
class CSSElement {
<<interface>>
}
class CSSFunction {
}
class CSSList {
<<abstract>>
}
class CSSListItem {
<<interface>>
}
class CSSNamespace {
}
class CSSString {
}
class CalcFunction {
}
class CalcRuleValueList {
}
class Charset {
}
class Color {
}
class Comment {
}
class Commentable {
<<interface>>
}
class DeclarationBlock {
}
class Document {
}
class Import {
}
class KeyFrame {
}
class KeyframeSelector {
}
class LineName {
}
class OutputException {
}
class OutputFormat {
}
class OutputFormatter {
}
class Parser {
}
class ParserState {
}
class Positionable {
<<interface>>
}
class PrimitiveValue {
<<abstract>>
}
class Renderable {
<<interface>>
}
class Rule {
}
class RuleContainer {
<<interface>>
}
class RuleSet {
}
class RuleValueList {
}
class Selector {
}
class Settings {
}
class Size {
}
class SourceException {
}
class SpecificityCalculator {
}
class URL {
}
class UnexpectedEOFException {
}
class UnexpectedTokenException {
}
class Value {
<<abstract>>
}
class ValueList {
<<abstract>>
}
Anchor ..> ParserState: dependency
CSSListItem <|-- AtRule: inheritance
AtRule <|.. AtRuleBlockList: realization
CSSBlockList <|-- AtRuleBlockList: inheritance
AtRule <|.. AtRuleSet: realization
RuleSet <|-- AtRuleSet: inheritance
CSSList <|-- CSSBlockList: inheritance
Renderable <|-- CSSElement: inheritance
ValueList <|-- CSSFunction: inheritance
CSSElement <|.. CSSList: realization
CSSListItem <|.. CSSList: realization
CSSList ..> Charset: dependency
CSSList ..> Import: dependency
Positionable <|.. CSSList: realization
Commentable <|-- CSSListItem: inheritance
Renderable <|-- CSSListItem: inheritance
AtRule <|.. CSSNamespace: realization
Positionable <|.. CSSNamespace: realization
PrimitiveValue <|-- CSSString: inheritance
CSSFunction <|-- CalcFunction: inheritance
RuleValueList <|-- CalcRuleValueList: inheritance
AtRule <|.. Charset: realization
Charset ..> CSSString: dependency
Positionable <|.. Charset: realization
CSSFunction <|-- Color: inheritance
Positionable <|.. Comment: realization
Renderable <|.. Comment: realization
CSSElement <|.. DeclarationBlock: realization
CSSListItem <|.. DeclarationBlock: realization
Positionable <|.. DeclarationBlock: realization
RuleContainer <|.. DeclarationBlock: realization
DeclarationBlock ..> RuleSet : dependency
DeclarationBlock ..> Selector: dependency
CSSBlockList <|-- Document: inheritance
AtRule <|.. Import: realization
Positionable <|.. Import: realization
AtRule <|.. KeyFrame: realization
CSSList <|-- KeyFrame: inheritance
Selector <|-- KeyframeSelector: inheritance
ValueList <|-- LineName: inheritance
SourceException <|-- OutputException: inheritance
OutputFormat ..> OutputFormatter: dependency
OutputFormatter ..> OutputFormat: dependency
Parser ..> ParserState: dependency
ParserState ..> Settings: dependency
Value <|-- PrimitiveValue: inheritance
CSSElement <|.. Rule: realization
Commentable <|.. Rule: realization
Positionable <|.. Rule: realization
Rule ..> RuleValueList: dependency
CSSElement <|.. RuleSet: realization
CSSListItem <|.. RuleSet: realization
Positionable <|.. RuleSet: realization
RuleSet ..> Rule: dependency
RuleContainer <|.. RuleSet: realization
ValueList <|-- RuleValueList: inheritance
Renderable <|.. Selector: realization
PrimitiveValue <|-- Size: inheritance
Exception <|-- SourceException: inheritance
Positionable <|.. SourceException: realization
URL ..> CSSString: dependency
PrimitiveValue <|-- URL: inheritance
UnexpectedTokenException <|-- UnexpectedEOFException: inheritance
SourceException <|-- UnexpectedTokenException: inheritance
CSSElement <|.. Value: realization
Positionable <|.. Value: realization
Value <|-- ValueList: inheritance
CSSList ..> CSSList: dependency
CSSList ..> Comment: dependency
CSSList ..> RuleSet: dependency
CSSNamespace ..> Comment: dependency
Charset ..> Comment: dependency
Import ..> Comment: dependency
OutputFormat ..> OutputFormat: dependency
Rule ..> Comment: dependency
RuleSet ..> Comment: dependency
ValueList ..> Value: dependency
```
## API and deprecation policy
Please have a look at our
[API and deprecation policy](docs/API-and-deprecation-policy.md).
## Contributing
Contributions in the form of bug reports, feature requests, or pull requests are
more than welcome. :pray: Please have a look at our
[contribution guidelines](CONTRIBUTING.md) to learn more about how to
contribute to PHP-CSS-Parser.
## Contributors/Thanks to
* [oliverklee](https://github.com/oliverklee) for lots of refactorings, code modernizations and CI integrations
* [raxbg](https://github.com/raxbg) for contributions to parse `calc`, grid lines, and various bugfixes.
* [westonruter](https://github.com/westonruter) for bugfixes and improvements.
* [FMCorz](https://github.com/FMCorz) for many patches and suggestions, for being able to parse comments and IE hacks (in lenient mode).
* [Lullabot](https://github.com/Lullabot) for a patch that allows to know the line number for each parsed token.
* [ju1ius](https://github.com/ju1ius) for the specificity parsing code and the ability to expand/compact shorthand properties.
* [ossinkine](https://github.com/ossinkine) for a 150 time performance boost.
* [GaryJones](https://github.com/GaryJones) for lots of input and [https://css-specificity.info/](https://css-specificity.info/).
* [docteurklein](https://github.com/docteurklein) for output formatting and `CSSList->remove()` inspiration.
* [nicolopignatelli](https://github.com/nicolopignatelli) for PSR-0 compatibility.
* [diegoembarcadero](https://github.com/diegoembarcadero) for keyframe at-rule parsing.
* [goetas](https://github.com/goetas) for `@namespace` at-rule support.
* [ziegenberg](https://github.com/ziegenberg) for general housekeeping and cleanup.
* [View full list](https://github.com/sabberworm/PHP-CSS-Parser/contributors)
## Misc
### Legacy Support
The latest pre-PSR-0 version of this project can be checked with the `0.9.0` tag.

View File

@@ -0,0 +1,141 @@
{
"name": "sabberworm/php-css-parser",
"description": "Parser for CSS Files written in PHP",
"license": "MIT",
"type": "library",
"keywords": [
"parser",
"css",
"stylesheet"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"require": {
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"ext-iconv": "*",
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.32 || 2.1.32",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.8",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7",
"phpunit/phpunit": "8.5.52",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.2.8",
"rector/type-perfect": "1.0.0 || 2.1.0",
"squizlabs/php_codesniffer": "4.0.1",
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"autoload": {
"psr-4": {
"Sabberworm\\CSS\\": "src/"
},
"files": [
"src/Rule/Rule.php",
"src/RuleSet/RuleContainer.php"
]
},
"autoload-dev": {
"psr-4": {
"Sabberworm\\CSS\\Tests\\": "tests/"
}
},
"config": {
"allow-plugins": {
"phpstan/extension-installer": true
},
"preferred-install": {
"*": "dist"
},
"sort-packages": true
},
"extra": {
"branch-alias": {
"dev-main": "9.3.x-dev"
}
},
"scripts": {
"check": [
"@check:static",
"@check:dynamic"
],
"check:composer:normalize": "\"./.phive/composer-normalize\" --dry-run",
"check:dynamic": [
"@check:tests"
],
"check:php:codesniffer": "phpcs --standard=config/phpcs.xml bin config src tests",
"check:php:fixer": "\"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix --dry-run -v --show-progress=dots --diff bin config src tests",
"check:php:lint": "parallel-lint bin config src tests",
"check:php:rector": "rector process --no-progress-bar --dry-run --config=config/rector.php",
"check:php:stan": "phpstan --no-progress --configuration=config/phpstan.neon",
"check:static": [
"@check:composer:normalize",
"@check:php:fixer",
"@check:php:codesniffer",
"@check:php:lint",
"@check:php:rector",
"@check:php:stan"
],
"check:tests": [
"@check:tests:unit"
],
"check:tests:coverage": "phpunit --do-not-cache-result --coverage-clover=coverage.xml",
"check:tests:sof": "phpunit --stop-on-failure --do-not-cache-result",
"check:tests:unit": "phpunit --do-not-cache-result",
"fix": [
"@fix:php"
],
"fix:composer:normalize": "\"./.phive/composer-normalize\" --no-check-lock",
"fix:php": [
"@fix:composer:normalize",
"@fix:php:rector",
"@fix:php:codesniffer",
"@fix:php:fixer"
],
"fix:php:codesniffer": "phpcbf --standard=config/phpcs.xml bin config src tests",
"fix:php:fixer": "\"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix bin config src tests",
"fix:php:rector": "rector process --config=config/rector.php",
"phpstan:baseline": "phpstan --configuration=config/phpstan.neon --generate-baseline=config/phpstan-baseline.neon --allow-empty-baseline",
"phpstan:clearcache": "phpstan clear-result-cache"
},
"scripts-descriptions": {
"check": "Runs all dynamic and static code checks.",
"check:composer:normalize": "Checks the formatting and structure of the composer.json.",
"check:dynamic": "Runs all dynamic code checks (i.e., currently, the unit tests).",
"check:php:codesniffer": "Checks the code style with PHP_CodeSniffer.",
"check:php:fixer": "Checks the code style with PHP CS Fixer.",
"check:php:lint": "Checks the syntax of the PHP code.",
"check:php:rector": "Checks the code for possible code updates and refactoring.",
"check:php:stan": "Checks the types with PHPStan.",
"check:static": "Runs all static code analysis checks for the code.",
"check:tests": "Runs all dynamic tests (i.e., currently, the unit tests).",
"check:tests:coverage": "Runs the unit tests with code coverage.",
"check:tests:sof": "Runs the unit tests and stops at the first failure.",
"check:tests:unit": "Runs all unit tests.",
"fix": "Runs all fixers",
"fix:composer:normalize": "Reformats and sorts the composer.json file.",
"fix:php": "Autofixes all autofixable issues in the PHP code.",
"fix:php:codesniffer": "Reformats the code with PHP_CodeSniffer.",
"fix:php:fixer": "Fixes autofixable issues found by PHP CS Fixer.",
"fix:php:rector": "Fixes autofixable issues found by Rector.",
"phpstan:baseline": "Updates the PHPStan baseline file to match the code.",
"phpstan:clearcache": "Clears the PHPStan cache."
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS;
/**
* Represents any entity in the CSS that is encapsulated by a class.
*
* Its primary purpose is to provide a type for use with `Document::getAllValues()`
* when a subset of values from a particular part of the document is required.
*
* Thus, elements which don't contain `Value`s (such as statement at-rules) don't need to implement this.
*
* It extends `Renderable` because every element is renderable.
*/
interface CSSElement extends Renderable {}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\CSSList;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Property\AtRule;
/**
* A `BlockList` constructed by an unknown at-rule. `@media` rules are rendered into `AtRuleBlockList` objects.
*/
class AtRuleBlockList extends CSSBlockList implements AtRule
{
/**
* @var non-empty-string
*/
private $type;
/**
* @var string
*/
private $arguments;
/**
* @param non-empty-string $type
* @param int<1, max>|null $lineNumber
*/
public function __construct(string $type, string $arguments = '', ?int $lineNumber = null)
{
parent::__construct($lineNumber);
$this->type = $type;
$this->arguments = $arguments;
}
/**
* @return non-empty-string
*/
public function atRuleName(): string
{
return $this->type;
}
public function atRuleArgs(): string
{
return $this->arguments;
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
$formatter = $outputFormat->getFormatter();
$result = $formatter->comments($this);
$result .= $outputFormat->getContentBeforeAtRuleBlock();
$arguments = $this->arguments;
if ($arguments !== '') {
$arguments = ' ' . $arguments;
}
$result .= "@{$this->type}$arguments{$formatter->spaceBeforeOpeningBrace()}{";
$result .= $this->renderListContents($outputFormat);
$result .= '}';
$result .= $outputFormat->getContentAfterAtRuleBlock();
return $result;
}
public function isRootList(): bool
{
return false;
}
}

View File

@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\CSSList;
use Sabberworm\CSS\CSSElement;
use Sabberworm\CSS\Property\Declaration;
use Sabberworm\CSS\Property\Selector;
use Sabberworm\CSS\RuleSet\DeclarationBlock;
use Sabberworm\CSS\RuleSet\DeclarationList;
use Sabberworm\CSS\RuleSet\RuleSet;
use Sabberworm\CSS\Value\CSSFunction;
use Sabberworm\CSS\Value\Value;
use Sabberworm\CSS\Value\ValueList;
/**
* A `CSSBlockList` is a `CSSList` whose `DeclarationBlock`s are guaranteed to contain valid declaration blocks or
* at-rules.
*
* Most `CSSList`s conform to this category but some at-rules (such as `@keyframes`) do not.
*/
abstract class CSSBlockList extends CSSList
{
/**
* Gets all `DeclarationBlock` objects recursively, no matter how deeply nested the selectors are.
*
* @return list<DeclarationBlock>
*/
public function getAllDeclarationBlocks(): array
{
$result = [];
foreach ($this->contents as $item) {
if ($item instanceof DeclarationBlock) {
$result[] = $item;
} elseif ($item instanceof CSSBlockList) {
$result = \array_merge($result, $item->getAllDeclarationBlocks());
}
}
return $result;
}
/**
* Returns all `RuleSet` objects recursively found in the tree, no matter how deeply nested the rule sets are.
*
* @return list<RuleSet>
*/
public function getAllRuleSets(): array
{
$result = [];
foreach ($this->contents as $item) {
if ($item instanceof RuleSet) {
$result[] = $item;
} elseif ($item instanceof CSSBlockList) {
$result = \array_merge($result, $item->getAllRuleSets());
} elseif ($item instanceof DeclarationBlock) {
$result[] = $item->getRuleSet();
}
}
return $result;
}
/**
* Returns all `Value` objects found recursively in `Declaration`s in the tree.
*
* @param CSSElement|null $element
* This is the `CSSList` or `RuleSet` to start the search from (defaults to the whole document).
* @param string|null $ruleSearchPattern
* This allows filtering rules by property name
* (e.g. if "color" is passed, only `Value`s from `color` properties will be returned,
* or if "font-" is provided, `Value`s from all font rules, like `font-size`, and including `font` itself,
* will be returned).
* @param bool $searchInFunctionArguments whether to also return `Value` objects used as `CSSFunction` arguments.
*
* @return list<Value>
*
* @see RuleSet->getRules()
*/
public function getAllValues(
?CSSElement $element = null,
?string $ruleSearchPattern = null,
bool $searchInFunctionArguments = false
): array {
$element = $element ?? $this;
$result = [];
if ($element instanceof CSSBlockList) {
foreach ($element->getContents() as $contentItem) {
// Statement at-rules are skipped since they do not contain values.
if ($contentItem instanceof CSSElement) {
$result = \array_merge(
$result,
$this->getAllValues($contentItem, $ruleSearchPattern, $searchInFunctionArguments)
);
}
}
} elseif ($element instanceof DeclarationList) {
foreach ($element->getRules($ruleSearchPattern) as $rule) {
$result = \array_merge(
$result,
$this->getAllValues($rule, $ruleSearchPattern, $searchInFunctionArguments)
);
}
} elseif ($element instanceof Declaration) {
$value = $element->getValue();
// `string` values are discarded.
if ($value instanceof CSSElement) {
$result = \array_merge(
$result,
$this->getAllValues($value, $ruleSearchPattern, $searchInFunctionArguments)
);
}
} elseif ($element instanceof ValueList) {
if ($searchInFunctionArguments || !($element instanceof CSSFunction)) {
foreach ($element->getListComponents() as $component) {
// `string` components are discarded.
if ($component instanceof CSSElement) {
$result = \array_merge(
$result,
$this->getAllValues($component, $ruleSearchPattern, $searchInFunctionArguments)
);
}
}
}
} elseif ($element instanceof Value) {
$result[] = $element;
}
return $result;
}
/**
* @return list<Selector>
*/
protected function getAllSelectors(?string $specificitySearch = null): array
{
$result = [];
foreach ($this->getAllDeclarationBlocks() as $declarationBlock) {
foreach ($declarationBlock->getSelectors() as $selector) {
if ($specificitySearch === null) {
$result[] = $selector;
} else {
$comparator = '===';
$expressionParts = \explode(' ', $specificitySearch);
$targetSpecificity = $expressionParts[0];
if (\count($expressionParts) > 1) {
$comparator = $expressionParts[0];
$targetSpecificity = $expressionParts[1];
}
$targetSpecificity = (int) $targetSpecificity;
$selectorSpecificity = $selector->getSpecificity();
$comparatorMatched = false;
switch ($comparator) {
case '<=':
$comparatorMatched = $selectorSpecificity <= $targetSpecificity;
break;
case '<':
$comparatorMatched = $selectorSpecificity < $targetSpecificity;
break;
case '>=':
$comparatorMatched = $selectorSpecificity >= $targetSpecificity;
break;
case '>':
$comparatorMatched = $selectorSpecificity > $targetSpecificity;
break;
default:
$comparatorMatched = $selectorSpecificity === $targetSpecificity;
break;
}
if ($comparatorMatched) {
$result[] = $selector;
}
}
}
}
return $result;
}
}

View File

@@ -0,0 +1,478 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\CSSList;
use Sabberworm\CSS\Comment\CommentContainer;
use Sabberworm\CSS\CSSElement;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\Position\Position;
use Sabberworm\CSS\Position\Positionable;
use Sabberworm\CSS\Property\AtRule;
use Sabberworm\CSS\Property\Charset;
use Sabberworm\CSS\Property\CSSNamespace;
use Sabberworm\CSS\Property\Import;
use Sabberworm\CSS\Property\Selector;
use Sabberworm\CSS\RuleSet\AtRuleSet;
use Sabberworm\CSS\RuleSet\DeclarationBlock;
use Sabberworm\CSS\RuleSet\RuleSet;
use Sabberworm\CSS\Value\CSSString;
use Sabberworm\CSS\Value\URL;
use Sabberworm\CSS\Value\Value;
use function Safe\preg_match;
/**
* This is the most generic container available. It can contain `DeclarationBlock`s (rule sets with a selector),
* `RuleSet`s as well as other `CSSList` objects.
*
* It can also contain `Import` and `Charset` objects stemming from at-rules.
*
* Note that `CSSListItem` extends both `Commentable` and `Renderable`,
* so those interfaces must also be implemented by concrete subclasses.
*/
abstract class CSSList implements CSSElement, CSSListItem, Positionable
{
use CommentContainer;
use Position;
/**
* @var array<int<0, max>, CSSListItem>
*
* @internal since 8.8.0
*/
protected $contents = [];
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(?int $lineNumber = null)
{
$this->setPosition($lineNumber);
}
/**
* @throws UnexpectedTokenException
* @throws SourceException
*
* @internal since V8.8.0
*/
public static function parseList(ParserState $parserState, CSSList $list): void
{
$isRoot = $list instanceof Document;
$usesLenientParsing = $parserState->getSettings()->usesLenientParsing();
$comments = [];
while (!$parserState->isEnd()) {
$parserState->consumeWhiteSpace($comments);
$listItem = null;
if ($usesLenientParsing) {
try {
$positionBeforeParse = $parserState->currentColumn();
$listItem = self::parseListItem($parserState, $list);
} catch (UnexpectedTokenException $e) {
$listItem = false;
// If the failed parsing did not consume anything that was to come ...
if ($parserState->currentColumn() === $positionBeforeParse && !$parserState->isEnd()) {
// ... the unexpected token needs to be skipped, otherwise there'll be an infinite loop.
$parserState->consume(1);
}
}
} else {
$listItem = self::parseListItem($parserState, $list);
}
if ($listItem === null) {
// List parsing finished
return;
}
if ($listItem) {
$listItem->addComments($comments);
$list->append($listItem);
}
$comments = [];
$parserState->consumeWhiteSpace($comments);
}
$list->addComments($comments);
if (!$isRoot && !$usesLenientParsing) {
throw new SourceException('Unexpected end of document', $parserState->currentLine());
}
}
/**
* @return CSSListItem|false|null
* If `null` is returned, it means the end of the list has been reached.
* If `false` is returned, it means an invalid item has been encountered,
* but parsing of the next item should proceed.
*
* @throws SourceException
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
private static function parseListItem(ParserState $parserState, CSSList $list)
{
$isRoot = $list instanceof Document;
if ($parserState->comes('@')) {
$atRule = self::parseAtRule($parserState);
if ($atRule instanceof Charset) {
if (!$isRoot) {
throw new UnexpectedTokenException(
'@charset may only occur in root document',
'',
'custom',
$parserState->currentLine()
);
}
if (\count($list->getContents()) > 0) {
throw new UnexpectedTokenException(
'@charset must be the first parseable token in a document',
'',
'custom',
$parserState->currentLine()
);
}
$parserState->setCharset($atRule->getCharset());
}
return $atRule;
} elseif ($parserState->comes('}')) {
if ($isRoot) {
if ($parserState->getSettings()->usesLenientParsing()) {
$parserState->consume(1);
return self::parseListItem($parserState, $list);
} else {
throw new SourceException('Unopened {', $parserState->currentLine());
}
} else {
// End of list
return null;
}
} else {
return DeclarationBlock::parse($parserState, $list) ?? false;
}
}
/**
* @throws SourceException
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*/
private static function parseAtRule(ParserState $parserState): ?CSSListItem
{
$parserState->consume('@');
$identifier = $parserState->parseIdentifier();
$identifierLineNumber = $parserState->currentLine();
$parserState->consumeWhiteSpace();
if ($identifier === 'import') {
$location = URL::parse($parserState);
$parserState->consumeWhiteSpace();
$mediaQuery = null;
if (!$parserState->comes(';')) {
$mediaQuery = \trim($parserState->consumeUntil([';', ParserState::EOF]));
if ($mediaQuery === '') {
$mediaQuery = null;
}
}
$parserState->consumeUntil([';', ParserState::EOF], true, true);
return new Import($location, $mediaQuery, $identifierLineNumber);
} elseif ($identifier === 'charset') {
$charsetString = CSSString::parse($parserState);
$parserState->consumeWhiteSpace();
$parserState->consumeUntil([';', ParserState::EOF], true, true);
return new Charset($charsetString, $identifierLineNumber);
} elseif (self::identifierIs($identifier, 'keyframes')) {
$result = new KeyFrame($identifierLineNumber);
$result->setVendorKeyFrame($identifier);
$result->setAnimationName(\trim($parserState->consumeUntil('{', false, true)));
CSSList::parseList($parserState, $result);
if ($parserState->comes('}')) {
$parserState->consume('}');
}
return $result;
} elseif ($identifier === 'namespace') {
$prefix = null;
$url = Value::parsePrimitiveValue($parserState);
if (!$parserState->comes(';')) {
$prefix = $url;
$url = Value::parsePrimitiveValue($parserState);
}
$parserState->consumeUntil([';', ParserState::EOF], true, true);
if ($prefix !== null && !\is_string($prefix)) {
throw new UnexpectedTokenException('Wrong namespace prefix', $prefix, 'custom', $identifierLineNumber);
}
if (!($url instanceof CSSString || $url instanceof URL)) {
throw new UnexpectedTokenException(
'Wrong namespace url of invalid type',
$url,
'custom',
$identifierLineNumber
);
}
return new CSSNamespace($url, $prefix, $identifierLineNumber);
} else {
// Unknown other at rule (font-face or such)
$arguments = \trim($parserState->consumeUntil('{', false, true));
if (\substr_count($arguments, '(') !== \substr_count($arguments, ')')) {
if ($parserState->getSettings()->usesLenientParsing()) {
return null;
} else {
throw new SourceException('Unmatched brace count in media query', $parserState->currentLine());
}
}
$useRuleSet = true;
foreach (\explode('/', AtRule::BLOCK_RULES) as $blockRuleName) {
if (self::identifierIs($identifier, $blockRuleName)) {
$useRuleSet = false;
break;
}
}
if ($useRuleSet) {
$atRule = new AtRuleSet($identifier, $arguments, $identifierLineNumber);
RuleSet::parseRuleSet($parserState, $atRule);
} else {
$atRule = new AtRuleBlockList($identifier, $arguments, $identifierLineNumber);
CSSList::parseList($parserState, $atRule);
if ($parserState->comes('}')) {
$parserState->consume('}');
}
}
return $atRule;
}
}
/**
* Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed.
* We need to check for these versions too.
*/
private static function identifierIs(string $identifier, string $match): bool
{
if (\strcasecmp($identifier, $match) === 0) {
return true;
}
return preg_match("/^(-\\w+-)?$match$/i", $identifier) === 1;
}
/**
* Prepends an item to the list of contents.
*/
public function prepend(CSSListItem $item): void
{
\array_unshift($this->contents, $item);
}
/**
* Appends an item to the list of contents.
*/
public function append(CSSListItem $item): void
{
$this->contents[] = $item;
}
/**
* Splices the list of contents.
*
* @param array<int, CSSListItem> $replacement
*/
public function splice(int $offset, ?int $length = null, ?array $replacement = null): void
{
\array_splice($this->contents, $offset, $length, $replacement);
}
/**
* Inserts an item in the CSS list before its sibling. If the desired sibling cannot be found,
* the item is appended at the end.
*/
public function insertBefore(CSSListItem $item, CSSListItem $sibling): void
{
if (\in_array($sibling, $this->contents, true)) {
$this->replace($sibling, [$item, $sibling]);
} else {
$this->append($item);
}
}
/**
* Removes an item from the CSS list.
*
* @param CSSListItem $itemToRemove
* May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`,
* a `Charset` or another `CSSList` (most likely a `MediaQuery`)
*
* @return bool whether the item was removed
*/
public function remove(CSSListItem $itemToRemove): bool
{
$key = \array_search($itemToRemove, $this->contents, true);
if ($key !== false) {
unset($this->contents[$key]);
return true;
}
return false;
}
/**
* Replaces an item from the CSS list.
*
* @param CSSListItem $oldItem
* May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`, a `Charset`
* or another `CSSList` (most likely a `MediaQuery`)
* @param CSSListItem|array<CSSListItem> $newItem
*/
public function replace(CSSListItem $oldItem, $newItem): bool
{
$key = \array_search($oldItem, $this->contents, true);
if ($key !== false) {
if (\is_array($newItem)) {
\array_splice($this->contents, $key, 1, $newItem);
} else {
\array_splice($this->contents, $key, 1, [$newItem]);
}
return true;
}
return false;
}
/**
* @param array<int, CSSListItem> $contents
*/
public function setContents(array $contents): void
{
$this->contents = [];
foreach ($contents as $content) {
$this->append($content);
}
}
/**
* Removes a declaration block from the CSS list if it matches all given selectors.
*
* @param DeclarationBlock|array<Selector>|string $selectors the selectors to match
* @param bool $removeAll whether to stop at the first declaration block found or remove all blocks
*/
public function removeDeclarationBlockBySelector($selectors, bool $removeAll = false): void
{
if ($selectors instanceof DeclarationBlock) {
$selectors = $selectors->getSelectors();
}
if (!\is_array($selectors)) {
$selectors = \explode(',', $selectors);
}
foreach ($selectors as $key => &$selector) {
if (!($selector instanceof Selector)) {
if (!Selector::isValid($selector)) {
throw new UnexpectedTokenException(
"Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.",
$selector,
'custom'
);
}
$selector = new Selector($selector);
}
}
foreach ($this->contents as $key => $item) {
if (!($item instanceof DeclarationBlock)) {
continue;
}
if (self::selectorsMatch($item->getSelectors(), $selectors)) {
unset($this->contents[$key]);
if (!$removeAll) {
return;
}
}
}
}
protected function renderListContents(OutputFormat $outputFormat): string
{
$result = '';
$isFirst = true;
$nextLevelFormat = $outputFormat;
if (!$this->isRootList()) {
$nextLevelFormat = $outputFormat->nextLevel();
}
$nextLevelFormatter = $nextLevelFormat->getFormatter();
$formatter = $outputFormat->getFormatter();
foreach ($this->contents as $listItem) {
$renderedCss = $formatter->safely(static function () use ($nextLevelFormat, $listItem): string {
return $listItem->render($nextLevelFormat);
});
if ($renderedCss === null) {
continue;
}
if ($isFirst) {
$isFirst = false;
$result .= $nextLevelFormatter->spaceBeforeBlocks();
} else {
$result .= $nextLevelFormatter->spaceBetweenBlocks();
}
$result .= $renderedCss;
}
if (!$isFirst) {
// Had some output
$result .= $formatter->spaceAfterBlocks();
}
return $result;
}
/**
* Return true if the list can not be further outdented. Only important when rendering.
*/
abstract public function isRootList(): bool;
/**
* Returns the stored items.
*
* @return array<int<0, max>, CSSListItem>
*/
public function getContents(): array
{
return $this->contents;
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
}
/**
* @param list<Selector> $selectors1
* @param list<Selector> $selectors2
*/
private static function selectorsMatch(array $selectors1, array $selectors2): bool
{
$selectorStrings1 = self::getSelectorStrings($selectors1);
$selectorStrings2 = self::getSelectorStrings($selectors2);
\sort($selectorStrings1);
\sort($selectorStrings2);
return $selectorStrings1 === $selectorStrings2;
}
/**
* @param list<Selector> $selectors
*
* @return list<string>
*/
private static function getSelectorStrings(array $selectors): array
{
return \array_map(
static function (Selector $selector): string {
return $selector->getSelector();
},
$selectors
);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\CSSList;
use Sabberworm\CSS\Comment\Commentable;
use Sabberworm\CSS\Renderable;
/**
* Represents anything that can be in the `$contents` of a `CSSList`.
*
* The interface does not define any methods to implement.
* It's purpose is to allow a single type to be specified for `CSSList::$contents` and manipulation methods thereof.
* It extends `Commentable` and `Renderable` because all `CSSListItem`s are both.
* This allows implementations to call methods from those interfaces without any additional type checks.
*/
interface CSSListItem extends Commentable, Renderable {}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\CSSList;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Property\Selector;
/**
* This class represents the root of a parsed CSS file. It contains all top-level CSS contents: mostly declaration
* blocks, but also any at-rules encountered (`Import` and `Charset`).
*/
class Document extends CSSBlockList
{
/**
* @throws SourceException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState): Document
{
$document = new Document($parserState->currentLine());
CSSList::parseList($parserState, $document);
return $document;
}
/**
* Returns all `Selector` objects with the requested specificity found recursively in the tree.
*
* Note that this does not yield the full `DeclarationBlock` that the selector belongs to
* (and, currently, there is no way to get to that).
*
* @param string|null $specificitySearch
* An optional filter by specificity.
* May contain a comparison operator and a number or just a number (defaults to "==").
*
* @return list<Selector>
*
* @example `getSelectorsBySpecificity('>= 100')`
*/
public function getSelectorsBySpecificity(?string $specificitySearch = null): array
{
return $this->getAllSelectors($specificitySearch);
}
/**
* Overrides `render()` to make format argument optional.
*/
public function render(?OutputFormat $outputFormat = null): string
{
if ($outputFormat === null) {
$outputFormat = new OutputFormat();
}
return $outputFormat->getFormatter()->comments($this) . $this->renderListContents($outputFormat);
}
public function isRootList(): bool
{
return true;
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\CSSList;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Property\AtRule;
class KeyFrame extends CSSList implements AtRule
{
/**
* @var non-empty-string
*/
private $vendorKeyFrame = 'keyframes';
/**
* @var non-empty-string
*/
private $animationName = 'none';
/**
* @param non-empty-string $vendorKeyFrame
*/
public function setVendorKeyFrame(string $vendorKeyFrame): void
{
$this->vendorKeyFrame = $vendorKeyFrame;
}
/**
* @return non-empty-string
*/
public function getVendorKeyFrame(): string
{
return $this->vendorKeyFrame;
}
/**
* @param non-empty-string $animationName
*/
public function setAnimationName(string $animationName): void
{
$this->animationName = $animationName;
}
/**
* @return non-empty-string
*/
public function getAnimationName(): string
{
return $this->animationName;
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
$formatter = $outputFormat->getFormatter();
$result = $formatter->comments($this);
$result .= "@{$this->vendorKeyFrame} {$this->animationName}{$formatter->spaceBeforeOpeningBrace()}{";
$result .= $this->renderListContents($outputFormat);
$result .= '}';
return $result;
}
public function isRootList(): bool
{
return false;
}
/**
* @return non-empty-string
*/
public function atRuleName(): string
{
return $this->vendorKeyFrame;
}
/**
* @return non-empty-string
*/
public function atRuleArgs(): string
{
return $this->animationName;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Comment;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Position\Position;
use Sabberworm\CSS\Position\Positionable;
use Sabberworm\CSS\Renderable;
use Sabberworm\CSS\ShortClassNameProvider;
class Comment implements Positionable, Renderable
{
use Position;
use ShortClassNameProvider;
/**
* @var string
*
* @internal since 8.8.0
*/
protected $commentText;
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(string $commentText = '', ?int $lineNumber = null)
{
$this->commentText = $commentText;
$this->setPosition($lineNumber);
}
public function getComment(): string
{
return $this->commentText;
}
public function setComment(string $commentText): void
{
$this->commentText = $commentText;
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
return '/*' . $this->commentText . '*/';
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
return [
'class' => $this->getShortClassName(),
// "contents" is the term used in the W3C specs:
// https://www.w3.org/TR/CSS22/syndata.html#comments
'contents' => $this->commentText,
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Comment;
/**
* Provides a standard reusable implementation of `Commentable`.
*
* @internal
*
* @phpstan-require-implements Commentable
*/
trait CommentContainer
{
/**
* @var list<Comment>
*/
protected $comments = [];
/**
* @param list<Comment> $comments
*/
public function addComments(array $comments): void
{
$this->comments = \array_merge($this->comments, $comments);
}
/**
* @return list<Comment>
*/
public function getComments(): array
{
return $this->comments;
}
/**
* @param list<Comment> $comments
*/
public function setComments(array $comments): void
{
$this->comments = $comments;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Comment;
/**
* A standard implementation of this interface is available in the `CommentContainer` trait.
*/
interface Commentable
{
/**
* @param list<Comment> $comments
*/
public function addComments(array $comments): void;
/**
* @return list<Comment>
*/
public function getComments(): array;
/**
* @param list<Comment> $comments
*/
public function setComments(array $comments): void;
}

View File

@@ -0,0 +1,778 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS;
final class OutputFormat
{
/**
* @var '"'|"'"
*/
private $stringQuotingType = '"';
/**
* Output RGB colors in hash notation if possible
*
* @var bool
*/
private $usesRgbHashNotation = true;
/**
* Declaration format
*
* Semicolon after the last rule of a declaration block can be omitted. To do that, set this false.
*
* @var bool
*/
private $renderSemicolonAfterLastRule = true;
/**
* Spacing
* Note that these strings are not sanity-checked: the value should only consist of whitespace
* Any newline character will be indented according to the current level.
* The triples (After, Before, Between) can be set using a wildcard
* (e.g. `$outputFormat->set('Space*Rules', "\n");`)
*
* @var string
*/
private $spaceAfterRuleName = ' ';
/**
* @var string
*/
private $spaceBeforeRules = '';
/**
* @var string
*/
private $spaceAfterRules = '';
/**
* @var string
*/
private $spaceBetweenRules = '';
/**
* @var string
*/
private $spaceBeforeBlocks = '';
/**
* @var string
*/
private $spaceAfterBlocks = '';
/**
* @var string
*/
private $spaceBetweenBlocks = "\n";
/**
* Content injected in and around at-rule blocks.
*
* @var string
*/
private $contentBeforeAtRuleBlock = '';
/**
* @var string
*/
private $contentAfterAtRuleBlock = '';
/**
* This is whats printed before and after the comma if a declaration block contains multiple selectors.
*
* @var string
*/
private $spaceBeforeSelectorSeparator = '';
/**
* @var string
*/
private $spaceAfterSelectorSeparator = ' ';
/**
* @var string
*/
private $spaceAroundSelectorCombinator = ' ';
/**
* This is whats inserted before the separator in value lists, by default.
*
* @var string
*/
private $spaceBeforeListArgumentSeparator = '';
/**
* Keys are separators (e.g. `,`). Values are the space sequence to insert, or an empty string.
*
* @var array<non-empty-string, string>
*/
private $spaceBeforeListArgumentSeparators = [];
/**
* This is whats inserted after the separator in value lists, by default.
*
* @var string
*/
private $spaceAfterListArgumentSeparator = '';
/**
* Keys are separators (e.g. `,`). Values are the space sequence to insert, or an empty string.
*
* @var array<non-empty-string, string>
*/
private $spaceAfterListArgumentSeparators = [];
/**
* @var string
*/
private $spaceBeforeOpeningBrace = ' ';
/**
* Content injected in and around declaration blocks.
*
* @var string
*/
private $contentBeforeDeclarationBlock = '';
/**
* @var string
*/
private $contentAfterDeclarationBlockSelectors = '';
/**
* @var string
*/
private $contentAfterDeclarationBlock = '';
/**
* Indentation character(s) per level. Only applicable if newlines are used in any of the spacing settings.
*
* @var string
*/
private $indentation = "\t";
/**
* Output exceptions.
*
* @var bool
*/
private $shouldIgnoreExceptions = false;
/**
* Render comments for lists and RuleSets
*
* @var bool
*/
private $shouldRenderComments = false;
/**
* @var OutputFormatter|null
*/
private $outputFormatter;
/**
* @var OutputFormat|null
*/
private $nextLevelFormat;
/**
* @var int<0, max>
*/
private $indentationLevel = 0;
/**
* @return '"'|"'"
*
* @internal
*/
public function getStringQuotingType(): string
{
return $this->stringQuotingType;
}
/**
* @param '"'|"'" $quotingType
*
* @return $this fluent interface
*/
public function setStringQuotingType(string $quotingType): self
{
$this->stringQuotingType = $quotingType;
return $this;
}
/**
* @internal
*/
public function usesRgbHashNotation(): bool
{
return $this->usesRgbHashNotation;
}
/**
* @return $this fluent interface
*/
public function setRGBHashNotation(bool $usesRgbHashNotation): self
{
$this->usesRgbHashNotation = $usesRgbHashNotation;
return $this;
}
/**
* @internal
*/
public function shouldRenderSemicolonAfterLastRule(): bool
{
return $this->renderSemicolonAfterLastRule;
}
/**
* @return $this fluent interface
*/
public function setSemicolonAfterLastRule(bool $renderSemicolonAfterLastRule): self
{
$this->renderSemicolonAfterLastRule = $renderSemicolonAfterLastRule;
return $this;
}
/**
* @internal
*/
public function getSpaceAfterRuleName(): string
{
return $this->spaceAfterRuleName;
}
/**
* @return $this fluent interface
*/
public function setSpaceAfterRuleName(string $whitespace): self
{
$this->spaceAfterRuleName = $whitespace;
return $this;
}
/**
* @internal
*/
public function getSpaceBeforeRules(): string
{
return $this->spaceBeforeRules;
}
/**
* @return $this fluent interface
*/
public function setSpaceBeforeRules(string $whitespace): self
{
$this->spaceBeforeRules = $whitespace;
return $this;
}
/**
* @internal
*/
public function getSpaceAfterRules(): string
{
return $this->spaceAfterRules;
}
/**
* @return $this fluent interface
*/
public function setSpaceAfterRules(string $whitespace): self
{
$this->spaceAfterRules = $whitespace;
return $this;
}
/**
* @internal
*/
public function getSpaceBetweenRules(): string
{
return $this->spaceBetweenRules;
}
/**
* @return $this fluent interface
*/
public function setSpaceBetweenRules(string $whitespace): self
{
$this->spaceBetweenRules = $whitespace;
return $this;
}
/**
* @internal
*/
public function getSpaceBeforeBlocks(): string
{
return $this->spaceBeforeBlocks;
}
/**
* @return $this fluent interface
*/
public function setSpaceBeforeBlocks(string $whitespace): self
{
$this->spaceBeforeBlocks = $whitespace;
return $this;
}
/**
* @internal
*/
public function getSpaceAfterBlocks(): string
{
return $this->spaceAfterBlocks;
}
/**
* @return $this fluent interface
*/
public function setSpaceAfterBlocks(string $whitespace): self
{
$this->spaceAfterBlocks = $whitespace;
return $this;
}
/**
* @internal
*/
public function getSpaceBetweenBlocks(): string
{
return $this->spaceBetweenBlocks;
}
/**
* @return $this fluent interface
*/
public function setSpaceBetweenBlocks(string $whitespace): self
{
$this->spaceBetweenBlocks = $whitespace;
return $this;
}
/**
* @internal
*/
public function getContentBeforeAtRuleBlock(): string
{
return $this->contentBeforeAtRuleBlock;
}
/**
* @return $this fluent interface
*/
public function setBeforeAtRuleBlock(string $content): self
{
$this->contentBeforeAtRuleBlock = $content;
return $this;
}
/**
* @internal
*/
public function getContentAfterAtRuleBlock(): string
{
return $this->contentAfterAtRuleBlock;
}
/**
* @return $this fluent interface
*/
public function setAfterAtRuleBlock(string $content): self
{
$this->contentAfterAtRuleBlock = $content;
return $this;
}
/**
* @internal
*/
public function getSpaceBeforeSelectorSeparator(): string
{
return $this->spaceBeforeSelectorSeparator;
}
/**
* @return $this fluent interface
*/
public function setSpaceBeforeSelectorSeparator(string $whitespace): self
{
$this->spaceBeforeSelectorSeparator = $whitespace;
return $this;
}
/**
* @internal
*/
public function getSpaceAfterSelectorSeparator(): string
{
return $this->spaceAfterSelectorSeparator;
}
/**
* @return $this fluent interface
*/
public function setSpaceAfterSelectorSeparator(string $whitespace): self
{
$this->spaceAfterSelectorSeparator = $whitespace;
return $this;
}
/**
* @internal
*/
public function getSpaceAroundSelectorCombinator(): string
{
return $this->spaceAroundSelectorCombinator;
}
/**
* The spacing set is also used for the descendent combinator, which is whitespace only,
* unless an empty string is set, in which case a space will be used.
*
* @return $this fluent interface
*/
public function setSpaceAroundSelectorCombinator(string $whitespace): self
{
$this->spaceAroundSelectorCombinator = $whitespace;
return $this;
}
/**
* @internal
*/
public function getSpaceBeforeListArgumentSeparator(): string
{
return $this->spaceBeforeListArgumentSeparator;
}
/**
* @return $this fluent interface
*/
public function setSpaceBeforeListArgumentSeparator(string $whitespace): self
{
$this->spaceBeforeListArgumentSeparator = $whitespace;
return $this;
}
/**
* @return array<non-empty-string, string>
*
* @internal
*/
public function getSpaceBeforeListArgumentSeparators(): array
{
return $this->spaceBeforeListArgumentSeparators;
}
/**
* @param array<non-empty-string, string> $separatorSpaces
*
* @return $this fluent interface
*/
public function setSpaceBeforeListArgumentSeparators(array $separatorSpaces): self
{
$this->spaceBeforeListArgumentSeparators = $separatorSpaces;
return $this;
}
/**
* @internal
*/
public function getSpaceAfterListArgumentSeparator(): string
{
return $this->spaceAfterListArgumentSeparator;
}
/**
* @return $this fluent interface
*/
public function setSpaceAfterListArgumentSeparator(string $whitespace): self
{
$this->spaceAfterListArgumentSeparator = $whitespace;
return $this;
}
/**
* @return array<non-empty-string, string>
*
* @internal
*/
public function getSpaceAfterListArgumentSeparators(): array
{
return $this->spaceAfterListArgumentSeparators;
}
/**
* @param array<non-empty-string, string> $separatorSpaces
*
* @return $this fluent interface
*/
public function setSpaceAfterListArgumentSeparators(array $separatorSpaces): self
{
$this->spaceAfterListArgumentSeparators = $separatorSpaces;
return $this;
}
/**
* @internal
*/
public function getSpaceBeforeOpeningBrace(): string
{
return $this->spaceBeforeOpeningBrace;
}
/**
* @return $this fluent interface
*/
public function setSpaceBeforeOpeningBrace(string $whitespace): self
{
$this->spaceBeforeOpeningBrace = $whitespace;
return $this;
}
/**
* @internal
*/
public function getContentBeforeDeclarationBlock(): string
{
return $this->contentBeforeDeclarationBlock;
}
/**
* @return $this fluent interface
*/
public function setBeforeDeclarationBlock(string $content): self
{
$this->contentBeforeDeclarationBlock = $content;
return $this;
}
/**
* @internal
*/
public function getContentAfterDeclarationBlockSelectors(): string
{
return $this->contentAfterDeclarationBlockSelectors;
}
/**
* @return $this fluent interface
*/
public function setAfterDeclarationBlockSelectors(string $content): self
{
$this->contentAfterDeclarationBlockSelectors = $content;
return $this;
}
/**
* @internal
*/
public function getContentAfterDeclarationBlock(): string
{
return $this->contentAfterDeclarationBlock;
}
/**
* @return $this fluent interface
*/
public function setAfterDeclarationBlock(string $content): self
{
$this->contentAfterDeclarationBlock = $content;
return $this;
}
/**
* @internal
*/
public function getIndentation(): string
{
return $this->indentation;
}
/**
* @return $this fluent interface
*/
public function setIndentation(string $indentation): self
{
$this->indentation = $indentation;
return $this;
}
/**
* @internal
*/
public function shouldIgnoreExceptions(): bool
{
return $this->shouldIgnoreExceptions;
}
/**
* @return $this fluent interface
*/
public function setIgnoreExceptions(bool $ignoreExceptions): self
{
$this->shouldIgnoreExceptions = $ignoreExceptions;
return $this;
}
/**
* @internal
*/
public function shouldRenderComments(): bool
{
return $this->shouldRenderComments;
}
/**
* @return $this fluent interface
*/
public function setRenderComments(bool $renderComments): self
{
$this->shouldRenderComments = $renderComments;
return $this;
}
/**
* @return int<0, max>
*
* @internal
*/
public function getIndentationLevel(): int
{
return $this->indentationLevel;
}
/**
* @param int<1, max> $numberOfTabs
*
* @return $this fluent interface
*/
public function indentWithTabs(int $numberOfTabs = 1): self
{
return $this->setIndentation(\str_repeat("\t", $numberOfTabs));
}
/**
* @param int<1, max> $numberOfSpaces
*
* @return $this fluent interface
*/
public function indentWithSpaces(int $numberOfSpaces = 2): self
{
return $this->setIndentation(\str_repeat(' ', $numberOfSpaces));
}
/**
* @internal since V8.8.0
*/
public function nextLevel(): self
{
if ($this->nextLevelFormat === null) {
$this->nextLevelFormat = clone $this;
$this->nextLevelFormat->indentationLevel++;
$this->nextLevelFormat->outputFormatter = null;
}
return $this->nextLevelFormat;
}
public function beLenient(): void
{
$this->shouldIgnoreExceptions = true;
}
/**
* @internal since 8.8.0
*/
public function getFormatter(): OutputFormatter
{
if ($this->outputFormatter === null) {
$this->outputFormatter = new OutputFormatter($this);
}
return $this->outputFormatter;
}
/**
* Creates an instance of this class without any particular formatting settings.
*/
public static function create(): self
{
return new OutputFormat();
}
/**
* Creates an instance of this class with a preset for compact formatting.
*/
public static function createCompact(): self
{
$format = self::create();
$format
->setSpaceBeforeRules('')
->setSpaceBetweenRules('')
->setSpaceAfterRules('')
->setSpaceBeforeBlocks('')
->setSpaceBetweenBlocks('')
->setSpaceAfterBlocks('')
->setSpaceAfterRuleName('')
->setSpaceBeforeOpeningBrace('')
->setSpaceAfterSelectorSeparator('')
->setSpaceAroundSelectorCombinator('')
->setSemicolonAfterLastRule(false)
->setRenderComments(false);
return $format;
}
/**
* Creates an instance of this class with a preset for pretty formatting.
*/
public static function createPretty(): self
{
$format = self::create();
$format
->setSpaceBeforeRules("\n")
->setSpaceBetweenRules("\n")
->setSpaceAfterRules("\n")
->setSpaceBeforeBlocks("\n")
->setSpaceBetweenBlocks("\n\n")
->setSpaceAfterBlocks("\n")
->setSpaceAfterListArgumentSeparators([',' => ' '])
->setRenderComments(true);
return $format;
}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS;
use Sabberworm\CSS\Comment\Commentable;
use Sabberworm\CSS\Parsing\OutputException;
/**
* @internal since 8.8.0
*/
class OutputFormatter
{
/**
* @var OutputFormat
*/
private $outputFormat;
public function __construct(OutputFormat $outputFormat)
{
$this->outputFormat = $outputFormat;
}
/**
* @param non-empty-string $name
*
* @throws \InvalidArgumentException
*/
public function space(string $name): string
{
switch ($name) {
case 'AfterRuleName':
$spaceString = $this->outputFormat->getSpaceAfterRuleName();
break;
case 'BeforeRules':
$spaceString = $this->outputFormat->getSpaceBeforeRules();
break;
case 'AfterRules':
$spaceString = $this->outputFormat->getSpaceAfterRules();
break;
case 'BetweenRules':
$spaceString = $this->outputFormat->getSpaceBetweenRules();
break;
case 'BeforeBlocks':
$spaceString = $this->outputFormat->getSpaceBeforeBlocks();
break;
case 'AfterBlocks':
$spaceString = $this->outputFormat->getSpaceAfterBlocks();
break;
case 'BetweenBlocks':
$spaceString = $this->outputFormat->getSpaceBetweenBlocks();
break;
case 'BeforeSelectorSeparator':
$spaceString = $this->outputFormat->getSpaceBeforeSelectorSeparator();
break;
case 'AfterSelectorSeparator':
$spaceString = $this->outputFormat->getSpaceAfterSelectorSeparator();
break;
case 'BeforeOpeningBrace':
$spaceString = $this->outputFormat->getSpaceBeforeOpeningBrace();
break;
case 'BeforeListArgumentSeparator':
$spaceString = $this->outputFormat->getSpaceBeforeListArgumentSeparator();
break;
case 'AfterListArgumentSeparator':
$spaceString = $this->outputFormat->getSpaceAfterListArgumentSeparator();
break;
default:
throw new \InvalidArgumentException("Unknown space type: $name", 1740049248);
}
return $this->prepareSpace($spaceString);
}
public function spaceAfterRuleName(): string
{
return $this->space('AfterRuleName');
}
public function spaceBeforeRules(): string
{
return $this->space('BeforeRules');
}
public function spaceAfterRules(): string
{
return $this->space('AfterRules');
}
public function spaceBetweenRules(): string
{
return $this->space('BetweenRules');
}
public function spaceBeforeBlocks(): string
{
return $this->space('BeforeBlocks');
}
public function spaceAfterBlocks(): string
{
return $this->space('AfterBlocks');
}
public function spaceBetweenBlocks(): string
{
return $this->space('BetweenBlocks');
}
public function spaceBeforeSelectorSeparator(): string
{
return $this->space('BeforeSelectorSeparator');
}
public function spaceAfterSelectorSeparator(): string
{
return $this->space('AfterSelectorSeparator');
}
/**
* @param non-empty-string $separator
*/
public function spaceBeforeListArgumentSeparator(string $separator): string
{
$spaceForSeparator = $this->outputFormat->getSpaceBeforeListArgumentSeparators();
return $spaceForSeparator[$separator] ?? $this->space('BeforeListArgumentSeparator');
}
/**
* @param non-empty-string $separator
*/
public function spaceAfterListArgumentSeparator(string $separator): string
{
$spaceForSeparator = $this->outputFormat->getSpaceAfterListArgumentSeparators();
return $spaceForSeparator[$separator] ?? $this->space('AfterListArgumentSeparator');
}
public function spaceBeforeOpeningBrace(): string
{
return $this->space('BeforeOpeningBrace');
}
/**
* Runs the given code, either swallowing or passing exceptions, depending on the `ignoreExceptions` setting.
*/
public function safely(callable $callable): ?string
{
if ($this->outputFormat->shouldIgnoreExceptions()) {
// If output exceptions are ignored, run the code with exception guards
try {
return $callable();
} catch (OutputException $e) {
return null;
} // Do nothing
} else {
// Run the code as-is
return $callable();
}
}
/**
* Clone of the `implode` function, but calls `render` with the current output format.
*
* @param array<array-key, Renderable|string> $values
*/
public function implode(string $separator, array $values, bool $increaseLevel = false): string
{
$result = '';
$outputFormat = $this->outputFormat;
if ($increaseLevel) {
$outputFormat = $outputFormat->nextLevel();
}
$isFirst = true;
foreach ($values as $value) {
if ($isFirst) {
$isFirst = false;
} else {
$result .= $separator;
}
if ($value instanceof Renderable) {
$result .= $value->render($outputFormat);
} else {
$result .= $value;
}
}
return $result;
}
public function removeLastSemicolon(string $string): string
{
if ($this->outputFormat->shouldRenderSemicolonAfterLastRule()) {
return $string;
}
$parts = \explode(';', $string);
if (\count($parts) < 2) {
return $parts[0];
}
$lastPart = \array_pop($parts);
$nextToLastPart = \array_pop($parts);
\array_push($parts, $nextToLastPart . $lastPart);
return \implode(';', $parts);
}
public function comments(Commentable $commentable): string
{
if (!$this->outputFormat->shouldRenderComments()) {
return '';
}
$result = '';
$comments = $commentable->getComments();
$lastCommentIndex = \count($comments) - 1;
foreach ($comments as $i => $comment) {
$result .= $comment->render($this->outputFormat);
$result .= $i === $lastCommentIndex ? $this->spaceAfterBlocks() : $this->spaceBetweenBlocks();
}
return $result;
}
private function prepareSpace(string $spaceString): string
{
return \str_replace("\n", "\n" . $this->indent(), $spaceString);
}
private function indent(): string
{
return \str_repeat($this->outputFormat->getIndentation(), $this->outputFormat->getIndentationLevel());
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS;
use Sabberworm\CSS\CSSList\Document;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
/**
* This class parses CSS from text into a data structure.
*/
class Parser
{
/**
* @var ParserState
*/
private $parserState;
/**
* @param string $text the complete CSS as text (i.e., usually the contents of a CSS file)
* @param int<1, max> $lineNumber the line number (starting from 1, not from 0)
*/
public function __construct(string $text, ?Settings $parserSettings = null, int $lineNumber = 1)
{
if ($parserSettings === null) {
$parserSettings = Settings::create();
}
$this->parserState = new ParserState($text, $parserSettings, $lineNumber);
}
/**
* Parses the CSS provided to the constructor and creates a `Document` from it.
*
* @throws SourceException
*/
public function parse(): Document
{
return Document::parse($this->parserState);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Parsing;
/**
* @internal since 8.7.0
*/
class Anchor
{
/**
* @var int<0, max>
*/
private $position;
/**
* @var ParserState
*/
private $parserState;
/**
* @param int<0, max> $position
*/
public function __construct(int $position, ParserState $parserState)
{
$this->position = $position;
$this->parserState = $parserState;
}
public function backtrack(): void
{
$this->parserState->setPosition($this->position);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Parsing;
/**
* Thrown if the CSS parser attempts to print something invalid.
*/
final class OutputException extends SourceException {}

View File

@@ -0,0 +1,502 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Parsing;
use Sabberworm\CSS\Comment\Comment;
use Sabberworm\CSS\Settings;
use function Safe\iconv;
use function Safe\preg_match;
use function Safe\preg_split;
/**
* @internal since 8.7.0
*/
class ParserState
{
public const EOF = null;
/**
* @var Settings
*/
private $parserSettings;
/**
* @var string
*/
private $text;
/**
* @var array<int, string>
*/
private $characters;
/**
* @var int<0, max>
*/
private $currentPosition = 0;
/**
* will only be used if the CSS does not contain an `@charset` declaration
*
* @var string
*/
private $charset;
/**
* @var int<1, max> $lineNumber
*/
private $lineNumber;
/**
* @param string $text the complete CSS as text (i.e., usually the contents of a CSS file)
* @param int<1, max> $lineNumber
*/
public function __construct(string $text, Settings $parserSettings, int $lineNumber = 1)
{
$this->parserSettings = $parserSettings;
$this->text = $text;
$this->lineNumber = $lineNumber;
$this->setCharset($this->parserSettings->getDefaultCharset());
}
/**
* Sets the charset to be used if the CSS does not contain an `@charset` declaration.
*/
public function setCharset(string $charset): void
{
$this->charset = $charset;
$this->characters = $this->strsplit($this->text);
}
/**
* @return int<1, max>
*/
public function currentLine(): int
{
return $this->lineNumber;
}
/**
* @return int<0, max>
*/
public function currentColumn(): int
{
return $this->currentPosition;
}
public function getSettings(): Settings
{
return $this->parserSettings;
}
public function anchor(): Anchor
{
return new Anchor($this->currentPosition, $this);
}
/**
* @param int<0, max> $position
*/
public function setPosition(int $position): void
{
$this->currentPosition = $position;
}
/**
* @return non-empty-string
*
* @throws UnexpectedTokenException
*/
public function parseIdentifier(bool $ignoreCase = true): string
{
if ($this->isEnd()) {
throw new UnexpectedEOFException('', '', 'identifier', $this->lineNumber);
}
$result = $this->parseCharacter(true);
if ($result === null) {
throw new UnexpectedTokenException('', $this->peek(5), 'identifier', $this->lineNumber);
}
$character = null;
while (!$this->isEnd() && ($character = $this->parseCharacter(true)) !== null) {
if (preg_match('/[a-zA-Z0-9\\x{00A0}-\\x{FFFF}_-]/Sux', $character) !== 0) {
$result .= $character;
} else {
$result .= '\\' . $character;
}
}
if ($ignoreCase) {
$result = $this->strtolower($result);
}
return $result;
}
/**
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public function parseCharacter(bool $isForIdentifier): ?string
{
if ($this->peek() === '\\') {
$this->consume('\\');
if ($this->comes('\\n') || $this->comes('\\r')) {
return '';
}
if (preg_match('/[0-9a-fA-F]/Su', $this->peek()) === 0) {
return $this->consume(1);
}
$hexCodePoint = $this->consumeExpression('/^[0-9a-fA-F]{1,6}/u', 6);
if ($this->strlen($hexCodePoint) < 6) {
// Consume whitespace after incomplete unicode escape
if (preg_match('/\\s/isSu', $this->peek()) !== 0) {
if ($this->comes('\\r\\n')) {
$this->consume(2);
} else {
$this->consume(1);
}
}
}
$codePoint = \intval($hexCodePoint, 16);
$utf32EncodedCharacter = '';
for ($i = 0; $i < 4; ++$i) {
$utf32EncodedCharacter .= \chr($codePoint & 0xff);
$codePoint = $codePoint >> 8;
}
return iconv('utf-32le', $this->charset, $utf32EncodedCharacter);
}
if ($isForIdentifier) {
$peek = \ord($this->peek());
// Ranges: a-z A-Z 0-9 - _
if (
($peek >= 97 && $peek <= 122)
|| ($peek >= 65 && $peek <= 90)
|| ($peek >= 48 && $peek <= 57)
|| ($peek === 45)
|| ($peek === 95)
|| ($peek > 0xa1)
) {
return $this->consume(1);
}
} else {
return $this->consume(1);
}
return null;
}
/**
* Consumes whitespace and/or comments until the next non-whitespace character that isn't a slash opening a comment.
*
* @param list<Comment> $comments Any comments consumed will be appended to this array.
*
* @return string the whitespace consumed, without the comments
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*
* @phpstan-impure
* This method may change the state of the object by advancing the internal position;
* it does not simply 'get' a value.
*/
public function consumeWhiteSpace(array &$comments = []): string
{
$consumed = '';
do {
while (preg_match('/\\s/isSu', $this->peek()) === 1) {
$consumed .= $this->consume(1);
}
if ($this->parserSettings->usesLenientParsing()) {
try {
$comment = $this->consumeComment();
} catch (UnexpectedEOFException $e) {
$this->currentPosition = \count($this->characters);
break;
}
} else {
$comment = $this->consumeComment();
}
if ($comment instanceof Comment) {
$comments[] = $comment;
}
} while ($comment instanceof Comment);
return $consumed;
}
/**
* @param non-empty-string $string
*/
public function comes(string $string, bool $caseInsensitive = false): bool
{
$peek = $this->peek(\strlen($string));
return ($peek !== '') && $this->streql($peek, $string, $caseInsensitive);
}
/**
* @param int<1, max> $length
* @param int<0, max> $offset
*/
public function peek(int $length = 1, int $offset = 0): string
{
$offset += $this->currentPosition;
if ($offset >= \count($this->characters)) {
return '';
}
return $this->substr($offset, $length);
}
/**
* @param string|int<1, max> $value
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public function consume($value = 1): string
{
if (\is_string($value)) {
$numberOfLines = \substr_count($value, "\n");
$length = $this->strlen($value);
if (!$this->streql($this->substr($this->currentPosition, $length), $value)) {
throw new UnexpectedTokenException(
$value,
$this->peek(\max($length, 5)),
'literal',
$this->lineNumber
);
}
$this->lineNumber += $numberOfLines;
$this->currentPosition += $this->strlen($value);
$result = $value;
} else {
if ($this->currentPosition + $value > \count($this->characters)) {
throw new UnexpectedEOFException((string) $value, $this->peek(5), 'count', $this->lineNumber);
}
$result = $this->substr($this->currentPosition, $value);
$numberOfLines = \substr_count($result, "\n");
$this->lineNumber += $numberOfLines;
$this->currentPosition += $value;
}
return $result;
}
/**
* If the possibly-expected next content is next, consume it.
*
* @param non-empty-string $nextContent
*
* @return bool whether the possibly-expected content was found and consumed
*/
public function consumeIfComes(string $nextContent): bool
{
$length = $this->strlen($nextContent);
if (!$this->streql($this->substr($this->currentPosition, $length), $nextContent)) {
return false;
}
$numberOfLines = \substr_count($nextContent, "\n");
$this->lineNumber += $numberOfLines;
$this->currentPosition += $this->strlen($nextContent);
return true;
}
/**
* @param string $expression
* @param int<1, max>|null $maximumLength
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public function consumeExpression(string $expression, ?int $maximumLength = null): string
{
$matches = null;
$input = ($maximumLength !== null) ? $this->peek($maximumLength) : $this->inputLeft();
if (preg_match($expression, $input, $matches, PREG_OFFSET_CAPTURE) !== 1) {
throw new UnexpectedTokenException($expression, $this->peek(5), 'expression', $this->lineNumber);
}
return $this->consume($matches[0][0]);
}
/**
* @return Comment|false
*/
public function consumeComment()
{
$lineNumber = $this->lineNumber;
$comment = null;
if ($this->comes('/*')) {
$this->consume(1);
$comment = '';
while (($char = $this->consume(1)) !== '') {
$comment .= $char;
if ($this->comes('*/')) {
$this->consume(2);
break;
}
}
}
// We skip the * which was included in the comment.
return \is_string($comment) ? new Comment(\substr($comment, 1), $lineNumber) : false;
}
public function isEnd(): bool
{
return $this->currentPosition >= \count($this->characters);
}
/**
* @param list<string|self::EOF>|string|self::EOF $stopCharacters
* @param list<Comment> $comments
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public function consumeUntil(
$stopCharacters,
bool $includeEnd = false,
bool $consumeEnd = false,
array &$comments = []
): string {
$stopCharacters = \is_array($stopCharacters) ? $stopCharacters : [$stopCharacters];
$consumedCharacters = '';
$start = $this->currentPosition;
$comments = \array_merge($comments, $this->consumeComments());
while (!$this->isEnd()) {
$character = $this->consume(1);
if (\in_array($character, $stopCharacters, true)) {
if ($includeEnd) {
$consumedCharacters .= $character;
} elseif (!$consumeEnd) {
$this->currentPosition -= $this->strlen($character);
}
return $consumedCharacters;
}
$consumedCharacters .= $character;
$comments = \array_merge($comments, $this->consumeComments());
}
if (\in_array(self::EOF, $stopCharacters, true)) {
return $consumedCharacters;
}
$this->currentPosition = $start;
throw new UnexpectedEOFException(
'One of ("' . \implode('","', $stopCharacters) . '")',
$this->peek(5),
'search',
$this->lineNumber
);
}
private function inputLeft(): string
{
return $this->substr($this->currentPosition, -1);
}
public function streql(string $string1, string $string2, bool $caseInsensitive = true): bool
{
return $caseInsensitive
? ($this->strtolower($string1) === $this->strtolower($string2))
: ($string1 === $string2);
}
/**
* @param int<1, max> $numberOfCharacters
*/
public function backtrack(int $numberOfCharacters): void
{
$this->currentPosition -= $numberOfCharacters;
}
/**
* @return int<0, max>
*/
public function strlen(string $string): int
{
return $this->parserSettings->hasMultibyteSupport()
? \mb_strlen($string, $this->charset)
: \strlen($string);
}
/**
* @param int<0, max> $offset
*/
private function substr(int $offset, int $length): string
{
if ($length < 0) {
$length = \count($this->characters) - $offset + $length;
}
if ($offset + $length > \count($this->characters)) {
$length = \count($this->characters) - $offset;
}
$result = '';
while ($length > 0) {
$result .= $this->characters[$offset];
$offset++;
$length--;
}
return $result;
}
/**
* @return ($string is non-empty-string ? non-empty-string : string)
*/
private function strtolower(string $string): string
{
return $this->parserSettings->hasMultibyteSupport()
? \mb_strtolower($string, $this->charset)
: \strtolower($string);
}
/**
* @return list<string>
*/
private function strsplit(string $string): array
{
if ($this->parserSettings->hasMultibyteSupport()) {
if ($this->streql($this->charset, 'utf-8')) {
$result = preg_split('//u', $string, -1, PREG_SPLIT_NO_EMPTY);
} else {
$length = \mb_strlen($string, $this->charset);
$result = [];
for ($i = 0; $i < $length; ++$i) {
$result[] = \mb_substr($string, $i, 1, $this->charset);
}
}
} else {
$result = ($string !== '') ? \str_split($string) : [];
}
return $result;
}
/**
* @return list<Comment>
*/
private function consumeComments(): array
{
$comments = [];
while (true) {
$comment = $this->consumeComment();
if ($comment instanceof Comment) {
$comments[] = $comment;
} else {
return $comments;
}
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Parsing;
use Sabberworm\CSS\Position\Position;
use Sabberworm\CSS\Position\Positionable;
class SourceException extends \Exception implements Positionable
{
use Position;
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(string $message, ?int $lineNumber = null)
{
$this->setPosition($lineNumber);
if ($lineNumber !== null) {
$message .= " [line no: $lineNumber]";
}
parent::__construct($message);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Parsing;
/**
* Thrown if the CSS parser encounters end of file it did not expect.
*
* Extends `UnexpectedTokenException` in order to preserve backwards compatibility.
*/
final class UnexpectedEOFException extends UnexpectedTokenException {}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Parsing;
/**
* Thrown if the CSS parser encounters a token it did not expect.
*/
class UnexpectedTokenException extends SourceException
{
/**
* @param 'literal'|'identifier'|'count'|'expression'|'search'|'custom' $matchType
* @param int<1, max>|null $lineNumber
*/
public function __construct(string $expected, string $found, string $matchType = 'literal', ?int $lineNumber = null)
{
$message = "Token “{$expected}” ({$matchType}) not found. Got “{$found}”.";
if ($matchType === 'search') {
$message = "Search for “{$expected}” returned no results. Context: “{$found}”.";
} elseif ($matchType === 'count') {
$message = "Next token was expected to have {$expected} chars. Context: “{$found}”.";
} elseif ($matchType === 'identifier') {
$message = "Identifier expected. Got “{$found}";
} elseif ($matchType === 'custom') {
$message = \trim("$expected $found");
}
parent::__construct($message, $lineNumber);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Position;
/**
* Provides a standard reusable implementation of `Positionable`.
*
* @internal
*
* @phpstan-require-implements Positionable
*/
trait Position
{
/**
* @var int<1, max>|null
*/
protected $lineNumber;
/**
* @var int<0, max>|null
*/
protected $columnNumber;
/**
* @return int<1, max>|null
*/
public function getLineNumber(): ?int
{
return $this->lineNumber;
}
/**
* @return int<0, max>|null
*/
public function getColumnNumber(): ?int
{
return $this->columnNumber;
}
/**
* @param int<1, max>|null $lineNumber
* @param int<0, max>|null $columnNumber
*
* @return $this fluent interface
*/
public function setPosition(?int $lineNumber, ?int $columnNumber = null): Positionable
{
$this->lineNumber = $lineNumber;
$this->columnNumber = $columnNumber;
return $this;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Position;
/**
* Represents a CSS item that may have a position in the source CSS document (line number and possibly column number).
*
* A standard implementation of this interface is available in the `Position` trait.
*/
interface Positionable
{
/**
* @return int<1, max>|null
*/
public function getLineNumber(): ?int;
/**
* @return int<0, max>|null
*/
public function getColumnNumber(): ?int;
/**
* @param int<1, max>|null $lineNumber
* @param int<0, max>|null $columnNumber
*
* @return $this fluent interface
*/
public function setPosition(?int $lineNumber, ?int $columnNumber = null): Positionable;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Property;
use Sabberworm\CSS\CSSList\CSSListItem;
/**
* Note that `CSSListItem` extends both `Commentable` and `Renderable`,
* so concrete classes implementing this interface must also implement those.
*/
interface AtRule extends CSSListItem
{
/**
* Since there are more set rules than block rules,
* were whitelisting the block rules and have anything else be treated as a set rule.
*
* @internal since 8.5.2
*/
public const BLOCK_RULES = 'media/document/supports/region-style/font-feature-values/container';
/**
* @return non-empty-string
*/
public function atRuleName(): string;
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Property;
use Sabberworm\CSS\Comment\CommentContainer;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Position\Position;
use Sabberworm\CSS\Position\Positionable;
use Sabberworm\CSS\Value\CSSString;
use Sabberworm\CSS\Value\URL;
/**
* `CSSNamespace` represents an `@namespace` rule.
*/
class CSSNamespace implements AtRule, Positionable
{
use CommentContainer;
use Position;
/**
* @var CSSString|URL
*/
private $url;
/**
* @var string|null
*/
private $prefix;
/**
* @param CSSString|URL $url
* @param int<1, max>|null $lineNumber
*/
public function __construct($url, ?string $prefix = null, ?int $lineNumber = null)
{
$this->url = $url;
$this->prefix = $prefix;
$this->setPosition($lineNumber);
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
return '@namespace ' . ($this->prefix === null ? '' : $this->prefix . ' ')
. $this->url->render($outputFormat) . ';';
}
/**
* @return CSSString|URL
*/
public function getUrl()
{
return $this->url;
}
public function getPrefix(): ?string
{
return $this->prefix;
}
/**
* @param CSSString|URL $url
*/
public function setUrl($url): void
{
$this->url = $url;
}
public function setPrefix(string $prefix): void
{
$this->prefix = $prefix;
}
/**
* @return non-empty-string
*/
public function atRuleName(): string
{
return 'namespace';
}
/**
* @return array{0: CSSString|URL|non-empty-string, 1?: CSSString|URL}
*/
public function atRuleArgs(): array
{
$result = [$this->url];
if (\is_string($this->prefix) && $this->prefix !== '') {
\array_unshift($result, $this->prefix);
}
return $result;
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Property;
use Sabberworm\CSS\Comment\CommentContainer;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Position\Position;
use Sabberworm\CSS\Position\Positionable;
use Sabberworm\CSS\ShortClassNameProvider;
use Sabberworm\CSS\Value\CSSString;
/**
* Class representing an `@charset` rule.
*
* The following restrictions apply:
* - May not be found in any CSSList other than the Document.
* - May only appear at the very top of a Documents contents.
* - Must not appear more than once.
*/
class Charset implements AtRule, Positionable
{
use CommentContainer;
use Position;
use ShortClassNameProvider;
/**
* @var CSSString
*/
private $charset;
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(CSSString $charset, ?int $lineNumber = null)
{
$this->charset = $charset;
$this->setPosition($lineNumber);
}
/**
* @param string|CSSString $charset
*/
public function setCharset($charset): void
{
$charset = $charset instanceof CSSString ? $charset : new CSSString($charset);
$this->charset = $charset;
}
public function getCharset(): string
{
return $this->charset->getString();
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
return "{$outputFormat->getFormatter()->comments($this)}@charset {$this->charset->render($outputFormat)};";
}
/**
* @return non-empty-string
*/
public function atRuleName(): string
{
return 'charset';
}
public function atRuleArgs(): CSSString
{
return $this->charset;
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
return [
'class' => $this->getShortClassName(),
'charset' => $this->charset->getArrayRepresentation(),
];
}
}

View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Property;
use Sabberworm\CSS\Comment\Comment;
use Sabberworm\CSS\Comment\Commentable;
use Sabberworm\CSS\Comment\CommentContainer;
use Sabberworm\CSS\CSSElement;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\Position\Position;
use Sabberworm\CSS\Position\Positionable;
use Sabberworm\CSS\Value\RuleValueList;
use Sabberworm\CSS\Value\Value;
use function Safe\preg_match;
/**
* `Declaration`s just have a string key (the property name) and a 'Value'.
*
* In CSS, `Declaration`s are expressed as follows: “key: value[0][0] value[0][1], value[1][0] value[1][1];”
*/
class Declaration implements Commentable, CSSElement, Positionable
{
use CommentContainer;
use Position;
/**
* @var non-empty-string
*/
private $propertyName;
/**
* @var RuleValueList|string|null
*/
private $value;
/**
* @var bool
*/
private $isImportant = false;
/**
* @param non-empty-string $propertyName
* @param int<1, max>|null $lineNumber
* @param int<0, max>|null $columnNumber
*/
public function __construct(string $propertyName, ?int $lineNumber = null, ?int $columnNumber = null)
{
$this->propertyName = $propertyName;
$this->setPosition($lineNumber, $columnNumber);
}
/**
* @param list<Comment> $commentsBefore
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState, array $commentsBefore = []): self
{
$comments = $commentsBefore;
$parserState->consumeWhiteSpace($comments);
$declaration = new self(
$parserState->parseIdentifier(!$parserState->comes('--')),
$parserState->currentLine(),
$parserState->currentColumn()
);
$parserState->consumeWhiteSpace($comments);
$declaration->setComments($comments);
$parserState->consume(':');
$value = Value::parseValue($parserState, self::getDelimitersForPropertyValue($declaration->getPropertyName()));
$declaration->setValue($value);
$parserState->consumeWhiteSpace();
if ($parserState->comes('!')) {
$parserState->consume('!');
$parserState->consumeWhiteSpace();
$parserState->consume('important');
$declaration->setIsImportant(true);
}
$parserState->consumeWhiteSpace();
while ($parserState->comes(';')) {
$parserState->consume(';');
}
return $declaration;
}
/**
* Returns a list of delimiters (or separators).
* The first item is the innermost separator (or, put another way, the highest-precedence operator).
* The sequence continues to the outermost separator (or lowest-precedence operator).
*
* @param non-empty-string $propertyName
*
* @return list<non-empty-string>
*/
private static function getDelimitersForPropertyValue(string $propertyName): array
{
if (preg_match('/^font($|-)/', $propertyName) === 1) {
return [',', '/', ' '];
}
switch ($propertyName) {
case 'src':
return [' ', ','];
default:
return [',', ' ', '/'];
}
}
/**
* @param non-empty-string $propertyName
*/
public function setPropertyName(string $propertyName): void
{
$this->propertyName = $propertyName;
}
/**
* @return non-empty-string
*/
public function getPropertyName(): string
{
return $this->propertyName;
}
/**
* @param non-empty-string $propertyName
*
* @deprecated in v9.2, will be removed in v10.0; use `setPropertyName()` instead.
*/
public function setRule(string $propertyName): void
{
$this->propertyName = $propertyName;
}
/**
* @return non-empty-string
*
* @deprecated in v9.2, will be removed in v10.0; use `getPropertyName()` instead.
*/
public function getRule(): string
{
return $this->propertyName;
}
/**
* @return RuleValueList|string|null
*/
public function getValue()
{
return $this->value;
}
/**
* @param RuleValueList|string|null $value
*/
public function setValue($value): void
{
$this->value = $value;
}
/**
* Adds a value to the existing value. Value will be appended if a `RuleValueList` exists of the given type.
* Otherwise, the existing value will be wrapped by one.
*
* @param RuleValueList|array<int, RuleValueList> $value
*/
public function addValue($value, string $type = ' '): void
{
if (!\is_array($value)) {
$value = [$value];
}
if (!($this->value instanceof RuleValueList) || $this->value->getListSeparator() !== $type) {
$currentValue = $this->value;
$this->value = new RuleValueList($type, $this->getLineNumber());
if ($currentValue !== null && $currentValue !== '') {
$this->value->addListComponent($currentValue);
}
}
foreach ($value as $valueItem) {
$this->value->addListComponent($valueItem);
}
}
public function setIsImportant(bool $isImportant): void
{
$this->isImportant = $isImportant;
}
public function getIsImportant(): bool
{
return $this->isImportant;
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
$formatter = $outputFormat->getFormatter();
$result = "{$formatter->comments($this)}{$this->propertyName}:{$formatter->spaceAfterRuleName()}";
if ($this->value instanceof Value) { // Can also be a ValueList
$result .= $this->value->render($outputFormat);
} else {
$result .= $this->value;
}
if ($this->isImportant) {
$result .= ' !important';
}
$result .= ';';
return $result;
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Property;
use Sabberworm\CSS\Comment\CommentContainer;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Position\Position;
use Sabberworm\CSS\Position\Positionable;
use Sabberworm\CSS\Value\URL;
/**
* Class representing an `@import` rule.
*/
class Import implements AtRule, Positionable
{
use CommentContainer;
use Position;
/**
* @var URL
*/
private $location;
/**
* @var string|null
*/
private $mediaQuery;
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(URL $location, ?string $mediaQuery, ?int $lineNumber = null)
{
$this->location = $location;
$this->mediaQuery = $mediaQuery;
$this->setPosition($lineNumber);
}
public function setLocation(URL $location): void
{
$this->location = $location;
}
public function getLocation(): URL
{
return $this->location;
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
return $outputFormat->getFormatter()->comments($this) . '@import ' . $this->location->render($outputFormat)
. ($this->mediaQuery === null ? '' : ' ' . $this->mediaQuery) . ';';
}
/**
* @return non-empty-string
*/
public function atRuleName(): string
{
return 'import';
}
/**
* @return array{0: URL, 1?: non-empty-string}
*/
public function atRuleArgs(): array
{
$result = [$this->location];
if (\is_string($this->mediaQuery) && $this->mediaQuery !== '') {
$result[] = $this->mediaQuery;
}
return $result;
}
public function getMediaQuery(): ?string
{
return $this->mediaQuery;
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Property;
class KeyframeSelector extends Selector
{
/**
* This differs from the parent class:
* - comma is not allowed unless escaped or quoted;
* - percentage value is allowed by itself.
*
* @internal since 8.5.2
*/
public const SELECTOR_VALIDATION_RX = '/
^(
(?:
# any sequence of valid unescaped characters, except quotes
[a-zA-Z0-9\\x{00A0}-\\x{FFFF}_^$|*=~\\[\\]()\\-\\s\\.:#+>]++
|
# one or more escaped characters
(?:\\\\.)++
|
# quoted text, like in `[id="example"]`
(?:
# opening quote
([\'"])
(?:
# sequence of characters except closing quote or backslash
(?:(?!\\g{-1}|\\\\).)++
|
# one or more escaped characters
(?:\\\\.)++
)*+ # zero or more times
# closing quote or end (unmatched quote is currently allowed)
(?:\\g{-1}|$)
)
)*+ # zero or more times
|
# keyframe animation progress percentage (e.g. 50%), untrimmed
\\s*+(\\d++%)\\s*+
)$
/ux';
}

View File

@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Property;
use Sabberworm\CSS\Comment\Comment;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\Property\Selector\Combinator;
use Sabberworm\CSS\Property\Selector\Component;
use Sabberworm\CSS\Property\Selector\CompoundSelector;
use Sabberworm\CSS\Renderable;
use Sabberworm\CSS\Settings;
use Sabberworm\CSS\ShortClassNameProvider;
use function Safe\preg_match;
/**
* Class representing a single CSS selector. Selectors have to be split by the comma prior to being passed into this
* class.
*/
class Selector implements Renderable
{
use ShortClassNameProvider;
/**
* @internal since 8.5.2
*/
public const SELECTOR_VALIDATION_RX = '/
^(
# not whitespace only
(?!\\s*+$)
(?:
# any sequence of valid unescaped characters, except quotes
[a-zA-Z0-9\\x{00A0}-\\x{FFFF}_^$|*=~\\[\\]()\\-\\s\\.:#+>,]++
|
# one or more escaped characters
(?:\\\\.)++
|
# quoted text, like in `[id="example"]`
(?:
# opening quote
([\'"])
(?:
# sequence of characters except closing quote or backslash
(?:(?!\\g{-1}|\\\\).)++
|
# one or more escaped characters
(?:\\\\.)++
)*+ # zero or more times
# closing quote or end (unmatched quote is currently allowed)
(?:\\g{-1}|$)
)
)*+ # zero or more times
)$
/ux';
/**
* @var non-empty-list<Component>
*/
private $components;
/**
* @internal since V8.8.0
*/
public static function isValid(string $selector): bool
{
// Note: We need to use `static::` here as the constant is overridden in the `KeyframeSelector` class.
$numberOfMatches = preg_match(static::SELECTOR_VALIDATION_RX, $selector);
return $numberOfMatches === 1;
}
/**
* @param non-empty-string|non-empty-list<Component> $selector
* Providing a string is deprecated in version 9.2 and will not work from v10.0
*
* @throws UnexpectedTokenException if the selector is not valid
*/
final public function __construct($selector)
{
if (\is_string($selector)) {
$this->setSelector($selector);
} else {
$this->setComponents($selector);
}
}
/**
* @param list<Comment> $comments
*
* @return non-empty-list<Component>
*
* @throws UnexpectedTokenException
*/
private static function parseComponents(ParserState $parserState, array &$comments = []): array
{
// Whitespace is a descendent combinator, not allowed around a compound selector.
// (It is allowed within, e.g. as part of a string or within a function like `:not()`.)
// Gobble any up now to get a clean start.
$parserState->consumeWhiteSpace($comments);
$selectorParts = [];
while (true) {
try {
$selectorParts[] = CompoundSelector::parse($parserState, $comments);
} catch (UnexpectedTokenException $e) {
if ($selectorParts !== [] && \end($selectorParts)->getValue() === ' ') {
// The whitespace was not a descendent combinator, and was, in fact, arbitrary,
// after the end of the selector. Discard it.
\array_pop($selectorParts);
break;
} else {
throw $e;
}
}
try {
$selectorParts[] = Combinator::parse($parserState, $comments);
} catch (UnexpectedTokenException $e) {
// End of selector has been reached.
break;
}
}
return $selectorParts;
}
/**
* @param list<Comment> $comments
*
* @throws UnexpectedTokenException
*
* @internal
*/
public static function parse(ParserState $parserState, array &$comments = []): self
{
$selectorParts = self::parseComponents($parserState, $comments);
// Check that the selector has been fully parsed:
if (!\in_array($parserState->peek(), ['{', '}', ',', ''], true)) {
throw new UnexpectedTokenException(
'`,`, `{`, `}` or EOF',
$parserState->peek(5),
'literal',
$parserState->currentLine()
);
}
return new static($selectorParts);
}
/**
* @return non-empty-list<Component>
*/
public function getComponents(): array
{
return $this->components;
}
/**
* @param non-empty-list<Component> $components
* This should be an alternating sequence of `CompoundSelector` and `Combinator`, starting and ending with a
* `CompoundSelector`, and may be a single `CompoundSelector`.
*/
public function setComponents(array $components): self
{
$this->components = $components;
return $this;
}
/**
* @return non-empty-string
*
* @deprecated in version 9.2, will be removed in v10.0. Use either `getComponents()` or `render()` instead.
*/
public function getSelector(): string
{
return $this->render(new OutputFormat());
}
/**
* @param non-empty-string $selector
*
* @throws UnexpectedTokenException if the selector is not valid
*
* @deprecated in version 9.2, will be removed in v10.0. Use `setComponents()` instead.
*/
public function setSelector(string $selector): void
{
$parserState = new ParserState($selector, Settings::create());
$components = self::parseComponents($parserState);
// Check that the selector has been fully parsed:
if (!$parserState->isEnd()) {
throw new UnexpectedTokenException(
'EOF',
$parserState->peek(5),
'literal'
);
}
$this->components = $components;
}
/**
* @return int<0, max>
*/
public function getSpecificity(): int
{
return \array_sum(\array_map(
static function (Component $component): int {
return $component->getSpecificity();
},
$this->components
));
}
public function render(OutputFormat $outputFormat): string
{
return \implode('', \array_map(
static function (Component $component) use ($outputFormat): string {
return $component->render($outputFormat);
},
$this->components
));
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
return [
'class' => $this->getShortClassName(),
'components' => \array_map(
static function (Component $component): array {
return $component->getArrayRepresentation();
},
$this->components
),
];
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Property\Selector;
use Sabberworm\CSS\Comment\Comment;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\ShortClassNameProvider;
/**
* Class representing a CSS selector combinator (space, `>`, `+`, or `~`).
*
* @phpstan-type ValidCombinatorValue ' '|'>'|'+'|'~'
*/
class Combinator implements Component
{
use ShortClassNameProvider;
/**
* @var ValidCombinatorValue
*/
private $value;
/**
* @param ValidCombinatorValue $value
*/
public function __construct(string $value)
{
$this->setValue($value);
}
/**
* @param list<Comment> $comments
*
* @throws UnexpectedTokenException
*
* @internal
*/
public static function parse(ParserState $parserState, array &$comments = []): self
{
$consumedWhitespace = $parserState->consumeWhiteSpace($comments);
$nextToken = $parserState->peek();
if (\in_array($nextToken, ['>', '+', '~'], true)) {
$value = $nextToken;
$parserState->consume(1);
$parserState->consumeWhiteSpace($comments);
} elseif ($consumedWhitespace !== '') {
$value = ' ';
} else {
throw new UnexpectedTokenException(
'combinator',
$nextToken,
'literal',
$parserState->currentLine()
);
}
return new self($value);
}
/**
* @return ValidCombinatorValue
*/
public function getValue(): string
{
return $this->value;
}
/**
* @param non-empty-string $value
*
* @throws \UnexpectedValueException if `$value` is not either space, '>', '+' or '~'
*/
public function setValue(string $value): void
{
if (!\in_array($value, [' ', '>', '+', '~'], true)) {
throw new \UnexpectedValueException('`' . $value . '` is not a valid selector combinator.');
}
$this->value = $value;
}
/**
* @return int<0, max>
*/
public function getSpecificity(): int
{
return 0;
}
public function render(OutputFormat $outputFormat): string
{
$spacing = $outputFormat->getSpaceAroundSelectorCombinator();
if ($this->value === ' ') {
$rendering = $spacing !== '' ? $spacing : ' ';
} else {
$rendering = $spacing . $this->value . $spacing;
}
return $rendering;
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
return [
'class' => $this->getShortClassName(),
'value' => $this->value,
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Property\Selector;
use Sabberworm\CSS\Renderable;
/**
* This interface is for a class that represents a part of a selector which is either a compound selector (or a simple
* selector, which is effectively a compound selector without any compounding) or a selector combinator.
*
* It allows a selector to be represented as an array of objects that implement this interface.
* This is the formal definition:
* selector = compound-selector [combinator, compound-selector]*
*
* The selector is comprised of an array of alternating types that can't be easily represented in a type-safe manner
* without this.
*
* 'Selector component' is not a known grammar in the spec, but a convenience for the implementation.
*
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Selectors/Selector_structure
* @see https://www.w3.org/TR/selectors-4/#structure
*/
interface Component extends Renderable
{
/**
* @return non-empty-string
*/
public function getValue(): string;
/**
* @param non-empty-string $value
*/
public function setValue(string $value): void;
/**
* @return int<0, max>
*/
public function getSpecificity(): int;
}

View File

@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Property\Selector;
use Sabberworm\CSS\Comment\Comment;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\ShortClassNameProvider;
use function Safe\preg_match;
/**
* Class representing a CSS compound selector.
* Selectors have to be split at combinators (space, `>`, `+`, `~`) before being passed to this class.
*/
class CompoundSelector implements Component
{
use ShortClassNameProvider;
private const PARSER_STOP_CHARACTERS = [
'{',
'}',
'\'',
'"',
'(',
')',
'[',
']',
',',
' ',
"\t",
"\n",
"\r",
'>',
'+',
'~',
ParserState::EOF,
'', // `ParserState::peek()` returns empty string rather than `ParserState::EOF` when end of string is reached
];
private const SELECTOR_VALIDATION_RX = '/
^
# not starting with whitespace
(?!\\s)
(?:
(?:
# any sequence of valid unescaped characters, except quotes
[a-zA-Z0-9\\x{00A0}-\\x{FFFF}_^$|*=~\\[\\]()\\-\\s\\.:#+>,]++
|
# one or more escaped characters
(?:\\\\.)++
|
# quoted text, like in `[id="example"]`
(?:
# opening quote
([\'"])
(?:
# sequence of characters except closing quote or backslash
(?:(?!\\g{-1}|\\\\).)++
|
# one or more escaped characters
(?:\\\\.)++
)*+ # zero or more times
# closing quote or end (unmatched quote is currently allowed)
(?:\\g{-1}|$)
)
)++ # one or more times
|
# keyframe animation progress percentage (e.g. 50%)
(?:\\d++%)
)
# not ending with whitespace
(?<!\\s)
$
/ux';
/**
* @var non-empty-string
*/
private $value;
/**
* @param non-empty-string $value
*/
public function __construct(string $value)
{
$this->setValue($value);
}
/**
* @param list<Comment> $comments
*
* @throws UnexpectedTokenException
*
* @internal
*/
public static function parse(ParserState $parserState, array &$comments = []): self
{
$selectorParts = [];
$stringWrapperCharacter = null;
$functionNestingLevel = 0;
$isWithinAttribute = false;
while (true) {
$selectorParts[] = $parserState->consumeUntil(self::PARSER_STOP_CHARACTERS, false, false, $comments);
$nextCharacter = $parserState->peek();
switch ($nextCharacter) {
case '':
// EOF
break 2;
case '\'':
// The fallthrough is intentional.
case '"':
$lastPart = \end($selectorParts);
$backslashCount = \strspn(\strrev($lastPart), '\\');
$quoteIsEscaped = ($backslashCount % 2 === 1);
if (!$quoteIsEscaped) {
if (!\is_string($stringWrapperCharacter)) {
$stringWrapperCharacter = $nextCharacter;
} elseif ($stringWrapperCharacter === $nextCharacter) {
$stringWrapperCharacter = null;
}
}
break;
case '(':
if (!\is_string($stringWrapperCharacter)) {
++$functionNestingLevel;
}
break;
case ')':
if (!\is_string($stringWrapperCharacter)) {
if ($functionNestingLevel <= 0) {
throw new UnexpectedTokenException(
'anything but',
')',
'literal',
$parserState->currentLine()
);
}
--$functionNestingLevel;
}
break;
case '[':
if (!\is_string($stringWrapperCharacter)) {
if ($isWithinAttribute) {
throw new UnexpectedTokenException(
'anything but',
'[',
'literal',
$parserState->currentLine()
);
}
$isWithinAttribute = true;
}
break;
case ']':
if (!\is_string($stringWrapperCharacter)) {
if (!$isWithinAttribute) {
throw new UnexpectedTokenException(
'anything but',
']',
'literal',
$parserState->currentLine()
);
}
$isWithinAttribute = false;
}
break;
case '{':
// The fallthrough is intentional.
case '}':
if (!\is_string($stringWrapperCharacter)) {
break 2;
}
break;
case ',':
// The fallthrough is intentional.
case ' ':
// The fallthrough is intentional.
case "\t":
// The fallthrough is intentional.
case "\n":
// The fallthrough is intentional.
case "\r":
// The fallthrough is intentional.
case '>':
// The fallthrough is intentional.
case '+':
// The fallthrough is intentional.
case '~':
if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0 && !$isWithinAttribute) {
break 2;
}
break;
}
$selectorParts[] = $parserState->consume(1);
}
if ($functionNestingLevel !== 0) {
throw new UnexpectedTokenException(')', $nextCharacter, 'literal', $parserState->currentLine());
}
if (\is_string($stringWrapperCharacter)) {
throw new UnexpectedTokenException(
$stringWrapperCharacter,
$nextCharacter,
'literal',
$parserState->currentLine()
);
}
$value = \implode('', $selectorParts);
if ($value === '') {
throw new UnexpectedTokenException('selector', $nextCharacter, 'literal', $parserState->currentLine());
}
if (!self::isValid($value)) {
throw new UnexpectedTokenException(
'Selector component is not valid:',
'`' . $value . '`',
'custom',
$parserState->currentLine()
);
}
return new self($value);
}
/**
* @return non-empty-string
*/
public function getValue(): string
{
return $this->value;
}
/**
* @param non-empty-string $value
*
* @throws \UnexpectedValueException if `$value` contains invalid characters or has surrounding whitespce
*/
public function setValue(string $value): void
{
if (!self::isValid($value)) {
throw new \UnexpectedValueException('`' . $value . '` is not a valid compound selector.');
}
$this->value = $value;
}
/**
* @return int<0, max>
*/
public function getSpecificity(): int
{
return SpecificityCalculator::calculate($this->value);
}
public function render(OutputFormat $outputFormat): string
{
return $this->getValue();
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
return [
'class' => $this->getShortClassName(),
'value' => $this->value,
];
}
private static function isValid(string $value): bool
{
$numberOfMatches = preg_match(self::SELECTOR_VALIDATION_RX, $value);
return $numberOfMatches === 1;
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Property\Selector;
use function Safe\preg_match_all;
/**
* Utility class to calculate the specificity of a CSS selector.
*
* The results are cached to avoid recalculating the specificity of the same selector multiple times.
*/
final class SpecificityCalculator
{
/**
* regexp for specificity calculations
*/
private const NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX = '/
(\\.[\\w]+) # classes
|
\\[(\\w+) # attributes
|
(\\:( # pseudo classes
link|visited|active
|hover|focus
|lang
|target
|enabled|disabled|checked|indeterminate
|root
|nth-child|nth-last-child|nth-of-type|nth-last-of-type
|first-child|last-child|first-of-type|last-of-type
|only-child|only-of-type
|empty|contains
))
/ix';
/**
* regexp for specificity calculations
*/
private const ELEMENTS_AND_PSEUDO_ELEMENTS_RX = '/
((^|[\\s\\+\\>\\~]+)[\\w]+ # elements
|
\\:{1,2}( # pseudo-elements
after|before|first-letter|first-line|selection
))
/ix';
/**
* @var array<string, int<0, max>>
*/
private static $cache = [];
/**
* Calculates the specificity of the given CSS selector.
*
* @return int<0, max>
*
* @internal
*/
public static function calculate(string $selector): int
{
if (!isset(self::$cache[$selector])) {
$a = 0;
/// @todo should exclude \# as well as "#"
$matches = null;
$b = \substr_count($selector, '#');
$c = preg_match_all(self::NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX, $selector, $matches);
$d = preg_match_all(self::ELEMENTS_AND_PSEUDO_ELEMENTS_RX, $selector, $matches);
self::$cache[$selector] = ($a * 1000) + ($b * 100) + ($c * 10) + $d;
}
return self::$cache[$selector];
}
/**
* Clears the cache in order to lower memory usage.
*/
public static function clearCache(): void
{
self::$cache = [];
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS;
interface Renderable
{
public function render(OutputFormat $outputFormat): string;
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array;
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Rule;
use Sabberworm\CSS\Property\Declaration;
use function Safe\class_alias;
/**
* @deprecated in v9.2, will be removed in v10.0. Use `Property\Declaration` instead, which is a direct replacement.
*/
class_alias(Declaration::class, Rule::class);

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\RuleSet;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Property\AtRule;
/**
* This class represents rule sets for generic at-rules which are not covered by specific classes, i.e., not
* `@import`, `@charset` or `@media`.
*
* A common example for this is `@font-face`.
*/
class AtRuleSet extends RuleSet implements AtRule
{
/**
* @var non-empty-string
*/
private $type;
/**
* @var string
*/
private $arguments;
/**
* @param non-empty-string $type
* @param int<1, max>|null $lineNumber
*/
public function __construct(string $type, string $arguments = '', ?int $lineNumber = null)
{
parent::__construct($lineNumber);
$this->type = $type;
$this->arguments = $arguments;
}
/**
* @return non-empty-string
*/
public function atRuleName(): string
{
return $this->type;
}
public function atRuleArgs(): string
{
return $this->arguments;
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
$formatter = $outputFormat->getFormatter();
$result = $formatter->comments($this);
$arguments = $this->arguments;
if ($arguments !== '') {
$arguments = ' ' . $arguments;
}
$result .= "@{$this->type}$arguments{$formatter->spaceBeforeOpeningBrace()}{";
$result .= $this->renderDeclarations($outputFormat);
$result .= '}';
return $result;
}
}

View File

@@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\RuleSet;
use Sabberworm\CSS\Comment\Comment;
use Sabberworm\CSS\Comment\CommentContainer;
use Sabberworm\CSS\CSSElement;
use Sabberworm\CSS\CSSList\CSSList;
use Sabberworm\CSS\CSSList\CSSListItem;
use Sabberworm\CSS\CSSList\KeyFrame;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\OutputException;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\Position\Position;
use Sabberworm\CSS\Position\Positionable;
use Sabberworm\CSS\Property\Declaration;
use Sabberworm\CSS\Property\KeyframeSelector;
use Sabberworm\CSS\Property\Selector;
use Sabberworm\CSS\Settings;
/**
* This class represents a `RuleSet` constrained by a `Selector`.
*
* It contains an array of selector objects (comma-separated in the CSS) as well as the rules to be applied to the
* matching elements.
*
* Declaration blocks usually appear directly inside a `Document` or another `CSSList` (mostly a `MediaQuery`).
*
* Note that `CSSListItem` extends both `Commentable` and `Renderable`, so those interfaces must also be implemented.
*/
class DeclarationBlock implements CSSElement, CSSListItem, Positionable, DeclarationList
{
use CommentContainer;
use LegacyDeclarationListMethods;
use Position;
/**
* @var list<Selector>
*/
private $selectors = [];
/**
* @var RuleSet
*/
private $ruleSet;
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(?int $lineNumber = null)
{
$this->ruleSet = new RuleSet($lineNumber);
$this->setPosition($lineNumber);
}
/**
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState, ?CSSList $list = null): ?DeclarationBlock
{
$comments = [];
$result = new DeclarationBlock($parserState->currentLine());
try {
$selectors = self::parseSelectors($parserState, $list, $comments);
$result->setSelectors($selectors, $list);
if ($parserState->comes('{')) {
$parserState->consume(1);
}
} catch (UnexpectedTokenException $e) {
if ($parserState->getSettings()->usesLenientParsing()) {
if (!$parserState->consumeIfComes('}')) {
$parserState->consumeUntil(['}', ParserState::EOF], false, true);
}
return null;
} else {
throw $e;
}
}
$result->setComments($comments);
RuleSet::parseRuleSet($parserState, $result->getRuleSet());
return $result;
}
/**
* @param array<Selector|string>|string $selectors
*
* @throws UnexpectedTokenException
*/
public function setSelectors($selectors, ?CSSList $list = null): void
{
if (\is_array($selectors)) {
$selectorsToSet = $selectors;
} else {
// A string of comma-separated selectors requires parsing.
try {
$parserState = new ParserState($selectors, Settings::create());
$selectorsToSet = self::parseSelectors($parserState, $list);
if (!$parserState->isEnd()) {
throw new UnexpectedTokenException('EOF', 'more');
}
} catch (UnexpectedTokenException $exception) {
// The exception message from parsing may refer to the faux `{` block start token,
// which would be confusing.
// Rethrow with a more useful message, that also includes the selector(s) string that was passed.
throw new UnexpectedTokenException(
'Selector(s) string is not valid.',
$selectors,
'custom'
);
}
}
// Convert all items to a `Selector` if not already
foreach ($selectorsToSet as $key => $selector) {
if (!($selector instanceof Selector)) {
if ($list === null || !($list instanceof KeyFrame)) {
if (!Selector::isValid($selector)) {
throw new UnexpectedTokenException(
"Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.",
$selector,
'custom'
);
}
$selectorsToSet[$key] = new Selector($selector);
} else {
if (!KeyframeSelector::isValid($selector)) {
throw new UnexpectedTokenException(
"Selector did not match '" . KeyframeSelector::SELECTOR_VALIDATION_RX . "'.",
$selector,
'custom'
);
}
$selectorsToSet[$key] = new KeyframeSelector($selector);
}
}
}
// Discard the keys and reindex the array
$this->selectors = \array_values($selectorsToSet);
}
/**
* Remove one of the selectors of the block.
*
* @param Selector|string $selectorToRemove
*/
public function removeSelector($selectorToRemove): bool
{
if ($selectorToRemove instanceof Selector) {
$selectorToRemove = $selectorToRemove->getSelector();
}
foreach ($this->selectors as $key => $selector) {
if ($selector->getSelector() === $selectorToRemove) {
unset($this->selectors[$key]);
return true;
}
}
return false;
}
/**
* @return list<Selector>
*/
public function getSelectors(): array
{
return $this->selectors;
}
public function getRuleSet(): RuleSet
{
return $this->ruleSet;
}
/**
* @see RuleSet::addDeclaration()
*/
public function addDeclaration(Declaration $declarationToAdd, ?Declaration $sibling = null): void
{
$this->ruleSet->addDeclaration($declarationToAdd, $sibling);
}
/**
* @return array<int<0, max>, Declaration>
*
* @see RuleSet::getDeclarations()
*/
public function getDeclarations(?string $searchPattern = null): array
{
return $this->ruleSet->getDeclarations($searchPattern);
}
/**
* @param array<Declaration> $declarations
*
* @see RuleSet::setDeclarations()
*/
public function setDeclarations(array $declarations): void
{
$this->ruleSet->setDeclarations($declarations);
}
/**
* @return array<string, Declaration>
*
* @see RuleSet::getDeclarationsAssociative()
*/
public function getDeclarationsAssociative(?string $searchPattern = null): array
{
return $this->ruleSet->getDeclarationsAssociative($searchPattern);
}
/**
* @see RuleSet::removeDeclaration()
*/
public function removeDeclaration(Declaration $declarationToRemove): void
{
$this->ruleSet->removeDeclaration($declarationToRemove);
}
/**
* @see RuleSet::removeMatchingDeclarations()
*/
public function removeMatchingDeclarations(string $searchPattern): void
{
$this->ruleSet->removeMatchingDeclarations($searchPattern);
}
/**
* @see RuleSet::removeAllDeclarations()
*/
public function removeAllDeclarations(): void
{
$this->ruleSet->removeAllDeclarations();
}
/**
* @return non-empty-string
*
* @throws OutputException
*/
public function render(OutputFormat $outputFormat): string
{
$formatter = $outputFormat->getFormatter();
$result = $formatter->comments($this);
if (\count($this->selectors) === 0) {
// If all the selectors have been removed, this declaration block becomes invalid
throw new OutputException(
'Attempt to print declaration block with missing selector',
$this->getLineNumber()
);
}
$result .= $outputFormat->getContentBeforeDeclarationBlock();
$result .= $formatter->implode(
$formatter->spaceBeforeSelectorSeparator() . ',' . $formatter->spaceAfterSelectorSeparator(),
$this->selectors
);
$result .= $outputFormat->getContentAfterDeclarationBlockSelectors();
$result .= $formatter->spaceBeforeOpeningBrace() . '{';
$result .= $this->ruleSet->render($outputFormat);
$result .= '}';
$result .= $outputFormat->getContentAfterDeclarationBlock();
return $result;
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
}
/**
* @param list<Comment> $comments
*
* @return list<Selector>
*
* @throws UnexpectedTokenException
*/
private static function parseSelectors(ParserState $parserState, ?CSSList $list, array &$comments = []): array
{
$selectorClass = $list instanceof KeyFrame ? KeyFrameSelector::class : Selector::class;
$selectors = [];
while (true) {
$selectors[] = $selectorClass::parse($parserState, $comments);
if (!$parserState->consumeIfComes(',')) {
break;
}
}
return $selectors;
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\RuleSet;
use Sabberworm\CSS\Property\Declaration;
/**
* Represents a CSS item that contains `Declaration`s, defining the methods to manipulate them.
*/
interface DeclarationList
{
public function addDeclaration(Declaration $declarationToAdd, ?Declaration $sibling = null): void;
public function removeDeclaration(Declaration $declarationToRemove): void;
public function removeMatchingDeclarations(string $searchPattern): void;
public function removeAllDeclarations(): void;
/**
* @param array<Declaration> $declarations
*/
public function setDeclarations(array $declarations): void;
/**
* @return array<int<0, max>, Declaration>
*/
public function getDeclarations(?string $searchPattern = null): array;
/**
* @return array<string, Declaration>
*/
public function getDeclarationsAssociative(?string $searchPattern = null): array;
/**
* @deprecated in v9.2, will be removed in v10.0; use `addDeclaration()` instead.
*/
public function addRule(Declaration $declarationToAdd, ?Declaration $sibling = null): void;
/**
* @deprecated in v9.2, will be removed in v10.0; use `removeDeclaration()` instead.
*/
public function removeRule(Declaration $declarationToRemove): void;
/**
* @deprecated in v9.2, will be removed in v10.0; use `removeMatchingDeclarations()` instead.
*/
public function removeMatchingRules(string $searchPattern): void;
/**
* @deprecated in v9.2, will be removed in v10.0; use `removeAllDeclarations()` instead.
*/
public function removeAllRules(): void;
/**
* @param array<Declaration> $declarations
*
* @deprecated in v9.2, will be removed in v10.0; use `setDeclarations()` instead.
*/
public function setRules(array $declarations): void;
/**
* @return array<int<0, max>, Declaration>
*
* @deprecated in v9.2, will be removed in v10.0; use `getDeclarations()` instead.
*/
public function getRules(?string $searchPattern = null): array;
/**
* @return array<string, Declaration>
*
* @deprecated in v9.2, will be removed in v10.0; use `getDeclarationsAssociative()` instead.
*/
public function getRulesAssoc(?string $searchPattern = null): array;
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\RuleSet;
use Sabberworm\CSS\Property\Declaration;
/**
* Provides a mapping of the deprecated methods in a `DeclarationList` to their renamed replacements.
*/
trait LegacyDeclarationListMethods
{
/**
* @deprecated in v9.2, will be removed in v10.0; use `addDeclaration()` instead.
*/
public function addRule(Declaration $declarationToAdd, ?Declaration $sibling = null): void
{
$this->addDeclaration($declarationToAdd, $sibling);
}
/**
* @deprecated in v9.2, will be removed in v10.0; use `removeDeclaration()` instead.
*/
public function removeRule(Declaration $declarationToRemove): void
{
$this->removeDeclaration($declarationToRemove);
}
/**
* @deprecated in v9.2, will be removed in v10.0; use `removeMatchingDeclarations()` instead.
*/
public function removeMatchingRules(string $searchPattern): void
{
$this->removeMatchingDeclarations($searchPattern);
}
/**
* @deprecated in v9.2, will be removed in v10.0; use `removeAllDeclarations()` instead.
*/
public function removeAllRules(): void
{
$this->removeAllDeclarations();
}
/**
* @param array<Declaration> $declarations
*
* @deprecated in v9.2, will be removed in v10.0; use `setDeclarations()` instead.
*/
public function setRules(array $declarations): void
{
$this->setDeclarations($declarations);
}
/**
* @return array<int<0, max>, Declaration>
*
* @deprecated in v9.2, will be removed in v10.0; use `getDeclarations()` instead.
*/
public function getRules(?string $searchPattern = null): array
{
return $this->getDeclarations($searchPattern);
}
/**
* @return array<string, Declaration>
*
* @deprecated in v9.2, will be removed in v10.0; use `getDeclarationsAssociative()` instead.
*/
public function getRulesAssoc(?string $searchPattern = null): array
{
return $this->getDeclarationsAssociative($searchPattern);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\RuleSet;
use function Safe\class_alias;
/**
* @deprecated in v9.2, will be removed in v10.0. Use `DeclarationList` instead, which is a direct replacement.
*/
class_alias(DeclarationList::class, RuleContainer::class);

View File

@@ -0,0 +1,392 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\RuleSet;
use Sabberworm\CSS\Comment\CommentContainer;
use Sabberworm\CSS\CSSElement;
use Sabberworm\CSS\CSSList\CSSListItem;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\Position\Position;
use Sabberworm\CSS\Position\Positionable;
use Sabberworm\CSS\Property\Declaration;
/**
* This class is a container for individual `Declaration`s.
*
* The most common form of a rule set is one constrained by a selector, i.e., a `DeclarationBlock`.
* However, unknown `AtRule`s (like `@font-face`) are rule sets as well.
*
* If you want to manipulate a `RuleSet`,
* use the methods `addDeclaration()`, `getDeclarations()`, `removeDeclaration()`, `removeMatchingDeclarations()`, etc.
*
* Note that `CSSListItem` extends both `Commentable` and `Renderable`, so those interfaces must also be implemented.
*/
class RuleSet implements CSSElement, CSSListItem, Positionable, DeclarationList
{
use CommentContainer;
use LegacyDeclarationListMethods;
use Position;
/**
* the declarations in this rule set, using the property name as the key,
* with potentially multiple declarations per property name.
*
* @var array<string, array<int<0, max>, Declaration>>
*/
private $declarations = [];
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(?int $lineNumber = null)
{
$this->setPosition($lineNumber);
}
/**
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*
* @internal since V8.8.0
*/
public static function parseRuleSet(ParserState $parserState, RuleSet $ruleSet): void
{
while ($parserState->comes(';')) {
$parserState->consume(';');
}
while (true) {
$commentsBeforeDeclaration = [];
$parserState->consumeWhiteSpace($commentsBeforeDeclaration);
if ($parserState->comes('}')) {
break;
}
$declaration = null;
if ($parserState->getSettings()->usesLenientParsing()) {
try {
$declaration = Declaration::parse($parserState, $commentsBeforeDeclaration);
} catch (UnexpectedTokenException $e) {
try {
$consumedText = $parserState->consumeUntil(["\n", ';', '}'], true);
// We need to “unfind” the matches to the end of the ruleSet as this will be matched later
if ($parserState->streql(\substr($consumedText, -1), '}')) {
$parserState->backtrack(1);
} else {
while ($parserState->comes(';')) {
$parserState->consume(';');
}
}
} catch (UnexpectedTokenException $e) {
// Weve reached the end of the document. Just close the RuleSet.
return;
}
}
} else {
$declaration = Declaration::parse($parserState, $commentsBeforeDeclaration);
}
if ($declaration instanceof Declaration) {
$ruleSet->addDeclaration($declaration);
}
}
$parserState->consume('}');
}
/**
* @throws \UnexpectedValueException
* if the last `Declaration` is needed as a basis for setting position, but does not have a valid position,
* which should never happen
*/
public function addDeclaration(Declaration $declarationToAdd, ?Declaration $sibling = null): void
{
$propertyName = $declarationToAdd->getPropertyName();
if (!isset($this->declarations[$propertyName])) {
$this->declarations[$propertyName] = [];
}
$position = \count($this->declarations[$propertyName]);
if ($sibling !== null) {
$siblingIsInSet = false;
$siblingPosition = \array_search($sibling, $this->declarations[$propertyName], true);
if ($siblingPosition !== false) {
$siblingIsInSet = true;
$position = $siblingPosition;
} else {
$siblingIsInSet = $this->hasDeclaration($sibling);
if ($siblingIsInSet) {
// Maintain ordering within `$this->declarations[$propertyName]`
// by inserting before first `Declaration` with a same-or-later position than the sibling.
foreach ($this->declarations[$propertyName] as $index => $declaration) {
if (self::comparePositionable($declaration, $sibling) >= 0) {
$position = $index;
break;
}
}
}
}
if ($siblingIsInSet) {
// Increment column number of all existing declarations on same line, starting at sibling
$siblingLineNumber = $sibling->getLineNumber();
$siblingColumnNumber = $sibling->getColumnNumber();
foreach ($this->declarations as $declarationsForAProperty) {
foreach ($declarationsForAProperty as $declaration) {
if (
$declaration->getLineNumber() === $siblingLineNumber &&
$declaration->getColumnNumber() >= $siblingColumnNumber
) {
$declaration->setPosition($siblingLineNumber, $declaration->getColumnNumber() + 1);
}
}
}
$declarationToAdd->setPosition($siblingLineNumber, $siblingColumnNumber);
}
}
if ($declarationToAdd->getLineNumber() === null) {
//this node is added manually, give it the next best line
$columnNumber = $declarationToAdd->getColumnNumber() ?? 0;
$declarations = $this->getDeclarations();
$declarationsCount = \count($declarations);
if ($declarationsCount > 0) {
$last = $declarations[$declarationsCount - 1];
$lastsLineNumber = $last->getLineNumber();
if (!\is_int($lastsLineNumber)) {
throw new \UnexpectedValueException(
'A Declaration without a line number was found during addDeclaration',
1750718399
);
}
$declarationToAdd->setPosition($lastsLineNumber + 1, $columnNumber);
} else {
$declarationToAdd->setPosition(1, $columnNumber);
}
} elseif ($declarationToAdd->getColumnNumber() === null) {
$declarationToAdd->setPosition($declarationToAdd->getLineNumber(), 0);
}
\array_splice($this->declarations[$propertyName], $position, 0, [$declarationToAdd]);
}
/**
* Returns all declarations matching the given property name
*
* @example $ruleSet->getDeclarations('font') // returns array(0 => $declaration, …) or array().
*
* @example $ruleSet->getDeclarations('font-')
* //returns an array of all declarations either beginning with font- or matching font.
*
* @param string|null $searchPattern
* Pattern to search for. If null, returns all declarations.
* If the pattern ends with a dash, all declarations starting with the pattern are returned
* as well as one matching the pattern with the dash excluded.
*
* @return array<int<0, max>, Declaration>
*/
public function getDeclarations(?string $searchPattern = null): array
{
$result = [];
foreach ($this->declarations as $propertyName => $declarations) {
// Either no search pattern was given
// or the search pattern matches the found declaration's property name exactly
// or the search pattern ends in “-”
// ... and the found declaration's property name starts with the search pattern
if (
$searchPattern === null || $propertyName === $searchPattern
|| (
\strrpos($searchPattern, '-') === \strlen($searchPattern) - \strlen('-')
&& (\strpos($propertyName, $searchPattern) === 0
|| $propertyName === \substr($searchPattern, 0, -1))
)
) {
$result = \array_merge($result, $declarations);
}
}
\usort($result, [self::class, 'comparePositionable']);
return $result;
}
/**
* Overrides all the declarations of this set.
*
* @param array<Declaration> $declarations
*/
public function setDeclarations(array $declarations): void
{
$this->declarations = [];
foreach ($declarations as $declaration) {
$this->addDeclaration($declaration);
}
}
/**
* Returns all declarations with property names matching the given pattern and returns them in an associative array
* with the property names as keys.
* This method exists mainly for backwards-compatibility and is really only partially useful.
*
* Note: This method loses some information: Calling this (with an argument of `background-`) on a declaration block
* like `{ background-color: green; background-color; rgba(0, 127, 0, 0.7); }` will only yield an associative array
* containing the rgba-valued declaration while `getDeclarations()` would yield an indexed array containing both.
*
* @param string|null $searchPattern
* Pattern to search for. If null, returns all declarations. If the pattern ends with a dash,
* all declarations starting with the pattern are returned as well as one matching the pattern with the dash
* excluded.
*
* @return array<string, Declaration>
*/
public function getDeclarationsAssociative(?string $searchPattern = null): array
{
/** @var array<string, Declaration> $result */
$result = [];
foreach ($this->getDeclarations($searchPattern) as $declaration) {
$result[$declaration->getPropertyName()] = $declaration;
}
return $result;
}
/**
* Removes a `Declaration` from this `RuleSet` by identity.
*/
public function removeDeclaration(Declaration $declarationToRemove): void
{
$nameOfPropertyToRemove = $declarationToRemove->getPropertyName();
if (!isset($this->declarations[$nameOfPropertyToRemove])) {
return;
}
foreach ($this->declarations[$nameOfPropertyToRemove] as $key => $declaration) {
if ($declaration === $declarationToRemove) {
unset($this->declarations[$nameOfPropertyToRemove][$key]);
}
}
}
/**
* Removes declarations by property name or search pattern.
*
* @param string $searchPattern
* pattern to remove.
* If the pattern ends in a dash,
* all declarations starting with the pattern are removed as well as one matching the pattern with the dash
* excluded.
*/
public function removeMatchingDeclarations(string $searchPattern): void
{
foreach ($this->declarations as $propertyName => $declarations) {
// Either the search pattern matches the found declaration's property name exactly
// or the search pattern ends in “-” and the found declaration's property name starts with the search
// pattern or equals it (without the trailing dash).
if (
$propertyName === $searchPattern
|| (\strrpos($searchPattern, '-') === \strlen($searchPattern) - \strlen('-')
&& (\strpos($propertyName, $searchPattern) === 0
|| $propertyName === \substr($searchPattern, 0, -1)))
) {
unset($this->declarations[$propertyName]);
}
}
}
public function removeAllDeclarations(): void
{
$this->declarations = [];
}
/**
* @internal
*/
public function render(OutputFormat $outputFormat): string
{
return $this->renderDeclarations($outputFormat);
}
protected function renderDeclarations(OutputFormat $outputFormat): string
{
$result = '';
$isFirst = true;
$nextLevelFormat = $outputFormat->nextLevel();
foreach ($this->getDeclarations() as $declaration) {
$nextLevelFormatter = $nextLevelFormat->getFormatter();
$renderedDeclaration = $nextLevelFormatter->safely(
static function () use ($declaration, $nextLevelFormat): string {
return $declaration->render($nextLevelFormat);
}
);
if ($renderedDeclaration === null) {
continue;
}
if ($isFirst) {
$isFirst = false;
$result .= $nextLevelFormatter->spaceBeforeRules();
} else {
$result .= $nextLevelFormatter->spaceBetweenRules();
}
$result .= $renderedDeclaration;
}
$formatter = $outputFormat->getFormatter();
if (!$isFirst) {
// Had some output
$result .= $formatter->spaceAfterRules();
}
return $formatter->removeLastSemicolon($result);
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
}
/**
* @return int negative if `$first` is before `$second`; zero if they have the same position; positive otherwise
*
* @throws \UnexpectedValueException if either argument does not have a valid position, which should never happen
*/
private static function comparePositionable(Positionable $first, Positionable $second): int
{
$firstsLineNumber = $first->getLineNumber();
$secondsLineNumber = $second->getLineNumber();
if (!\is_int($firstsLineNumber) || !\is_int($secondsLineNumber)) {
throw new \UnexpectedValueException(
'A Declaration without a line number was passed to comparePositionable',
1750637683
);
}
if ($firstsLineNumber === $secondsLineNumber) {
$firstsColumnNumber = $first->getColumnNumber();
$secondsColumnNumber = $second->getColumnNumber();
if (!\is_int($firstsColumnNumber) || !\is_int($secondsColumnNumber)) {
throw new \UnexpectedValueException(
'A Declaration without a column number was passed to comparePositionable',
1750637761
);
}
return $firstsColumnNumber - $secondsColumnNumber;
}
return $firstsLineNumber - $secondsLineNumber;
}
private function hasDeclaration(Declaration $declaration): bool
{
foreach ($this->declarations as $declarationsForAProperty) {
if (\in_array($declaration, $declarationsForAProperty, true)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS;
/**
* Parser settings class.
*
* Configure parser behaviour here.
*/
class Settings
{
/**
* Multi-byte string support.
*
* If `true` (`mbstring` extension must be enabled), will use (slower) `mb_strlen`, `mb_convert_case`, `mb_substr`
* and `mb_strpos` functions. Otherwise, the normal (ASCII-Only) functions will be used.
*
* @var bool
*/
private $multibyteSupport;
/**
* The default charset for the CSS if no `@charset` declaration is found. Defaults to utf-8.
*
* @var non-empty-string
*/
private $defaultCharset = 'utf-8';
/**
* Whether the parser silently ignore invalid rules instead of choking on them.
*
* @var bool
*/
private $lenientParsing = true;
private function __construct()
{
$this->multibyteSupport = \extension_loaded('mbstring');
}
public static function create(): self
{
return new Settings();
}
/**
* Enables/disables multi-byte string support.
*
* If `true` (`mbstring` extension must be enabled), will use (slower) `mb_strlen`, `mb_convert_case`, `mb_substr`
* and `mb_strpos` functions. Otherwise, the normal (ASCII-Only) functions will be used.
*
* @return $this fluent interface
*/
public function withMultibyteSupport(bool $multibyteSupport = true): self
{
$this->multibyteSupport = $multibyteSupport;
return $this;
}
/**
* Sets the charset to be used if the CSS does not contain an `@charset` declaration.
*
* @param non-empty-string $defaultCharset
*
* @return $this fluent interface
*/
public function withDefaultCharset(string $defaultCharset): self
{
$this->defaultCharset = $defaultCharset;
return $this;
}
/**
* Configures whether the parser should silently ignore invalid rules.
*
* @return $this fluent interface
*/
public function withLenientParsing(bool $usesLenientParsing = true): self
{
$this->lenientParsing = $usesLenientParsing;
return $this;
}
/**
* Configures the parser to choke on invalid rules.
*
* @return $this fluent interface
*/
public function beStrict(): self
{
return $this->withLenientParsing(false);
}
/**
* @internal
*/
public function hasMultibyteSupport(): bool
{
return $this->multibyteSupport;
}
/**
* @return non-empty-string
*
* @internal
*/
public function getDefaultCharset(): string
{
return $this->defaultCharset;
}
/**
* @internal
*/
public function usesLenientParsing(): bool
{
return $this->lenientParsing;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS;
/**
* Provides a method to obtain the short name of the instantiated class (i.e. without namespace prefix).
*
* @internal
*/
trait ShortClassNameProvider
{
/**
* @return non-empty-string
*/
private function getShortClassName(): string
{
return (new \ReflectionClass($this))->getShortName();
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
/**
* A `CSSFunction` represents a special kind of value that also contains a function name and where the values are the
* functions arguments. It also handles equals-sign-separated argument lists like `filter: alpha(opacity=90);`.
*/
class CSSFunction extends ValueList
{
/**
* @var non-empty-string
*
* @internal since 8.8.0
*/
protected $name;
/**
* @param non-empty-string $name
* @param RuleValueList|array<Value|string> $arguments
* @param non-empty-string $separator
* @param int<1, max>|null $lineNumber
*/
public function __construct(string $name, $arguments, string $separator = ',', ?int $lineNumber = null)
{
if ($arguments instanceof RuleValueList) {
$separator = $arguments->getListSeparator();
$arguments = $arguments->getListComponents();
}
$this->name = $name;
$this->setPosition($lineNumber); // TODO: redundant?
parent::__construct($arguments, $separator, $lineNumber);
}
/**
* @throws SourceException
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState, bool $ignoreCase = false): CSSFunction
{
$name = self::parseName($parserState, $ignoreCase);
$parserState->consume('(');
$arguments = self::parseArguments($parserState);
$result = new CSSFunction($name, $arguments, ',', $parserState->currentLine());
$parserState->consume(')');
return $result;
}
/**
* @throws SourceException
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
private static function parseName(ParserState $parserState, bool $ignoreCase = false): string
{
return $parserState->parseIdentifier($ignoreCase);
}
/**
* @return Value|string
*
* @throws SourceException
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
private static function parseArguments(ParserState $parserState)
{
return Value::parseValue($parserState, ['=', ' ', ',']);
}
/**
* @return non-empty-string
*/
public function getName(): string
{
return $this->name;
}
/**
* @param non-empty-string $name
*/
public function setName(string $name): void
{
$this->name = $name;
}
/**
* @return array<Value|string>
*/
public function getArguments(): array
{
return $this->components;
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
$arguments = parent::render($outputFormat);
return "{$this->name}({$arguments})";
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
return \array_merge(
[
'class' => 'placeholder',
'name' => $this->name,
],
parent::getArrayRepresentation()
);
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\ShortClassNameProvider;
use function Safe\preg_match;
/**
* This class is a wrapper for quoted strings to distinguish them from keywords.
*
* `CSSString`s always output with double quotes.
*/
class CSSString extends PrimitiveValue
{
use ShortClassNameProvider;
/**
* @var string
*/
private $string;
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(string $string, ?int $lineNumber = null)
{
$this->string = $string;
parent::__construct($lineNumber);
}
/**
* @throws SourceException
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState): CSSString
{
$begin = $parserState->peek();
$quote = null;
if ($begin === "'") {
$quote = "'";
} elseif ($begin === '"') {
$quote = '"';
}
if ($quote !== null) {
$parserState->consume($quote);
}
$result = '';
$content = null;
if ($quote === null) {
// Unquoted strings end in whitespace or with braces, brackets, parentheses
while (preg_match('/[\\s{}()<>\\[\\]]/isu', $parserState->peek()) === 0) {
$result .= $parserState->parseCharacter(false);
}
} else {
while (!$parserState->comes($quote)) {
$content = $parserState->parseCharacter(false);
if ($content === null) {
throw new SourceException(
"Non-well-formed quoted string {$parserState->peek(3)}",
$parserState->currentLine()
);
}
$result .= $content;
}
$parserState->consume($quote);
}
return new CSSString($result, $parserState->currentLine());
}
public function setString(string $string): void
{
$this->string = $string;
}
public function getString(): string
{
return $this->string;
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
return $outputFormat->getStringQuotingType()
. $this->escape($this->string, $outputFormat)
. $outputFormat->getStringQuotingType();
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
return [
'class' => $this->getShortClassName(),
// We're using the term "contents" here to make the difference to the class more clear.
'contents' => $this->string,
];
}
private function escape(string $string, OutputFormat $outputFormat): string
{
$charactersToEscape = '\\';
$charactersToEscape .= ($outputFormat->getStringQuotingType() === '"' ? '"' : "'");
$withEscapedQuotes = \addcslashes($string, $charactersToEscape);
$withNewlineEncoded = \str_replace("\n", '\\A', $withEscapedQuotes);
return $withNewlineEncoded;
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use function Safe\preg_match;
class CalcFunction extends CSSFunction
{
private const T_OPERAND = 1;
private const T_OPERATOR = 2;
/**
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState, bool $ignoreCase = false): CSSFunction
{
$operators = ['+', '-', '*', '/'];
$function = $parserState->parseIdentifier();
if ($parserState->peek() !== '(') {
// Found ; or end of line before an opening bracket
throw new UnexpectedTokenException('(', $parserState->peek(), 'literal', $parserState->currentLine());
} elseif ($function !== 'calc') {
// Found invalid calc definition. Example calc (...
throw new UnexpectedTokenException('calc', $function, 'literal', $parserState->currentLine());
}
$parserState->consume('(');
$calcRuleValueList = new CalcRuleValueList($parserState->currentLine());
$list = new RuleValueList(',', $parserState->currentLine());
$nestingLevel = 0;
$lastComponentType = null;
while (!$parserState->comes(')') || $nestingLevel > 0) {
if ($parserState->isEnd() && $nestingLevel === 0) {
break;
}
$parserState->consumeWhiteSpace();
if ($parserState->comes('(')) {
$nestingLevel++;
$calcRuleValueList->addListComponent($parserState->consume(1));
$parserState->consumeWhiteSpace();
continue;
} elseif ($parserState->comes(')')) {
$nestingLevel--;
$calcRuleValueList->addListComponent($parserState->consume(1));
$parserState->consumeWhiteSpace();
continue;
}
if ($lastComponentType !== CalcFunction::T_OPERAND) {
$value = Value::parsePrimitiveValue($parserState);
$calcRuleValueList->addListComponent($value);
$lastComponentType = CalcFunction::T_OPERAND;
} else {
if (\in_array($parserState->peek(), $operators, true)) {
if (($parserState->comes('-') || $parserState->comes('+'))) {
if (
preg_match('/\\s/', $parserState->peek(1, -1)) !== 1
|| preg_match('/\\s/', $parserState->peek(1, 1)) !== 1
) {
throw new UnexpectedTokenException(
" {$parserState->peek()} ",
$parserState->peek(1, -1) . $parserState->peek(2),
'literal',
$parserState->currentLine()
);
}
}
$calcRuleValueList->addListComponent($parserState->consume(1));
$lastComponentType = CalcFunction::T_OPERATOR;
} else {
throw new UnexpectedTokenException(
\sprintf(
'Next token was expected to be an operand of type %s. Instead "%s" was found.',
\implode(', ', $operators),
$parserState->peek()
),
'',
'custom',
$parserState->currentLine()
);
}
}
$parserState->consumeWhiteSpace();
}
$list->addListComponent($calcRuleValueList);
if (!$parserState->isEnd()) {
$parserState->consume(')');
}
return new CalcFunction($function, $list, ',', $parserState->currentLine());
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
class CalcRuleValueList extends RuleValueList
{
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(?int $lineNumber = null)
{
parent::__construct(',', $lineNumber);
}
public function render(OutputFormat $outputFormat): string
{
return $outputFormat->getFormatter()->implode(' ', $this->components);
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
}
}

View File

@@ -0,0 +1,400 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
/**
* `Color's can be input in the form #rrggbb, #rgb or schema(val1, val2, …) but are always stored as an array of
* ('s' => val1, 'c' => val2, 'h' => val3, …) and output in the second form.
*/
class Color extends CSSFunction
{
/**
* @param array<non-empty-string, Value|string> $colorValues
* @param int<1, max>|null $lineNumber
*/
public function __construct(array $colorValues, ?int $lineNumber = null)
{
parent::__construct(\implode('', \array_keys($colorValues)), $colorValues, ',', $lineNumber);
}
/**
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState, bool $ignoreCase = false): CSSFunction
{
return $parserState->comes('#')
? self::parseHexColor($parserState)
: self::parseColorFunction($parserState);
}
/**
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
private static function parseHexColor(ParserState $parserState): Color
{
$parserState->consume('#');
$hexValue = $parserState->parseIdentifier(false);
if ($parserState->strlen($hexValue) === 3) {
$hexValue = $hexValue[0] . $hexValue[0] . $hexValue[1] . $hexValue[1] . $hexValue[2] . $hexValue[2];
} elseif ($parserState->strlen($hexValue) === 4) {
$hexValue = $hexValue[0] . $hexValue[0] . $hexValue[1] . $hexValue[1] . $hexValue[2] . $hexValue[2]
. $hexValue[3] . $hexValue[3];
}
if ($parserState->strlen($hexValue) === 8) {
$colorValues = [
'r' => new Size(\intval($hexValue[0] . $hexValue[1], 16), null, true, $parserState->currentLine()),
'g' => new Size(\intval($hexValue[2] . $hexValue[3], 16), null, true, $parserState->currentLine()),
'b' => new Size(\intval($hexValue[4] . $hexValue[5], 16), null, true, $parserState->currentLine()),
'a' => new Size(
\round(self::mapRange(\intval($hexValue[6] . $hexValue[7], 16), 0, 255, 0, 1), 2),
null,
true,
$parserState->currentLine()
),
];
} elseif ($parserState->strlen($hexValue) === 6) {
$colorValues = [
'r' => new Size(\intval($hexValue[0] . $hexValue[1], 16), null, true, $parserState->currentLine()),
'g' => new Size(\intval($hexValue[2] . $hexValue[3], 16), null, true, $parserState->currentLine()),
'b' => new Size(\intval($hexValue[4] . $hexValue[5], 16), null, true, $parserState->currentLine()),
];
} else {
throw new UnexpectedTokenException(
'Invalid hex color value',
$hexValue,
'custom',
$parserState->currentLine()
);
}
return new Color($colorValues, $parserState->currentLine());
}
/**
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
private static function parseColorFunction(ParserState $parserState): CSSFunction
{
$colorValues = [];
$colorMode = $parserState->parseIdentifier(true);
$parserState->consumeWhiteSpace();
$parserState->consume('(');
// CSS Color Module Level 4 says that `rgb` and `rgba` are now aliases; likewise `hsl` and `hsla`.
// So, attempt to parse with the `a`, and allow for it not being there.
switch ($colorMode) {
case 'rgb':
$colorModeForParsing = 'rgba';
$mayHaveOptionalAlpha = true;
break;
case 'hsl':
$colorModeForParsing = 'hsla';
$mayHaveOptionalAlpha = true;
break;
case 'rgba':
// This is handled identically to the following case.
case 'hsla':
$colorModeForParsing = $colorMode;
$mayHaveOptionalAlpha = true;
break;
default:
$colorModeForParsing = $colorMode;
$mayHaveOptionalAlpha = false;
}
$containsVar = false;
$containsNone = false;
$isLegacySyntax = false;
$expectedArgumentCount = $parserState->strlen($colorModeForParsing);
for ($argumentIndex = 0; $argumentIndex < $expectedArgumentCount; ++$argumentIndex) {
$parserState->consumeWhiteSpace();
$valueKey = $colorModeForParsing[$argumentIndex];
if ($parserState->comes('var')) {
$colorValues[$valueKey] = CSSFunction::parseIdentifierOrFunction($parserState);
$containsVar = true;
} elseif (!$isLegacySyntax && $parserState->comes('none')) {
$colorValues[$valueKey] = $parserState->parseIdentifier();
$containsNone = true;
} else {
$colorValues[$valueKey] = Size::parse($parserState, true);
}
// This must be done first, to consume comments as well, so that the `comes` test will work.
$parserState->consumeWhiteSpace();
// With a `var` argument, the function can have fewer arguments.
// And as of CSS Color Module Level 4, the alpha argument is optional.
$canCloseNow =
$containsVar
|| ($mayHaveOptionalAlpha && $argumentIndex >= $expectedArgumentCount - 2);
if ($canCloseNow && $parserState->comes(')')) {
break;
}
// "Legacy" syntax is comma-delimited, and does not allow the `none` keyword.
// "Modern" syntax is space-delimited, with `/` as alpha delimiter.
// They cannot be mixed.
if ($argumentIndex === 0 && !$containsNone) {
// An immediate closing parenthesis is not valid.
if ($parserState->comes(')')) {
throw new UnexpectedTokenException(
'Color function with no arguments',
'',
'custom',
$parserState->currentLine()
);
}
$isLegacySyntax = $parserState->comes(',');
}
if ($isLegacySyntax && $argumentIndex < ($expectedArgumentCount - 1)) {
$parserState->consume(',');
}
// In the "modern" syntax, the alpha value must be delimited with `/`.
if (!$isLegacySyntax) {
if ($containsVar) {
// If the `var` substitution encompasses more than one argument,
// the alpha deliminator may come at any time.
if ($parserState->comes('/')) {
$parserState->consume('/');
}
} elseif (($colorModeForParsing[$argumentIndex + 1] ?? '') === 'a') {
// Alpha value is the next expected argument.
// Since a closing parenthesis was not found, a `/` separator is now required.
$parserState->consume('/');
}
}
}
$parserState->consume(')');
return $containsVar
? new CSSFunction($colorMode, \array_values($colorValues), ',', $parserState->currentLine())
: new Color($colorValues, $parserState->currentLine());
}
private static function mapRange(float $value, float $fromMin, float $fromMax, float $toMin, float $toMax): float
{
$fromRange = $fromMax - $fromMin;
$toRange = $toMax - $toMin;
$multiplier = $toRange / $fromRange;
$newValue = $value - $fromMin;
$newValue *= $multiplier;
return $newValue + $toMin;
}
/**
* @return array<non-empty-string, Value|string>
*/
public function getColor(): array
{
return $this->components;
}
/**
* @param array<non-empty-string, Value|string> $colorValues
*/
public function setColor(array $colorValues): void
{
$this->setName(\implode('', \array_keys($colorValues)));
$this->components = $colorValues;
}
/**
* @return non-empty-string
*/
public function getColorDescription(): string
{
return $this->getName();
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
if ($this->shouldRenderAsHex($outputFormat)) {
return $this->renderAsHex();
}
if ($this->shouldRenderInModernSyntax()) {
return $this->renderInModernSyntax($outputFormat);
}
return parent::render($outputFormat);
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
}
private function shouldRenderAsHex(OutputFormat $outputFormat): bool
{
return
$outputFormat->usesRgbHashNotation()
&& $this->getRealName() === 'rgb'
&& $this->allComponentsAreNumbers();
}
/**
* The function name is a concatenation of the array keys of the components, which is passed to the constructor.
* However, this can be changed by calling {@see CSSFunction::setName},
* so is not reliable in situations where it's necessary to determine the function name based on the components.
*/
private function getRealName(): string
{
return \implode('', \array_keys($this->components));
}
/**
* Test whether all color components are absolute numbers (CSS type `number`), not percentages or anything else.
* If any component is not an instance of `Size`, the method will also return `false`.
*/
private function allComponentsAreNumbers(): bool
{
foreach ($this->components as $component) {
if (!($component instanceof Size) || $component->getUnit() !== null) {
return false;
}
}
return true;
}
/**
* Note that this method assumes the following:
* - The `components` array has keys for `r`, `g` and `b`;
* - The values in the array are all instances of `Size`.
*
* Errors will be triggered or thrown if this is not the case.
*
* @return non-empty-string
*/
private function renderAsHex(): string
{
$result = \sprintf(
'%02x%02x%02x',
$this->components['r']->getSize(),
$this->components['g']->getSize(),
$this->components['b']->getSize()
);
$canUseShortVariant = ($result[0] === $result[1]) && ($result[2] === $result[3]) && ($result[4] === $result[5]);
return '#' . ($canUseShortVariant ? $result[0] . $result[2] . $result[4] : $result);
}
/**
* The "legacy" syntax does not allow RGB colors to have a mixture of `percentage`s and `number`s,
* and does not allow `none` as any component value.
*
* The "legacy" and "modern" monikers are part of the formal W3C syntax.
* See the following for more information:
* - {@link
* https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/rgb#formal_syntax
* Description of the formal syntax for `rgb()` on MDN
* };
* - {@link
* https://www.w3.org/TR/css-color-4/#rgb-functions
* The same in the CSS Color Module Level 4 W3C Candidate Recommendation Draft
* } (as of 13 February 2024, at time of writing).
*/
private function shouldRenderInModernSyntax(): bool
{
if ($this->hasNoneAsComponentValue()) {
return true;
}
if (!$this->colorFunctionMayHaveMixedValueTypes($this->getRealName())) {
return false;
}
$hasPercentage = false;
$hasNumber = false;
foreach ($this->components as $key => $value) {
if ($key === 'a') {
// Alpha can have units that don't match those of the RGB components in the "legacy" syntax.
// So it is not necessary to check it. It's also always last, hence `break` rather than `continue`.
break;
}
if (!($value instanceof Size)) {
// Unexpected, unknown, or modified via the API
return false;
}
$unit = $value->getUnit();
// `switch` only does loose comparison
if ($unit === null) {
$hasNumber = true;
} elseif ($unit === '%') {
$hasPercentage = true;
} else {
// Invalid unit
return false;
}
}
return $hasPercentage && $hasNumber;
}
private function hasNoneAsComponentValue(): bool
{
return \in_array('none', $this->components, true);
}
/**
* Some color functions, such as `rgb`,
* may have a mixture of `percentage`, `number`, or possibly other types in their arguments.
*
* Note that this excludes the alpha component, which is treated separately.
*/
private function colorFunctionMayHaveMixedValueTypes(string $function): bool
{
$functionsThatMayHaveMixedValueTypes = ['rgb', 'rgba'];
return \in_array($function, $functionsThatMayHaveMixedValueTypes, true);
}
/**
* @return non-empty-string
*/
private function renderInModernSyntax(OutputFormat $outputFormat): string
{
// Maybe not yet without alpha, but will be...
$componentsWithoutAlpha = $this->components;
\end($componentsWithoutAlpha);
if (\key($componentsWithoutAlpha) === 'a') {
$alpha = $this->components['a'];
unset($componentsWithoutAlpha['a']);
}
$formatter = $outputFormat->getFormatter();
$arguments = $formatter->implode(' ', $componentsWithoutAlpha);
if (isset($alpha)) {
$separator = $formatter->spaceBeforeListArgumentSeparator('/')
. '/' . $formatter->spaceAfterListArgumentSeparator('/');
$arguments = $formatter->implode($separator, [$arguments, $alpha]);
}
return $this->getName() . '(' . $arguments . ')';
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
class LineName extends ValueList
{
/**
* @param array<Value|string> $components
* @param int<1, max>|null $lineNumber
*/
public function __construct(array $components = [], ?int $lineNumber = null)
{
parent::__construct($components, ' ', $lineNumber);
}
/**
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState): LineName
{
$parserState->consume('[');
$parserState->consumeWhiteSpace();
$names = [];
do {
if ($parserState->getSettings()->usesLenientParsing()) {
try {
$names[] = $parserState->parseIdentifier();
} catch (UnexpectedTokenException $e) {
if (!$parserState->comes(']')) {
throw $e;
}
}
} else {
$names[] = $parserState->parseIdentifier();
}
$parserState->consumeWhiteSpace();
} while (!$parserState->comes(']'));
$parserState->consume(']');
return new LineName($names, $parserState->currentLine());
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
return '[' . parent::render(OutputFormat::createCompact()) . ']';
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
}
}

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
abstract class PrimitiveValue extends Value {}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
/**
* This class is used to represent all multivalued rules like `font: bold 12px/3 Helvetica, Verdana, sans-serif;`
* (where the value would be a whitespace-separated list of the primitive value `bold`, a slash-separated list
* and a comma-separated list).
*/
class RuleValueList extends ValueList
{
/**
* @param non-empty-string $separator
* @param int<1, max>|null $lineNumber
*/
public function __construct(string $separator = ',', ?int $lineNumber = null)
{
parent::__construct([], $separator, $lineNumber);
}
}

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\ShortClassNameProvider;
use function Safe\preg_match;
use function Safe\preg_replace;
/**
* A `Size` consists of a numeric `size` value and a unit.
*/
class Size extends PrimitiveValue
{
use ShortClassNameProvider;
/**
* vh/vw/vm(ax)/vmin/rem are absolute insofar as they dont scale to the immediate parent (only the viewport)
*/
private const ABSOLUTE_SIZE_UNITS = [
'px',
'pt',
'pc',
'cm',
'mm',
'mozmm',
'in',
'vh',
'dvh',
'svh',
'lvh',
'vw',
'vmin',
'vmax',
'rem',
];
private const RELATIVE_SIZE_UNITS = ['%', 'em', 'ex', 'ch', 'fr'];
private const NON_SIZE_UNITS = ['deg', 'grad', 'rad', 's', 'ms', 'turn', 'Hz', 'kHz'];
/**
* @var array<int<1, max>, array<lowercase-string, non-empty-string>>|null
*/
private static $SIZE_UNITS = null;
/**
* @var float
*/
private $size;
/**
* @var string|null
*/
private $unit;
/**
* @var bool
*/
private $isColorComponent;
/**
* @param float|int|string $size
* @param int<1, max>|null $lineNumber
*/
public function __construct($size, ?string $unit = null, bool $isColorComponent = false, ?int $lineNumber = null)
{
parent::__construct($lineNumber);
$this->size = (float) $size;
$this->unit = $unit;
$this->isColorComponent = $isColorComponent;
}
/**
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState, bool $isColorComponent = false): Size
{
$size = '';
if ($parserState->comes('-')) {
$size .= $parserState->consume('-');
}
while (\is_numeric($parserState->peek()) || $parserState->comes('.') || $parserState->comes('e', true)) {
if ($parserState->comes('.')) {
$size .= $parserState->consume('.');
} elseif ($parserState->comes('e', true)) {
$lookahead = $parserState->peek(1, 1);
if (\is_numeric($lookahead) || $lookahead === '+' || $lookahead === '-') {
$size .= $parserState->consume(2);
} else {
break; // Reached the unit part of the number like "em" or "ex"
}
} else {
$size .= $parserState->consume(1);
}
}
$unit = null;
$sizeUnits = self::getSizeUnits();
foreach ($sizeUnits as $length => &$values) {
$key = \strtolower($parserState->peek($length));
if (\array_key_exists($key, $values)) {
if (($unit = $values[$key]) !== null) {
$parserState->consume($length);
break;
}
}
}
return new Size((float) $size, $unit, $isColorComponent, $parserState->currentLine());
}
/**
* @return array<int<1, max>, array<lowercase-string, non-empty-string>>
*/
private static function getSizeUnits(): array
{
if (!\is_array(self::$SIZE_UNITS)) {
self::$SIZE_UNITS = [];
$sizeUnits = \array_merge(self::ABSOLUTE_SIZE_UNITS, self::RELATIVE_SIZE_UNITS, self::NON_SIZE_UNITS);
foreach ($sizeUnits as $sizeUnit) {
$tokenLength = \strlen($sizeUnit);
if (!isset(self::$SIZE_UNITS[$tokenLength])) {
self::$SIZE_UNITS[$tokenLength] = [];
}
self::$SIZE_UNITS[$tokenLength][\strtolower($sizeUnit)] = $sizeUnit;
}
\krsort(self::$SIZE_UNITS, SORT_NUMERIC);
}
return self::$SIZE_UNITS;
}
public function setUnit(string $unit): void
{
$this->unit = $unit;
}
public function getUnit(): ?string
{
return $this->unit;
}
/**
* @param float|int|string $size
*/
public function setSize($size): void
{
$this->size = (float) $size;
}
public function getSize(): float
{
return $this->size;
}
public function isColorComponent(): bool
{
return $this->isColorComponent;
}
/**
* Returns whether the number stored in this Size really represents a size (as in a length of something on screen).
*
* Returns `false` if the unit is an angle, a duration, a frequency, or the number is a component in a `Color`
* object.
*/
public function isSize(): bool
{
if (\in_array($this->unit, self::NON_SIZE_UNITS, true)) {
return false;
}
return !$this->isColorComponent();
}
public function isRelative(): bool
{
if (\in_array($this->unit, self::RELATIVE_SIZE_UNITS, true)) {
return true;
}
if ($this->unit === null && $this->size !== 0.0) {
return true;
}
return false;
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
$locale = \localeconv();
$decimalPoint = \preg_quote($locale['decimal_point'], '/');
$size = preg_match('/[\\d\\.]+e[+-]?\\d+/i', (string) $this->size) === 1
? preg_replace("/$decimalPoint?0+$/", '', \sprintf('%f', $this->size)) : (string) $this->size;
return preg_replace(["/$decimalPoint/", '/^(-?)0\\./'], ['.', '$1.'], $size) . ($this->unit ?? '');
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
return [
'class' => $this->getShortClassName(),
// 'number' is the official W3C terminology (not 'size')
'number' => $this->size,
'unit' => $this->unit,
];
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\ShortClassNameProvider;
/**
* This class represents URLs in CSS. `URL`s always output in `URL("")` notation.
*/
class URL extends PrimitiveValue
{
use ShortClassNameProvider;
/**
* @var CSSString
*/
private $url;
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(CSSString $url, ?int $lineNumber = null)
{
parent::__construct($lineNumber);
$this->url = $url;
}
/**
* @throws SourceException
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState): URL
{
$anchor = $parserState->anchor();
$identifier = '';
for ($i = 0; $i < 3; $i++) {
$character = $parserState->parseCharacter(true);
if ($character === null) {
break;
}
$identifier .= $character;
}
$useUrl = $parserState->streql($identifier, 'url');
if ($useUrl) {
$parserState->consumeWhiteSpace();
$parserState->consume('(');
} else {
$anchor->backtrack();
}
$parserState->consumeWhiteSpace();
$result = new URL(CSSString::parse($parserState), $parserState->currentLine());
if ($useUrl) {
$parserState->consumeWhiteSpace();
$parserState->consume(')');
}
return $result;
}
public function setURL(CSSString $url): void
{
$this->url = $url;
}
public function getURL(): CSSString
{
return $this->url;
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
return "url({$this->url->render($outputFormat)})";
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
return [
'class' => $this->getShortClassName(),
// We're using the term "uri" here to match the wording used in the specs:
// https://www.w3.org/TR/CSS22/syndata.html#uri
'uri' => $this->url->getArrayRepresentation(),
];
}
}

View File

@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\CSSElement;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\Position\Position;
use Sabberworm\CSS\Position\Positionable;
use Sabberworm\CSS\ShortClassNameProvider;
use function Safe\preg_match;
/**
* Abstract base class for specific classes of CSS values: `Size`, `Color`, `CSSString` and `URL`, and another
* abstract subclass `ValueList`.
*/
abstract class Value implements CSSElement, Positionable
{
use Position;
use ShortClassNameProvider;
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(?int $lineNumber = null)
{
$this->setPosition($lineNumber);
}
/**
* @param array<non-empty-string> $listDelimiters
*
* @return Value|string
*
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*
* @internal since V8.8.0
*/
public static function parseValue(ParserState $parserState, array $listDelimiters = [])
{
/** @var list<Value|string> $stack */
$stack = [];
$parserState->consumeWhiteSpace();
//Build a list of delimiters and parsed values
while (
!($parserState->comes('}') || $parserState->comes(';') || $parserState->comes('!')
|| $parserState->comes(')')
|| $parserState->isEnd())
) {
if (\count($stack) > 0) {
$foundDelimiter = false;
foreach ($listDelimiters as $delimiter) {
if ($parserState->comes($delimiter)) {
\array_push($stack, $parserState->consume($delimiter));
$parserState->consumeWhiteSpace();
$foundDelimiter = true;
break;
}
}
if (!$foundDelimiter) {
//Whitespace was the list delimiter
\array_push($stack, ' ');
}
}
\array_push($stack, self::parsePrimitiveValue($parserState));
$parserState->consumeWhiteSpace();
}
// Convert the list to list objects
foreach ($listDelimiters as $delimiter) {
$stackSize = \count($stack);
if ($stackSize === 1) {
return $stack[0];
}
$newStack = [];
for ($offset = 0; $offset < $stackSize; ++$offset) {
if ($offset === ($stackSize - 1) || $delimiter !== $stack[$offset + 1]) {
$newStack[] = $stack[$offset];
continue;
}
$length = 2; //Number of elements to be joined
for ($i = $offset + 3; $i < $stackSize; $i += 2, ++$length) {
if ($delimiter !== $stack[$i]) {
break;
}
}
$list = new RuleValueList($delimiter, $parserState->currentLine());
for ($i = $offset; $i - $offset < $length * 2; $i += 2) {
$list->addListComponent($stack[$i]);
}
$newStack[] = $list;
$offset += $length * 2 - 2;
}
$stack = $newStack;
}
if (!isset($stack[0])) {
throw new UnexpectedTokenException(
" {$parserState->peek()} ",
$parserState->peek(1, -1) . $parserState->peek(2),
'literal',
$parserState->currentLine()
);
}
return $stack[0];
}
/**
* @return CSSFunction|string
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*
* @internal since V8.8.0
*/
public static function parseIdentifierOrFunction(ParserState $parserState, bool $ignoreCase = false)
{
$anchor = $parserState->anchor();
$result = $parserState->parseIdentifier($ignoreCase);
if ($parserState->comes('(')) {
$anchor->backtrack();
if ($parserState->streql('url', $result)) {
$result = URL::parse($parserState);
} elseif ($parserState->streql('calc', $result)) {
$result = CalcFunction::parse($parserState);
} else {
$result = CSSFunction::parse($parserState, $ignoreCase);
}
}
return $result;
}
/**
* @return CSSFunction|CSSString|LineName|Size|URL|string
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
* @throws SourceException
*
* @internal since V8.8.0
*/
public static function parsePrimitiveValue(ParserState $parserState)
{
$value = null;
$parserState->consumeWhiteSpace();
if (
\is_numeric($parserState->peek())
|| ($parserState->comes('-.')
&& \is_numeric($parserState->peek(1, 2)))
|| (($parserState->comes('-') || $parserState->comes('.')) && \is_numeric($parserState->peek(1, 1)))
) {
$value = Size::parse($parserState);
} elseif ($parserState->comes('#') || $parserState->comes('rgb', true) || $parserState->comes('hsl', true)) {
$value = Color::parse($parserState);
} elseif ($parserState->comes("'") || $parserState->comes('"')) {
$value = CSSString::parse($parserState);
} elseif ($parserState->comes('progid:') && $parserState->getSettings()->usesLenientParsing()) {
$value = self::parseMicrosoftFilter($parserState);
} elseif ($parserState->comes('[')) {
$value = LineName::parse($parserState);
} elseif ($parserState->comes('U+')) {
$value = self::parseUnicodeRangeValue($parserState);
} else {
$nextCharacter = $parserState->peek(1);
try {
$value = self::parseIdentifierOrFunction($parserState);
} catch (UnexpectedTokenException $e) {
if (\in_array($nextCharacter, ['+', '-', '*', '/'], true)) {
$value = $parserState->consume(1);
} else {
throw $e;
}
}
}
$parserState->consumeWhiteSpace();
return $value;
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
return [
'class' => $this->getShortClassName(),
];
}
/**
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
private static function parseMicrosoftFilter(ParserState $parserState): CSSFunction
{
$function = $parserState->consumeUntil('(', false, true);
$arguments = Value::parseValue($parserState, [',', '=']);
return new CSSFunction($function, $arguments, ',', $parserState->currentLine());
}
/**
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
private static function parseUnicodeRangeValue(ParserState $parserState): string
{
$codepointMaxLength = 6; // Code points outside BMP can use up to six digits
$range = '';
$parserState->consume('U+');
do {
if ($parserState->comes('-')) {
$codepointMaxLength = 13; // Max length is 2 six-digit code points + the dash(-) between them
}
$range .= $parserState->consume(1);
} while (
(\strlen($range) < $codepointMaxLength) && (preg_match('/[A-Fa-f0-9\\?-]/', $parserState->peek()) === 1)
);
return "U+{$range}";
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\ShortClassNameProvider;
/**
* A `ValueList` represents a lists of `Value`s, separated by some separation character
* (mostly `,`, whitespace, or `/`).
*
* There are two types of `ValueList`s: `RuleValueList` and `CSSFunction`
*/
abstract class ValueList extends Value
{
use ShortClassNameProvider;
/**
* @var array<Value|string>
*
* @internal since 8.8.0
*/
protected $components;
/**
* @var non-empty-string
*
* @internal since 8.8.0
*/
protected $separator;
/**
* @param array<Value|string>|Value|string $components
* @param non-empty-string $separator
* @param int<1, max>|null $lineNumber
*/
public function __construct($components = [], $separator = ',', ?int $lineNumber = null)
{
parent::__construct($lineNumber);
if (!\is_array($components)) {
$components = [$components];
}
$this->components = $components;
$this->separator = $separator;
}
/**
* @param Value|string $component
*/
public function addListComponent($component): void
{
$this->components[] = $component;
}
/**
* @return array<Value|string>
*/
public function getListComponents(): array
{
return $this->components;
}
/**
* @param array<Value|string> $components
*/
public function setListComponents(array $components): void
{
$this->components = $components;
}
/**
* @return non-empty-string
*/
public function getListSeparator(): string
{
return $this->separator;
}
/**
* @param non-empty-string $separator
*/
public function setListSeparator(string $separator): void
{
$this->separator = $separator;
}
public function render(OutputFormat $outputFormat): string
{
$formatter = $outputFormat->getFormatter();
return $formatter->implode(
$formatter->spaceBeforeListArgumentSeparator($this->separator) . $this->separator
. $formatter->spaceAfterListArgumentSeparator($this->separator),
$this->components
);
}
/**
* @return array<string, bool|int|float|string|array<mixed>|null>
*
* @internal
*/
public function getArrayRepresentation(): array
{
return [
'class' => $this->getShortClassName(),
'components' => \array_map(
/**
* @parm Value|string $component
*/
function ($component): array {
if (\is_string($component)) {
return ['class' => 'string', 'value' => $component];
}
return $component->getArrayRepresentation();
},
$this->components
),
'separator' => $this->separator,
];
}
}