Repository: tempestphp/tempest-framework
Pull request #1009: feat(view): add icon component
Author: login: nhedger
Labels: name: Views
Created: 2025-03-13T15:04:22Z | Merged: 2025-03-14T18:32:33Z

This PR adds a new `x-icon` component.

### Usage

```html
<x-icon name="ph:eye" class="size-5 text-zinc-500" />
```

### How it works

On first render, the icon is downloaded from Iconify, and cached indefinitely. On subsequent renders, the icon is pulled from the cache instead of the Iconify API.

### API

The component accepts the following props:

- `name`: The name of the component, in the format `{prefix}:{name}`
- `class`: The class attribute to set on the underlying `svg` HTML element

### Areas of improvement

- [x] Configurable cache duration
- [x] Configurable Iconify API url
- [ ] Default icon prefix

### Documentation

This PR doesn't include documentation for the component, but I plan to send an other PR to address it.

Closes #1002 

Linked issue #1002: `x-icon` component
Something that's used in every app is icons. I always use https://icones.js.org/, and, in the front-end world, I use [`unplugin-icons`](https://github.com/antfu/unplugin-icons).

Tempest could have a `x-icon` component that works the same way, caching SVGs locally as they're used:

```html
<x-icon name="tabler:brand-bluesky" class="size-6 text-blue-400" />
```
Linked issue comments: user: login: nhedger
created_at: 2025-03-12T21:25:40Z
body: Happy to port https://unicon.rocks
author_association: CONTRIBUTOR
user: login: innocenzi
created_at: 2025-03-12T23:24:20Z
body: That would be awesome!
author_association: MEMBER
user: login: nhedger
created_at: 2025-03-13T11:36:08Z
body: Should this be part of the core or a separate package ?
author_association: CONTRIBUTOR
user: login: innocenzi
created_at: 2025-03-13T13:06:31Z
body: In my opinion that should be built-in, given we plan on having other built-in components
author_association: MEMBER
user: login: brendt
created_at: 2025-03-13T13:40:45Z
body: Agree, let's have it built-in!
author_association: MEMBER

Diff:
diff --git a/.env.example b/.env.example
index 9a3402f988..9c34bdcb70 100644
--- a/.env.example
+++ b/.env.example
@@ -14,6 +14,9 @@ DISCOVERY_CACHE=false
 # Enable or disable config cache
 CONFIG_CACHE=false
 
+# Enable or disable icon cache
+ICON_CACHE=true
+
 # Enable or disable view cache
 VIEW_CACHE=false
 
diff --git a/src/Tempest/Cache/src/CacheConfig.php b/src/Tempest/Cache/src/CacheConfig.php
index 7cc98b8a7f..9f064511a3 100644
--- a/src/Tempest/Cache/src/CacheConfig.php
+++ b/src/Tempest/Cache/src/CacheConfig.php
@@ -17,6 +17,8 @@ final class CacheConfig
 
     public ?bool $enable;
 
+    public bool $iconCache = false;
+
     public bool $projectCache = false;
 
     public bool $viewCache = false;
@@ -34,6 +36,7 @@ public function __construct(
         ?bool $enable = null,
     ) {
         $this->enable = $enable ?? env('CACHE');
+        $this->iconCache = (bool) env('ICON_CACHE', true);
         $this->projectCache = (bool) env('PROJECT_CACHE', false);
         $this->viewCache = (bool) env('VIEW_CACHE', false);
         $this->discoveryCache = $this->resolveDiscoveryCacheStrategy();
diff --git a/src/Tempest/Cache/src/IconCache.php b/src/Tempest/Cache/src/IconCache.php
new file mode 100644
index 0000000000..2c905a5856
--- /dev/null
+++ b/src/Tempest/Cache/src/IconCache.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tempest\Cache;
+
+use Psr\Cache\CacheItemPoolInterface;
+use Symfony\Component\Cache\Adapter\FilesystemAdapter;
+
+final class IconCache implements Cache
+{
+    use IsCache;
+
+    private CacheItemPoolInterface $pool;
+
+    public function __construct(
+        private readonly CacheConfig $cacheConfig,
+    ) {
+        $this->pool = new FilesystemAdapter(
+            directory: $this->cacheConfig->directory . '/icons',
+        );
+    }
+
+    protected function getCachePool(): CacheItemPoolInterface
+    {
+        return $this->pool;
+    }
+
+    public function isEnabled(): bool
+    {
+        return $this->cacheConfig->enable ?? $this->cacheConfig->iconCache;
+    }
+}
diff --git a/src/Tempest/Console/src/Testing/ConsoleTester.php b/src/Tempest/Console/src/Testing/ConsoleTester.php
index aa86fc67c0..eb583c21c9 100644
--- a/src/Tempest/Console/src/Testing/ConsoleTester.php
+++ b/src/Tempest/Console/src/Testing/ConsoleTester.php
@@ -182,6 +182,24 @@ public function assertSee(string $text): self
         return $this->assertContains($text);
     }
 
