Skip to content

Commit 71fbd0b

Browse files
weaverryandunglas
andcommitted
Introducing AssetMapper component with importmap support
Co-authored-by: Kévin Dunglas <dunglas@gmail.com>
1 parent e3d251f commit 71fbd0b

File tree

78 files changed

+4442
-1
lines changed

Some content is hidden

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

78 files changed

+4442
-1
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: 63 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,66 @@ 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('importmap_script_attribute')
825+
->children()
826+
// add array node called "paths" that will be an array of strings
827+
->arrayNode('paths')
828+
->info('Directories that hold assets that should be in the mapper')
829+
->example(['assets/'])
830+
->beforeNormalization()
831+
->castToArray()
832+
->ifTrue(function ($v) { return isset($v[0]); })
833+
// change indexed array to one where each value
834+
// becomes the key and all values are ''
835+
->then(function ($v) { return array_combine($v, array_fill(0, \count($v), '')); })
836+
->end()
837+
->prototype('scalar')->end()
838+
->end()
839+
->booleanNode('server')
840+
->info('If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default)')
841+
->defaultValue($this->debug)
842+
->end()
843+
->scalarNode('public_prefix')
844+
->info('The public path where the assets will be written to (and served from when "server" is true)')
845+
->defaultValue('/assets/')
846+
->end()
847+
->scalarNode('importmap_path')
848+
->info('The path of the importmap.php file.')
849+
->defaultValue('%kernel.project_dir%/importmap.php')
850+
->end()
851+
->scalarNode('importmap_polyfill')
852+
->info('URL of the ES Module Polyfill to use, false to disable. Defaults to using a CDN URL.')
853+
->defaultValue(null)
854+
->end()
855+
->arrayNode('importmap_script_attributes')
856+
->info('Key-value pair of attributes to add to script tags output for the importmap.')
857+
->normalizeKeys(false)
858+
->useAttributeAsKey('name')
859+
->example(['data-turbo-track' => 'reload'])
860+
->prototype('scalar')->end()
861+
->end()
862+
->scalarNode('vendor_dir')
863+
->info('The directory to store JavaScript vendors.')
864+
->defaultValue('%kernel.project_dir%/assets/vendor')
865+
->end()
866+
->scalarNode('provider')
867+
->info('The provider (CDN) to use', class_exists(ImportMapManager::class) ? sprintf(' (e.g.: "%s").', implode('", "', ImportMapManager::PROVIDERS)) : '.')
868+
->defaultValue('jspm')
869+
->end()
870+
->end()
871+
->end()
872+
->end()
873+
;
874+
}
875+
813876
private function addTranslatorSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone): void
814877
{
815878
$rootNode

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
use Symfony\Bundle\FullStack;
3232
use Symfony\Bundle\MercureBundle\MercureBundle;
3333
use Symfony\Component\Asset\PackageInterface;
34+
use Symfony\Component\AssetMapper\AssetMapper;
35+
use Symfony\Component\AssetMapper\ImportMap\ImportMapManager;
3436
use Symfony\Component\BrowserKit\AbstractBrowser;
3537
use Symfony\Component\Cache\Adapter\AdapterInterface;
3638
use Symfony\Component\Cache\Adapter\ArrayAdapter;
@@ -330,6 +332,14 @@ public function load(array $configs, ContainerBuilder $container)
330332
$this->registerAssetsConfiguration($config['assets'], $container, $loader);
331333
}
332334

335+
if ($this->readConfigEnabled('asset_mapper', $container, $config['asset_mapper'])) {
336+
if (!class_exists(AssetMapper::class)) {
337+
throw new LogicException('AssetMapper support cannot be enabled as the AssetMapper component is not installed. Try running "composer require symfony/asset-mapper".');
338+
}
339+
340+
$this->registerAssetMapperConfiguration($config['asset_mapper'], $container, $loader, $this->readConfigEnabled('assets', $container, $config['assets']));
341+
}
342+
333343
if ($this->readConfigEnabled('http_client', $container, $config['http_client'])) {
334344
$this->registerHttpClientConfiguration($config['http_client'], $container, $loader);
335345
}
@@ -1231,6 +1241,52 @@ private function registerAssetsConfiguration(array $config, ContainerBuilder $co
12311241
}
12321242
}
12331243

1244+
private function registerAssetMapperConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $assetEnabled): void
1245+
{
1246+
$loader->load('asset_mapper.php');
1247+
1248+
if (!$assetEnabled) {
1249+
$container->removeDefinition('asset_mapper.asset_package');
1250+
1251+
return;
1252+
}
1253+
1254+
$publicDirName = $this->getPublicDirectoryName((string) $container->getParameter('kernel.project_dir'));
1255+
$container->getDefinition('asset_mapper')
1256+
->setArgument(3, $config['public_prefix'])
1257+
->setArgument(4, $publicDirName)
1258+
;
1259+
1260+
$paths = $config['paths'];
1261+
foreach ($container->getParameter('kernel.bundles_metadata') as $name => $bundle) {
1262+
if ($container->fileExists($dir = $bundle['path'].'/Resources/public') || $container->fileExists($dir = $bundle['path'].'/public')) {
1263+
$paths[$dir] = sprintf('bundles/%s', preg_replace('/bundle$/', '', strtolower($name)));
1264+
}
1265+
}
1266+
$container->getDefinition('asset_mapper_repository')
1267+
->setArgument(0, $paths);
1268+
1269+
$container->getDefinition('asset_mapper.command.compile')
1270+
->setArgument(4, $publicDirName);
1271+
1272+
if (!$config['server']) {
1273+
$container->removeDefinition('asset_mapper.dev_server_subscriber');
1274+
}
1275+
1276+
$container
1277+
->getDefinition('asset_mapper.importmap.manager')
1278+
->replaceArgument(1, $config['importmap_path'])
1279+
->replaceArgument(2, $config['vendor_dir'])
1280+
->replaceArgument(3, $config['provider'])
1281+
;
1282+
1283+
$container
1284+
->getDefinition('asset_mapper.importmap.renderer')
1285+
->replaceArgument(2, $config['importmap_polyfill'] ?? ImportMapManager::POLYFILL_URL)
1286+
->replaceArgument(3, $config['importmap_script_attributes'])
1287+
;
1288+
}
1289+
12341290
/**
12351291
* Returns a definition for an asset package.
12361292
*/
@@ -2994,4 +3050,19 @@ private function writeConfigEnabled(string $path, bool $value, array &$config):
29943050
$this->configsEnabled[$path] = $value;
29953051
$config['enabled'] = $value;
29963052
}
3053+
3054+
private function getPublicDirectoryName(string $projectDir): string
3055+
{
3056+
$defaultPublicDir = 'public';
3057+
3058+
$composerFilePath = $projectDir.'/composer.json';
3059+
3060+
if (!file_exists($composerFilePath)) {
3061+
return $defaultPublicDir;
3062+
}
3063+
3064+
$composerConfig = json_decode(file_get_contents($composerFilePath), true);
3065+
3066+
return $composerConfig['extra']['public-dir'] ?? $defaultPublicDir;
3067+
}
29973068
}

0 commit comments

Comments
 (0)