Vendor packages updated

This commit is contained in:
2026-07-07 22:49:30 +02:00
parent 30269316b2
commit 97963b34aa
1239 changed files with 216109 additions and 81192 deletions

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,183 @@
<?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();
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 = [];
$parserState->consumeWhiteSpace($comments);
while (!$parserState->isEnd()) {
$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) {
// ... 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 (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 &$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;
}
}