Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

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  }