Skip to content

[AssetMapper] add support for assets pre-compression #59020

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ jobs:
mode: low-deps
- php: '8.3'
- php: '8.4'
# brotli and zstd extensions are optional, when not present the commands will be used instead,
# we must test both scenarios
extensions: amqp,apcu,brotli,igbinary,intl,mbstring,memcached,redis,relay,zstd
#mode: experimental
fail-fast: false

Expand All @@ -53,6 +56,12 @@ jobs:
extensions: "${{ matrix.extensions || env.extensions }}"
tools: flex

- name: Install optional commands
if: matrix.php == '8.4'
run: |
sudo apt-get update
sudo apt-get install zopfli

- name: Configure environment
run: |
git config --global user.email ""
Expand Down
5 changes: 5 additions & 0 deletions src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

7.3
---

* Add support for assets pre-compression

7.2
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Symfony\Bundle\FullStack;
use Symfony\Component\Asset\Package;
use Symfony\Component\AssetMapper\AssetMapper;
use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
use Symfony\Component\Cache\Adapter\DoctrineAdapter;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\NodeBuilder;
Expand Down Expand Up @@ -924,6 +925,29 @@ private function addAssetMapperSection(ArrayNodeDefinition $rootNode, callable $
->info('The directory to store JavaScript vendors.')
->defaultValue('%kernel.project_dir%/assets/vendor')
->end()
->arrayNode('precompress')
->info('Precompress assets with Brotli, Zstandard and gzip.')
->canBeEnabled()
->fixXmlConfig('format')
->fixXmlConfig('extension')
->children()
->arrayNode('formats')
->info('Array of formats to enable. "brotli", "zstandard" and "gzip" are supported. Defaults to all formats supported by the system. The entire list must be provided.')
->prototype('scalar')->end()
->performNoDeepMerging()
->validate()
->ifTrue(static fn (array $v) => array_diff($v, ['brotli', 'zstandard', 'gzip']))
->thenInvalid('Unsupported format: "brotli", "zstandard" and "gzip" are supported.')
->end()
->end()
->arrayNode('extensions')
->info('Array of extensions to compress. The entire list must be provided, no merging occurs.')
->prototype('scalar')->end()
->performNoDeepMerging()
->defaultValue(interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [])
->end()
->end()
->end()
->end()
->end()
->end()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
use Symfony\Component\Asset\PackageInterface;
use Symfony\Component\AssetMapper\AssetMapper;
use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface;
use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
use Symfony\Component\BrowserKit\AbstractBrowser;
use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
Expand Down Expand Up @@ -1372,6 +1373,26 @@ private function registerAssetMapperConfiguration(array $config, ContainerBuilde
->replaceArgument(3, $config['importmap_polyfill'])
->replaceArgument(4, $config['importmap_script_attributes'])
;

if (interface_exists(CompressorInterface::class)) {
$compressors = [];
foreach ($config['precompress']['formats'] as $format) {
$compressors[$format] = new Reference("asset_mapper.compressor.$format");
}

$container->getDefinition('asset_mapper.compressor')->replaceArgument(0, $compressors ?: null);

if ($config['precompress']['enabled']) {
$container
->getDefinition('asset_mapper.local_public_assets_filesystem')
->addArgument(new Reference('asset_mapper.compressor'))
->addArgument($config['precompress']['extensions'])
;
}
} else {
$container->removeDefinition('asset_mapper.compressor');
$container->removeDefinition('asset_mapper.assets.command.compress');
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Symfony\Component\AssetMapper\AssetMapperInterface;
use Symfony\Component\AssetMapper\AssetMapperRepository;
use Symfony\Component\AssetMapper\Command\AssetMapperCompileCommand;
use Symfony\Component\AssetMapper\Command\CompressAssetsCommand;
use Symfony\Component\AssetMapper\Command\DebugAssetMapperCommand;
use Symfony\Component\AssetMapper\Command\ImportMapAuditCommand;
use Symfony\Component\AssetMapper\Command\ImportMapInstallCommand;
Expand All @@ -28,6 +29,11 @@
use Symfony\Component\AssetMapper\Compiler\CssAssetUrlCompiler;
use Symfony\Component\AssetMapper\Compiler\JavaScriptImportPathCompiler;
use Symfony\Component\AssetMapper\Compiler\SourceMappingUrlsCompiler;
use Symfony\Component\AssetMapper\Compressor\BrotliCompressor;
use Symfony\Component\AssetMapper\Compressor\ChainCompressor;
use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
use Symfony\Component\AssetMapper\Compressor\GzipCompressor;
use Symfony\Component\AssetMapper\Compressor\ZstandardCompressor;
use Symfony\Component\AssetMapper\Factory\CachedMappedAssetFactory;
use Symfony\Component\AssetMapper\Factory\MappedAssetFactory;
use Symfony\Component\AssetMapper\ImportMap\ImportMapAuditor;
Expand Down Expand Up @@ -254,5 +260,20 @@
->set('asset_mapper.importmap.command.outdated', ImportMapOutdatedCommand::class)
->args([service('asset_mapper.importmap.update_checker')])
->tag('console.command')

->set('asset_mapper.compressor.brotli', BrotliCompressor::class)
->set('asset_mapper.compressor.zstandard', ZstandardCompressor::class)
->set('asset_mapper.compressor.gzip', GzipCompressor::class)

->set('asset_mapper.compressor', ChainCompressor::class)
->args([
abstract_arg('compressor'),
service('logger'),
])
->alias(CompressorInterface::class, 'asset_mapper.compressor')

->set('asset_mapper.assets.command.compress', CompressAssetsCommand::class)
->args([service('asset_mapper.compressor')])
->tag('console.command')
;
};
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@
<xsd:element name="excluded-pattern" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="extension" type="asset_mapper_extension" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="importmap-script-attribute" type="asset_mapper_attribute" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="precompress" type="asset_mapper_precompress" minOccurs="0" maxOccurs="1" />
</xsd:sequence>
<xsd:attribute name="enabled" type="xsd:boolean" />
<xsd:attribute name="exclude-dotfiles" type="xsd:boolean" />
Expand All @@ -230,6 +231,16 @@
<xsd:attribute name="key" type="xsd:string" use="required" />
</xsd:complexType>

<xsd:complexType name="asset_mapper_precompress">
<xsd:sequence>
<xsd:element name="format" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="extension" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
</xsd:sequence>

<xsd:attribute name="enabled" type="xsd:boolean" />
</xsd:complexType>


<xsd:simpleType name="missing-import-mode">
<xsd:restriction base="xsd:string">
<xsd:enumeration value="strict" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Configuration;
use Symfony\Bundle\FullStack;
use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
use Symfony\Component\Cache\Adapter\DoctrineAdapter;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\Config\Definition\Processor;
Expand Down Expand Up @@ -141,6 +142,11 @@ public function testAssetMapperCanBeEnabled()
'vendor_dir' => '%kernel.project_dir%/assets/vendor',
'importmap_script_attributes' => [],
'exclude_dotfiles' => true,
'precompress' => [
'enabled' => false,
'formats' => [],
'extensions' => interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [],
],
];

$this->assertEquals($defaultConfig, $config['asset_mapper']);
Expand Down Expand Up @@ -847,6 +853,11 @@ protected static function getBundleDefaultConfig()
'vendor_dir' => '%kernel.project_dir%/assets/vendor',
'importmap_script_attributes' => [],
'exclude_dotfiles' => true,
'precompress' => [
'enabled' => false,
'formats' => [],
'extensions' => interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [],
],
],
'cache' => [
'pools' => [],
Expand Down
5 changes: 5 additions & 0 deletions src/Symfony/Component/AssetMapper/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

7.3
---

* Add support for pre-compressing assets with Brotli, Zstandard, Zopfli, and gzip

7.2
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\AssetMapper\Command;

use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

/**
* Pre-compresses files to serve through a web server.
*
* @author Kévin Dunglas <kevin@dunglas.dev>
*/
#[AsCommand(name: 'assets:compress', description: 'Pre-compresses files to serve through a web server')]
final class CompressAssetsCommand extends Command
{
public function __construct(
private readonly CompressorInterface $compressor,
) {
parent::__construct();
}

protected function configure(): void
{
$this
->addArgument('paths', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'The files to compress')
->setHelp(<<<'EOT'
The <info>%command.name%</info> command compresses the given file in Brotli, Zstandard and gzip formats.
This is especially useful to serve pre-compressed files through a web server.

The existing file will be kept. The compressed files will be created in the same directory.
The extension of the compression format will be appended to the original file name.
EOT
);
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);

$paths = $input->getArgument('paths');
foreach ($paths as $path) {
$this->compressor->compress($path);
}

$io->success(\sprintf('File%s compressed successfully.', \count($paths) > 1 ? 's' : ''));

return Command::SUCCESS;
}
}
48 changes: 48 additions & 0 deletions src/Symfony/Component/AssetMapper/Compressor/BrotliCompressor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\AssetMapper\Compressor;

