%PDF- %PDF-
Direktori : /home/q/g/b/qgbqkvz/www/wp-content/plugins/wp-scss/scssphp/src/Serializer/ |
Current File : /home/q/g/b/qgbqkvz/www/wp-content/plugins/wp-scss/scssphp/src/Serializer/SerializeVisitor.php |
<?php /** * SCSSPHP * * @copyright 2018-2020 Anthon Pang * * @license http://opensource.org/licenses/MIT MIT * * @link http://scssphp.github.io/scssphp */ namespace ScssPhp\ScssPhp\Serializer; use ScssPhp\ScssPhp\Ast\AstNode; use ScssPhp\ScssPhp\Ast\Css\CssComment; use ScssPhp\ScssPhp\Ast\Css\CssDeclaration; use ScssPhp\ScssPhp\Ast\Css\CssMediaQuery; use ScssPhp\ScssPhp\Ast\Css\CssNode; use ScssPhp\ScssPhp\Ast\Css\CssParentNode; use ScssPhp\ScssPhp\Ast\Css\CssValue; use ScssPhp\ScssPhp\Ast\Selector\AttributeSelector; use ScssPhp\ScssPhp\Ast\Selector\ClassSelector; use ScssPhp\ScssPhp\Ast\Selector\ComplexSelector; use ScssPhp\ScssPhp\Ast\Selector\CompoundSelector; use ScssPhp\ScssPhp\Ast\Selector\IDSelector; use ScssPhp\ScssPhp\Ast\Selector\ParentSelector; use ScssPhp\ScssPhp\Ast\Selector\PlaceholderSelector; use ScssPhp\ScssPhp\Ast\Selector\PseudoSelector; use ScssPhp\ScssPhp\Ast\Selector\SelectorList; use ScssPhp\ScssPhp\Ast\Selector\TypeSelector; use ScssPhp\ScssPhp\Ast\Selector\UniversalSelector; use ScssPhp\ScssPhp\Colors; use ScssPhp\ScssPhp\Exception\SassRuntimeException; use ScssPhp\ScssPhp\Exception\SassScriptException; use ScssPhp\ScssPhp\OutputStyle; use ScssPhp\ScssPhp\Parser\LineScanner; use ScssPhp\ScssPhp\Parser\Parser; use ScssPhp\ScssPhp\Parser\StringScanner; use ScssPhp\ScssPhp\Util; use ScssPhp\ScssPhp\Util\Character; use ScssPhp\ScssPhp\Util\NumberUtil; use ScssPhp\ScssPhp\Util\SpanUtil; use ScssPhp\ScssPhp\Util\StringUtil; use ScssPhp\ScssPhp\Value\CalculationInterpolation; use ScssPhp\ScssPhp\Value\CalculationOperation; use ScssPhp\ScssPhp\Value\CalculationOperator; use ScssPhp\ScssPhp\Value\ColorFormat; use ScssPhp\ScssPhp\Value\ListSeparator; use ScssPhp\ScssPhp\Value\SassBoolean; use ScssPhp\ScssPhp\Value\SassCalculation; use ScssPhp\ScssPhp\Value\SassColor; use ScssPhp\ScssPhp\Value\SassFunction; use ScssPhp\ScssPhp\Value\SassList; use ScssPhp\ScssPhp\Value\SassMap; use ScssPhp\ScssPhp\Value\SassNumber; use ScssPhp\ScssPhp\Value\SassString; use ScssPhp\ScssPhp\Value\Value; use ScssPhp\ScssPhp\Visitor\CssVisitor; use ScssPhp\ScssPhp\Visitor\SelectorVisitor; use ScssPhp\ScssPhp\Visitor\ValueVisitor; /** * @internal * * @template-implements CssVisitor<void> * @template-implements ValueVisitor<void> * @template-implements SelectorVisitor<void> */ final class SerializeVisitor implements CssVisitor, ValueVisitor, SelectorVisitor { /** * @var StringBuffer */ private $buffer; /** * The current indentation of the CSS output. * * @var int */ private $indentation = 0; /** * Whether we're emitting an unambiguous representation of the source * structure, as opposed to valid CSS. * * @var bool */ private $inspect; /** * Whether quoted strings should be emitted with quotes. * * @var bool */ private $quote; /** * @var bool */ private $compressed; /** * @phpstan-param OutputStyle::* $style */ public function __construct(bool $inspect = false, bool $quote = true, string $style = OutputStyle::EXPANDED) { $this->buffer = new SimpleStringBuffer(); $this->inspect = $inspect; $this->quote = $quote; $this->compressed = $style === OutputStyle::COMPRESSED; } /** * @return StringBuffer */ public function getBuffer(): StringBuffer { return $this->buffer; } public function visitCssStylesheet($node): void { $previous = null; foreach ($node->getChildren() as $child) { if ($this->isInvisible($child)) { continue; } if ($previous !== null) { if ($this->requiresSemicolon($previous)) { $this->buffer->writeChar(';'); } if ($this->isTrailingComment($child, $previous)) { $this->writeOptionalSpace(); } else { $this->writeLineFeed(); if ($previous->isGroupEnd()) { $this->writeLineFeed(); } } } $previous = $child; $child->accept($this); } if ($previous !== null && $this->requiresSemicolon($previous) && !$this->compressed) { $this->buffer->writeChar(';'); } } public function visitCssComment($node): void { $this->for($node, function () use ($node) { // Preserve comments that start with `/*!`. if ($this->compressed && !$node->isPreserved()) { return; } $minimumIndentation = $this->minimumIndentation($node->getText()); assert($minimumIndentation !== -1); if ($minimumIndentation === null) { $this->writeIndentation(); $this->buffer->write($node->getText()); return; } $minimumIndentation = min($minimumIndentation, $node->getSpan()->getStart()->getColumn()); $this->writeIndentation(); $this->writeWithIndent($node->getText(), $minimumIndentation); }); } public function visitCssAtRule($node): void { $this->writeIndentation(); $this->for($node, function () use ($node) { $this->buffer->writeChar('@'); $this->write($node->getName()); $value = $node->getValue(); if ($value !== null) { $this->buffer->writeChar(' '); $this->write($value); } if (!$node->isChildless()) { $this->writeOptionalSpace(); $this->visitChildren($node); } }); } public function visitCssMediaRule($node): void { $this->writeIndentation(); $this->for($node, function () use ($node) { $this->buffer->write('@media'); $firstQuery = $node->getQueries()[0]; if (!$this->compressed || $firstQuery->getModifier() !== null || $firstQuery->getType() !== null || (\count($firstQuery->getConditions()) === 1) && StringUtil::startsWith($firstQuery->getConditions()[0], '(not ')) { $this->buffer->writeChar(' '); } $this->writeBetween($node->getQueries(), $this->getCommaSeparator(), [$this, 'visitMediaQuery']); }); $this->writeOptionalSpace(); $this->visitChildren($node); } public function visitCssImport($node): void { $this->writeIndentation(); $this->for($node, function () use ($node) { $this->buffer->write('@import'); $this->writeOptionalSpace(); $this->for($node->getUrl(), function () use ($node) { $this->writeImportUrl($node->getUrl()->getValue()); }); if ($node->getModifiers() !== null) { $this->writeOptionalSpace(); $this->write($node->getModifiers()); } }); } /** * Writes $url, which is an import's URL, to the buffer. */ private function writeImportUrl(string $url): void { if (!$this->compressed || $url[0] !== 'u') { $this->buffer->write($url); return; } // If this is url(...), remove the surrounding function. This is terser and // it allows us to remove whitespace between `@import` and the URL. $urlContents = substr($url, 4, \strlen($url) - 5); $maybeQuote = $urlContents[0]; if ($maybeQuote === "'" || $maybeQuote === '"') { $this->buffer->write($urlContents); } else { // If the URL didn't contain quotes, write them manually. $this->visitQuotedString($urlContents); } } public function visitCssKeyframeBlock($node): void { $this->writeIndentation(); $this->for($node->getSelector(), function () use ($node) { $this->writeBetween($node->getSelector()->getValue(), $this->getCommaSeparator(), [$this->buffer, 'write']); }); $this->writeOptionalSpace(); $this->visitChildren($node); } private function visitMediaQuery(CssMediaQuery $query): void { if ($query->getModifier() !== null) { $this->buffer->write($query->getModifier()); $this->buffer->writeChar(' '); } if ($query->getType() !== null) { $this->buffer->write($query->getType()); if (\count($query->getConditions())) { $this->buffer->write(' and '); } } if (\count($query->getConditions()) === 1 && StringUtil::startsWith($query->getConditions()[0], '(not ')) { $this->buffer->write('not '); $condition = $query->getConditions()[0]; $this->buffer->write(substr($condition, \strlen('(not '), \strlen($condition) - (\strlen('(not ') + 1))); } else { $operator = $query->isConjunction() ? 'and' : 'or'; $this->writeBetween($query->getConditions(), $this->compressed ? "$operator " : " $operator ", [$this->buffer, 'write']); } } public function visitCssStyleRule($node): void { $this->writeIndentation(); $this->for($node->getSelector(), function () use ($node) { $node->getSelector()->getValue()->accept($this); }); $this->writeOptionalSpace(); $this->visitChildren($node); } public function visitCssSupportsRule($node): void { $this->writeIndentation(); $this->for($node, function () use ($node) { $this->buffer->write('@supports'); if (!($this->compressed && $node->getCondition()->getValue()[0] === '(')) { $this->buffer->writeChar(' '); } $this->write($node->getCondition()); }); $this->writeOptionalSpace(); $this->visitChildren($node); } public function visitCssDeclaration($node): void { $this->writeIndentation(); $this->write($node->getName()); $this->buffer->writeChar(':'); // If `node` is a custom property that was parsed as a normal Sass-syntax // property (such as `#{--foo}: ...`), we serialize its value using the // normal Sass property logic as well. if ($node->isCustomProperty() && $node->isParsedAsCustomProperty()) { $this->for($node->getValue(), function () use ($node) { if ($this->compressed) { $this->writeFoldedValue($node); } else { $this->writeReindentedValue($node); } }); } else { $this->writeOptionalSpace(); try { // TODO implement source map tracking $node->getValue()->getValue()->accept($this); } catch (SassScriptException $error) { throw new SassRuntimeException($error->getMessage(), $node->getValue()->getSpan(), $error); } } } /** * Emits the value of $node, with all newlines followed by whitespace */ private function writeFoldedValue(CssDeclaration $node): void { $value = $node->getValue()->getValue(); assert($value instanceof SassString); $scannner = new StringScanner($value->getText()); while (!$scannner->isDone()) { $next = $scannner->readUtf8Char(); if ($next !== "\n") { $this->buffer->writeChar($next); continue; } $this->buffer->writeChar(' '); while (Character::isWhitespace($scannner->peekChar())) { $scannner->readChar(); } } } /** * Emits the value of $node, re-indented relative to the current indentation. */ private function writeReindentedValue(CssDeclaration $node): void { $nodeValue = $node->getValue()->getValue(); assert($nodeValue instanceof SassString); $value = $nodeValue->getText(); $minimumIndentation = $this->minimumIndentation($value); if ($minimumIndentation === null) { $this->buffer->write($value); return; } if ($minimumIndentation === -1) { $this->buffer->write(StringUtil::trimAsciiRight($value, true)); $this->buffer->writeChar(' '); return; } $minimumIndentation = min($minimumIndentation, $node->getName()->getSpan()->getStart()->getColumn()); $this->writeWithIndent($value, $minimumIndentation); } /** * Returns the indentation level of the least-indented non-empty line in * $text after the first. * * Returns `null` if $text contains no newlines, and -1 if it contains * newlines but no lines are indented. */ private function minimumIndentation(string $text): ?int { $scanner = new LineScanner($text); while (!$scanner->isDone() && $scanner->readChar() !== "\n") { } if ($scanner->isDone()) { return $scanner->peekChar(-1) === "\n" ? -1 : null; } $min = null; while (!$scanner->isDone()) { while (!$scanner->isDone()) { $next = $scanner->peekChar(); if ($next !== ' ' && $next !== "\t") { break; } $scanner->readChar(); } if ($scanner->isDone() || $scanner->scanChar("\n")) { continue; } $min = $min === null ? $scanner->getColumn() : min($min, $scanner->getColumn()); while (!$scanner->isDone() && $scanner->readChar() !== "\n") { } } return $min ?? -1; } /** * Writes $text to {@see buffer}, replacing $minimumIndentation with * {@see indentation} for each non-empty line after the first. * * Compresses trailing empty lines of $text into a single trailing space. */ private function writeWithIndent(string $text, int $minimumIndentation): void { $scanner = new LineScanner($text); while (!$scanner->isDone()) { $next = $scanner->readChar(); if ($next === "\n") { break; } $this->buffer->writeChar($next); } while (true) { assert(Character::isWhitespace($scanner->peekChar(-1))); // Scan forward until we hit non-whitespace or the end of [text]. $lineStart = $scanner->getPosition(); $newlines = 1; while (true) { // If we hit the end of $text, we still need to preserve the fact that // whitespace exists because it could matter for custom properties. if ($scanner->isDone()) { $this->buffer->writeChar(' '); return; } $next = $scanner->readChar(); if ($next === ' ' || $next === "\t") { continue; } if ($next !== "\n") { break; } $lineStart = $scanner->getPosition(); $newlines++; } $this->writeTimes("\n", $newlines); $this->writeIndentation(); $this->buffer->write($scanner->substring($lineStart + $minimumIndentation)); // Scan and write until we hit a newline or the end of $text. while (true) { if ($scanner->isDone()) { return; } $next = $scanner->readChar(); if ($next === "\n") { break; } $this->buffer->writeChar($next); } } } // ## Values public function visitBoolean(SassBoolean $value) { $this->buffer->write($value->getValue() ? 'true': 'false'); } public function visitCalculation(SassCalculation $value) { $this->buffer->write($value->getName()); $this->buffer->writeChar('('); $isFirst = true; foreach ($value->getArguments() as $argument) { if ($isFirst) { $isFirst = false; } else { $this->buffer->write($this->getCommaSeparator()); } $this->writeCalculationValue($argument); } $this->buffer->writeChar(')'); } private function writeCalculationValue(object $value): void { if ($value instanceof Value) { $value->accept($this); } elseif ($value instanceof CalculationInterpolation) { $this->buffer->write($value->getValue()); } elseif ($value instanceof CalculationOperation) { $left = $value->getLeft(); $parenthesizeLeft = $left instanceof CalculationInterpolation || ($left instanceof CalculationOperation && CalculationOperator::getPrecedence($left->getOperator()) < CalculationOperator::getPrecedence($value->getOperator())); if ($parenthesizeLeft) { $this->buffer->writeChar('('); } $this->writeCalculationValue($left); if ($parenthesizeLeft) { $this->buffer->writeChar(')'); } $operatorWhitespace = !$this->compressed || CalculationOperator::getPrecedence($value->getOperator()) === 1; if ($operatorWhitespace) { $this->buffer->writeChar(' '); } $this->buffer->write($value->getOperator()); if ($operatorWhitespace) { $this->buffer->writeChar(' '); } $right = $value->getRight(); $parenthesizeRight = $right instanceof CalculationInterpolation || ($right instanceof CalculationOperation && $this->parenthesizeCalculationRhs($value->getOperator(), $right->getOperator())); if ($parenthesizeRight) { $this->buffer->writeChar('('); } $this->writeCalculationValue($right); if ($parenthesizeRight) { $this->buffer->writeChar(')'); } } } /** * Returns whether the right-hand operation of a calculation should be * parenthesized. * * In `a ? (b # c)`, `outer` is `?` and `right` is `#`. * * @phpstan-param CalculationOperator::* $outer * @phpstan-param CalculationOperator::* $right */ private function parenthesizeCalculationRhs(string $outer, string $right): bool { if ($outer === CalculationOperator::DIVIDED_BY) { return true; } if ($outer === CalculationOperator::PLUS) { return false; } return $right === CalculationOperator::PLUS || $right === CalculationOperator::MINUS; } public function visitColor(SassColor $value) { $name = Colors::RGBaToColorName($value->getRed(), $value->getGreen(), $value->getBlue(), $value->getAlpha()); // In compressed mode, emit colors in the shortest representation possible. if ($this->compressed) { if (!NumberUtil::fuzzyEquals($value->getAlpha(), 1)) { $this->writeRgb($value); } else { $canUseShortHex = $this->canUseShortHex($value); $hexLength = $canUseShortHex ? 4 : 7; if ($name !== null && \strlen($name) <= $hexLength) { $this->buffer->write($name); } elseif ($canUseShortHex) { $this->buffer->writeChar('#'); $this->buffer->writeChar(dechex($value->getRed() & 0xF)); $this->buffer->writeChar(dechex($value->getGreen() & 0xF)); $this->buffer->writeChar(dechex($value->getBlue() & 0xF)); } else { $this->buffer->writeChar('#'); $this->writeHexComponent($value->getRed()); $this->writeHexComponent($value->getGreen()); $this->writeHexComponent($value->getBlue()); } } return; } $format = $value->getFormat(); if ($format !== null) { if ($format === ColorFormat::RGB_FUNCTION) { $this->writeRgb($value); } elseif ($format === ColorFormat::HSL_FUNCTION) { $this->writeHsl($value); } else { $this->buffer->write($format->getOriginal()); } } elseif ($name !== null && // Always emit generated transparent colors in rgba format. This works // around an IE bug. See https://github.com/sass/sass/issues/1782. !NumberUtil::fuzzyEquals($value->getAlpha(), 0) ) { $this->buffer->write($name); } elseif (NumberUtil::fuzzyEquals($value->getAlpha(), 1)) { $this->buffer->writeChar('#'); $this->writeHexComponent($value->getRed()); $this->writeHexComponent($value->getGreen()); $this->writeHexComponent($value->getBlue()); } else { $this->writeRgb($value); } } /** * Writes $value as an `rgb` or `rgba` function. */ private function writeRgb(SassColor $value): void { $opaque = NumberUtil::fuzzyEquals($value->getAlpha(), 1); $this->buffer->write($opaque ? 'rgb(' : 'rgba('); $this->buffer->write((string) $value->getRed()); $this->buffer->write($this->getCommaSeparator()); $this->buffer->write((string) $value->getGreen()); $this->buffer->write($this->getCommaSeparator()); $this->buffer->write((string) $value->getBlue()); if (!$opaque) { $this->buffer->write($this->getCommaSeparator()); $this->writeNumber($value->getAlpha()); } $this->buffer->writeChar(')'); } /** * Writes $value as an `hsl` or `hsla` function. */ private function writeHsl(SassColor $value): void { $opaque = NumberUtil::fuzzyEquals($value->getAlpha(), 1); $this->buffer->write($opaque ? 'hsl(' : 'hsla('); $this->writeNumber($value->getHue()); $this->buffer->write('deg'); $this->buffer->write($this->getCommaSeparator()); $this->writeNumber($value->getSaturation()); $this->buffer->writeChar('%'); $this->buffer->write($this->getCommaSeparator()); $this->writeNumber($value->getLightness()); $this->buffer->writeChar('%'); if (!$opaque) { $this->buffer->write($this->getCommaSeparator()); $this->writeNumber($value->getAlpha()); } $this->buffer->writeChar(')'); } /** * Returns whether $color's hex pair representation is symmetrical (e.g. `FF`). */ private function isSymmetricalHex(int $color): bool { return ($color & 0xF) === $color >> 4; } /** * Returns whether $color can be represented as a short hexadecimal color * (e.g. `#fff`). */ private function canUseShortHex(SassColor $color): bool { return $this->isSymmetricalHex($color->getRed()) && $this->isSymmetricalHex($color->getGreen()) && $this->isSymmetricalHex($color->getBlue()); } /** * Emits $color as a hex character pair. */ private function writeHexComponent(int $color): void { $this->buffer->write(str_pad(dechex($color), 2, '0', STR_PAD_LEFT)); } public function visitFunction(SassFunction $value) { if (!$this->inspect) { throw new SassScriptException("$value is not a valid CSS value."); } $this->buffer->write('get-function('); $this->visitQuotedString($value->getName()); $this->buffer->writeChar(')'); } public function visitList(SassList $value) { if ($value->hasBrackets()) { $this->buffer->writeChar('['); } elseif (\count($value->asList()) === 0) { if (!$this->inspect) { throw new SassScriptException("() is not a valid CSS value."); } $this->buffer->write('()'); return; } $singleton = $this->inspect && \count($value->asList()) === 1 && ($value->getSeparator() === ListSeparator::COMMA || $value->getSeparator() === ListSeparator::SLASH); if ($singleton && !$value->hasBrackets()) { $this->buffer->writeChar('('); } $separator = $this->separatorString($value->getSeparator()); $isFirst = true; foreach ($value->asList() as $element) { if (!$this->inspect && $element->isBlank()) { continue; } if ($isFirst) { $isFirst = false; } else { $this->buffer->write($separator); } $needsParens = $this->inspect && self::elementNeedsParens($value->getSeparator(), $element); if ($needsParens) { $this->buffer->writeChar('('); } $element->accept($this); if ($needsParens) { $this->buffer->writeChar(')'); } } if ($singleton) { $this->buffer->write($value->getSeparator()); if (!$value->hasBrackets()) { $this->buffer->writeChar(')'); } } if ($value->hasBrackets()) { $this->buffer->writeChar(']'); } } /** * @phpstan-param ListSeparator::* $separator */ private function separatorString(string $separator): string { switch ($separator) { case ListSeparator::COMMA: return $this->getCommaSeparator(); case ListSeparator::SLASH: return $this->compressed ? '/' : ' / '; case ListSeparator::SPACE: return ' '; default: /** * This should never be used, but it may still be returned since * {@see separatorString} is invoked eagerly by {@see writeList} even for lists * with only one element. */ return ''; } } /** * Returns whether the value needs parentheses as an element in a list with the given separator. * * @param string $separator * @param Value $value * * @return bool * * @phpstan-param ListSeparator::* $separator */ private static function elementNeedsParens(string $separator, Value $value): bool { if (!$value instanceof SassList) { return false; } if (count($value->asList()) < 2) { return false; } if ($value->hasBrackets()) { return false; } switch ($separator) { case ListSeparator::COMMA: return $value->getSeparator() === ListSeparator::COMMA; case ListSeparator::SLASH: return $value->getSeparator() === ListSeparator::COMMA || $value->getSeparator() === ListSeparator::SLASH; default: return $value->getSeparator() !== ListSeparator::UNDECIDED; } } public function visitMap(SassMap $value) { if (!$this->inspect) { throw new SassScriptException("$value is not a valid CSS value."); } $this->buffer->writeChar('('); $isFirst = true; foreach ($value->getContents() as $key => $element) { if ($isFirst) { $isFirst = false; } else { $this->buffer->write(', '); } $this->writeMapElement($key); $this->buffer->write(': '); $this->writeMapElement($element); } $this->buffer->writeChar(')'); } private function writeMapElement(Value $value): void { $needsParens = $value instanceof SassList && ListSeparator::COMMA === $value->getSeparator() && !$value->hasBrackets(); if ($needsParens) { $this->buffer->writeChar('('); } $value->accept($this); if ($needsParens) { $this->buffer->writeChar(')'); } } public function visitNull() { if ($this->inspect) { $this->buffer->write('null'); } } public function visitNumber(SassNumber $value) { $asSlash = $value->getAsSlash(); if ($asSlash !== null) { $this->visitNumber($asSlash[0]); $this->buffer->writeChar('/'); $this->visitNumber($asSlash[1]); return; } $this->writeNumber($value->getValue()); if (!$this->inspect) { if (\count($value->getNumeratorUnits()) > 1 || \count($value->getDenominatorUnits()) > 0) { throw new SassScriptException("$value is not a valid CSS value."); } if (\count($value->getNumeratorUnits()) > 0) { $this->buffer->write($value->getNumeratorUnits()[0]); } } else { $this->buffer->write($value->getUnitString()); } } /** * Writes $number without exponent notation and with at most * {@see SassNumber::PRECISION} digits after the decimal point. * * @param float $number */ private function writeNumber(float $number): void { if (is_nan($number)) { $this->buffer->write('NaN'); return; } if ($number === INF) { $this->buffer->write('Infinity'); return; } if ($number === -INF) { $this->buffer->write('-Infinity'); return; } $int = NumberUtil::fuzzyAsInt($number); if ($int !== null) { $this->buffer->write((string) $int); return; } $output = number_format($number, SassNumber::PRECISION, '.', ''); $this->buffer->write(rtrim(rtrim($output, '0'), '.')); } public function visitString(SassString $value) { if ($this->quote && $value->hasQuotes()) { $this->visitQuotedString($value->getText()); } else { $this->visitUnquotedString($value->getText()); } } private function visitQuotedString(string $string): void { $includesDoubleQuote = false !== strpos($string, '"'); $includesSingleQuote = false !== strpos($string, '\''); $forceDoubleQuotes = $includesSingleQuote && $includesDoubleQuote; $quote = $forceDoubleQuotes || !$includesDoubleQuote ? '"' : "'"; $this->buffer->writeChar($quote); $length = \strlen($string); for ($i = 0; $i < $length; $i++) { $char = $string[$i]; switch ($char) { case "'": $this->buffer->writeChar("'"); // such string is always rendered double-quoted break; case '"': if ($forceDoubleQuotes) { $this->buffer->writeChar('\\'); } $this->buffer->writeChar('"'); break; case "\0": case "\x1": case "\x2": case "\x3": case "\x4": case "\x5": case "\x6": case "\x7": case "\x8": case "\xA": case "\xB": case "\xC": case "\xD": case "\xE": case "\xF": case "\x11": case "\x12": case "\x13": case "\x14": case "\x15": case "\x16": case "\x17": case "\x18": case "\x19": case "\x1A": case "\x1B": case "\x1C": case "\x1D": case "\x1E": case "\x1F": $this->writeEscape($this->buffer, $char, $string, $i); break; case '\\': $this->buffer->writeChar('\\'); $this->buffer->writeChar('\\'); break; default: $newIndex = $this->tryPrivateUseCharacter($this->buffer, $char, $string, $i); if ($newIndex !== null) { $i = $newIndex; break; } $this->buffer->writeChar($char); break; } } $this->buffer->writeChar($quote); } private function visitUnquotedString(string $string): void { $afterNewline = false; $length = \strlen($string); for ($i = 0; $i < $length; ++$i) { $char = $string[$i]; switch ($char) { case "\n": $this->buffer->writeChar(' '); $afterNewline = true; break; case ' ': if (!$afterNewline) { $this->buffer->writeChar(' '); } break; default: $afterNewline = false; $newIndex = $this->tryPrivateUseCharacter($this->buffer, $char, $string, $i); if ($newIndex !== null) { $i = $newIndex; break; } $this->buffer->writeChar($char); break; } } } /** * If $char is the beginning of a private-use character and Sass isn't * emitting compressed CSS, writes that character as an escape to $buffer. * * The $string is the string from which $char was read, and $i is the * index it was read from. If this successfully writes the character, returns * the index of the *last* byte that was consumed for it. Otherwise, * returns `null`. * * In expanded mode, we print all characters in Private Use Areas as escape * codes since there's no useful way to render them directly. These * characters are often used for glyph fonts, where it's useful for readers * to be able to distinguish between them in the rendered stylesheet. */ private function tryPrivateUseCharacter(StringBuffer $buffer, string $char, string $string, int $i): ?int { if ($this->compressed) { return null; } $firstByteCode = \ord($char); if ($firstByteCode >= 0xF0) { $extraBytes = 3; // 4-bytes chars } elseif ($firstByteCode >= 0xE0) { $extraBytes = 2; // 3-bytes chars } elseif ($firstByteCode >= 0xC2) { $extraBytes = 1; // 2-bytes chars } elseif ($firstByteCode >= 0x80 && $firstByteCode <= 0x8F) { return null; // Continuation of a UTF-8 char started in a previous byte } else { $extraBytes = 0; } if (\strlen($string) <= $i + $extraBytes) { return null; // Invalid UTF-8 chars } if ($extraBytes) { $fullChar = substr($string, $i, $extraBytes + 1); $charCode = Util::mbOrd($fullChar); } else { $fullChar = $char; $charCode = $firstByteCode; } if ($charCode >= 0xE000 && $charCode <= 0xF8FF || // PUA of the BMP $charCode >= 0xF0000 && $charCode <= 0x10FFFF // Supplementary PUAs of the planes 15 and 16 ) { $this->writeEscape($buffer, $fullChar, $string, $i + $extraBytes); return $i + $extraBytes; } return null; } /** * Writes $character as a hexadecimal escape sequence to $buffer. * * The $string is the string from which the escape is being written, and $i * is the index of the last byte of $character in that string. These * are used to write a trailing space after the escape if necessary to * disambiguate it from the next character. */ private function writeEscape(StringBuffer $buffer, string $character, string $string, int $i): void { $buffer->writeChar('\\'); $buffer->write(dechex(Util::mbOrd($character))); if (\strlen($string) === $i + 1) { return; } $next = $string[$i + 1]; if ($next === ' ' || $next === "\t" || Character::isHex($next)) { $buffer->writeChar(' '); } } // ## Selectors public function visitAttributeSelector(AttributeSelector $attribute) { $this->buffer->writeChar('['); $this->buffer->write($attribute->getName()); $value = $attribute->getValue(); if ($value !== null) { assert($attribute->getOp() !== null); $this->buffer->write($attribute->getOp()); // Emit identifiers that start with `--` with quotes, because IE11 // doesn't consider them to be valid identifiers. if (Parser::isIdentifier($value) && 0 !== strpos($value, '--')) { $this->buffer->write($value); if ($attribute->getModifier() !== null) { $this->buffer->writeChar(' '); } } else { $this->visitQuotedString($value); if ($attribute->getModifier() !== null) { $this->writeOptionalSpace(); } } if ($attribute->getModifier() !== null) { $this->buffer->write($attribute->getModifier()); } } $this->buffer->writeChar(']'); } public function visitClassSelector(ClassSelector $klass) { $this->buffer->writeChar('.'); $this->buffer->write($klass->getName()); } public function visitComplexSelector(ComplexSelector $complex) { $this->writeCombinators($complex->getLeadingCombinators()); if (\count($complex->getLeadingCombinators()) !== 0 && \count($complex->getComponents()) !== 0) { $this->writeOptionalSpace(); } foreach ($complex->getComponents() as $i => $component) { $this->visitCompoundSelector($component->getSelector()); if (\count($component->getCombinators()) !== 0) { $this->writeOptionalSpace(); } $this->writeCombinators($component->getCombinators()); if ($i !== \count($complex->getComponents()) - 1 && (!$this->compressed || \count($component->getCombinators()) === 0)) { $this->buffer->writeChar(' '); } } } /** * Writes $combinators to {@see buffer}, with spaces in between in expanded * mode. * * @param string[] $combinators * * @return void */ private function writeCombinators(array $combinators): void { $this->writeBetween($combinators, $this->compressed ? '' : ' ', function ($text) { $this->buffer->write($text); }); } public function visitCompoundSelector(CompoundSelector $compound) { $start = $this->buffer->getLength(); foreach ($compound->getComponents() as $simple) { $simple->accept($this); } // If we emit an empty compound, it's because all of the components got // optimized out because they match all selectors, so we just emit the // universal selector. if ($this->buffer->getLength() === $start) { $this->buffer->writeChar('*'); } } public function visitIDSelector(IDSelector $id) { $this->buffer->writeChar('#'); $this->buffer->write($id->getName()); } public function visitSelectorList(SelectorList $list) { $first = true; foreach ($list->getComponents() as $complex) { if (!$this->inspect && $complex->isInvisible()) { continue; } if ($first) { $first = false; } else { $this->buffer->writeChar(','); if ($complex->getLineBreak()) { $this->writeLineFeed(); } else { $this->writeOptionalSpace(); } } $this->visitComplexSelector($complex); } } public function visitParentSelector(ParentSelector $parent) { $this->buffer->writeChar('&'); if ($parent->getSuffix() !== null) { $this->buffer->write($parent->getSuffix()); } } public function visitPlaceholderSelector(PlaceholderSelector $placeholder) { $this->buffer->writeChar('%'); $this->buffer->write($placeholder->getName()); } public function visitPseudoSelector(PseudoSelector $pseudo) { $innerSelector = $pseudo->getSelector(); // `:not(%a)` is semantically identical to `*`. if ($innerSelector !== null && $pseudo->getName() === 'not' && $innerSelector->isInvisible()) { return; } $this->buffer->writeChar(':'); if ($pseudo->isSyntacticElement()) { $this->buffer->writeChar(':'); } $this->buffer->write($pseudo->getName()); if ($pseudo->getArgument() === null && $pseudo->getSelector() === null) { return; } $this->buffer->writeChar('('); if ($pseudo->getArgument() !== null) { $this->buffer->write($pseudo->getArgument()); if ($pseudo->getSelector() !== null) { $this->buffer->writeChar(' '); } } if ($innerSelector !== null) { $this->visitSelectorList($innerSelector); } $this->buffer->writeChar(')'); } public function visitTypeSelector(TypeSelector $type) { $this->buffer->write($type->getName()); } public function visitUniversalSelector(UniversalSelector $universal) { if ($universal->getNamespace() !== null) { $this->buffer->write($universal->getNamespace()); $this->buffer->writeChar('|'); } $this->buffer->writeChar('*'); } // ## Utilities /** * Runs $callback and associates all text written within it with the span of $node * * @template T * * @param AstNode $node * @param callable(): T $callback * * @return T */ private function for(AstNode $node, callable $callback) { // TODO implement sourcemap tracking return $callback(); } /** * @param CssValue<string> $value */ private function write(CssValue $value): void { $this->for($value, function () use ($value) { $this->buffer->write($value->getValue()); }); } /** * Emits `$parent->getChildren()` in a block */ private function visitChildren(CssParentNode $parent): void { $this->buffer->writeChar('{'); $prePrevious = null; $previous = null; foreach ($parent->getChildren() as $child) { if ($this->isInvisible($child)) { continue; } if ($previous !== null && $this->requiresSemicolon($previous)) { $this->buffer->writeChar(';'); } if ($this->isTrailingComment($child, $previous ?? $parent)) { $this->writeOptionalSpace(); $this->withoutIndendation(function () use ($child) { $child->accept($this); }); } else { $this->writeLineFeed(); $this->indent(function () use ($child) { $child->accept($this); }); } $prePrevious = $previous; $previous = $child; } if ($previous !== null) { if ($this->requiresSemicolon($previous) && !$this->compressed) { $this->buffer->writeChar(';'); } if ($prePrevious !== null && $this->isTrailingComment($previous, $parent)) { $this->writeOptionalSpace(); } else { $this->writeLineFeed(); $this->writeIndentation(); } } $this->buffer->writeChar('}'); } /** * Whether $node requires a semicolon to be written after it. */ private function requiresSemicolon(CssNode $node): bool { if ($node instanceof CssParentNode) { return $node->isChildless(); } return !$node instanceof CssComment; } private function isTrailingComment(CssNode $node, CssNode $previous): bool { // Short-circuit in compressed mode to avoid expensive span shenanigans // (shespanigans?), since we're compressing all whitespace anyway. if ($this->compressed) { return false; } if (!$node instanceof CssComment) { return false; } if (!SpanUtil::contains($previous->getSpan(), $node->getSpan())) { return $node->getSpan()->getStart()->getLine() === $previous->getSpan()->getEnd()->getLine(); } // Walk back from just before the current node starts looking for the // parent's left brace (to open the child block). This is safer than a // simple forward search of the previous.span.text as that might contain // other left braces. $searchFrom = $node->getSpan()->getStart()->getOffset() - $previous->getSpan()->getStart()->getOffset() - 1; // Imports can cause a node to be "contained" by another node when they are // actually the same node twice in a row. if ($searchFrom < 0) { return false; } $endOffset = strrpos($previous->getSpan()->getText(), '{', $searchFrom); if ($endOffset === false) { $endOffset = 0; } $span = $previous->getSpan()->getFile()->span($previous->getSpan()->getStart()->getOffset(), $previous->getSpan()->getStart()->getOffset() + $endOffset); return $node->getSpan()->getStart()->getLine() === $span->getEnd()->getLine(); } /** * Writes a line feed, unless this emitting compressed CSS. */ private function writeLineFeed(): void { if (!$this->compressed) { $this->buffer->writeChar("\n"); } } private function writeOptionalSpace(): void { if (!$this->compressed) { $this->buffer->writeChar(' '); } } private function writeIndentation(): void { if (!$this->compressed) { $this->writeTimes(' ', $this->indentation * 2); } } /** * Writes $char to {@see buffer} with $times repetitions. */ private function writeTimes(string $char, int $times): void { for ($i = 0; $i < $times; $i++) { $this->buffer->writeChar($char); } } /** * Calls $callback to write each value in $iterable, and writes $text * between each one. * * @template T * * @param iterable<T> $iterable * @param string $text * @param callable(T): void $callback */ private function writeBetween(iterable $iterable, string $text, callable $callback): void { $first = true; foreach ($iterable as $value) { if ($first) { $first = false; } else { $this->buffer->write($text); } $callback($value); } } /** * Returns a comma used to separate values in lists. */ private function getCommaSeparator(): string { return $this->compressed ? ',': ', '; } /** * Runs $callback with indentation increased one level. * * @param callable(): void $callback */ private function indent(callable $callback): void { $this->indentation++; $callback(); $this->indentation--; } /** * Runs $callback without any indentation. * * @param callable(): void $callback */ private function withoutIndendation(callable $callback): void { $savedIndentation = $this->indentation; $this->indentation = 0; $callback(); $this->indentation = $savedIndentation; } /** * Returns whether $node is invisible. */ private function isInvisible(CssNode $node): bool { return !$this->inspect && ($this->compressed ? $node->isInvisibleHidingComments() : $node->isInvisible()); } }