diff --git a/src/Icons/.gitattributes b/src/Icons/.gitattributes new file mode 100644 index 00000000000..fe55079a9ec --- /dev/null +++ b/src/Icons/.gitattributes @@ -0,0 +1,5 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/.symfony.bundle.yaml export-ignore +/assets/src export-ignore +/assets/test export-ignore diff --git a/src/Icons/.gitignore b/src/Icons/.gitignore new file mode 100644 index 00000000000..6b4669489f4 --- /dev/null +++ b/src/Icons/.gitignore @@ -0,0 +1,4 @@ +/vendor +/composer.lock +/var +/.phpunit.result.cache diff --git a/src/Icons/.symfony.bundle.yaml b/src/Icons/.symfony.bundle.yaml new file mode 100644 index 00000000000..6d9a74acb76 --- /dev/null +++ b/src/Icons/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["2.x"] +maintained_branches: ["2.x"] +doc_dir: "doc" diff --git a/src/Icons/LICENSE b/src/Icons/LICENSE new file mode 100644 index 00000000000..0ed3a246553 --- /dev/null +++ b/src/Icons/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-present 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/Icons/README.md b/src/Icons/README.md new file mode 100644 index 00000000000..709f05fe636 --- /dev/null +++ b/src/Icons/README.md @@ -0,0 +1,66 @@ +# Symfony UX Icons + +## Installation + +```bash +composer require symfony/ux-icons +``` + +## Add Icons + +No icons are provided by this package. Add your svg icons to the `templates/icons/` directory and commit them. +The name of the file is used as the name of the icon (`name.svg` will be named `name`). + +When icons are rendered, any attributes (except `viewBox`) on the file's `` element will +be removed. This allows you to copy/paste icons from sites like +[heroicons.com](https://heroicons.com/) and not worry about hard-coded attributes interfering with +your design. + +## Usage + +```twig +{{ ux_icon('user-profile', {class: 'w-4 h-4'}) }} + +{{ ux_icon('sub-dir:user-profile', {class: 'w-4 h-4'}) }} +``` + +### HTML Syntax + +> [!NOTE] +> `symfony/ux-twig-component` is required to use the HTML syntax. + +```html + + + +``` + +> [!TIP] +> The Twig component _name_ can be [configured](#full-default-configuration). For instance, you can change +> it to `Icon` to use ``. + +## Caching + +To avoid having to parse icon files on every request, icons are cached. + +During container warmup (`cache:warmup` and `cache:clear`), the icon cache is warmed. + +> [!NOTE] +> During development, if you change an icon, you will need to clear the cache (`bin/console cache:clear`) +> to see the changes. + +## Full Default Configuration + +```yaml +ux_icons: + # The local directory where icons are stored. + icon_dir: '%kernel.project_dir%/templates/icons' + + # The name of the Twig component to use for rendering icons. + twig_component_name: 'UX:Icon' + + # Default attributes to add to all icons. + default_icon_attributes: + # Default: + fill: currentColor +``` diff --git a/src/Icons/composer.json b/src/Icons/composer.json new file mode 100644 index 00000000000..74c53686ad5 --- /dev/null +++ b/src/Icons/composer.json @@ -0,0 +1,52 @@ +{ + "name": "symfony/ux-icons", + "type": "symfony-bundle", + "description": "Twig component to use svg icons in your Symfony app", + "keywords": [ + "symfony-ux", + "twig", + "icons" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\Icons\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\Icons\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.1", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/twig-bundle": "^6.4|7.0" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/phpunit-bridge": "^6.4|^7.0", + "symfony/ux-twig-component": "^2.14", + "zenstruck/console-test": "^1.5" + }, + "config": { + "sort-packages": true + }, + "conflict": { + "symfony/flex": "<1.13" + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +} diff --git a/src/Icons/config/services.php b/src/Icons/config/services.php new file mode 100644 index 00000000000..98408835adf --- /dev/null +++ b/src/Icons/config/services.php @@ -0,0 +1,57 @@ + + * + * 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\IconRenderer; +use Symfony\UX\Icons\Registry\CacheIconRegistry; +use Symfony\UX\Icons\Registry\LocalSvgIconRegistry; +use Symfony\UX\Icons\Twig\UXIconComponent; +use Symfony\UX\Icons\Twig\UXIconComponentListener; +use Symfony\UX\Icons\Twig\UXIconExtension; + +return static function (ContainerConfigurator $container): void { + $container->services() + ->set('.ux_icons.cache_icon_registry', CacheIconRegistry::class) + ->args([ + iterator([service('.ux_icons.local_svg_icon_registry')]), + service('cache.system'), + ]) + ->tag('kernel.cache_warmer') + + ->set('.ux_icons.local_svg_icon_registry', LocalSvgIconRegistry::class) + ->args([ + abstract_arg('icon_dir'), + ]) + + ->alias('.ux_icons.icon_registry', '.ux_icons.cache_icon_registry') + + ->set('.ux_icons.twig_icon_extension', UXIconExtension::class) + ->tag('twig.extension') + + ->set('.ux_icons.icon_renderer', IconRenderer::class) + ->args([ + service('.ux_icons.icon_registry'), + ]) + ->tag('twig.runtime') + + ->set('.ux_icons.twig_component_listener', UXIconComponentListener::class) + ->args([ + service('.ux_icons.icon_renderer'), + ]) + ->tag('kernel.event_listener', [ + 'event' => 'Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent', + 'method' => 'onPreCreateForRender', + ]) + + ->set('.ux_icons.twig_component.icon', UXIconComponent::class) + ; +}; diff --git a/src/Icons/phpunit.xml.dist b/src/Icons/phpunit.xml.dist new file mode 100644 index 00000000000..6f2c1f9c28a --- /dev/null +++ b/src/Icons/phpunit.xml.dist @@ -0,0 +1,32 @@ + + + + + + + + + + + + + ./tests/ + + + + + + ./src + + + + + + + diff --git a/src/Icons/src/Command/WarmIconCacheCommand.php b/src/Icons/src/Command/WarmIconCacheCommand.php new file mode 100644 index 00000000000..2b0d9871302 --- /dev/null +++ b/src/Icons/src/Command/WarmIconCacheCommand.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\UX\Icons\Registry\CacheIconRegistry; + +/** + * @author Kevin Bond + * + * @internal + */ +#[AsCommand( + name: 'ux:icons:warm-cache', + description: 'Warm the icon cache', +)] +final class WarmIconCacheCommand extends Command +{ + public function __construct(private CacheIconRegistry $registry) + { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + foreach ($io->progressIterate($this->registry) as $name) { + $this->registry->get($name, refresh: true); + } + + $io->success('Icon cache warmed.'); + + return Command::SUCCESS; + } +} diff --git a/src/Icons/src/DependencyInjection/UXIconsExtension.php b/src/Icons/src/DependencyInjection/UXIconsExtension.php new file mode 100644 index 00000000000..d0649a00c91 --- /dev/null +++ b/src/Icons/src/DependencyInjection/UXIconsExtension.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons\DependencyInjection; + +use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension; +use Symfony\UX\Icons\Twig\UXIconExtension; + +/** + * @author Kevin Bond + * + * @internal + */ +final class UXIconsExtension extends ConfigurableExtension implements ConfigurationInterface +{ + public function getConfigTreeBuilder(): TreeBuilder + { + $builder = new TreeBuilder('ux_icons'); + $rootNode = $builder->getRootNode(); + + $rootNode + ->children() + ->scalarNode('icon_dir') + ->info('The local directory where icons are stored.') + ->defaultValue('%kernel.project_dir%/templates/icons') + ->end() + ->scalarNode('twig_component_name') + ->info('The name of the Twig component to use for rendering icons.') + ->defaultValue('UX:Icon') + ->end() + ->variableNode('default_icon_attributes') + ->info('Default attributes to add to all icons.') + ->defaultValue(['fill' => 'currentColor']) + ->end() + ->end() + ; + + return $builder; + } + + public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface + { + return $this; + } + + protected function loadInternal(array $mergedConfig, ContainerBuilder $container): void // @phpstan-ignore-line + { + $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../../config')); + $loader->load('services.php'); + + $container->getDefinition('.ux_icons.local_svg_icon_registry') + ->setArguments([ + $mergedConfig['icon_dir'], + ]) + ; + + $container->getDefinition('.ux_icons.icon_renderer') + ->setArgument(1, $mergedConfig['default_icon_attributes']) + ; + + $container->getDefinition('.ux_icons.twig_icon_extension') + ->setArgument(0, $mergedConfig['twig_component_name']) + ; + + $container->getDefinition('.ux_icons.twig_component_listener') + ->setArgument(1, $mergedConfig['twig_component_name']) + ; + + $container->getDefinition('.ux_icons.twig_component.icon') + ->addTag('twig.component', [ + 'key' => $mergedConfig['twig_component_name'], + 'template' => '@UXIcons/Icon.html.twig', + ]) + ; + } +} diff --git a/src/Icons/src/Exception/IconNotFoundException.php b/src/Icons/src/Exception/IconNotFoundException.php new file mode 100644 index 00000000000..185e475a0b2 --- /dev/null +++ b/src/Icons/src/Exception/IconNotFoundException.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 IconNotFoundException extends \RuntimeException +{ +} diff --git a/src/Icons/src/IconRegistryInterface.php b/src/Icons/src/IconRegistryInterface.php new file mode 100644 index 00000000000..5c0171a4bee --- /dev/null +++ b/src/Icons/src/IconRegistryInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons; + +use Symfony\UX\Icons\Exception\IconNotFoundException; + +/** + * @author Kevin Bond + * + * @extends \IteratorAggregate + * + * @internal + */ +interface IconRegistryInterface extends \IteratorAggregate, \Countable +{ + /** + * @return array{0: string, 1: array} + * + * @throws IconNotFoundException + */ + public function get(string $name): array; +} diff --git a/src/Icons/src/IconRenderer.php b/src/Icons/src/IconRenderer.php new file mode 100644 index 00000000000..06ffaf13654 --- /dev/null +++ b/src/Icons/src/IconRenderer.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons; + +/** + * @author Kevin Bond + * + * @internal + */ +final class IconRenderer +{ + public function __construct( + private IconRegistryInterface $registry, + private array $defaultIconAttributes = [], + ) { + } + + /** + * @param array $attributes + */ + public function renderIcon(string $name, array $attributes = []): string + { + [$content, $iconAttr] = $this->registry->get($name); + + $iconAttr = array_merge($iconAttr, $this->defaultIconAttributes); + + return sprintf( + '%s', + self::normalizeAttributes([...$iconAttr, ...$attributes]), + $content, + ); + } + + /** + * @param array $attributes + */ + private static function normalizeAttributes(array $attributes): string + { + return array_reduce( + array_keys($attributes), + static function (string $carry, string $key) use ($attributes) { + $value = $attributes[$key]; + + return match ($value) { + true => "{$carry} {$key}", + false => $carry, + default => sprintf('%s %s="%s"', $carry, $key, $value), + }; + }, + '' + ); + } +} diff --git a/src/Icons/src/Registry/CacheIconRegistry.php b/src/Icons/src/Registry/CacheIconRegistry.php new file mode 100644 index 00000000000..7609aab53e6 --- /dev/null +++ b/src/Icons/src/Registry/CacheIconRegistry.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons\Registry; + +use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\UX\Icons\Exception\IconNotFoundException; +use Symfony\UX\Icons\IconRegistryInterface; + +/** + * @author Kevin Bond + * + * @internal + */ +final class CacheIconRegistry implements IconRegistryInterface, CacheWarmerInterface +{ + /** + * @param IconRegistryInterface[] $registries + */ + public function __construct(private \Traversable $registries, private CacheInterface $cache) + { + } + + public function get(string $name, bool $refresh = false): array + { + return $this->cache->get( + sprintf('ux-icon-%s', str_replace([':', '/'], ['-', '-'], $name)), + function () use ($name) { + foreach ($this->registries as $registry) { + try { + return $registry->get($name); + } catch (IconNotFoundException) { + // ignore + } + } + + throw new IconNotFoundException(sprintf('The icon "%s" does not exist.', $name)); + }, + beta: $refresh ? \INF : null, + ); + } + + public function getIterator(): \Traversable + { + foreach ($this->registries as $registry) { + yield from $registry; + } + } + + public function count(): int + { + $count = 0; + + foreach ($this->registries as $registry) { + $count += \count($registry); + } + + return $count; + } + + public function isOptional(): bool + { + return true; + } + + public function warmUp(string $cacheDir, ?string $buildDir = null): array + { + foreach ($this as $name) { + $this->get($name, refresh: true); + } + + return []; + } +} diff --git a/src/Icons/src/Registry/LocalSvgIconRegistry.php b/src/Icons/src/Registry/LocalSvgIconRegistry.php new file mode 100644 index 00000000000..15cbab42370 --- /dev/null +++ b/src/Icons/src/Registry/LocalSvgIconRegistry.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons\Registry; + +use Symfony\Component\Finder\Finder; +use Symfony\UX\Icons\Exception\IconNotFoundException; +use Symfony\UX\Icons\IconRegistryInterface; + +/** + * @author Kevin Bond + * + * @internal + */ +final class LocalSvgIconRegistry implements IconRegistryInterface +{ + public function __construct(private string $iconDir) + { + } + + public function get(string $name): array + { + if (!file_exists($filename = sprintf('%s/%s.svg', $this->iconDir, str_replace(':', '/', $name)))) { + throw new IconNotFoundException(sprintf('The icon "%s" (%s) does not exist.', $name, $filename)); + } + + $svg = file_get_contents($filename) ?: throw new \RuntimeException(sprintf('The icon file "%s" could not be read.', $filename)); + $doc = new \DOMDocument(); + $doc->preserveWhiteSpace = false; + + try { + $doc->loadXML($svg); + } catch (\Throwable $e) { + throw new \RuntimeException(sprintf('The icon file "%s" does not contain a valid SVG.', $filename), previous: $e); + } + + $svgElements = $doc->getElementsByTagName('svg'); + + if (0 === $svgElements->length) { + throw new \RuntimeException(sprintf('The icon file "%s" does not contain a valid SVG.', $filename)); + } + + if (1 !== $svgElements->length) { + throw new \RuntimeException(sprintf('The icon file "%s" contains more than one SVG.', $filename)); + } + + $svgElement = $svgElements->item(0) ?? throw new \RuntimeException(sprintf('The icon file "%s" does not contain a valid SVG.', $filename)); + + $html = ''; + + foreach ($svgElement->childNodes as $child) { + $html .= $doc->saveHTML($child); + } + + if (!$html) { + throw new \RuntimeException(sprintf('The icon file "%s" contains an empty SVG.', $filename)); + } + + $allAttributes = array_map(fn (\DOMAttr $a) => $a->value, [...$svgElement->attributes]); + $attributes = []; + + if (isset($allAttributes['viewBox'])) { + $attributes['viewBox'] = $allAttributes['viewBox']; + } + + return [$html, $attributes]; + } + + public function getIterator(): \Traversable + { + foreach ($this->finder()->sortByName() as $file) { + yield str_replace(['.svg', '/'], ['', ':'], $file->getRelativePathname()); + } + } + + public function count(): int + { + return $this->finder()->count(); + } + + private function finder(): Finder + { + return Finder::create()->in($this->iconDir)->files()->name('*.svg'); + } +} diff --git a/src/Icons/src/Twig/UXIconComponent.php b/src/Icons/src/Twig/UXIconComponent.php new file mode 100644 index 00000000000..9ac5fb92757 --- /dev/null +++ b/src/Icons/src/Twig/UXIconComponent.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons\Twig; + +/** + * @author Kevin Bond + * + * @internal + */ +final class UXIconComponent +{ + public string $name; +} diff --git a/src/Icons/src/Twig/UXIconComponentListener.php b/src/Icons/src/Twig/UXIconComponentListener.php new file mode 100644 index 00000000000..fcf6069163b --- /dev/null +++ b/src/Icons/src/Twig/UXIconComponentListener.php @@ -0,0 +1,35 @@ + + * + * @internal + */ +final class UXIconComponentListener +{ + public function __construct( + private IconRenderer $iconRenderer, + private string $componentName, + ) { + } + + public function onPreCreateForRender(PreCreateForRenderEvent $event): void + { + if ($this->componentName !== $event->getName()) { + return; + } + + $attributes = $event->getInputProps(); + $name = (string) $attributes['name']; + unset($attributes['name']); + + $svg = $this->iconRenderer->renderIcon($name, $attributes); + $event->setRenderedString($svg); + $event->stopPropagation(); + } +} diff --git a/src/Icons/src/Twig/UXIconExtension.php b/src/Icons/src/Twig/UXIconExtension.php new file mode 100644 index 00000000000..50ec835821a --- /dev/null +++ b/src/Icons/src/Twig/UXIconExtension.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\UX\Icons\Twig; + +use Symfony\UX\Icons\IconRenderer; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +/** + * @author Kevin Bond + * + * @internal + */ +final class UXIconExtension extends AbstractExtension +{ + public function __construct( + private readonly ?string $componentName = null, + ) + { + } + + public function getFunctions(): array + { + return [ + new TwigFunction('ux_icon', [IconRenderer::class, 'renderIcon'], ['is_safe' => ['html']]), + ]; + } + + public function getNodeVisitors(): array + { + if (null === $this->componentName) { + return []; + } + + return [ + new UXIconNodeVisitor($this->componentName,'ux_icon'), + ]; + } +} diff --git a/src/Icons/src/Twig/UXIconNodeVisitor.php b/src/Icons/src/Twig/UXIconNodeVisitor.php new file mode 100644 index 00000000000..d191d736fab --- /dev/null +++ b/src/Icons/src/Twig/UXIconNodeVisitor.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons\Twig; + +use Twig\Environment; +use Twig\Node\Expression\ConstantExpression; +use Twig\Node\Expression\FunctionExpression; +use Twig\Node\Node; +use Twig\NodeVisitor\AbstractNodeVisitor; + +/** + * @author Simon André + * + * @internal + */ +final class UXIconNodeVisitor extends AbstractNodeVisitor +{ + public function __construct( + private string $componentName, + private string $functionName, + ) + { + } + + protected function doEnterNode(Node $node, Environment $env): Node + { + return $node; + } + + /** + * Called after child nodes are visited. + * + * @return Node|null The modified node or null if the node must be removed + */ + protected function doLeaveNode(Node $node, Environment $env): ?Node + { + if ($node instanceof FunctionExpression) { + // @todo a lot of checks + if ('component' === $node->getAttribute('name')) { + if (!$node->hasNode('arguments')) { + return $node; + } + $arguments = $node->getNode('arguments'); + if (!$arguments->hasNode(0)) { + return $node; + } + $name = $arguments->getNode(0); + if (!$name instanceof ConstantExpression) { + return $node; + } + if ($name->getAttribute('value') !== $this->componentName) { + return $node; + } + + if (!$arguments->hasNode(1)) { + return $node; + } + $arguments = $arguments->getNode(1); + + $node->setAttribute('name', $this->functionName); + // @todo a lot of checks + $name->setAttribute('value', $arguments->getNode(1)->getAttribute('value')); + $arguments->removeNode(0); + $arguments->removeNode(1); + + return $node; + } + } + + return $node; + } + + public function getPriority(): int + { + return 0; + } +} diff --git a/src/Icons/src/UXIconsBundle.php b/src/Icons/src/UXIconsBundle.php new file mode 100644 index 00000000000..37c2c0d1050 --- /dev/null +++ b/src/Icons/src/UXIconsBundle.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +/** + * @author Kevin Bond + */ +final class UXIconsBundle extends Bundle +{ + public function getPath(): string + { + return \dirname(__DIR__); + } +} diff --git a/src/Icons/templates/Icon.html.twig b/src/Icons/templates/Icon.html.twig new file mode 100644 index 00000000000..fef2a01c3cb --- /dev/null +++ b/src/Icons/templates/Icon.html.twig @@ -0,0 +1 @@ +{{ ux_icon(this.name, attributes.all) }} diff --git a/src/Icons/tests/Fixtures/TestKernel.php b/src/Icons/tests/Fixtures/TestKernel.php new file mode 100644 index 00000000000..badf7478904 --- /dev/null +++ b/src/Icons/tests/Fixtures/TestKernel.php @@ -0,0 +1,53 @@ + + */ +final class TestKernel extends Kernel +{ + use MicroKernelTrait; + + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new TwigBundle(); + yield new TwigComponentBundle(); + yield new UXIconsBundle(); + } + + protected function configureContainer(ContainerConfigurator $c): void + { + $c->extension('framework', [ + 'secret' => 'S3CRET', + 'test' => true, + 'router' => ['utf8' => true], + 'secrets' => false, + 'http_method_override' => false, + 'php_errors' => ['log' => true], + 'property_access' => true, + ]); + + $c->extension('twig_component', [ + 'defaults' => [], + 'anonymous_template_directory' => 'components', + ]); + + $c->extension('ux_icons', [ + 'icon_dir' => '%kernel.project_dir%/tests/Fixtures/icons', + 'twig_component_name' => 'Icon', + ]); + + $c->services()->set('logger', NullLogger::class); + } +} diff --git a/src/Icons/tests/Fixtures/icons/sub/check.svg b/src/Icons/tests/Fixtures/icons/sub/check.svg new file mode 100644 index 00000000000..040d324fe14 --- /dev/null +++ b/src/Icons/tests/Fixtures/icons/sub/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Icons/tests/Fixtures/icons/user.svg b/src/Icons/tests/Fixtures/icons/user.svg new file mode 100644 index 00000000000..c2d64b7ed1f --- /dev/null +++ b/src/Icons/tests/Fixtures/icons/user.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Icons/tests/Fixtures/svg/invalid1.svg b/src/Icons/tests/Fixtures/svg/invalid1.svg new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Icons/tests/Fixtures/svg/invalid2.svg b/src/Icons/tests/Fixtures/svg/invalid2.svg new file mode 100644 index 00000000000..b3c19cdcc8b --- /dev/null +++ b/src/Icons/tests/Fixtures/svg/invalid2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Icons/tests/Fixtures/svg/invalid3.svg b/src/Icons/tests/Fixtures/svg/invalid3.svg new file mode 100644 index 00000000000..5ba771efaa3 --- /dev/null +++ b/src/Icons/tests/Fixtures/svg/invalid3.svg @@ -0,0 +1,8 @@ +
+ + + + + + +
diff --git a/src/Icons/tests/Fixtures/svg/invalid4.svg b/src/Icons/tests/Fixtures/svg/invalid4.svg new file mode 100644 index 00000000000..ce94f9d3431 --- /dev/null +++ b/src/Icons/tests/Fixtures/svg/invalid4.svg @@ -0,0 +1 @@ + diff --git a/src/Icons/tests/Fixtures/svg/valid1.svg b/src/Icons/tests/Fixtures/svg/valid1.svg new file mode 100644 index 00000000000..c2d64b7ed1f --- /dev/null +++ b/src/Icons/tests/Fixtures/svg/valid1.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Icons/tests/Fixtures/svg/valid2.svg b/src/Icons/tests/Fixtures/svg/valid2.svg new file mode 100644 index 00000000000..e2d12f985bc --- /dev/null +++ b/src/Icons/tests/Fixtures/svg/valid2.svg @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/src/Icons/tests/Fixtures/svg/valid3.svg b/src/Icons/tests/Fixtures/svg/valid3.svg new file mode 100644 index 00000000000..2c097a54516 --- /dev/null +++ b/src/Icons/tests/Fixtures/svg/valid3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Icons/tests/Fixtures/svg/valid4.svg b/src/Icons/tests/Fixtures/svg/valid4.svg new file mode 100644 index 00000000000..e9bc137b171 --- /dev/null +++ b/src/Icons/tests/Fixtures/svg/valid4.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Icons/tests/Fixtures/svg/valid5.svg b/src/Icons/tests/Fixtures/svg/valid5.svg new file mode 100644 index 00000000000..c3b2c3a88d5 --- /dev/null +++ b/src/Icons/tests/Fixtures/svg/valid5.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Icons/tests/Integration/Twig/UXIconExtensionTest.php b/src/Icons/tests/Integration/Twig/UXIconExtensionTest.php new file mode 100644 index 00000000000..9e9ba199e6d --- /dev/null +++ b/src/Icons/tests/Integration/Twig/UXIconExtensionTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons\Tests\Integration\Twig; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Twig\Environment; + +/** + * @author Kevin Bond + */ +final class UXIconExtensionTest extends KernelTestCase +{ + public function testRenderIcons(): void + { + $output = self::getContainer()->get(Environment::class)->createTemplate(<< +
  • {{ ux_icon('user', {class: 'h-6 w-6'}) }}
  • +
  • {{ ux_icon('user') }}
  • +
  • {{ ux_icon('sub:check') }}
  • +
  • {{ ux_icon('sub/check') }}
  • +
  • +
  • + + TWIG + )->render(); + + $this->assertSame(<< +
  • +
  • +
  • +
  • +
  • +
  • + + HTML, + preg_replace("#(\s+)#m", "", $output) + ); + } +} diff --git a/src/Icons/tests/Unit/Registry/LocalSvgIconRegistryTest.php b/src/Icons/tests/Unit/Registry/LocalSvgIconRegistryTest.php new file mode 100644 index 00000000000..292f37e5df3 --- /dev/null +++ b/src/Icons/tests/Unit/Registry/LocalSvgIconRegistryTest.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\UX\Icons\Tests\Unit\Registry; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Icons\Registry\LocalSvgIconRegistry; + +/** + * @author Kevin Bond + */ +final class LocalSvgIconRegistryTest extends TestCase +{ + /** + * @dataProvider validSvgProvider + */ + public function testValidSvgs(string $name, array $expectedAttributes, string $expectedContent): void + { + [$content, $attributes] = $this->registry()->get($name); + + $this->assertSame($expectedContent, $content); + $this->assertSame($expectedAttributes, $attributes); + } + + public static function validSvgProvider(): iterable + { + yield ['valid1', ['viewBox' => '0 0 24 24'], '']; + yield ['valid2', ['viewBox' => '0 0 24 24'], '']; + yield ['valid3', ['viewBox' => '0 0 24 24'], '']; + yield ['valid4', ['viewBox' => '0 0 24 24'], '']; + yield ['valid5', ['viewBox' => '0 0 24 24'], '']; + } + + /** + * @dataProvider invalidSvgProvider + */ + public function testInvalidSvgs(string $name): void + { + $this->expectException(\RuntimeException::class); + + $this->registry()->get($name); + } + + public static function invalidSvgProvider(): iterable + { + yield ['invalid1']; + yield ['invalid2']; + yield ['invalid3']; + yield ['invalid4']; + } + + private function registry(): LocalSvgIconRegistry + { + return new LocalSvgIconRegistry(__DIR__.'/../../Fixtures/svg'); + } +} diff --git a/src/Icons/tests/bootstrap.php b/src/Icons/tests/bootstrap.php new file mode 100644 index 00000000000..3a6f67b1c33 --- /dev/null +++ b/src/Icons/tests/bootstrap.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\Filesystem\Filesystem; + +require __DIR__.'/../vendor/autoload.php'; + +(new Filesystem())->remove(__DIR__.'/../var');