From 11375911a700237dde49ad63338f8cc2a5ea1ee1 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Thu, 5 Nov 2026 07:29:02 -0500 Subject: [PATCH 1/5] Align IconDriverInterface with SvgDriver and bind it The interface described a directory API no driver in use implemented, and SvgDriver, the driver core runs, implemented nothing. The interface now states SvgDriver's render() and load(), SvgDriver implements it, the provider binds it to the SvgDriver singleton, and IconRegistry, IconSvgReader and DirectoryIconDriver depend on it. A bound fake renders through the registry; it could not be declared before (C-17). --- src/Contracts/IconDriverInterface.php | 64 +++++++++--------------- src/Drivers/DirectoryIconDriver.php | 18 +++---- src/Drivers/SvgDriver.php | 3 +- src/Providers/IchavaServiceProvider.php | 2 + src/Services/IconRegistry.php | 5 +- src/Services/IconSvgReader.php | 3 +- tests/Feature/BrowserDataWiringTest.php | 2 +- tests/Feature/IconDriverContractTest.php | 42 ++++++++++++++++ 8 files changed, 82 insertions(+), 57 deletions(-) create mode 100644 tests/Feature/IconDriverContractTest.php diff --git a/src/Contracts/IconDriverInterface.php b/src/Contracts/IconDriverInterface.php index 9f5759b..41369f5 100644 --- a/src/Contracts/IconDriverInterface.php +++ b/src/Contracts/IconDriverInterface.php @@ -4,55 +4,39 @@ namespace Simtabi\Laranail\Ichava\Contracts; +use Simtabi\Laranail\Ichava\Data\IconData; +use Simtabi\Laranail\Ichava\Drivers\SvgDriver; +use Simtabi\Laranail\Ichava\Exceptions\IchavaException; + +/** + * Reads SVG files safely and renders icons from them. + * + * Bound to {@see SvgDriver}. The registry, the served-SVG reader and the + * directory driver depend on this, so a host substitutes the driver (a + * remote store, an encrypted one) without subclassing. The signatures are + * SvgDriver's: until 2026-11-05 this interface described a directory API no + * driver in use implemented (C-17). + */ interface IconDriverInterface { /** - * Get all available icons + * Render $icon with $attributes applied to its root element. * - * @return array - */ - public function all(): array; - - /** - * Render an icon as SVG string - * - * @param string $name Icon name - * @param array $attributes HTML attributes - */ - public function render(string $name, array $attributes = []): string; - - /** - * Check if an icon exists - * - * @param string $name Icon name - */ - public function has(string $name): bool; - - /** - * Set configuration for the driver - * - * @param array $config + * @param array $attributes + * @param string|null $baseDir the pack directory the file must stay inside + * @param int|null $maxBytes the size ceiling for the icon's kind; null keeps the global one * - * @return $this + * @throws IchavaException when the file is refused or cannot be read */ - public function setConfig(array $config): static; + public function render(IconData $icon, array $attributes = [], ?string $baseDir = null, ?int $maxBytes = null): string; /** - * Get configuration + * The raw, unsanitised content of the SVG at $path, after the safety + * checks: no symlink, contained in $baseDir, within the size bounds. * - * @return array - */ - public function getConfig(): array; - - /** - * Set the icon path + * @param array $options reserved for future loaders * - * @return $this - */ - public function setIconPath(string $path): static; - - /** - * Get the icon path + * @throws IchavaException when the file is refused or cannot be read */ - public function getIconPath(): string; + public function load(string $path, array $options = [], ?string $baseDir = null, ?int $maxBytes = null): string; } diff --git a/src/Drivers/DirectoryIconDriver.php b/src/Drivers/DirectoryIconDriver.php index 4f4c071..ebe03d2 100644 --- a/src/Drivers/DirectoryIconDriver.php +++ b/src/Drivers/DirectoryIconDriver.php @@ -10,18 +10,12 @@ use Simtabi\Laranail\Ichava\Contracts\IconDriverInterface; /** - * `IconDriverInterface` over one directory of SVG files. - * - * The extension point for a driver author: list, check and render icons by - * their path under the directory (`ui/check` → `ui/check.svg`), with no - * registry, pack or database involved. Rendering goes through SvgDriver, so - * the same containment checks, size limit and sanitiser apply. - * - * SvgDriver itself does not implement the interface: its `render()` takes a - * resolved IconData, which is what IconRegistry hands it, and changing that - * signature would break the registry and every subclass. + * Lists, checks and renders the icons in one directory of SVG files, by their + * path under it (`ui/check` → `ui/check.svg`), with no registry, pack or + * database involved. Rendering goes through the bound IconDriverInterface + * (SvgDriver), so the same containment checks, size limit and sanitiser apply. */ -final class DirectoryIconDriver implements IconDriverInterface +final class DirectoryIconDriver { /** @var array */ private array $config = []; @@ -29,7 +23,7 @@ final class DirectoryIconDriver implements IconDriverInterface private string $iconPath = ''; public function __construct( - private readonly SvgDriver $driver, + private readonly IconDriverInterface $driver, ) {} /** diff --git a/src/Drivers/SvgDriver.php b/src/Drivers/SvgDriver.php index 129e3fb..b352021 100644 --- a/src/Drivers/SvgDriver.php +++ b/src/Drivers/SvgDriver.php @@ -4,6 +4,7 @@ namespace Simtabi\Laranail\Ichava\Drivers; +use Simtabi\Laranail\Ichava\Contracts\IconDriverInterface; use Exception; use Stringable; use Illuminate\Support\Str; @@ -37,7 +38,7 @@ * @see IconCacheService * @see IchavaConstants */ -final class SvgDriver +final class SvgDriver implements IconDriverInterface { use ContainsFilePaths; diff --git a/src/Providers/IchavaServiceProvider.php b/src/Providers/IchavaServiceProvider.php index 9bca0d8..ea88d1d 100644 --- a/src/Providers/IchavaServiceProvider.php +++ b/src/Providers/IchavaServiceProvider.php @@ -33,6 +33,7 @@ use Simtabi\Laranail\Ichava\Support\RuntimeLimits; use Simtabi\Laranail\Ichava\Support\SecurityNonce; use Simtabi\Laranail\Ichava\Contracts\PresentsUser; +use Simtabi\Laranail\Ichava\Contracts\IconDriverInterface; use Simtabi\Laranail\Ichava\Contracts\ResolvesUser; use Simtabi\Laranail\Ichava\Services\IconsManifest; use Simtabi\Laranail\Ichava\View\ComponentPrefixes; @@ -195,6 +196,7 @@ public function registeringPackage(): void $this->app->singleton(PathResolver::class); $this->app->singleton(SvgDriver::class); + $this->app->bind(IconDriverInterface::class, fn (Container $app): IconDriverInterface => $app->make(SvgDriver::class)); // 3. Cache service (depends on IchavaLogger only) $this->app->singleton(IconCacheService::class); diff --git a/src/Services/IconRegistry.php b/src/Services/IconRegistry.php index 480fafa..7808f3f 100644 --- a/src/Services/IconRegistry.php +++ b/src/Services/IconRegistry.php @@ -4,6 +4,7 @@ namespace Simtabi\Laranail\Ichava\Services; +use Simtabi\Laranail\Ichava\Contracts\IconDriverInterface; use Exception; use Throwable; use Illuminate\Support\Str; @@ -110,7 +111,7 @@ final class IconRegistry public function __construct( private Application $app, - private SvgDriver $driver, + private IconDriverInterface $driver, private IchavaLogger $logger, private PathResolver $pathResolver, private IconAliasMap $aliases, @@ -921,7 +922,7 @@ public function countIconsInDirectory(string $path): int /** * Direct access to the SvgDriver instance (load, process, render SVG files). */ - public function driver(): SvgDriver + public function driver(): IconDriverInterface { return $this->driver; } diff --git a/src/Services/IconSvgReader.php b/src/Services/IconSvgReader.php index b14c74c..6bc357b 100644 --- a/src/Services/IconSvgReader.php +++ b/src/Services/IconSvgReader.php @@ -4,6 +4,7 @@ namespace Simtabi\Laranail\Ichava\Services; +use Simtabi\Laranail\Ichava\Contracts\IconDriverInterface; use Throwable; use Simtabi\Laranail\Ichava\Models\Icon; use Simtabi\Laranail\Ichava\Enums\IconKind; @@ -24,7 +25,7 @@ { public function __construct( private IconCacheService $cache, - private SvgDriver $driver, + private IconDriverInterface $driver, private SvgProcessingService $processor, private AuditLogger $audit, ) {} diff --git a/tests/Feature/BrowserDataWiringTest.php b/tests/Feature/BrowserDataWiringTest.php index 47c6eeb..babd655 100644 --- a/tests/Feature/BrowserDataWiringTest.php +++ b/tests/Feature/BrowserDataWiringTest.php @@ -215,7 +215,7 @@ public function configurePackage(Package $package): void {} ->and($provider->getPackageMetadata()['kind'])->toBe('icon'); }); -it('implements IconDriverInterface over a directory', function (): void { +it('lists, checks and renders a directory through the bound driver', function (): void { $driver = app(DirectoryIconDriver::class)->setIconPath(fixturePack('KindPack/files')); expect($driver->all())->toContain('glyphs/star', 'scenes/park') diff --git a/tests/Feature/IconDriverContractTest.php b/tests/Feature/IconDriverContractTest.php new file mode 100644 index 0000000..0022b33 --- /dev/null +++ b/tests/Feature/IconDriverContractTest.php @@ -0,0 +1,42 @@ +toBe(app(SvgDriver::class)) + ->and(app(IconRegistry::class)->driver())->toBeInstanceOf(IconDriverInterface::class); +}); + +it('renders through whatever driver the interface is bound to', function (): void { + app()->forgetInstance(IconRegistry::class); + app()->instance(IconDriverInterface::class, new class implements IconDriverInterface + { + public function render(IconData $icon, array $attributes = [], ?string $baseDir = null, ?int $maxBytes = null): string + { + return ''; + } + + public function load(string $path, array $options = [], ?string $baseDir = null, ?int $maxBytes = null): string + { + return ''; + } + }); + + $registry = app(IconRegistry::class); + $registry->fromDirectory(dirname(__DIR__, 2) . '/resources/assets/svg/test-icons'); + + expect($registry->render('ichava/test-icons::test-icons/check'))->toBe(''); +}); From b8a0c1a6891e3c304b554d22adf7db194d71174f Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Thu, 5 Nov 2026 07:30:21 -0500 Subject: [PATCH 2/5] Make IconSetBuilder::cache(false) turn the lookup cache off cache() set a flag nothing read, so a set always cached. get() and all() now rediscover on every call when it is off, and caching stays on by default. Two properties nothing wrote or read, $discoveredIcons and $initialized, are removed. --- src/Services/IconSetBuilder.php | 23 ++++++++++++----------- tests/Unit/IconSetBuilderCacheTest.php | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/Services/IconSetBuilder.php b/src/Services/IconSetBuilder.php index 207d643..183fcff 100644 --- a/src/Services/IconSetBuilder.php +++ b/src/Services/IconSetBuilder.php @@ -36,10 +36,6 @@ final class IconSetBuilder implements IconSetInterface private IconSetConfig $config; - /** @var array */ - // @phpstan-ignore property.onlyWritten (written and never read since before 2026-11-05; listed for an owner decision, not removed) - private array $discoveredIcons = []; - // From IconSetBuilder (fluent configuration) private string $name; @@ -62,8 +58,8 @@ final class IconSetBuilder implements IconSetInterface private ?string $fallback = null; - // @phpstan-ignore property.onlyWritten (written and never read since before 2026-11-05; listed for an owner decision, not removed) - private bool $enableCache = false; + /** Whether lookups are cached; cache(false) rediscovers on every call. */ + private bool $enableCache = true; /** @var IconSetInterface[] Merged icon sets */ private array $mergedSets = []; @@ -71,10 +67,6 @@ final class IconSetBuilder implements IconSetInterface /** @var bool Whether this is a merged set */ private bool $isMerged = false; - /** @var bool Whether the parent has been initialized */ - // @phpstan-ignore property.onlyWritten (written and never read since before 2026-11-05; listed for an owner decision, not removed) - private bool $initialized = false; - /** * Create new icon set */ @@ -323,7 +315,8 @@ public function fallback(?string $icon): static } /** - * Enable/disable caching + * Enable/disable caching of icon lookups (on by default). Off, get() + * and all() read the directory on every call. * * @param bool $enabled Cache enabled * @@ -403,6 +396,10 @@ public function get(string $name, ?string $variant = null, ?string $category = n return null; } + if (! $this->enableCache) { + return $this->discoverIcon($name, $variant, $category); + } + $cacheKey = $this->getCacheKey($name, $variant, $category); $cached = $this->cache->remember($cacheKey, function () use ($name, $variant, $category) { @@ -449,6 +446,10 @@ public function all(?string $variant = null, ?string $category = null): array return $allIcons; } + if (! $this->enableCache) { + return $this->discoverAllIcons($variant, $category); + } + $cacheKey = $this->getAllCacheKey($variant, $category); $cached = $this->cache->remember($cacheKey, function () use ($variant, $category) { diff --git a/tests/Unit/IconSetBuilderCacheTest.php b/tests/Unit/IconSetBuilderCacheTest.php index 57e7619..9601afc 100644 --- a/tests/Unit/IconSetBuilderCacheTest.php +++ b/tests/Unit/IconSetBuilderCacheTest.php @@ -136,3 +136,23 @@ function useHardenedFileCache(): void } }); }); + +describe('cache() switches the set cache', function () { + it('serves a looked-up icon from the cache by default, and rediscovers with cache(false)', function () { + $dir = makeCacheTestDir(); + $cached = makeCacheTestSet($dir); + $uncached = IconSetBuilder::make('test-cache-set-off')->setBasePath($dir)->prefix('tco')->cache(false); + + expect($cached->get('check', 'outline'))->not->toBeNull() + ->and($uncached->get('check', 'outline'))->not->toBeNull(); + + File::delete($dir . '/outline/check.svg'); + + // cache(false) used to set a flag nothing read, so both still answered. + expect($cached->get('check', 'outline'))->not->toBeNull() + ->and($uncached->get('check', 'outline'))->toBeNull() + ->and($uncached->all('outline'))->toBe([]); + + File::deleteDirectory($dir); + }); +}); From 92f125552b82cb5ddeb26157e541f258c754535b Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Thu, 5 Nov 2026 07:31:21 -0500 Subject: [PATCH 3/5] Read components.test_icons and drop the unused queue-command check ComponentPrefixes::testIconsEnabled() answers components.test_icons, off whenever components.enabled is, so icon-browser can read core's key. The config and the Blade page say who reads it. isIchavaQueueCommand() lost its only caller in C-01 and is removed (forward-only). --- CHANGELOG.md | 4 ++++ config/ichava-core.php | 2 ++ docs/tools/blade-components.md | 2 +- src/Contracts/IconDriverInterface.php | 4 ++-- src/Drivers/SvgDriver.php | 2 +- src/Providers/IchavaServiceProvider.php | 20 +---------------- src/Services/IconRegistry.php | 2 +- src/Services/IconSvgReader.php | 2 +- src/View/ComponentPrefixes.php | 11 +++++++++ tests/Feature/TestIconsComponentKeyTest.php | 25 +++++++++++++++++++++ 10 files changed, 49 insertions(+), 25 deletions(-) create mode 100644 tests/Feature/TestIconsComponentKeyTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc62d1..7bf126d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,9 @@ All notable changes to `ichava/core` follow [Keep a Changelog](https://keepachan ### Changed +- **`IconDriverInterface` states `SvgDriver`'s real API** (`render(IconData …)`, `load(string $path …)`), `SvgDriver` implements it, the provider binds it, and `IconRegistry`, `IconSvgReader` and `DirectoryIconDriver` depend on it (C-17). The interface's old directory methods (`all`, `has`, `setConfig`, `getConfig`, `setIconPath`, `getIconPath`) are gone from it; `DirectoryIconDriver` keeps them as its own API. `IconRegistry::driver()` returns the interface. `tests/Feature/IconDriverContractTest.php`. +- **`IconSetBuilder::cache(false)` turns the lookup cache off.** It set a flag nothing read. Caching stays on by default. `tests/Unit/IconSetBuilderCacheTest.php`. +- **`components.test_icons` has a reader**: `ComponentPrefixes::testIconsEnabled()`, false whenever `components.enabled` is. `tests/Feature/TestIconsComponentKeyTest.php`. - **A pack's `config.defaults` applies wherever the pack renders** (P-06). The registry applies `config.defaults.class`, `.attributes`, `.stroke_width` (as `stroke-width`) and `.fallback` to the set it builds, so ``, the pack's own tag and `IconRegistry::render()` draw the same attributes. Before, a pack's defaults lived in its `IconComponent` subclass and reached its own tag only. `tests/Feature/PackConfigDefaultsTest.php`. - **The pack base providers live in `Providers\`** (C-07): `Support\IconPackServiceProvider` is `Providers\IconPackServiceProvider`, and `Support\ServiceProvider` is `Providers\PackServiceProvider`. No alias is left at the old names; a pack changes one `use` line. `tests/Unit/ProvidersNamespaceTest.php`. - **`custom-icons.sets` keys take effect** (C-09): `enabled`, `prefix`, `default_class`, `default_attributes`, `fallback`, `variants`, `default_variant`, `supports_categories`, `blade_namespace`, `blade_component`, plus `package` for a directory without a `config.json` and `bulk`/`only` for several sets. Before, only `path` was read. `tests/Feature/CustomIconSetsTest.php`. @@ -70,6 +73,7 @@ All notable changes to `ichava/core` follow [Keep a Changelog](https://keepachan ### Removed +- **`IchavaServiceProvider::isIchavaQueueCommand()`**, unused since C-01 moved the runtime limits into `RuntimeLimits`. **`IconSetBuilder::$discoveredIcons` and `$initialized`**, which nothing wrote or read. - **Forward-only renames, with no alias left behind** (owner, 2026-11-05): `Support\IconPackServiceProvider` and `Support\ServiceProvider` (now `Providers\IconPackServiceProvider`, `Providers\PackServiceProvider`), `Actions\CountSvgFiles` (now `Support\SvgFileCounter`), `ClearDiscoveryCaches::generation()` (now `DiscoveryCacheGeneration::current()`), and `__invoke()` on four actions (now `handle()`). Every caller in the estate moves in the same sweep; each is recorded in the deprecations audit. - **The bare `icon` morph alias** (now `ichava-icon`; the migration moves stored rows). The estate is forward-only (owner, 2026-11-05: "never support legacy only forward"). Nothing has been released, so these compatibility paths go rather than stay beside their replacements. diff --git a/config/ichava-core.php b/config/ichava-core.php index 4100826..6fd56a7 100644 --- a/config/ichava-core.php +++ b/config/ichava-core.php @@ -299,6 +299,8 @@ // beside the canonical ``. 'short_prefix' => env('ICHAVA_COMPONENT_SHORT_PREFIX', 'ichava'), + // The bundled component, registered by + // ichava/icon-browser when this is on (ComponentPrefixes::testIconsEnabled()). 'test_icons' => env('ICHAVA_TEST_COMPONENT_ENABLED', env('APP_ENV') === 'local'), ], diff --git a/docs/tools/blade-components.md b/docs/tools/blade-components.md index 259ec46..416d452 100644 --- a/docs/tools/blade-components.md +++ b/docs/tools/blade-components.md @@ -80,7 +80,7 @@ Both extend `IconComponent` and accept the same `name`, `class`, `title`, `aria` ``` -Both are registered by `ichava/icon-browser`, and its `test_component_enabled` key turns the test component off. Core's own `components.test_icons` key is read by nothing today; it is kept, and listed for a decision. +Both are registered by `ichava/icon-browser`. Whether the test component is registered is core's `components.test_icons` key (`ICHAVA_TEST_COMPONENT_ENABLED`, on by default only when `APP_ENV` is `local`), read through `ComponentPrefixes::testIconsEnabled()`; `components.enabled` false turns it off with every other core tag. ### Per-package components diff --git a/src/Contracts/IconDriverInterface.php b/src/Contracts/IconDriverInterface.php index 41369f5..826cf3c 100644 --- a/src/Contracts/IconDriverInterface.php +++ b/src/Contracts/IconDriverInterface.php @@ -23,8 +23,8 @@ interface IconDriverInterface * Render $icon with $attributes applied to its root element. * * @param array $attributes - * @param string|null $baseDir the pack directory the file must stay inside - * @param int|null $maxBytes the size ceiling for the icon's kind; null keeps the global one + * @param string|null $baseDir the pack directory the file must stay inside + * @param int|null $maxBytes the size ceiling for the icon's kind; null keeps the global one * * @throws IchavaException when the file is refused or cannot be read */ diff --git a/src/Drivers/SvgDriver.php b/src/Drivers/SvgDriver.php index b352021..3c1e4c5 100644 --- a/src/Drivers/SvgDriver.php +++ b/src/Drivers/SvgDriver.php @@ -4,7 +4,6 @@ namespace Simtabi\Laranail\Ichava\Drivers; -use Simtabi\Laranail\Ichava\Contracts\IconDriverInterface; use Exception; use Stringable; use Illuminate\Support\Str; @@ -14,6 +13,7 @@ use Simtabi\Laranail\Ichava\Constants\IchavaConstants; use Simtabi\Laranail\Ichava\Services\IconCacheService; use Simtabi\Laranail\Ichava\Exceptions\IchavaException; +use Simtabi\Laranail\Ichava\Contracts\IconDriverInterface; use Simtabi\Laranail\Ichava\Services\SvgProcessingService; /** diff --git a/src/Providers/IchavaServiceProvider.php b/src/Providers/IchavaServiceProvider.php index ea88d1d..1cdb201 100644 --- a/src/Providers/IchavaServiceProvider.php +++ b/src/Providers/IchavaServiceProvider.php @@ -5,7 +5,6 @@ namespace Simtabi\Laranail\Ichava\Providers; use Throwable; -use Illuminate\Support\Str; use Laravel\Horizon\Horizon; use Illuminate\Support\Facades\DB; use Simtabi\Laranail\Ichava\Ichava; @@ -33,7 +32,6 @@ use Simtabi\Laranail\Ichava\Support\RuntimeLimits; use Simtabi\Laranail\Ichava\Support\SecurityNonce; use Simtabi\Laranail\Ichava\Contracts\PresentsUser; -use Simtabi\Laranail\Ichava\Contracts\IconDriverInterface; use Simtabi\Laranail\Ichava\Contracts\ResolvesUser; use Simtabi\Laranail\Ichava\Services\IconsManifest; use Simtabi\Laranail\Ichava\View\ComponentPrefixes; @@ -54,6 +52,7 @@ use Simtabi\Laranail\Ichava\Contracts\DatabaseOperations; use Simtabi\Laranail\Ichava\Support\IchavaSessionManager; use Illuminate\Contracts\Auth\Access\Gate as GateContract; +use Simtabi\Laranail\Ichava\Contracts\IconDriverInterface; use Simtabi\Laranail\Ichava\Services\ConfigurationService; use Simtabi\Laranail\Ichava\Services\IconDiscoveryService; use Simtabi\Laranail\Ichava\Services\SvgProcessingService; @@ -647,21 +646,4 @@ protected function configureRuntimeSettings(): RuntimeConfigurator { return $this->app->make(RuntimeLimits::class)->configurator(); } - - /** - * Check if we're running an Ichava queue command. - */ - protected function isIchavaQueueCommand(): bool - { - if (! $this->app->runningInConsole()) { - return false; - } - - $argv = $_SERVER['argv'] ?? []; - $argv = is_array($argv) ? array_map(Coerce::string(...), $argv) : []; - $isQueueCommand = collect($argv)->contains(fn (string $arg) => Str::contains($arg, 'queue:')); - $isIchavaQueue = collect($argv)->contains(fn (string $arg) => Str::contains($arg, 'ichava')); - - return $isQueueCommand && $isIchavaQueue; - } } diff --git a/src/Services/IconRegistry.php b/src/Services/IconRegistry.php index 7808f3f..6e23f1b 100644 --- a/src/Services/IconRegistry.php +++ b/src/Services/IconRegistry.php @@ -4,7 +4,6 @@ namespace Simtabi\Laranail\Ichava\Services; -use Simtabi\Laranail\Ichava\Contracts\IconDriverInterface; use Exception; use Throwable; use Illuminate\Support\Str; @@ -29,6 +28,7 @@ use Simtabi\Laranail\Ichava\Contracts\IconSetInterface; use Simtabi\Laranail\Ichava\Exceptions\IchavaException; use Simtabi\Laranail\Ichava\Events\IconRegistrationEvent; +use Simtabi\Laranail\Ichava\Contracts\IconDriverInterface; /** * IconRegistry - Icon Package Registration & Management diff --git a/src/Services/IconSvgReader.php b/src/Services/IconSvgReader.php index 6bc357b..976e422 100644 --- a/src/Services/IconSvgReader.php +++ b/src/Services/IconSvgReader.php @@ -4,13 +4,13 @@ namespace Simtabi\Laranail\Ichava\Services; -use Simtabi\Laranail\Ichava\Contracts\IconDriverInterface; use Throwable; use Simtabi\Laranail\Ichava\Models\Icon; use Simtabi\Laranail\Ichava\Enums\IconKind; use Simtabi\Laranail\Ichava\Drivers\SvgDriver; use Simtabi\Laranail\Ichava\Support\AuditLogger; use Simtabi\Laranail\Ichava\Exceptions\IchavaException; +use Simtabi\Laranail\Ichava\Contracts\IconDriverInterface; /** * Reads one SVG file of an icon and returns its served form: read safely, diff --git a/src/View/ComponentPrefixes.php b/src/View/ComponentPrefixes.php index 4e24764..bba2622 100644 --- a/src/View/ComponentPrefixes.php +++ b/src/View/ComponentPrefixes.php @@ -43,6 +43,17 @@ public function enabled(): bool return $this->config->get('ichava.ichava-core.components.enabled', true) !== false; } + /** + * Whether the bundled test-icons component should be registered: + * `components.test_icons` (default: on when `APP_ENV` is `local`), and + * never while `components.enabled` is false. Core ships no such + * component; ichava/icon-browser registers it and reads this. + */ + public function testIconsEnabled(): bool + { + return $this->enabled() && (bool) $this->config->get('ichava.ichava-core.components.test_icons', false); + } + /** * The short prefix, or null when it is off. * diff --git a/tests/Feature/TestIconsComponentKeyTest.php b/tests/Feature/TestIconsComponentKeyTest.php new file mode 100644 index 0000000..a86045a --- /dev/null +++ b/tests/Feature/TestIconsComponentKeyTest.php @@ -0,0 +1,25 @@ +set('ichava.ichava-core.components.test_icons', $value); + + expect(app(ComponentPrefixes::class)->testIconsEnabled())->toBe($value); +})->with([true, false]); + +it('turns the test-icons component off with the rest of the Blade surface', function (): void { + config()->set('ichava.ichava-core.components.test_icons', true); + config()->set('ichava.ichava-core.components.enabled', false); + + expect(app(ComponentPrefixes::class)->testIconsEnabled())->toBeFalse(); +}); From e61b94d9be52805afc771a33c0fca035b2086f73 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Thu, 5 Nov 2026 07:44:54 -0500 Subject: [PATCH 4/5] Generate the generic Blade snippet under a prefix this host registers The snippet was always , so with components.short_prefix off a host copied a tag that does not render. It now uses the short prefix while one is configured and ichava-core when it is off, through ComponentPrefixes::snippetPrefix(). Red for the renamed and the off cases. --- src/Services/IconBrowserService.php | 8 ++++++-- src/View/ComponentPrefixes.php | 10 ++++++++++ tests/Unit/IconSnippetFormatTest.php | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Services/IconBrowserService.php b/src/Services/IconBrowserService.php index e3019a6..1dde43b 100644 --- a/src/Services/IconBrowserService.php +++ b/src/Services/IconBrowserService.php @@ -4,6 +4,7 @@ namespace Simtabi\Laranail\Ichava\Services; +use Simtabi\Laranail\Ichava\View\ComponentPrefixes; use stdClass; use ArrayAccess; use Illuminate\Support\Str; @@ -37,6 +38,7 @@ public function __construct( private IconCacheService $cacheManager, private IchavaLogger $logger, private DatabaseOperations $database, + private ComponentPrefixes $prefixes, ) {} /** @@ -149,7 +151,9 @@ public function transformIcon(Icon $icon): array * ``, with the * folders in `category` because the pack component reads a name containing * a slash as a full path. Falls back to the generic form otherwise. - * - Generic: ``. + * - Generic: ``, + * under the short prefix while `components.short_prefix` names one and + * `ichava-core` when it is off, so the copied tag renders on this host. * It used to be ``, a * tag nothing registers holding a reference the registry refuses. */ @@ -159,7 +163,7 @@ public function generateBladeComponent(Icon $icon, bool $useCleanSyntax = true, $tag = $useCleanSyntax ? $this->packComponentTag($icon) : null; if ($tag === null) { - return ""; + return "prefixes->snippetPrefix()}::icon name=\"{$path}\" class=\"{$class}\" />"; } $relative = substr($path, strpos($path, '::') + 2); diff --git a/src/View/ComponentPrefixes.php b/src/View/ComponentPrefixes.php index bba2622..22e64a5 100644 --- a/src/View/ComponentPrefixes.php +++ b/src/View/ComponentPrefixes.php @@ -78,6 +78,16 @@ public function shortPrefix(): ?string return $prefix; } + /** + * The prefix a generated snippet uses for the icon component: the short + * one while it is on, so existing habits keep working, else the canonical + * one, so a copied tag always renders on this host. + */ + public function snippetPrefix(): string + { + return $this->shortPrefix() ?? self::CANONICAL; + } + /** * The prefixes core registers, canonical first. * diff --git a/tests/Unit/IconSnippetFormatTest.php b/tests/Unit/IconSnippetFormatTest.php index 93f1a3a..0700576 100644 --- a/tests/Unit/IconSnippetFormatTest.php +++ b/tests/Unit/IconSnippetFormatTest.php @@ -100,3 +100,17 @@ public function variant(string $path, array $map): ?string expect((new Icon)->getMorphClass())->toBe('ichava-icon'); }); }); + +describe('Generic snippet tag follows the registered prefixes', function () { + it('uses the short tag while components.short_prefix names one, and ichava-core when it is off', function (?string $short, string $tag) { + config()->set('ichava.ichava-core.components.short_prefix', $short); + $icon = snippetIcon('ichava/icon-sets-tabler', 'home', 'files/home.svg'); + + expect(app(Simtabi\Laranail\Ichava\Services\IconBrowserService::class)->generateBladeComponent($icon, useCleanSyntax: false)) + ->toBe(""); + })->with([ + 'default' => ['ichava', 'ichava'], + 'renamed' => ['icon', 'icon'], + 'off' => [null, 'ichava-core'], + ]); +}); From f9f9c026b723c2543204411204bb5e6d744e3f0a Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Thu, 5 Nov 2026 07:45:59 -0500 Subject: [PATCH 5/5] Record each catalogued pack's GitHub visibility icon-sets.json gains visibility, public or private, synced by bin/sync-icon-sets.php from the repos API and kept when a response does not state it, so consumers read one source for which packs are public (F-02). Documented under The pack catalogue. --- CHANGELOG.md | 2 ++ bin/sync-icon-sets.php | 24 ++++++++++++++- docs/tools/artisan-commands.md | 4 +++ icon-sets.json | 14 +++++---- src/Services/IconBrowserService.php | 2 +- tests/Unit/IconSnippetFormatTest.php | 8 ++--- tests/Unit/SyncIconSetsVisibilityTest.php | 36 +++++++++++++++++++++++ 7 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 tests/Unit/SyncIconSetsVisibilityTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bf126d..2ff983f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,8 @@ All notable changes to `ichava/core` follow [Keep a Changelog](https://keepachan ### Changed +- **`icon-sets.json` records each pack's `visibility`** (`public` or `private`), synced by `bin/sync-icon-sets.php` from the repos API and kept when a response does not state it (F-02). Documented under *The pack catalogue* in `docs/tools/artisan-commands.md`. `tests/Unit/SyncIconSetsVisibilityTest.php`. +- **The generic Blade snippet uses a prefix this host registers**: the short one while `components.short_prefix` names one, `ichava-core` when it is off (`ComponentPrefixes::snippetPrefix()`). - **`IconDriverInterface` states `SvgDriver`'s real API** (`render(IconData …)`, `load(string $path …)`), `SvgDriver` implements it, the provider binds it, and `IconRegistry`, `IconSvgReader` and `DirectoryIconDriver` depend on it (C-17). The interface's old directory methods (`all`, `has`, `setConfig`, `getConfig`, `setIconPath`, `getIconPath`) are gone from it; `DirectoryIconDriver` keeps them as its own API. `IconRegistry::driver()` returns the interface. `tests/Feature/IconDriverContractTest.php`. - **`IconSetBuilder::cache(false)` turns the lookup cache off.** It set a flag nothing read. Caching stays on by default. `tests/Unit/IconSetBuilderCacheTest.php`. - **`components.test_icons` has a reader**: `ComponentPrefixes::testIconsEnabled()`, false whenever `components.enabled` is. `tests/Feature/TestIconsComponentKeyTest.php`. diff --git a/bin/sync-icon-sets.php b/bin/sync-icon-sets.php index e547ec3..ac05418 100755 --- a/bin/sync-icon-sets.php +++ b/bin/sync-icon-sets.php @@ -8,7 +8,8 @@ * * For every set pinned in icon-sets.json (key/package/repository) this * script resolves the latest release tag, reads the pack's config.json at - * that tag, counts the committed SVGs (in all, and per kind as icon_counts) + * that tag, counts the committed SVGs (in all, and per kind as icon_counts), + * records the repository's GitHub visibility (public or private) * via the git-trees API, and writes * the snapshot back — so `ichava:install` stays fully local with zero * runtime network calls. @@ -144,6 +145,7 @@ function snapshot(string $repository, string $configPath, array $previous): ?arr } $variants = $config['metadata']['data']['variants'] ?? null; + $visibility = visibilityOf(getJson("https://api.github.com/repos/{$repository}"), $previous['visibility'] ?? null); $tree = $tag !== null ? svgPaths($repository, $tag, $configPath) : null; $count = $tree !== null ? count($tree) : null; $counts = $tree !== null ? countsByKind($tree, $config) : null; @@ -156,9 +158,29 @@ function snapshot(string $repository, string $configPath, array $previous): ?arr 'icon_counts' => $counts ?? ($previous['icon_counts'] ?? null), 'variants' => is_array($variants) ? array_keys($variants) : ($previous['variants'] ?? null), 'latest_version' => $tag !== null ? ltrim($tag, 'vV') : ($previous['latest_version'] ?? null), + 'visibility' => $visibility, ]; } +/** + * The repository's GitHub visibility, `public` or `private`, from the repos + * API response. A response that does not say (a failed request, or a private + * repository the token cannot see, which answers 404) keeps the previous + * value, so one bad night never flips a pack's recorded visibility. + * + * @param array|null $repository + */ +function visibilityOf(?array $repository, mixed $previous): ?string +{ + $visibility = is_array($repository) ? ($repository['visibility'] ?? null) : null; + + if (in_array($visibility, ['public', 'private'], true)) { + return $visibility; + } + + return in_array($previous, ['public', 'private'], true) ? $previous : null; +} + function latestTag(string $repository): ?string { $tags = getJson("https://api.github.com/repos/{$repository}/tags?per_page=100"); diff --git a/docs/tools/artisan-commands.md b/docs/tools/artisan-commands.md index 40917c8..3008319 100644 --- a/docs/tools/artisan-commands.md +++ b/docs/tools/artisan-commands.md @@ -57,6 +57,10 @@ php artisan ichava::ichava-core.watch --package=vendor/pack # Rescan one pack php artisan ichava::ichava-core.watch --refresh # Record every pack's fingerprint after the scan ``` +### The pack catalogue, `icon-sets.json` + +`ichava::ichava-core.install` offers the packs listed in `icon-sets.json` at the root of core, and makes no network call. `bin/sync-icon-sets.php` refreshes it daily (`.github/workflows/sync-icon-sets.yml`). Each entry pins `key`, `package` and `repository`; the script syncs `title`, `description`, `license`, `icon_count`, `icon_counts` (per kind), `variants` and `latest_version` from the pack's `config.json` at its latest tag, and `visibility`, the repository's GitHub visibility (`public` or `private`), from the repos API. A response that does not state the visibility keeps the recorded value. A consumer that needs to know which packs are public reads `visibility` here rather than probing GitHub. + ### Scaffolding a new pack moved out of core `ichava::ichava-core.make:icon-package` was removed in core `0.3.0`. It lives in diff --git a/icon-sets.json b/icon-sets.json index 95453c7..1284fe6 100644 --- a/icon-sets.json +++ b/icon-sets.json @@ -1,6 +1,6 @@ { "schema_version": "3.0", - "_note": "Static snapshot refreshed daily by .github/workflows/sync-icon-sets.yml (bin/sync-icon-sets.php). Each set pins key/package/repository; title, description, license, icon_count, icon_counts (per kind: icon, illustration), variants and latest_version are synced from the pack’s config.json at its latest GitHub tag. To add a set, append an entry carrying at least key, package, repository, title, icon_count and variants — IconSetCatalogService rejects any set missing one of those six, treating an empty string or empty array as missing, so a set pinned with only the first three makes ichava::ichava-core.install throw until the next nightly sync fills the rest. Take the values from the pack’s own config.json and let the workflow refresh them. Write with JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, as this file is generated and any other encoding reflows it. ichava::ichava-core.install reads this file only — zero runtime network calls.", + "_note": "Static snapshot refreshed daily by .github/workflows/sync-icon-sets.yml (bin/sync-icon-sets.php). Each set pins key/package/repository; title, description, license, icon_count, icon_counts (per kind: icon, illustration), variants, latest_version and visibility (the repository's GitHub visibility, public or private) are synced from the pack’s config.json at its latest GitHub tag. To add a set, append an entry carrying at least key, package, repository, title, icon_count and variants — IconSetCatalogService rejects any set missing one of those six, treating an empty string or empty array as missing, so a set pinned with only the first three makes ichava::ichava-core.install throw until the next nightly sync fills the rest. Take the values from the pack’s own config.json and let the workflow refresh them. Write with JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, as this file is generated and any other encoding reflows it. ichava::ichava-core.install reads this file only — zero runtime network calls.", "config_path": "resources/assets/svg/config.json", "sets": [ { @@ -19,7 +19,8 @@ "icon_counts": { "icon": 6238, "illustration": 0 - } + }, + "visibility": "public" }, { "key": "flags", @@ -37,7 +38,8 @@ "icon_counts": { "icon": 542, "illustration": 0 - } + }, + "visibility": "public" }, { "key": "emoji", @@ -54,7 +56,8 @@ "icon_counts": { "icon": 3435, "illustration": 0 - } + }, + "visibility": "public" }, { "key": "openmoji", @@ -71,7 +74,8 @@ "icon_counts": { "icon": 8568, "illustration": 0 - } + }, + "visibility": "public" } ], "synced_at": "2026-11-03T10:18:20Z" diff --git a/src/Services/IconBrowserService.php b/src/Services/IconBrowserService.php index 1dde43b..22ae6cb 100644 --- a/src/Services/IconBrowserService.php +++ b/src/Services/IconBrowserService.php @@ -4,7 +4,6 @@ namespace Simtabi\Laranail\Ichava\Services; -use Simtabi\Laranail\Ichava\View\ComponentPrefixes; use stdClass; use ArrayAccess; use Illuminate\Support\Str; @@ -20,6 +19,7 @@ use Simtabi\Laranail\Ichava\Models\IconTerm; use Simtabi\Laranail\Ichava\Support\Helpers; use Simtabi\Laranail\Ichava\Data\VariantData; +use Simtabi\Laranail\Ichava\View\ComponentPrefixes; use Illuminate\Database\Query\Builder as QueryBuilder; use Simtabi\Laranail\Ichava\Exceptions\IchavaException; use Illuminate\Contracts\Pagination\LengthAwarePaginator; diff --git a/tests/Unit/IconSnippetFormatTest.php b/tests/Unit/IconSnippetFormatTest.php index 0700576..829dbdc 100644 --- a/tests/Unit/IconSnippetFormatTest.php +++ b/tests/Unit/IconSnippetFormatTest.php @@ -106,11 +106,11 @@ public function variant(string $path, array $map): ?string config()->set('ichava.ichava-core.components.short_prefix', $short); $icon = snippetIcon('ichava/icon-sets-tabler', 'home', 'files/home.svg'); - expect(app(Simtabi\Laranail\Ichava\Services\IconBrowserService::class)->generateBladeComponent($icon, useCleanSyntax: false)) + expect(app(IconBrowserService::class)->generateBladeComponent($icon, useCleanSyntax: false)) ->toBe(""); })->with([ - 'default' => ['ichava', 'ichava'], - 'renamed' => ['icon', 'icon'], - 'off' => [null, 'ichava-core'], + 'default' => ['ichava', 'ichava'], + 'renamed' => ['icon', 'icon'], + 'off' => [null, 'ichava-core'], ]); }); diff --git a/tests/Unit/SyncIconSetsVisibilityTest.php b/tests/Unit/SyncIconSetsVisibilityTest.php new file mode 100644 index 0000000..81202b5 --- /dev/null +++ b/tests/Unit/SyncIconSetsVisibilityTest.php @@ -0,0 +1,36 @@ + $visibility], null))->toBe($visibility); +})->with(['public', 'private']); + +it('keeps the recorded visibility when the API does not say', function (?array $response): void { + expect(visibilityOf($response, 'private'))->toBe('private'); +})->with([ + 'a failed request' => [null], + 'a 404 for a hidden repo' => [['message' => 'Not Found']], + 'an unknown value' => [['visibility' => 'internal']], +]); + +it('records no visibility it has never seen', function (): void { + expect(visibilityOf(null, null))->toBeNull(); +}); + +it('records a visibility for every catalogued pack', function (): void { + $catalog = json_decode((string) file_get_contents(dirname(__DIR__, 2) . '/icon-sets.json'), true, flags: JSON_THROW_ON_ERROR); + + expect($catalog['sets'])->not->toBeEmpty(); + + foreach ($catalog['sets'] as $set) { + expect($set['visibility'] ?? null)->toBeIn(['public', 'private'], "{$set['key']} has no visibility"); + } +});