Skip to content

Commit e71a3a1

Browse files
weaverryanfabpot
authored andcommitted
[Asset] [AssetMapper] New AssetMapper component: Map assets to publicly available, versioned paths
1 parent ff98eff commit e71a3a1

File tree

82 files changed

+4711
-2
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

82 files changed

+4711
-2
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
},
5959
"replace": {
6060
"symfony/asset": "self.version",
61+
"symfony/asset-mapper": "self.version",
6162
"symfony/browser-kit": "self.version",
6263
"symfony/cache": "self.version",
6364
"symfony/clock": "self.version",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Bridge\Twig\Extension;
13+
14+
use Twig\Extension\AbstractExtension;
15+
use Twig\TwigFunction;
16+
17+
/**
18+
* @author Kévin Dunglas <kevin@dunglas.dev>
19+
*/
20+
final class ImportMapExtension extends AbstractExtension
21+
{
22+
public function getFunctions(): array
23+
{
24+
return [
25+
new TwigFunction('importmap', [ImportMapRuntime::class, 'importmap'], ['is_safe' => ['html']]),
26+
];
27+
}
28+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Bridge\Twig\Extension;
13+
14+
use Symfony\Component\AssetMapper\ImportMap\ImportMapRenderer;
15+
16+
/**
17+
* @author Kévin Dunglas <kevin@dunglas.dev>
18+
*/
19+
class ImportMapRuntime
20+
{
21+
public function __construct(private readonly ImportMapRenderer $importMapRenderer)
22+
{
23+
}
24+
25+
public function importmap(?string $entryPoint = 'app'): string
26+
{
27+
return $this->importMapRenderer->render($entryPoint);
28+
}
29+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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 Extension;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Bridge\Twig\Extension\ImportMapExtension;
16+
use Symfony\Bridge\Twig\Extension\ImportMapRuntime;
17+
use Symfony\Component\AssetMapper\ImportMap\ImportMapRenderer;
18+
use Twig\Environment;
19+
use Twig\Loader\ArrayLoader;
20+
use Twig\RuntimeLoader\RuntimeLoaderInterface;
21+
22+
class ImportMapExtensionTest extends TestCase
23+
{
24+
public function testItRendersTheImportmap()
25+
{
26+
$twig = new Environment(new ArrayLoader([
27+
'template' => '{{ importmap("application") }}',
28+
]), ['debug' => true, 'cache' => false, 'autoescape' => 'html', 'optimizations' => 0]);
29+
$twig->addExtension(new ImportMapExtension());
30+
$importMapRenderer = $this->createMock(ImportMapRenderer::class);
31+
$expected = '<script type="importmap">{ "imports": {}}</script>';
32+
$importMapRenderer->expects($this->once())
33+
->method('render')
34+
->with('application')
35+
->willReturn($expected);
36+
$runtime = new ImportMapRuntime($importMapRenderer);
37+
38+
$mockRuntimeLoader = $this->createMock(RuntimeLoaderInterface::class);
39+
$mockRuntimeLoader
40+
->method('load')
41+
->willReturnMap([
42+
[ImportMapRuntime::class, $runtime],
43+
])
44+
;
45+
$twig->addRuntimeLoader($mockRuntimeLoader);
46+
47+
$this->assertSame($expected, $twig->render('template'));
48+
}
49+
}

src/Symfony/Bridge/Twig/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"league/html-to-markdown": "^5.0",
2727
"phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
2828
"symfony/asset": "^5.4|^6.0",
29+
"symfony/asset-mapper": "^6.3",
2930
"symfony/dependency-injection": "^5.4|^6.0",
3031
"symfony/finder": "^5.4|^6.0",
3132
"symfony/form": "^6.3",

src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Psr\Container\ContainerInterface;
1515
use Psr\Link\EvolvableLinkInterface;
1616
use Psr\Link\LinkInterface;
17+
use Symfony\Component\AssetMapper\ImportMap\ImportMapManager;
1718
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
1819
use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface;
1920
use Symfony\Component\Form\Extension\Core\Type\FormType;
@@ -44,6 +45,7 @@
4445
use Symfony\Component\WebLink\EventListener\AddLinkHeaderListener;
4546
use Symfony\Component\WebLink\GenericLinkProvider;
4647
use Symfony\Component\WebLink\HttpHeaderSerializer;
48+
use Symfony\Component\WebLink\Link;
4749
use Symfony\Contracts\Service\Attribute\Required;
4850
use Symfony\Contracts\Service\ServiceSubscriberInterface;
4951
use Twig\Environment;
@@ -95,6 +97,7 @@ public static function getSubscribedServices(): array
9597
'security.csrf.token_manager' => '?'.CsrfTokenManagerInterface::class,
9698
'parameter_bag' => '?'.ContainerBagInterface::class,
9799
'web_link.http_header_serializer' => '?'.HttpHeaderSerializer::class,
100+
'asset_mapper.importmap.manager' => '?'.ImportMapManager::class,
98101
];
99102
}
100103

@@ -409,7 +412,7 @@ protected function addLink(Request $request, LinkInterface $link): void
409412
/**
410413
* @param LinkInterface[] $links
411414
*/
412-
protected function sendEarlyHints(iterable $links, Response $response = null): Response
415+
protected function sendEarlyHints(iterable $links = [], Response $response = null, bool $preloadJavaScriptModules = false): Response
413416
{
414417
if (!$this->container->has('web_link.http_header_serializer')) {
415418
throw new \LogicException('You cannot use the "sendEarlyHints" method if the WebLink component is not available. Try running "composer require symfony/web-link".');
@@ -418,6 +421,17 @@ protected function sendEarlyHints(iterable $links, Response $response = null): R
418421
$response ??= new Response();
419422

420423
$populatedLinks = [];
424+
425+
if ($preloadJavaScriptModules) {
426+
if (!$this->container->has('asset_mapper.importmap.manager')) {
427+
throw new \LogicException('You cannot use the JavaScript modules method if the AssetMapper component is not available. Try running "composer require symfony/asset-mapper".');
428+
}
429+
430+
foreach ($this->container->get('asset_mapper.importmap.manager')->getModulesToPreload() as $url) {
431+
$populatedLinks[] = new Link('modulepreload', $url);
432+
}
433+
}
434+
421435
foreach ($links as $link) {
422436
if ($link instanceof EvolvableLinkInterface && !$link->getRels()) {
423437
$link = $link->withRel('preload');

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class UnusedTagsPass implements CompilerPassInterface
2424
private const KNOWN_TAGS = [
2525
'annotations.cached_reader',
2626
'assets.package',
27+
'asset_mapper.compiler',
2728
'auto_alias',
2829
'cache.pool',
2930
'cache.pool.clearer',

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
use Psr\Log\LogLevel;
1717
use Symfony\Bundle\FullStack;
1818
use Symfony\Component\Asset\Package;
19+
use Symfony\Component\AssetMapper\AssetMapper;
20+
use Symfony\Component\AssetMapper\ImportMap\ImportMapManager;
1921
use Symfony\Component\Cache\Adapter\DoctrineAdapter;
2022
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
2123
use Symfony\Component\Config\Definition\Builder\NodeBuilder;
@@ -161,6 +163,7 @@ public function getConfigTreeBuilder(): TreeBuilder
161163
$this->addSessionSection($rootNode);
162164
$this->addRequestSection($rootNode);
163165
$this->addAssetsSection($rootNode, $enableIfStandalone);
166+
$this->addAssetMapperSection($rootNode, $enableIfStandalone);
164167
$this->addTranslatorSection($rootNode, $enableIfStandalone);
165168
$this->addValidationSection($rootNode, $enableIfStandalone);
166169
$this->addAnnotationsSection($rootNode, $willBeAvailable);
@@ -810,6 +813,97 @@ private function addAssetsSection(ArrayNodeDefinition $rootNode, callable $enabl
810813
;
811814
}
812815

816+
private function addAssetMapperSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone): void
817+
{
818+
$rootNode
819+
->children()
820+
->arrayNode('asset_mapper')
821+
->info('Asset Mapper configuration')
822+
->{$enableIfStandalone('symfony/asset-mapper', AssetMapper::class)}()
823+
->fixXmlConfig('path')
824+
->fixXmlConfig('extension')
825+
->fixXmlConfig('importmap_script_attribute')
826+
->children()
827+
// add array node called "paths" that will be an array of strings
828+
->arrayNode('paths')
829+
->info('Directories that hold assets that should be in the mapper. Can be a simple array of an array of ["path/to/assets": "namespace"]')
830+
->example(['assets/'])
831+
->normalizeKeys(false)
832+
->useAttributeAsKey('namespace')
833+
->beforeNormalization()
834+
->always()
835+
->then(function ($v) {
836+
$result = [];
837+
foreach ($v as $key => $item) {
838+
// "dir" => "namespace"
839+
if (\is_string($key)) {
840+
$result[$key] = $item;
841+
842+
continue;
843+
}
844+
845+
if (\is_array($item)) {
846+
// $item = ["namespace" => "the/namespace", "value" => "the/dir"]
847+
$result[$item['value']] = $item['namespace'] ?? '';
848+
} else {
849+
// $item = "the/dir"
850+
$result[$item] = '';
851+
}
852+
}
853+
854+
return $result;
855+
})
856+
->end()
857+
->prototype('scalar')->end()
858+
->end()
859+
->booleanNode('server')
860+
->info('If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default)')
861+
->defaultValue($this->debug)
862+
->end()
863+
->scalarNode('public_prefix')
864+
->info('The public path where the assets will be written to (and served from when "server" is true)')
865+
->defaultValue('/assets/')
866+
->end()
867+
->booleanNode('strict_mode')
868+
->info('If true, an exception will be thrown if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import \'./non-existent.js\'"')
869+
->defaultValue(true)
870+
->end()
871+
->arrayNode('extensions')
872+
->info('Key-value pair of file extensions set to their mime type.')
873+
->normalizeKeys(false)
874+
->useAttributeAsKey('extension')
875+
->example(['.zip' => 'application/zip'])
876+
->prototype('scalar')->end()
877+
->end()
878+
->scalarNode('importmap_path')
879+
->info('The path of the importmap.php file.')
880+
->defaultValue('%kernel.project_dir%/importmap.php')
881+
->end()
882+
->scalarNode('importmap_polyfill')
883+
->info('URL of the ES Module Polyfill to use, false to disable. Defaults to using a CDN URL.')
884+
->defaultValue(null)
885+
->end()
886+
->arrayNode('importmap_script_attributes')
887+
->info('Key-value pair of attributes to add to script tags output for the importmap.')
888+
->normalizeKeys(false)
889+
->useAttributeAsKey('key')
890+
->example(['data-turbo-track' => 'reload'])
891+
->prototype('scalar')->end()
892+
->end()
893+
->scalarNode('vendor_dir')
894+
->info('The directory to store JavaScript vendors.')
895+
->defaultValue('%kernel.project_dir%/assets/vendor')
896+
->end()
897+
->scalarNode('provider')
898+
->info('The provider (CDN) to use', class_exists(ImportMapManager::class) ? sprintf(' (e.g.: "%s").', implode('", "', ImportMapManager::PROVIDERS)) : '.')
899+
->defaultValue('jspm')
900+
->end()
901+
->end()
902+
->end()
903+
->end()
904+
;
905+
}
906+
813907
private function addTranslatorSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone): void
814908
{
815909
$rootNode

0 commit comments

Comments
 (0)