From b26cc85da70d29b7aab96a2ea55c5edcebd83ae0 Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Thu, 14 Jan 2021 20:14:08 -0500 Subject: [PATCH] Adding support for custom attributes on rendered script and link tags --- .travis.yml | 2 +- README.md | 55 ++++++++++++ composer.json | 16 ++-- src/Asset/TagRenderer.php | 51 +++++++++-- src/DependencyInjection/Configuration.php | 17 +++- .../WebpackEncoreExtension.php | 4 +- src/Event/RenderAssetTagEvent.php | 64 ++++++++++++++ src/Resources/config/services.xml | 5 +- src/Twig/EntryFilesTwigExtension.php | 8 +- tests/Asset/TagRendererTest.php | 84 +++++++++++++++++-- tests/IntegrationTest.php | 9 +- tests/fixtures/template.twig | 4 +- 12 files changed, 283 insertions(+), 36 deletions(-) create mode 100644 src/Event/RenderAssetTagEvent.php diff --git a/.travis.yml b/.travis.yml index e003ff4b..0528ff42 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,7 +20,7 @@ matrix: env: deps=low - php: 7.4 env: SYMFONY_PHPUNIT_VERSION=9.4 - - php: nightly + - php: 8.0 env: SYMFONY_PHPUNIT_VERSION=9.4 before_install: diff --git a/README.md b/README.md index 503be84c..5fb4c861 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,13 @@ webpack_encore: output_path: '%kernel.project_dir%/public/build' # If multiple builds are defined (as shown below), you can disable the default build: # output_path: false + + # Set attributes that will be rendered on all script and link tags + # script_attributes: + # defer: true + # referrerpolicy: origin + # link_attributes: + # referrerpolicy: origin # if using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials') # crossorigin: 'anonymous' @@ -84,6 +91,13 @@ For example, to render all of the `script` and `link` tags for a specific {{ parent() }} {{ encore_entry_script_tags('entry1') }} + + {# or render a custom attribute #} + {# + {{ encore_entry_script_tags('entry1', attributes={ + defer: true + }) }} + #} {% endblock %} {% block stylesheets %} @@ -144,3 +158,44 @@ class SomeController If you have multiple builds, you can also autowire `Symfony\WebpackEncoreBundle\Asset\EntrypointLookupCollectionInterface` and use it to get the `EntrypointLookupInterface` object for any build. + +## Custom Attributes on script and link Tags + +Custom attributes can be added to rendered `script` or `link` in 3 +different ways: + +1. Via global config (`script_attributes` and `link_attributes`) - see the + config example above. + +1. When rendering in Twig - see the `attributes` option in the docs above. + +1. By listening to the `Symfony\WebpackEncoreBundle\Event\RenderAssetTagEvent` + event. For example: + +```php + 'onRenderAssetTag' + ]; + } + + public function onRenderAssetTag(RenderAssetTagEvent $event) + { + if ($event->isScriptTag()) { + $event->setAttribute('nonce', 'lookup nonce'); + } + } +} +``` + +Ok, have fun! diff --git a/composer.json b/composer.json index e6fab536..896cf8e7 100644 --- a/composer.json +++ b/composer.json @@ -22,17 +22,17 @@ "minimum-stability": "dev", "require": { "php": ">=7.1.3", - "symfony/asset": "^3.4 || ^4.0 || ^5.0", - "symfony/config": "^3.4 || ^4.0 || ^5.0", - "symfony/dependency-injection": "^3.4 || ^4.0 || ^5.0", - "symfony/http-kernel": "^3.4 || ^4.0 || ^5.0", + "symfony/asset": "^4.4 || ^5.0", + "symfony/config": "^4.4 || ^5.0", + "symfony/dependency-injection": "^4.4 || ^5.0", + "symfony/http-kernel": "^4.4 || ^5.0", "symfony/service-contracts": "^1.0 || ^2.0" }, "require-dev": { - "symfony/framework-bundle": "^3.4 || ^4.0 || ^5.0", - "symfony/phpunit-bridge": "^4.3.5 || ^5.0", - "symfony/twig-bundle": "^3.4 || ^4.0 || ^5.0", - "symfony/web-link": "^3.4 || ^4.0 || ^5.0" + "symfony/framework-bundle": "^4.4 || ^5.0", + "symfony/phpunit-bridge": "^4.4 || ^5.0", + "symfony/twig-bundle": "^4.4 || ^5.0", + "symfony/web-link": "^4.4 || ^5.0" }, "extra": { "thanks": { diff --git a/src/Asset/TagRenderer.php b/src/Asset/TagRenderer.php index 4e5ffc25..ee0e72e7 100644 --- a/src/Asset/TagRenderer.php +++ b/src/Asset/TagRenderer.php @@ -11,7 +11,9 @@ use Symfony\Component\Asset\Packages; use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\Service\ResetInterface; +use Symfony\WebpackEncoreBundle\Event\RenderAssetTagEvent; /** * @final @@ -19,17 +21,21 @@ class TagRenderer implements ResetInterface { private $entrypointLookupCollection; - private $packages; - private $defaultAttributes; + private $defaultScriptAttributes; + private $defaultLinkAttributes; + private $eventDispatcher; private $renderedFiles = []; public function __construct( $entrypointLookupCollection, Packages $packages, - array $defaultAttributes = [] + array $defaultAttributes = [], + array $defaultScriptAttributes = [], + array $defaultLinkAttributes = [], + EventDispatcherInterface $eventDispatcher = null ) { if ($entrypointLookupCollection instanceof EntrypointLookupInterface) { @trigger_error(sprintf('The "$entrypointLookupCollection" argument in method "%s()" must be an instance of EntrypointLookupCollection.', __METHOD__), E_USER_DEPRECATED); @@ -47,24 +53,39 @@ public function __construct( $this->packages = $packages; $this->defaultAttributes = $defaultAttributes; + $this->defaultScriptAttributes = $defaultScriptAttributes; + $this->defaultLinkAttributes = $defaultLinkAttributes; + $this->eventDispatcher = $eventDispatcher; $this->reset(); } - public function renderWebpackScriptTags(string $entryName, string $packageName = null, string $entrypointName = '_default'): string + public function renderWebpackScriptTags(string $entryName, string $packageName = null, string $entrypointName = null, array $extraAttributes = []): string { + $entrypointName = $entrypointName ?: '_default'; $scriptTags = []; $entryPointLookup = $this->getEntrypointLookup($entrypointName); $integrityHashes = ($entryPointLookup instanceof IntegrityDataProviderInterface) ? $entryPointLookup->getIntegrityData() : []; foreach ($entryPointLookup->getJavaScriptFiles($entryName) as $filename) { - $attributes = $this->defaultAttributes; + $attributes = []; $attributes['src'] = $this->getAssetPath($filename, $packageName); + $attributes = array_merge($attributes, $this->defaultAttributes, $this->defaultScriptAttributes, $extraAttributes); if (isset($integrityHashes[$filename])) { $attributes['integrity'] = $integrityHashes[$filename]; } + $event = new RenderAssetTagEvent( + RenderAssetTagEvent::TYPE_SCRIPT, + $attributes['src'], + $attributes + ); + if (null !== $this->eventDispatcher) { + $event = $this->eventDispatcher->dispatch($event); + } + $attributes = $event->getAttributes(); + $scriptTags[] = sprintf( '', $this->convertArrayToAttributes($attributes) @@ -76,21 +97,33 @@ public function renderWebpackScriptTags(string $entryName, string $packageName = return implode('', $scriptTags); } - public function renderWebpackLinkTags(string $entryName, string $packageName = null, string $entrypointName = '_default'): string + public function renderWebpackLinkTags(string $entryName, string $packageName = null, string $entrypointName = null, array $extraAttributes = []): string { + $entrypointName = $entrypointName ?: '_default'; $scriptTags = []; $entryPointLookup = $this->getEntrypointLookup($entrypointName); $integrityHashes = ($entryPointLookup instanceof IntegrityDataProviderInterface) ? $entryPointLookup->getIntegrityData() : []; foreach ($entryPointLookup->getCssFiles($entryName) as $filename) { - $attributes = $this->defaultAttributes; + $attributes = []; $attributes['rel'] = 'stylesheet'; $attributes['href'] = $this->getAssetPath($filename, $packageName); + $attributes = array_merge($attributes, $this->defaultAttributes, $this->defaultLinkAttributes, $extraAttributes); if (isset($integrityHashes[$filename])) { $attributes['integrity'] = $integrityHashes[$filename]; } + $event = new RenderAssetTagEvent( + RenderAssetTagEvent::TYPE_LINK, + $attributes['href'], + $attributes + ); + if (null !== $this->eventDispatcher) { + $this->eventDispatcher->dispatch($event); + } + $attributes = $event->getAttributes(); + $scriptTags[] = sprintf( '', $this->convertArrayToAttributes($attributes) @@ -146,6 +179,10 @@ private function convertArrayToAttributes(array $attributesMap): string { return implode(' ', array_map( function ($key, $value) { + // allows for things like defer: true to only render "defer" + if ($value === true) { + return $key; + } return sprintf('%s="%s"', $key, htmlentities($value)); }, array_keys($attributesMap), diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 9dce21d2..87b5ef1a 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -55,7 +55,7 @@ public function getConfigTreeBuilder() ->useAttributeAsKey('name') ->normalizeKeys(false) ->scalarPrototype() - ->validate() + ->validate() ->always(function ($values) { if (isset($values['_default'])) { throw new InvalidDefinitionException("Key '_default' can't be used as build name."); @@ -63,8 +63,23 @@ public function getConfigTreeBuilder() return $values; }) + ->end() ->end() ->end() + ->arrayNode('script_attributes') + ->info('Key/value pair of attributes to render on all script tags') + ->example('{ defer: true, referrerpolicy: "origin" }') + ->useAttributeAsKey('name') + ->normalizeKeys(false) + ->scalarPrototype()->end() + ->end() + ->arrayNode('link_attributes') + ->info('Key/value pair of attributes to render on all CSS link tags') + ->example('{ referrerpolicy: "origin" }') + ->useAttributeAsKey('name') + ->normalizeKeys(false) + ->scalarPrototype()->end() + ->end() ->end() ; diff --git a/src/DependencyInjection/WebpackEncoreExtension.php b/src/DependencyInjection/WebpackEncoreExtension.php index 521a2e97..53a68dce 100644 --- a/src/DependencyInjection/WebpackEncoreExtension.php +++ b/src/DependencyInjection/WebpackEncoreExtension.php @@ -68,7 +68,9 @@ public function load(array $configs, ContainerBuilder $container) } $container->getDefinition('webpack_encore.tag_renderer') - ->replaceArgument(2, $defaultAttributes); + ->replaceArgument(2, $defaultAttributes) + ->replaceArgument(3, $config['script_attributes']) + ->replaceArgument(4, $config['link_attributes']); if ($config['preload']) { if (!class_exists(AddLinkHeaderListener::class)) { diff --git a/src/Event/RenderAssetTagEvent.php b/src/Event/RenderAssetTagEvent.php new file mode 100644 index 00000000..bcffa85c --- /dev/null +++ b/src/Event/RenderAssetTagEvent.php @@ -0,0 +1,64 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\WebpackEncoreBundle\Event; + +/** + * Dispatched each time a script or link tag is rendered. + */ +final class RenderAssetTagEvent +{ + public const TYPE_SCRIPT = 'script'; + public const TYPE_LINK = 'link'; + + private $type; + private $url; + private $attributes; + + public function __construct(string $type, string $url, array $attributes) + { + $this->type = $type; + $this->url = $url; + $this->attributes = $attributes; + } + + public function isScriptTag(): bool + { + return $this->type === self::TYPE_SCRIPT; + } + + public function isLinkTag(): bool + { + return $this->type === self::TYPE_LINK; + } + + public function getUrl(): string + { + return $this->url; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * @param string $name The attribute name + * @param string|bool $value Value can be "true" to have an attribute without a value (e.g. "defer") + */ + public function setAttribute(string $name, $value): void + { + $this->attributes[$name] = $value; + } + + public function removeAttribute(string $name): void + { + unset($this->attributes[$name]); + } +} diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 2464c368..cb03623a 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -17,7 +17,10 @@ - + + + + diff --git a/src/Twig/EntryFilesTwigExtension.php b/src/Twig/EntryFilesTwigExtension.php index 30254cb3..5b6bc1bf 100644 --- a/src/Twig/EntryFilesTwigExtension.php +++ b/src/Twig/EntryFilesTwigExtension.php @@ -46,16 +46,16 @@ public function getWebpackCssFiles(string $entryName, string $entrypointName = ' ->getCssFiles($entryName); } - public function renderWebpackScriptTags(string $entryName, string $packageName = null, string $entrypointName = '_default'): string + public function renderWebpackScriptTags(string $entryName, string $packageName = null, string $entrypointName = '_default', array $attributes = []): string { return $this->getTagRenderer() - ->renderWebpackScriptTags($entryName, $packageName, $entrypointName); + ->renderWebpackScriptTags($entryName, $packageName, $entrypointName, $attributes); } - public function renderWebpackLinkTags(string $entryName, string $packageName = null, string $entrypointName = '_default'): string + public function renderWebpackLinkTags(string $entryName, string $packageName = null, string $entrypointName = '_default', array $attributes = []): string { return $this->getTagRenderer() - ->renderWebpackLinkTags($entryName, $packageName, $entrypointName); + ->renderWebpackLinkTags($entryName, $packageName, $entrypointName, $attributes); } private function getEntrypointLookup(string $entrypointName): EntrypointLookupInterface diff --git a/tests/Asset/TagRendererTest.php b/tests/Asset/TagRendererTest.php index 9279c4ee..e4fae3dd 100644 --- a/tests/Asset/TagRendererTest.php +++ b/tests/Asset/TagRendererTest.php @@ -11,9 +11,11 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Asset\Packages; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupCollection; use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface; use Symfony\WebpackEncoreBundle\Asset\TagRenderer; +use Symfony\WebpackEncoreBundle\Event\RenderAssetTagEvent; use Symfony\WebpackEncoreBundle\Tests\TestEntrypointLookupIntegrityDataProviderInterface; class TagRendererTest extends TestCase @@ -40,15 +42,79 @@ public function testRenderScriptTagsWithDefaultAttributes() ->willReturnCallback(function ($path) { return 'http://localhost:8080'.$path; }); - $renderer = new TagRenderer($entrypointCollection, $packages, []); + $renderer = new TagRenderer($entrypointCollection, $packages, ['defer' => true]); $output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package'); $this->assertStringContainsString( - '', + '', $output ); $this->assertStringContainsString( - '', + '', + $output + ); + } + + public function testRenderScriptTagsWithExtraAttributes() + { + $entrypointLookup = $this->createMock(EntrypointLookupInterface::class); + $entrypointLookup->expects($this->once()) + ->method('getJavaScriptFiles') + ->willReturn(['/build/file1.js']); + $entrypointCollection = $this->createMock(EntrypointLookupCollection::class); + $entrypointCollection->expects($this->once()) + ->method('getEntrypointLookup') + ->willReturn($entrypointLookup); + + $packages = $this->createMock(Packages::class); + $packages->expects($this->exactly(1)) + ->method('getUrl') + ->willReturn('http://localhost:8080/build/file1.js'); + $renderer = new TagRenderer($entrypointCollection, $packages, [ + 'defer' => true, + 'nonce' => 'abc123' + ], ['referrerpolicy' => 'origin']); + + $output = $renderer->renderWebpackScriptTags('my_entry', null, null, [ + // override the attribute + 'nonce' => '12345', + ]); + $this->assertStringContainsString( + '', + $output + ); + } + + public function testRenderScriptTagsDispatchesAnEvent() + { + $entrypointLookup = $this->createMock(EntrypointLookupInterface::class); + $entrypointLookup->expects($this->once()) + ->method('getJavaScriptFiles') + ->willReturn(['/build/file1.js']); + $entrypointCollection = $this->createMock(EntrypointLookupCollection::class); + $entrypointCollection->expects($this->once()) + ->method('getEntrypointLookup') + ->willReturn($entrypointLookup); + + $packages = $this->createMock(Packages::class); + $packages->expects($this->exactly(1)) + ->method('getUrl') + ->willReturn('http://localhost:8080/build/file1.js'); + + $event = new RenderAssetTagEvent(RenderAssetTagEvent::TYPE_SCRIPT, 'http://foo', [ + 'src' => 'http://localhost:8080/build/file1.js', + 'nonce' => 'some_nonce_here', + ]); + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher->expects($this->once()) + ->method('dispatch') + ->willReturn($event); + + $renderer = new TagRenderer($entrypointCollection, $packages, [], [], [], $dispatcher); + + $output = $renderer->renderWebpackScriptTags('my_entry'); + $this->assertStringContainsString( + '', $output ); } @@ -75,7 +141,7 @@ public function testRenderScriptTagsWithBadFilename() $output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package'); $this->assertStringContainsString( - '', + '', $output ); } @@ -121,17 +187,17 @@ public function testRenderScriptTagsWithinAnEntryPointCollection() $output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package'); $this->assertStringContainsString( - '', + '', $output ); $output = $renderer->renderWebpackScriptTags('my_entry', null, 'second'); $this->assertStringContainsString( - '', + '', $output ); $output = $renderer->renderWebpackScriptTags('my_entry', 'specific_package', 'third'); $this->assertStringContainsString( - '', + '', $output ); } @@ -168,11 +234,11 @@ public function testRenderScriptTagsWithHashes() $output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package'); $this->assertStringContainsString( - '', + '', $output ); $this->assertStringContainsString( - '', + '', $output ); } diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 2cf8da5d..2ff1b78d 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -35,12 +35,13 @@ class IntegrationTest extends TestCase public function testTwigIntegration() { $kernel = new WebpackEncoreIntegrationTestKernel(true); + $kernel->scriptAttributes = ['referrerpolicy' => 'origin']; $kernel->boot(); $twig = $this->getTwigEnvironmentFromBootedKernel($kernel); $html1 = $twig->render('@integration_test/template.twig'); $this->assertStringContainsString( - '', + '', $html1 ); $this->assertStringContainsString( @@ -49,7 +50,7 @@ public function testTwigIntegration() $html1 ); $this->assertStringContainsString( - '', + '', $html1 ); $this->assertStringContainsString( @@ -118,7 +119,7 @@ public function testCacheWarmer() public function testEnabledStrictMode_throwsException_ifBuildMissing() { $this->expectException(\Twig\Error\RuntimeError::class); - $this->expectExceptionMessageRegExp('/Could not find the entrypoints file/'); + $this->expectExceptionMessage('Could not find the entrypoints file from Webpack: the file "missing_build/entrypoints.json" does not exist.'); $kernel = new WebpackEncoreIntegrationTestKernel(true); $kernel->outputPath = 'missing_build'; @@ -208,6 +209,7 @@ abstract class AbstractWebpackEncoreIntegrationTestKernel extends Kernel public $builds = [ 'different_build' => __DIR__.'/fixtures/different_build', ]; + public $scriptAttributes = []; public function __construct(bool $enableAssets) { @@ -255,6 +257,7 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa 'preload' => true, 'builds' => $this->builds, 'strict_mode' => $this->strictMode, + 'script_attributes' => $this->scriptAttributes, ]); $container->register(WebpackEncoreCacheWarmerTester::class) diff --git a/tests/fixtures/template.twig b/tests/fixtures/template.twig index 11b66b92..5130c1dc 100644 --- a/tests/fixtures/template.twig +++ b/tests/fixtures/template.twig @@ -1,4 +1,6 @@ -{{ encore_entry_script_tags('my_entry') }} +{{ encore_entry_script_tags('my_entry', attributes={ + defer: true +}) }} {{ encore_entry_link_tags('my_entry') }} {{ encore_entry_script_tags('third_entry', null, 'different_build') }} {{ encore_entry_link_tags('third_entry', null, 'different_build') }}