+    public function assertSeeCount(string $text, int $expectedCount): self
+    {
+        $actualCount = substr_count($this->output->asUnformattedString(), $text);
+
+        Assert::assertSame(
+            $expectedCount,
+            $actualCount,
+            sprintf(
+                'Failed to assert that console output counted: %s exactly %d times. These lines were printed: %s',
+                $text,
+                $expectedCount,
+                PHP_EOL . PHP_EOL . $this->output->asUnformattedString() . PHP_EOL,
+            ),
+        );
+
+        return $this;
+    }
+
     public function assertNotSee(string $text): self
     {
         return $this->assertDoesNotContain($text);
diff --git a/src/Tempest/View/src/Components/Icon.php b/src/Tempest/View/src/Components/Icon.php
new file mode 100644
index 0000000000..1bbcc5f3ba
--- /dev/null
+++ b/src/Tempest/View/src/Components/Icon.php
@@ -0,0 +1,119 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tempest\View\Components;
+
+use DateInterval;
+use DateTimeImmutable;
+use Exception;
+use Tempest\Cache\IconCache;
+use Tempest\Core\AppConfig;
+use Tempest\Http\Status;
+use Tempest\HttpClient\HttpClient;
+use Tempest\Support\Str\ImmutableString;
+use Tempest\View\Elements\ViewComponentElement;
+use Tempest\View\IconConfig;
+use Tempest\View\ViewComponent;
+
+final readonly class Icon implements ViewComponent
+{
+    public function __construct(
+        private AppConfig $appConfig,
+        private IconCache $iconCache,
+        private IconConfig $iconConfig,
+        private HttpClient $http,
+    ) {
+    }
+
+    public static function getName(): string
+    {
+        return 'x-icon';
+    }
+
+    public function compile(ViewComponentElement $element): string
+    {
+        $name = $element->getAttribute('name');
+        $class = $element->getAttribute('class');
+
+        $svg = $this->render($name);
+
+        if (! $svg) {
+            return $this->appConfig->environment->isLocal()
+                ? ('<!-- unknown-icon: ' . $name . ' -->')
+                : '';
+        }
+
+        return match ($class) {
+            null => $svg,
+            default => $this->injectClass($svg, $class),
+        };
+    }
+
+    /**
+     * Downloads the icon's SVG file from the Iconify API
+     */
+    private function download(string $prefix, string $name): ?string
+    {
+        try {
+            $url = new ImmutableString($this->iconConfig->iconifyApiUrl)
+                ->finish('/')
+                ->append("{$prefix}/{$name}.svg")
+                ->toString();
+
+            $response = $this->http->get($url);
+
+            if ($response->status !== Status::OK) {
+                return null;
+            }
+
+            return $response->body;
+        } catch (Exception) {
+            return null;
+        }
+    }
+
+    /**
+     * Renders an icon
+     *
+     * This method is responsible for rendering the icon. If the icon is not
+     * in the cache, it will download it on the fly and cache it for future
+     * use. If the icon is already in the cache, it will be served from there.
+     */
+    private function render(string $name): ?string
+    {
+        try {
+            $parts = explode(':', $name, 2);
+
+            if (count($parts) !== 2) {
+                return null;
+            }
+
+            [$prefix, $name] = $parts;
+
+            return $this->iconCache->resolve(
+                key: "iconify-{$prefix}-{$name}",
+                cache: fn () => $this->download($prefix, $name),
+                expiresAt: $this->iconConfig->cacheDuration
+                    ? new DateTimeImmutable()
+                        ->add(DateInterval::createFromDateString("{$this->iconConfig->cacheDuration} seconds"))
+                    : null,
+            );
+        } catch (Exception) {
+            return null;
+        }
+    }
+
+    /**
+     * Forwards the user-provided class attribute to the SVG element
+     */
+    private function injectClass(string $svg, string $class): string
+    {
+        return new ImmutableString($svg)
+            ->replace(
+                search: '<svg ',
+                replace: "<svg class=\"{$class}\" ",
+            )
+            ->toString();
+    }
+}
diff --git a/src/Tempest/View/src/Config/icon.config.php b/src/Tempest/View/src/Config/icon.config.php
new file mode 100644
index 0000000000..00b9acd82e
--- /dev/null
+++ b/src/Tempest/View/src/Config/icon.config.php
@@ -0,0 +1,7 @@
+<?php
+
+declare(strict_types=1);
+
+use Tempest\View\IconConfig;
+
+return new IconConfig();
diff --git a/src/Tempest/View/src/IconConfig.php b/src/Tempest/View/src/IconConfig.php
new file mode 100644
index 0000000000..90ac461f9b
--- /dev/null
+++ b/src/Tempest/View/src/IconConfig.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tempest\View;
+
+final class IconConfig
+{
+    public function __construct(
+        /**
+         * The number of seconds to cache the icon SVG files.
+         *
+         * If null, the icons will be cached indefinitely.
+         *
+         * @var int|null
+         */
+        public ?int $cacheDuration = null,
+
+        /**
+         * URL of the Iconify API.
+         *
+         * This allows you to switch to a local or self-hosted Iconify API.
+         *
+         * @var string
+         */
+        public string $iconifyApiUrl = 'https://api.iconify.design',
+    ) {
+    }
+}
diff --git a/tests/Integration/Cache/CacheClearCommandTest.php b/tests/Integration/Cache/CacheClearCommandTest.php
index 1c1906bf35..a2618c37b6 100644
--- a/tests/Integration/Cache/CacheClearCommandTest.php
+++ b/tests/Integration/Cache/CacheClearCommandTest.php
@@ -17,10 +17,10 @@ public function test_cache_clear_single(): void
     {
         $this->console
             ->call('cache:clear')
+            ->print()
             ->assertSee(ProjectCache::class)
             ->submit('0')
             ->submit('yes')
-            ->assertSee(ProjectCache::class)
-            ->assertNotSee(ViewCache::class);
+           ->assertSeeCount('CLEARED', 1);
     }
 }
