diff --git a/src/Icons/src/IconRegistryInterface.php b/src/Icons/src/IconRegistryInterface.php index 5c0171a4bee..b5e43c06430 100644 --- a/src/Icons/src/IconRegistryInterface.php +++ b/src/Icons/src/IconRegistryInterface.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Icons; use Symfony\UX\Icons\Exception\IconNotFoundException; +use Symfony\UX\Icons\Svg\Icon; /** * @author Kevin Bond @@ -23,9 +24,7 @@ interface IconRegistryInterface extends \IteratorAggregate, \Countable { /** - * @return array{0: string, 1: array} - * * @throws IconNotFoundException */ - public function get(string $name): array; + public function get(string $name): Icon; } diff --git a/src/Icons/src/IconRenderer.php b/src/Icons/src/IconRenderer.php index f7b11855a9b..61a60f4a0a6 100644 --- a/src/Icons/src/IconRenderer.php +++ b/src/Icons/src/IconRenderer.php @@ -11,6 +11,8 @@ namespace Symfony\UX\Icons; +use Symfony\Ux\Icons\Svg\Icon; + /** * @author Kevin Bond * @@ -19,8 +21,8 @@ final class IconRenderer { public function __construct( - private IconRegistryInterface $registry, - private IconStack $stack, + private readonly IconRegistryInterface $registry, + private readonly IconStack $stack, private array $defaultIconAttributes = [], private array $defaultDeferredAttributes = [], ) { @@ -32,22 +34,19 @@ public function __construct( public function renderIcon(string $name, array $attributes = []): string { $deferred = $attributes['defer'] ?? false; - unset($attributes['defer']); if ($deferred) { $this->stack->push($name); - return sprintf('', self::normalizeAttributes($attributes), self::idFor($name)); + return (new Icon('')) + ->withAttributes($attributes) + ->toHtml(); } - [$content, $iconAttr] = $this->getIcon($name); - - return sprintf( - '%s', - self::normalizeAttributes([...$iconAttr, ...$attributes]), - $content, - ); + return $this->getIcon($name) + ->withAttributes($attributes) + ->toHtml(); } /** @@ -71,17 +70,15 @@ public function renderDeferred(array $attributes = []): string return $return.''; } - private function getIcon(string $name): array + private function getIcon(string $name): Icon { - [$content, $iconAttr] = $this->registry->get($name); - - $iconAttr = array_merge($iconAttr, $this->defaultIconAttributes); - - return [$content, $iconAttr]; + return $this->registry->get($name)->withAttributes($this->defaultIconAttributes); } private static function idFor(string $name): string { + // @todo Icon::idFor ? + return 'ux-icon-'.str_replace(['/', ':'], ['-', '--'], $name); } diff --git a/src/Icons/src/Registry/CacheIconRegistry.php b/src/Icons/src/Registry/CacheIconRegistry.php index 2798eb73781..0e0c00da827 100644 --- a/src/Icons/src/Registry/CacheIconRegistry.php +++ b/src/Icons/src/Registry/CacheIconRegistry.php @@ -17,6 +17,7 @@ use Symfony\Contracts\Cache\TagAwareCacheInterface; use Symfony\UX\Icons\Exception\IconNotFoundException; use Symfony\UX\Icons\IconRegistryInterface; +use Symfony\UX\Icons\Svg\Icon; /** * @author Kevin Bond @@ -32,7 +33,7 @@ public function __construct(private \Traversable $registries, private CacheInter { } - public function get(string $name, bool $refresh = false): array + public function get(string $name, bool $refresh = false): Icon { return $this->cache->get( sprintf('ux-icon-%s', str_replace([':', '/'], ['--', '-'], $name)), diff --git a/src/Icons/src/Registry/LocalSvgIconRegistry.php b/src/Icons/src/Registry/LocalSvgIconRegistry.php index 926f9b5a7fa..2553d942edc 100644 --- a/src/Icons/src/Registry/LocalSvgIconRegistry.php +++ b/src/Icons/src/Registry/LocalSvgIconRegistry.php @@ -16,6 +16,7 @@ use Symfony\Component\Finder\Finder; use Symfony\UX\Icons\Exception\IconNotFoundException; use Symfony\UX\Icons\IconRegistryInterface; +use Symfony\UX\Icons\Svg\Icon; /** * @author Kevin Bond @@ -28,7 +29,7 @@ public function __construct(private string $iconDir) { } - public function get(string $name): array + public function get(string $name): Icon { if (!file_exists($filename = sprintf('%s/%s.svg', $this->iconDir, $name))) { throw new IconNotFoundException(sprintf('The icon "%s" (%s) does not exist.', $name, $filename)); @@ -43,7 +44,7 @@ public function get(string $name): array $attributes['viewBox'] = $viewBox; } - return [$crawler->html(), $attributes]; + return new Icon($crawler->html(), $attributes); } /** diff --git a/src/Icons/src/Svg/Icon.php b/src/Icons/src/Svg/Icon.php new file mode 100644 index 00000000000..02ba1ed7bfc --- /dev/null +++ b/src/Icons/src/Svg/Icon.php @@ -0,0 +1,122 @@ + + * + * @internal + */ +final class Icon implements \Stringable, \Serializable, \ArrayAccess +{ + public function __construct( + private readonly string $innerSvg, + private readonly array $attributes = [], + ) + { + // @todo validate attributes (?) + // the main idea is to have a way to validate the attributes + // before the icon is cached to improve performances + // (avoiding to validate the attributes each time the icon is rendered) + } + + public function toHtml(): string + { + $htmlAttributes = ''; + foreach ($this->attributes as $name => $value) { + if (false === $value) { + continue; + } + $htmlAttributes .= ' '.$name; + if (true !== $value) { + $value = htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $htmlAttributes .= '="'. $value .'"'; + } + } + + return ''.$this->innerSvg.''; + } + + public function getInnerSvg(): string + { + return $this->innerSvg; + } + + /** + * @param array $attributes + * @return self + */ + public function withAttributes(array $attributes): self + { + foreach ($attributes as $name => $value) { + if (!is_string($name)) { + throw new \InvalidArgumentException(sprintf('Attribute names must be string, "%s" given.', get_debug_type($name))); + } + // @todo regexp would be better ? + if (!ctype_alnum($name) && !str_contains($name, '-')) { + throw new \InvalidArgumentException(sprintf('Invalid attribute name "%s".', $name)); + } + if (!is_string($value) && !is_bool($value)) { + throw new \InvalidArgumentException(sprintf('Invalid value type for attribute "%s". Boolean or string allowed, "%s" provided. ', $name, get_debug_type($value))); + } + } + + return new self($this->innerSvg, [...$this->attributes, ...$attributes]); + } + + public function withInnerSvg(string $innerSvg): self + { + // @todo validate svg ? + + // The main idea is to not validate the attributes for every icon + // when they come from a pack (and thus share a set of attributes) + + return new self($innerSvg, $this->attributes); + } + + public function __toString(): string + { + return $this->toHtml(); + } + + public function serialize(): string + { + return serialize([$this->innerSvg, $this->attributes]); + } + + public function unserialize(string $data): void + { + [$this->innerSvg, $this->attributes] = unserialize($data); + } + + public function __serialize(): array + { + return [$this->innerSvg, $this->attributes]; + } + + public function __unserialize(array $data): void + { + [$this->innerSvg, $this->attributes] = $data; + } + + public function offsetExists(mixed $offset): bool + { + return isset($this->attributes[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->attributes[$offset]; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new \LogicException('The Icon object is immutable.'); + } + + public function offsetUnset(mixed $offset): void + { + throw new \LogicException('The Icon object is immutable.'); + } +}