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 @@
+
+
+
+
+[![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
+
+
+
+
+
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));
+ }
+}