diff --git a/tests/Integration/View/IconComponentTest.php b/tests/Integration/View/IconComponentTest.php
new file mode 100644
index 0000000000..87a0e64d3c
--- /dev/null
+++ b/tests/Integration/View/IconComponentTest.php
@@ -0,0 +1,165 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Tempest\Integration\View;
+
+use Tempest\Cache\IconCache;
+use Tempest\Core\AppConfig;
+use Tempest\Core\ConfigCache;
+use Tempest\Core\Environment;
+use Tempest\Http\Status;
+use Tempest\HttpClient\HttpClient;
+use Tempest\Router\GenericResponse;
+use Tempest\View\IconConfig;
+use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
+
+final class IconComponentTest extends FrameworkIntegrationTestCase
+{
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->container->get(IconCache::class)->clear();
+        $this->container->get(ConfigCache::class)->clear();
+    }
+
+    public function test_it_renders_an_icon(): void
+    {
+        $mockHttpClient = $this->createMock(HttpClient::class);
+            $mockHttpClient
+                ->expects($this->once())
+                ->method('get')
+                ->with('https://api.iconify.design/ph/eye.svg')
+                ->willReturn(new GenericResponse(status: Status::OK, body: '<svg></svg>'));
+
+        $this->container->register(HttpClient::class, fn() => $mockHttpClient);
+
+        $this->assertSame(
+            '<svg></svg>',
+            $this->render(
+                '<x-icon name="ph:eye" />',
+            ),
+        );
+    }
+
+    public function test_it_downloads_the_icon_from_a_custom_api(): void
+    {
+        $mockHttpClient = $this->createMock(HttpClient::class);
+            $mockHttpClient
+                ->expects($this->exactly(1))
+                ->method('get')
+                ->with('https://api.iconify.test/ph/eye.svg')
+                ->willReturn(new GenericResponse(status: Status::OK, body: '<svg></svg>'));
+
+        $this->container->register(HttpClient::class, fn() => $mockHttpClient);
+        
+        $this->container->singleton(
+            IconConfig::class, 
+            fn () => new IconConfig(iconifyApiUrl: 'https://api.iconify.test')
+        );
+       
+        $this->assertSame(
+            '<svg></svg>',
+            $this->render(
+                '<x-icon name="ph:eye" />',
+            ),
+        );
+    }
+
+    public function test_it_caches_icons_on_the_first_render(): void
+    {
+        $mockHttpClient = $this->createMock(HttpClient::class);
+            $mockHttpClient
+                ->expects($this->once())
+                ->method('get')
+                ->with('https://api.iconify.design/ph/eye.svg')
+                ->willReturn(new GenericResponse(status: Status::OK, body: '<svg></svg>'));
+
+        $this->container->register(HttpClient::class, fn() => $mockHttpClient);
+
+        $this->render('<x-icon name="ph:eye" />');
+
+        $iconCache = $this->container->get(IconCache::class);
+        $cachedIcon = $iconCache?->get('iconify-ph-eye');
+
+        $this->assertNotNull($cachedIcon);
+        $this->assertSame('<svg></svg>', $cachedIcon);
+    }
+
+    public function test_it_renders_an_icon_from_cache(): void
+    {
+        $mockHttpClient = $this->createMock(HttpClient::class);
+        $mockHttpClient
+            ->expects($this->exactly(1))
+            ->method('get')
+            ->with('https://api.iconify.design/ph/eye.svg')
+            ->willReturn(new GenericResponse(status: Status::OK, body: '<svg></svg>'));
+
+        $this->container->register(HttpClient::class, fn() => $mockHttpClient);
+
+        // Trigger first render, which should cache the icon
+        $this->render('<x-icon name="ph:eye" />');
+
+        $this->assertSame(
+            '<svg></svg>',
+            $this->render('<x-icon name="ph:eye" />'),
+        );
+    }
+
+    public function test_it_renders_a_debug_comment_in_local_env_when_icon_does_not_exist(): void
+    {
+        $mockHttpClient = $this->createMock(HttpClient::class);
+        $mockHttpClient
+            ->expects($this->once())
+            ->method('get')
+            ->with('https://api.iconify.design/ph/eye.svg')
+            ->willReturn(new GenericResponse(status: Status::NOT_FOUND, body: ''));
+
+        $this->container->register(HttpClient::class, fn() => $mockHttpClient);
+        $this->container->singleton(AppConfig::class, fn () => new AppConfig(environment: Environment::LOCAL));
+        
+        $this->assertSame(
+            '<!-- unknown-icon: ph:eye -->',
+            $this->render('<x-icon name="ph:eye" />'),
+        );
+    }
+
+    public function test_it_renders_an_empty_string__in_non_local_env_when_icon_does_not_exist(): void
+    {
+        $mockHttpClient = $this->createMock(HttpClient::class);
+        $mockHttpClient
+            ->expects($this->once())
+            ->method('get')
+            ->with('https://api.iconify.design/ph/eye.svg')
+            ->willReturn(new GenericResponse(status: Status::NOT_FOUND, body: ''));
+
+        $this->container->register(HttpClient::class, fn() => $mockHttpClient);
+        $this->container->singleton(AppConfig::class, fn () => new AppConfig(environment: Environment::PRODUCTION));
+
+        $this->assertSame(
+            '',
+            $this->render('<x-icon name="ph:eye" />'),
+        );
+    }
+
+    public function test_it_forwards_the_class_attribute(): void
+    {
+        $mockHttpClient = $this->createMock(HttpClient::class);
+        $mockHttpClient
+            ->expects($this->exactly(1))
+            ->method('get')
+            ->with('https://api.iconify.design/ph/eye.svg')
+            ->willReturn(new GenericResponse(status: Status::OK, body: '<svg></svg>'));
+
+        $this->container->register(HttpClient::class, fn() => $mockHttpClient);
+
+        $this->assertSame(
+            '<svg class="size-5"></svg>',
+            $this->render(
+                '<x-icon name="ph:eye" class="size-5" />',
+            ),
+        );
+    }
+}
\ No newline at end of file


