Skip to content

Commit 46570eb

Browse files
nhedgerbrendt
andauthored
feat(view): add icon component (#1009)
Co-authored-by: brendt <[email protected]>
1 parent 6931350 commit 46570eb

File tree

9 files changed

+379
-2
lines changed

9 files changed

+379
-2
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ DISCOVERY_CACHE=false
1414
# Enable or disable config cache
1515
CONFIG_CACHE=false
1616

17+
# Enable or disable icon cache
18+
ICON_CACHE=true
19+
1720
# Enable or disable view cache
1821
VIEW_CACHE=false
1922

src/Tempest/Cache/src/CacheConfig.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ final class CacheConfig
1717

1818
public ?bool $enable;
1919

20+
public bool $iconCache = false;
21+
2022
public bool $projectCache = false;
2123

2224
public bool $viewCache = false;
@@ -34,6 +36,7 @@ public function __construct(
3436
?bool $enable = null,
3537
) {
3638
$this->enable = $enable ?? env('CACHE');
39+
$this->iconCache = (bool) env('ICON_CACHE', true);
3740
$this->projectCache = (bool) env('PROJECT_CACHE', false);
3841
$this->viewCache = (bool) env('VIEW_CACHE', false);
3942
$this->discoveryCache = $this->resolveDiscoveryCacheStrategy();
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Cache;
6+
7+
use Psr\Cache\CacheItemPoolInterface;
8+
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
9+
10+
final class IconCache implements Cache
11+
{
12+
use IsCache;
13+
14+
private CacheItemPoolInterface $pool;
15+
16+
public function __construct(
17+
private readonly CacheConfig $cacheConfig,
18+
) {
19+
$this->pool = new FilesystemAdapter(
20+
directory: $this->cacheConfig->directory . '/icons',
21+
);
22+
}
23+
24+
protected function getCachePool(): CacheItemPoolInterface
25+
{
26+
return $this->pool;
27+
}
28+
29+
public function isEnabled(): bool
30+
{
31+
return $this->cacheConfig->enable ?? $this->cacheConfig->iconCache;
32+
}
33+
}

src/Tempest/Console/src/Testing/ConsoleTester.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,24 @@ public function assertSee(string $text): self
182182
return $this->assertContains($text);
183183
}
184184

185+
public function assertSeeCount(string $text, int $expectedCount): self
186+
{
187+
$actualCount = substr_count($this->output->asUnformattedString(), $text);
188+
189+
Assert::assertSame(
190+
$expectedCount,
191+
$actualCount,
192+
sprintf(
193+
'Failed to assert that console output counted: %s exactly %d times. These lines were printed: %s',
194+
$text,
195+
$expectedCount,
196+
PHP_EOL . PHP_EOL . $this->output->asUnformattedString() . PHP_EOL,
197+
),
198+
);
199+
200+
return $this;
201+
}
202+
185203
public function assertNotSee(string $text): self
186204
{
187205
return $this->assertDoesNotContain($text);
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\View\Components;
6+
7+
use DateInterval;
8+
use DateTimeImmutable;
9+
use Exception;
10+
use Tempest\Cache\IconCache;
11+
use Tempest\Core\AppConfig;
12+
use Tempest\Http\Status;
13+
use Tempest\HttpClient\HttpClient;
14+
use Tempest\Support\Str\ImmutableString;
15+
use Tempest\View\Elements\ViewComponentElement;
16+
use Tempest\View\IconConfig;
17+
use Tempest\View\ViewComponent;
18+
19+
final readonly class Icon implements ViewComponent
20+
{
21+
public function __construct(
22+
private AppConfig $appConfig,
23+
private IconCache $iconCache,
24+
private IconConfig $iconConfig,
25+
private HttpClient $http,
26+
) {
27+
}
28+
29+
public static function getName(): string
30+
{
31+
return 'x-icon';
32+
}
33+
34+
public function compile(ViewComponentElement $element): string
35+
{
36+
$name = $element->getAttribute('name');
37+
$class = $element->getAttribute('class');
38+
39+
$svg = $this->render($name);
40+
41+
if (! $svg) {
42+
return $this->appConfig->environment->isLocal()
43+
? ('<!-- unknown-icon: ' . $name . ' -->')
44+
: '';
45+
}
46+
47+
return match ($class) {
48+
null => $svg,
49+
default => $this->injectClass($svg, $class),
50+
};
51+
}
52+
53+
/**
54+
* Downloads the icon's SVG file from the Iconify API
55+
*/
56+
private function download(string $prefix, string $name): ?string
57+
{
58+
try {
59+
$url = new ImmutableString($this->iconConfig->iconifyApiUrl)
60+
->finish('/')
61+
->append("{$prefix}/{$name}.svg")
62+
->toString();
63+
64+
$response = $this->http->get($url);
65+
66+
if ($response->status !== Status::OK) {
67+
return null;
68+
}
69+
70+
return $response->body;
71+
} catch (Exception) {
72+
return null;
73+
}
74+
}
75+
76+
/**
77+
* Renders an icon
78+
*
79+
* This method is responsible for rendering the icon. If the icon is not
80+
* in the cache, it will download it on the fly and cache it for future
81+
* use. If the icon is already in the cache, it will be served from there.
82+
*/
83+
private function render(string $name): ?string
84+
{
85+
try {
86+
$parts = explode(':', $name, 2);
87+
88+
if (count($parts) !== 2) {
89+
return null;
90+
}
91+
92+
[$prefix, $name] = $parts;
93+
94+
return $this->iconCache->resolve(
95+
key: "iconify-{$prefix}-{$name}",
96+
cache: fn () => $this->download($prefix, $name),
97+
expiresAt: $this->iconConfig->cacheDuration
98+
? new DateTimeImmutable()
99+
->add(DateInterval::createFromDateString("{$this->iconConfig->cacheDuration} seconds"))
100+
: null,
101+
);
102+
} catch (Exception) {
103+
return null;
104+
}
105+
}
106+
107+
/**
108+
* Forwards the user-provided class attribute to the SVG element
109+
*/
110+
private function injectClass(string $svg, string $class): string
111+
{
112+
return new ImmutableString($svg)
113+
->replace(
114+
search: '<svg ',
115+
replace: "<svg class=\"{$class}\" ",
116+
)
117+
->toString();
118+
}
119+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Tempest\View\IconConfig;
6+
7+
return new IconConfig();
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\View;
6+
7+
final class IconConfig
8+
{
9+
public function __construct(
10+
/**
11+
* The number of seconds to cache the icon SVG files.
12+
*
13+
* If null, the icons will be cached indefinitely.
14+
*
15+
* @var int|null
16+
*/
17+
public ?int $cacheDuration = null,
18+
19+
/**
20+
* URL of the Iconify API.
21+
*
22+
* This allows you to switch to a local or self-hosted Iconify API.
23+
*
24+
* @var string
25+
*/
26+
public string $iconifyApiUrl = 'https://api.iconify.design',
27+
) {
28+
}
29+
}

tests/Integration/Cache/CacheClearCommandTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ public function test_cache_clear_single(): void
1717
{
1818
$this->console
1919
->call('cache:clear')
20+
->print()
2021
->assertSee(ProjectCache::class)
2122
->submit('0')
2223
->submit('yes')
23-
->assertSee(ProjectCache::class)
24-
->assertNotSee(ViewCache::class);
24+
->assertSeeCount('CLEARED', 1);
2525
}
2626
}

0 commit comments

Comments
 (0)