v1.0.0 initial release

This commit is contained in:
samy
2025-06-13 10:48:20 -10:00
commit 0c8f70bca5
3333 changed files with 189946 additions and 0 deletions
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace BladeUI\Icons;
use BladeUI\Icons\Components\Icon;
use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
final class BladeIconsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->registerConfig();
$this->registerFactory();
$this->registerManifest();
}
public function boot(): void
{
$this->bootCommands();
$this->bootDirectives();
$this->bootIconComponent();
$this->bootPublishing();
}
private function registerConfig(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/blade-icons.php', 'blade-icons');
}
private function registerFactory(): void
{
$this->app->singleton(Factory::class, function (Application $app) {
$config = $app->make('config')->get('blade-icons', []);
$factory = new Factory(
new Filesystem,
$app->make(IconsManifest::class),
$app->make(FilesystemFactory::class),
$config,
);
foreach ($config['sets'] ?? [] as $set => $options) {
if (! isset($options['disk']) || ! $options['disk']) {
$paths = $options['paths'] ?? $options['path'] ?? [];
$options['paths'] = array_map(
fn ($path) => $app->basePath($path),
(array) $paths,
);
}
$factory->add($set, $options);
}
return $factory;
});
$this->callAfterResolving(ViewFactory::class, function ($view, Application $app) {
$app->make(Factory::class)->registerComponents();
});
}
private function registerManifest(): void
{
$this->app->singleton(IconsManifest::class, function (Application $app) {
return new IconsManifest(
new Filesystem,
$this->manifestPath(),
$app->make(FilesystemFactory::class),
);
});
}
private function manifestPath(): string
{
return $this->app->bootstrapPath('cache/blade-icons.php');
}
private function bootCommands(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
Console\CacheCommand::class,
Console\ClearCommand::class,
]);
if (method_exists($this, 'optimizes')) {
$this->optimizes(
'icons:cache',
'icons:clear',
'blade-icons'
);
}
}
}
private function bootDirectives(): void
{
Blade::directive('svg', fn ($expression) => "<?php echo e(svg($expression)); ?>");
}
private function bootIconComponent(): void
{
if ($name = config('blade-icons.components.default')) {
Blade::component($name, Icon::class);
}
}
private function bootPublishing(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../config/blade-icons.php' => $this->app->configPath('blade-icons.php'),
], 'blade-icons');
}
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace BladeUI\Icons\Components;
use Closure;
use Illuminate\View\Component;
final class Icon extends Component
{
public string $name;
public function __construct(string $name)
{
$this->name = $name;
}
public function render(): Closure
{
return function (array $data) {
$attributes = $data['attributes']->getIterator()->getArrayCopy();
$class = $attributes['class'] ?? '';
unset($attributes['class']);
return svg($this->name, $class, $attributes)->toHtml();
};
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BladeUI\Icons\Components;
use Closure;
use Illuminate\View\Component;
final class Svg extends Component
{
public function render(): Closure
{
return function (array $data) {
$attributes = $data['attributes']->getIterator()->getArrayCopy();
$class = $attributes['class'] ?? '';
unset($attributes['class']);
return svg($this->componentName, $class, $attributes)->toHtml();
};
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace BladeUI\Icons\Concerns;
use Illuminate\Support\Str;
trait RendersAttributes
{
private array $attributes;
public function attributes(): array
{
return $this->attributes;
}
private function renderAttributes(): string
{
if (count($this->attributes) == 0) {
return '';
}
return ' '.collect($this->attributes)->map(function (string $value, $attribute) {
if (is_int($attribute)) {
return $value;
}
return sprintf('%s="%s"', $attribute, $value);
})->implode(' ');
}
public function __call(string $method, array $arguments): self
{
if (count($arguments) === 0) {
$this->attributes[] = Str::snake($method, '-');
} else {
$this->attributes[Str::snake($method, '-')] = $arguments[0];
}
return $this;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace BladeUI\Icons\Console;
use BladeUI\Icons\Factory;
use BladeUI\Icons\IconsManifest;
use Illuminate\Console\Command;
final class CacheCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'icons:cache';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Discover icon sets and generate a manifest file';
public function handle(Factory $factory, IconsManifest $manifest): int
{
$manifest->write($factory->all());
$this->info('Blade icons manifest file generated successfully!');
return 0;
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace BladeUI\Icons\Console;
use BladeUI\Icons\IconsManifest;
use Illuminate\Console\Command;
final class ClearCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'icons:clear';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Remove the blade icons manifest file';
public function handle(IconsManifest $manifest): int
{
$manifest->delete();
$this->info('Blade icons manifest file cleared!');
return 0;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace BladeUI\Icons\Exceptions;
use Exception;
final class CannotRegisterIconSet extends Exception
{
public static function pathsNotDefined(string $set): self
{
return new self("The options for the \"$set\" set don't have any paths defined.");
}
public static function nonExistingPath(string $set, string $path): self
{
return new self("The [$path] path for the \"$set\" set does not exist.");
}
public static function prefixNotDefined(string $set): self
{
return new self("The options for the \"$set\" set don't have a prefix defined.");
}
public static function prefixNotUnique(string $set, string $collidingSet): self
{
return new self("The prefix for the \"$set\" collides with the one from the \"$collidingSet\" set.");
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace BladeUI\Icons\Exceptions;
use Exception;
final class SvgNotFound extends Exception
{
public static function missing(string $set, string $name): self
{
return new self("Svg by name \"$name\" from set \"$set\" not found.");
}
}
+257
View File
@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace BladeUI\Icons;
use BladeUI\Icons\Components\Svg as SvgComponent;
use BladeUI\Icons\Exceptions\CannotRegisterIconSet;
use BladeUI\Icons\Exceptions\SvgNotFound;
use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Str;
final class Factory
{
private Filesystem $filesystem;
private IconsManifest $manifest;
private ?FilesystemFactory $disks;
private array $config;
private array $sets = [];
private array $cache = [];
public function __construct(
Filesystem $filesystem,
IconsManifest $manifest,
?FilesystemFactory $disks = null,
array $config = []
) {
$this->filesystem = $filesystem;
$this->manifest = $manifest;
$this->disks = $disks;
$this->config = $config;
$this->config['class'] = $config['class'] ?? '';
$this->config['attributes'] = (array) ($config['attributes'] ?? []);
$this->config['fallback'] = $config['fallback'] ?? '';
$this->config['components'] = [
'disabled' => $config['components']['disabled'] ?? false,
'default' => $config['components']['default'] ?? 'icon',
];
}
/**
* @internal This method is only meant for internal purposes and does not fall under the package's BC promise.
*/
public function all(): array
{
return $this->sets;
}
/**
* @throws CannotRegisterIconSet
*/
public function add(string $set, array $options): self
{
if (! isset($options['prefix'])) {
throw CannotRegisterIconSet::prefixNotDefined($set);
}
if ($collidingSet = $this->getSetByPrefix($options['prefix'])) {
throw CannotRegisterIconSet::prefixNotUnique($set, $collidingSet);
}
$paths = (array) ($options['paths'] ?? $options['path'] ?? []);
$options['paths'] = array_filter(array_map(
fn ($path) => $path !== '/' ? rtrim($path, '/') : $path,
$paths,
));
if (empty($options['paths'])) {
throw CannotRegisterIconSet::pathsNotDefined($set);
}
unset($options['path']);
$filesystem = $this->filesystem($options['disk'] ?? null);
foreach ($options['paths'] as $path) {
if ($path !== '/' && $filesystem->missing($path)) {
throw CannotRegisterIconSet::nonExistingPath($set, $path);
}
}
$this->sets[$set] = $options;
$this->cache = [];
return $this;
}
public function registerComponents(): void
{
if ($this->config['components']['disabled']) {
return;
}
foreach ($this->manifest->getManifest($this->sets) as $set => $paths) {
foreach ($paths as $icons) {
foreach ($icons as $icon) {
Blade::component(
SvgComponent::class,
$icon,
$this->sets[$set]['prefix'] ?? '',
);
}
}
}
}
/**
* @throws SvgNotFound
*/
public function svg(string $name, $class = '', array $attributes = []): Svg
{
[$set, $name] = $this->splitSetAndName($name);
try {
return new Svg(
$name,
$this->contents($set, $name),
$this->formatAttributes($set, $class, $attributes),
);
} catch (SvgNotFound $exception) {
if (isset($this->sets[$set]['fallback']) && $this->sets[$set]['fallback'] !== '') {
$name = $this->sets[$set]['fallback'];
try {
return new Svg(
$name,
$this->contents($set, $name),
$this->formatAttributes($set, $class, $attributes),
);
} catch (SvgNotFound $exception) {
//
}
}
if ($this->config['fallback']) {
return $this->svg($this->config['fallback'], $class, $attributes);
}
throw $exception;
}
}
/**
* @throws SvgNotFound
*/
private function contents(string $set, string $name): string
{
if (isset($this->cache[$set][$name])) {
return $this->cache[$set][$name];
}
if (isset($this->sets[$set])) {
foreach ($this->sets[$set]['paths'] as $path) {
try {
return $this->cache[$set][$name] = $this->getSvgFromPath(
$name,
$path,
$this->sets[$set]['disk'] ?? null,
);
} catch (FileNotFoundException $exception) {
//
}
}
}
throw SvgNotFound::missing($set, $name);
}
private function getSvgFromPath(string $name, string $path, ?string $disk = null): string
{
$contents = trim($this->filesystem($disk)->get(sprintf(
'%s/%s.svg',
rtrim($path),
str_replace('.', '/', $name),
)));
return $this->cleanSvgContents($contents);
}
private function cleanSvgContents(string $contents): string
{
return trim(preg_replace('/^(<\?xml.+?\?>)/', '', $contents));
}
private function splitSetAndName(string $name): array
{
$prefix = Str::before($name, '-');
$set = $this->getSetByPrefix($prefix);
$name = $set ? Str::after($name, '-') : $name;
return [$set ?? 'default', $name];
}
private function getSetByPrefix(string $prefix): ?string
{
return collect($this->sets)->where('prefix', $prefix)->keys()->first();
}
private function formatAttributes(string $set, $class = '', array $attributes = []): array
{
if (is_string($class)) {
if ($class = $this->buildClass($set, $class)) {
$attributes['class'] = $attributes['class'] ?? $class;
}
} elseif (is_array($class)) {
$attributes = $class;
if (! isset($attributes['class']) && $class = $this->buildClass($set, '')) {
$attributes['class'] = $class;
}
}
$attributes = array_merge(
$this->config['attributes'],
(array) ($this->sets[$set]['attributes'] ?? []),
$attributes,
);
foreach ($attributes as $key => $value) {
if (is_string($value)) {
$attributes[$key] = str_replace('"', '&quot;', $value);
}
}
return $attributes;
}
private function buildClass(string $set, string $class): string
{
return trim(sprintf(
'%s %s',
trim(sprintf('%s %s', $this->config['class'], $this->sets[$set]['class'] ?? '')),
$class,
));
}
/**
* @return \Illuminate\Contracts\Filesystem\Filesystem|Filesystem
*/
private function filesystem(?string $disk = null)
{
return $this->disks && $disk ? $this->disks->disk($disk) : $this->filesystem;
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace BladeUI\Icons\Generation;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;
use Symfony\Component\Finder\SplFileInfo;
final class IconGenerator
{
private Filesystem $filesystem;
private array $sets;
public function __construct(array $sets)
{
$this->filesystem = new Filesystem;
$this->sets = $sets;
}
public static function create(array $config): self
{
return new self($config);
}
public function generate(): void
{
foreach ($this->sets as $set) {
$destination = $this->getDestinationDirectory($set);
$files = array_filter(
$this->filesystem->files($set['source']),
fn (SplFileInfo $value) => str_ends_with($value->getFilename(), '.svg')
);
foreach ($files as $file) {
$filename = Str::of($file->getFilename());
$filename = $this->applyPrefixes($set, $filename);
$filename = $this->applySuffixes($set, $filename);
$pathname = $destination.$filename;
$this->filesystem->copy($file->getRealPath(), $pathname);
if (is_callable($set['after'] ?? null)) {
$set['after']($pathname, $set, $file);
}
}
}
}
private function getDestinationDirectory(array $set): string
{
$destination = Str::finish($set['destination'], DIRECTORY_SEPARATOR);
if (! Arr::get($set, 'safe', false)) {
$this->filesystem->deleteDirectory($destination);
}
$this->filesystem->ensureDirectoryExists($destination);
return $destination;
}
private function applyPrefixes($set, Stringable $filename): Stringable
{
if ($set['input-prefix'] ?? false) {
$filename = $filename->after($set['input-prefix']);
}
if ($set['output-prefix'] ?? false) {
$filename = $filename->prepend($set['output-prefix']);
}
return $filename;
}
private function applySuffixes($set, Stringable $filename): Stringable
{
if ($set['input-suffix'] ?? false) {
$filename = $filename->replace($set['input-suffix'].'.svg', '.svg');
}
if ($set['output-suffix'] ?? false) {
$filename = $filename->replace('.svg', $set['output-suffix'].'.svg');
}
return $filename;
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace BladeUI\Icons;
use Exception;
use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Str;
use Symfony\Component\Finder\SplFileInfo;
final class IconsManifest
{
private Filesystem $filesystem;
private string $manifestPath;
private ?FilesystemFactory $disks;
private ?array $manifest = null;
public function __construct(Filesystem $filesystem, string $manifestPath, ?FilesystemFactory $disks = null)
{
$this->filesystem = $filesystem;
$this->manifestPath = $manifestPath;
$this->disks = $disks;
}
private function build(array $sets): array
{
$compiled = [];
foreach ($sets as $name => $set) {
$icons = [];
foreach ($set['paths'] as $path) {
$icons[$path] = [];
foreach ($this->filesystem($set['disk'] ?? null)->allFiles($path) as $file) {
if ($file instanceof SplFileInfo) {
if ($file->getExtension() !== 'svg') {
continue;
}
$icons[$path][] = $this->format($file->getPathName(), $path);
} else {
if (! Str::endsWith($file, '.svg')) {
continue;
}
$icons[$path][] = $this->format($file, $path);
}
}
$icons[$path] = array_unique($icons[$path]);
}
$compiled[$name] = array_filter($icons);
}
return $compiled;
}
/**
* @return \Illuminate\Contracts\Filesystem\Filesystem|Filesystem
*/
private function filesystem(?string $disk = null)
{
return $this->disks && $disk ? $this->disks->disk($disk) : $this->filesystem;
}
public function delete(): bool
{
return $this->filesystem->delete($this->manifestPath);
}
private function format(string $pathname, string $path): string
{
return (string) Str::of($pathname)
->after($path.DIRECTORY_SEPARATOR)
->replace(DIRECTORY_SEPARATOR, '.')
->basename('.svg');
}
public function getManifest(array $sets): array
{
if (! is_null($this->manifest)) {
return $this->manifest;
}
if (! $this->filesystem->exists($this->manifestPath)) {
return $this->manifest = $this->build($sets);
}
return $this->manifest = $this->filesystem->getRequire($this->manifestPath);
}
/**
* @throws Exception
*/
public function write(array $sets): void
{
if (! is_writable($dirname = dirname($this->manifestPath))) {
throw new Exception("The {$dirname} directory must be present and writable.");
}
$this->filesystem->replace(
$this->manifestPath,
'<?php return '.var_export($this->build($sets), true).';',
);
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace BladeUI\Icons;
use BladeUI\Icons\Concerns\RendersAttributes;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Str;
final class Svg implements Htmlable
{
use RendersAttributes;
private string $name;
private string $contents;
public function __construct(string $name, string $contents, array $attributes = [])
{
$this->name = $name;
$this->contents = $this->deferContent($contents, $attributes['defer'] ?? false);
unset($attributes['defer']);
$this->attributes = $attributes;
}
public function name(): string
{
return $this->name;
}
public function contents(): string
{
return $this->contents;
}
/**
* This method adds a title element and an aria-labelledby attribute to the SVG.
* To comply with accessibility standards, SVGs should have a title element.
* Check accessibility patterns for icons: https://www.deque.com/blog/creating-accessible-svgs/
*/
public function addTitle(string $title): string
{
// create title element
$titleElement = '<title>'.$title.'</title>';
// add role attribute to svg element
$this->attributes['role'] = 'img';
// add title element to svg
return preg_replace('/<svg[^>]*>/', "$0$titleElement", $this->contents);
}
public function toHtml(): string
{
// Check if the title attribute is set and add a title element to the SVG
if (array_key_exists('title', $this->attributes)) {
$this->contents = $this->addTitle($this->attributes['title']);
}
return str_replace(
'<svg',
sprintf('<svg%s', $this->renderAttributes()),
$this->contents,
);
}
protected function deferContent(string $contents, $defer = false): string
{
if ($defer === false) {
return $contents;
}
$svgContent = Str::of($contents)
->replaceMatches('/<svg[^>]*>/', '')
->replaceMatches('/<\/svg>/', '')
->__toString();
// Force Unix line endings for hash.
$hashContent = str_replace(PHP_EOL, "\n", $svgContent);
$hash = 'icon-'.(is_string($defer) ? $defer : md5($hashContent));
$contents = str_replace($svgContent, strtr('<use href=":href"></use>', [':href' => '#'.$hash]), $contents).PHP_EOL;
$svgContent = ltrim($svgContent, PHP_EOL);
$contents .= <<<BLADE
@once("{$hash}")
@push("bladeicons")
<g id="{$hash}">
{$svgContent}
</g>
@endpush
@endonce
BLADE;
return $contents;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use BladeUI\Icons\Factory;
use BladeUI\Icons\Svg;
if (! function_exists('svg')) {
function svg(string $name, $class = '', array $attributes = []): Svg
{
return app(Factory::class)->svg($name, $class, $attributes);
}
}