diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..52990bb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +/.editorconfig export-ignore +/.git* export-ignore +/.php-cs-fixer.dist.php export-ignore +/doc/ export-ignore +/tests/ export-ignore +/phpunit.dist.xml export-ignore +/phpstan.dist.neon export-ignore diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml new file mode 100644 index 0000000..77bb36e --- /dev/null +++ b/.github/workflows/CI.yaml @@ -0,0 +1,131 @@ +name: "CI" + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + + dep: + name: "Dependencies" + runs-on: ubuntu-latest + steps: + - name: "Git: checkout" + uses: actions/checkout@v4 + - name: "PHP: setup 8.3 " + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: composer + - name: "Composer: cache config" + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: "Composer: cache restore" + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - name: "Composer: validate" + run: composer validate --strict + - name: "Composer: install" + run: composer install --prefer-dist --no-progress --no-suggest + - name: "Composer: audit" + run: composer audit + + cs: + name: "Code style" + runs-on: ubuntu-latest + steps: + - name: "Git: checkout" + uses: actions/checkout@v4 + - name: "PHP: setup 8.3" + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: php-cs-fixer + - name: "Php-CS-Fixer: version" + run: php-cs-fixer -V + - name: "Php-CS-Fixer: check" + run: php-cs-fixer check --diff + + sa: + name: "Static Analysis" + runs-on: ubuntu-latest + steps: + - name: "Git: checkout" + uses: actions/checkout@v4 + - name: "PHP: setup ${{ matrix.php-version }}" + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: phpstan + - name: "Composer: cache config" + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: "Composer: cache restore" + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - name: "Composer: validate" + run: composer validate --strict + - name: "Composer: install" + run: composer install --prefer-dist --no-progress --no-suggest + - name: "PHPStan: version" + run: phpstan --version + - name: "PHPStan: analyse" + run: phpstan analyse src/ + + tests: + name: "Tests (PHP ${{ matrix.php-version }})" + runs-on: ubuntu-latest + strategy: + matrix: + php-version: + - '8.2' + - '8.3' + fail-fast: false + steps: + - name: "Git: Checkout" + uses: actions/checkout@v4 + - name: "PHP: setup ${{ matrix.php-version }}" + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: xdebug + ini-values: xdebug.mode=coverage + - name: "PHP: php matcher" + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + - name: "Composer: cache config" + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: "Composer: cache restore" + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - name: "Composer: validate" + run: composer validate --strict + - name: "Composer: install" + run: composer install --prefer-dist --no-progress --no-suggest + - name: "PHPUnit: version" + run: php vendor/bin/phpunit --version + - name: "PHPUnit: tests" + run: php vendor/bin/phpunit +# - name: "Codecov: upload" +# uses: codecov/codecov-action@v4.0.1 +# with: +# token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f264f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.phpunit.cache +/.php-cs-fixer.cache +/composer.lock +/phpunit.xml +/tests/Fixtures/var/ +/vendor/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..57344cb --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,25 @@ +in(__DIR__) + ->exclude('tests/Fixtures/var') +; + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + 'declare_strict_types' => true, + 'header_comment' => ['header' => $licence], + ]) + ->setFinder($finder) +; diff --git a/.symfony.bundle.yaml b/.symfony.bundle.yaml new file mode 100644 index 0000000..b84c48b --- /dev/null +++ b/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["main"] +maintained_branches: ["main"] +doc_dir: "doc" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..25498b9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 0.9.0 + +- First version of the bundle diff --git a/LICENSE b/LICENSE index 1788e19..00fc58e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 SensioLabs +Copyright (c) 2024-present Simon André & SensioLabs Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f4d6ff --- /dev/null +++ b/README.md @@ -0,0 +1,204 @@ + + +SensioLabs MinifyBundle for Symfony + + +
+ +[![PHP Version](https://img.shields.io/badge/%C2%A0php-%3E%3D%208.3-777BB4.svg?logo=php&logoColor=white)](https://github.com/sensiolabs/minify-bundle/blob/main/composer.json) +[![CI](https://github.com/sensiolabs/minify-bundle/actions/workflows/CI.yaml/badge.svg)](https://github.com/sensiolabs/minify-bundle/actions) +[![Release](https://img.shields.io/github/v/release/sensiolabs/minify-bundle)](https://github.com/sensiolabs/minify-bundle/releases) +[![License](https://img.shields.io/github/license/sensiolabs/minify-bundle?color=82E83F)](https://github.com/sensiolabs/minify-bundle/blob/main/LICENSE) + +
+ +

SensioLabs Minify Bundle

+ +## Minify integration + +SensioLabs Minify Bundle integrates [Minify](https://github.com/tdewolff/minify) into Symfony Asset Mapper. + +### Asset Minifier + + ✅ Minify `CSS` and `JS` files, remove whitespace, comments, and more.. + + 🌍🌍 Reduces the size of your assets by up to `70%` (see metrics below). + +🚀🚀🚀 Improves the loading time of your website, and the `user experience`. + +### Asset Mapper + +🎯 Automatically `minify` assets during the build process. + +📦📦 Compress and store minified assets in the `cache` directory. + +🌿🌿🌿 Download the Minify binary `automatically` from the repository. + +## Minification + +### JavaScript + +| Asset | Before | After | Diff | Compression | Time | +|------------------------|-------:|-------:|-----:|------------------------------------------|------:| +| [Autocomplete.js][1] | 20 kB | 9.2 kB | -54% | ⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩 | 8 ms | +| [Bootstrap.js][3] | 145 kB | 62 kB | -57% | ⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩 | 10 ms | +| [Video.js][5] | 2.3 MB | 0.7 MB | -71% | ⬜️⬜️⬜️⬜️⬜️⬜️🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩 | 42 ms | +| [w3c.org js][7] | 44 kB | 19 kB | -57% | ⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩 | 6 ms | + + +### CSS + +| Asset | Before | After | Diff | Compression | Time | +|-----------------------|-------:|-------:|-----:|-------------------------------------------|-----:| +| [Autocomplete.css][2] | 3.1 kB | 2.5 kB | -19% | ⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️🟩🟩🟩🟩🟩 | 2 ms | +| [Bootstrap.css][4] | 281 kB | 232 kB | -18% | ⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️🟩🟩🟩🟩 | 9 ms | +| [Video-js.css][6] | 53 kB | 47 kB | -12% | ⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜⬜️⬜️⬜️⬜️⬜️⬜️⬜️️🟩🟩 | 4 ms | +| [w3c.org css][8] | 111 kB | 70 kB | -37% | ⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️⬜️🟩🟩🟩🟩🟩🟩🟩🟩 | 5 ms | + + +## Installation + +Make sure Composer is installed globally, as explained in the +[installation chapter](https://getcomposer.org/doc/00-intro.md) +of the Composer documentation. + +### With Symfony Flex + +Open a command console, enter your project directory and execute: + +```shell +composer require sensiolabs/minify-bundle +``` + +### Without Symfony Flex + +#### Step 1: Download the Bundle + +Open a command console, enter your project directory and execute the +following command to download the latest stable version of this bundle: + +```shell +composer require sensiolabs/minify-bundle +``` + +#### Step 2: Enable the Bundle + +Then, enable the bundle by adding it to the list of registered bundles +in the `config/bundles.php` file of your project: + +```php +// config/bundles.php + +return [ + // ... + Sensiolabs\MinifyBundle\SensiolabsMinifyBundle::class => ['all' => true], +]; +``` + +Depending on your deployment process, you might want to enable the +bundle only in the desired environment(s). + + +## Configuration + +### AssetMapper Settings + +#### Asset types + +```yaml +# config/packages/sensiolabs_minify.yaml +sensiolabs_minify: + asset_mapper: + + # Minify CSS and JS files + types: + css: true + js: true +``` + +#### Exclude files + +```yaml +# config/packages/sensiolabs_minify.yaml +sensiolabs_minify: + asset_mapper: + + # Exclude files + ignore_paths: + - 'admin/*' + - '*.min.js' + + # Exclude vendor assets + ignore_vendor: true +``` + + +### Minify Binary + + +#### Local binary + +```yaml +# config/packages/sensiolabs_minify.yaml +sensiolabs_minify: + minify: + + # Auto-detect the local binary + local_binary: 'auto' + + # Specify the local binary path + # local_binary: "/usr/local/sbin/minify" + + # Or set false to disable + # local_binary: false +``` + +#### Automatic download + +```yaml +# config/packages/sensiolabs_minify.yaml +sensiolabs_minify: + minify: + + # Enable automatic download from GitHub + download_binary: true + + # Directory to store the downloaded binary + download_directory: '%kernel.project_dir%/var/minify' + +``` + +## Console + +### Command Line + +#### Install Minify locally + +```shell +php bin/console minify:install +``` + +#### Minify assets + +```shell +php bin/console minify:assets css/main.css css/main.min.css +``` + +## Credits + +- Minify binary: [Timo Dewolf](https://github.com/tdewolff) +- Symfony Bundle: [Simon André](https://github.com/smnandre) & [SensioLabs](https://github.com/sensiolabs) + +## License + +The [SensioLabs Minify Bundle](https://github.com/sensiolabs/minify-bundle) is released under the [MIT license](LICENSE). + + +[1]: https://cdn.jsdelivr.net/npm/@tarekraafat/autocomplete.js@10.2.7/dist/autoComplete.js +[3]: https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.js +[5]: https://cdn.jsdelivr.net/npm/video.js@8.18.1/dist/video.js +[7]: https://github.com/w3c/w3c-website-templates-bundle/blob/main/public/dist/assets/js/main.js +[2]: https://cdn.jsdelivr.net/npm/@tarekraafat/autocomplete.js@10.2.7/dist/css/autoComplete.css +[4]: https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.css +[6]: https://cdn.jsdelivr.net/npm/video.js@8.18.1/dist/video-js.css +[8]: https://github.com/w3c/w3c-website-templates-bundle/blob/main/public/dist/assets/styles/core.css diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ecfe61b --- /dev/null +++ b/composer.json @@ -0,0 +1,64 @@ +{ + "name": "sensiolabs/minify-bundle", + "description": "Minify assets (CSS, JS, SVG, ..) Symfony", + "license": "MIT", + "homepage": "https://github.com/sensiolabs/minify-bundle", + "type": "symfony-bundle", + "keywords": [ + "symfony", + "assets", + "minify", + "minifier", + "compress", + "uglify", + "css", + "js", + "asset-mapper" + ], + "authors": [ + { + "name": "Simon André", + "email": "smn.andre@gmail.com" + }, + { + "name": "SensioLabs", + "homepage": "https://sensiolabs.com" + } + ], + "require": { + "php": ">=8.2", + "psr/log": "^2|^3", + "symfony/filesystem": "^7.0", + "symfony/http-client-contracts": "^3.5", + "symfony/process": "^7.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^10.5", + "symfony/asset-mapper": "^7.1", + "symfony/console": "^7.1", + "symfony/framework-bundle": "^7.1", + "symfony/http-client": "^7.1", + "symfony/http-kernel": "^7.1" + }, + "autoload": { + "psr-4": { + "Sensiolabs\\MinifyBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Sensiolabs\\MinifyBundle\\Tests\\": "tests/" + } + }, + "config": { + "optimize-autoloader": true, + "sort-packages": true + }, + "extra": { + "thanks": { + "name": "tdewolff/minify", + "url": "https://github.com/tdewolff/minify" + } + } +} diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..c53ab81 --- /dev/null +++ b/config/services.php @@ -0,0 +1,62 @@ +services() + + ->set('.sensiolabs_minify.minifier.minify_installer', MinifyInstaller::class) + ->args([ + abstract_arg('download_path'), + service('http_client')->nullOnInvalid(), + ]) + + ->set('.sensiolabs_minify.minifier.minify_factory', MinifyFactory::class) + ->args([ + abstract_arg('local_binary'), + service('.sensiolabs_minify.minifier.minify_installer')->nullOnInvalid(), + ]) + + ->set('sensiolabs_minify.minifier.minify', Minify::class) + ->factory([service('.sensiolabs_minify.minifier.minify_factory'), 'create']) + + ->alias('sensiolabs_minify.minifier', 'sensiolabs_minify.minifier.minify') + + ->alias(MinifierInterface::class, 'sensiolabs_minify.minifier') + + ->set('.sensiolabs_minify.asset_mapper.compiler', MinifierCompiler::class) + ->args([service('sensiolabs_minify.minifier')]) + ->tag('asset_mapper.compiler', ['priority' => -1024]) + + ->set('.sensiolabs_minify.command.minify_install', MinifyInstallCommand::class) + ->args([service('.sensiolabs_minify.minifier.minify_installer')]) + ->tag('console.command') + + ->set('.sensiolabs_minify.command.minify_asset', MinifyAssetCommand::class) + ->args([ + service('sensiolabs_minify.minifier'), + param('kernel.project_dir'), + ]) + ->tag('console.command') + ; +}; diff --git a/doc/bench.md b/doc/bench.md new file mode 100644 index 0000000..71277fc --- /dev/null +++ b/doc/bench.md @@ -0,0 +1,21 @@ +# Benchmarks + +## Download files + +``` +curl -O https://cdn.jsdelivr.net/npm/@tarekraafat/autocomplete.js@10.2.7/dist/autoComplete.js +curl -O https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.js +curl -O https://cdn.jsdelivr.net/npm/video.js@8.18.1/dist/video.js +curl -O https://raw.githubusercontent.com/w3c/w3c-website-templates-bundle/refs/heads/main/public/dist/assets/js/main.js +curl -O https://cdn.jsdelivr.net/npm/@tarekraafat/autocomplete.js@10.2.7/dist/css/autoComplete.css +curl -O https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.css +curl -O https://cdn.jsdelivr.net/npm/video.js@8.18.1/dist/video-js.css +curl -O https://raw.githubusercontent.com/w3c/w3c-website-templates-bundle/refs/heads/main/public/dist/assets/styles/core.css +``` + + +## Minify files + +``` +minify -s -o min/ * +``` diff --git a/doc/index.md b/doc/index.md new file mode 100644 index 0000000..46271c7 --- /dev/null +++ b/doc/index.md @@ -0,0 +1,8 @@ +# Asset Minifier Bundle +--- + +## Installation + +``` +composer require --dev sensiolabs/asset-minifier +``` diff --git a/minify.dark.svg b/minify.dark.svg new file mode 100644 index 0000000..4c111ec --- /dev/null +++ b/minify.dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/minify.svg b/minify.svg new file mode 100644 index 0000000..24a749a --- /dev/null +++ b/minify.svg @@ -0,0 +1,4 @@ + + + + diff --git a/phpstan.dist.neon b/phpstan.dist.neon new file mode 100644 index 0000000..37ee80c --- /dev/null +++ b/phpstan.dist.neon @@ -0,0 +1,7 @@ +parameters: + level: 9 + paths: + - src/ + - tests/ + excludePaths: + - src/SensiolabsMinifyBundle.php diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..91e27ee --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + tests + + + + + + src + + + diff --git a/src/AssetMapper/MinifierCompiler.php b/src/AssetMapper/MinifierCompiler.php new file mode 100644 index 0000000..a30be60 --- /dev/null +++ b/src/AssetMapper/MinifierCompiler.php @@ -0,0 +1,69 @@ + + */ +final class MinifierCompiler implements AssetCompilerInterface +{ + /** + * @param MinifierInterface $minify + * @param list $extensions + * @param list $ignorePaths + */ + public function __construct( + private readonly MinifierInterface $minify, + private readonly array $extensions = ['css', 'js'], + private readonly array $ignorePaths = [], + private readonly bool $ignoreVendor = true, + ) { + } + + public function supports(MappedAsset $asset): bool + { + if (!in_array($asset->publicExtension, $this->extensions, true)) { + return false; + } + + if ($this->ignoreVendor && $asset->isVendor) { + return false; + } + + foreach ($this->ignorePaths as $ignorePath) { + if (fnmatch($ignorePath, $asset->sourcePath)) { + return false; + } + } + + return true; + } + + public function compile(string $content, MappedAsset $asset, AssetMapperInterface $assetMapper): string + { + $type = match($extension = $asset->publicExtension) { + 'css', 'scss' => 'css', + 'js' => 'js', + default => throw new RuntimeException(sprintf('Invalid type "%s".', $extension)) + }; + + return $this->minify->minify($content, $type); + } +} diff --git a/src/Command/MinifyAssetCommand.php b/src/Command/MinifyAssetCommand.php new file mode 100644 index 0000000..959697b --- /dev/null +++ b/src/Command/MinifyAssetCommand.php @@ -0,0 +1,114 @@ + + */ +#[AsCommand('minify:asset')] +final class MinifyAssetCommand extends Command +{ + public function __construct( + private MinifierInterface $minifier, + private readonly string $projectDir, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('input', InputArgument::REQUIRED, 'Asset filename, relative to the project directory (e.g. assets/css/styles.css)') + ->addArgument('output', InputArgument::OPTIONAL, 'Output filename, relative to the project directory (e.g. public/css/styles.min.css)') + ->addOption('type', 't', InputOption::VALUE_REQUIRED, 'Asset type: css or js. If not provided, the file extension will be used.') + ->setHelp( + <<<'EOF' +The %command.name% command minifies an asset file. + + php %command.full_name% assets/css/asset.css + +The minified asset will be output to the console. +To write the minified result into a file, pass the output +filename as the second argument: + + php %command.full_name% assets/css/asset.css public/css/asset.min.css + +You can also specify the type of the asset with the --type option: + + php %command.full_name% assets/js/asset.jsm --type=js public/js/asset.js + +INFORMATION +If you're using AssetMapper, the assets will be minified automatically +during the "asset-map:deploy" command. +EOF + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $fs = new Filesystem(); + + /** @var string $inputArg */ + $inputArg = $input->getArgument('input'); + if (Path::isRelative($inputArg)) { + $inputArg = Path::join($this->projectDir, $inputArg); + } + if (!$fs->exists($inputArg)) { + $io->error(sprintf('Cannot read file "%s".', $inputArg)); + return Command::FAILURE; + } + + /** @var string|null $outputArg */ + $outputArg = $input->getArgument('output'); + if ($outputArg && Path::isRelative($outputArg)) { + $outputArg = Path::join($this->projectDir, $outputArg); + } + + /** @var 'css'|'js' $typeArg */ + $typeArg = $input->getOption('type') ?? pathinfo($inputArg, PATHINFO_EXTENSION); + $inputArg = $fs->readFile($inputArg); + + $output = $this->minifier->minify($inputArg, $typeArg); + + if (null === $outputArg) { + $io->text($output); + return Command::SUCCESS; + } + + $fs = new Filesystem(); + try { + $fs->dumpFile($outputArg, $output); + } catch (IOException) { + $io->error(sprintf('Cannot write to file "%s".', $outputArg)); + return Command::FAILURE; + } + + $io->success(sprintf('Asset minified into "%s".', $outputArg)); + + return Command::SUCCESS; + } +} diff --git a/src/Command/MinifyInstallCommand.php b/src/Command/MinifyInstallCommand.php new file mode 100644 index 0000000..44dec5e --- /dev/null +++ b/src/Command/MinifyInstallCommand.php @@ -0,0 +1,77 @@ + + */ +#[AsCommand('minify:install', description: 'Install the Minify binary')] +final class MinifyInstallCommand extends Command +{ + public function __construct( + private readonly MinifierInstallerInterface $minifyInstaller, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption('force', null, InputOption::VALUE_NONE, 'Force a reinstall of the binary if it is already installed') + ->setHelp( + <<<'EOF' +The %command.name% command installs the minify binary. + +It will download the binary from the tadewolf/minify GitHub repository +and will store it in the project directory. + + php %command.full_name% + +Per default, the binary will not be re-installed if already present. + +Use the --force option to overwrite the existing binary: + + php %command.full_name% --force +EOF + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if ($input->getOption('force')) { + $io->warning('The binary will be re-installed'); + } + + if ($this->minifyInstaller->isInstalled() && !$input->getOption('force')) { + $io->success('The Minify binary is already installed'); + + return Command::SUCCESS; + } + + $this->minifyInstaller->install(force: (bool) $input->getOption('force')); + $io->success('The Minify binary has been installed'); + + return Command::SUCCESS; + } +} diff --git a/src/EventListener/PreAssetsCompileEventListener.php b/src/EventListener/PreAssetsCompileEventListener.php new file mode 100644 index 0000000..94b7652 --- /dev/null +++ b/src/EventListener/PreAssetsCompileEventListener.php @@ -0,0 +1,45 @@ + + * + * @internal + */ +final class PreAssetsCompileEventListener +{ + public function __construct( + private readonly MinifierInstallerInterface $minifyInstaller, + ) { + } + + public function __invoke(PreAssetsCompileEvent $event): void + { + if ($this->minifyInstaller->isInstalled()) { + $event->getOutput()->writeln('Minify binary already installed.', OutputInterface::VERBOSITY_DEBUG); + + return; + } + + $this->minifyInstaller->install(); + $event->getOutput()->writeln('Minify binary installed.', OutputInterface::VERBOSITY_NORMAL); + } +} diff --git a/src/Exception/BinaryNotFoundException.php b/src/Exception/BinaryNotFoundException.php new file mode 100644 index 0000000..1f45d69 --- /dev/null +++ b/src/Exception/BinaryNotFoundException.php @@ -0,0 +1,21 @@ + + */ +class BinaryNotFoundException extends LogicException +{ +} diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php new file mode 100644 index 0000000..7df567a --- /dev/null +++ b/src/Exception/ExceptionInterface.php @@ -0,0 +1,21 @@ + + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Exception/InstallException.php b/src/Exception/InstallException.php new file mode 100644 index 0000000..b09c763 --- /dev/null +++ b/src/Exception/InstallException.php @@ -0,0 +1,21 @@ + + */ +class InstallException extends RuntimeException +{ +} diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php new file mode 100644 index 0000000..8c93e7d --- /dev/null +++ b/src/Exception/InvalidArgumentException.php @@ -0,0 +1,21 @@ + + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Exception/LogicException.php b/src/Exception/LogicException.php new file mode 100644 index 0000000..1608dc8 --- /dev/null +++ b/src/Exception/LogicException.php @@ -0,0 +1,18 @@ + + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Minifier/MinifierFactoryInterface.php b/src/Minifier/MinifierFactoryInterface.php new file mode 100644 index 0000000..fdb4607 --- /dev/null +++ b/src/Minifier/MinifierFactoryInterface.php @@ -0,0 +1,27 @@ + + */ +interface MinifierFactoryInterface +{ + /** + * @return T&MinifierInterface + */ + public function create(): MinifierInterface; +} diff --git a/src/Minifier/MinifierInstallerInterface.php b/src/Minifier/MinifierInstallerInterface.php new file mode 100644 index 0000000..94dae68 --- /dev/null +++ b/src/Minifier/MinifierInstallerInterface.php @@ -0,0 +1,40 @@ + + */ +interface MinifierInstallerInterface +{ + public const VERSION_LATEST = 'latest'; + + /** + * Install the binary. + * + * @param string $version The version to install (default to latest) + * @param bool $force Whether to force the installation + */ + public function install(string $version = self::VERSION_LATEST, bool $force = false): void; + + /** + * @return bool Whether the binary is installed + */ + public function isInstalled(): bool; + + /** + * @return string The path to the installed binary + */ + public function getInstallBinaryPath(): string; +} diff --git a/src/Minifier/MinifierInterface.php b/src/Minifier/MinifierInterface.php new file mode 100644 index 0000000..b477ac5 --- /dev/null +++ b/src/Minifier/MinifierInterface.php @@ -0,0 +1,30 @@ + + */ +interface MinifierInterface +{ + public const TYPE_CSS = 'css'; + public const TYPE_JS = 'js'; + + /** + * @param string $input + * @param self::TYPE_CSS|self::TYPE_JS $type + * @return string + */ + public function minify(string $input, string $type): string; +} diff --git a/src/Minifier/SystemUtils.php b/src/Minifier/SystemUtils.php new file mode 100644 index 0000000..6d3b82d --- /dev/null +++ b/src/Minifier/SystemUtils.php @@ -0,0 +1,72 @@ + + */ +final class SystemUtils +{ + private const OS_WINDOWS = 'windows'; + private const OS_LINUX = 'linux'; + private const OS_DARWIN = 'darwin'; + private const OS_FREEBSD = 'freebsd'; + + private const ARCH_ARM64 = 'arm64'; + private const ARCH_AMD64 = 'amd64'; + private const ARCH_X86 = 'x86'; + + public function __construct( + private ?string $platform = null, + private ?string $architecture = null, + ) { + $this->platform = $platform ? $this->getPlatform($platform) : null; + $this->architecture = $architecture ? $this->getArchitecture($architecture) : null; + } + + public static function create(): self + { + return new self(\PHP_OS_FAMILY, \php_uname('m')); + } + + public function match(string $release): bool + { + if (null === $this->platform || null === $this->architecture) { + return false; + } + + return str_contains($release, $this->platform) && str_contains($release, $this->architecture); + } + + private function getPlatform(string $platform): ?string + { + return match (\strtolower($platform)) { + 'freebsd' => self::OS_FREEBSD, + 'darwin' => self::OS_DARWIN, + 'linux' => self::OS_LINUX, + 'windows' => self::OS_WINDOWS, + default => null, + }; + } + + private function getArchitecture(string $architecture): ?string + { + return match ($architecture) { + 'amd64', 'x86_64' => self::ARCH_AMD64, + 'arm64' => self::ARCH_ARM64, + 'i386', 'i686' => self::ARCH_X86, + default => null, + }; + } +} diff --git a/src/Minifier/TraceableMinifier.php b/src/Minifier/TraceableMinifier.php new file mode 100644 index 0000000..14b74a8 --- /dev/null +++ b/src/Minifier/TraceableMinifier.php @@ -0,0 +1,57 @@ + + */ +final class TraceableMinifier implements MinifierInterface +{ + public function __construct( + private readonly MinifierInterface $minifier, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function minify(string $input, string $type): string + { + $inputSize = strlen($input); + $this->logger->debug('Minify command input: {inputSize} kB', [ + 'inputSize' => round($inputSize / 1024, 1), + 'input' => $input, + ]); + + $timeStart = microtime(true); + $output = $this->minifier->minify($input, $type); + $timeEnd = microtime(true); + + $outputSize = strlen($output); + $this->logger->debug('Minify command output: {outputSize} kB', [ + 'output' => $output, + 'outputSize' => round($outputSize / 1024, 1), + ]); + + $this->logger->info('Minified asset type {type}: reduced {ratio} % in {duration} ms.', [ + 'type' => $type, + 'ratio' => abs((int) ($inputSize > 0 ? ($outputSize - $inputSize) / $inputSize * 100 : 0)), + 'duration' => (int) (($timeEnd - $timeStart) * 1000), + 'sizeDiff' => round(($outputSize - $inputSize) / 1024, 1), + ]); + + return $output; + } +} diff --git a/src/Minify.php b/src/Minify.php new file mode 100644 index 0000000..771614b --- /dev/null +++ b/src/Minify.php @@ -0,0 +1,47 @@ + + */ +final class Minify implements MinifierInterface +{ + public function __construct( + private readonly string $binaryPath, + ) { + } + + public function minify(string $input, string $type): string + { + $process = new Process([$this->binaryPath, '--type', $type]); + $process->setInput($input); + + try { + $process->run(); + } catch (\Throwable $e) { + throw new RuntimeException('Error during minify command: '.$e->getMessage(), 0, $e); + } + + if (!$process->isSuccessful()) { + throw new RuntimeException(sprintf('Minify error %s: "%s".', $process->getExitCode(), $process->getExitCodeText())); + } + + return $process->getOutput(); + } +} diff --git a/src/MinifyFactory.php b/src/MinifyFactory.php new file mode 100644 index 0000000..56bdb5a --- /dev/null +++ b/src/MinifyFactory.php @@ -0,0 +1,70 @@ + + * + * @author Simon André + */ +final class MinifyFactory implements MinifierFactoryInterface +{ + private const BINARY_PATH_AUTO = 'auto'; + + public function __construct( + private readonly string|bool $binaryPath = self::BINARY_PATH_AUTO, + private readonly ?MinifierInstallerInterface $installer = null, + ) { + } + + public function create(): Minify + { + if ($binaryPath = $this->getBinaryPath()) { + return new Minify($binaryPath); + } + + throw new BinaryNotFoundException('The minify binary cannot not be found.'); + } + + private function getBinaryPath(): ?string + { + if (false !== $this->binaryPath) { + if ('auto' === $this->binaryPath || true === $this->binaryPath) { + if ($path = (new ExecutableFinder())->find('minify')) { + return $path; + } + } elseif (file_exists($this->binaryPath)) { + return $this->binaryPath; + } + } + + if (null === $this->installer) { + throw new LogicException('The minify binary path is not set and no installer is provided.'); + } + + if (!$this->installer->isInstalled()) { + $this->installer->install(); + } + + $path = $this->installer->getInstallBinaryPath(); + + return file_exists($path) ? $path : null; + } +} diff --git a/src/MinifyInstaller.php b/src/MinifyInstaller.php new file mode 100644 index 0000000..18aac31 --- /dev/null +++ b/src/MinifyInstaller.php @@ -0,0 +1,126 @@ + + */ +final class MinifyInstaller implements MinifierInstallerInterface +{ + private const RELEASES_API_URL = 'https://api.github.com/repos/tdewolff/minify/releases'; + private readonly HttpClientInterface $httpClient; + private readonly Filesystem $filesystem; + + public function __construct( + private readonly string $installDirectory, + ?HttpClientInterface $httpClient = null, + ) { + if (null === $httpClient && !class_exists(HttpClient::class)) { + throw new LogicException(\sprintf('The "%s" class needs an HTTP client to download the minify binary. Try running "composer require symfony/http-client".', self::class)); + } + $this->httpClient = $httpClient ?? HttpClient::create(); + $this->filesystem = new Filesystem(); + } + + public function install(string $version = self::VERSION_LATEST, bool $force = false): void + { + if ($this->isInstalled() && !$force) { + return; + } + + $this->download($version); + } + + public function isInstalled(): bool + { + return file_exists($this->getInstallBinaryPath()) && is_executable($this->getInstallBinaryPath()); + } + + public function getInstallBinaryPath(): string + { + return Path::join($this->installDirectory, 'minify'); + } + + public function download(string $version): void + { + $releaseAsset = $this->getReleaseAsset($version); + $releaseDownloadUrl = $releaseAsset['browser_download_url']; + + $tempDir = sys_get_temp_dir().'/minify'; + $this->filesystem->mkdir($tempDir); + + $downloadFilename = Path::join($tempDir, basename($releaseDownloadUrl)); + $response = $this->httpClient->request('GET', $releaseDownloadUrl, [ + 'headers' => [ + 'Accept' => 'application/octet-stream', + ], + ]); + if (200 !== $response->getStatusCode()) { + throw new InstallException(sprintf('Error downloading the minify binary from GitHub "%s".', $response->getContent(false))); + } + foreach ($this->httpClient->stream($response) as $chunk) { + $this->filesystem->appendToFile($downloadFilename, $chunk->getContent(), true); + } + + // TODO windows + + $archive = new \PharData($downloadFilename); + if (!isset($archive['minify'])) { + throw new LogicException('The minify binary is missing from the archive.'); + } + $archive->extractTo($tempDir, ['minify'], true); + + $this->filesystem->mkdir(dirname($this->getInstallBinaryPath())); + $this->filesystem->copy(Path::join($tempDir, 'minify'), $this->getInstallBinaryPath()); + $this->filesystem->remove($tempDir); + } + + /** + * @return array{ + * name: string, + * browser_download_url: string, + * content_type: string, + * } + */ + private function getReleaseAsset(string $version): array + { + $versionUrl = self::VERSION_LATEST === $version ? $version : 'tags/'.$version; + $response = $this->httpClient->request('GET', self::RELEASES_API_URL.'/'.$versionUrl, [ + 'headers' => ['Accept' => 'application/json'], + 'max_redirects' => 2, + ]); + if (200 !== $response->getStatusCode()) { + throw new InstallException(sprintf('The release "%s" does not exist.', $version)); + } + + $systemUtils = SystemUtils::create(); + foreach ($response->toArray()['assets'] ?? [] as $asset) { + if ($systemUtils->match($asset['name'])) { + return $asset; + } + } + + throw new InstallException(sprintf('Unable to find a binary for release "%s".', $version)); + } + +} diff --git a/src/SensiolabsMinifyBundle.php b/src/SensiolabsMinifyBundle.php new file mode 100644 index 0000000..767c5ca --- /dev/null +++ b/src/SensiolabsMinifyBundle.php @@ -0,0 +1,134 @@ + + * + * @phpstan-ignore-file + */ +final class SensiolabsMinifyBundle extends AbstractBundle +{ + /** + * @param array> $config + */ + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $container->import('../config/services.php'); + + $container->services() + ->get('.sensiolabs_minify.minifier.minify_installer') + ->arg(0, $config['minify']['download_directory']) + + ->get('.sensiolabs_minify.minifier.minify_factory') + ->arg(0, $config['minify']['local_binary']) + + ->get('.sensiolabs_minify.asset_mapper.compiler') + ->arg(1, array_keys(array_filter($config['asset_mapper']['types']), boolval(...))) + ->arg(2, $config['asset_mapper']['ignore_paths']) + ->arg(3, $config['asset_mapper']['ignore_vendor']) + ; + + if (!$config['asset_mapper']['enabled']) { + $container->services() + ->remove('.sensiolabs_minify.asset_mapper.compiler') + ; + } + + if (!$config['minify']['download_binary']) { + $container->services() + ->remove('.sensiolabs_minify.minifier.minify_installer') + ; + } + + if ($builder->getParameter('kernel.debug')) { + $container->services() + ->set('sensiolabs_minify.traceable_minifier', TraceableMinifier::class) + ->decorate('sensiolabs_minify.minifier') + ->args([ + service('sensiolabs_minify.traceable_minifier.inner'), + service('logger')->nullOnInvalid(), + ]) + ->tag('monolog.logger', ['channel' => 'assets']) + ; + } + } + + public function configure(DefinitionConfigurator $definition): void + { + $definition + ->rootNode() + ->children() + ->arrayNode('asset_mapper') + ->info('AssetMapper compiler settings') + ->addDefaultsIfNotSet() + ->canBeDisabled() + ->children() + ->arrayNode('types') + ->info('Asset types to minify') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('css')->defaultTrue()->end() + ->booleanNode('js')->defaultTrue()->end() + ->end() + ->end() + ->arrayNode('ignore_paths') + ->info('Paths to exclude from minification') + ->example(['admin/*', '*.min.*']) + ->beforeNormalization()->castToArray()->end() + ->scalarPrototype()->end() + ->end() + ->booleanNode('ignore_vendor') + ->info('Exclude vendor assets from minification') + ->defaultTrue() + ->end() + ->end() + ->end() + ->arrayNode('minify') + ->info('Minify settings') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('local_binary') + ->info('Path to the local binary (use "auto" for automatic detection)') + ->defaultValue(false) + ->end() + ->booleanNode('download_binary') + ->info('Download the binary from GitHub (defaults to "true" in debug mode)') + ->defaultValue('%kernel.debug%') + ->end() + ->scalarNode('download_directory') + ->info('Directory to store the downloaded binary') + ->defaultValue('%kernel.project_dir%/var/minify') + ->end() + ->end() + ->end() + ->end() + ->end(); + } +} diff --git a/tests/AssetMapper/MinifyCompilerTest.php b/tests/AssetMapper/MinifyCompilerTest.php new file mode 100644 index 0000000..846da22 --- /dev/null +++ b/tests/AssetMapper/MinifyCompilerTest.php @@ -0,0 +1,132 @@ +createMock(MinifierInterface::class); + $minifierCompiler = new MinifierCompiler($minifier); + + $asset = new MappedAsset( + 'asset.css', + '/source/asset.css', + '/public/asset.png', + ); + + $this->assertFalse($minifierCompiler->supports($asset)); + } + + public function testSupportsReturnsFalseWhenAssetIsVendorAndIgnoreVendorIsTrue(): void + { + $minifier = $this->createMock(MinifierInterface::class); + $minifierCompiler = new MinifierCompiler($minifier); + + $asset = new MappedAsset( + 'file.css', + '/path/ignored/file.css', + '/public/path.css', + isVendor: true, + ); + + $this->assertFalse($minifierCompiler->supports($asset)); + } + + public function testSupportsReturnsFalseWhenAssetSourcePathMatchesIgnorePath(): void + { + $minifier = $this->createMock(MinifierInterface::class); + $minifierCompiler = new MinifierCompiler($minifier, ['css', 'js'], ['*/ignored/*']); + + $asset = new MappedAsset( + 'file.css', + '/path/ignored/file.css', + '/public/path.css', + ); + + $this->assertFalse($minifierCompiler->supports($asset)); + } + + public function testSupportsReturnsTrueForValidAsset(): void + { + $minifier = $this->createMock(MinifierInterface::class); + $minifierCompiler = new MinifierCompiler($minifier); + + $asset = new MappedAsset( + 'file.css', + '/path/to/file.css', + '/public/path.css', + ); + $this->assertTrue($minifierCompiler->supports($asset)); + } + + public function testCompileReturnsMinifiedContentForCss() + { + $minifier = $this->createMock(MinifierInterface::class); + $minifier->method('minify')->willReturn('minified content'); + + $asset = new MappedAsset( + 'file.css', + '/path/to/file.css', + '/public/path.css', + ); + $assetMapper = $this->createMock(AssetMapperInterface::class); + + $compiler = new MinifierCompiler($minifier); + $this->assertSame('minified content', $compiler->compile('input content', $asset, $assetMapper)); + } + + public function testCompileReturnsMinifiedContentForJs() + { + $minifier = $this->createMock(MinifierInterface::class); + $minifier->method('minify')->willReturn('minified content'); + + $asset = new MappedAsset( + 'file.js', + '/path/to/file.js', + '/public/path.js', + ); + $assetMapper = $this->createMock(AssetMapperInterface::class); + + $compiler = new MinifierCompiler($minifier); + $this->assertSame('minified content', $compiler->compile('input content', $asset, $assetMapper)); + } + + public function testCompileThrowsRuntimeExceptionForInvalidType() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid type "txt".'); + + $minifier = $this->createMock(MinifierInterface::class); + + $asset = new MappedAsset( + 'file.txt', + '/path/to/file.txt', + '/public/path.txt', + ); + $assetMapper = $this->createMock(AssetMapperInterface::class); + + $compiler = new MinifierCompiler($minifier); + $compiler->compile('input content', $asset, $assetMapper); + } +} diff --git a/tests/Command/MinifyAssetCommandTest.php b/tests/Command/MinifyAssetCommandTest.php new file mode 100644 index 0000000..fef4759 --- /dev/null +++ b/tests/Command/MinifyAssetCommandTest.php @@ -0,0 +1,79 @@ +createMock(MinifierInterface::class); + $minifier->method('minify')->willReturn('minified content'); + + $command = new MinifyAssetCommand($minifier, __DIR__.'/../Fixtures'); + $tester = new CommandTester($command); + + $tester->execute(['input' => 'assets/css/style.css']); + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertStringContainsString('minified content', $tester->getDisplay()); + } + + public function testMinifyAssetCommandWritesMinifiedContentToFile(): void + { + $minifier = $this->createMock(MinifierInterface::class); + $minifier->method('minify')->willReturn('minified content'); + + $projectDir = realpath(__DIR__.'/../Fixtures'); + $command = new MinifyAssetCommand($minifier, $projectDir); + $tester = new CommandTester($command); + + $outputFile = 'var/output.css'; + $tester->execute(['input' => 'assets/css/style.css', 'output' => $outputFile]); + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertFileExists($outputFile = $projectDir.'/'.$outputFile); + $this->assertStringEqualsFile($outputFile, 'minified content'); + unlink($outputFile); + } + + public function testMinifyAssetCommandFailsWhenInputFileDoesNotExist(): void + { + $minifier = $this->createMock(MinifierInterface::class); + + $command = new MinifyAssetCommand($minifier, __DIR__.'/../Fixtures'); + $tester = new CommandTester($command); + + $tester->execute(['input' => 'nonexistent.css']); + $this->assertSame(Command::FAILURE, $tester->getStatusCode()); + $this->assertStringContainsString('Cannot read file', $tester->getDisplay()); + } + + public function testMinifyAssetCommandFailsWhenOutputFileIsNotWritable(): void + { + $minifier = $this->createMock(MinifierInterface::class); + + $command = new MinifyAssetCommand($minifier, __DIR__.'/../Fixtures'); + $tester = new CommandTester($command); + + $tester->execute(['input' => 'assets/css/style.css', 'output' => '/\\ .']); + $this->assertSame(Command::FAILURE, $tester->getStatusCode()); + $this->assertStringContainsString('Cannot write to file', $tester->getDisplay()); + } +} diff --git a/tests/Command/MinifyInstallCommandTest.php b/tests/Command/MinifyInstallCommandTest.php new file mode 100644 index 0000000..902b0db --- /dev/null +++ b/tests/Command/MinifyInstallCommandTest.php @@ -0,0 +1,68 @@ +createMock(MinifierInstallerInterface::class); + $minifyInstaller->method('isInstalled')->willReturn(false); + $minifyInstaller->expects($this->once())->method('install'); + + $command = new MinifyInstallCommand($minifyInstaller); + $tester = new CommandTester($command); + + $tester->execute([]); + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertStringContainsString('The Minify binary has been installed', $tester->getDisplay()); + } + + public function testMinifyInstallCommandDoesNotReinstallBinaryWhenAlreadyInstalled() + { + $minifyInstaller = $this->createMock(MinifierInstallerInterface::class); + $minifyInstaller->method('isInstalled')->willReturn(true); + $minifyInstaller->expects($this->never())->method('install'); + + $command = new MinifyInstallCommand($minifyInstaller); + $tester = new CommandTester($command); + + $tester->execute([]); + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertStringContainsString('The Minify binary is already installed', $tester->getDisplay()); + } + + public function testMinifyInstallCommandReinstallsBinaryWhenForceOptionIsUsed() + { + $minifyInstaller = $this->createMock(MinifierInstallerInterface::class); + $minifyInstaller->method('isInstalled')->willReturn(true); + $minifyInstaller->expects($this->once())->method('install'); + + $command = new MinifyInstallCommand($minifyInstaller); + $tester = new CommandTester($command); + + $tester->execute(['--force' => true]); + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertStringContainsString('The binary will be re-installed', $tester->getDisplay()); + $this->assertStringContainsString('The Minify binary has been installed', $tester->getDisplay()); + } +} diff --git a/tests/EventListener/PreAssetsCompileEventListenerTest.php b/tests/EventListener/PreAssetsCompileEventListenerTest.php new file mode 100644 index 0000000..b5357b4 --- /dev/null +++ b/tests/EventListener/PreAssetsCompileEventListenerTest.php @@ -0,0 +1,73 @@ +createMock(MinifierInstallerInterface::class); + $installer->expects($this->once()) + ->method('isInstalled') + ->willReturn(false); + $installer->expects($this->once()) + ->method('install') + ->with('latest', false); + + $listener = new PreAssetsCompileEventListener($installer); + + $output = $this->createMock(NullOutput::class); + $output->expects($this->once()) + ->method('writeln') + ->with('Minify binary installed.', Output::VERBOSITY_NORMAL); + + $event = new PreAssetsCompileEvent($output); + + $listener($event); + + $this->assertFalse($event->isPropagationStopped()); + } + + public function testItInstallWhenMinifierIsAlreadyInstalled(): void + { + $installer = $this->createMock(MinifierInstallerInterface::class); + $installer->expects($this->once()) + ->method('isInstalled') + ->willReturn(true); + $installer->expects($this->never()) + ->method('install'); + + $listener = new PreAssetsCompileEventListener($installer); + + $output = $this->createMock(NullOutput::class); + $output->expects($this->once()) + ->method('writeln') + ->with('Minify binary already installed.', Output::VERBOSITY_DEBUG); + + $event = new PreAssetsCompileEvent($output); + + $listener($event); + + $this->assertFalse($event->isPropagationStopped()); + } +} diff --git a/tests/Fixtures/MinifyBundleTestKernel.php b/tests/Fixtures/MinifyBundleTestKernel.php new file mode 100644 index 0000000..85db7ca --- /dev/null +++ b/tests/Fixtures/MinifyBundleTestKernel.php @@ -0,0 +1,79 @@ +loadFromExtension('framework', [ + 'secret' => 'foo', + 'test' => true, + 'http_method_override' => true, + 'handle_all_throwables' => true, + 'php_errors' => [ + 'log' => true, + ], + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/assets', + ], + ], + ]); + } + + public function getCacheDir(): string + { + return $this->getProjectDir().'/var/cache/'.$this->environment; + } + + public function getBuildDir(): string + { + return $this->getProjectDir().'/var/build/'.$this->environment; + } + + public function getProjectDir(): string + { + return __DIR__; + } +} diff --git a/tests/Fixtures/assets/css/style.css b/tests/Fixtures/assets/css/style.css new file mode 100644 index 0000000..a2739c5 --- /dev/null +++ b/tests/Fixtures/assets/css/style.css @@ -0,0 +1,80 @@ +/* + This is a multi-line CSS comment. + This file includes various CSS features for testing. +*/ + +/* Simple rule set */ +body { + margin: 0; + padding: 0; + font-family: Arial, sans-serif; + background-color: #f4f4f4; /* Light background */ +} + +.button:hover { + background-color: #005f5f; /* Darker blue on hover */ +} + +/* Descendant combinator */ +.nav ul { + list-style-type: none; + padding: 0; + margin: 0; +} + +/* Attribute selector */ +input[type="text"] { + width: 100%; + padding: 10px; + border: 1px solid #ccc; + border-radius: 3px; + box-sizing: border-box; +} + +/* Pseudo-class and pseudo-element */ +a:link { + color: #0000ff; + text-decoration: none; +} + +a::before { + content: "🌐"; +} + +/* Universal selector and box shadow */ +* { + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); /* Soft shadow on all elements */ +} + +/* Media query for responsiveness */ +@media (max-width: 768px) { + .container { + flex-direction: column; /* Stack items vertically on smaller screens */ + } + + .grid-container { + grid-template-columns: 1fr; /* Single column on small screens */ + } +} + +/* Keyframes and animation */ +@keyframes fadeIn { + from { + opacity: 0; + } +} + +.fade-in { + animation: fadeIn 2s ease-in-out; +} + +/* CSS Variables */ +:root { + --main-color: #ff6347; /* Tomato */ + --secondary-color: #4caf50; /* Green */ +} + +.header { + background-color: var(--main-color); + text-align: center; +} diff --git a/tests/Fixtures/assets/css/style.minified.css b/tests/Fixtures/assets/css/style.minified.css new file mode 100644 index 0000000..bf6acfd --- /dev/null +++ b/tests/Fixtures/assets/css/style.minified.css @@ -0,0 +1 @@ +body{margin:0;padding:0;font-family:Arial,sans-serif;background-color:#f4f4f4}.button:hover{background-color:#005f5f}.nav ul{list-style-type:none;padding:0;margin:0}input[type=text]{width:100%;padding:10px;border:1px solid #ccc;border-radius:3px;box-sizing:border-box}a:link{color:#00f;text-decoration:none}a::before{content:"🌐"}*{box-shadow:0 0 10px rgba(0,0,0,.1)}@media(max-width:768px){.container{flex-direction:column}.grid-container{grid-template-columns:1fr}}@keyframes fadeIn{from{opacity:0}}.fade-in{animation:fadeIn 2s ease-in-out}:root{--main-color:#ff6347;--secondary-color:#4caf50}.header{background-color:var(--main-color);text-align:center} \ No newline at end of file diff --git a/tests/Fixtures/assets/js/script.js b/tests/Fixtures/assets/js/script.js new file mode 100644 index 0000000..0b75f62 --- /dev/null +++ b/tests/Fixtures/assets/js/script.js @@ -0,0 +1,26 @@ +// This is a single-line comment explaining the file + +/** + * This is a multi-line comment explaining the + * purpose of this function, which demonstrates + * JavaScript's modern ES6+ features. + */ + +// Function declaration +function greet( person ) { + return "Hello, " + person + "!"; +} + +// Arrow function with implicit return +const greetArrow = ( person ) => `Hello, ${person}!`; // ES6 template literals + +/* + Calling functions to greet the user. + Both traditional and arrow function examples. +*/ +console.log( greet( name ) ); // Outputs: Hello, John Doe! +console.log( greetArrow( name ) ); // Outputs: Hello, John Doe! + +// Object destructuring +console.log( user.profile?.username ?? "Guest" ); // Outputs: jsCoder +console.log( user.address?.street ?? "No address provided" ); // Outputs: No address provided diff --git a/tests/Fixtures/assets/js/script.minified.js b/tests/Fixtures/assets/js/script.minified.js new file mode 100644 index 0000000..c917017 --- /dev/null +++ b/tests/Fixtures/assets/js/script.minified.js @@ -0,0 +1 @@ +function greet(e){return"Hello, "+e+"!"}const greetArrow=e=>`Hello, ${e}!`;console.log(greet(name)),console.log(greetArrow(name)),console.log(user.profile?.username??"Guest"),console.log(user.address?.street??"No address provided") \ No newline at end of file diff --git a/tests/Fixtures/bin/fakify b/tests/Fixtures/bin/fakify new file mode 100755 index 0000000..64546cc --- /dev/null +++ b/tests/Fixtures/bin/fakify @@ -0,0 +1,28 @@ +#!/usr/bin/env php +assertInstanceOf(SystemUtils::class, $releaseUtils); + } + + public function testMatchReturnsFalseWhenPlatformIsNull() + { + $releaseUtils = new SystemUtils(null, 'amd64'); + $this->assertFalse($releaseUtils->match('release')); + } + + public function testMatchReturnsFalseWhenArchitectureIsNull() + { + $releaseUtils = new SystemUtils('linux', null); + $this->assertFalse($releaseUtils->match('release')); + } + + public function testMatchReturnsTrueForMatchingPlatformAndArchitecture() + { + $releaseUtils = new SystemUtils('linux', 'amd64'); + $this->assertTrue($releaseUtils->match('linux-amd64')); + } + + public function testMatchReturnsFalseForNonMatchingPlatform() + { + $releaseUtils = new SystemUtils('linux', 'amd64'); + $this->assertFalse($releaseUtils->match('windows-amd64')); + } + + public function testMatchReturnsFalseForNonMatchingArchitecture() + { + $releaseUtils = new SystemUtils('linux', 'amd64'); + $this->assertFalse($releaseUtils->match('linux-arm64')); + } + +} diff --git a/tests/Minifier/TraceableMinifierTest.php b/tests/Minifier/TraceableMinifierTest.php new file mode 100644 index 0000000..244b1a8 --- /dev/null +++ b/tests/Minifier/TraceableMinifierTest.php @@ -0,0 +1,77 @@ +createMock(MinifierInterface::class); + $minifier->method('minify')->willReturn('minified content'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(2))->method('debug'); + + $traceableMinifier = new TraceableMinifier($minifier, $logger); + $traceableMinifier->minify('input content', 'css'); + } + + public function testMinifyLogsInfoMessage(): void + { + $minifier = $this->createMock(MinifierInterface::class); + $minifier->method('minify')->willReturn('minified content'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('info'); + + $traceableMinifier = new TraceableMinifier($minifier, $logger); + $traceableMinifier->minify('input content', 'css'); + } + + public function testMinifyReturnsMinifiedContent(): void + { + $minifier = $this->createMock(MinifierInterface::class); + $minifier->method('minify')->willReturn('minified content'); + + $traceableMinifier = new TraceableMinifier($minifier); + $this->assertSame('minified content', $traceableMinifier->minify('input content', 'css')); + } + + public function testMinifyHandlesEmptyInput(): void + { + $minifier = $this->createMock(MinifierInterface::class); + $minifier->method('minify')->willReturn(''); + + $traceableMinifier = new TraceableMinifier($minifier); + $this->assertSame('', $traceableMinifier->minify('', 'css')); + } + + public function testMinifyHandlesExceptionFromMinifier(): void + { + $this->expectException(\RuntimeException::class); + + $minifier = $this->createMock(MinifierInterface::class); + $minifier->method('minify')->willThrowException(new \RuntimeException('Minify error')); + + $traceableMinifier = new TraceableMinifier($minifier); + $traceableMinifier->minify('input content', 'css'); + } +} diff --git a/tests/MinifyTest.php b/tests/MinifyTest.php new file mode 100644 index 0000000..fa8f4a2 --- /dev/null +++ b/tests/MinifyTest.php @@ -0,0 +1,60 @@ +chmod(self::FIXTURES_BINARY_PATH, 0755); + } + + public function testMinifyReturnsOutputOnSuccess(): void + { + $minify = new Minify(self::FIXTURES_BINARY_PATH); + $input = file_get_contents(self::FIXTURES_PATH.'/assets/css/style.css'); + + $this->assertSame($input, $minify->minify($input, 'css')); + } + + public function testMinifyThrowsRuntimeExceptionOnProcessException(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Minify error 127: "Command not found".'); + + $minify = new Minify('foo'); + + $minify->minify('input content', 'foo'); + } + + public function testMinifyThrowsRuntimeExceptionOnProcessFailure(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Minify error 1: "General error".'); + + $minify = new Minify(self::FIXTURES_BINARY_PATH); + + $minify->minify('input content', 'foo'); + } +} diff --git a/tests/SensiolabsMinifyBundleTest.php b/tests/SensiolabsMinifyBundleTest.php new file mode 100644 index 0000000..e634564 --- /dev/null +++ b/tests/SensiolabsMinifyBundleTest.php @@ -0,0 +1,30 @@ +assertFalse(self::getContainer()->has(Minify::class)); + $this->assertTrue(self::getContainer()->has(MinifierInterface::class)); + } +}