Skip to content

Commit d6c5ca4

Browse files
authored
Merge pull request #64 from MetaFilter/svg-icon-collector
SVG icon component
2 parents 9ed9119 + 1e422f9 commit d6c5ca4

10 files changed

Lines changed: 284 additions & 72 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Console\Commands;
6+
7+
use App\View\Components\Icons\SvgIconRegistry;
8+
use Illuminate\Console\Command;
9+
use Illuminate\Support\Facades\File;
10+
11+
class ClearIconCache extends Command
12+
{
13+
protected $signature = 'icon-cache:clear';
14+
protected $description = 'Clear cached Blade templates for SVG icons';
15+
16+
public function handle()
17+
{
18+
File::deleteDirectory(SvgIconRegistry::getBladePath());
19+
$this->info('SVG icon cache cleared.');
20+
}
21+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Providers;
6+
7+
use App\View\Components\Icons\SvgIconRegistry;
8+
use Illuminate\Support\ServiceProvider;
9+
use Illuminate\Support\Facades\View;
10+
11+
final class SvgIconServiceProvider extends ServiceProvider
12+
{
13+
public function boot(): void
14+
{
15+
View::addLocation(SvgIconRegistry::getBladePath());
16+
}
17+
18+
public function register(): void
19+
{
20+
$this->app->singleton(SvgIconRegistry::class);
21+
}
22+
}

app/View/Components/Icons/IconComponent.php

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,14 @@
44

55
namespace App\View\Components\Icons;
66

7-
use App\Traits\IconTrait;
87
use Illuminate\Contracts\View\View;
98
use Illuminate\View\Component;
109

1110
final class IconComponent extends Component
1211
{
13-
use IconTrait;
14-
1512
public string $altText = '';
1613
public string $class = '';
1714
public string $filename = '';
18-
public string $iconPath = '';
1915
public string $titleText = '';
2016

2117
public function __construct(
@@ -32,13 +28,11 @@ public function __construct(
3228

3329
public function render(): View
3430
{
35-
$this->iconPath = $this->getIconPath($this->filename) ?: 'icon-path';
36-
3731
return view('components.icons.icon-component', [
38-
'iconPath' => $this->iconPath,
32+
'filename' => $this->filename,
33+
'class' => $this->class,
3934
'altText' => $this->altText,
4035
'titleText' => $this->titleText,
41-
'class' => $this->class,
4236
]);
4337
}
4438
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\View\Components\Icons;
6+
7+
use Illuminate\Contracts\View\View;
8+
use Illuminate\View\Component;
9+
10+
final class SvgIconComponent extends Component
11+
{
12+
private SvgIconRegistry $registry;
13+
public string $altText = '';
14+
public string $class = '';
15+
public string $filename = '';
16+
public string $titleText = '';
17+
18+
public function __construct(
19+
SvgIconRegistry $registry,
20+
string $filename,
21+
string $class = '',
22+
string $label = '',
23+
string $title = '',
24+
) {
25+
$this->registry = $registry;
26+
$this->filename = $filename;
27+
$this->class = $class;
28+
$this->label = $label;
29+
$this->title = $title;
30+
}
31+
32+
public function render(): View
33+
{
34+
$firstRender = $this->registry->isFirstRender($this->filename);
35+
$viewName = $this->registry->getViewName($this->filename);
36+
37+
return view($viewName, [
38+
'class' => $this->class,
39+
'firstRender' => $firstRender,
40+
'label' => $this->label,
41+
'title' => $this->title,
42+
]);
43+
}
44+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\View\Components\Icons;
6+
7+
use Illuminate\Support\Str;
8+
9+
/**
10+
* Tracks the SVG icons used while rendering the page so that subsequent renders
11+
* can emit a reference to the earlier rendered icon.
12+
*
13+
* @method static bool isFirstRender(string $filename)
14+
*/
15+
final class SvgIconRegistry
16+
{
17+
private const ICON_DIRECTORY = 'public_html/images/icons';
18+
private const BLADE_DIRECTORY = 'app/generated';
19+
private const VIEW_PARENT_PATH = 'svg-icon';
20+
21+
public static function getBladePath(): string
22+
{
23+
return storage_path(self::BLADE_DIRECTORY);
24+
}
25+
26+
protected array $used = [];
27+
28+
protected array $compiled = [];
29+
30+
/**
31+
* Returns true if the icon with this filename has not been rendered yet.
32+
*
33+
* @param string $filename The filename of the icon to check.
34+
* @return bool
35+
*/
36+
public function isFirstRender(string $filename): bool
37+
{
38+
$firstRender = !isset($this->used[$filename]);
39+
$this->used[$filename] = true;
40+
return $firstRender;
41+
}
42+
43+
/**
44+
* Returns the name of the view that renders the icon with this filename.
45+
*
46+
* If the view has not been compiled yet, or is out-of-date, it will be
47+
* compiled and saved to the icon-cache directory.
48+
*
49+
* @param string $filename The filename of the icon to get the view name for.
50+
* @return string The name of the view that renders the icon.
51+
*/
52+
public function getViewName(string $filename): string
53+
{
54+
if (isset($this->compiled[$filename])) {
55+
return $this->compiled[$filename];
56+
}
57+
58+
$sourcePath = base_path(implode(DIRECTORY_SEPARATOR, [self::ICON_DIRECTORY, "{$filename}.svg"]));
59+
60+
$viewId = Str::slug($filename);
61+
$compiledPath = storage_path(implode(DIRECTORY_SEPARATOR, [self::BLADE_DIRECTORY, self::VIEW_PARENT_PATH, "{$viewId}.blade.php"]));
62+
63+
if (!file_exists($sourcePath)) {
64+
throw new \RuntimeException("SVG icon not found at {$sourcePath} for {$filename}");
65+
}
66+
67+
if (!file_exists($compiledPath) || (filemtime($sourcePath) > filemtime($compiledPath))) {
68+
$svg = file_get_contents($sourcePath);
69+
$compiled = $this->transformSvgToBlade($svg, $viewId);
70+
71+
@mkdir(dirname($compiledPath), 0777, true);
72+
file_put_contents($compiledPath, $compiled);
73+
}
74+
75+
$this->compiled[$filename] = implode('.', [self::VIEW_PARENT_PATH, $viewId]);
76+
77+
return $this->compiled[$filename];
78+
}
79+
80+
protected function buildSvgAttributeString(array $attributes): string
81+
{
82+
$attributes = array_filter($attributes);
83+
if (empty($attributes)) {
84+
return '';
85+
}
86+
87+
return ' ' . implode(' ', array_map(
88+
fn($key, $value) => $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"',
89+
array_keys($attributes),
90+
$attributes,
91+
));
92+
}
93+
94+
/**
95+
* Transforms the SVG markup into a Blade view that renders the icon.
96+
*
97+
* @param string $svg The SVG markup to transform.
98+
* @param string $viewId The ID of the view that will render the icon.
99+
* @return string The Blade view that renders the icon.
100+
*/
101+
protected function transformSvgToBlade(string $svg, string $viewId): string
102+
{
103+
$dom = new \DOMDocument();
104+
$dom->loadXML($svg);
105+
106+
$svgElement = $dom->documentElement;
107+
108+
// Attributes to use on uses of the SVG element.
109+
$width = $svgElement->getAttribute('width');
110+
$height = $svgElement->getAttribute('height');
111+
112+
// Attributes to copy to the SVG symbol
113+
$viewBox = $svgElement->getAttribute('viewBox');
114+
$preserveAspectRatio = $svgElement->getAttribute('preserveAspectRatio');
115+
116+
// Extract title if present to use as a default title for the icon.
117+
$titleElement = $svgElement->getElementsByTagName('title')->item(0);
118+
$title = $titleElement ? $titleElement->textContent : '';
119+
120+
// Extract the aria-label if present to use as a default label for the icon.
121+
$label = $svgElement->getAttribute('aria-label');
122+
123+
$output = "@php \$title ??= '" . addslashes($title) . "'; @endphp\n";
124+
$output .= "@php \$titleId = '$viewId-' . uniqid(); @endphp\n";
125+
$output .= "@php \$label ??= '" . addslashes($label) . "'; @endphp\n";
126+
127+
$output .= '<svg xmlns="http://www.w3.org/2000/svg" version="2.0" role="img"';
128+
$output .= $this->buildSvgAttributeString([
129+
'width' => $width,
130+
'height' => $height,
131+
]) . "\n";
132+
133+
$output .= " @if (!empty(\$label)) aria-label=\"{{ \$label }}\" @endif\n";
134+
$output .= " @if (!empty(\$title)) aria-describedby=\"{{ \$titleId }}\" @endif\n";
135+
$output .= ">\n";
136+
137+
$output .= " @if (!empty(\$title))\n";
138+
$output .= " <title id=\"{{ \$titleId }}\">{{ \$title }}</title>\n";
139+
$output .= " @endif\n";
140+
141+
$output .= " @if (\$firstRender)\n";
142+
$output .= " <defs>\n";
143+
$output .= " <symbol id=\"svg-icon-$viewId\"";
144+
$output .= $this->buildSvgAttributeString([
145+
'viewBox' => $viewBox,
146+
'preserveAspectRatio' => $preserveAspectRatio,
147+
]);
148+
$output .= ">\n";
149+
150+
// Add all child nodes except title
151+
foreach ($svgElement->childNodes as $child) {
152+
if ($child instanceof \DOMElement && $child->tagName === 'title') {
153+
continue;
154+
}
155+
156+
$content = mb_trim($child->ownerDocument->saveXML($child));
157+
if (!empty($content)) {
158+
$output .= " $content\n";
159+
}
160+
}
161+
162+
$output .= " </symbol>\n";
163+
$output .= " </defs>\n";
164+
$output .= " @endif\n";
165+
$output .= " <use href=\"#svg-icon-$viewId\"/>\n";
166+
$output .= "</svg>\n";
167+
168+
return $output;
169+
}
170+
}

bootstrap/providers.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use App\Providers\FortifyServiceProvider;
99
use App\Providers\HorizonServiceProvider;
1010
use App\Providers\RepositoryServiceProvider;
11+
use App\Providers\SvgIconServiceProvider;
1112
use App\Providers\TelescopeServiceProvider;
1213
use App\Providers\ViewComposerServiceProvider;
1314

@@ -18,6 +19,7 @@
1819
FortifyServiceProvider::class,
1920
HorizonServiceProvider::class,
2021
RepositoryServiceProvider::class,
22+
SvgIconServiceProvider::class,
2123
TelescopeServiceProvider::class,
2224
ViewComposerServiceProvider::class,
2325
];
Lines changed: 1 addition & 1 deletion
Loading

resources/sass/modules/_images.scss

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ figure a:hover {
2222
color: inherit;
2323
}
2424

25-
.icon img {
25+
.icon img,
26+
.icon svg {
2627
display: inline-flex;
2728
width: 1rem;
2829
height: 1rem;
@@ -32,27 +33,31 @@ figure a:hover {
3233
color: currentColor;
3334
}
3435

35-
.icon.icon-big img {
36+
.icon.icon-big img,
37+
.icon.icon-big svg {
3638
width: 1.25rem;
3739
height: 1.25rem;
3840
}
3941

40-
.icon.icon-bigger img {
42+
.icon.icon-bigger img,
43+
.icon.icon-bigger svg {
4144
width: 1.5rem;
4245
height: 1.5rem;
4346
}
4447

45-
.icon.icon-small img {
48+
.icon.icon-small img,
49+
.icon.icon-small svg {
4650
width: 0.75rem;
4751
height: 0.75rem;
4852
bottom: 0;
4953
}
5054

51-
.icon.icon-smaller img {
55+
.icon.icon-smaller img,
56+
.icon.icon-smaller svg {
5257
width: 0.5rem;
5358
height: 0.5rem;
5459
}
5560

5661
svg {
5762
fill: currentColor;
58-
}
63+
}

0 commit comments

Comments
 (0)