Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions app/Console/Commands/ClearIconCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\View\Components\Icons\SvgIconRegistry;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;

class ClearIconCache extends Command
{
protected $signature = 'icon-cache:clear';
protected $description = 'Clear cached Blade templates for SVG icons';

public function handle()
{
File::deleteDirectory(SvgIconRegistry::getBladePath());
$this->info('SVG icon cache cleared.');
}
}
22 changes: 22 additions & 0 deletions app/Providers/SvgIconServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace App\Providers;

use App\View\Components\Icons\SvgIconRegistry;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\View;

final class SvgIconServiceProvider extends ServiceProvider
{
public function boot(): void
{
View::addLocation(SvgIconRegistry::getBladePath());
}

public function register(): void
{
$this->app->singleton(SvgIconRegistry::class);
}
}
10 changes: 2 additions & 8 deletions app/View/Components/Icons/IconComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,14 @@

namespace App\View\Components\Icons;

use App\Traits\IconTrait;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;

final class IconComponent extends Component
{
use IconTrait;

public string $altText = '';
public string $class = '';
public string $filename = '';
public string $iconPath = '';
public string $titleText = '';

public function __construct(
Expand All @@ -32,13 +28,11 @@ public function __construct(

public function render(): View
{
$this->iconPath = $this->getIconPath($this->filename) ?: 'icon-path';

return view('components.icons.icon-component', [
'iconPath' => $this->iconPath,
'filename' => $this->filename,
'class' => $this->class,
'altText' => $this->altText,
'titleText' => $this->titleText,
'class' => $this->class,
]);
}
}
44 changes: 44 additions & 0 deletions app/View/Components/Icons/SvgIconComponent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace App\View\Components\Icons;

use Illuminate\Contracts\View\View;
use Illuminate\View\Component;

final class SvgIconComponent extends Component
{
private SvgIconRegistry $registry;
public string $altText = '';
public string $class = '';
public string $filename = '';
public string $titleText = '';

public function __construct(
SvgIconRegistry $registry,
string $filename,
string $class = '',
string $label = '',
string $title = '',
) {
$this->registry = $registry;
$this->filename = $filename;
$this->class = $class;
$this->label = $label;
$this->title = $title;
}

public function render(): View
{
$firstRender = $this->registry->isFirstRender($this->filename);
$viewName = $this->registry->getViewName($this->filename);

return view($viewName, [
'class' => $this->class,
'firstRender' => $firstRender,
'label' => $this->label,
'title' => $this->title,
]);
}
}
170 changes: 170 additions & 0 deletions app/View/Components/Icons/SvgIconRegistry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

declare(strict_types=1);

namespace App\View\Components\Icons;

use Illuminate\Support\Str;

/**
* Tracks the SVG icons used while rendering the page so that subsequent renders
* can emit a reference to the earlier rendered icon.
*
* @method static bool isFirstRender(string $filename)
*/
final class SvgIconRegistry
{
private const ICON_DIRECTORY = 'public_html/images/icons';
private const BLADE_DIRECTORY = 'app/generated';
private const VIEW_PARENT_PATH = 'svg-icon';

public static function getBladePath(): string
{
return storage_path(self::BLADE_DIRECTORY);
}

protected array $used = [];

protected array $compiled = [];

/**
* Returns true if the icon with this filename has not been rendered yet.
*
* @param string $filename The filename of the icon to check.
* @return bool
*/
public function isFirstRender(string $filename): bool
{
$firstRender = !isset($this->used[$filename]);
$this->used[$filename] = true;
return $firstRender;
}

/**
* Returns the name of the view that renders the icon with this filename.
*
* If the view has not been compiled yet, or is out-of-date, it will be
* compiled and saved to the icon-cache directory.
*
* @param string $filename The filename of the icon to get the view name for.
* @return string The name of the view that renders the icon.
*/
public function getViewName(string $filename): string
{
if (isset($this->compiled[$filename])) {
return $this->compiled[$filename];
}

$sourcePath = base_path(implode(DIRECTORY_SEPARATOR, [self::ICON_DIRECTORY, "{$filename}.svg"]));

$viewId = Str::slug($filename);
$compiledPath = storage_path(implode(DIRECTORY_SEPARATOR, [self::BLADE_DIRECTORY, self::VIEW_PARENT_PATH, "{$viewId}.blade.php"]));

if (!file_exists($sourcePath)) {
throw new \RuntimeException("SVG icon not found at {$sourcePath} for {$filename}");
}

if (!file_exists($compiledPath) || (filemtime($sourcePath) > filemtime($compiledPath))) {
$svg = file_get_contents($sourcePath);
$compiled = $this->transformSvgToBlade($svg, $viewId);

@mkdir(dirname($compiledPath), 0777, true);
file_put_contents($compiledPath, $compiled);
}

$this->compiled[$filename] = implode('.', [self::VIEW_PARENT_PATH, $viewId]);

return $this->compiled[$filename];
}

protected function buildSvgAttributeString(array $attributes): string
{
$attributes = array_filter($attributes);
if (empty($attributes)) {
return '';
}

return ' ' . implode(' ', array_map(
fn($key, $value) => $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"',
array_keys($attributes),
$attributes,
));
}

/**
* Transforms the SVG markup into a Blade view that renders the icon.
*
* @param string $svg The SVG markup to transform.
* @param string $viewId The ID of the view that will render the icon.
* @return string The Blade view that renders the icon.
*/
protected function transformSvgToBlade(string $svg, string $viewId): string
{
$dom = new \DOMDocument();
$dom->loadXML($svg);

$svgElement = $dom->documentElement;

// Attributes to use on uses of the SVG element.
$width = $svgElement->getAttribute('width');
$height = $svgElement->getAttribute('height');

// Attributes to copy to the SVG symbol
$viewBox = $svgElement->getAttribute('viewBox');
$preserveAspectRatio = $svgElement->getAttribute('preserveAspectRatio');

// Extract title if present to use as a default title for the icon.
$titleElement = $svgElement->getElementsByTagName('title')->item(0);
$title = $titleElement ? $titleElement->textContent : '';

// Extract the aria-label if present to use as a default label for the icon.
$label = $svgElement->getAttribute('aria-label');

$output = "@php \$title ??= '" . addslashes($title) . "'; @endphp\n";
$output .= "@php \$titleId = '$viewId-' . uniqid(); @endphp\n";
$output .= "@php \$label ??= '" . addslashes($label) . "'; @endphp\n";

$output .= '<svg xmlns="http://www.w3.org/2000/svg" version="2.0" role="img"';
$output .= $this->buildSvgAttributeString([
'width' => $width,
'height' => $height,
]) . "\n";

$output .= " @if (!empty(\$label)) aria-label=\"{{ \$label }}\" @endif\n";
$output .= " @if (!empty(\$title)) aria-describedby=\"{{ \$titleId }}\" @endif\n";
$output .= ">\n";

$output .= " @if (!empty(\$title))\n";
$output .= " <title id=\"{{ \$titleId }}\">{{ \$title }}</title>\n";
$output .= " @endif\n";

$output .= " @if (\$firstRender)\n";
$output .= " <defs>\n";
$output .= " <symbol id=\"svg-icon-$viewId\"";
$output .= $this->buildSvgAttributeString([
'viewBox' => $viewBox,
'preserveAspectRatio' => $preserveAspectRatio,
]);
$output .= ">\n";

// Add all child nodes except title
foreach ($svgElement->childNodes as $child) {
if ($child instanceof \DOMElement && $child->tagName === 'title') {
continue;
}

$content = mb_trim($child->ownerDocument->saveXML($child));
if (!empty($content)) {
$output .= " $content\n";
}
}

$output .= " </symbol>\n";
$output .= " </defs>\n";
$output .= " @endif\n";
$output .= " <use href=\"#svg-icon-$viewId\"/>\n";
$output .= "</svg>\n";

return $output;
}
}
2 changes: 2 additions & 0 deletions bootstrap/providers.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use App\Providers\FortifyServiceProvider;
use App\Providers\HorizonServiceProvider;
use App\Providers\RepositoryServiceProvider;
use App\Providers\SvgIconServiceProvider;
use App\Providers\TelescopeServiceProvider;
use App\Providers\ViewComposerServiceProvider;

Expand All @@ -18,6 +19,7 @@
FortifyServiceProvider::class,
HorizonServiceProvider::class,
RepositoryServiceProvider::class,
SvgIconServiceProvider::class,
TelescopeServiceProvider::class,
ViewComposerServiceProvider::class,
];
2 changes: 1 addition & 1 deletion public_html/images/icons/person.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 11 additions & 6 deletions resources/sass/modules/_images.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ figure a:hover {
color: inherit;
}

.icon img {
.icon img,
.icon svg {
display: inline-flex;
width: 1rem;
height: 1rem;
Expand All @@ -32,27 +33,31 @@ figure a:hover {
color: currentColor;
}

.icon.icon-big img {
.icon.icon-big img,
.icon.icon-big svg {
width: 1.25rem;
height: 1.25rem;
}

.icon.icon-bigger img {
.icon.icon-bigger img,
.icon.icon-bigger svg {
width: 1.5rem;
height: 1.5rem;
}

.icon.icon-small img {
.icon.icon-small img,
.icon.icon-small svg {
width: 0.75rem;
height: 0.75rem;
bottom: 0;
}

.icon.icon-smaller img {
.icon.icon-smaller img,
.icon.icon-smaller svg {
width: 0.5rem;
height: 0.5rem;
}

svg {
fill: currentColor;
}
}
Loading