PluginProbe
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress / 11.0.4
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress v11.0.4
11.1.1 11.1.0 11.0.5 11.0.4 11.0.3 11.0.2 11.0.1 11.0.0 10.11.1 10.11.0 10.10.2 10.10.1 10.10.0 10.9.1 trunk 10.0.0 10.0.1 10.1.0 10.1.1 10.1.2 10.1.3 10.1.4 10.2.0 10.2.1 10.2.2 All 62 releases
acymailing / back / Libraries / Sabberworm / CSS / src / CSSList / CSSList.php

CSSList.php in AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress 11.0.4, at back/Libraries/Sabberworm/CSS/src/CSSList/CSSList.php

467 lines 16.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types = 1);
4
5 namespace AcyMailing\Libraries\Sabberworm\CSS\CSSList;
6
7 use AcyMailing\Libraries\Sabberworm\CSS\Comment\CommentContainer;
8 use AcyMailing\Libraries\Sabberworm\CSS\CSSElement;
9 use AcyMailing\Libraries\Sabberworm\CSS\OutputFormat;
10 use AcyMailing\Libraries\Sabberworm\CSS\Parsing\ParserState;
11 use AcyMailing\Libraries\Sabberworm\CSS\Parsing\SourceException;
12 use AcyMailing\Libraries\Sabberworm\CSS\Parsing\UnexpectedEOFException;
13 use AcyMailing\Libraries\Sabberworm\CSS\Parsing\UnexpectedTokenException;
14 use AcyMailing\Libraries\Sabberworm\CSS\Position\Position;
15 use AcyMailing\Libraries\Sabberworm\CSS\Position\Positionable;
16 use AcyMailing\Libraries\Sabberworm\CSS\Property\AtRule;
17 use AcyMailing\Libraries\Sabberworm\CSS\Property\Charset;
18 use AcyMailing\Libraries\Sabberworm\CSS\Property\CSSNamespace;
19 use AcyMailing\Libraries\Sabberworm\CSS\Property\Import;
20 use AcyMailing\Libraries\Sabberworm\CSS\Property\Selector;
21 use AcyMailing\Libraries\Sabberworm\CSS\RuleSet\AtRuleSet;
22 use AcyMailing\Libraries\Sabberworm\CSS\RuleSet\DeclarationBlock;
23 use AcyMailing\Libraries\Sabberworm\CSS\RuleSet\RuleSet;
24 use AcyMailing\Libraries\Sabberworm\CSS\Value\CSSString;
25 use AcyMailing\Libraries\Sabberworm\CSS\Value\URL;
26 use AcyMailing\Libraries\Sabberworm\CSS\Value\Value;
27
28 /**
29 * This is the most generic container available. It can contain `DeclarationBlock`s (rule sets with a selector),
30 * `RuleSet`s as well as other `CSSList` objects.
31 *
32 * It can also contain `Import` and `Charset` objects stemming from at-rules.
33 *
34 * Note that `CSSListItem` extends both `Commentable` and `Renderable`,
35 * so those interfaces must also be implemented by concrete subclasses.
36 */
37 abstract class CSSList implements CSSElement, CSSListItem, Positionable
38 {
39 use CommentContainer;
40 use Position;
41
42 /**
43 * @var array<int<0, max>, CSSListItem>
44 *
45 * @internal since 8.8.0
46 */
47 protected $contents = [];
48
49 /**
50 * @param int<1, max>|null $lineNumber
51 */
52 public function __construct(?int $lineNumber = null)
53 {
54 $this->setPosition($lineNumber);
55 }
56
57 /**
58 * @throws UnexpectedTokenException
59 * @throws SourceException
60 *
61 * @internal since V8.8.0
62 */
63 public static function parseList(ParserState $parserState, CSSList $list): void
64 {
65 $isRoot = $list instanceof Document;
66 $usesLenientParsing = $parserState->getSettings()->usesLenientParsing();
67 $comments = [];
68 while (!$parserState->isEnd()) {
69 $comments = \array_merge($comments, $parserState->consumeWhiteSpace());
70 $listItem = null;
71 if ($usesLenientParsing) {
72 try {
73 $listItem = self::parseListItem($parserState, $list);
74 } catch (UnexpectedTokenException $e) {
75 $listItem = false;
76 }
77 } else {
78 $listItem = self::parseListItem($parserState, $list);
79 }
80 if ($listItem === null) {
81 // List parsing finished
82 return;
83 }
84 if ($listItem) {
85 $listItem->addComments($comments);
86 $list->append($listItem);
87 }
88 $comments = $parserState->consumeWhiteSpace();
89 }
90 $list->addComments($comments);
91 if (!$isRoot && !$usesLenientParsing) {
92 throw new SourceException('Unexpected end of document', $parserState->currentLine());
93 }
94 }
95
96 /**
97 * @return CSSListItem|false|null
98 * If `null` is returned, it means the end of the list has been reached.
99 * If `false` is returned, it means an invalid item has been encountered,
100 * but parsing of the next item should proceed.
101 *
102 * @throws SourceException
103 * @throws UnexpectedEOFException
104 * @throws UnexpectedTokenException
105 */
106 private static function parseListItem(ParserState $parserState, CSSList $list)
107 {
108 $isRoot = $list instanceof Document;
109 if ($parserState->comes('@')) {
110 $atRule = self::parseAtRule($parserState);
111 if ($atRule instanceof Charset) {
112 if (!$isRoot) {
113 throw new UnexpectedTokenException(
114 '@charset may only occur in root document',
115 '',
116 'custom',
117 $parserState->currentLine()
118 );
119 }
120 if (\count($list->getContents()) > 0) {
121 throw new UnexpectedTokenException(
122 '@charset must be the first parseable token in a document',
123 '',
124 'custom',
125 $parserState->currentLine()
126 );
127 }
128 $parserState->setCharset($atRule->getCharset());
129 }
130
131 return $atRule;
132 } elseif ($parserState->comes('}')) {
133 if ($isRoot) {
134 if ($parserState->getSettings()->usesLenientParsing()) {
135 return DeclarationBlock::parse($parserState) ?? false;
136 } else {
137 throw new SourceException('Unopened {', $parserState->currentLine());
138 }
139 } else {
140 // End of list
141 return null;
142 }
143 } else {
144 return DeclarationBlock::parse($parserState, $list) ?? false;
145 }
146 }
147
148 /**
149 * @throws SourceException
150 * @throws UnexpectedTokenException
151 * @throws UnexpectedEOFException
152 */
153 private static function parseAtRule(ParserState $parserState): ?CSSListItem
154 {
155 $parserState->consume('@');
156 $identifier = $parserState->parseIdentifier();
157 $identifierLineNumber = $parserState->currentLine();
158 $parserState->consumeWhiteSpace();
159 if ($identifier === 'import') {
160 $location = URL::parse($parserState);
161 $parserState->consumeWhiteSpace();
162 $mediaQuery = null;
163 if (!$parserState->comes(';')) {
164 $mediaQuery = \trim($parserState->consumeUntil([';', ParserState::EOF]));
165 if ($mediaQuery === '') {
166 $mediaQuery = null;
167 }
168 }
169 $parserState->consumeUntil([';', ParserState::EOF], true, true);
170
171 return new Import($location, $mediaQuery, $identifierLineNumber);
172 } elseif ($identifier === 'charset') {
173 $charsetString = CSSString::parse($parserState);
174 $parserState->consumeWhiteSpace();
175 $parserState->consumeUntil([';', ParserState::EOF], true, true);
176
177 return new Charset($charsetString, $identifierLineNumber);
178 } elseif (self::identifierIs($identifier, 'keyframes')) {
179 $result = new KeyFrame($identifierLineNumber);
180 $result->setVendorKeyFrame($identifier);
181 $result->setAnimationName(\trim($parserState->consumeUntil('{', false, true)));
182 CSSList::parseList($parserState, $result);
183 if ($parserState->comes('}')) {
184 $parserState->consume('}');
185 }
186
187 return $result;
188 } elseif ($identifier === 'namespace') {
189 $prefix = null;
190 $url = Value::parsePrimitiveValue($parserState);
191 if (!$parserState->comes(';')) {
192 $prefix = $url;
193 $url = Value::parsePrimitiveValue($parserState);
194 }
195 $parserState->consumeUntil([';', ParserState::EOF], true, true);
196 if ($prefix !== null && !\is_string($prefix)) {
197 throw new UnexpectedTokenException('Wrong namespace prefix', $prefix, 'custom', $identifierLineNumber);
198 }
199 if (!($url instanceof CSSString || $url instanceof URL)) {
200 throw new UnexpectedTokenException(
201 'Wrong namespace url of invalid type',
202 $url,
203 'custom',
204 $identifierLineNumber
205 );
206 }
207
208 return new CSSNamespace($url, $prefix, $identifierLineNumber);
209 } else {
210 // Unknown other at rule (font-face or such)
211 $arguments = \trim($parserState->consumeUntil('{', false, true));
212 if (\substr_count($arguments, '(') !== \substr_count($arguments, ')')) {
213 if ($parserState->getSettings()->usesLenientParsing()) {
214 return null;
215 } else {
216 throw new SourceException('Unmatched brace count in media query', $parserState->currentLine());
217 }
218 }
219 $useRuleSet = true;
220 foreach (\explode('/', AtRule::BLOCK_RULES) as $blockRuleName) {
221 if (self::identifierIs($identifier, $blockRuleName)) {
222 $useRuleSet = false;
223 break;
224 }
225 }
226 if ($useRuleSet) {
227 $atRule = new AtRuleSet($identifier, $arguments, $identifierLineNumber);
228 RuleSet::parseRuleSet($parserState, $atRule);
229 } else {
230 $atRule = new AtRuleBlockList($identifier, $arguments, $identifierLineNumber);
231 CSSList::parseList($parserState, $atRule);
232 if ($parserState->comes('}')) {
233 $parserState->consume('}');
234 }
235 }
236
237 return $atRule;
238 }
239 }
240
241 /**
242 * Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed.
243 * We need to check for these versions too.
244 */
245 private static function identifierIs(string $identifier, string $match): bool
246 {
247 if (\strcasecmp($identifier, $match) === 0) {
248 return true;
249 }
250
251 return self::preg_match("/^(-\\w+-)?$match$/i", $identifier) === 1;
252 }
253
254 public static function preg_match(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int
255 {
256 $safeResult = \preg_match($pattern, $subject, $matches, $flags, $offset);
257 if ($safeResult === false) {
258 throw new \Exception('Error during regex match');
259 }
260
261 return $safeResult;
262 }
263
264 public static function preg_split(string $pattern, string $subject, ?int $limit = -1, int $flags = 0): array
265 {
266 $safeResult = \preg_split($pattern, $subject, $limit, $flags);
267 if ($safeResult === false) {
268 throw new \Exception('Error during regex split');
269 }
270
271 return $safeResult;
272 }
273
274 public static function iconv(string $from_encoding, string $to_encoding, string $string): string
275 {
276 $safeResult = \iconv($from_encoding, $to_encoding, $string);
277 if ($safeResult === false) {
278 throw new \Exception('Error during iconv');
279 }
280
281 return $safeResult;
282 }
283
284 /**
285 * Prepends an item to the list of contents.
286 */
287 public function prepend(CSSListItem $item): void
288 {
289 \array_unshift($this->contents, $item);
290 }
291
292 /**
293 * Appends an item to the list of contents.
294 */
295 public function append(CSSListItem $item): void
296 {
297 $this->contents[] = $item;
298 }
299
300 /**
301 * Splices the list of contents.
302 *
303 * @param array<int, CSSListItem> $replacement
304 */
305 public function splice(int $offset, ?int $length = null, ?array $replacement = null): void
306 {
307 \array_splice($this->contents, $offset, $length, $replacement);
308 }
309
310 /**
311 * Inserts an item in the CSS list before its sibling. If the desired sibling cannot be found,
312 * the item is appended at the end.
313 */
314 public function insertBefore(CSSListItem $item, CSSListItem $sibling): void
315 {
316 if (\in_array($sibling, $this->contents, true)) {
317 $this->replace($sibling, [$item, $sibling]);
318 } else {
319 $this->append($item);
320 }
321 }
322
323 /**
324 * Removes an item from the CSS list.
325 *
326 * @param CSSListItem $itemToRemove
327 * May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`,
328 * a `Charset` or another `CSSList` (most likely a `MediaQuery`)
329 *
330 * @return bool whether the item was removed
331 */
332 public function remove(CSSListItem $itemToRemove): bool
333 {
334 $key = \array_search($itemToRemove, $this->contents, true);
335 if ($key !== false) {
336 unset($this->contents[$key]);
337
338 return true;
339 }
340
341 return false;
342 }
343
344 /**
345 * Replaces an item from the CSS list.
346 *
347 * @param CSSListItem $oldItem
348 * May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`, a `Charset`
349 * or another `CSSList` (most likely a `MediaQuery`)
350 * @param CSSListItem|array<CSSListItem> $newItem
351 */
352 public function replace(CSSListItem $oldItem, $newItem): bool
353 {
354 $key = \array_search($oldItem, $this->contents, true);
355 if ($key !== false) {
356 if (\is_array($newItem)) {
357 \array_splice($this->contents, $key, 1, $newItem);
358 } else {
359 \array_splice($this->contents, $key, 1, [$newItem]);
360 }
361
362 return true;
363 }
364
365 return false;
366 }
367
368 /**
369 * @param array<int, CSSListItem> $contents
370 */
371 public function setContents(array $contents): void
372 {
373 $this->contents = [];
374 foreach ($contents as $content) {
375 $this->append($content);
376 }
377 }
378
379 /**
380 * Removes a declaration block from the CSS list if it matches all given selectors.
381 *
382 * @param DeclarationBlock|array<Selector>|string $selectors the selectors to match
383 * @param bool $removeAll whether to stop at the first declaration block found or remove all blocks
384 */
385 public function removeDeclarationBlockBySelector($selectors, bool $removeAll = false): void
386 {
387 if ($selectors instanceof DeclarationBlock) {
388 $selectors = $selectors->getSelectors();
389 }
390 if (!\is_array($selectors)) {
391 $selectors = \explode(',', $selectors);
392 }
393 foreach ($selectors as $key => &$selector) {
394 if (!($selector instanceof Selector)) {
395 if (!Selector::isValid($selector)) {
396 throw new UnexpectedTokenException(
397 "Selector did not match '".Selector::SELECTOR_VALIDATION_RX."'.",
398 $selector,
399 'custom'
400 );
401 }
402 $selector = new Selector($selector);
403 }
404 }
405 foreach ($this->contents as $key => $item) {
406 if (!($item instanceof DeclarationBlock)) {
407 continue;
408 }
409 if ($item->getSelectors() == $selectors) {
410 unset($this->contents[$key]);
411 if (!$removeAll) {
412 return;
413 }
414 }
415 }
416 }
417
418 protected function renderListContents(OutputFormat $outputFormat): string
419 {
420 $result = '';
421 $isFirst = true;
422 $nextLevelFormat = $outputFormat;
423 if (!$this->isRootList()) {
424 $nextLevelFormat = $outputFormat->nextLevel();
425 }
426 $nextLevelFormatter = $nextLevelFormat->getFormatter();
427 $formatter = $outputFormat->getFormatter();
428 foreach ($this->contents as $listItem) {
429 $renderedCss = $formatter->safely(static function () use ($nextLevelFormat, $listItem): string {
430 return $listItem->render($nextLevelFormat);
431 });
432 if ($renderedCss === null) {
433 continue;
434 }
435 if ($isFirst) {
436 $isFirst = false;
437 $result .= $nextLevelFormatter->spaceBeforeBlocks();
438 } else {
439 $result .= $nextLevelFormatter->spaceBetweenBlocks();
440 }
441 $result .= $renderedCss;
442 }
443
444 if (!$isFirst) {
445 // Had some output
446 $result .= $formatter->spaceAfterBlocks();
447 }
448
449 return $result;
450 }
451
452 /**
453 * Return true if the list can not be further outdented. Only important when rendering.
454 */
455 abstract public function isRootList(): bool;
456
457 /**
458 * Returns the stored items.
459 *
460 * @return array<int<0, max>, CSSListItem>
461 */
462 public function getContents(): array
463 {
464 return $this->contents;
465 }
466 }
467