Skip to content

Commit 5fa3e92

Browse files
dunglasfabpot
authored andcommitted
[AssetMapper] add support for assets pre-compression
1 parent 73d4904 commit 5fa3e92

24 files changed

+858
-2
lines changed

.github/workflows/unit-tests.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ jobs:
3333
mode: low-deps
3434
- php: '8.3'
3535
- php: '8.4'
36+
# brotli and zstd extensions are optional, when not present the commands will be used instead,
37+
# we must test both scenarios
38+
extensions: amqp,apcu,brotli,igbinary,intl,mbstring,memcached,redis,relay,zstd
3639
#mode: experimental
3740
fail-fast: false
3841

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

59+
- name: Install optional commands
60+
if: matrix.php == '8.4'
61+
run: |
62+
sudo apt-get update
63+
sudo apt-get install zopfli
64+
5665
- name: Configure environment
5766
run: |
5867
git config --global user.email ""

src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.3
5+
---
6+
7+
* Add support for assets pre-compression
8+
49
7.2
510
---
611

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\Bundle\FullStack;
1818
use Symfony\Component\Asset\Package;
1919
use Symfony\Component\AssetMapper\AssetMapper;
20+
use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
2021
use Symfony\Component\Cache\Adapter\DoctrineAdapter;
2122
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
2223
use Symfony\Component\Config\Definition\Builder\NodeBuilder;
@@ -924,6 +925,29 @@ private function addAssetMapperSection(ArrayNodeDefinition $rootNode, callable $
924925
->info('The directory to store JavaScript vendors.')
925926
->defaultValue('%kernel.project_dir%/assets/vendor')
926927
->end()
928+
->arrayNode('precompress')
929+
->info('Precompress assets with Brotli, Zstandard and gzip.')
930+
->canBeEnabled()
931+
->fixXmlConfig('format')
932+
->fixXmlConfig('extension')
933+
->children()
934+
->arrayNode('formats')
935+
->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.')
936+
->prototype('scalar')->end()
937+
->performNoDeepMerging()
938+
->validate()
939+
->ifTrue(static fn (array $v) => array_diff($v, ['brotli', 'zstandard', 'gzip']))
940+
->thenInvalid('Unsupported format: "brotli", "zstandard" and "gzip" are supported.')
941+
->end()
942+
->end()
943+
->arrayNode('extensions')
944+
->info('Array of extensions to compress. The entire list must be provided, no merging occurs.')
945+
->prototype('scalar')->end()
946+
->performNoDeepMerging()
947+
->defaultValue(interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [])
948+
->end()
949+
->end()
950+
->end()
927951
->end()
928952
->end()
929953
->end()

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
use Symfony\Component\Asset\PackageInterface;
3333
use Symfony\Component\AssetMapper\AssetMapper;
3434
use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface;
35+
use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
3536
use Symfony\Component\BrowserKit\AbstractBrowser;
3637
use Symfony\Component\Cache\Adapter\AdapterInterface;
3738
use Symfony\Component\Cache\Adapter\ArrayAdapter;
@@ -1372,6 +1373,26 @@ private function registerAssetMapperConfiguration(array $config, ContainerBuilde
13721373
->replaceArgument(3, $config['importmap_polyfill'])
13731374
->replaceArgument(4, $config['importmap_script_attributes'])
13741375
;
1376+
1377+
if (interface_exists(CompressorInterface::class)) {
1378+
$compressors = [];
1379+
foreach ($config['precompress']['formats'] as $format) {
1380+
$compressors[$format] = new Reference("asset_mapper.compressor.$format");
1381+
}
1382+
1383+
$container->getDefinition('asset_mapper.compressor')->replaceArgument(0, $compressors ?: null);
1384+
1385+
if ($config['precompress']['enabled']) {
1386+
$container
1387+
->getDefinition('asset_mapper.local_public_assets_filesystem')
1388+
->addArgument(new Reference('asset_mapper.compressor'))
1389+
->addArgument($config['precompress']['extensions'])
1390+
;
1391+
}
1392+
} else {
1393+
$container->removeDefinition('asset_mapper.compressor');
1394+
$container->removeDefinition('asset_mapper.assets.command.compress');
1395+
}
13751396
}
13761397

13771398
/**

src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\Component\AssetMapper\AssetMapperInterface;
1818
use Symfony\Component\AssetMapper\AssetMapperRepository;
1919
use Symfony\Component\AssetMapper\Command\AssetMapperCompileCommand;
20+
use Symfony\Component\AssetMapper\Command\CompressAssetsCommand;
2021
use Symfony\Component\AssetMapper\Command\DebugAssetMapperCommand;
2122
use Symfony\Component\AssetMapper\Command\ImportMapAuditCommand;
2223
use Symfony\Component\AssetMapper\Command\ImportMapInstallCommand;
@@ -28,6 +29,11 @@
2829
use Symfony\Component\AssetMapper\Compiler\CssAssetUrlCompiler;
2930
use Symfony\Component\AssetMapper\Compiler\JavaScriptImportPathCompiler;
3031
use Symfony\Component\AssetMapper\Compiler\SourceMappingUrlsCompiler;
32+
use Symfony\Component\AssetMapper\Compressor\BrotliCompressor;
33+
use Symfony\Component\AssetMapper\Compressor\ChainCompressor;
34+
use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
35+
use Symfony\Component\AssetMapper\Compressor\GzipCompressor;
36+
use Symfony\Component\AssetMapper\Compressor\ZstandardCompressor;
3137
use Symfony\Component\AssetMapper\Factory\CachedMappedAssetFactory;
3238
use Symfony\Component\AssetMapper\Factory\MappedAssetFactory;
3339
use Symfony\Component\AssetMapper\ImportMap\ImportMapAuditor;
@@ -254,5 +260,20 @@
254260
->set('asset_mapper.importmap.command.outdated', ImportMapOutdatedCommand::class)
255261
->args([service('asset_mapper.importmap.update_checker')])
256262
->tag('console.command')
263+
264+
->set('asset_mapper.compressor.brotli', BrotliCompressor::class)
265+
->set('asset_mapper.compressor.zstandard', ZstandardCompressor::class)
266+
->set('asset_mapper.compressor.gzip', GzipCompressor::class)
267+
268+
->set('asset_mapper.compressor', ChainCompressor::class)
269+
->args([
270+
abstract_arg('compressor'),
271+
service('logger'),
272+
])
273+
->alias(CompressorInterface::class, 'asset_mapper.compressor')
274+
275+
->set('asset_mapper.assets.command.compress', CompressAssetsCommand::class)
276+
->args([service('asset_mapper.compressor')])
277+
->tag('console.command')
257278
;
258279
};

src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@
206206
<xsd:element name="excluded-pattern" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
207207
<xsd:element name="extension" type="asset_mapper_extension" minOccurs="0" maxOccurs="unbounded" />
208208
<xsd:element name="importmap-script-attribute" type="asset_mapper_attribute" minOccurs="0" maxOccurs="unbounded" />
209+
<xsd:element name="precompress" type="asset_mapper_precompress" minOccurs="0" maxOccurs="1" />
209210
</xsd:sequence>
210211
<xsd:attribute name="enabled" type="xsd:boolean" />
211212
<xsd:attribute name="exclude-dotfiles" type="xsd:boolean" />
@@ -230,6 +231,16 @@
230231
<xsd:attribute name="key" type="xsd:string" use="required" />
231232
</xsd:complexType>
232233

234+
<xsd:complexType name="asset_mapper_precompress">
235+
<xsd:sequence>
236+
<xsd:element name="format" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
237+
<xsd:element name="extension" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
238+
</xsd:sequence>
239+
240+
<xsd:attribute name="enabled" type="xsd:boolean" />
241+
</xsd:complexType>
242+
243+
233244
<xsd:simpleType name="missing-import-mode">
234245
<xsd:restriction base="xsd:string">
235246
<xsd:enumeration value="strict" />

src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use PHPUnit\Framework\TestCase;
1616
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Configuration;
1717
use Symfony\Bundle\FullStack;
18+
use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
1819
use Symfony\Component\Cache\Adapter\DoctrineAdapter;
1920
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
2021
use Symfony\Component\Config\Definition\Processor;
@@ -141,6 +142,11 @@ public function testAssetMapperCanBeEnabled()
141142
'vendor_dir' => '%kernel.project_dir%/assets/vendor',
142143
'importmap_script_attributes' => [],
143144
'exclude_dotfiles' => true,
145+
'precompress' => [
146+
'enabled' => false,
147+
'formats' => [],
148+
'extensions' => interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [],
149+
],
144150
];
145151

146152
$this->assertEquals($defaultConfig, $config['asset_mapper']);
@@ -847,6 +853,11 @@ protected static function getBundleDefaultConfig()
847853
'vendor_dir' => '%kernel.project_dir%/assets/vendor',
848854
'importmap_script_attributes' => [],
849855
'exclude_dotfiles' => true,
856+
'precompress' => [
857+
'enabled' => false,
858+
'formats' => [],
859+
'extensions' => interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [],
860+
],
850861
],
851862
'cache' => [
852863
'pools' => [],

src/Symfony/Component/AssetMapper/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.3
5+
---
6+
7+
* Add support for pre-compressing assets with Brotli, Zstandard, Zopfli, and gzip
8+
49
7.2
510
---
611

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper\Command;
13+
14+
use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
15+
use Symfony\Component\Console\Attribute\AsCommand;
16+
use Symfony\Component\Console\Command\Command;
17+
use Symfony\Component\Console\Input\InputArgument;
18+
use Symfony\Component\Console\Input\InputInterface;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
use Symfony\Component\Console\Style\SymfonyStyle;
21+
22+
/**
23+
* Pre-compresses files to serve through a web server.
24+
*
25+
* @author Kévin Dunglas <[email protected]>
26+
*/
27+
#[AsCommand(name: 'assets:compress', description: 'Pre-compresses files to serve through a web server')]
28+
final class CompressAssetsCommand extends Command
29+
{
30+
public function __construct(
31+
private readonly CompressorInterface $compressor,
32+
) {
33+
parent::__construct();
34+
}
35+
36+
protected function configure(): void
37+
{
38+
$this
39+
->addArgument('paths', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'The files to compress')
40+
->setHelp(<<<'EOT'
41+
The <info>%command.name%</info> command compresses the given file in Brotli, Zstandard and gzip formats.
42+
This is especially useful to serve pre-compressed files through a web server.
43+
44+
The existing file will be kept. The compressed files will be created in the same directory.
45+
The extension of the compression format will be appended to the original file name.
46+
EOT
47+
);
48+
}
49+
50+
protected function execute(InputInterface $input, OutputInterface $output): int
51+
{
52+
$io = new SymfonyStyle($input, $output);
53+
54+
$paths = $input->getArgument('paths');
55+
foreach ($paths as $path) {
56+
$this->compressor->compress($path);
57+
}
58+
59+
$io->success(\sprintf('File%s compressed successfully.', \count($paths) > 1 ? 's' : ''));
60+
61+
return Command::SUCCESS;
62+
}
63+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper\Compressor;
13+
14+
use Symfony\Component\Process\Process;
15+
16+
/**
17+
* Compresses a file using Brotli.
18+
*
19+
* @author Kévin Dunglas <[email protected]>
20+
*/
21+
final class BrotliCompressor implements SupportedCompressorInterface
22+
{
23+
use CompressorTrait;
24+
25+
private const WRAPPER = 'compress.brotli';
26+
private const COMMAND = 'brotli';
27+
private const PHP_EXTENSION = 'brotli';
28+
private const FILE_EXTENSION = 'br';
29+
30+
public function __construct(
31+
?string $executable = null,
32+
) {
33+
$this->executable = $executable;
34+
}
35+
36+
/**
37+
* @return resource
38+
*/
39+
private function createStreamContext()
40+
{
41+
return stream_context_create(['brotli' => ['level' => BROTLI_COMPRESS_LEVEL_MAX]]);
42+
}
43+
44+
private function compressWithBinary(string $path): void
45+
{
46+
(new Process([$this->executable, '--best', '--force', "--output=$path.".self::FILE_EXTENSION, '--', $path]))->mustRun();
47+
}
48+
}

0 commit comments

Comments
 (0)