Reviews:
user: login: innocenzi
body: This works well, it's pretty nice!
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T15:32:04Z
user: login: nhedger
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T15:34:41Z
user: login: nhedger
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T15:35:50Z
user: login: innocenzi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T15:38:52Z
user: login: nhedger
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T15:40:20Z
user: login: innocenzi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T15:44:45Z
user: login: innocenzi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T15:45:24Z
user: login: innocenzi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T15:47:00Z
user: login: nhedger
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T15:57:09Z

Review comments:
diff_hunk: @@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tempest\View\Components;
+
+use DateInterval;
+use DateTimeImmutable;
+use Exception;
+use Tempest\Cache\Cache;
+use Tempest\HttpClient\HttpClient;
+use Tempest\Support\Str\ImmutableString;
+use Tempest\View\Elements\ViewComponentElement;
+use Tempest\View\ViewComponent;
+
+final readonly class Icon implements ViewComponent
+{
+    public function __construct(
+        private Cache $cache,
path: src/Tempest/View/src/Components/Icon.php
user: login: innocenzi
body: This should use `ViewCache`, or even a dedicated icon cache.
created_at: 2025-03-13T15:26:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tempest\View\Components;
+
+use DateInterval;
+use DateTimeImmutable;
+use Exception;
+use Tempest\Cache\Cache;
+use Tempest\HttpClient\HttpClient;
+use Tempest\Support\Str\ImmutableString;
+use Tempest\View\Elements\ViewComponentElement;
+use Tempest\View\ViewComponent;
+
+final readonly class Icon implements ViewComponent
+{
+    public function __construct(
+        private Cache $cache,
+        private HttpClient $http,
+    ) {
+    }
+
+    public static function getName(): string
+    {
+        return 'x-icon';
+    }
+
+    public function compile(ViewComponentElement $element): string
+    {
+        $name = $element->getAttribute('name');
+        $class = $element->getAttribute('class');
+
+        $svg = $this->render($name);
+
+        if (! $svg) {
+            return '';
+        }
+
+        return match ($class) {
+            null => $svg,
+            default => $this->injectClass($svg, $class),
+        };
+    }
+
+    /**
+     * Downloads the icon's SVG file from the Iconify API
+     */
+    private function download(string $prefix, string $name): ?string
+    {
+        try {
+            return $this->http->get("https://api.iconify.design/{$prefix}/{$name}.svg")->body;
+        } catch (Exception) {
+            return null;
+        }
+    }
+
+    /**
+     * Renders an icon
+     *
+     * This method is responsible for rendering the icon. If the icon is not
+     * in the cache, it will download it on the fly and cache it for future
+     * use. If the icon is already in the cache, it will be served from there.
+     */
+    private function render(string $name): ?string
path: src/Tempest/View/src/Components/Icon.php
user: login: innocenzi
body: In development, maybe render a comment like `<!-- unknown-icon: $name -->` to help with debugging?
created_at: 2025-03-13T15:27:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tempest\View\Components;
+
+use DateInterval;
+use DateTimeImmutable;
+use Exception;
+use Tempest\Cache\Cache;
+use Tempest\HttpClient\HttpClient;
+use Tempest\Support\Str\ImmutableString;
+use Tempest\View\Elements\ViewComponentElement;
+use Tempest\View\ViewComponent;
+
+final readonly class Icon implements ViewComponent
+{
+    public function __construct(
+        private Cache $cache,
+        private HttpClient $http,
+    ) {
+    }
+
+    public static function getName(): string
+    {
+        return 'x-icon';
+    }
+
+    public function compile(ViewComponentElement $element): string
+    {
+        $name = $element->getAttribute('name');
+        $class = $element->getAttribute('class');
+
+        $svg = $this->render($name);
+
+        if (! $svg) {
+            return '';
+        }
+
+        return match ($class) {
+            null => $svg,
+            default => $this->injectClass($svg, $class),
+        };
+    }
+
+    /**
+     * Downloads the icon's SVG file from the Iconify API
+     */
+    private function download(string $prefix, string $name): ?string
+    {
+        try {
+            return $this->http->get("https://api.iconify.design/{$prefix}/{$name}.svg")->body;
+        } catch (Exception) {
+            return null;
+        }
+    }
+
+    /**
+     * Renders an icon
+     *
+     * This method is responsible for rendering the icon. If the icon is not
+     * in the cache, it will download it on the fly and cache it for future
+     * use. If the icon is already in the cache, it will be served from there.
+     */
+    private function render(string $name): ?string
path: src/Tempest/View/src/Components/Icon.php
user: login: nhedger
body: Makes sense, I'll add it.
created_at: 2025-03-13T15:34:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tempest\View\Components;
+
+use DateInterval;
+use DateTimeImmutable;
+use Exception;
+use Tempest\Cache\Cache;
+use Tempest\HttpClient\HttpClient;
+use Tempest\Support\Str\ImmutableString;
+use Tempest\View\Elements\ViewComponentElement;
+use Tempest\View\ViewComponent;
+
+final readonly class Icon implements ViewComponent
+{
+    public function __construct(
+        private Cache $cache,
path: src/Tempest/View/src/Components/Icon.php
user: login: nhedger
body: Wasn't sure how to go about it. Should I just create a new IconCache class?
created_at: 2025-03-13T15:35:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tempest\View\Components;
+
+use DateInterval;
+use DateTimeImmutable;
+use Exception;
+use Tempest\Cache\Cache;
+use Tempest\HttpClient\HttpClient;
+use Tempest\Support\Str\ImmutableString;
+use Tempest\View\Elements\ViewComponentElement;
+use Tempest\View\ViewComponent;
+
+final readonly class Icon implements ViewComponent
+{
+    public function __construct(
+        private Cache $cache,
path: src/Tempest/View/src/Components/Icon.php
user: login: innocenzi
body: Possibly. But I was thinking, all views are compiled and stored... so we might not even need that a cache, the SVG will be in the rendered template in `vendor/.tempest/cache/views` 🤔 
created_at: 2025-03-13T15:38:52Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tempest\View\Components;
+
+use DateInterval;
+use DateTimeImmutable;
+use Exception;
+use Tempest\Cache\Cache;
+use Tempest\HttpClient\HttpClient;
+use Tempest\Support\Str\ImmutableString;
+use Tempest\View\Elements\ViewComponentElement;
+use Tempest\View\ViewComponent;
+
+final readonly class Icon implements ViewComponent
+{
+    public function __construct(
+        private Cache $cache,
+        private HttpClient $http,
+    ) {
+    }
+
+    public static function getName(): string
+    {
+        return 'x-icon';
+    }
+
+    public function compile(ViewComponentElement $element): string
+    {
+        $name = $element->getAttribute('name');
+        $class = $element->getAttribute('class');
+
+        $svg = $this->render($name);
+
+        if (! $svg) {
+            return '';
+        }
+
+        return match ($class) {
+            null => $svg,
+            default => $this->injectClass($svg, $class),
+        };
+    }
+
+    /**
+     * Downloads the icon's SVG file from the Iconify API
+     */
+    private function download(string $prefix, string $name): ?string
+    {
+        try {
+            return $this->http->get("https://api.iconify.design/{$prefix}/{$name}.svg")->body;
+        } catch (Exception) {
+            return null;
+        }
+    }
+
+    /**
+     * Renders an icon
+     *
+     * This method is responsible for rendering the icon. If the icon is not
+     * in the cache, it will download it on the fly and cache it for future
+     * use. If the icon is already in the cache, it will be served from there.
+     */
+    private function render(string $name): ?string
path: src/Tempest/View/src/Components/Icon.php
user: login: nhedger
body: Is there a more idiomatic way to retrieve te env than the following?

```
env('ENVIRONMENT') !== 'local'
```
created_at: 2025-03-13T15:40:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tempest\View\Components;
+
+use DateInterval;
+use DateTimeImmutable;
+use Exception;
+use Tempest\Cache\Cache;
+use Tempest\HttpClient\HttpClient;
+use Tempest\Support\Str\ImmutableString;
+use Tempest\View\Elements\ViewComponentElement;
+use Tempest\View\ViewComponent;
+
+final readonly class Icon implements ViewComponent
+{
+    public function __construct(
+        private Cache $cache,
path: src/Tempest/View/src/Components/Icon.php
user: login: innocenzi
body: Nevermind, we definitely need a cache in development.
created_at: 2025-03-13T15:44:45Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tempest\View\Components;
+
+use DateInterval;
+use DateTimeImmutable;
+use Exception;
+use Tempest\Cache\Cache;
+use Tempest\HttpClient\HttpClient;
+use Tempest\Support\Str\ImmutableString;
+use Tempest\View\Elements\ViewComponentElement;
+use Tempest\View\ViewComponent;
+
+final readonly class Icon implements ViewComponent
+{
+    public function __construct(
+        private Cache $cache,
+        private HttpClient $http,
+    ) {
+    }
+
+    public static function getName(): string
+    {
+        return 'x-icon';
+    }
+
+    public function compile(ViewComponentElement $element): string
+    {
+        $name = $element->getAttribute('name');
+        $class = $element->getAttribute('class');
+
+        $svg = $this->render($name);
+
+        if (! $svg) {
+            return '';
+        }
+
+        return match ($class) {
+            null => $svg,
+            default => $this->injectClass($svg, $class),
+        };
+    }
+
+    /**
+     * Downloads the icon's SVG file from the Iconify API
+     */
+    private function download(string $prefix, string $name): ?string
+    {
+        try {
+            return $this->http->get("https://api.iconify.design/{$prefix}/{$name}.svg")->body;
+        } catch (Exception) {
+            return null;
+        }
+    }
+
+    /**
+     * Renders an icon
+     *
+     * This method is responsible for rendering the icon. If the icon is not
+     * in the cache, it will download it on the fly and cache it for future
+     * use. If the icon is already in the cache, it will be served from there.
+     */
+    private function render(string $name): ?string
path: src/Tempest/View/src/Components/Icon.php
user: login: innocenzi
body: Yes, you can inject `AppConfig`, which has an `environment` object
created_at: 2025-03-13T15:45:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tempest\View\Components;
+
+use DateInterval;
+use DateTimeImmutable;
+use Exception;
+use Tempest\Cache\Cache;
+use Tempest\HttpClient\HttpClient;
+use Tempest\Support\Str\ImmutableString;
+use Tempest\View\Elements\ViewComponentElement;
+use Tempest\View\ViewComponent;
+
+final readonly class Icon implements ViewComponent
+{
+    public function __construct(
+        private Cache $cache,
path: src/Tempest/View/src/Components/Icon.php
user: login: innocenzi
body: @brendt I need your opinion on this as well. Cache is only useful to not delay page loading in development, so which cache should we use?
created_at: 2025-03-13T15:47:00Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tempest\View\Components;
+
+use DateInterval;
+use DateTimeImmutable;
+use Exception;
+use Tempest\Cache\Cache;
+use Tempest\HttpClient\HttpClient;
+use Tempest\Support\Str\ImmutableString;
+use Tempest\View\Elements\ViewComponentElement;
+use Tempest\View\ViewComponent;
+
+final readonly class Icon implements ViewComponent
+{
+    public function __construct(
+        private Cache $cache,
path: src/Tempest/View/src/Components/Icon.php
user: login: nhedger
body: I created a dedicated icon cache. Let me know if this doesnt sit well with you guys and I'll revert
created_at: 2025-03-13T15:57:09Z
author_association: CONTRIBUTOR

PR comments:
user: login: innocenzi
created_at: 2025-03-13T15:34:41Z
body: I'm not sure where to put the configuration. I'm guessing we can simply have a `IconConfig` config class and use that, but I'm worrying about future components and their own configs.

Any opinion @brendt?
author_association: MEMBER
user: login: brendt
created_at: 2025-03-14T05:50:09Z
body: I'm ok with `IconConfig`. Remember to provide a default implementation so the people only need to provide a config file if they actually want to override the defaults. Here's an example: https://github.com/tempestphp/tempest-framework/blob/main/src/Tempest/View/src/Config/views.config.php

I'm also ok with `IconCache`, makes sense to have it separately because you're likely to always cache icons. So make sure the default it `true`.
author_association: MEMBER
user: login: nhedger
created_at: 2025-03-14T12:39:14Z
body: Added `IconConfig` and enabled icon cache by default
author_association: CONTRIBUTOR
user: login: innocenzi
created_at: 2025-03-14T13:25:25Z
body: Nice!

The test fails because it was assuming the first cache to be listed to be the project cache. Can you adapt the test?

Could you also add tests for this component? Or I can do it later if you prefer
author_association: MEMBER
user: login: nhedger
created_at: 2025-03-14T13:37:26Z
body: > Nice!
> 
> The test fails because it was assuming the first cache to be listed to be the project cache. Can you adapt the test?
> 
> Could you also add tests for this component? Or I can do it later if you prefer

Weird, tests pass locally.

I'll look into adding some basic tests to the component.
author_association: CONTRIBUTOR
user: login: nhedger
created_at: 2025-03-14T14:06:16Z
body: Running `php tempest cache:clear` locally shows ProjectCache in the first place.

```
❯ php tempest cache:clear

  │ Which caches do you want to clear?
  │ / Filter...
  │ → ⋅ Tempest\Cache\ProjectCache
  │   ⋅ Tempest\Cache\IconCache
  │   ⋅ Tempest\Core\ConfigCache
  │   ⋅ Tempest\Core\DiscoveryCache
  │   ⋅ Tempest\View\ViewCache

  ·  / filter
  ·  space select
  ·  ↑ up
  ·  ↓ down
  ·  enter skip
  ·  ctrl+c cancel
```
author_association: CONTRIBUTOR
user: login: nhedger
created_at: 2025-03-14T15:31:28Z
body: I added the component tests.

Not sure why it's failing in CI, locally everything is green. I suspect the discovery reports the caches in a different order in CI, but I'm not sure.

Also, looking at the failing test's source code, I can't seem to understand how it works. Not sure what `submit(0)` is supposed to do, as typing `0` on the CLI doesn't to anything when testing manually.
author_association: CONTRIBUTOR
user: login: brendt
created_at: 2025-03-14T18:19:08Z
body: This isn't really your fault, it's just a flaky test. I'll fix it for you, hang on :)
author_association: MEMBER
user: login: brendt
created_at: 2025-03-14T18:24:31Z
body: Just some background info about that `submit(0)`: our console components have interactive and non-interactive variants, because in some contexts (like with tests), you cannot render interactive components that update with every keystroke. In case of the select list, it will render something like this:

```txt
Select multiple items using comas
- [0] Tempest\Cache\ProjectCache
- [1] Tempest\Cache\IconCache
- [2] Tempest\Core\ConfigCache
- [3] Tempest\Core\DiscoveryCache
- [4] Tempest\View\ViewCache
```

So when we `submit(0)`, we essentially select the first option. This test failed in CI because the discovery order of caches isn't the same for every OS, which cause "option 0" to contain a different option than expected. This was a flaky test because it assumed a fixed order. Essentially it would break every time we'd add a cache. So I just simplified it. 
author_association: MEMBER
user: login: brendt
created_at: 2025-03-14T18:32:45Z
body: Great PR btw! Thanks
author_association: MEMBER
user: login: brendt
created_at: 2025-03-14T18:35:28Z
body: FYI, I made an issue to add this (and other built-in components) to the docs. Just leaving it here for reference, I'll probably pick it up myself soon-ish: #1021
author_association: MEMBER
user: login: nhedger
created_at: 2025-03-14T18:36:40Z
body: Thanks!

Appreciate the explanation about submit!
author_association: CONTRIBUTOR