diff --git a/.horde.yml b/.horde.yml index 4fa273e..eaaeb5a 100644 --- a/.horde.yml +++ b/.horde.yml @@ -27,7 +27,7 @@ dependencies: required: php: ^8 composer: - sabberworm/php-css-parser: ^8.9 + sabberworm/php-css-parser: ^8.9 || ^9.3 horde/exception: ^3 optional: ext: @@ -38,7 +38,10 @@ autoload: classmap: - lib/ vendor: horde -keywords: [] +keywords: + - css + - sabberworm + - facade quality: phpstan: level: 9 diff --git a/composer.json b/composer.json index 5820790..3bc735d 100644 --- a/composer.json +++ b/composer.json @@ -15,10 +15,9 @@ "repositories": [], "require": { "php": "^8", - "sabberworm/php-css-parser": "^8.9", + "sabberworm/php-css-parser": "^8.9 || ^9.0", "horde/exception": "^3 || dev-FRAMEWORK_6_0" }, - "require-dev": {}, "suggest": { "ext-mbstring": "*" }, @@ -42,5 +41,6 @@ "branch-alias": { "dev-FRAMEWORK_6_0": "2.x-dev" } - } -} \ No newline at end of file + }, + "minimum-stability": "dev" +} diff --git a/doc/SABBERWORM_COMPATIBILITY.md b/doc/SABBERWORM_COMPATIBILITY.md new file mode 100644 index 0000000..8c851bf --- /dev/null +++ b/doc/SABBERWORM_COMPATIBILITY.md @@ -0,0 +1,111 @@ +# Sabberworm Version Compatibility + +This library supports both Sabberworm 8.x and 9.x via runtime autodetection. + +## Modern API (src/Parser.php) + +**Fully compatible with both versions:** +- Sabberworm 8.9+ (PHP 5.4+) +- Sabberworm 9.0+ (PHP 7.2+) + +The modern PSR-4 facade (`Horde\Css\Parser\Parser`) automatically detects which +version is installed and adapts method calls accordingly. No code changes needed +when switching between Sabberworm versions. + +### How It Works + +The parser uses runtime detection to handle API differences: + +1. **Class detection** - Checks for `Sabberworm\CSS\Property\Declaration` (9.2+ only) +2. **Method detection** - Uses `method_exists()` to call correct methods +3. **Structural adaptation** - Handles `DeclarationBlock` changes in 9.0 + +This means your code works identically with both versions: + +```php +use Horde\Css\Parser\Parser; + +$parser = new Parser($css); +$safe = $parser->removeImports() + ->removeUrlRules() + ->removeRulesByName('cursor'); +$output = $safe->compress(); + +// Works with Sabberworm 8.9 OR 9.3 - no changes needed +``` + +## Legacy API (lib/Horde/Css/Parser.php) + +**DEPRECATED and version-locked:** +- Only supports Sabberworm 8.x +- Exposes Sabberworm objects directly via public properties +- *May break with PHP 8.5** unless Sabberworm 8.x adds PHP 8.5 support + +The legacy API (`Horde_Css_Parser`) exposes Sabberworm's internal `Document` +and `Parser` objects directly. This means: + +- It does not adapt to Sabberworm 9.x API changes +- Advanced users accessing `$parser->doc` directly will see breaking changes +- PHP 8.5 compatibility depends on Sabberworm 8.x adding PHP 8.5 support + +**Recommendation:** Migrate to modern API (`Horde\Css\Parser\Parser`). + +## PHP Version Requirements + +| Horde Css_Parser | PHP Required | Sabberworm Supported | +|------------------|--------------|----------------------| +| 2.x (modern API) | ^8.0 | 8.9+ OR 9.0+ | +| 2.x (legacy API) | ^8.0 | 8.9+ only | + +## PHP 8.5 Compatibility + +### Modern API (Recommended) + +Will work with PHP 8.5 if either: +- Sabberworm 8.x adds PHP 8.5 support, OR +- You upgrade to Sabberworm 9.x (which already supports modern PHP) + +The modern API abstracts all version differences, so upgrading Sabberworm +is transparent to your code. + +### Legacy API (Deprecated) + +Will break with PHP 8.5 unless: +- Sabberworm 8.x adds PHP 8.5 support (unlikely given 9.x exists) + +If you're using the legacy API and need PHP 8.5, you must: +1. Upgrade to Sabberworm 9.x manually, AND +2. Fix any direct `$parser->doc` usage that breaks + +Better solution: Migrate to modern API now. + +## Choosing a Sabberworm Version + +### Use Sabberworm 8.9 if: +- You need the legacy API (`Horde_Css_Parser`) +- You're on PHP 8.0-8.4 +- You want maximum stability + +### Use Sabberworm 9.3 if: +- You need PHP 8.5 support +- You want latest CSS features (`@layer`, etc.) +- You prefer stricter type safety +- You're starting new code + +### Either Works if: +- You use the modern API (`Horde\Css\Parser\Parser`) +- You're on PHP 8.0-8.4 +- You don't need cutting-edge CSS features + +## Getting Help + +- **Modern API issues:** Report to Horde Css_Parser +- **Sabberworm compatibility:** Report to Horde Css_Parser +- **Legacy API issues:** Consider migrating to modern API +- **Sabberworm bugs:** Report upstream to sabberworm/php-css-parser + +## See Also + +- [Sabberworm PHP-CSS-Parser](https://github.com/MyIntervals/PHP-CSS-Parser) +- [Sabberworm Release Notes](https://github.com/MyIntervals/PHP-CSS-Parser/releases) +- Horde Css_Parser changelog: `doc/changelog.yml` diff --git a/doc/changelog.yml b/doc/changelog.yml index 2e09d08..a336ed0 100644 --- a/doc/changelog.yml +++ b/doc/changelog.yml @@ -1,4 +1,18 @@ --- +2.0.0beta6: + api: 2.0.0beta1 + state: + release: beta + api: beta + date: 2026-04-07 + license: + identifier: LGPL-2.1-only + uri: http://www.horde.org/licenses/lgpl21 + notes: | + Add dual Sabberworm 8.x/9.x support via runtime autodetection. + Modern API (src/Parser.php) now works with both Sabberworm 8.9+ and 9.0+. + Legacy API (lib/Horde/Css/Parser.php) remains locked to Sabberworm 8.x. + See doc/SABBERWORM_COMPATIBILITY.md for migration guide. 2.0.0alpha5: api: 2.0.0alpha1 state: diff --git a/src/Parser.php b/src/Parser.php index cc4f1ad..2b3229b 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -20,6 +20,7 @@ use Horde\Exception\DetailsTrait; use Horde\Exception\HordeThrowable; use Sabberworm\CSS\CSSList\Document; +use Sabberworm\CSS\OutputFormat; use Sabberworm\CSS\Parser as SabberwormParser; use Sabberworm\CSS\Property\Import as SabberwormImport; use Sabberworm\CSS\RuleSet\DeclarationBlock; @@ -27,6 +28,7 @@ use Sabberworm\CSS\Settings; use Sabberworm\CSS\Value\RuleValueList; use Sabberworm\CSS\Value\URL as SabberwormUrl; +use Throwable; /** * Modern CSS parser wrapper. @@ -155,7 +157,7 @@ public function removeUrlRules(): self { $newDocument = $this->deepCloneDocument(); foreach ($newDocument->getContents() as $element) { - if ($element instanceof RuleSet) { + if ($this->isRuleContainer($element)) { $toRemove = []; foreach ($element->getRules() as $rule) { if ($this->valueContainsUrl($rule->getValue())) { @@ -185,10 +187,10 @@ public function removeRulesByName(string ...$names): self { $newDocument = $this->deepCloneDocument(); foreach ($newDocument->getContents() as $element) { - if ($element instanceof RuleSet) { + if ($this->isRuleContainer($element)) { $toRemove = []; foreach ($element->getRules() as $rule) { - if (in_array($rule->getRule(), $names, true)) { + if (in_array($this->getRulePropertyName($rule), $names, true)) { $toRemove[] = $rule; } } @@ -215,7 +217,7 @@ public function keepOnlyUrlRules(): self { $newDocument = $this->deepCloneDocument(); foreach ($newDocument->getContents() as $element) { - if ($element instanceof RuleSet) { + if ($this->isRuleContainer($element)) { $toRemove = []; foreach ($element->getRules() as $rule) { // Remove rules that DON'T contain URLs @@ -247,11 +249,11 @@ public function keepOnlyRulesByName(string ...$names): self { $newDocument = $this->deepCloneDocument(); foreach ($newDocument->getContents() as $element) { - if ($element instanceof RuleSet) { + if ($this->isRuleContainer($element)) { $toRemove = []; foreach ($element->getRules() as $rule) { // Remove rules that DON'T match the names - if (!in_array($rule->getRule(), $names, true)) { + if (!in_array($this->getRulePropertyName($rule), $names, true)) { $toRemove[] = $rule; } } @@ -279,10 +281,10 @@ public function keepOnlyDangerousCss(string ...$ruleNames): self { $newDocument = $this->deepCloneDocument(); - // Remove all non-import top-level elements except RuleSets + // Remove all non-import top-level elements except RuleSets/DeclarationBlocks $toRemoveTopLevel = []; foreach ($newDocument->getContents() as $element) { - if (!($element instanceof SabberwormImport) && !($element instanceof RuleSet)) { + if (!($element instanceof SabberwormImport) && !$this->isRuleContainer($element)) { $toRemoveTopLevel[] = $element; } } @@ -290,9 +292,9 @@ public function keepOnlyDangerousCss(string ...$ruleNames): self $newDocument->remove($element); } - // In RuleSets, keep only URL rules and named rules + // In RuleSets/DeclarationBlocks, keep only URL rules and named rules foreach ($newDocument->getContents() as $element) { - if ($element instanceof RuleSet) { + if ($this->isRuleContainer($element)) { $toRemove = []; foreach ($element->getRules() as $rule) { $keepRule = false; @@ -303,7 +305,7 @@ public function keepOnlyDangerousCss(string ...$ruleNames): self } // Keep if matches named rules - if (!empty($ruleNames) && in_array($rule->getRule(), $ruleNames, true)) { + if (!empty($ruleNames) && in_array($this->getRulePropertyName($rule), $ruleNames, true)) { $keepRule = true; } @@ -336,11 +338,14 @@ public function getRulesBySelectors(array $selectors): string foreach ($this->document->getContents() as $element) { if ($element instanceof DeclarationBlock) { $elementSelectors = array_map( - 'strval', + fn($sel) => $this->getSelectorString($sel), $element->getSelectors() ); if (array_intersect($selectors, $elementSelectors)) { - $rules = array_map('strval', $element->getRules()); + $rules = array_map( + fn($rule) => $this->getRuleString($rule), + $this->getDeclarationBlockRules($element) + ); $output .= implode('', $rules); } } @@ -473,4 +478,116 @@ private function deepCloneDocument(): Document $parser = new SabberwormParser($css, Settings::create()); return $parser->parse(); } + + /** + * Internal: Detect if Sabberworm 9.x is installed. + * + * Uses class existence check for reliable version detection. + * Property\Declaration only exists in Sabberworm 9.2+. + * + * @return bool True if Sabberworm 9.2+, false if 8.x + */ + private function isSabberworm9x(): bool + { + static $is9x = null; + if ($is9x === null) { + $is9x = class_exists('Sabberworm\\CSS\\Property\\Declaration'); + } + return $is9x; + } + + /** + * Internal: Get CSS property name from rule object (version-agnostic). + * + * Handles both Sabberworm 8.x (getRule) and 9.2+ (getPropertyName). + * Uses runtime method detection for maximum compatibility. + * + * @param mixed $rule Rule/Declaration object from Sabberworm + * @return string CSS property name (e.g., 'color', 'cursor') + */ + private function getRulePropertyName($rule): string + { + if (method_exists($rule, 'getPropertyName')) { + return $rule->getPropertyName(); // Sabberworm 9.2+ + } + return $rule->getRule(); // Sabberworm 8.x + } + + /** + * Internal: Get rules from DeclarationBlock (version-agnostic). + * + * Handles structural change in Sabberworm 9.0 where DeclarationBlock + * no longer extends RuleSet and contains it as a property instead. + * + * @param DeclarationBlock $block Declaration block + * @return array Array of Rule/Declaration objects + */ + private function getDeclarationBlockRules(DeclarationBlock $block): array + { + // In 9.0+, DeclarationBlock contains RuleSet as property + if (method_exists($block, 'getRuleSet')) { + return $block->getRuleSet()->getRules(); + } + // In 8.x, DeclarationBlock extends RuleSet directly + return $block->getRules(); + } + + /** + * Internal: Check if element is a RuleSet or DeclarationBlock (version-agnostic). + * + * In Sabberworm 8.x, DeclarationBlock extends RuleSet. + * In Sabberworm 9.0+, DeclarationBlock does NOT extend RuleSet. + * This method handles both cases for filtering operations. + * + * @param mixed $element Element from document + * @return bool True if element has rules that can be filtered + */ + private function isRuleContainer($element): bool + { + return ($element instanceof RuleSet) || ($element instanceof DeclarationBlock); + } + + /** + * Internal: Get selector string (version-agnostic). + * + * In Sabberworm 8.x, Selector has __toString(). + * In Sabberworm 9.x, Selector doesn't have __toString(), use getSelector(). + * + * @param mixed $selector Selector object from Sabberworm + * @return string Selector string (e.g., 'body', '.header') + */ + private function getSelectorString($selector): string + { + // In 9.x, Selector no longer has __toString() + if (method_exists($selector, 'getSelector')) { + return $selector->getSelector(); + } + // In 8.x, use __toString() + return (string) $selector; + } + + /** + * Internal: Get rule string (version-agnostic). + * + * In Sabberworm 8.x, Rule has __toString(). + * In Sabberworm 9.x, Declaration requires OutputFormat for render(). + * + * @param mixed $rule Rule/Declaration object from Sabberworm + * @return string Rule string (e.g., 'color:red;') + */ + private function getRuleString($rule): string + { + // In 9.x, render() requires OutputFormat parameter + if (method_exists($rule, 'render')) { + try { + // Try with OutputFormat (9.x) + return $rule->render(OutputFormat::createCompact()); + } catch (Throwable $e) { + // Fall back to render() without args (8.x) + return $rule->render(); + } + } + // Fallback to string cast + return (string) $rule; + } } diff --git a/test/test-dual-version.sh b/test/test-dual-version.sh new file mode 100755 index 0000000..6115c44 --- /dev/null +++ b/test/test-dual-version.sh @@ -0,0 +1,266 @@ +#!/bin/bash +set -e + +# Dual Sabberworm Version Test Script +# Tests horde/css_parser with both Sabberworm 8.9 and 9.3 +# to verify compatibility layer works correctly + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Test results tracking +SABBERWORM_8_PASSED=0 +SABBERWORM_8_FAILED=0 +SABBERWORM_9_PASSED=0 +SABBERWORM_9_FAILED=0 + +echo -e "${BLUE}============================================${NC}" +echo -e "${BLUE}Horde Css_Parser Dual Version Test Suite${NC}" +echo -e "${BLUE}============================================${NC}" +echo "" + +# Check PHP version +PHP_VERSION=$(php -r 'echo PHP_VERSION;') +echo -e "${BLUE}PHP Version:${NC} $PHP_VERSION" +echo "" + +# Function to run tests and capture results +run_tests() { + local test_suite=$1 + local test_name=$2 + + echo -e "${YELLOW}Running: $test_name${NC}" + + if [ "$test_suite" = "all" ]; then + # Run all tests (legacy + modern) + OUTPUT=$(phpunit --testdox --colors=never 2>&1) + else + # Run only specific test suite + OUTPUT=$(phpunit --testdox --colors=never --testsuite="$test_suite" 2>&1) + fi + + # Check exit status + RESULT=$? + + # Extract test counts + TOTAL_TESTS=$(echo "$OUTPUT" | grep -oP 'Tests: \K[0-9]+' | head -1 || echo "0") + ASSERTIONS=$(echo "$OUTPUT" | grep -oP 'Assertions: \K[0-9]+' | head -1 || echo "0") + ERRORS=$(echo "$OUTPUT" | grep -oP 'Errors: \K[0-9]+' | head -1 || echo "0") + FAILURES=$(echo "$OUTPUT" | grep -oP 'Failures: \K[0-9]+' | head -1 || echo "0") + + # Calculate passed tests + FAILED=$((ERRORS + FAILURES)) + PASSED=$((TOTAL_TESTS - FAILED)) + + # Print summary + if [ $RESULT -eq 0 ]; then + echo -e " ${GREEN}✓ PASS${NC} - Tests: $TOTAL_TESTS, Assertions: $ASSERTIONS" + return 0 + else + echo -e " ${RED}✗ FAIL${NC} - Tests: $TOTAL_TESTS, Passed: $PASSED, Failed: $FAILED" + if [ $ERRORS -gt 0 ]; then + echo -e " Errors: $ERRORS" + fi + if [ $FAILURES -gt 0 ]; then + echo -e " Failures: $FAILURES" + fi + + # Show failed test details + echo "$OUTPUT" | grep -A 3 "✘" | head -20 + + return 1 + fi +} + +# Function to get installed Sabberworm version +get_sabberworm_version() { + composer show sabberworm/php-css-parser 2>/dev/null | grep -oP 'versions : \* \K[^ ]+' || echo "unknown" +} + +echo -e "${BLUE}============================================${NC}" +echo -e "${BLUE}Phase 1: Testing with Sabberworm 8.9${NC}" +echo -e "${BLUE}============================================${NC}" +echo "" + +# Force install Sabberworm 8.9 +echo -e "${YELLOW}Installing Sabberworm 8.9...${NC}" +composer require sabberworm/php-css-parser:^8.9 --quiet --no-interaction 2>&1 | tail -3 +INSTALLED_VERSION=$(get_sabberworm_version) +echo -e "${GREEN}Installed: $INSTALLED_VERSION${NC}" +echo "" + +# Run all tests with Sabberworm 8.9 (including legacy API) +echo -e "${YELLOW}Running ALL tests (legacy lib/ + modern src/)...${NC}" +echo "" + +if run_tests "all" "All Tests (8.9)"; then + SABBERWORM_8_STATUS="PASS" + SABBERWORM_8_STYLE="${GREEN}" +else + SABBERWORM_8_STATUS="FAIL" + SABBERWORM_8_STYLE="${RED}" +fi + +echo "" + +# Get detailed test results for 8.9 +echo -e "${YELLOW}Test breakdown:${NC}" +phpunit --testdox --colors=never 2>&1 | grep -E "^(Import|Parser|Security|Selector|Url|Value)" | while read line; do + echo " $line" +done +echo "" + +echo -e "${BLUE}============================================${NC}" +echo -e "${BLUE}Phase 2: Testing with Sabberworm 9.3${NC}" +echo -e "${BLUE}============================================${NC}" +echo "" + +# Force install Sabberworm 9.3 +echo -e "${YELLOW}Installing Sabberworm 9.3...${NC}" +composer require sabberworm/php-css-parser:^9.3 --quiet --no-interaction 2>&1 | tail -3 +INSTALLED_VERSION=$(get_sabberworm_version) +echo -e "${GREEN}Installed: $INSTALLED_VERSION${NC}" +echo "" + +# Run only modern tests with Sabberworm 9.x (exclude legacy lib/) +echo -e "${YELLOW}Running MODERN tests only (src/ - excluding legacy lib/)...${NC}" +echo "" + +# Create temporary phpunit config that excludes legacy tests +cat > phpunit-modern-only.xml << 'EOF' + + + + + test/unit + + + + + src + + + +EOF + +# Run modern tests only +OUTPUT_9=$(phpunit --testdox --colors=never -c phpunit-modern-only.xml 2>&1) +RESULT_9=$? + +echo "$OUTPUT_9" | grep -E "(Tests:|Assertions:|OK|ERRORS|FAILURES)" || true +echo "" + +if [ $RESULT_9 -eq 0 ]; then + SABBERWORM_9_STATUS="PASS" + SABBERWORM_9_STYLE="${GREEN}" +else + SABBERWORM_9_STATUS="FAIL" + SABBERWORM_9_STYLE="${RED}" + + # Show failures + echo -e "${RED}Failed tests:${NC}" + echo "$OUTPUT_9" | grep -A 3 "✘" | head -30 + echo "" +fi + +# Get detailed test results for 9.x +echo -e "${YELLOW}Test breakdown:${NC}" +echo "$OUTPUT_9" | grep -E "^(Import|Parser|Security|Selector|Url|Value)" | while read line; do + echo " $line" +done +echo "" + +# Check legacy tests fail as expected +echo -e "${YELLOW}Verifying legacy tests fail (as expected)...${NC}" +LEGACY_OUTPUT=$(phpunit --testdox --colors=never test/Horde/Css/Parser/ParserTest.php 2>&1 || true) +LEGACY_ERRORS=$(echo "$LEGACY_OUTPUT" | grep -oP 'Errors: \K[0-9]+' | head -1 || echo "0") + +if [ "$LEGACY_ERRORS" -gt 0 ]; then + echo -e " ${GREEN}✓ EXPECTED${NC} - Legacy lib/ tests fail with Sabberworm 9.x (Errors: $LEGACY_ERRORS)" +else + echo -e " ${YELLOW}⚠ WARNING${NC} - Legacy tests didn't fail as expected" +fi +echo "" + +# Cleanup temporary config +rm -f phpunit-modern-only.xml + +echo -e "${BLUE}============================================${NC}" +echo -e "${BLUE}Final Report${NC}" +echo -e "${BLUE}============================================${NC}" +echo "" + +echo -e "${BLUE}Sabberworm 8.9 (All Tests):${NC}" +echo -e " Status: ${SABBERWORM_8_STYLE}${SABBERWORM_8_STATUS}${NC}" +echo "" + +echo -e "${BLUE}Sabberworm 9.3 (Modern src/ Only):${NC}" +echo -e " Status: ${SABBERWORM_9_STYLE}${SABBERWORM_9_STATUS}${NC}" +echo "" + +# Overall assessment +echo -e "${BLUE}Assessment:${NC}" + +if [ "$SABBERWORM_8_STATUS" = "PASS" ] && [ "$SABBERWORM_9_STATUS" = "PASS" ]; then + echo -e " ${GREEN}✓ SUCCESS${NC} - Dual version support working correctly!" + echo -e " ${GREEN}✓${NC} Modern API works with both Sabberworm 8.9 and 9.3" + echo -e " ${GREEN}✓${NC} Legacy API works with Sabberworm 8.9" + echo -e " ${GREEN}✓${NC} Legacy API correctly fails with Sabberworm 9.3 (as documented)" + EXIT_CODE=0 +elif [ "$SABBERWORM_8_STATUS" = "PASS" ] && [ "$SABBERWORM_9_STATUS" = "FAIL" ]; then + echo -e " ${RED}✗ FAILURE${NC} - Modern API broken with Sabberworm 9.3!" + echo -e " ${GREEN}✓${NC} Sabberworm 8.9 works" + echo -e " ${RED}✗${NC} Sabberworm 9.3 has failures in modern API" + echo "" + echo -e " ${RED}Action Required:${NC} Fix compatibility issues with Sabberworm 9.3" + EXIT_CODE=1 +elif [ "$SABBERWORM_8_STATUS" = "FAIL" ] && [ "$SABBERWORM_9_STATUS" = "PASS" ]; then + echo -e " ${RED}✗ FAILURE${NC} - Sabberworm 8.9 support broken!" + echo -e " ${RED}✗${NC} Sabberworm 8.9 has failures" + echo -e " ${GREEN}✓${NC} Sabberworm 9.3 works" + echo "" + echo -e " ${RED}Action Required:${NC} Fix compatibility issues with Sabberworm 8.9" + EXIT_CODE=1 +else + echo -e " ${RED}✗ FAILURE${NC} - Both versions broken!" + echo -e " ${RED}✗${NC} Sabberworm 8.9 has failures" + echo -e " ${RED}✗${NC} Sabberworm 9.3 has failures" + echo "" + echo -e " ${RED}Action Required:${NC} Fix compatibility layer implementation" + EXIT_CODE=1 +fi + +echo "" +echo -e "${BLUE}============================================${NC}" +echo -e "${BLUE}Documentation${NC}" +echo -e "${BLUE}============================================${NC}" +echo "" +echo -e "Migration Guide: ${GREEN}doc/SABBERWORM_COMPATIBILITY.md${NC}" +echo -e "Changelog: ${GREEN}doc/changelog.yml${NC}" +echo "" +echo -e "${BLUE}Composer Constraint:${NC}" +composer show sabberworm/php-css-parser 2>/dev/null | grep "versions" || true +echo "" + +exit $EXIT_CODE