use Symfony\Component\Process\Process;

/**
* Compresses a file using Brotli.
*
* @author Kévin Dunglas <kevin@dunglas.dev>
*/
final class BrotliCompressor implements SupportedCompressorInterface
{
use CompressorTrait;

private const WRAPPER = 'compress.brotli';
private const COMMAND = 'brotli';
private const PHP_EXTENSION = 'brotli';
private const FILE_EXTENSION = 'br';

public function __construct(
?string $executable = null,
) {
$this->executable = $executable;
}

/**
* @return resource
*/
private function createStreamContext()
{
return stream_context_create(['brotli' => ['level' => BROTLI_COMPRESS_LEVEL_MAX]]);
}

private function compressWithBinary(string $path): void
{
(new Process([$this->executable, '--best', '--force', "--output=$path.".self::FILE_EXTENSION, '--', $path]))->mustRun();
}
}
50 changes: 50 additions & 0 deletions src/Symfony/Component/AssetMapper/Compressor/ChainCompressor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\AssetMapper\Compressor;

use Psr\Log\LoggerInterface;

/**
* Calls multiple compressors in a chain.
*
* @author Kévin Dunglas <kevin@dunglas.dev>
*/
final class ChainCompressor implements CompressorInterface
{
/**
* @param CompressorInterface[] $compressors
*/
public function __construct(
private ?array $compressors = null,
private readonly ?LoggerInterface $logger = null,
) {
}

public function compress(string $path): void
{
if (null === $this->compressors) {
$this->compressors = [];
foreach ([new BrotliCompressor(), new ZstandardCompressor(), new GzipCompressor()] as $compressor) {
$unsupportedReason = $compressor->getUnsupportedReason();
if (null === $unsupportedReason) {
$this->compressors[] = $compressor;
} else {
$this->logger?->warning($unsupportedReason);
}
}
}

foreach ($this->compressors as $compressor) {
$compressor->compress($path);
}
}
}
Loading
Loading