See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401]
1 <?php 2 3 namespace Sabberworm\CSS\CSSList; 4 5 use Sabberworm\CSS\Comment\Comment; 6 use Sabberworm\CSS\Comment\Commentable; 7 use Sabberworm\CSS\OutputFormat; 8 use Sabberworm\CSS\Parsing\ParserState; 9 use Sabberworm\CSS\Parsing\SourceException; 10 use Sabberworm\CSS\Parsing\UnexpectedEOFException; 11 use Sabberworm\CSS\Parsing\UnexpectedTokenException; 12 use Sabberworm\CSS\Property\AtRule; 13 use Sabberworm\CSS\Property\Charset; 14 use Sabberworm\CSS\Property\CSSNamespace; 15 use Sabberworm\CSS\Property\Import; 16 use Sabberworm\CSS\Property\Selector; 17 use Sabberworm\CSS\Renderable; 18 use Sabberworm\CSS\RuleSet\AtRuleSet; 19 use Sabberworm\CSS\RuleSet\DeclarationBlock; 20 use Sabberworm\CSS\RuleSet\RuleSet; 21 use Sabberworm\CSS\Settings; 22 use Sabberworm\CSS\Value\CSSString; 23 use Sabberworm\CSS\Value\URL; 24 use Sabberworm\CSS\Value\Value; 25 26 /** 27 * A `CSSList` is the most generic container available. Its contents include `RuleSet` as well as other `CSSList` 28 * objects. 29 * 30 * Also, it may contain `Import` and `Charset` objects stemming from at-rules. 31 */ 32 abstract class CSSList implements Renderable, Commentable 33 { 34 /** 35 * @var array<array-key, Comment> 36 */ 37 protected $aComments; 38 39 /** 40 * @var array<int, RuleSet|CSSList|Import|Charset> 41 */ 42 protected $aContents; 43 44 /** 45 * @var int 46 */ 47 protected $iLineNo; 48 49 /** 50 * @param int $iLineNo 51 */ 52 public function __construct($iLineNo = 0) 53 { 54 $this->aComments = []; 55 $this->aContents = []; 56 $this->iLineNo = $iLineNo; 57 } 58 59 /** 60 * @return void 61 * 62 * @throws UnexpectedTokenException 63 * @throws SourceException 64 */ 65 public static function parseList(ParserState $oParserState, CSSList $oList) 66 { 67 $bIsRoot = $oList instanceof Document; 68 if (is_string($oParserState)) { 69 $oParserState = new ParserState($oParserState, Settings::create()); 70 } 71 $bLenientParsing = $oParserState->getSettings()->bLenientParsing; 72 while (!$oParserState->isEnd()) { 73 $comments = $oParserState->consumeWhiteSpace(); 74 $oListItem = null; 75 if ($bLenientParsing) { 76 try { 77 $oListItem = self::parseListItem($oParserState, $oList); 78 } catch (UnexpectedTokenException $e) { 79 $oListItem = false; 80 } 81 } else { 82 $oListItem = self::parseListItem($oParserState, $oList); 83 } 84 if ($oListItem === null) { 85 // List parsing finished 86 return; 87 } 88 if ($oListItem) { 89 $oListItem->setComments($comments); 90 $oList->append($oListItem); 91 } 92 } 93 if (!$bIsRoot && !$bLenientParsing) { 94 throw new SourceException("Unexpected end of document", $oParserState->currentLine()); 95 } 96 } 97 98 /** 99 * @return AtRuleBlockList|KeyFrame|Charset|CSSNamespace|Import|AtRuleSet|DeclarationBlock|null|false 100 * 101 * @throws SourceException 102 * @throws UnexpectedEOFException 103 * @throws UnexpectedTokenException 104 */ 105 private static function parseListItem(ParserState $oParserState, CSSList $oList) 106 { 107 $bIsRoot = $oList instanceof Document; 108 if ($oParserState->comes('@')) { 109 $oAtRule = self::parseAtRule($oParserState); 110 if ($oAtRule instanceof Charset) { 111 if (!$bIsRoot) { 112 throw new UnexpectedTokenException( 113 '@charset may only occur in root document', 114 '', 115 'custom', 116 $oParserState->currentLine() 117 ); 118 } 119 if (count($oList->getContents()) > 0) { 120 throw new UnexpectedTokenException( 121 '@charset must be the first parseable token in a document', 122 '', 123 'custom', 124 $oParserState->currentLine() 125 ); 126 } 127 $oParserState->setCharset($oAtRule->getCharset()->getString()); 128 } 129 return $oAtRule; 130 } elseif ($oParserState->comes('}')) { 131 if (!$oParserState->getSettings()->bLenientParsing) { 132 throw new UnexpectedTokenException('CSS selector', '}', 'identifier', $oParserState->currentLine()); 133 } else { 134 if ($bIsRoot) { 135 if ($oParserState->getSettings()->bLenientParsing) { 136 return DeclarationBlock::parse($oParserState); 137 } else { 138 throw new SourceException("Unopened {", $oParserState->currentLine()); 139 } 140 } else { 141 return null; 142 } 143 } 144 } else { 145 return DeclarationBlock::parse($oParserState, $oList); 146 } 147 } 148 149 /** 150 * @param ParserState $oParserState 151 * 152 * @return AtRuleBlockList|KeyFrame|Charset|CSSNamespace|Import|AtRuleSet|null 153 * 154 * @throws SourceException 155 * @throws UnexpectedTokenException 156 * @throws UnexpectedEOFException 157 */ 158 private static function parseAtRule(ParserState $oParserState) 159 { 160 $oParserState->consume('@'); 161 $sIdentifier = $oParserState->parseIdentifier(); 162 $iIdentifierLineNum = $oParserState->currentLine(); 163 $oParserState->consumeWhiteSpace(); 164 if ($sIdentifier === 'import') { 165 $oLocation = URL::parse($oParserState); 166 $oParserState->consumeWhiteSpace(); 167 $sMediaQuery = null; 168 if (!$oParserState->comes(';')) { 169 $sMediaQuery = trim($oParserState->consumeUntil([';', ParserState::EOF])); 170 } 171 $oParserState->consumeUntil([';', ParserState::EOF], true, true); 172 return new Import($oLocation, $sMediaQuery ?: null, $iIdentifierLineNum); 173 } elseif ($sIdentifier === 'charset') { 174 $sCharset = CSSString::parse($oParserState); 175 $oParserState->consumeWhiteSpace(); 176 $oParserState->consumeUntil([';', ParserState::EOF], true, true); 177 return new Charset($sCharset, $iIdentifierLineNum); 178 } elseif (self::identifierIs($sIdentifier, 'keyframes')) { 179 $oResult = new KeyFrame($iIdentifierLineNum); 180 $oResult->setVendorKeyFrame($sIdentifier); 181 $oResult->setAnimationName(trim($oParserState->consumeUntil('{', false, true))); 182 CSSList::parseList($oParserState, $oResult); 183 if ($oParserState->comes('}')) { 184 $oParserState->consume('}'); 185 } 186 return $oResult; 187 } elseif ($sIdentifier === 'namespace') { 188 $sPrefix = null; 189 $mUrl = Value::parsePrimitiveValue($oParserState); 190 if (!$oParserState->comes(';')) { 191 $sPrefix = $mUrl; 192 $mUrl = Value::parsePrimitiveValue($oParserState); 193 } 194 $oParserState->consumeUntil([';', ParserState::EOF], true, true); 195 if ($sPrefix !== null && !is_string($sPrefix)) { 196 throw new UnexpectedTokenException('Wrong namespace prefix', $sPrefix, 'custom', $iIdentifierLineNum); 197 } 198 if (!($mUrl instanceof CSSString || $mUrl instanceof URL)) { 199 throw new UnexpectedTokenException( 200 'Wrong namespace url of invalid type', 201 $mUrl, 202 'custom', 203 $iIdentifierLineNum 204 ); 205 } 206 return new CSSNamespace($mUrl, $sPrefix, $iIdentifierLineNum); 207 } else { 208 // Unknown other at rule (font-face or such) 209 $sArgs = trim($oParserState->consumeUntil('{', false, true)); 210 if (substr_count($sArgs, "(") != substr_count($sArgs, ")")) { 211 if ($oParserState->getSettings()->bLenientParsing) { 212 return null; 213 } else { 214 throw new SourceException("Unmatched brace count in media query", $oParserState->currentLine()); 215 } 216 } 217 $bUseRuleSet = true; 218 foreach (explode('/', AtRule::BLOCK_RULES) as $sBlockRuleName) { 219 if (self::identifierIs($sIdentifier, $sBlockRuleName)) { 220 $bUseRuleSet = false; 221 break; 222 } 223 } 224 if ($bUseRuleSet) { 225 $oAtRule = new AtRuleSet($sIdentifier, $sArgs, $iIdentifierLineNum); 226 RuleSet::parseRuleSet($oParserState, $oAtRule); 227 } else { 228 $oAtRule = new AtRuleBlockList($sIdentifier, $sArgs, $iIdentifierLineNum); 229 CSSList::parseList($oParserState, $oAtRule); 230 if ($oParserState->comes('}')) { 231 $oParserState->consume('}'); 232 } 233 } 234 return $oAtRule; 235 } 236 } 237 238 /** 239 * Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed. 240 * We need to check for these versions too. 241 * 242 * @param string $sIdentifier 243 * @param string $sMatch 244 * 245 * @return bool 246 */ 247 private static function identifierIs($sIdentifier, $sMatch) 248 { 249 return (strcasecmp($sIdentifier, $sMatch) === 0) 250 ?: preg_match("/^(-\\w+-)?$sMatch$/i", $sIdentifier) === 1; 251 } 252 253 /** 254 * @return int 255 */ 256 public function getLineNo() 257 { 258 return $this->iLineNo; 259 } 260 261 /** 262 * Prepends an item to the list of contents. 263 * 264 * @param RuleSet|CSSList|Import|Charset $oItem 265 * 266 * @return void 267 */ 268 public function prepend($oItem) 269 { 270 array_unshift($this->aContents, $oItem); 271 } 272 273 /** 274 * Appends an item to tje list of contents. 275 * 276 * @param RuleSet|CSSList|Import|Charset $oItem 277 * 278 * @return void 279 */ 280 public function append($oItem) 281 { 282 $this->aContents[] = $oItem; 283 } 284 285 /** 286 * Insert an item before its sibling. 287 * 288 * @param mixed $oItem The item. 289 * @param mixed $oSibling The sibling. 290 */ 291 public function insert($oItem, $oSibling) { 292 $iIndex = array_search($oSibling, $this->aContents); 293 if ($iIndex === false) { 294 return $this->append($oItem); 295 } 296 array_splice($this->aContents, $iIndex, 0, array($oItem)); 297 } 298 299 /** 300 * Splices the list of contents. 301 * 302 * @param int $iOffset 303 * @param int $iLength 304 * @param array<int, RuleSet|CSSList|Import|Charset> $mReplacement 305 * 306 * @return void 307 */ 308 public function splice($iOffset, $iLength = null, $mReplacement = null) 309 { 310 array_splice($this->aContents, $iOffset, $iLength, $mReplacement); 311 } 312 313 /** 314 * Removes an item from the CSS list. 315 * 316 * @param RuleSet|Import|Charset|CSSList $oItemToRemove 317 * May be a RuleSet (most likely a DeclarationBlock), a Import, 318 * a Charset or another CSSList (most likely a MediaQuery) 319 * 320 * @return bool whether the item was removed 321 */ 322 public function remove($oItemToRemove) 323 { 324 $iKey = array_search($oItemToRemove, $this->aContents, true); 325 if ($iKey !== false) { 326 unset($this->aContents[$iKey]); 327 return true; 328 } 329 return false; 330 } 331 332 /** 333 * Replaces an item from the CSS list. 334 * 335 * @param RuleSet|Import|Charset|CSSList $oOldItem 336 * May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`, a `Charset` 337 * or another `CSSList` (most likely a `MediaQuery`) 338 * 339 * @return bool 340 */ 341 public function replace($oOldItem, $mNewItem) 342 { 343 $iKey = array_search($oOldItem, $this->aContents, true); 344 if ($iKey !== false) { 345 if (is_array($mNewItem)) { 346 array_splice($this->aContents, $iKey, 1, $mNewItem); 347 } else { 348 array_splice($this->aContents, $iKey, 1, [$mNewItem]); 349 } 350 return true; 351 } 352 return false; 353 } 354 355 /** 356 * @param array<int, RuleSet|Import|Charset|CSSList> $aContents 357 */ 358 public function setContents(array $aContents) 359 { 360 $this->aContents = []; 361 foreach ($aContents as $content) { 362 $this->append($content); 363 } 364 } 365 366 /** 367 * Removes a declaration block from the CSS list if it matches all given selectors. 368 * 369 * @param DeclarationBlock|array<array-key, Selector>|string $mSelector the selectors to match 370 * @param bool $bRemoveAll whether to stop at the first declaration block found or remove all blocks 371 * 372 * @return void 373 */ 374 public function removeDeclarationBlockBySelector($mSelector, $bRemoveAll = false) 375 { 376 if ($mSelector instanceof DeclarationBlock) { 377 $mSelector = $mSelector->getSelectors(); 378 } 379 if (!is_array($mSelector)) { 380 $mSelector = explode(',', $mSelector); 381 } 382 foreach ($mSelector as $iKey => &$mSel) { 383 if (!($mSel instanceof Selector)) { 384 if (!Selector::isValid($mSel)) { 385 throw new UnexpectedTokenException( 386 "Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.", 387 $mSel, 388 "custom" 389 ); 390 } 391 $mSel = new Selector($mSel); 392 } 393 } 394 foreach ($this->aContents as $iKey => $mItem) { 395 if (!($mItem instanceof DeclarationBlock)) { 396 continue; 397 } 398 if ($mItem->getSelectors() == $mSelector) { 399 unset($this->aContents[$iKey]); 400 if (!$bRemoveAll) { 401 return; 402 } 403 } 404 } 405 } 406 407 /** 408 * @return string 409 */ 410 public function __toString() 411 { 412 return $this->render(new OutputFormat()); 413 } 414 415 /** 416 * @return string 417 */ 418 public function render(OutputFormat $oOutputFormat) 419 { 420 $sResult = ''; 421 $bIsFirst = true; 422 $oNextLevel = $oOutputFormat; 423 if (!$this->isRootList()) { 424 $oNextLevel = $oOutputFormat->nextLevel(); 425 } 426 foreach ($this->aContents as $oContent) { 427 $sRendered = $oOutputFormat->safely(function () use ($oNextLevel, $oContent) { 428 return $oContent->render($oNextLevel); 429 }); 430 if ($sRendered === null) { 431 continue; 432 } 433 if ($bIsFirst) { 434 $bIsFirst = false; 435 $sResult .= $oNextLevel->spaceBeforeBlocks(); 436 } else { 437 $sResult .= $oNextLevel->spaceBetweenBlocks(); 438 } 439 $sResult .= $sRendered; 440 } 441 442 if (!$bIsFirst) { 443 // Had some output 444 $sResult .= $oOutputFormat->spaceAfterBlocks(); 445 } 446 447 return $sResult; 448 } 449 450 /** 451 * Return true if the list can not be further outdented. Only important when rendering. 452 * 453 * @return bool 454 */ 455 abstract public function isRootList(); 456 457 /** 458 * @return array<int, RuleSet|Import|Charset|CSSList> 459 */ 460 public function getContents() 461 { 462 return $this->aContents; 463 } 464 465 /** 466 * @param array<array-key, Comment> $aComments 467 * 468 * @return void 469 */ 470 public function addComments(array $aComments) 471 { 472 $this->aComments = array_merge($this->aComments, $aComments); 473 } 474 475 /** 476 * @return array<array-key, Comment> 477 */ 478 public function getComments() 479 { 480 return $this->aComments; 481 } 482 483 /** 484 * @param array<array-key, Comment> $aComments 485 * 486 * @return void 487 */ 488 public function setComments(array $aComments) 489 { 490 $this->aComments = $aComments; 491 } 492 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body