From c14e89be718b85c81e929ef01a214c3868ac0cc0 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Sat, 5 Apr 2025 11:48:45 -0400 Subject: [PATCH] [Icons] improve DX when `symfony/http-client` is not installed - throw exception if http-client is not available - always enable iconify, iconify commands and on-demand registry - display "potential" missing icons to `ux:icons:warm-cache` --- src/Icons/CHANGELOG.md | 4 ++ src/Icons/config/iconify.php | 56 ------------------- src/Icons/config/services.php | 39 +++++++++++++ src/Icons/doc/index.rst | 24 ++++++++ src/Icons/src/Command/WarmCacheCommand.php | 5 ++ .../DependencyInjection/UXIconsExtension.php | 27 ++++----- .../HttpClientNotInstalledException.php | 21 +++++++ src/Icons/src/IconCacheWarmer.php | 8 +-- src/Icons/src/Iconify.php | 31 ++++++---- src/Icons/src/Registry/ChainIconRegistry.php | 10 +++- .../src/Registry/IconifyOnDemandRegistry.php | 7 ++- src/Icons/tests/Unit/IconifyTest.php | 18 +++--- 12 files changed, 152 insertions(+), 98 deletions(-) delete mode 100644 src/Icons/config/iconify.php create mode 100644 src/Icons/src/Exception/HttpClientNotInstalledException.php diff --git a/src/Icons/CHANGELOG.md b/src/Icons/CHANGELOG.md index 48a8ba77b5a..52b4fb26f97 100644 --- a/src/Icons/CHANGELOG.md +++ b/src/Icons/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.25.0 + +- Improve DX when `symfony/http-client` is not installed. + ## 2.24.0 - Add `xmlns` attribute to icons downloaded with Iconify, to correctly render icons browser as an external file, in SVG editors, and in files explorers or text editors previews. diff --git a/src/Icons/config/iconify.php b/src/Icons/config/iconify.php deleted file mode 100644 index 52aa705a887..00000000000 --- a/src/Icons/config/iconify.php +++ /dev/null @@ -1,56 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\DependencyInjection\Loader\Configurator; - -use Symfony\UX\Icons\Command\ImportIconCommand; -use Symfony\UX\Icons\Command\LockIconsCommand; -use Symfony\UX\Icons\Command\SearchIconCommand; -use Symfony\UX\Icons\Iconify; -use Symfony\UX\Icons\Registry\IconifyOnDemandRegistry; - -return static function (ContainerConfigurator $container): void { - $container->services() - ->set('.ux_icons.iconify_on_demand_registry', IconifyOnDemandRegistry::class) - ->args([ - service('.ux_icons.iconify'), - ]) - ->tag('ux_icons.registry') - - ->set('.ux_icons.iconify', Iconify::class) - ->args([ - service('.ux_icons.cache'), - abstract_arg('endpoint'), - service('http_client')->nullOnInvalid(), - ]) - - ->set('.ux_icons.command.import', ImportIconCommand::class) - ->args([ - service('.ux_icons.iconify'), - service('.ux_icons.local_svg_icon_registry'), - ]) - ->tag('console.command') - - ->set('.ux_icons.command.lock', LockIconsCommand::class) - ->args([ - service('.ux_icons.iconify'), - service('.ux_icons.local_svg_icon_registry'), - service('.ux_icons.icon_finder'), - ]) - ->tag('console.command') - - ->set('.ux_icons.command.search', SearchIconCommand::class) - ->args([ - service('.ux_icons.iconify'), - ]) - ->tag('console.command') - ; -}; diff --git a/src/Icons/config/services.php b/src/Icons/config/services.php index 537c0cbb892..4b29a381f7a 100644 --- a/src/Icons/config/services.php +++ b/src/Icons/config/services.php @@ -11,12 +11,17 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\UX\Icons\Command\ImportIconCommand; +use Symfony\UX\Icons\Command\LockIconsCommand; +use Symfony\UX\Icons\Command\SearchIconCommand; use Symfony\UX\Icons\Command\WarmCacheCommand; use Symfony\UX\Icons\IconCacheWarmer; +use Symfony\UX\Icons\Iconify; use Symfony\UX\Icons\IconRenderer; use Symfony\UX\Icons\IconRendererInterface; use Symfony\UX\Icons\Registry\CacheIconRegistry; use Symfony\UX\Icons\Registry\ChainIconRegistry; +use Symfony\UX\Icons\Registry\IconifyOnDemandRegistry; use Symfony\UX\Icons\Registry\LocalSvgIconRegistry; use Symfony\UX\Icons\Twig\IconFinder; use Symfony\UX\Icons\Twig\UXIconExtension; @@ -86,5 +91,39 @@ service('.ux_icons.cache_warmer'), ]) ->tag('console.command') + + ->set('.ux_icons.iconify', Iconify::class) + ->args([ + service('.ux_icons.cache'), + abstract_arg('endpoint'), + service('http_client')->nullOnInvalid(), + ]) + + ->set('.ux_icons.iconify_on_demand_registry', IconifyOnDemandRegistry::class) + ->args([ + service('.ux_icons.iconify'), + ]) + ->tag('ux_icons.registry', ['priority' => -10]) + + ->set('.ux_icons.command.import', ImportIconCommand::class) + ->args([ + service('.ux_icons.iconify'), + service('.ux_icons.local_svg_icon_registry'), + ]) + ->tag('console.command') + + ->set('.ux_icons.command.lock', LockIconsCommand::class) + ->args([ + service('.ux_icons.iconify'), + service('.ux_icons.local_svg_icon_registry'), + service('.ux_icons.icon_finder'), + ]) + ->tag('console.command') + + ->set('.ux_icons.command.search', SearchIconCommand::class) + ->args([ + service('.ux_icons.iconify'), + ]) + ->tag('console.command') ; }; diff --git a/src/Icons/doc/index.rst b/src/Icons/doc/index.rst index 5700e12a7b5..607e0160354 100644 --- a/src/Icons/doc/index.rst +++ b/src/Icons/doc/index.rst @@ -286,6 +286,18 @@ the report to overwrite existing icons by using the ``--force`` option: $ php bin/console ux:icons:lock --force +.. caution:: + + The process to find icons to lock in your Twig templates is imperfect. It + looks for any string that matches the pattern ``something:something`` so + it's probable there will be false positives. This command should not be used + to audit the icons in your templates in an automated way. Add ``-v`` see + *potential* invalid icons: + + .. code-block:: terminal + + $ php bin/console ux:icons:lock -v + Rendering Icons --------------- @@ -472,6 +484,18 @@ In production, you can pre-warm the cache by running the following command: This command looks in all your Twig templates for ``ux_icon()`` calls and ```` tags and caches the icons it finds. +.. caution:: + + The process to find icons to cache in your Twig templates is imperfect. It + looks for any string that matches the pattern ``something:something`` so + it's probable there will be false positives. This command should not be used + to audit the icons in your templates in an automated way. Add ``-v`` see + *potential* invalid icons: + + .. code-block:: terminal + + $ php bin/console ux:icons:warm-cache -v + .. caution:: Icons that have a name built dynamically will not be cached. It's advised to diff --git a/src/Icons/src/Command/WarmCacheCommand.php b/src/Icons/src/Command/WarmCacheCommand.php index 1451a7cfc89..8263183c700 100644 --- a/src/Icons/src/Command/WarmCacheCommand.php +++ b/src/Icons/src/Command/WarmCacheCommand.php @@ -45,6 +45,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->writeln(\sprintf(' Warmed icon %s.', $name)); } }, + onFailure: function (string $name, \Exception $e) use ($io) { + if ($io->isVerbose()) { + $io->writeln(\sprintf(' Failed to warm (potential) icon %s.', $name)); + } + } ); $io->success('Icon cache warmed.'); diff --git a/src/Icons/src/DependencyInjection/UXIconsExtension.php b/src/Icons/src/DependencyInjection/UXIconsExtension.php index 85672aebeec..b93240911cb 100644 --- a/src/Icons/src/DependencyInjection/UXIconsExtension.php +++ b/src/Icons/src/DependencyInjection/UXIconsExtension.php @@ -18,7 +18,6 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension; -use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\UX\Icons\Iconify; /** @@ -87,7 +86,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->arrayNode('iconify') ->info('Configuration for the remote icon service.') - ->{interface_exists(HttpClientInterface::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->canBeDisabled() ->children() ->booleanNode('on_demand') ->info('Whether to download icons "on demand".') @@ -164,26 +163,24 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container ->setArgument(1, $mergedConfig['ignore_not_found']) ; - if ($mergedConfig['iconify']['enabled']) { - $loader->load('iconify.php'); + $container->getDefinition('.ux_icons.iconify') + ->setArgument(1, $mergedConfig['iconify']['endpoint']); - $container->getDefinition('.ux_icons.iconify') - ->setArgument(1, $mergedConfig['iconify']['endpoint']); + $container->getDefinition('.ux_icons.iconify_on_demand_registry') + ->setArgument(1, $iconSetAliases); - $container->getDefinition('.ux_icons.iconify_on_demand_registry') - ->setArgument(1, $iconSetAliases); + $container->getDefinition('.ux_icons.command.lock') + ->setArgument(3, $mergedConfig['aliases']) + ->setArgument(4, $iconSetAliases); - $container->getDefinition('.ux_icons.command.lock') - ->setArgument(3, $mergedConfig['aliases']) - ->setArgument(4, $iconSetAliases); - - if (!$mergedConfig['iconify']['on_demand']) { - $container->removeDefinition('.ux_icons.iconify_on_demand_registry'); - } + if (!$mergedConfig['iconify']['on_demand'] || !$mergedConfig['iconify']['enabled']) { + $container->removeDefinition('.ux_icons.iconify_on_demand_registry'); } if (!$container->getParameter('kernel.debug')) { $container->removeDefinition('.ux_icons.command.import'); + $container->removeDefinition('.ux_icons.command.search'); + $container->removeDefinition('.ux_icons.command.lock'); } } } diff --git a/src/Icons/src/Exception/HttpClientNotInstalledException.php b/src/Icons/src/Exception/HttpClientNotInstalledException.php new file mode 100644 index 00000000000..eb624ade512 --- /dev/null +++ b/src/Icons/src/Exception/HttpClientNotInstalledException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons\Exception; + +/** + * @author Kevin Bond + * + * @internal + */ +final class HttpClientNotInstalledException extends \LogicException +{ +} diff --git a/src/Icons/src/IconCacheWarmer.php b/src/Icons/src/IconCacheWarmer.php index 04215ae713f..2a63ad8499d 100644 --- a/src/Icons/src/IconCacheWarmer.php +++ b/src/Icons/src/IconCacheWarmer.php @@ -27,8 +27,8 @@ public function __construct(private CacheIconRegistry $registry, private IconFin } /** - * @param callable(string,Icon):void|null $onSuccess - * @param callable(string):void|null $onFailure + * @param callable(string,Icon):void|null $onSuccess + * @param callable(string,\Exception):void|null $onFailure */ public function warm(?callable $onSuccess = null, ?callable $onFailure = null): void { @@ -40,8 +40,8 @@ public function warm(?callable $onSuccess = null, ?callable $onFailure = null): $icon = $this->registry->get($name, refresh: true); $onSuccess($name, $icon); - } catch (IconNotFoundException) { - $onFailure($name); + } catch (IconNotFoundException $e) { + $onFailure($name, $e); } } } diff --git a/src/Icons/src/Iconify.php b/src/Icons/src/Iconify.php index 55c5051d335..7f7db1ea552 100644 --- a/src/Icons/src/Iconify.php +++ b/src/Icons/src/Iconify.php @@ -16,6 +16,7 @@ use Symfony\Component\HttpClient\ScopingHttpClient; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\UX\Icons\Exception\HttpClientNotInstalledException; use Symfony\UX\Icons\Exception\IconNotFoundException; /** @@ -39,15 +40,10 @@ final class Iconify public function __construct( private CacheInterface $cache, - string $endpoint = self::API_ENDPOINT, - ?HttpClientInterface $http = null, + private string $endpoint = self::API_ENDPOINT, + private ?HttpClientInterface $httpClient = null, ?int $maxIconsQueryLength = null, ) { - if (!class_exists(HttpClient::class)) { - throw new \LogicException('You must install "symfony/http-client" to use Iconify. Try running "composer require symfony/http-client".'); - } - - $this->http = ScopingHttpClient::forBaseUri($http ?? HttpClient::create(), $endpoint); $this->maxIconsQueryLength = min(self::MAX_ICONS_QUERY_LENGTH, $maxIconsQueryLength ?? self::MAX_ICONS_QUERY_LENGTH); } @@ -62,7 +58,7 @@ public function fetchIcon(string $prefix, string $name): Icon throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name)); } - $response = $this->http->request('GET', \sprintf('/%s.json?icons=%s', $prefix, $name)); + $response = $this->http()->request('GET', \sprintf('/%s.json?icons=%s', $prefix, $name)); if (200 !== $response->getStatusCode()) { throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name)); @@ -112,7 +108,7 @@ public function fetchIcons(string $prefix, array $names): array throw new \InvalidArgumentException('The query string is too long.'); } - $response = $this->http->request('GET', \sprintf('/%s.json', $prefix), [ + $response = $this->http()->request('GET', \sprintf('/%s.json', $prefix), [ 'headers' => [ 'Accept' => 'application/json', ], @@ -158,7 +154,7 @@ public function getIconSets(): array public function searchIcons(string $prefix, string $query) { - $response = $this->http->request('GET', '/search', [ + $response = $this->http()->request('GET', '/search', [ 'query' => [ 'query' => $query, 'prefix' => $prefix, @@ -205,9 +201,22 @@ public function chunk(string $prefix, array $names): iterable private function sets(): \ArrayObject { return $this->sets ??= $this->cache->get('iconify-sets', function () { - $response = $this->http->request('GET', '/collections'); + $response = $this->http()->request('GET', '/collections'); return new \ArrayObject($response->toArray()); }); } + + private function http(): HttpClientInterface + { + if (isset($this->http)) { + return $this->http; + } + + if (!class_exists(HttpClient::class)) { + throw new HttpClientNotInstalledException('You must install "symfony/http-client" to use icons from ux.symfony.com/icons. Try running "composer require symfony/http-client".'); + } + + return $this->http = ScopingHttpClient::forBaseUri($this->httpClient ?? HttpClient::create(), $this->endpoint); + } } diff --git a/src/Icons/src/Registry/ChainIconRegistry.php b/src/Icons/src/Registry/ChainIconRegistry.php index c6e882cff59..d476d25c056 100644 --- a/src/Icons/src/Registry/ChainIconRegistry.php +++ b/src/Icons/src/Registry/ChainIconRegistry.php @@ -34,10 +34,16 @@ public function get(string $name): Icon foreach ($this->registries as $registry) { try { return $registry->get($name); - } catch (IconNotFoundException) { + } catch (IconNotFoundException $e) { } } - throw new IconNotFoundException(\sprintf('Icon "%s" not found.', $name)); + $message = \sprintf('Icon "%s" not found.', $name); + + if (isset($e)) { + $message .= " {$e->getMessage()}"; + } + + throw new IconNotFoundException($message, previous: $e ?? null); } } diff --git a/src/Icons/src/Registry/IconifyOnDemandRegistry.php b/src/Icons/src/Registry/IconifyOnDemandRegistry.php index 5931854ca75..60c1b591cc4 100644 --- a/src/Icons/src/Registry/IconifyOnDemandRegistry.php +++ b/src/Icons/src/Registry/IconifyOnDemandRegistry.php @@ -11,6 +11,7 @@ namespace Symfony\UX\Icons\Registry; +use Symfony\UX\Icons\Exception\HttpClientNotInstalledException; use Symfony\UX\Icons\Exception\IconNotFoundException; use Symfony\UX\Icons\Icon; use Symfony\UX\Icons\Iconify; @@ -36,6 +37,10 @@ public function get(string $name): Icon } [$prefix, $icon] = $parts; - return $this->iconify->fetchIcon($this->prefixAliases[$prefix] ?? $prefix, $icon); + try { + return $this->iconify->fetchIcon($this->prefixAliases[$prefix] ?? $prefix, $icon); + } catch (HttpClientNotInstalledException $e) { + throw new IconNotFoundException($e->getMessage()); + } } } diff --git a/src/Icons/tests/Unit/IconifyTest.php b/src/Icons/tests/Unit/IconifyTest.php index cec6172cb7d..8516568b028 100644 --- a/src/Icons/tests/Unit/IconifyTest.php +++ b/src/Icons/tests/Unit/IconifyTest.php @@ -29,7 +29,7 @@ public function testFetchIcon(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -55,7 +55,7 @@ public function testFetchIconByAlias(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -96,7 +96,7 @@ public function testFetchIconUsesIconsetViewBoxHeight(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [ 'height' => 17, @@ -124,7 +124,7 @@ public function testFetchIconThrowsWhenViewBoxCannotBeComputed(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -149,7 +149,7 @@ public function testFetchIconThrowsWhenStatusCodeNot200(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -168,7 +168,7 @@ public function testFetchIcons(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -199,7 +199,7 @@ public function testFetchIconsByAliases(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'mdi' => [], ]), @@ -239,7 +239,7 @@ public function testFetchIconsThrowsWithInvalidIconNames(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -256,7 +256,7 @@ public function testFetchIconsThrowsWithTooManyIcons(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]),