diff --git a/src/Symfony/Bridge/Twig/Extension/AssetExtension.php b/src/Symfony/Bridge/Twig/Extension/AssetExtension.php index f599a9eb5c..212a7f17ad 100644 --- a/src/Symfony/Bridge/Twig/Extension/AssetExtension.php +++ b/src/Symfony/Bridge/Twig/Extension/AssetExtension.php @@ -12,6 +12,7 @@ namespace Symfony\Bridge\Twig\Extension; use Symfony\Component\Asset\Packages; +use Symfony\Component\Asset\Preload\PreloadManagerInterface; /** * Twig extension for the Symfony Asset component. @@ -21,10 +22,12 @@ use Symfony\Component\Asset\Packages; class AssetExtension extends \Twig_Extension { private $packages; + private $preloadManager; - public function __construct(Packages $packages) + public function __construct(Packages $packages, PreloadManagerInterface $preloadManager = null) { $this->packages = $packages; + $this->preloadManager = $preloadManager; } /** @@ -35,6 +38,7 @@ class AssetExtension extends \Twig_Extension return array( new \Twig_SimpleFunction('asset', array($this, 'getAssetUrl')), new \Twig_SimpleFunction('asset_version', array($this, 'getAssetVersion')), + new \Twig_SimpleFunction('preload', array($this, 'preload')), ); } @@ -67,6 +71,26 @@ class AssetExtension extends \Twig_Extension return $this->packages->getVersion($path, $packageName); } + /** + * Preloads an asset. + * + * @param string $path A public path + * @param string $as A valid destination according to https://fetch.spec.whatwg.org/#concept-request-destination + * @param bool $nopush If this asset should not be pushed over HTTP/2 + * + * @return string The path of the asset + */ + public function preload($path, $as = '', $nopush = false) + { + if (null === $this->preloadManager) { + throw new \RuntimeException('A preload manager must be configured to use the "preload" function.'); + } + + $this->preloadManager->addResource($path, $as, $nopush); + + return $path; + } + /** * Returns the name of the extension. * diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/AssetExtensionTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/AssetExtensionTest.php new file mode 100644 index 0000000000..fbc10b939a --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Extension/AssetExtensionTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Extension; + +use Symfony\Bridge\Twig\Extension\AssetExtension; +use Symfony\Component\Asset\Packages; +use Symfony\Component\Asset\Preload\PreloadManager; + +/** + * @author Kévin Dunglas + */ +class AssetExtensionTest extends \PHPUnit_Framework_TestCase +{ + public function testGetAndPreloadAssetUrl() + { + if (!class_exists(PreloadManager::class)) { + $this->markTestSkipped('Requires Asset 3.3+.'); + } + + $preloadManager = new PreloadManager(); + $extension = new AssetExtension(new Packages(), $preloadManager); + + $this->assertEquals('/foo.css', $extension->preload('/foo.css', 'style', true)); + $this->assertEquals('; rel=preload; as=style; nopush', $preloadManager->buildLinkValue()); + } + + /** + * @expectedException \RuntimeException + */ + public function testNoConfiguredPreloadManager() + { + $extension = new AssetExtension(new Packages()); + $extension->preload('/foo.css'); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.xml index 4f2e1fbf36..4d648a82ac 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.xml @@ -38,5 +38,13 @@ + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index 33db8ad7bb..318767d762 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -376,6 +376,12 @@ abstract class FrameworkExtensionTest extends TestCase $this->assertEquals('assets.custom_version_strategy', (string) $defaultPackage->getArgument(1)); } + public function testAssetHasPreloadListener() + { + $container = $this->createContainerFromFile('assets'); + $this->assertTrue($container->hasDefinition('asset.preload_listener')); + } + public function testTranslator() { $container = $this->createContainerFromFile('full'); diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index cf4d1785f8..ca3d8093cf 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -60,6 +60,7 @@ "conflict": { "phpdocumentor/reflection-docblock": "<3.0", "phpdocumentor/type-resolver": "<0.2.0", + "symfony/asset": "<3.3", "symfony/console": "<3.3", "symfony/serializer": "<3.3", "symfony/form": "<3.3", diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml index 800fc08367..f247da8b8a 100644 --- a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml @@ -70,6 +70,7 @@ + diff --git a/src/Symfony/Component/Asset/EventListener/PreloadListener.php b/src/Symfony/Component/Asset/EventListener/PreloadListener.php new file mode 100644 index 0000000000..a50958c0ca --- /dev/null +++ b/src/Symfony/Component/Asset/EventListener/PreloadListener.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Asset\EventListener; + +use Symfony\Component\Asset\Preload\PreloadManager; +use Symfony\Component\Asset\Preload\PreloadManagerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\FilterResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * Adds the preload Link HTTP header to the response. + * + * @author Kévin Dunglas + */ +class PreloadListener implements EventSubscriberInterface +{ + private $preloadManager; + + public function __construct(PreloadManagerInterface $preloadManager) + { + $this->preloadManager = $preloadManager; + } + + public function onKernelResponse(FilterResponseEvent $event) + { + if (!$event->isMasterRequest()) { + return; + } + + if ($value = $this->preloadManager->buildLinkValue()) { + $event->getResponse()->headers->set('Link', $value, false); + + // Free memory + $this->preloadManager->clear(); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() + { + return array(KernelEvents::RESPONSE => 'onKernelResponse'); + } +} diff --git a/src/Symfony/Component/Asset/Preload/PreloadManager.php b/src/Symfony/Component/Asset/Preload/PreloadManager.php new file mode 100644 index 0000000000..b070147a6c --- /dev/null +++ b/src/Symfony/Component/Asset/Preload/PreloadManager.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Asset\Preload; + +/** + * Manages preload HTTP headers. + * + * @author Kévin Dunglas + */ +class PreloadManager implements PreloadManagerInterface +{ + private $resources = array(); + + /** + * {@inheritdoc} + */ + public function addResource($uri, $as = '', $nopush = false) + { + $this->resources[$uri] = array('as' => $as, 'nopush' => $nopush); + } + + /** + * {@inheritdoc} + */ + public function clear() + { + $this->resources = array(); + } + + /** + * {@inheritdoc} + */ + public function buildLinkValue() + { + if (!$this->resources) { + return; + } + + $parts = array(); + foreach ($this->resources as $uri => $options) { + $as = '' === $options['as'] ? '' : sprintf('; as=%s', $options['as']); + $nopush = $options['nopush'] ? '; nopush' : ''; + + $parts[] = sprintf('<%s>; rel=preload%s%s', $uri, $as, $nopush); + } + + return implode(',', $parts); + } +} diff --git a/src/Symfony/Component/Asset/Preload/PreloadManagerInterface.php b/src/Symfony/Component/Asset/Preload/PreloadManagerInterface.php new file mode 100644 index 0000000000..455f5a2ef7 --- /dev/null +++ b/src/Symfony/Component/Asset/Preload/PreloadManagerInterface.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Asset\Preload; + +/** + * Manages resources to preload according to the W3C "Preload" specification. + * + * @see https://www.w3.org/TR/preload/ + * + * @author Kévin Dunglas + */ +interface PreloadManagerInterface +{ + /** + * Adds an element to the list of resources to preload. + * + * @param string $uri The resource URI + * @param string $as A valid destination according to https://fetch.spec.whatwg.org/#concept-request-destination + * @param bool $nopush If this asset should not be pushed over HTTP/2 + */ + public function addResource($uri, $as = '', $nopush = false); + + /** + * Clears the list of resources. + */ + public function clear(); + + /** + * Builds the value of the preload Link HTTP header. + * + * @return string|null + */ + public function buildLinkValue(); +} diff --git a/src/Symfony/Component/Asset/Tests/EventListener/PreloadListenerTest.php b/src/Symfony/Component/Asset/Tests/EventListener/PreloadListenerTest.php new file mode 100644 index 0000000000..809010d391 --- /dev/null +++ b/src/Symfony/Component/Asset/Tests/EventListener/PreloadListenerTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Asset\Tests\EventListener; + +use Symfony\Component\Asset\EventListener\PreloadListener; +use Symfony\Component\Asset\Preload\PreloadManager; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\FilterResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * @author Kévin Dunglas + */ +class PreloadListenerTest extends \PHPUnit_Framework_TestCase +{ + public function testOnKernelResponse() + { + $manager = new PreloadManager(); + $manager->addResource('/foo'); + + $subscriber = new PreloadListener($manager); + $response = new Response('', 200, array('Link' => '; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"')); + + $event = $this->getMockBuilder(FilterResponseEvent::class)->disableOriginalConstructor()->getMock(); + $event->method('isMasterRequest')->willReturn(true); + $event->method('getResponse')->willReturn($response); + + $subscriber->onKernelResponse($event); + + $this->assertInstanceOf(EventSubscriberInterface::class, $subscriber); + + $expected = array( + '; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"', + '; rel=preload', + ); + + $this->assertEquals($expected, $response->headers->get('Link', null, false)); + $this->assertNull($manager->buildLinkValue()); + } + + public function testSubscribedEvents() + { + $this->assertEquals(array(KernelEvents::RESPONSE => 'onKernelResponse'), PreloadListener::getSubscribedEvents()); + } +} diff --git a/src/Symfony/Component/Asset/Tests/Preload/PreloadManagerTest.php b/src/Symfony/Component/Asset/Tests/Preload/PreloadManagerTest.php new file mode 100644 index 0000000000..dee268e9cc --- /dev/null +++ b/src/Symfony/Component/Asset/Tests/Preload/PreloadManagerTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Asset\Preload; + +/** + * @author Kévin Dunglas + */ +class PreloadManagerTest extends \PHPUnit_Framework_TestCase +{ + public function testManageResources() + { + $manager = new PreloadManager(); + $this->assertInstanceOf(PreloadManagerInterface::class, $manager); + + $manager->addResource('/foo/bar.js', 'script', false); + $manager->addResource('/foo/baz.css'); + $manager->addResource('/foo/bat.png', 'image', true); + + $this->assertEquals('; rel=preload; as=script,; rel=preload,; rel=preload; as=image; nopush', $manager->buildLinkValue()); + } +} diff --git a/src/Symfony/Component/Asset/composer.json b/src/Symfony/Component/Asset/composer.json index dec9d88a93..8ed8d9d725 100644 --- a/src/Symfony/Component/Asset/composer.json +++ b/src/Symfony/Component/Asset/composer.json @@ -22,7 +22,8 @@ "symfony/http-foundation": "" }, "require-dev": { - "symfony/http-foundation": "~2.8|~3.0" + "symfony/http-foundation": "~2.8|~3.0", + "symfony/http-kernel": "~2.8|~3.0" }, "autoload": { "psr-4": { "Symfony\\Component\\Asset\\": "" },