diff --git a/composer.json b/composer.json index dbcba8c99d..42be8e2b4e 100644 --- a/composer.json +++ b/composer.json @@ -18,9 +18,11 @@ "require": { "php": ">=5.5.9", "doctrine/common": "~2.4", + "fig/link-util": "^1.0", "twig/twig": "~1.32|~2.2", "psr/cache": "~1.0", "psr/container": "^1.0", + "psr/link": "^1.0", "psr/log": "~1.0", "psr/simple-cache": "^1.0", "symfony/polyfill-intl-icu": "~1.0", @@ -76,6 +78,7 @@ "symfony/twig-bundle": "self.version", "symfony/validator": "self.version", "symfony/var-dumper": "self.version", + "symfony/web-link": "self.version", "symfony/web-profiler-bundle": "self.version", "symfony/web-server-bundle": "self.version", "symfony/workflow": "self.version", diff --git a/src/Symfony/Bridge/Twig/Extension/AssetExtension.php b/src/Symfony/Bridge/Twig/Extension/AssetExtension.php index 212a7f17ad..f599a9eb5c 100644 --- a/src/Symfony/Bridge/Twig/Extension/AssetExtension.php +++ b/src/Symfony/Bridge/Twig/Extension/AssetExtension.php @@ -12,7 +12,6 @@ namespace Symfony\Bridge\Twig\Extension; use Symfony\Component\Asset\Packages; -use Symfony\Component\Asset\Preload\PreloadManagerInterface; /** * Twig extension for the Symfony Asset component. @@ -22,12 +21,10 @@ use Symfony\Component\Asset\Preload\PreloadManagerInterface; class AssetExtension extends \Twig_Extension { private $packages; - private $preloadManager; - public function __construct(Packages $packages, PreloadManagerInterface $preloadManager = null) + public function __construct(Packages $packages) { $this->packages = $packages; - $this->preloadManager = $preloadManager; } /** @@ -38,7 +35,6 @@ 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')), ); } @@ -71,26 +67,6 @@ 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/Extension/WebLinkExtension.php b/src/Symfony/Bridge/Twig/Extension/WebLinkExtension.php new file mode 100644 index 0000000000..14e1fc2736 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Extension/WebLinkExtension.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Fig\Link\GenericLinkProvider; +use Fig\Link\Link; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Twig extension for the Symfony WebLink component. + * + * @author Kévin Dunglas + */ +class WebLinkExtension extends \Twig_Extension +{ + private $requestStack; + + public function __construct(RequestStack $requestStack) + { + $this->requestStack = $requestStack; + } + + /** + * {@inheritdoc} + */ + public function getFunctions() + { + return array( + new \Twig_SimpleFunction('link', array($this, 'link')), + new \Twig_SimpleFunction('preload', array($this, 'preload')), + new \Twig_SimpleFunction('dns_prefetch', array($this, 'dnsPrefetch')), + new \Twig_SimpleFunction('preconnect', array($this, 'preconnect')), + new \Twig_SimpleFunction('prefetch', array($this, 'prefetch')), + new \Twig_SimpleFunction('prerender', array($this, 'prerender')), + ); + } + + /** + * Adds a "Link" HTTP header. + * + * @param string $uri The relation URI + * @param string $rel The relation type (e.g. "preload", "prefetch", "prerender" or "dns-prefetch") + * @param array $attributes The attributes of this link (e.g. "array('as' => true)", "array('pr' => 0.5)") + * + * @return string The relation URI + */ + public function link($uri, $rel, array $attributes = array()) + { + if (!$request = $this->requestStack->getMasterRequest()) { + return $uri; + } + + $link = new Link($rel, $uri); + foreach ($attributes as $key => $value) { + $link = $link->withAttribute($key, $value); + } + + $linkProvider = $request->attributes->get('_links', new GenericLinkProvider()); + $request->attributes->set('_links', $linkProvider->withLink($link)); + + return $uri; + } + + /** + * Preloads a resource. + * + * @param string $uri A public path + * @param array $attributes The attributes of this link (e.g. "array('as' => true)", "array('crossorigin' => 'use-credentials')") + * + * @return string The path of the asset + */ + public function preload($uri, array $attributes = array()) + { + return $this->link($uri, 'preload', $attributes); + } + + /** + * Resolves a resource origin as early as possible. + * + * @param string $uri A public path + * @param array $attributes The attributes of this link (e.g. "array('as' => true)", "array('pr' => 0.5)") + * + * @return string The path of the asset + */ + public function dnsPrefetch($uri, array $attributes = array()) + { + return $this->link($uri, 'dns-prefetch', $attributes); + } + + /** + * Initiates a early connection to a resource (DNS resolution, TCP handshake, TLS negotiation). + * + * @param string $uri A public path + * @param array $attributes The attributes of this link (e.g. "array('as' => true)", "array('pr' => 0.5)") + * + * @return string The path of the asset + */ + public function preconnect($uri, array $attributes = array()) + { + return $this->link($uri, 'preconnect', $attributes); + } + + /** + * Indicates to the client that it should prefetch this resource. + * + * @param string $uri A public path + * @param array $attributes The attributes of this link (e.g. "array('as' => true)", "array('pr' => 0.5)") + * + * @return string The path of the asset + */ + public function prefetch($uri, array $attributes = array()) + { + return $this->link($uri, 'prefetch', $attributes); + } + + /** + * Indicates to the client that it should prerender this resource . + * + * @param string $uri A public path + * @param array $attributes The attributes of this link (e.g. "array('as' => true)", "array('pr' => 0.5)") + * + * @return string The path of the asset + */ + public function prerender($uri, array $attributes = array()) + { + return $this->link($uri, 'prerender', $attributes); + } +} diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/AssetExtensionTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/AssetExtensionTest.php deleted file mode 100644 index 6201ebb1e5..0000000000 --- a/src/Symfony/Bridge/Twig/Tests/Extension/AssetExtensionTest.php +++ /dev/null @@ -1,45 +0,0 @@ - - * - * 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 PHPUnit\Framework\TestCase; -use Symfony\Bridge\Twig\Extension\AssetExtension; -use Symfony\Component\Asset\Packages; -use Symfony\Component\Asset\Preload\PreloadManager; - -/** - * @author Kévin Dunglas - */ -class AssetExtensionTest extends 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/Bridge/Twig/Tests/Extension/WebLinkExtensionTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/WebLinkExtensionTest.php new file mode 100644 index 0000000000..3424b58875 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Extension/WebLinkExtensionTest.php @@ -0,0 +1,92 @@ + + * + * 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 Fig\Link\Link; +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Twig\Extension\WebLinkExtension; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * @author Kévin Dunglas + */ +class WebLinkExtensionTest extends TestCase +{ + /** + * @var Request + */ + private $request; + + /** + * @var WebLinkExtension + */ + private $extension; + + protected function setUp() + { + $this->request = new Request(); + + $requestStack = new RequestStack(); + $requestStack->push($this->request); + + $this->extension = new WebLinkExtension($requestStack); + } + + public function testLink() + { + $this->assertEquals('/foo.css', $this->extension->link('/foo.css', 'preload', array('as' => 'style', 'nopush' => true))); + + $link = (new Link('preload', '/foo.css'))->withAttribute('as', 'style')->withAttribute('nopush', true); + $this->assertEquals(array($link), array_values($this->request->attributes->get('_links')->getLinks())); + } + + public function testPreload() + { + $this->assertEquals('/foo.css', $this->extension->preload('/foo.css', array('as' => 'style', 'crossorigin' => true))); + + $link = (new Link('preload', '/foo.css'))->withAttribute('as', 'style')->withAttribute('crossorigin', true); + $this->assertEquals(array($link), array_values($this->request->attributes->get('_links')->getLinks())); + } + + public function testDnsPrefetch() + { + $this->assertEquals('/foo.css', $this->extension->dnsPrefetch('/foo.css', array('as' => 'style', 'crossorigin' => true))); + + $link = (new Link('dns-prefetch', '/foo.css'))->withAttribute('as', 'style')->withAttribute('crossorigin', true); + $this->assertEquals(array($link), array_values($this->request->attributes->get('_links')->getLinks())); + } + + public function testPreconnect() + { + $this->assertEquals('/foo.css', $this->extension->preconnect('/foo.css', array('as' => 'style', 'crossorigin' => true))); + + $link = (new Link('preconnect', '/foo.css'))->withAttribute('as', 'style')->withAttribute('crossorigin', true); + $this->assertEquals(array($link), array_values($this->request->attributes->get('_links')->getLinks())); + } + + public function testPrefetch() + { + $this->assertEquals('/foo.css', $this->extension->prefetch('/foo.css', array('as' => 'style', 'crossorigin' => true))); + + $link = (new Link('prefetch', '/foo.css'))->withAttribute('as', 'style')->withAttribute('crossorigin', true); + $this->assertEquals(array($link), array_values($this->request->attributes->get('_links')->getLinks())); + } + + public function testPrerender() + { + $this->assertEquals('/foo.css', $this->extension->prerender('/foo.css', array('as' => 'style', 'crossorigin' => true))); + + $link = (new Link('prerender', '/foo.css'))->withAttribute('as', 'style')->withAttribute('crossorigin', true); + $this->assertEquals(array($link), array_values($this->request->attributes->get('_links')->getLinks())); + } +} diff --git a/src/Symfony/Bridge/Twig/composer.json b/src/Symfony/Bridge/Twig/composer.json index 3f65bfaafa..d3e0af6280 100644 --- a/src/Symfony/Bridge/Twig/composer.json +++ b/src/Symfony/Bridge/Twig/composer.json @@ -20,6 +20,7 @@ "twig/twig": "~1.28|~2.0" }, "require-dev": { + "fig/link-util": "^1.0", "symfony/asset": "~2.8|~3.0", "symfony/finder": "~2.8|~3.0", "symfony/form": "^3.2.5", @@ -34,7 +35,8 @@ "symfony/stopwatch": "~2.8|~3.0", "symfony/console": "~2.8|~3.0", "symfony/var-dumper": "~2.8.10|~3.1.4|~3.2", - "symfony/expression-language": "~2.8|~3.0" + "symfony/expression-language": "~2.8|~3.0", + "symfony/web-link": "~3.3" }, "suggest": { "symfony/finder": "", @@ -48,7 +50,8 @@ "symfony/security": "For using the SecurityExtension", "symfony/stopwatch": "For using the StopwatchExtension", "symfony/var-dumper": "For using the DumpExtension", - "symfony/expression-language": "For using the ExpressionExtension" + "symfony/expression-language": "For using the ExpressionExtension", + "symfony/web-link": "For using the WebLinkExtension" }, "autoload": { "psr-4": { "Symfony\\Bridge\\Twig\\": "" }, diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 4bb7977e96..8b6b4da71f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -18,10 +18,10 @@ use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Form\Form; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Translation\Translator; use Symfony\Component\Validator\Validation; +use Symfony\Component\WebLink\HttpHeaderSerializer; /** * FrameworkExtension configuration structure. @@ -101,6 +101,7 @@ class Configuration implements ConfigurationInterface $this->addPropertyInfoSection($rootNode); $this->addCacheSection($rootNode); $this->addPhpErrorsSection($rootNode); + $this->addWebLinkSection($rootNode); return $treeBuilder; } @@ -806,4 +807,16 @@ class Configuration implements ConfigurationInterface ->end() ; } + + private function addWebLinkSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->children() + ->arrayNode('web_link') + ->info('web links configuration') + ->{!class_exists(FullStack::class) && class_exists(HttpHeaderSerializer::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->end() + ->end() + ; + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 01ea8d6ef5..9a757e8082 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -15,8 +15,10 @@ use Doctrine\Common\Annotations\Reader; use Symfony\Bridge\Monolog\Processor\DebugProcessor; use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\Config\Resource\DirectoryResource; +use Symfony\Component\Console\Application; use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -28,7 +30,6 @@ use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\Finder\Finder; use Symfony\Component\HttpKernel\DependencyInjection\Extension; -use Symfony\Component\Config\FileLocator; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\Serializer\Encoder\YamlEncoder; use Symfony\Component\Serializer\Encoder\CsvEncoder; @@ -36,8 +37,8 @@ use Symfony\Component\Serializer\Mapping\Factory\CacheClassMetadataFactory; use Symfony\Component\Serializer\Normalizer\DataUriNormalizer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer; +use Symfony\Component\WebLink\HttpHeaderSerializer; use Symfony\Component\Workflow; -use Symfony\Component\Console\Application; /** * FrameworkExtension. @@ -208,6 +209,14 @@ class FrameworkExtension extends Extension $this->registerPropertyInfoConfiguration($config['property_info'], $container, $loader); } + if ($this->isConfigEnabled($container, $config['web_link'])) { + if (!class_exists(HttpHeaderSerializer::class)) { + throw new LogicException('WebLink support cannot be enabled as the WebLink component is not installed.'); + } + + $loader->load('web_link.xml'); + } + $this->addAnnotatedClassesToCompile(array( '**Bundle\\Controller\\', '**Bundle\\Entity\\', diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.xml index 226e7000df..52aaab15ca 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.xml @@ -43,13 +43,5 @@ - - - - - - - - diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index fc324c24bb..29d07c5758 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -14,6 +14,7 @@ + @@ -62,6 +63,10 @@ + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web_link.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web_link.xml new file mode 100644 index 0000000000..bcaadc8061 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web_link.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index ff1904595a..353e959c68 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -238,6 +238,9 @@ class ConfigurationTest extends TestCase 'log' => true, 'throw' => true, ), + 'web_link' => array( + 'enabled' => !class_exists(FullStack::class), + ), ); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/web_link.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/web_link.php new file mode 100644 index 0000000000..990064cca9 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/web_link.php @@ -0,0 +1,5 @@ +loadFromExtension('framework', array( + 'web_link' => array('enabled' => true), +)); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/web_link.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/web_link.xml new file mode 100644 index 0000000000..a061f0b15b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/web_link.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/web_link.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/web_link.yml new file mode 100644 index 0000000000..4276aacbe2 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/web_link.yml @@ -0,0 +1,3 @@ +framework: + web_link: + enabled: true diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index c36d1eb248..8282f082ae 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -408,10 +408,10 @@ abstract class FrameworkExtensionTest extends TestCase $this->assertEquals('assets.custom_version_strategy', (string) $defaultPackage->getArgument(1)); } - public function testAssetHasPreloadListener() + public function testWebLink() { - $container = $this->createContainerFromFile('assets'); - $this->assertTrue($container->hasDefinition('asset.preload_listener')); + $container = $this->createContainerFromFile('web_link'); + $this->assertTrue($container->hasDefinition('web_link.add_link_header_listener')); } public function testTranslator() diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 8bf400dbda..f208b482cc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -34,6 +34,7 @@ "doctrine/cache": "~1.0" }, "require-dev": { + "fig/link-util": "^1.0", "symfony/asset": "~2.8|~3.0", "symfony/browser-kit": "~2.8|~3.0", "symfony/console": "~3.3", @@ -53,6 +54,7 @@ "symfony/workflow": "~3.3", "symfony/yaml": "~3.2", "symfony/property-info": "~3.3", + "symfony/web-link": "~3.3", "doctrine/annotations": "~1.0", "phpdocumentor/reflection-docblock": "^3.0", "twig/twig": "~1.26|~2.0", @@ -79,7 +81,8 @@ "symfony/validator": "For using validation", "symfony/yaml": "For using the debug:config and lint:yaml commands", "symfony/property-info": "For using the property_info service", - "symfony/process": "For using the server:run, server:start, server:stop, and server:status commands" + "symfony/process": "For using the server:run, server:start, server:stop, and server:status commands", + "symfony/web-link": "For using web links, features such as preloading, prefetching or prerendering" }, "autoload": { "psr-4": { "Symfony\\Bundle\\FrameworkBundle\\": "" }, diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php index b54f486ec5..790ab41a79 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php @@ -11,11 +11,13 @@ namespace Symfony\Bundle\TwigBundle\DependencyInjection; +use Symfony\Bridge\Twig\Extension\WebLinkExtension; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\WebLink\HttpHeaderSerializer; /** * TwigExtension. @@ -48,6 +50,12 @@ class TwigExtension extends Extension $container->removeDefinition('twig.translation.extractor'); } + if (class_exists(HttpHeaderSerializer::class)) { + $definition = $container->register('twig.extension.weblink', WebLinkExtension::class); + $definition->setPublic(false); + $definition->addArgument(new Reference('request_stack')); + } + foreach ($configs as $key => $config) { if (isset($config['globals'])) { foreach ($config['globals'] as $name => $value) { diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml index d1056db7bb..e879f09dc8 100644 --- a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.xml @@ -72,7 +72,6 @@ - diff --git a/src/Symfony/Bundle/TwigBundle/composer.json b/src/Symfony/Bundle/TwigBundle/composer.json index 2783ffdde0..e645ca2015 100644 --- a/src/Symfony/Bundle/TwigBundle/composer.json +++ b/src/Symfony/Bundle/TwigBundle/composer.json @@ -34,6 +34,7 @@ "symfony/templating": "~2.8|~3.0", "symfony/yaml": "~2.8|~3.0", "symfony/framework-bundle": "^3.2.2", + "symfony/web-link": "~3.3", "doctrine/annotations": "~1.0" }, "conflict": { diff --git a/src/Symfony/Component/Asset/Preload/PreloadManager.php b/src/Symfony/Component/Asset/Preload/PreloadManager.php deleted file mode 100644 index b070147a6c..0000000000 --- a/src/Symfony/Component/Asset/Preload/PreloadManager.php +++ /dev/null @@ -1,58 +0,0 @@ - - * - * 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 deleted file mode 100644 index 455f5a2ef7..0000000000 --- a/src/Symfony/Component/Asset/Preload/PreloadManagerInterface.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * 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/Preload/PreloadManagerTest.php b/src/Symfony/Component/Asset/Tests/Preload/PreloadManagerTest.php deleted file mode 100644 index e4b78df649..0000000000 --- a/src/Symfony/Component/Asset/Tests/Preload/PreloadManagerTest.php +++ /dev/null @@ -1,32 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Asset\Preload; - -use PHPUnit\Framework\TestCase; - -/** - * @author Kévin Dunglas - */ -class PreloadManagerTest extends 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/WebLink/.gitignore b/src/Symfony/Component/WebLink/.gitignore new file mode 100644 index 0000000000..c49a5d8df5 --- /dev/null +++ b/src/Symfony/Component/WebLink/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/WebLink/CHANGELOG.md b/src/Symfony/Component/WebLink/CHANGELOG.md new file mode 100644 index 0000000000..2204282c26 --- /dev/null +++ b/src/Symfony/Component/WebLink/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +3.3.0 +----- + + * added the component diff --git a/src/Symfony/Component/Asset/EventListener/PreloadListener.php b/src/Symfony/Component/WebLink/EventListener/AddLinkHeaderListener.php similarity index 54% rename from src/Symfony/Component/Asset/EventListener/PreloadListener.php rename to src/Symfony/Component/WebLink/EventListener/AddLinkHeaderListener.php index a50958c0ca..ef2fdee79d 100644 --- a/src/Symfony/Component/Asset/EventListener/PreloadListener.php +++ b/src/Symfony/Component/WebLink/EventListener/AddLinkHeaderListener.php @@ -9,26 +9,28 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Asset\EventListener; +namespace Symfony\Component\WebLink\EventListener; -use Symfony\Component\Asset\Preload\PreloadManager; -use Symfony\Component\Asset\Preload\PreloadManagerInterface; +use Psr\Link\LinkProviderInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\WebLink\HttpHeaderSerializer; /** - * Adds the preload Link HTTP header to the response. + * Adds the Link HTTP header to the response. * * @author Kévin Dunglas + * + * @final */ -class PreloadListener implements EventSubscriberInterface +class AddLinkHeaderListener implements EventSubscriberInterface { - private $preloadManager; + private $serializer; - public function __construct(PreloadManagerInterface $preloadManager) + public function __construct() { - $this->preloadManager = $preloadManager; + $this->serializer = new HttpHeaderSerializer(); } public function onKernelResponse(FilterResponseEvent $event) @@ -37,12 +39,12 @@ class PreloadListener implements EventSubscriberInterface return; } - if ($value = $this->preloadManager->buildLinkValue()) { - $event->getResponse()->headers->set('Link', $value, false); - - // Free memory - $this->preloadManager->clear(); + $linkProvider = $event->getRequest()->attributes->get('_links'); + if (!$linkProvider instanceof LinkProviderInterface || !$links = $linkProvider->getLinks()) { + return; } + + $event->getResponse()->headers->set('Link', $this->serializer->serialize($links), false); } /** diff --git a/src/Symfony/Component/WebLink/HttpHeaderSerializer.php b/src/Symfony/Component/WebLink/HttpHeaderSerializer.php new file mode 100644 index 0000000000..66bfa55ab4 --- /dev/null +++ b/src/Symfony/Component/WebLink/HttpHeaderSerializer.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\WebLink; + +use Psr\Link\LinkInterface; + +/** + * Serializes a list of Link instances to a HTTP Link header. + * + * @see https://tools.ietf.org/html/rfc5988 + * + * @author Kévin Dunglas + */ +final class HttpHeaderSerializer +{ + /** + * Builds the value of the "Link" HTTP header. + * + * @param LinkInterface[]|\Traversable $links + * + * @return string|null + */ + public function serialize($links) + { + $elements = array(); + foreach ($links as $link) { + if ($link->isTemplated()) { + continue; + } + + $attributesParts = array('', sprintf('rel="%s"', implode(' ', $link->getRels()))); + foreach ($link->getAttributes() as $key => $value) { + if (is_array($value)) { + foreach ($value as $v) { + $attributesParts[] = sprintf('%s="%s"', $key, $v); + } + + continue; + } + + if (!is_bool($value)) { + $attributesParts[] = sprintf('%s="%s"', $key, $value); + + continue; + } + + if (true === $value) { + $attributesParts[] = $key; + } + } + + $elements[] = sprintf('<%s>%s', $link->getHref(), implode('; ', $attributesParts)); + } + + return $elements ? implode(',', $elements) : null; + } +} diff --git a/src/Symfony/Component/WebLink/LICENSE b/src/Symfony/Component/WebLink/LICENSE new file mode 100644 index 0000000000..17d16a1336 --- /dev/null +++ b/src/Symfony/Component/WebLink/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2004-2017 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/WebLink/README.md b/src/Symfony/Component/WebLink/README.md new file mode 100644 index 0000000000..61fd3bff67 --- /dev/null +++ b/src/Symfony/Component/WebLink/README.md @@ -0,0 +1,18 @@ +WebLink Component +================= + +The WebLink component manages links between resources. It is particularly useful to advise clients +to preload and prefetch documents through HTTP and HTTP/2 pushes. + +This component implements the [HTML5's Links](https://www.w3.org/TR/html5/links.html), [Preload](https://www.w3.org/TR/preload/) +and [Resource Hints](https://www.w3.org/TR/resource-hints/) W3C's specifications. +It can also be used with extensions defined in the [HTML5 link type extensions wiki](http://microformats.org/wiki/existing-rel-values#HTML5_link_type_extensions). + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/weblink/introduction.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Asset/Tests/EventListener/PreloadListenerTest.php b/src/Symfony/Component/WebLink/Tests/EventListener/AddLinkHeaderListenerTest.php similarity index 70% rename from src/Symfony/Component/Asset/Tests/EventListener/PreloadListenerTest.php rename to src/Symfony/Component/WebLink/Tests/EventListener/AddLinkHeaderListenerTest.php index 50ad22f246..8a0aabc69c 100644 --- a/src/Symfony/Component/Asset/Tests/EventListener/PreloadListenerTest.php +++ b/src/Symfony/Component/WebLink/Tests/EventListener/AddLinkHeaderListenerTest.php @@ -9,11 +9,13 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Asset\Tests\EventListener; +namespace Symfony\Component\WebLink\Tests\EventListener; +use Fig\Link\GenericLinkProvider; +use Fig\Link\Link; use PHPUnit\Framework\TestCase; -use Symfony\Component\Asset\EventListener\PreloadListener; -use Symfony\Component\Asset\Preload\PreloadManager; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\WebLink\EventListener\AddLinkHeaderListener; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; @@ -22,18 +24,18 @@ use Symfony\Component\HttpKernel\KernelEvents; /** * @author Kévin Dunglas */ -class PreloadListenerTest extends TestCase +class AddLinkHeaderListenerTest extends TestCase { public function testOnKernelResponse() { - $manager = new PreloadManager(); - $manager->addResource('/foo'); - - $subscriber = new PreloadListener($manager); + $request = new Request(array(), array(), array('_links' => new GenericLinkProvider(array(new Link('preload', '/foo'))))); $response = new Response('', 200, array('Link' => '; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"')); + $subscriber = new AddLinkHeaderListener(); + $event = $this->getMockBuilder(FilterResponseEvent::class)->disableOriginalConstructor()->getMock(); $event->method('isMasterRequest')->willReturn(true); + $event->method('getRequest')->willReturn($request); $event->method('getResponse')->willReturn($response); $subscriber->onKernelResponse($event); @@ -42,15 +44,14 @@ class PreloadListenerTest extends TestCase $expected = array( '; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"', - '; rel=preload', + '; 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()); + $this->assertEquals(array(KernelEvents::RESPONSE => 'onKernelResponse'), AddLinkHeaderListener::getSubscribedEvents()); } } diff --git a/src/Symfony/Component/WebLink/Tests/HttpHeaderSerializerTest.php b/src/Symfony/Component/WebLink/Tests/HttpHeaderSerializerTest.php new file mode 100644 index 0000000000..c5d0716ef9 --- /dev/null +++ b/src/Symfony/Component/WebLink/Tests/HttpHeaderSerializerTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\WebLink\Tests; + +use Fig\Link\GenericLinkProvider; +use Fig\Link\Link; +use PHPUnit\Framework\TestCase; +use Symfony\Component\WebLink\HttpHeaderSerializer; + +class HttpHeaderSerializerTest extends TestCase +{ + /** + * @var HttpHeaderSerializer + */ + private $serializer; + + protected function setUp() + { + $this->serializer = new HttpHeaderSerializer(); + } + + public function testSerialize() + { + $links = array( + new Link('prerender', '/1'), + (new Link('dns-prefetch', '/2'))->withAttribute('pr', 0.7), + (new Link('preload', '/3'))->withAttribute('as', 'script')->withAttribute('nopush', false), + (new Link('preload', '/4'))->withAttribute('as', 'image')->withAttribute('nopush', true), + (new Link('alternate', '/5'))->withRel('next')->withAttribute('hreflang', array('fr', 'de'))->withAttribute('title', 'Hello'), + ); + + $this->assertEquals('; rel="prerender",; rel="dns-prefetch"; pr="0.7",; rel="preload"; as="script",; rel="preload"; as="image"; nopush,; rel="alternate next"; hreflang="fr"; hreflang="de"; title="Hello"', $this->serializer->serialize($links)); + } + + public function testSerializeEmpty() + { + $this->assertNull($this->serializer->serialize(array())); + } +} diff --git a/src/Symfony/Component/WebLink/composer.json b/src/Symfony/Component/WebLink/composer.json new file mode 100644 index 0000000000..47c2c80503 --- /dev/null +++ b/src/Symfony/Component/WebLink/composer.json @@ -0,0 +1,43 @@ +{ + "name": "symfony/web-link", + "type": "library", + "description": "Symfony WebLink Component", + "keywords": ["link", "psr13", "http", "HTTP/2", "preload", "prefetch", "prerender", "dns-prefetch", "push", "performance"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=5.5.9", + "fig/link-util": "^1.0", + "psr/link": "^1.0" + }, + "suggest": { + "symfony/http-kernel": "" + }, + "require-dev": { + "symfony/event-dispatcher": "^2.8|^3.0", + "symfony/http-foundation": "^2.8|^3.0", + "symfony/http-kernel": "^2.8|^3.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Link\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "3.3-dev" + } + } +} diff --git a/src/Symfony/Component/WebLink/phpunit.xml.dist b/src/Symfony/Component/WebLink/phpunit.xml.dist new file mode 100644 index 0000000000..e07aade334 --- /dev/null +++ b/src/Symfony/Component/WebLink/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + +