Skip to content

Commit 39e11ce

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

File tree

82 files changed

+4524
-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

+4524
-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: 90 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,93 @@ 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+
->arrayNode('extensions')
868+
->info('Key-value pair of file extensions set to their mime type.')
869+
->normalizeKeys(false)
870+
->useAttributeAsKey('extension')
871+
->example(['.zip' => 'application/zip'])
872+
->prototype('scalar')->end()
873+
->end()
874+
->scalarNode('importmap_path')
875+
->info('The path of the importmap.php file.')
876+
->defaultValue('%kernel.project_dir%/importmap.php')
877+
->end()
878+
->scalarNode('importmap_polyfill')
879+
->info('URL of the ES Module Polyfill to use, false to disable. Defaults to using a CDN URL.')
880+
->defaultValue(null)
881+
->end()
882+
->arrayNode('importmap_script_attributes')
883+
->info('Key-value pair of attributes to add to script tags output for the importmap.')
884+
->normalizeKeys(false)
885+
->useAttributeAsKey('key')
886+
->example(['data-turbo-track' => 'reload'])
887+
->prototype('scalar')->end()
888+
->end()
889+
->scalarNode('vendor_dir')
890+
->info('The directory to store JavaScript vendors.')
891+
->defaultValue('%kernel.project_dir%/assets/vendor')
892+
->end()
893+
->scalarNode('provider')
894+
->info('The provider (CDN) to use', class_exists(ImportMapManager::class) ? sprintf(' (e.g.: "%s").', implode('", "', ImportMapManager::PROVIDERS)) : '.')
895+
->defaultValue('jspm')
896+
->end()
897+
->end()
898+
->end()
899+
->end()
900+
;
901+
}
902+
813903
private function addTranslatorSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone): void
814904
{
815905
$rootNode

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

Lines changed: 72 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;
@@ -45,6 +47,7 @@
4547
use Symfony\Component\Config\FileLocator;
4648
use Symfony\Component\Config\Loader\LoaderInterface;
4749
use Symfony\Component\Config\Resource\DirectoryResource;
50+
use Symfony\Component\Config\Resource\FileResource;
4851
use Symfony\Component\Config\ResourceCheckerInterface;
4952
use Symfony\Component\Console\Application;
5053
use Symfony\Component\Console\Command\Command;
@@ -330,6 +333,14 @@ public function load(array $configs, ContainerBuilder $container)
330333
$this->registerAssetsConfiguration($config['assets'], $container, $loader);
331334
}
332335

336+
if ($this->readConfigEnabled('asset_mapper', $container, $config['asset_mapper'])) {
337+
if (!class_exists(AssetMapper::class)) {
338+
throw new LogicException('AssetMapper support cannot be enabled as the AssetMapper component is not installed. Try running "composer require symfony/asset-mapper".');
339+
}
340+
341+
$this->registerAssetMapperConfiguration($config['asset_mapper'], $container, $loader, $this->readConfigEnabled('assets', $container, $config['assets']));
342+
}
343+
333344
if ($this->readConfigEnabled('http_client', $container, $config['http_client'])) {
334345
$this->registerHttpClientConfiguration($config['http_client'], $container, $loader);
335346
}
@@ -1231,6 +1242,51 @@ private function registerAssetsConfiguration(array $config, ContainerBuilder $co
12311242
}
12321243
}
12331244

1245+
private function registerAssetMapperConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $assetEnabled): void
1246+
{
1247+
$loader->load('asset_mapper.php');
1248+
1249+
if (!$assetEnabled) {
1250+
$container->removeDefinition('asset_mapper.asset_package');
1251+
}
1252+
1253+
$publicDirName = $this->getPublicDirectoryName($container);
1254+
$container->getDefinition('asset_mapper')
1255+
->setArgument(3, $config['public_prefix'])
1256+
->setArgument(4, $publicDirName)
1257+
->setArgument(5, $config['extensions'])
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,20 @@ private function writeConfigEnabled(string $path, bool $value, array &$config):
29943050
$this->configsEnabled[$path] = $value;
29953051
$config['enabled'] = $value;
29963052
}
3053+
3054+
private function getPublicDirectoryName(ContainerBuilder $container): string
3055+
{
3056+
$defaultPublicDir = 'public';
3057+
3058+
$composerFilePath = $container->getParameter('kernel.project_dir').'/composer.json';
3059+
3060+
if (!file_exists($composerFilePath)) {
3061+
return $defaultPublicDir;
3062+
}
3063+
3064+
$container->addResource(new FileResource($composerFilePath));
3065+
$composerConfig = json_decode(file_get_contents($composerFilePath), true);
3066+
3067+
return $composerConfig['extra']['public-dir'] ?? $defaultPublicDir;
3068+
}
29973069
}

0 commit comments

Comments
 (0)