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,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,
];
}
}