diff --git a/CHANGELOG.md b/CHANGELOG.md index c69e7bf4..b8963f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to `ichava/core` follow [Keep a Changelog](https://keepachan ### Added +- **One pack model, with a simple and an advanced path** (Proposal 2). Every set now registers as a `Data\PackDefinition` through `IconRegistry::register()`, built by the fluent `Support\Pack\PackBuilder` from a `config.json` (`PackBuilder::fromDirectory()`), from code (`PackBuilder::make('vendor/package', $filesPath)`), or both. Each builder method writes a key an existing reader consumes (`prefix`, `about`, `variant`, `defaultVariant`, `categories`, `kind`, `categoryKind`, `kindRule`, `itemKind`, `themeTwin`, `alias`/`aliases`, `defaultClass`, `defaultAttributes`, `fallback`, `providedBy`, `component`, `metadata`, `withOptions`, `when`). Fluent scalars replace the file's, maps merge per key, and fluent kind rules come first. `fromDirectory()`, `package()` and `custom-icons` all delegate to it, and a golden snapshot over 31 pack directories (19 estate packs and every fixture) proves registration is unchanged. `IconRegistry::definition()`/`definitionAt()` return what a set registered with, and a pack's constants class reads it through `Support\Pack\PackConfigReader`, so it sees the builder's overrides. `docs/tools/pack-registration.md`. `tests/Feature/PackBuilderTest.php`, `RegistrationGoldenTest.php`, `JsonConfigConstantsReadsDefinitionTest.php`. +- **`IconPackServiceProvider::pack(PackSpec $pack)`**, an optional hook for the advanced path. The default does nothing, so a pack that keeps everything in `config.json` is unchanged. `Support\Pack\PackSpec` shapes the default set (`set()`), adds a set (`addSet()`) or every config-bearing sub-directory of a path (`sets()`), adds Blade components (`component()`, `mainComponent()`) and names the config file (`configFile()`). `tests/Feature/PackSpecTest.php`, fixture `tests/fixtures/SpecPack`. +- **Bulk registration on the same path** (Proposal 3). `IconRegistry::registerMany(iterable $definitions, ?BulkOptions $options): BulkResult` registers definitions or builders as one run: one registrar id, `started` (the factory had no dispatcher), each set's `processing`/`registered`/`failed`, then one `completed` with the counts. A broken set is reported and skipped unless `BulkOptions::$failFast`. `PackBuilder::discover($basePath, $only)` lists the config-bearing sub-directories. `Data\BulkOptions`, `Data\BulkResult`. Core's bundled test set registers through it on every boot. `tests/Feature/BulkRegistrationTest.php`. +- **`components.short_prefix` and `components.enabled`** (Proposal 1). `View\ComponentPrefixes` registers the canonical `ichava-core` prefix always and the short one (default `ichava`, `ICHAVA_COMPONENT_SHORT_PREFIX`; null turns it off) beside it, validated against Blade's tag parser at boot. `components.enabled` false registers no core tag. Doctor's `component.short_prefix_shadowed` warns when a later provider has replaced the short alias. Packs follow the same two tiers: `` always, `` while the short prefix is on (C-08). `tests/Feature/ComponentNamespaceDeferralTest.php`. +- **A pack test kit** (P-03, P-04): `Testing\PackTestCase`, the base every pack suite extends; `Testing\PackConformance`, one method per pack rule (core-only dependency, pack type, PHPStan level, sibling constraints, sync caller, workflow permissions, stray text, SVG policy), each asserting how many files it read; and `Support\SvgInspector` (`policyViolations()`, `strayTextNodes()`). `tests/Unit/PackConformanceTest.php`, `SvgInspectorTest.php`. +- **`Contracts\ChecksPackUpdates` and `Contracts\DatabaseOperations`**, bound to `IconPackUpdateChecker` and `DatabaseOperationsService`; core's consumers depend on the contracts (C-06). `tests/Feature/ServiceContractsTest.php`. +- **`Services\IconSvgReader`**, the served-SVG pipeline with its collaborators injected; `Icon`'s accessor forwards to it (C-11). **`Support\RuntimeLimits`**, **`Support\PackConfigCache`**, **`Support\DiscoveryCacheGeneration`**. - **`Support\IconPackServiceProvider`, the base a pack provider extends.** A pack declares `constantsClass()` and `componentClass()` and nothing else; the base names the package from the constants class, sets the path from the provider file, registers `config/{slug}.php` when the pack ships one, registers ``, and registers `resources/assets/svg` with the registry, in the order every pack used. A pack built on it writes no `laranail/package-tools` type, so it can require `ichava/core` alone. `Support\ServiceProvider` is unchanged and still works for a provider that writes its own `configurePackage()`. `tests/Feature/IconPackServiceProviderTest.php`, with the `tests/fixtures/BasePack` fixture. - **`IchavaConstants::PACK_COMPOSER_TYPE`** (`ichava-icon-set`), the composer `type` a pack declares so discovery can find it. - **Kind resolution covers every D6 row** (K1, contract §1–2). Besides a category's `kind` and `package.kind`, a pack can now declare kind per item (`metadata.data.items..kind`) and by ordered glob rule (`metadata.data.kinds.rules[]`, first match wins), and a host can override any of it in `ichava.ichava-core.kinds.overrides` (`'::' => kind`, exact keys before globs). The order is host, item, rule, category, pack, then `icon`. New `Support\KindGlob` (`*`, `?`, whole-segment `**`; the contract's vector table is its test), `Support\KindResolver`, `Enums\KindSource` and the `final readonly` DTO `Data\KindResolution`; `PackConfigData::items()`/`kindRules()` read the new keys from the documented shape and the top level. `IconRegistry::kindOf()`, the renderer's size budget and `IconClassifier::kind()` all resolve through `KindResolver`; `IconRegistry::kindFor()` keeps its category/pack behaviour for its callers. A v0.1 `config.json` with no kind keys loads exactly as before. `tests/Unit/KindGlobTest.php`, `tests/Feature/KindResolutionTest.php`. @@ -39,6 +46,18 @@ All notable changes to `ichava/core` follow [Keep a Changelog](https://keepachan ### Changed +- **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`. +- **`registerBulkIconSets()`/`IchavaRegistrar` apply what they are given** (C-10): each set's values are applied as builder overrides, an empty map registers every set, the five write-only settings gain readers, `statistics()` returns the run's `BulkResult`, and `enableLogging()` logs before or after `configure()`. +- **`package()` registrations carry the full metadata** (C-03), the same as `fromDirectory()`. +- **Actions expose one `handle()`** (C-13): `BuildIconUsageSyntax`, `ClearDiscoveryCaches`, `DiscoverInstalledPackages` and `SearchIconsInDatabase` no longer use `__invoke()`. `Actions\CountSvgFiles` is `Support\SvgFileCounter`. `ClearDiscoveryCaches::generation()` is `DiscoveryCacheGeneration::current()`. `tests/Unit/ActionShapeTest.php`. +- **Concrete classes are `final` by default** (C-14): 19 classes nothing extends or mocks; `tests/Unit/FinalByDefaultTest.php` lists the 7 that stay open and why. +- **Collaborators are injected** (C-05): `IconRegistry` takes `IconCacheService`, `Filesystem` and `SvgFileCounter`; `IchavaSessionManager` takes `IchavaLogger`; the watcher no longer reaches `ConfigurationService`. `tests/Unit/ContainerReachInTest.php`, `tests/Feature/WatcherCacheDecouplingTest.php`. +- **One reader of `config.json` per concern** (C-04): the terms seeder seeds variants from the registry's `variant_metadata`, and `ConfigurationService` parses through `Helpers::loadConfigJson()`. +- **Served SVGs are read through `SvgDriver::load()`** (C-12), so the model path refuses a symlink and a file under `MIN_SVG_FILE_SIZE` as the driver does. 0 of 246,854 pack SVGs are under the minimum or symlinked. `tests/Feature/SvgModelReadsThroughDriverTest.php`. +- **Icon's morph alias is `ichava-icon`, not `icon`** (C-02). The morph map is one flat map per application, so a host or package mapping its own model to `icon` silently replaced core's entry, and every `ichava_icon_termables` row then loaded the wrong model. `Icon::MORPH_ALIAS` is now `ichava-icon`. Migration `2026_11_05_000001_namespace_icon_morph_alias` moves stored `termable_type = 'icon'` rows to it and reinstalls the PostgreSQL search-text functions; `down()` reverses both. Once every provider has registered, boot throws `IchavaException::invalidConfiguration` if the live map resolves `ichava-icon` to any class other than `Icon`. There is no fallback to `icon`. Run `php artisan migrate` after upgrading. `tests/Feature/IconMorphAliasTest.php`. +- **Ichava no longer changes the host's PHP limits** (C-01). The provider raised `memory_limit` and lifted `max_execution_time` while it registered, so every request and command in a host application ran without its own time limit. It also read `runtime.*` before the config had merged, so the shipped `2G` default never took effect and a code fallback of `1G` did. The limits now apply only inside Ichava's own seed, cache and watch commands, `IchavaSeeder::run()` and `SeedIconsJob`, through the new `Support\RuntimeLimits::scope()`, which reads the merged config and restores the previous values when the operation returns or throws. A `SeedIconsJob` now runs under `runtime.memory_limit` (default `2G`) with its own `timeout`, where it set a fixed `512M`. A host that relied on Ichava lifting its limits sets them itself. `tests/Feature/RuntimeLimitsScopeTest.php`. - **The icon-sets catalog lists `ichava/icon-sets-openmoji` and counts emoji as Twemoji only.** `icon-sets.json` gains the openmoji set (8,568 icons, `latest_version` null until the pack is tagged), and the emoji set's count, description and licence follow icon-sets-emoji#99 (3,435 Twemoji icons; OpenMoji moved out). `docs/installation.md`'s pack table follows, as `InstallationPackTableTest` requires. Edited by hand: `bin/sync-icon-sets.php` counts a pack at its latest tag, and emoji's `v0.1.0` still carries the OpenMoji sets until the release re-squash, so the nightly sync will propose emoji's old count back until then. - **The sanitiser keeps `display`, `color` and `marker-*`, each behind a value guard** (parity with ichava/icon-sets-sync#85). A dry run of OpenMoji 17 stripped `display="none"` from 13 subdivision flags, so their hidden shapes would render. `resources/security/svg-policy.json` now matches icon-sets-sync's copy byte for byte (sha256 `7a7c07fd…`): it allows the five names and adds `_allowedAttributes_addendum_presentation`. `display` must be bare keywords, `color` must pass `SvgCssGuard`, and `marker-start`/`marker-mid`/`marker-end` must be `none` or `url(#id)`; `display="url(x)"`, `marker-end="url(https://e/x)"`, `color: expression(…)` and `sodipodi:docname` are still stripped. The sanitiser also drops non-whitespace text and CDATA directly under `` or `` and keeps the indentation; text in ``, ``, `` and `` is untouched. Across 142,247 pack SVGs, 36 outputs changed: 17 for `display`, 17 for `color`, and 2 for stray text (OpenMoji's 1F684 high-speed train, eos-icons' link-off). `RENDER_PIPELINE_VERSION` is now 4, because the stray-text rule changes output that the attribute hash does not see. `tests/Unit/SvgPresentationParityTest.php`, with `tests/fixtures/svg/presentation-vectors.json` shared with react-icon-browser and js-icon-components. - **Doctor's `--format=json` prints a report** instead of the table: `{passed, checks: [...]}`. The table output and the exit code are unchanged. @@ -50,6 +69,8 @@ All notable changes to `ichava/core` follow [Keep a Changelog](https://keepachan ### Removed +- **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. - **The top-level `variants` / `categories` / `items` / `kinds` fallback in `config.json`.** `PackConfigData` reads `metadata.data` only, the shape every pack and the scaffolder's stub ship; doctor's kind checks always name `metadata.data.…` keys. No `config.json` in the estate used the top level (43 read; the one hit was the test fixture that pinned the fallback, which now pins that the top level is ignored). diff --git a/README.md b/README.md index 96b981ff..f9c728bd 100644 --- a/README.md +++ b/README.md @@ -45,23 +45,28 @@ Render an icon with the Blade component, which works with any installed pack: ``` -`` is the same component under the ecosystem prefix and keeps working. - -Or with the fluent helper, which renders when cast to a string: +`` is the same component under the configurable short prefix (`components.short_prefix`), on by default. Or use the fluent helper, which renders when cast to a string: ```blade {{ ichava('ichava/icon-sets-tabler::filled/home')->color('#4338ca')->class('w-5 h-5') }} ``` -Ask what an SVG is, and which rule decided it: +Register a set of your own, from a pack provider's optional `pack()` hook or straight from code: ```php -use Simtabi\Laranail\Ichava\Facades\IchavaFacade as Ichava; - -Ichava::kindOf('ichava/icon-sets-tabler::outline/home'); // IconKind::Icon -Ichava::kindResolution('ichava/icon-sets-tabler::outline/home'); // kind, source (host, item, rule, category, pack, default) and rule +use Simtabi\Laranail\Ichava\Services\IconRegistry; +use Simtabi\Laranail\Ichava\Support\Pack\PackBuilder; + +app(IconRegistry::class)->register( + PackBuilder::make('acme/social', storage_path('icons/social/files')) + ->prefix('social') + ->variant('outline', ['fill' => 'none', 'stroke' => 'currentColor']) + ->build(), +); ``` +A pack that keeps everything in its `config.json` needs none of this: its provider names its constants and component classes, and [Pack registration](docs/tools/pack-registration.md) covers both paths and bulk registration. + See [Getting started](docs/getting-started.md) for the full walkthrough, and the [documentation index](#documentation) for everything else. ## Documentation @@ -84,7 +89,8 @@ The full documentation lives in this repository, under `docs/`, and is indexed b ### Reference - [Icon path format](docs/tools/icon-path-format.md) — the one normaliser both spellings go through -- [Blade components](docs/tools/blade-components.md) — `` and its attributes +- [Blade components](docs/tools/blade-components.md) — ``, the short tag, and the attributes +- [Pack registration](docs/tools/pack-registration.md) — the simple and advanced pack paths, `PackBuilder`, and bulk registration - [Global helper](docs/tools/global-helper.md) — the fluent `ichava()` API - [Artisan commands](docs/tools/artisan-commands.md) — every command core registers - [Query and model API](docs/tools/query-api.md) — scopes, filters, events, status and the extension interfaces diff --git a/composer.json b/composer.json index 0a484c1e..96ce4fdc 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,8 @@ "laravel/pint": "^1.18", "orchestra/testbench": "^11.0", "pestphp/pest": "^5.0", - "phpstan/phpstan": "^2.1" + "phpstan/phpstan": "^2.1", + "symfony/yaml": "^7.4 || ^8.0" }, "conflict": { "symfony/http-foundation": "<7.4.23 || >=8.0,<8.1.11", @@ -65,7 +66,8 @@ "Simtabi\\Laranail\\Ichava\\Tests\\": "tests/", "Simtabi\\Laranail\\Ichava\\Tests\\Fixtures\\BasePack\\": "tests/fixtures/BasePack/src/", "Simtabi\\Laranail\\Ichava\\Tests\\Fixtures\\TranslatedPack\\": "tests/fixtures/TranslatedPack/src/", - "Simtabi\\Laranail\\Ichava\\Tests\\Fixtures\\ViewfulPack\\": "tests/fixtures/ViewfulPack/src/" + "Simtabi\\Laranail\\Ichava\\Tests\\Fixtures\\ViewfulPack\\": "tests/fixtures/ViewfulPack/src/", + "Simtabi\\Laranail\\Ichava\\Tests\\Fixtures\\SpecPack\\": "tests/fixtures/SpecPack/src/" } }, "extra": { diff --git a/config/ichava-core.php b/config/ichava-core.php index fabc42b8..41008262 100644 --- a/config/ichava-core.php +++ b/config/ichava-core.php @@ -118,8 +118,8 @@ |-------------------------------------------------------------------------- | PHP runtime |-------------------------------------------------------------------------- - | Applied at provider registration so heavy operations (seeding 100k+ - | icons, batch processing) get the headroom they need. + | Applied only around Ichava's own seed, watch and cache operations, and + | restored when each returns. The host's limits are never changed. */ 'runtime' => [ 'memory_limit' => env('ICHAVA_MEMORY_LIMIT', '2G'), @@ -289,7 +289,16 @@ |-------------------------------------------------------------------------- */ 'components' => [ - 'enabled' => env('ICHAVA_COMPONENTS_ENABLED', true), + // Core's whole Blade surface. false registers neither tag below. + 'enabled' => env('ICHAVA_COMPONENTS_ENABLED', true), + + // `` is always registered. This is the short + // prefix registered beside it: `` by default, any + // lowercase prefix Blade can parse (letters, digits, hyphens), or + // null/'' to turn it off. Packs follow it: `` + // beside the canonical ``. + 'short_prefix' => env('ICHAVA_COMPONENT_SHORT_PREFIX', 'ichava'), + 'test_icons' => env('ICHAVA_TEST_COMPONENT_ENABLED', env('APP_ENV') === 'local'), ], diff --git a/database/migrations/2026_11_05_000001_namespace_icon_morph_alias.php b/database/migrations/2026_11_05_000001_namespace_icon_morph_alias.php new file mode 100644 index 00000000..773373d0 --- /dev/null +++ b/database/migrations/2026_11_05_000001_namespace_icon_morph_alias.php @@ -0,0 +1,48 @@ +move(self::PREVIOUS_ALIAS, Icon::MORPH_ALIAS); + } + + public function down(): void + { + $this->move(Icon::MORPH_ALIAS, self::PREVIOUS_ALIAS); + } + + private function move(string $from, string $to): void + { + DB::table('ichava_icon_termables')->where('termable_type', $from)->update(['termable_type' => $to]); + + if (! Helpers::dbDriverIsPgSql()) { + return; + } + + IconSearchTextFunctions::install($to); + + if (! env('ICHAVA_SKIP_FTS_BACKFILL', false)) { + IconSearchTextFunctions::rebuildAll(); + } + } +}; diff --git a/docs/architecture.md b/docs/architecture.md index a9310b3a..957d635a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -123,9 +123,9 @@ ecosystem and only the namespace varies, so a contributor can `grep "class Icons and find every pack. A `IconsServiceProvider` name is the thing this convention exists to prevent; the same holds for `IconsConstants` and `IconComponent`. -> **Rule for icon packs:** extend `Simtabi\Laranail\Ichava\Support\IconPackServiceProvider` (lives in `ichava/core`) and declare two methods, `constantsClass()` and `componentClass()`. The base names the package from the constants class, sets the path from the provider file, registers `config/{slug}.php` when the pack ships one, registers ``, and hands `resources/assets/svg` to the registry. Never extend `IchavaServiceProvider`, `IconBrowserServiceProvider`, or `PackageServiceProvider` directly. +> **Rule for icon packs:** extend `Simtabi\Laranail\Ichava\Providers\IconPackServiceProvider` (lives in `ichava/core`) and declare two methods, `constantsClass()` and `componentClass()`. The base names the package from the constants class, sets the path from the provider file, registers `config/{slug}.php` when the pack ships one, registers `` (and the short `` while `components.short_prefix` is on), and hands `resources/assets/svg` to the registry. A pack that needs more overrides the optional `pack(PackSpec $pack)` hook: see [Pack registration](tools/pack-registration.md). Never extend `IchavaServiceProvider`, `IconBrowserServiceProvider`, or `PackageServiceProvider` directly. > -> `Support\ServiceProvider`, which `IconPackServiceProvider` extends, still works for a provider that writes its own `configurePackage(Package $package)`; such a provider imports `laranail/package-tools`' `Package`, which is why no estate pack does any more. +> `Providers\PackServiceProvider`, which `IconPackServiceProvider` extends, still works for a provider that writes its own `configurePackage(Package $package)`; such a provider imports `laranail/package-tools`' `Package`, which is why no estate pack does any more. > **Rule for the browser package:** extends `laranail/package-tools`' `PackageServiceProvider` directly. Boots its routes, views, and Vite assets in `bootingPackage()` so core's services are guaranteed to be bound first. @@ -249,37 +249,25 @@ Migrations are auto-discovered through `discoversMigrations()`, which uses the s Public asset publishing for the SPA happens in `IconBrowserServiceProvider::bootingPackage()`, not core. The browser package's `public/` directory is copied to `public/vendor/ichava/` under the `ichava::icon-browser-assets` tag. -## RuntimeConfigurator (PHP runtime tuning) +## Runtime limits -Heavy operations (seeding 100k+ icons, batch processing) need higher memory limits and longer timeouts. Core uses the `RuntimeConfigurator` utility from `laranail/package-tools`: +Heavy operations (seeding 100k+ icons, watching, cache builds) need a higher memory limit, a longer time limit and Telescope paused. Core applies them only around its own operations, never to the host: `Support\RuntimeLimits::scope()` applies `ichava.ichava-core.runtime.memory_limit`, `runtime.max_execution_time` and `runtime.disable_telescope_in_queue` from the merged config, runs the operation, and restores the previous values when it returns or throws. ```php -use Simtabi\Laranail\PackageTools\Support\RuntimeConfigurator; +use Simtabi\Laranail\Ichava\Support\RuntimeLimits; -RuntimeConfigurator::make() - ->memory('2G') - ->timeout(0) - ->disableTelescope() - ->apply(); +app(RuntimeLimits::class)->scope(fn () => $seeder->seedAllPackages()); ``` -`IchavaServiceProvider::configureRuntimeSettings()` calls this automatically during `registeringPackage()`, reading `ichava.ichava-core.runtime.memory_limit`, `ichava.ichava-core.runtime.max_execution_time`, and `ichava.ichava-core.runtime.disable_telescope_in_queue` from config. No manual setup required. +The seed, cache and watch commands, `IchavaSeeder::run()` and each `SeedIconsJob` run inside it (a job passes its own `timeout`). A host request or command that never calls them keeps its own limits. The configurator is `Simtabi\Laranail\Package\Tools\Support\RuntimeConfigurator` from `laranail/package-tools`. -See `Simtabi\Laranail\Packager\Package\Support\RuntimeConfigurator` for the full fluent API (preset factories like `forQueueJob()`, `forBatchProcessing()`, scoped `scope()` callbacks, Xdebug and Debugbar toggles). +## Bulk registration -## IchavaRegistrar (bulk registration helper) +Several sets under one directory register through `IconRegistry::registerMany()`, the same path as a set registered alone: from a pack's `PackSpec::sets()`, from `PackServiceProvider::registerBulkIconSets()` (which returns the `IchavaRegistrar` that runs it), or from a `custom-icons` entry with `'bulk' => true`. One run is one registrar id: `started`, each set's `processing` and `registered` (or `failed`), and one `completed` with the counts. A broken set is reported in the `BulkResult` and the rest register, unless `BulkOptions::$failFast`. -Use `IchavaRegistrar` when a service provider manages multiple icon-set sub-directories under a shared base path (for example, a pack that vendors dozens of upstream sets). It loops over the directories, skips missing ones, and optionally tracks statistics. +> **Call stage:** only from `bootingPackage()` or later. `IconRegistry` is not bound until core's provider has registered. -| Scenario | Recommended approach | -|---|---| -| Single directory | `$this->registerIconDirectory()` in `bootingPackage()` | -| 2 to 10 directories | `IchavaRegistrar` or `$this->registerBulkIconSets()` | -| 10+ directories or large bundle | `IchavaRegistrar` with `trackStatistics()->enableLogging()` | - -> **Call stage:** only call from `bootingPackage()`. `IconRegistry` is not bound until after the registration phase completes. - -`packagePrefix`, `iconSetSuffix`, and `bladeNamespace` are extensibility hooks. They live on the registrar instance and are available inside a custom `registerIconSet()` override. The default `registerIconSet()` only passes path + provider class to `IconRegistry::fromDirectory()`; it does not act on these properties itself. +The three forms, the options and the event lifecycle are in [Pack registration](tools/pack-registration.md#bulk-registration). ## Icon-package scaffolding @@ -295,7 +283,7 @@ supplied comes from `laranail/console` directly. What a pack is scaffolded *into* is still core's contract: the class short names (`IconsServiceProvider`, `IconsConstants`, `Variant`, `IconComponent`), the -`Support\IconPackServiceProvider` a pack extends, and the `config.json` shape its constants read. That +`Providers\IconPackServiceProvider` a pack extends, and the `config.json` shape its constants read. That contract is documented here; how a pack gets generated is documented [in the scaffolder](https://github.com/ichava/icon-sets-package-scaffolder/blob/main/docs/architecture.md). @@ -310,7 +298,7 @@ Two unified event classes cover Ichava's runtime signals. Listeners can branch o | Action | Constructor | Fires when | |---|---|---| -| `started` | `IconRegistrationEvent::started($registrarId, $name, $totalIconSets, $mode)` | A bulk registration begins | +| `started` | `IconRegistrationEvent::started($registrarId, $name, $totalIconSets, $mode)` | A bulk registration begins (`registerMany()`, mode `bulk`) | | `processing` | `IconRegistrationEvent::processing($registrarId, $name, $metadata, $position, $total)` | Each icon set in a bulk is processed | | `registered` | `IconRegistrationEvent::registered($registrarId, $name, $metadata, $duration, $iconCount)` | A single icon set is registered | | `failed` | `IconRegistrationEvent::failed($registrarId, $name, $metadata, $exception)` | Registration of one set throws | diff --git a/docs/recipes/add-a-custom-icon-set.md b/docs/recipes/add-a-custom-icon-set.md index 9012645c..208a5d3c 100644 --- a/docs/recipes/add-a-custom-icon-set.md +++ b/docs/recipes/add-a-custom-icon-set.md @@ -76,30 +76,37 @@ return [ ### Usage in Blade -Once registered, icons are available via the generic component using `prefix:icon-name` syntax: +A directory with no `config.json` registers as `app/{entry key}` (or the entry's `package`), and its icons are addressed by that name, like any pack's: ```blade - - - - + + + + ``` +A directory holding a `config.json` registers under the `package.name` it declares, with the entry's keys applied over the file. + ### Field reference | Key | Required | Description | |---|---|---| -| `enabled` | ✅ | `true` to register, `false` to skip the entry | -| `path` | ✅ | Absolute path to the SVG directory | -| `prefix` | ✅ | Lookup prefix used in `name="prefix:icon"` | +| `enabled` |. | `false` skips the entry (default `true`) | +| `path` | ✅ | Absolute path to the SVG directory, or to a directory holding a `config.json` | +| `package` |. | The `vendor/package` a directory with no `config.json` registers as (default `app/{entry key}`) | +| `bulk` |. | `true` registers every sub-directory of `path` that holds a `config.json` | +| `only` |. | With `bulk`, the sub-directory names to register (default all) | +| `prefix` |. | The set's prefix (default the package half of its name) | | `default_variant` |. | Variant used when none specified (e.g. `'outline'`) | | `variants` |. | Array of supported subdirectory variants | | `supports_categories` |. | `true` if icons are nested by category folders | | `default_class` |. | CSS class auto-applied to all icons from this set | | `default_attributes` |. | Map of attributes auto-merged onto every SVG | | `fallback` |. | Per-set fallback icon name if lookup fails | -| `blade_namespace` |. | Blade component namespace (default: `'ichava'`) | -| `blade_component` |. | Component tag if you want `` | +| `blade_component` |. | The Blade alias recorded for the set, `{blade_namespace}-{blade_component}`, so the registry can report a clash | +| `blade_namespace` |. | The prefix of that recorded alias | + +Every key is applied through `PackBuilder::withOptions()`, the same path a pack takes; see [Pack registration](../tools/pack-registration.md). ### Difference from custom *packages* diff --git a/docs/tools/blade-components.md b/docs/tools/blade-components.md index 2e6c1cd1..259ec469 100644 --- a/docs/tools/blade-components.md +++ b/docs/tools/blade-components.md @@ -8,10 +8,22 @@ The single component that renders icons from any registered package. It answers | Tag | Prefix | Notes | |---|---|---| -| `` | `ichava-core`, vendor-scoped | Preferred in new code: the prefix names the package that ships it | -| `` | `ichava`, the ecosystem prefix | Unchanged and fully supported; packs register their own short names under it | +| `` | `ichava-core`, canonical | Always registered; the prefix names the package that ships it | +| `` | `components.short_prefix`, default `ichava` | The short tag: registered beside the canonical one while the key names a prefix | -Both resolve the same class, `Simtabi\Laranail\Ichava\View\Components\IconComponent`, and render the same bytes. The examples below use the bare prefix; swap in `ichava-core` anywhere. +Both resolve the same class, `Simtabi\Laranail\Ichava\View\Components\IconComponent`, and render the same bytes. The examples below use the short prefix; swap in `ichava-core` anywhere. + +#### Short tag + +`components.short_prefix` (`ICHAVA_COMPONENT_SHORT_PREFIX`) names the short prefix: + +| Value | Registered | +|---|---| +| `'ichava'` (default) | `` and `` | +| `'icon'`, or any lowercase prefix of letters, digits and hyphens | `` and `` | +| `null` or `''` | `` only | + +A value Blade cannot parse (a `/`, a `::`, upper case) or `ichava-core` itself throws `IchavaException` at boot rather than registering a tag that silently truncates. `components.enabled` (`ICHAVA_COMPONENTS_ENABLED`) set to `false` registers no core tag at all. Registration cannot stop a provider that boots later from overwriting the short alias, so `php artisan ichava::ichava-core.info doctor` checks the live alias map and warns under `component.short_prefix_shadowed` when it no longer resolves to the icon component. ```blade {{-- Basic usage --}} @@ -68,11 +80,11 @@ Both extend `IconComponent` and accept the same `name`, `class`, `title`, `aria` ``` -The `test_icons` component is auto-disabled in production, see `components.test_icons` in `config/ichava/ichava-core.php`. +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. ### Per-package components -Each child icon package registers its own thin wrapper component for ergonomics, e.g. ``, ``, ``. They forward to the same underlying `IconComponent`, see each child package's README for the exact tag name and accepted attributes. +Each icon pack registers its own component under the same two tiers: the canonical `` always, and the short `` while `components.short_prefix` names a prefix. For tabler that is `` and ``. A pack can add components through its `pack()` hook ([Pack registration](pack-registration.md#the-advanced-path-packspec)). Both forward to `IconComponent`, and a pack's `config.json` `config.defaults` (class, attributes, `stroke_width`) applies to every tag and to `render()` alike. --- diff --git a/docs/tools/pack-registration.md b/docs/tools/pack-registration.md new file mode 100644 index 00000000..ccc8360f --- /dev/null +++ b/docs/tools/pack-registration.md @@ -0,0 +1,165 @@ +# Pack registration + +Three ways to register icon sets, one path underneath: every set becomes a `PackDefinition` and goes through `IconRegistry::register()`. + +```text +config.json ──┐ + ├──► PackBuilder ──build()──► PackDefinition ──► IconRegistry::register() +fluent calls ─┘ +``` + +A set registered by a pack provider, by `custom-icons` config, in bulk or from code carries the same metadata, the same kinds and the same aliases, whichever way it came in. + +## The simple path: two methods + +A pack whose `resources/assets/svg/config.json` says everything about it extends `Simtabi\Laranail\Ichava\Providers\IconPackServiceProvider` and names two classes: + +```php +use Simtabi\Laranail\Ichava\Providers\IconPackServiceProvider; + +final class IconsServiceProvider extends IconPackServiceProvider +{ + protected function constantsClass(): string + { + return IconsConstants::class; + } + + protected function componentClass(): string + { + return IconComponent::class; + } +} +``` + +The base names the package from the constants class, registers `config/{slug}.php` when the pack ships one, registers the Blade component as `` (and `` while the [short tag](blade-components.md#short-tag) is on), and registers `resources/assets/svg`. + +`config.json`'s `config.defaults` is the one place a pack's default rendering lives: `class`, `attributes` and `stroke_width` apply to the pack's own tag, to `` and to `IconRegistry::render()` alike. + +## The advanced path: `PackSpec` + +The same provider can override one optional hook, `pack()`. It receives a `Simtabi\Laranail\Ichava\Support\Pack\PackSpec`: + +```php +use Simtabi\Laranail\Ichava\Enums\IconKind; +use Simtabi\Laranail\Ichava\Support\Pack\PackSpec; +use Simtabi\Laranail\Ichava\Support\Pack\PackBuilder; + +protected function pack(PackSpec $pack): void +{ + $pack + ->set(fn (PackBuilder $set) => $set + ->kindRule('illustrations/**', IconKind::Illustration) + ->themeTwin('illustrations', 'dark', '-dark') + ->alias('shapes/circle', 'shapes/dot')) + ->addSet('resources/assets/svg-brands', fn (PackBuilder $set) => $set->prefix('brand')) + ->sets('resources/assets/svg/sets', only: ['fontawesome', 'bootstrap']) + ->component('flag', FlagComponent::class); +} +``` + +| Method | Does | +|---|---| +| `set(Closure)` | Shapes the default set, `resources/assets/svg`, with a `PackBuilder` | +| `addSet(string $path, ?Closure)` | Registers another set, a directory under the pack root holding its own `config.json` | +| `sets(string $path, array $only = [], ?Closure)` | Registers every sub-directory of `$path` holding a `config.json`, or those named | +| `component(string $name, string $class)` | Registers ``, and `` while the short tag is on | +| `mainComponent(string $name)` | Renames the main tag from `icon` | +| `configFile(string $name)` | Registers `config/{$name}.php` instead of `config/{slug}.php` | + +A pack that does not override `pack()` registers exactly as on the simple path. + +## `PackBuilder` + +`Simtabi\Laranail\Ichava\Support\Pack\PackBuilder` builds one set. `PackBuilder::fromDirectory($path)` starts from the `config.json` in `$path` (read by `build()`, so a broken file fails where it is registered); `PackBuilder::make('vendor/package', $filesPath)` starts from nothing. + +| Method | Writes | +|---|---| +| `named('vendor/package')` | `package.name` | +| `prefix('ti')` | `config.icon_prefix` | +| `about('title' \| 'description' \| 'version' \| 'license' \| 'keywords' \| 'homepage' \| 'repository', $value)` | the descriptive fields | +| `variant('outline', ['fill' => 'none'], 'Stroked', 'outline/home')` | one variant block | +| `defaultVariant('outline')` | the variant a name without one resolves in | +| `categories(true)` | whether icons are filed under category folders | +| `kind(IconKind)` · `categoryKind($slug, IconKind)` · `kindRule($glob, IconKind)` · `itemKind($path, IconKind)` | the kind rows ([Icons and illustrations](../kinds.md)) | +| `themeTwin($category, 'dark', '-dark')` | a category's theme twins | +| `alias($old, $kept)` · `aliases([...])` | icon aliases, beside any `aliases.json` | +| `defaultClass('w-6 h-6')` · `defaultAttributes([...])` · `fallback('misc/missing')` | `config.defaults` | +| `providedBy(Provider::class)` · `component('alias')` · `metadata([...])` | attribution, the recorded Blade alias, extra registry metadata | +| `withOptions([...])` | the keys above in the array shape `custom-icons` entries use | +| `when($condition, fn (PackBuilder $b) => ...)` | a conditional chain | + +The merge contract with `config.json`, the same for every key: + +| What | Rule | +|---|---| +| scalars (prefix, about fields, kind) | the fluent value replaces the file's | +| maps (variants, categories, items, aliases, default attributes) | merged per key; the fluent entry wins its key | +| kind rules | fluent rules first, in the order added, then the file's (first match wins) | + +From a host, with no provider and no `config.json`: + +```php +use Simtabi\Laranail\Ichava\Services\IconRegistry; +use Simtabi\Laranail\Ichava\Support\Pack\PackBuilder; + +app(IconRegistry::class)->register( + PackBuilder::make('acme/social', storage_path('icons/social/files')) + ->prefix('social') + ->variant('outline', ['fill' => 'none', 'stroke' => 'currentColor']) + ->build(), +); +``` + +A pack's constants class (`JsonConfigConstants`) reads the registered definition, so it sees what the builder registered, not a second read of the file. + +## Bulk registration + +`IconRegistry::registerMany()` registers several definitions or builders as one run. Each set goes through `register()`, so a set registered in bulk is the same as one registered alone. + +```php +use Simtabi\Laranail\Ichava\Data\BulkOptions; + +$result = app(IconRegistry::class)->registerMany( + PackBuilder::discover(resource_path('icons/vendored'), only: ['fontawesome', 'bootstrap']), + new BulkOptions(name: 'vendored', failFast: false, log: true), +); + +$result->registered; // ['acme/fontawesome', 'acme/bootstrap'] +$result->failed; // set => message, for a set that failed to build or register +``` + +| `BulkOptions` | Default | Meaning | +|---|---|---| +| `name` | `bulk` | the label the run's events and log line carry | +| `failFast` | `false` | rethrow the first failure; otherwise report it in `BulkResult::$failed` and register the rest | +| `log` | `false` | log one line with the counts | + +A builder whose directory does not exist is reported in `BulkResult::$skipped`. One registrar id covers the run, and `IconRegistrationEvent` reports it as `started` (mode `bulk`), each set's `processing` and `registered` (or `failed`), then one `completed` whose `statistics` are `['registered' => n, 'skipped' => n, 'failed' => n]`. `AutoSeedIconsOnRegistration` acts on `registered` only, so a bulk run seeds each set once. + +Three ways in: + +```php +// 1. A pack, declaratively: +$pack->sets('resources/assets/svg/sets', only: ['fontawesome', 'bootstrap'], + shape: fn (PackBuilder $set) => $set->kind(IconKind::Icon)); + +// 2. A PackServiceProvider, through the registrar (an empty map discovers every set): +$this->registerBulkIconSets( + basePath: $this->svgAssetsPath('sets'), + iconSets: ['fontawesome' => ['prefix' => 'fa'], 'bootstrap' => []], + vendor: 'Icons bundle', +)->trackStatistics()->enableLogging(); + +// 3. A host, in config/ichava/ichava-core.php: +'custom-icons' => ['sets' => [ + 'vendored' => ['path' => resource_path('icons/vendored'), 'bulk' => true, 'only' => []], +]], +``` + +`IchavaRegistrar`, behind `registerBulkIconSets()`, applies each set's values through `PackBuilder::withOptions()`; `packagePrefix()`/`iconSetSuffix()` build a prefix for a set that names none, `bladeNamespace()` records `{namespace}-{set}` as its component alias, and `statistics()` returns the run's `BulkResult` after `trackStatistics()`. `enableLogging()` logs the run whether it is called before or after `configure()`. + +Core registers its own bundled test set through `registerMany()`, so the bulk path runs on every boot. + +--- + +[← Docs index](../../README.md#documentation) diff --git a/docs/tools/query-api.md b/docs/tools/query-api.md index ce48f045..d16d20a7 100644 --- a/docs/tools/query-api.md +++ b/docs/tools/query-api.md @@ -71,7 +71,7 @@ Host events (`FavoriteToggled`, `CollectionChanged`, `PreferencesChanged`) are i ## Extension interfaces -- **`IchavaPackageInterface`** is implemented by `Support\ServiceProvider`, the base every pack extends. `getPackageName()`, `getIconSetName()` and `getPackageMetadata()` read the registry, which is the one reader of `config.json`. `getPackageVersion()` reads Composer (`dev` when Composer does not know the package). +- **`IchavaPackageInterface`** is implemented by `Providers\PackServiceProvider`, the base every pack extends. `getPackageName()`, `getIconSetName()` and `getPackageMetadata()` read the registry, which is the one reader of `config.json`. `getPackageVersion()` reads Composer (`dev` when Composer does not know the package). - **`IconDriverInterface`** is implemented by `Drivers\DirectoryIconDriver`, which serves icons from one directory: `setIconPath()`, then `all()`, `has()` and `render()`. Rendering goes through `SvgDriver`, so containment, the size limit and the sanitiser all apply. `SvgDriver` itself does not implement the interface, because its `render()` takes a resolved `IconData`. ## Status diff --git a/resources/lang/en/commands.php b/resources/lang/en/commands.php index b3d5c5d6..a9eb35ce 100644 --- a/resources/lang/en/commands.php +++ b/resources/lang/en/commands.php @@ -333,15 +333,16 @@ ], 'doctor' => [ - 'intro' => '🩺 Ichava Doctor', - 'packages' => 'Icon packs registered', - 'queue' => 'Queue connection configured', - 'auth' => 'Host auth available', - 'default_guard' => 'default guard', - 'gates' => 'ichava.admin Gate defined', - 'routes' => 'Browser routes mounted', - 'orphans' => 'No orphaned icon rows', - 'kinds' => [ + 'intro' => '🩺 Ichava Doctor', + 'packages' => 'Icon packs registered', + 'queue' => 'Queue connection configured', + 'auth' => 'Host auth available', + 'default_guard' => 'default guard', + 'gates' => 'ichava.admin Gate defined', + 'routes' => 'Browser routes mounted', + 'orphans' => 'No orphaned icon rows', + 'short_prefix_shadowed' => 'Short component prefix resolves to the icon component', + 'kinds' => [ 'kind.invalid_value' => 'Kind values name a kind', 'kind.invalid_glob' => 'Kind rule patterns are valid', 'kind.override_invalid' => 'Host kind override keys are valid', diff --git a/src/Actions/BuildIconUsageSyntax.php b/src/Actions/BuildIconUsageSyntax.php index 888fdee4..b1ae1a88 100644 --- a/src/Actions/BuildIconUsageSyntax.php +++ b/src/Actions/BuildIconUsageSyntax.php @@ -25,7 +25,7 @@ * * @return array{helper: string, directive: string, component: string|null} */ - public function __invoke( + public function handle( string $package, array $packageData, string $name, diff --git a/src/Actions/ClearDiscoveryCaches.php b/src/Actions/ClearDiscoveryCaches.php index 9972e195..bb5c5a66 100644 --- a/src/Actions/ClearDiscoveryCaches.php +++ b/src/Actions/ClearDiscoveryCaches.php @@ -5,6 +5,7 @@ namespace Simtabi\Laranail\Ichava\Actions; use Illuminate\Support\Facades\Cache; +use Simtabi\Laranail\Ichava\Support\DiscoveryCacheGeneration; /** * Invalidate everything `IconDiscoveryService` caches. @@ -26,12 +27,12 @@ final class ClearDiscoveryCaches { /** The prefix every discovery cache key carries. */ - public const string PREFIX = 'ichava.discovery'; + public const string PREFIX = DiscoveryCacheGeneration::PREFIX; /** The key holding the invalidation generation. */ - public const string GENERATION_KEY = self::PREFIX . '.generation'; + public const string GENERATION_KEY = DiscoveryCacheGeneration::KEY; - public function __invoke(): void + public function handle(): void { // Written with `Cache::remember()` on the default store, so it is // forgettable by name and does not need the generation. @@ -40,20 +41,6 @@ public function __invoke(): void // Everything else -- `.packages`, `.categories`, `.search` on the file // store, and `icons.search.db.*` through IconCacheService -- carries // the generation in its key. One increment retires all of them. - Cache::increment(self::GENERATION_KEY); - } - - /** - * The current generation, mixed into every key the service builds. - * - * Read on each key construction rather than memoised: a clear may run in - * another request, and a memoised value would serve the previous - * generation for the life of the instance. - */ - public static function generation(): int - { - $generation = Cache::get(self::GENERATION_KEY, 0); - - return is_numeric($generation) ? (int) $generation : 0; + DiscoveryCacheGeneration::advance(); } } diff --git a/src/Actions/DiscoverInstalledPackages.php b/src/Actions/DiscoverInstalledPackages.php index 2939222c..625b42d7 100644 --- a/src/Actions/DiscoverInstalledPackages.php +++ b/src/Actions/DiscoverInstalledPackages.php @@ -29,7 +29,7 @@ public function __construct(private Filesystem $files) {} /** * @return list */ - public function __invoke(string $composerLockPath): array + public function handle(string $composerLockPath): array { if (! $this->files->exists($composerLockPath)) { return []; diff --git a/src/Actions/SearchIconsInDatabase.php b/src/Actions/SearchIconsInDatabase.php index 80dac4c4..7bf55b4f 100644 --- a/src/Actions/SearchIconsInDatabase.php +++ b/src/Actions/SearchIconsInDatabase.php @@ -9,6 +9,7 @@ use Simtabi\Laranail\Ichava\Models\Icon; use Simtabi\Laranail\Ichava\Models\IconTerm; use Simtabi\Laranail\Ichava\Services\IconCacheService; +use Simtabi\Laranail\Ichava\Support\DiscoveryCacheGeneration; /** * Search the icons table, paginate, and shape the result. @@ -45,7 +46,7 @@ public function __construct( * * @return array{items: list>, total: int, page: int, per_page: int, last_page: int} */ - public function __invoke( + public function handle( string $query = '', array $packages = [], array $categories = [], @@ -58,7 +59,7 @@ public function __invoke( ): array { // The generation is part of the key, so a clear retires every variant // at once without enumerating the md5 suffixes. - $cacheKey = 'icons.search.db.' . ClearDiscoveryCaches::generation() . '.' . md5(serialize([ + $cacheKey = 'icons.search.db.' . DiscoveryCacheGeneration::current() . '.' . md5(serialize([ $query, $packages, $categories, $page, $perPage, $sortBy, $sortDirection, ])); @@ -82,7 +83,7 @@ public function __invoke( } /** - * Whether a value has the shape `__invoke()` returns. + * Whether a value has the shape `handle()` returns. * * @phpstan-assert-if-true array{items: list>, total: int, page: int, per_page: int, last_page: int} $value */ diff --git a/src/Commands/CacheCommand.php b/src/Commands/CacheCommand.php index 80fee7e0..e0cd4424 100644 --- a/src/Commands/CacheCommand.php +++ b/src/Commands/CacheCommand.php @@ -16,6 +16,7 @@ use Simtabi\Laranail\Ichava\Support\Coerce; use Simtabi\Laranail\Console\Tools\Support\Status; use Simtabi\Laranail\Ichava\Services\IchavaLogger; +use Simtabi\Laranail\Ichava\Support\RuntimeLimits; use Simtabi\Laranail\Console\Tools\Support\FileSize; use Simtabi\Laranail\Console\Tools\Support\TimeFormat; use Simtabi\Laranail\Console\Tools\Widgets\MetricTable; @@ -60,7 +61,7 @@ public function __construct( parent::__construct(); } - public function handle(): int + public function handle(RuntimeLimits $limits): int { $action = Coerce::string($this->argument('action')); @@ -81,7 +82,8 @@ public function handle(): int ); } - return match ($action) { + // Cache builds run under Ichava's limits, restored when they return (C-01). + return $limits->scope(fn (): int => match ($action) { 'clear' => $this->handleClear(), 'rebuild' => $this->handleRebuild(), 'refresh' => $this->handleRefresh(), @@ -89,7 +91,7 @@ public function handle(): int 'manifest' => $this->handleManifest(), 'stats' => $this->handleStats(), default => $this->handleInvalidAction((string) $action, $this->validActions), - }; + }); } /** diff --git a/src/Commands/CheckIconUpdatesCommand.php b/src/Commands/CheckIconUpdatesCommand.php index d868cb30..9fa9f256 100644 --- a/src/Commands/CheckIconUpdatesCommand.php +++ b/src/Commands/CheckIconUpdatesCommand.php @@ -13,7 +13,7 @@ use Simtabi\Laranail\Ichava\Support\Coerce; use Simtabi\Laranail\Console\Tools\Support\Status; use Simtabi\Laranail\Console\Tools\Widgets\StatusBadge; -use Simtabi\Laranail\Ichava\Services\IconPackUpdateChecker; +use Simtabi\Laranail\Ichava\Contracts\ChecksPackUpdates; /** * Reports which registered icon packs are behind their upstream source. @@ -67,7 +67,7 @@ public function handle(): int // so test doubles bound via $this->app->instance() AFTER provider // boot still take effect. The Command itself is cached by Artisan // and would otherwise capture whatever singleton was first resolved. - $checker = $this->laravel->make(IconPackUpdateChecker::class); + $checker = $this->laravel->make(ChecksPackUpdates::class); $packageFilter = Coerce::stringOrNull($this->option('package')); $format = $this->option('format'); diff --git a/src/Commands/CleanupIchavaLogsCommand.php b/src/Commands/CleanupIchavaLogsCommand.php index 61d9d0fb..9b284d94 100644 --- a/src/Commands/CleanupIchavaLogsCommand.php +++ b/src/Commands/CleanupIchavaLogsCommand.php @@ -28,7 +28,7 @@ * Removes Ichava log files older than the configured retention period. * Runs daily via the scheduler or on demand. */ -class CleanupIchavaLogsCommand extends BaseCommand +final class CleanupIchavaLogsCommand extends BaseCommand { /** * What happened to each file, on the shared status vocabulary. diff --git a/src/Commands/DatabaseCommand.php b/src/Commands/DatabaseCommand.php index f67d562c..be50a007 100644 --- a/src/Commands/DatabaseCommand.php +++ b/src/Commands/DatabaseCommand.php @@ -16,10 +16,11 @@ use Simtabi\Laranail\Ichava\Support\Coerce; use Simtabi\Laranail\Ichava\Support\CommandName; use Simtabi\Laranail\Ichava\Services\IchavaLogger; +use Simtabi\Laranail\Ichava\Support\RuntimeLimits; use Simtabi\Laranail\Console\Tools\Widgets\MetricTable; use Simtabi\Laranail\Ichava\Support\Seeder\IchavaSeeder; +use Simtabi\Laranail\Ichava\Contracts\DatabaseOperations; use Simtabi\Laranail\Ichava\Support\Seeder\IconTermsSeeder; -use Simtabi\Laranail\Ichava\Services\DatabaseOperationsService; /** * Unified Icon Database Command @@ -60,7 +61,7 @@ final class DatabaseCommand extends BaseCommand protected array $validActions = ['seed', 'seed:icons', 'seed:terms', 'migrate', 'unseed', 'refresh', 'truncate', 'stats']; public function __construct( - protected DatabaseOperationsService $databaseService, + protected DatabaseOperations $databaseService, protected IchavaLogger $logger, protected IchavaSeeder $ichavaSeeder, protected IconTermsSeeder $termSeeder, @@ -68,7 +69,7 @@ public function __construct( parent::__construct(); } - public function handle(): int + public function handle(RuntimeLimits $limits): int { $action = Coerce::string($this->argument('action')); @@ -101,7 +102,8 @@ public function handle(): int return self::FAILURE; } - return match ($action) { + // Seeding runs under Ichava's limits, restored when it returns (C-01). + return $limits->scope(fn (): int => match ($action) { 'seed' => $this->handleSeed(), 'seed:icons' => $this->handleSeedIcons(), 'seed:terms' => $this->handleSeedTerms(), @@ -110,7 +112,7 @@ public function handle(): int 'truncate' => $this->handleTruncate(), 'stats' => $this->handleStats(), default => $this->handleInvalidAction((string) $action, $this->validActions), - }; + }); } /** diff --git a/src/Commands/InfoCommand.php b/src/Commands/InfoCommand.php index 7db2ae7c..b3adce55 100644 --- a/src/Commands/InfoCommand.php +++ b/src/Commands/InfoCommand.php @@ -19,6 +19,7 @@ use Simtabi\Laranail\Ichava\Services\KindDoctor; use Simtabi\Laranail\Console\Tools\Support\Status; use Simtabi\Laranail\Ichava\Services\IchavaLogger; +use Simtabi\Laranail\Ichava\View\ComponentPrefixes; use Simtabi\Laranail\Console\Tools\Widgets\CheckList; use Simtabi\Laranail\Ichava\Support\Auth\IchavaGates; use Simtabi\Laranail\Console\Tools\Widgets\StatusBadge; @@ -321,6 +322,8 @@ protected function handleDoctor(): int ? array_filter(array_keys(app('router')->getRoutes()->getRoutesByName()), static fn (string $name): bool => str_starts_with($name, 'ichava.')) : []; + $shadowed = app(ComponentPrefixes::class)->shadowedShortAlias(); + // id => [label, state, detail]; the ids are what --format=json reports. $install = [ 'packages' => [__('ichava/ichava-core::commands.info.doctor.packages'), $health->packages > 0, (string) $health->packages], @@ -332,6 +335,9 @@ protected function handleDoctor(): int 'gates' => [__('ichava/ichava-core::commands.info.doctor.gates'), $gateDefined ? true : Status::Warning, null], 'routes' => [__('ichava/ichava-core::commands.info.doctor.routes'), $routes !== [] ? true : Status::Warning, (string) count($routes)], 'orphans' => [__('ichava/ichava-core::commands.info.doctor.orphans'), $health->orphans === [] ? true : Status::Warning, $health->orphans === [] ? null : implode(', ', $health->orphans)], + // Registration cannot stop a later provider overwriting the short + // alias, so the live map is read after boot. + 'component.short_prefix_shadowed' => [__('ichava/ichava-core::commands.info.doctor.short_prefix_shadowed'), $shadowed === null ? true : Status::Warning, $shadowed], ]; $kindChecks = ($this->kindDoctor ?? app(KindDoctor::class))->checks($this->suggestKinds()); diff --git a/src/Commands/InstallCommand.php b/src/Commands/InstallCommand.php index 7c447248..88e1f52c 100644 --- a/src/Commands/InstallCommand.php +++ b/src/Commands/InstallCommand.php @@ -18,8 +18,8 @@ use Simtabi\Laranail\Ichava\Support\Coerce; use Simtabi\Laranail\Ichava\Support\CommandName; use Simtabi\Laranail\Console\Tools\Support\Status; +use Simtabi\Laranail\Ichava\Contracts\DatabaseOperations; use Simtabi\Laranail\Ichava\Services\IconSetCatalogService; -use Simtabi\Laranail\Ichava\Services\DatabaseOperationsService; /** * Install an Ichava icon set. @@ -47,7 +47,7 @@ final class InstallCommand extends BaseCommand public function __construct( protected IconSetCatalogService $catalog, - protected DatabaseOperationsService $database, + protected DatabaseOperations $database, ) { parent::__construct(); } diff --git a/src/Commands/JobStatusCommand.php b/src/Commands/JobStatusCommand.php index a8f81402..ff7df450 100644 --- a/src/Commands/JobStatusCommand.php +++ b/src/Commands/JobStatusCommand.php @@ -33,7 +33,7 @@ * * @see https://laravel.com/docs/12.x/prompts */ -class JobStatusCommand extends BaseCommand +final class JobStatusCommand extends BaseCommand { protected $signature = 'ichava::ichava-core.job-status {package? : Specific package to check} diff --git a/src/Commands/WatchIconFilesCommand.php b/src/Commands/WatchIconFilesCommand.php index 223f8f18..4efda46c 100644 --- a/src/Commands/WatchIconFilesCommand.php +++ b/src/Commands/WatchIconFilesCommand.php @@ -11,6 +11,7 @@ use function Laravel\Prompts\warning; use Simtabi\Laranail\Ichava\Support\Coerce; +use Simtabi\Laranail\Ichava\Support\RuntimeLimits; use Simtabi\Laranail\Console\Tools\Support\TimeFormat; use Simtabi\Laranail\Console\Tools\Widgets\MetricTable; use Simtabi\Laranail\Ichava\Services\IconWatcherService; @@ -22,7 +23,7 @@ * * @see https://laravel.com/docs/12.x/prompts */ -class WatchIconFilesCommand extends BaseCommand +final class WatchIconFilesCommand extends BaseCommand { protected $signature = 'ichava::ichava-core.watch {--force : Force scan even if already running} @@ -31,7 +32,16 @@ class WatchIconFilesCommand extends BaseCommand protected $description = 'Watch icon files for changes and auto-sync database'; - public function handle(IconWatcherService $watcher): int + public function handle(IconWatcherService $watcher, RuntimeLimits $limits): int + { + // The scan runs under Ichava's limits, restored when it returns (C-01). + return $limits->scope(fn (): int => $this->scan($watcher)); + } + + /** + * Rescan one pack, or scan every pack, and report what changed. + */ + private function scan(IconWatcherService $watcher): int { intro(__('ichava/ichava-core::commands.watch.intro')); diff --git a/src/Constants/JsonConfigConstants.php b/src/Constants/JsonConfigConstants.php index 5213e7fa..290ad622 100644 --- a/src/Constants/JsonConfigConstants.php +++ b/src/Constants/JsonConfigConstants.php @@ -9,8 +9,10 @@ use Simtabi\Laranail\Ichava\Support\Helpers; use Simtabi\Laranail\Ichava\Data\VariantData; use Simtabi\Laranail\Ichava\Support\KindResolver; +use Simtabi\Laranail\Ichava\Services\IconRegistry; use Simtabi\Laranail\Ichava\Support\PackConfigData; use Simtabi\Laranail\Ichava\Services\ConfigurationService; +use Simtabi\Laranail\Ichava\Support\Pack\PackConfigReader; /** * Abstract base for icon-package metadata classes that derive their values @@ -546,7 +548,9 @@ public static function getUpstreamAdditionalSources(): array } /** - * Load and cache config data from config.json for the calling child class. + * The set's effective config for the calling child class: what it + * registered with, PackBuilder overrides included, or its config.json + * before it registers. * * @return array */ @@ -554,13 +558,29 @@ final protected static function config(): array { $class = static::class; - if (! isset(self::$configs[$class])) { - $service = resolve(ConfigurationService::class); - $path = static::getConfigPath(); - self::$configs[$class] = $service->rememberPackageConfig($path); + if (isset(self::$configs[$class])) { + return self::$configs[$class]; } - return self::$configs[$class]; + $path = static::getConfigPath(); + + // Before core's provider has bound the registry there is nothing + // registered to read; building a throwaway registry here would be worse. + if (! app()->bound(IconRegistry::class)) { + return resolve(ConfigurationService::class)->rememberPackageConfig($path); + } + + $reader = resolve(PackConfigReader::class); + $registered = $reader->registered($path); + + // Memoised once the set has registered, overrides included. Before + // that the file is read through ConfigurationService's cache and not + // memoised here, so a set that registers later is seen as registered. + if ($registered !== null) { + return self::$configs[$class] = $registered; + } + + return $reader->read($path); } /** diff --git a/src/Contracts/ChecksPackUpdates.php b/src/Contracts/ChecksPackUpdates.php new file mode 100644 index 00000000..9d036023 --- /dev/null +++ b/src/Contracts/ChecksPackUpdates.php @@ -0,0 +1,39 @@ + + */ + public function checkAll(?string $packageFilter = null): array; + + /** + * One result per upstream source the pack declares. + * + * @return list + */ + public function checkPackage(string $packageName): array; + + /** + * The pack's primary upstream source. + * + * @return CheckResult + */ + public function checkOne(string $packageName): array; +} diff --git a/src/Contracts/DatabaseOperations.php b/src/Contracts/DatabaseOperations.php new file mode 100644 index 00000000..c0c6e3a9 --- /dev/null +++ b/src/Contracts/DatabaseOperations.php @@ -0,0 +1,56 @@ + + */ + public function getMissingTables(): array; + + /** @return list */ + public function dropTables(): array; + + public function runMigrations(): int; + + /** @return array{dropped_tables: list, forgotten: list, migration_exit_code: int, success: bool} */ + public function freshMigration(): array; + + /** @return list */ + public function truncateTables(): array; + + /** @return array */ + public function unseedPackage(string $packageName): array; + + /** @return array */ + public function unseedAll(): array; + + public function cleanupOrphanedTerms(): int; + + public function shouldUseQueue(int $iconCount, bool $forceSync = false): bool; + + /** @return array */ + public function getStatistics(): array; + + /** @return array package name => icon count */ + public function getPackageStatistics(): array; + + /** @phpstan-impure reads the database */ + public function hasSeeds(): bool; +} diff --git a/src/Data/BulkOptions.php b/src/Data/BulkOptions.php new file mode 100644 index 00000000..846dd603 --- /dev/null +++ b/src/Data/BulkOptions.php @@ -0,0 +1,24 @@ + $registered package names, in order + * @param list $skipped directories that do not exist + * @param array $failed set (package name or directory) => message + */ + public function __construct( + public array $registered, + public array $skipped, + public array $failed, + public float $durationMs, + ) {} + + /** @return array{registered: int, skipped: int, failed: int} */ + public function counts(): array + { + return ['registered' => count($this->registered), 'skipped' => count($this->skipped), 'failed' => count($this->failed)]; + } +} diff --git a/src/Data/PackDefinition.php b/src/Data/PackDefinition.php new file mode 100644 index 00000000..51f475d0 --- /dev/null +++ b/src/Data/PackDefinition.php @@ -0,0 +1,174 @@ + $config the effective config.json + * @param bool $hasCategories whether the set files its icons under category folders + * @param array $aliases old path => kept path, registered beside any `aliases.json` + * @param array $extraMetadata merged over the built metadata, last + * @param string|null $defaultVariant the variant a name without one resolves in + */ + public function __construct( + public string $name, + public string $basePath, + public string $filesPath, + public string $prefix, + public array $config, + public bool $hasCategories, + public array $aliases = [], + public ?string $providerClass = null, + public ?string $bladeComponent = null, + public array $extraMetadata = [], + public ?string $defaultVariant = null, + ) {} + + /** + * The class every icon renders with when the caller gives none, from + * `config.defaults.class`; null keeps the registry's default. + */ + public function defaultClass(): ?string + { + $class = $this->defaults()['class'] ?? null; + + return is_string($class) && $class !== '' ? $class : null; + } + + /** + * The SVG attributes every icon renders with: `config.defaults.attributes`, + * plus `stroke-width` from `config.defaults.stroke_width` when the + * attributes do not name one. + * + * @return array + */ + public function defaultAttributes(): array + { + $defaults = $this->defaults(); + $attributes = []; + + foreach (is_array($defaults['attributes'] ?? null) ? $defaults['attributes'] : [] as $key => $value) { + $attributes[(string) $key] = $value; + } + + $strokeWidth = $defaults['stroke_width'] ?? null; + + if ((is_int($strokeWidth) || is_float($strokeWidth) || is_string($strokeWidth)) && ! isset($attributes['stroke-width'])) { + $attributes['stroke-width'] = (string) $strokeWidth; + } + + return $attributes; + } + + /** The icon rendered when a name is not found, from `config.defaults.fallback`. */ + public function fallback(): ?string + { + $fallback = $this->defaults()['fallback'] ?? null; + + return is_string($fallback) && $fallback !== '' ? $fallback : null; + } + + /** + * The variant names, in declared order. + * + * @return list + */ + public function variantNames(): array + { + return array_map(strval(...), array_keys(PackConfigData::variants($this->config))); + } + + /** + * The registry's metadata for this set. + * + * @param int $total SVGs counted under the files path + * @param string|null $notedBladeComponent the alias the provider noted with the registry, used when the definition names none + * + * @return array + */ + public function toMetadata(int $total, ?string $notedBladeComponent = null): array + { + $config = $this->config; + $declaredKind = PackConfigData::packageKind($config); + + return array_merge([ + 'package_name' => $this->name, + 'name' => $this->value('package', 'title'), + 'description' => $this->value('package', 'description') ?? '', + 'vendor' => Helpers::getVendorFromPackage($this->name), + 'version' => $this->value('package', 'version'), + 'license' => $this->value('package', 'license') ?? 'Unknown', + 'homepage' => $this->value('metadata', 'homepage'), + 'repository' => $this->value('metadata', 'repository'), + 'keywords' => $this->value('package', 'keywords') ?? [], + 'total' => $total, + 'base_path' => $this->basePath, + 'icon_set_name' => $this->name, + 'provider_class' => $this->providerClass, + 'blade_component' => $this->bladeComponent ?? $notedBladeComponent, + 'prefix' => $this->prefix, + // What the set's SVGs are, and the declaration behind it: KindResolver + // tells "declared icon" (row 5) from "declared nothing" (row 6). + 'kind' => ($declaredKind ?? IconKind::Icon)->value, + 'declared_kind' => $declaredKind?->value, + 'category_kinds' => array_map( + static fn (IconKind $kind): string => $kind->value, + PackConfigData::categoryKinds($config), + ), + // The rest of the kind resolution order (D6 rows 2 and 3), plus what + // doctor reports, so KindResolver and doctor never re-read config.json. + 'item_kinds' => array_map( + static fn (IconKind $kind): string => $kind->value, + PackConfigData::items($config), + ), + 'kind_rules' => array_map( + static fn (array $rule): array => ['match' => $rule['match'], 'kind' => $rule['kind']->value], + PackConfigData::kindRules($config), + ), + 'kind_issues' => PackConfigData::kindIssues($config), + 'theme_variants' => PackConfigData::themeVariants($config), + // The variant blocks as declared, for VariantData and the terms seeder. + 'variant_metadata' => array_filter(PackConfigData::variants($config), is_array(...)), + ], $this->extraMetadata); + } + + /** @return array */ + private function defaults(): array + { + $config = $this->config['config'] ?? null; + $defaults = is_array($config) ? ($config['defaults'] ?? null) : null; + + return is_array($defaults) ? $defaults : []; + } + + /** One key of one config section, or null when either is absent. */ + private function value(string $section, string $key): mixed + { + $values = $this->config[$section] ?? null; + + return is_array($values) ? ($values[$key] ?? null) : null; + } +} diff --git a/src/Drivers/SvgDriver.php b/src/Drivers/SvgDriver.php index 87c911b0..129e3fb8 100644 --- a/src/Drivers/SvgDriver.php +++ b/src/Drivers/SvgDriver.php @@ -37,7 +37,7 @@ * @see IconCacheService * @see IchavaConstants */ -class SvgDriver +final class SvgDriver { use ContainsFilePaths; @@ -51,7 +51,7 @@ public function __construct( ) { // @phpstan-ignore booleanNot.alwaysFalse (defensive: kept for subclasses that override the constructor) if (! $this->cache) { - throw IchavaException::dependencyNotInjected('IconCacheService', static::class); + throw IchavaException::dependencyNotInjected('IconCacheService', self::class); } } diff --git a/src/Exceptions/IchavaException.php b/src/Exceptions/IchavaException.php index 2ba1612c..21d9fa34 100644 --- a/src/Exceptions/IchavaException.php +++ b/src/Exceptions/IchavaException.php @@ -15,14 +15,14 @@ * * @phpstan-consistent-constructor */ -class IchavaException extends RuntimeException +final class IchavaException extends RuntimeException { /** * SVG content rejected by the sanitizer (empty, malformed, or non-svg root). */ public static function invalidSvgContent(string $reason): static { - return new static("Invalid SVG content: {$reason}"); + return new self("Invalid SVG content: {$reason}"); } /** @@ -30,7 +30,7 @@ public static function invalidSvgContent(string $reason): static */ public static function svgTooLarge(int $bytes, int $limit): static { - return new static("Invalid SVG content: {$bytes} bytes exceeds the limit of {$limit} bytes"); + return new self("Invalid SVG content: {$bytes} bytes exceeds the limit of {$limit} bytes"); } /** @@ -38,7 +38,7 @@ public static function svgTooLarge(int $bytes, int $limit): static */ public static function svgTooComplex(int $limit): static { - return new static("Invalid SVG content: more than {$limit} elements"); + return new self("Invalid SVG content: more than {$limit} elements"); } /** @@ -46,7 +46,7 @@ public static function svgTooComplex(int $limit): static */ public static function iconSetAlreadyRegistered(string $name): static { - return new static( + return new self( "Icon set '{$name}' is already registered. " . "Use IconRegistry::package('{$name}') to register a different icon set.", ); @@ -57,7 +57,7 @@ public static function iconSetAlreadyRegistered(string $name): static */ public static function iconSetNotFound(string $name): static { - return new static("Icon set '{$name}' not found."); + return new self("Icon set '{$name}' not found."); } /** @@ -65,7 +65,7 @@ public static function iconSetNotFound(string $name): static */ public static function invalidConfiguration(string $reason): static { - return new static("Invalid icon set configuration: {$reason}"); + return new self("Invalid icon set configuration: {$reason}"); } /** @@ -73,7 +73,7 @@ public static function invalidConfiguration(string $reason): static */ public static function missingConfigFile(string $configPath): static { - return new static( + return new self( "Icon set configuration file not found: '{$configPath}'. " . 'Each icon set must have a config.json file in its directory.', ); @@ -84,7 +84,7 @@ public static function missingConfigFile(string $configPath): static */ public static function invalidConfig(string $reason, string $configPath): static { - return new static( + return new self( "Invalid icon set configuration at '{$configPath}': {$reason}", ); } @@ -98,7 +98,7 @@ public static function configurationValidationFailed(string $providerClass, arra { $errorList = implode("\n - ", $errors); - return new static( + return new self( "Ichava package configuration failed for '{$providerClass}'.\n\n" . "Missing or invalid configuration:\n - {$errorList}\n\n" . 'Fix these issues in your service provider to register this package.', @@ -110,7 +110,7 @@ public static function configurationValidationFailed(string $providerClass, arra */ public static function configurationBuildFailed(string $iconSetClass, string $message): static { - return new static( + return new self( "Icon set configuration build failed for '{$iconSetClass}'.\n\n" . "Error: {$message}\n\n" . "Check your icon set's buildConfig() method implementation.", @@ -128,7 +128,7 @@ public static function iconNotFound(string $name, ?string $package = null): stat $message .= " in package '{$package}'"; } - return new static($message . '.'); + return new self($message . '.'); } /** @@ -136,7 +136,7 @@ public static function iconNotFound(string $name, ?string $package = null): stat */ public static function iconNotFoundInSet(string $name, string $set): static { - return new static("Icon '{$name}' not found in set '{$set}'."); + return new self("Icon '{$name}' not found in set '{$set}'."); } /** @@ -144,7 +144,7 @@ public static function iconNotFoundInSet(string $name, string $set): static */ public static function invalidIconPath(string $path, string $expectedFormat = 'vendor/package::variant/category/icon'): static { - return new static( + return new self( "Invalid icon path format: '{$path}'. " . "Expected format: '{$expectedFormat}'. Example: 'ichava/icon-sets-tabler::outline/home'", ); @@ -155,7 +155,7 @@ public static function invalidIconPath(string $path, string $expectedFormat = 'v */ public static function missingPackageInPath(string $path): static { - return new static( + return new self( "Icon path must include vendor/package: '{$path}'. " . "Expected format: vendor/package::icon (e.g., 'ichava/icon-sets-tabler::outline/home')", ); @@ -166,7 +166,7 @@ public static function missingPackageInPath(string $path): static */ public static function pathTraversalAttempt(string $path): static { - return new static( + return new self( "Icon path contains path traversal attempt: '{$path}'. " . 'Path traversal (../) is not allowed for security reasons.', ); @@ -179,7 +179,7 @@ public static function pathTooLong(string $path, int $maxLength = 255): static { $length = strlen($path); - return new static( + return new self( "Icon path exceeds maximum length of {$maxLength} characters: {$length} characters. " . 'Please use a shorter path.', ); @@ -190,7 +190,7 @@ public static function pathTooLong(string $path, int $maxLength = 255): static */ public static function pathTooDeep(string $path, int $maxDepth = 10): static { - return new static( + return new self( "Icon path has too many nested levels (max {$maxDepth}): '{$path}'. " . 'Please reduce path nesting depth.', ); @@ -201,7 +201,7 @@ public static function pathTooDeep(string $path, int $maxDepth = 10): static */ public static function invalidIdentifier(string $path): static { - return new static( + return new self( "Icon path contains invalid vendor or package identifier: '{$path}'. " . 'Only alphanumeric characters, dashes, and underscores are allowed.', ); @@ -212,7 +212,7 @@ public static function invalidIdentifier(string $path): static */ public static function invalidIconName(string $name): static { - return new static( + return new self( "Invalid icon name: '{$name}'. " . 'Only alphanumeric characters, dashes, underscores, and dots are allowed. ' . 'If extension is present, it must be .svg', @@ -224,7 +224,7 @@ public static function invalidIconName(string $name): static */ public static function invalidPathSegment(string $segment): static { - return new static( + return new self( "Invalid path segment: '{$segment}'. " . 'Only alphanumeric characters, dashes, and underscores are allowed.', ); @@ -235,7 +235,7 @@ public static function invalidPathSegment(string $segment): static */ public static function pathNotFound(string $path): static { - return new static("Required path not found: '{$path}'"); + return new self("Required path not found: '{$path}'"); } /** @@ -243,7 +243,7 @@ public static function pathNotFound(string $path): static */ public static function directoryNotFound(string $path): static { - return new static("Directory not found: '{$path}'"); + return new self("Directory not found: '{$path}'"); } /** @@ -251,7 +251,7 @@ public static function directoryNotFound(string $path): static */ public static function filesystemFailure(string $operation, string $path): static { - return new static("Filesystem {$operation} failed for: '{$path}'"); + return new self("Filesystem {$operation} failed for: '{$path}'"); } /** @@ -259,7 +259,7 @@ public static function filesystemFailure(string $operation, string $path): stati */ public static function invalidSvg(string $reason): static { - return new static("Invalid SVG content: {$reason}"); + return new self("Invalid SVG content: {$reason}"); } /** @@ -267,7 +267,7 @@ public static function invalidSvg(string $reason): static */ public static function renderFailed(string $iconName, string $reason, ?Throwable $previous = null): static { - return new static("Failed to render icon '{$iconName}': {$reason}", previous: $previous); + return new self("Failed to render icon '{$iconName}': {$reason}", previous: $previous); } /** @@ -275,7 +275,7 @@ public static function renderFailed(string $iconName, string $reason, ?Throwable */ public static function packageNotRegistered(string $packageKey): static { - return new static( + return new self( "Package '{$packageKey}' is not registered. " . 'Register it first using IconRegistry::register().', ); @@ -290,7 +290,7 @@ public static function missingPackageMetadata(string $packageName, array $missin { $fieldList = implode("\n - ", $missingFields); - return new static( + return new self( "Package registration failed for '{$packageName}'.\n\n" . "Missing required metadata:\n - {$fieldList}\n\n" . "These fields are mandatory. Fix them in your service provider's registerWithIconRegistry() method.", @@ -302,7 +302,7 @@ public static function missingPackageMetadata(string $packageName, array $missin */ public static function packageClassNotFound(string $packageName, string $className): static { - return new static( + return new self( "Package registration failed for '{$packageName}'.\n\n" . "Icon set class does not exist: {$className}\n\n" . 'Ensure the class is properly autoloaded and the namespace is correct.', @@ -314,7 +314,7 @@ public static function packageClassNotFound(string $packageName, string $classNa */ public static function packagePathNotFound(string $packageName, string $path): static { - return new static( + return new self( "Package registration failed for '{$packageName}'.\n\n" . "Icon base path does not exist: {$path}\n\n" . 'Ensure the path is correct and the directory exists.', @@ -330,7 +330,7 @@ public static function iconSetNameConflict( string $newPackage, string $providerClass, ): static { - return new static( + return new self( "Icon set name conflict: '{$iconSetName}' is already registered by '{$existingPackage}'.\n\n" . "Package '{$newPackage}' cannot reuse that name.\n\n" . "To resolve: change the icon set name in {$providerClass}::getIconSetName(), or remove one of the conflicting packages.", @@ -346,7 +346,7 @@ public static function prefixConflict(string $prefix, array $packages): static { $packageList = implode(', ', $packages); - return new static( + return new self( "Icon prefix conflict: '{$prefix}' is used by multiple packages: {$packageList}.\n\n" . 'Consider using unique prefixes for each package.', ); @@ -361,7 +361,7 @@ public static function bladeComponentConflict(string $componentName, array $pack { $packageList = implode(', ', $packages); - return new static( + return new self( "Blade component conflict: '{$componentName}' is used by multiple packages: {$packageList}.\n\n" . 'Only one component will be registered. Consider using unique component names.', ); @@ -376,7 +376,7 @@ public static function packagePrefixConflict(string $prefix, array $packages): s { $packageList = implode("\n - ", $packages); - return new static( + return new self( "Package prefix conflict: '{$prefix}' is already used by: {$packageList}.\n\n" . 'Each package must have a unique prefix.', ); @@ -391,7 +391,7 @@ public static function packageBladeComponentConflict(string $componentName, arra { $packageList = implode("\n - ", $packages); - return new static( + return new self( "Blade component conflict: '{$componentName}' is already registered by: {$packageList}.\n\n" . 'Each package must have a unique blade component name.', ); @@ -402,7 +402,7 @@ public static function packageBladeComponentConflict(string $componentName, arra */ public static function configurationMissingMethod(string $providerClass, string $methodName): static { - return new static( + return new self( "Configuration method missing in '{$providerClass}'.\n\n" . "Required method '{$methodName}()' is not defined.\n\n" . 'Ensure your provider implements all required methods.', @@ -414,7 +414,7 @@ public static function configurationMissingMethod(string $providerClass, string */ public static function seedingFailed(string $package, string $reason): static { - return new static("Icon seeding failed for package '{$package}': {$reason}"); + return new self("Icon seeding failed for package '{$package}': {$reason}"); } /** @@ -422,7 +422,7 @@ public static function seedingFailed(string $package, string $reason): static */ public static function seedingRequiresConsole(): static { - return new static('Icon seeding can only be run from the console.'); + return new self('Icon seeding can only be run from the console.'); } /** @@ -430,7 +430,7 @@ public static function seedingRequiresConsole(): static */ public static function dependencyNotInjected(string $dependency, string $class): static { - return new static( + return new self( "Dependency not injected in '{$class}'.\n\n" . "Missing: {$dependency}\n\n" . "Ensure {$dependency} is registered as a singleton and all constructor dependencies are wired through the service container.", @@ -442,7 +442,7 @@ public static function dependencyNotInjected(string $dependency, string $class): */ public static function operationFailed(string $operation, string $reason): static { - return new static("{$operation} failed: {$reason}"); + return new self("{$operation} failed: {$reason}"); } /** @@ -456,7 +456,7 @@ public static function pathNotReadable(string $path, string $context = ''): stat $message .= "\nContext: {$context}\n"; } - return new static($message); + return new self($message); } /** @@ -464,7 +464,7 @@ public static function pathNotReadable(string $path, string $context = ''): stat */ public static function securityViolation(string $reason): static { - return new static("Security violation: {$reason}"); + return new self("Security violation: {$reason}"); } /** @@ -472,7 +472,7 @@ public static function securityViolation(string $reason): static */ public static function invalidPathType(string $path, string $expectedType, string $actualType): static { - return new static( + return new self( "Invalid path type for '{$path}': expected {$expectedType}, got {$actualType}.\n\n" . 'Ensure the path points to the correct resource type.', ); diff --git a/src/Facades/IchavaFacade.php b/src/Facades/IchavaFacade.php index d72e31da..06aba26c 100644 --- a/src/Facades/IchavaFacade.php +++ b/src/Facades/IchavaFacade.php @@ -58,7 +58,7 @@ * * @see Ichava */ -class IchavaFacade extends Facade +final class IchavaFacade extends Facade { protected static function getFacadeAccessor(): string { diff --git a/src/Jobs/SeedIconsJob.php b/src/Jobs/SeedIconsJob.php index 83ac778d..9d1e923f 100644 --- a/src/Jobs/SeedIconsJob.php +++ b/src/Jobs/SeedIconsJob.php @@ -24,10 +24,10 @@ use Simtabi\Laranail\DbTools\Query\ChunkedWriter; use Simtabi\Laranail\Ichava\Services\IchavaLogger; use Simtabi\Laranail\Ichava\Services\IconRegistry; +use Simtabi\Laranail\Ichava\Support\RuntimeLimits; use Simtabi\Laranail\Ichava\Support\IconClassifier; use Simtabi\Laranail\Ichava\Support\Seeder\IchavaSeeder; use Simtabi\Laranail\Ichava\Support\Seeder\IconSeederHelpers; -use Simtabi\Laranail\Package\Tools\Support\RuntimeConfigurator; use Simtabi\Laranail\Package\Tools\Services\Database\SeederRunTracker; /** @@ -48,7 +48,7 @@ * @see IchavaSeeder::seed() Dispatches these jobs * @see IconSeederHelpers Trait for path/tag extraction */ -class SeedIconsJob implements ShouldQueue +final class SeedIconsJob implements ShouldQueue { use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use IconSeederHelpers; @@ -89,8 +89,11 @@ public function __construct( /** * Execute the job. */ - public function handle(IchavaLogger $logger): void + public function handle(IchavaLogger $logger, ?RuntimeLimits $limits = null): void { + // The queue injects $limits; a caller handling the job directly need not. + $limits ??= app(RuntimeLimits::class); + // Check if batch was cancelled if ($this->batch()?->cancelled()) { $logger->info('🛑 Seeding cancelled, skipping', [ @@ -101,47 +104,9 @@ public function handle(IchavaLogger $logger): void return; } - RuntimeConfigurator::make() - ->memory('512M') - ->timeout($this->timeout) - ->disableTelescope() - ->apply(); - - $fileCount = count($this->files); - $logger->info("Processing job {$this->jobIndex}/{$this->totalJobs}", [ - 'package' => $this->packageName, - 'files' => $fileCount, - ]); - - try { - $result = $this->processFiles($logger); - - // Inside a queued batch the job is the only thing that knows its chunk - // is done, so it reports progress. An inline run is counted by the - // ChunkedBatchDispatcher driving it; reporting here too would double it. - if ($this->batchId !== null) { - app(SeederRunTracker::class)->advance(IchavaSeeder::trackingKey($this->packageName), by: $fileCount); - } - - $logger->info("Job {$this->jobIndex} completed", [ - 'package' => $this->packageName, - 'files_received' => $fileCount, - 'files_processed' => $result['processed'], - 'files_skipped' => $result['skipped'], - 'files_new' => $result['new'], - 'files_updated' => $result['updated'], - ]); - } catch (Throwable $e) { - if ($this->batchId !== null) { - app(SeederRunTracker::class)->advance(IchavaSeeder::trackingKey($this->packageName), failed: true, by: $fileCount); - } - - $logger->jobFailed(self::class, $e, [ - 'package' => $this->packageName, - 'job_index' => $this->jobIndex, - ]); - throw $e; - } + // The job's own limits, restored when it returns, so a worker that runs + // other jobs after this one keeps the limits it started with (C-01). + $limits->scope(fn () => $this->seedChunk($logger), timeout: $this->timeout); } /** @@ -574,4 +539,46 @@ protected function arraysDiffer(array $a, array $b): bool return $a !== $b; } + + /** + * Seed this job's chunk and report its progress. + */ + private function seedChunk(IchavaLogger $logger): void + { + $fileCount = count($this->files); + $logger->info("Processing job {$this->jobIndex}/{$this->totalJobs}", [ + 'package' => $this->packageName, + 'files' => $fileCount, + ]); + + try { + $result = $this->processFiles($logger); + + // Inside a queued batch the job is the only thing that knows its chunk + // is done, so it reports progress. An inline run is counted by the + // ChunkedBatchDispatcher driving it; reporting here too would double it. + if ($this->batchId !== null) { + app(SeederRunTracker::class)->advance(IchavaSeeder::trackingKey($this->packageName), by: $fileCount); + } + + $logger->info("Job {$this->jobIndex} completed", [ + 'package' => $this->packageName, + 'files_received' => $fileCount, + 'files_processed' => $result['processed'], + 'files_skipped' => $result['skipped'], + 'files_new' => $result['new'], + 'files_updated' => $result['updated'], + ]); + } catch (Throwable $e) { + if ($this->batchId !== null) { + app(SeederRunTracker::class)->advance(IchavaSeeder::trackingKey($this->packageName), failed: true, by: $fileCount); + } + + $logger->jobFailed(self::class, $e, [ + 'package' => $this->packageName, + 'job_index' => $this->jobIndex, + ]); + throw $e; + } + } } diff --git a/src/Listeners/AutoSeedIconsOnRegistration.php b/src/Listeners/AutoSeedIconsOnRegistration.php index 6a41f3b1..afa1b876 100644 --- a/src/Listeners/AutoSeedIconsOnRegistration.php +++ b/src/Listeners/AutoSeedIconsOnRegistration.php @@ -23,13 +23,13 @@ * Only seeds when the package has zero icons in the database, preventing * duplicate work. The recommended approach is `php artisan ichava::ichava-core.database seed`. */ -class AutoSeedIconsOnRegistration +final class AutoSeedIconsOnRegistration { public function __construct( - protected IchavaLifecycleManager $lifecycle, - protected IchavaLogger $logger, - protected IchavaSeeder $seeder, - protected IconRegistry $registry, + private IchavaLifecycleManager $lifecycle, + private IchavaLogger $logger, + private IchavaSeeder $seeder, + private IconRegistry $registry, ) {} /** @@ -149,7 +149,7 @@ public function shouldQueue(): bool /** * Get SVG path for a package */ - protected function getSvgPath(string $packageName): ?string + private function getSvgPath(string $packageName): ?string { try { $iconSet = $this->registry->set($packageName); @@ -166,7 +166,7 @@ protected function getSvgPath(string $packageName): ?string /** * Check if package already has icons in database */ - protected function packageHasIcons(string $packageName): bool + private function packageHasIcons(string $packageName): bool { try { return Icon::where('package', $packageName)->exists(); @@ -179,7 +179,7 @@ protected function packageHasIcons(string $packageName): bool /** * Check if seeding is already in progress for this package */ - protected function isSeedingInProgress(string $packageName): bool + private function isSeedingInProgress(string $packageName): bool { $cacheKey = "ichava:seeding:{$packageName}"; @@ -199,7 +199,7 @@ protected function isSeedingInProgress(string $packageName): bool * worker exit (SIGTERM, php-fpm reload) clears the marker immediately, * not after the full TTL. */ - protected function markSeedingInProgress(string $packageName): void + private function markSeedingInProgress(string $packageName): void { $cacheKey = "ichava:seeding:{$packageName}"; @@ -220,7 +220,7 @@ protected function markSeedingInProgress(string $packageName): void /** * Clear seeding in progress flag */ - protected function clearSeedingInProgress(string $packageName): void + private function clearSeedingInProgress(string $packageName): void { $cacheKey = "ichava:seeding:{$packageName}"; @@ -234,7 +234,7 @@ protected function clearSeedingInProgress(string $packageName): void /** * Log migrations warning once per TTL to prevent spam */ - protected function logMigrationsWarningOnce(string $packageName): void + private function logMigrationsWarningOnce(string $packageName): void { $ttl = Coerce::int(config('ichava.ichava-core.logging.deduplication_ttl', 300)); $cacheKey = "ichava:migration-warning:{$packageName}"; @@ -264,7 +264,7 @@ protected function logMigrationsWarningOnce(string $packageName): void /** * Check if a database-related command is currently running */ - protected function isRunningDatabaseCommand(): bool + private function isRunningDatabaseCommand(): bool { if (! app()->runningInConsole()) { return false; diff --git a/src/Listeners/AutoUnseedOnUnregistration.php b/src/Listeners/AutoUnseedOnUnregistration.php index 12b21972..0e93cd84 100644 --- a/src/Listeners/AutoUnseedOnUnregistration.php +++ b/src/Listeners/AutoUnseedOnUnregistration.php @@ -6,19 +6,19 @@ use Throwable; use Simtabi\Laranail\Ichava\Services\IchavaLogger; +use Simtabi\Laranail\Ichava\Contracts\DatabaseOperations; use Simtabi\Laranail\Ichava\Events\IconRegistrationEvent; use Simtabi\Laranail\Ichava\Services\IchavaLifecycleManager; -use Simtabi\Laranail\Ichava\Services\DatabaseOperationsService; /** * Removes a package's icon rows + orphaned terms when the package unregisters. */ -class AutoUnseedOnUnregistration +final class AutoUnseedOnUnregistration { public function __construct( - protected DatabaseOperationsService $databaseService, - protected IchavaLifecycleManager $lifecycle, - protected IchavaLogger $logger, + private DatabaseOperations $databaseService, + private IchavaLifecycleManager $lifecycle, + private IchavaLogger $logger, ) {} /** diff --git a/src/Models/Icon.php b/src/Models/Icon.php index 724fdb15..f863bd60 100644 --- a/src/Models/Icon.php +++ b/src/Models/Icon.php @@ -4,7 +4,6 @@ namespace Simtabi\Laranail\Ichava\Models; -use Throwable; use Carbon\Carbon; use Illuminate\Support\Str; use Illuminate\Support\Facades\DB; @@ -15,15 +14,14 @@ use Illuminate\Database\Eloquent\Collection; use Simtabi\Laranail\Ichava\Support\Helpers; use Simtabi\Laranail\Ichava\Enums\KindSource; -use Simtabi\Laranail\Ichava\Support\AuditLogger; use Illuminate\Database\Eloquent\Casts\Attribute; use Simtabi\Laranail\Ichava\Services\IconRegistry; use Illuminate\Database\Eloquent\Factories\Factory; +use Simtabi\Laranail\Ichava\Services\IconSvgReader; use Illuminate\Database\Eloquent\Factories\HasFactory; use Simtabi\Laranail\Ichava\Services\IconCacheService; use Simtabi\Laranail\Ichava\Support\FtsLanguageHelper; use Illuminate\Database\Eloquent\Relations\MorphToMany; -use Simtabi\Laranail\Ichava\Exceptions\IchavaException; use Simtabi\Laranail\Ichava\Services\SvgProcessingService; /** @@ -76,8 +74,12 @@ final class Icon extends Model /** * The morph alias core registers for this model, and so the value every * `termable_type` row holds. A literal so raw search SQL can quote it. + * + * Vendor-scoped: the morph map is one flat map per application, and a bare + * `icon` is a name a host or another package may claim for its own model. + * Migration 2026_11_05_000001 moves rows stored under `icon` (C-02). */ - public const MORPH_ALIAS = 'icon'; + public const MORPH_ALIAS = 'ichava-icon'; /** @var string */ protected $table = self::TABLE; @@ -1157,113 +1159,16 @@ private function themeVariantMap(): array */ private function processedSvg(string $path, string $storedPath, string $cacheKey): ?string { - /* - * Sanitise here, at the single point every consumer reads through. - * - * This used to be a bare `File::get()`. The SVG endpoint's own comment - * claimed the content "has been sanitised by SvgProcessingService" and - * added nosniff plus a restrictive CSP as defence in depth -- but nothing - * had sanitised it, so those headers were the only defence, and they only - * apply to that one route. - * - * The path that mattered more is JSON: `IconBrowserService` puts - * `svg_content` straight into an API payload, where no response header - * helps, and the client injects it into the DOM. A pack shipping a hostile - * file reached the browser verbatim -- and packs do contain - * `foreignObject`, `script` and `image` elements today. - * - * The result is cached post-sanitisation, so this costs one pass per icon - * per cache lifetime rather than one per request. - */ - $content = app(IconCacheService::class)->remember( - $cacheKey, - function () use ($path, $storedPath): string { - /* - * The kind's byte budget is enforced at seed time, but the - * file is read again here, on every cache miss -- and it may - * have been replaced or grown since. Check it before reading, - * so an oversized file costs a stat rather than a parse (S2). - */ - $kind = $this->kind instanceof IconKind ? $this->kind : IconKind::Icon; - $limit = $kind->maxFileSize(); - $size = @filesize($path); - - if ($size !== false && $size > $limit) { - app(AuditLogger::class)->warning('svg.sanitiser_rejected', [ - 'path' => $storedPath, - 'package' => $this->package, - 'reason' => IchavaException::svgTooLarge($size, $limit)->getMessage(), - ]); - - return ''; - } + $base = $this->getPackageBasePath(); - $raw = File::get($path); - - try { - $svg = app(SvgProcessingService::class); - - /* - * Namespace ids before sanitising, and inside the cache, - * so it costs one pass per icon per cache lifetime. - * - * SVG ids are page-scoped in practice. 2,157 files in the - * corpus share `_Transparent_Rectangle_` and 261 share - * `Layer_1`; render two of them together and the second - * icon's `url(#Layer_1)` resolves to the first one's - * definition, silently. The browser renders 60+ at once. - * The prefix is derived from the path, not the content, so - * it survives an upstream refresh and stays deterministic - * for the content-addressed cache (CR1). - */ - $raw = $svg->namespaceIds($raw, $storedPath); - - /* - * Hand sizing back to the component. An icon shipping - * both a viewBox and hard width/height ignores the size - * it is asked for; 6,146 tabler icons alone ship that - * pair. An icon with neither is - * reported rather than dropped -- it still renders, it - * just has no intrinsic size to scale against. - */ - $raw = $svg->normaliseSizing($raw, function (string $reason) use ($storedPath): void { - app(AuditLogger::class)->warning('svg.unusable_sizing', [ - 'path' => $storedPath, - 'package' => $this->package, - 'reason' => $reason, - ]); - }); - - /* - * Opt-in (B7): a monochrome icon that hardcodes black - * vanishes on a dark background. Off by default; the flag - * is part of render_version, so turning it on re-keys this - * cache entry by itself. - */ - if ($svg->convertsMonochromeToCurrentColor()) { - $raw = $svg->monochromeToCurrentColor($raw); - } - - return $svg->process($raw, [], false); - } catch (Throwable $e) { - /* - * A file the sanitiser rejects is not served raw as a - * fallback. Failing closed is the point: the rejection means - * it could not be made safe. - */ - app(AuditLogger::class)->warning('svg.sanitiser_rejected', [ - 'path' => $storedPath, - 'package' => $this->package, - 'reason' => $e->getMessage(), - ]); - - return ''; - } - }, - expects: 'string', + return app(IconSvgReader::class)->read( + $path, + $storedPath, + $cacheKey, + $this->kind instanceof IconKind ? $this->kind : IconKind::Icon, + (string) $this->package, + $base !== '' ? $base : null, ); - - return is_string($content) ? $content : null; } /** diff --git a/src/Models/IconTerm.php b/src/Models/IconTerm.php index 3200ee2b..d2125e3d 100644 --- a/src/Models/IconTerm.php +++ b/src/Models/IconTerm.php @@ -40,7 +40,7 @@ * @property-read int $icons_count Cached icon count * @property-read int $depth Term depth in hierarchy */ -class IconTerm extends Model +final class IconTerm extends Model { public const TYPE_CATEGORY = 'category'; @@ -80,7 +80,7 @@ public static function descendantsOf(int $termId): Collection SELECT * FROM term_tree; ', [$termId]); - return static::hydrate($rows); + return self::hydrate($rows); } /** @@ -225,7 +225,7 @@ public function ancestors(): Collection /** @return Collection */ public function descendants(): Collection { - return static::descendantsOf($this->id); + return self::descendantsOf($this->id); } /** diff --git a/src/Providers/IchavaServiceProvider.php b/src/Providers/IchavaServiceProvider.php index 70cebe3a..9bca0d89 100644 --- a/src/Providers/IchavaServiceProvider.php +++ b/src/Providers/IchavaServiceProvider.php @@ -17,6 +17,7 @@ use Simtabi\Laranail\Ichava\Support\Coerce; use Simtabi\Laranail\Package\Tools\Package; use Illuminate\Contracts\Container\Container; +use Simtabi\Laranail\Ichava\Data\BulkOptions; use Simtabi\Laranail\Ichava\Enums\KindSource; use Simtabi\Laranail\Ichava\Drivers\SvgDriver; use Simtabi\Laranail\Ichava\Support\AuditLogger; @@ -29,29 +30,33 @@ use Simtabi\Laranail\Ichava\Services\IchavaLogger; use Simtabi\Laranail\Ichava\Services\IconRegistry; use Simtabi\Laranail\Ichava\Support\Auth\HostUser; +use Simtabi\Laranail\Ichava\Support\RuntimeLimits; use Simtabi\Laranail\Ichava\Support\SecurityNonce; use Simtabi\Laranail\Ichava\Contracts\PresentsUser; use Simtabi\Laranail\Ichava\Contracts\ResolvesUser; use Simtabi\Laranail\Ichava\Services\IconsManifest; +use Simtabi\Laranail\Ichava\View\ComponentPrefixes; use Illuminate\Database\Eloquent\Relations\Relation; use Simtabi\Laranail\Ichava\Commands\InstallCommand; -use Simtabi\Laranail\Ichava\Support\ServiceProvider; use Simtabi\Laranail\Ichava\Commands\DatabaseCommand; use Simtabi\Laranail\Ichava\Support\Auth\IchavaGates; +use Simtabi\Laranail\Ichava\Support\Pack\PackBuilder; use Simtabi\Laranail\Ichava\Commands\JobStatusCommand; use Simtabi\Laranail\Ichava\Contracts\PreferenceStore; use Simtabi\Laranail\Ichava\Services\IconCacheService; +use Simtabi\Laranail\Ichava\Exceptions\IchavaException; +use Simtabi\Laranail\Ichava\Contracts\ChecksPackUpdates; use Simtabi\Laranail\Ichava\Services\IconBrowserService; use Simtabi\Laranail\Ichava\Services\IconWatcherService; use Simtabi\Laranail\Ichava\Services\InformationService; use Simtabi\Laranail\Ichava\Support\Seeder\IchavaSeeder; +use Simtabi\Laranail\Ichava\Contracts\DatabaseOperations; use Simtabi\Laranail\Ichava\Support\IchavaSessionManager; use Illuminate\Contracts\Auth\Access\Gate as GateContract; use Simtabi\Laranail\Ichava\Services\ConfigurationService; use Simtabi\Laranail\Ichava\Services\IconDiscoveryService; use Simtabi\Laranail\Ichava\Services\SvgProcessingService; use Simtabi\Laranail\Ichava\Support\DeferredIconsRegistry; -use Simtabi\Laranail\Ichava\View\Components\IconComponent; use Simtabi\Laranail\Ichava\Commands\WatchIconFilesCommand; use Simtabi\Laranail\Ichava\Services\IconPackUpdateChecker; use Simtabi\Laranail\Ichava\Services\IconPreferenceService; @@ -73,20 +78,21 @@ * * Bootstraps the entire Ichava icon ecosystem for a Laravel application. * Auto-discovered via composer.json, no manual registration needed. - * Child icon packages must extend Support\ServiceProvider, not this class. + * Child icon packages extend Providers\IconPackServiceProvider, not this class. * * Full list of registered services, commands, Blade components, middleware, * and log channels: see README.md § Architecture. * - * @see ServiceProvider + * @see IconPackServiceProvider * @see IchavaFacade */ -class IchavaServiceProvider extends PackageServiceProvider +final class IchavaServiceProvider extends PackageServiceProvider { /** * The vendor-scoped Blade component prefix: ``. + * The same value as ComponentPrefixes::CANONICAL, which owns it. */ - public const string SCOPED_COMPONENT_PREFIX = 'ichava-core'; + public const string SCOPED_COMPONENT_PREFIX = ComponentPrefixes::CANONICAL; /** * Declare the Ichava core package metadata, assets, and commands. @@ -100,7 +106,7 @@ public function configurePackage(Package $packager): void ->hasConfigFile('ichava-core') ->hasAboutSection('Ichava', fn (): array => $this->aboutEcosystem()) // Core extends PackageServiceProvider directly, so it does NOT - // inherit the default-on registration Support\ServiceProvider gives + // inherit the default-on registration PackServiceProvider gives // the packs -- it has to ask. That asymmetry has already cost one // defect class here; anything added to the packs' default needs a // deliberate counterpart on this line. @@ -133,8 +139,10 @@ public function configurePackage(Package $packager): void */ public function registeringPackage(): void { - // Configure runtime settings for heavy operations (seeding, processing) - $this->configureRuntimeSettings(); + // No runtime limits here. A library must not change the host's + // process-wide memory or time limits, and the config has not merged + // yet in this phase. Ichava's own seed, watch and cache operations + // run inside RuntimeLimits::scope(), which restores them after (C-01). // Register morph map for polymorphic relationships Relation::morphMap([ @@ -215,6 +223,12 @@ public function registeringPackage(): void $this->app->singleton(IchavaSeeder::class); $this->app->singleton(IconPackUpdateChecker::class); + // The two I/O services with a second implementation today (the test + // doubles), behind contracts. Each resolves the concrete singleton, so + // a binding of the class (as icon-browser's tests make) still wins (C-06). + $this->app->bind(ChecksPackUpdates::class, fn (Container $app): ChecksPackUpdates => $app->make(IconPackUpdateChecker::class)); + $this->app->bind(DatabaseOperations::class, fn (Container $app): DatabaseOperations => $app->make(DatabaseOperationsService::class)); + // Manifest service $this->app->singleton(IconsManifest::class, function (Container $app) { $pathResolver = $app->make(PathResolver::class); @@ -278,69 +292,14 @@ public function packageRegistered(): void * Boot the Ichava ecosystem once all providers are registered. * * Execution sequence: - * 1. Blade::componentNamespace() , 'ichava' component namespace (the entire HTTP layer, routes, middleware, controllers, layouts, lives in IconBrowserServiceProvider in ichava/icon-browser) * 2. IconRegistry::fromDirectory() , register bundled test-icons set (core test fixtures) * 3. registerCustomIconSets() , user-defined sets from ichava.ichava-core.custom-icons.sets - * 4. registerCoreIconComponent() , register the generic Blade component + * 4. registerCoreIconComponent() , , and the configured short * 5. registerFileWatcher() , schedule ichava:watch if database.auto_sync enabled * 6. scheduleLogCleanup() , schedule ichava:cleanup-logs if logging.auto_cleanup enabled */ public function bootingPackage(): void { - // Register class-based components under the 'ichava' namespace so - // packages registering their own short-name components (e.g. browser's - // ) can do so via Blade::component(...). - // The HTTP layer (routes, middleware, controllers, requests, resources) - // is owned entirely by IconBrowserServiceProvider in ichava/icon-browser , - // core ships zero HTTP surface. - // DEFERRED, not overlooked: `ichava` is a bare generic slug in a flat - // map, the same class of claim `ichava/icon-browser` corrected for view - // hints. It is deliberate here -- packs register their own short-name - // components under one shared ecosystem prefix -- but the standard's - // reasoning about silent replacement applies to it unchanged. - // - // Left in place because `` is the ecosystem's - // documented public API. Renaming it to `ichava-core::` is a breaking - // change for every consumer and belongs in its own release with a - // migration note, not folded into unrelated work. Revisit at 1.0. - // - // 112 lines across the eight package repos, measured 2026-09-21: - // - // for p in core browser icon-sets-*; do - // git -C "$p" grep -I -c -F 'x-ichava::' origin/main \ - // -- 'src/*' 'resources/*' 'docs/*' - // done | awk -F: '{s+=$NF} END{print s}' - // - // Do not cite that number without re-running the command. An earlier - // revision of this comment read "~85 ... plus 24 in - // `ichava/documentation`", which was true when measured and was made - // wrong by the docs decentralisation: those pages moved into the - // packages, so the per-package count rose and `ichava/documentation` - // now holds **zero** -- it would send a reader to the one place with - // nothing left to find. - // - // Three flat maps carry the bare `ichava` key, not one, so Decision B - // is wider than this call site: - // - // 1. this class-component namespace; - // 2. the class-component aliases -- core's `ichava::icon` in - // registerCoreIconComponent() below, plus five in browser; - // 3. browser's `Blade::anonymousComponentPath(..., 'ichava')`, which - // Laravel stores as addNamespace(hash('xxh128', $prefix), $path) - // -- so the slug still determines a key in the *view-hint* map - // that ichava/icon-browser#33 otherwise cleared of it. - // - // The fourth, the view hints themselves, is the one already corrected. - Blade::componentNamespace('Simtabi\\Laranail\\Ichava\\View\\Components', 'ichava'); - - // The vendor-scoped prefix, `ichava-core` (vendor `ichava`, package - // `core`; Blade prefixes keep the hyphen because a tag cannot hold a - // slash). Registered over the same classes as the bare prefix, so - // `` and `` resolve the same - // component. Additive: the bare prefix above is unchanged, and whether - // it is ever deprecated is a separate owner decision. - Blade::componentNamespace('Simtabi\\Laranail\\Ichava\\View\\Components', self::SCOPED_COMPONENT_PREFIX); - // Register core's bundled icon set (test fixtures shipped for the // API/test suite; multi-set structure: svg/{set-name}/config.json + // files/{set-name}/*.svg). The browser package's `ui-icons` set is @@ -361,6 +320,13 @@ public function bootingPackage(): void // Schedule automatic log cleanup $this->scheduleLogCleanup(); + // The morph map is flat: a provider registered after core can map + // Icon's alias to its own model, and stored termable rows would then + // load the wrong class. Checked once every provider has registered. + $this->app->booted(function (): void { + $this->assertIconMorphAlias(); + }); + // Default-deny Gates, defined after every provider has booted so a // host's own definitions are seen first and never overwritten. $this->app->booted(function (): void { @@ -370,6 +336,25 @@ public function bootingPackage(): void }); } + /** + * Fail at boot when the live morph map resolves Icon's alias to another class. + * + * @throws IchavaException + */ + protected function assertIconMorphAlias(): void + { + $model = Relation::getMorphedModel(Icon::MORPH_ALIAS); + + if ($model !== Icon::class) { + throw IchavaException::invalidConfiguration(sprintf( + "the morph alias '%s' resolves to %s, not %s. Another provider has claimed it; map that model under its own alias.", + Icon::MORPH_ALIAS, + $model ?? 'nothing', + Icon::class, + )); + } + } + /** * Register core's bundled test set, unless `test.enabled` is false; * `test.path` points it at another directory. @@ -382,9 +367,16 @@ protected function registerTestIconSet(): void $testPath = config('ichava.ichava-core.test.path'); - $this->app->make(IconRegistry::class)->fromDirectory( - is_string($testPath) && $testPath !== '' ? $testPath : $this->package->basePath('resources/assets/svg/test-icons'), - self::class, + // Through the bulk path, so registerMany() runs on every boot: the + // bundled set sits in the multi-set layout (svg/{set}/config.json). + // failFast keeps a broken test set loud, as fromDirectory() was. + $sets = is_string($testPath) && $testPath !== '' + ? [PackBuilder::fromDirectory($testPath)] + : PackBuilder::discover($this->package->basePath('resources/assets/svg'), only: ['test-icons']); + + $this->app->make(IconRegistry::class)->registerMany( + array_map(static fn (PackBuilder $set): PackBuilder => $set->providedBy(self::class), $sets), + new BulkOptions(name: 'ichava/core', failFast: true), ); } @@ -542,9 +534,11 @@ protected function registerFileWatcher(): void } /** - * Register core's generic developer-facing Blade icon component. - * - * - `` → IconComponent + * Register core's Blade tags: `` always, and the short + * `` while `components.short_prefix` names one, each with + * the component namespace over the same classes. `components.enabled` + * false registers neither. ComponentPrefixes validates the short prefix + * against Blade's tag parser and throws at boot on one it cannot parse. * * Browser-only demo components (``, * ``) are registered by @@ -552,23 +546,17 @@ protected function registerFileWatcher(): void */ protected function registerCoreIconComponent(): void { - // `ichava::` here is the deferred bare slug -- see the DEFERRED note in - // bootingPackage(). This alias is the one the deferral is actually - // about, so do not "tidy" it in isolation. - Blade::component('ichava::icon', IconComponent::class); - - // The vendor-scoped spelling of the same alias. A class namespace - // alone would look for `Components\Icon`, so the alias is needed too. - Blade::component(self::SCOPED_COMPONENT_PREFIX . '::icon', IconComponent::class); + $this->app->make(ComponentPrefixes::class)->register(); } /** * Register user-defined icon sets from the ichava.ichava-core.custom-icons.sets config. * - * Each entry in the array may be a plain path string or an associative array - * with a 'path' key. Registration is deferred to app()->booted() to ensure - * all service providers have finished registering before icons are loaded. - * Empty or missing config silently no-ops. + * Each entry is a path, or an array of options: `path`, `enabled`, + * `package`, `bulk`/`only`, and the PackBuilder keys (`prefix`, + * `default_class`, `blade_component`, ...); IconRegistry::registerFromConfig() + * documents them. Registration is deferred to app()->booted() so every + * provider has registered first. Empty or missing config no-ops. */ protected function registerCustomIconSets(): void { @@ -645,34 +633,17 @@ protected function horizonSupervisorConfig(): array } /** - * Configure PHP runtime settings for heavy Ichava operations. + * The PHP runtime settings Ichava's heavy operations run under. * - * Ichava processes large icon sets (100k+ SVGs) which requires: - * - Higher memory limits for bulk operations - * - Extended execution time for seeding/processing - * - Disabled Telescope during queue jobs to prevent memory leaks - * - * Settings are configurable via ichava.php config file. + * Ichava processes large icon sets (100k+ SVGs), which needs a higher + * memory limit, a longer execution time and Telescope paused. This builds + * that configurator from the merged config and applies nothing: the + * operations call RuntimeLimits::scope(), so the host keeps its limits + * everywhere else. */ - protected function configureRuntimeSettings(): void + protected function configureRuntimeSettings(): RuntimeConfigurator { - // Get configured values with sensible defaults - $memoryLimit = Coerce::string(config('ichava.ichava-core.runtime.memory_limit', '1G')); - $maxExecutionTime = Coerce::int(config('ichava.ichava-core.runtime.max_execution_time', 0)); - $disableTelescopeInQueue = config('ichava.ichava-core.runtime.disable_telescope_in_queue', true); - - // Build runtime configurator with settings from config - $configurator = RuntimeConfigurator::make() - ->memory($memoryLimit) - ->timeout($maxExecutionTime); - - // Disable Telescope during queue processing to prevent memory exhaustion - if ($disableTelescopeInQueue && $this->isIchavaQueueCommand()) { - $configurator->disableTelescope(); - } - - // Apply all settings - $configurator->apply(); + return $this->app->make(RuntimeLimits::class)->configurator(); } /** diff --git a/src/Support/IconPackServiceProvider.php b/src/Providers/IconPackServiceProvider.php similarity index 58% rename from src/Support/IconPackServiceProvider.php rename to src/Providers/IconPackServiceProvider.php index b746a3e6..963a8128 100644 --- a/src/Support/IconPackServiceProvider.php +++ b/src/Providers/IconPackServiceProvider.php @@ -2,10 +2,13 @@ declare(strict_types=1); -namespace Simtabi\Laranail\Ichava\Support; +namespace Simtabi\Laranail\Ichava\Providers; use Illuminate\View\Component; use Simtabi\Laranail\Package\Tools\Package; +use Simtabi\Laranail\Ichava\Services\IconRegistry; +use Simtabi\Laranail\Ichava\Support\Pack\PackSpec; +use Simtabi\Laranail\Ichava\View\ComponentPrefixes; use Simtabi\Laranail\Ichava\Constants\JsonConfigConstants; use Simtabi\Laranail\Package\Tools\Exceptions\InvalidPath; use Simtabi\Laranail\Package\Tools\Exceptions\InvalidPackage; @@ -40,20 +43,28 @@ * that wrote it had to import that package; here core writes it, and a pack * depends on `ichava/core` alone. * - * `ServiceProvider`, which this extends, is unchanged and still works for a - * provider that writes its own `configurePackage()`. + * That is the simple path. The advanced one is one optional hook, pack(), + * which receives a {@see PackSpec}: kind rules, theme twins and aliases in + * code, more sets (one, or every config-bearing sub-directory of a path), + * extra Blade components and a config file under another name. * - * @see ServiceProvider + * `PackServiceProvider`, which this extends, still works for a provider that + * writes its own `configurePackage()`. + * + * @see PackServiceProvider */ -abstract class IconPackServiceProvider extends ServiceProvider +abstract class IconPackServiceProvider extends PackServiceProvider { + /** What the pack registers, built once in configurePackage(). */ + private ?PackSpec $spec = null; + /** * Name from the constants class, path from the provider's own file * (`/src/Providers/` is two levels below the pack root), and the * config file when the pack ships `config/{slug}.php`. * * The config call is what the packs that ship one already made. - * `ServiceProvider::newPackage()` auto-loads `config/*.php` too, and + * `PackServiceProvider::newPackage()` auto-loads `config/*.php` too, and * package-tools de-duplicates by name, so this is a statement of the file * rather than a second merge. * @@ -66,10 +77,12 @@ public function configurePackage(Package $package): void ->setName(($this->constantsClass())::getVendorPackage()) ->setPathFrom(source: $this, levelsUp: 2); - $slug = $this->packSlug(); + $spec = $this->packSpec($package); - if (is_file($package->basePath("config/{$slug}.php"))) { - $package->hasConfigFile($slug); + $config = $spec->configFileName() ?? $this->packSlug(); + + if (is_file($package->basePath("config/{$config}.php"))) { + $package->hasConfigFile($config); } } @@ -80,11 +93,20 @@ public function configurePackage(Package $package): void */ public function bootingPackage(): void { - $this->loadBladeComponent(componentClass: $this->componentClass(), packageName: $this->packSlug()); - - $this->registerIconDirectory($this->svgAssetsPath()); + $this->packSpec()->registerWith( + $this->app->make(IconRegistry::class), + $this->app->make(ComponentPrefixes::class), + ); } + /** + * Shape the pack beyond its config.json: kind rules, theme twins and + * aliases computed in code, further sets, extra Blade components, or a + * config file under another name. Optional; the default does nothing, so + * a pack that keeps everything in config.json writes nothing here. + */ + protected function pack(PackSpec $pack): void {} + /** * The pack's constants class, which reads its `config.json`. * @@ -108,4 +130,20 @@ protected function packSlug(): string { return ($this->constantsClass())::getPackageName(); } + + /** + * The pack's spec, built once: in configurePackage(), or here when + * bootingPackage() runs on a provider whose configurePackage() did not. + */ + private function packSpec(?Package $package = null): PackSpec + { + if ($this->spec instanceof PackSpec) { + return $this->spec; + } + + $spec = PackSpec::forProvider(static::class, ($this->constantsClass())::getVendorPackage(), ($package ?? $this->package)->basePath(), $this->componentClass()); + $this->pack($spec); + + return $this->spec = $spec; + } } diff --git a/src/Support/ServiceProvider.php b/src/Providers/PackServiceProvider.php similarity index 89% rename from src/Support/ServiceProvider.php rename to src/Providers/PackServiceProvider.php index 7d981695..6ed538a6 100644 --- a/src/Support/ServiceProvider.php +++ b/src/Providers/PackServiceProvider.php @@ -2,19 +2,20 @@ declare(strict_types=1); -namespace Simtabi\Laranail\Ichava\Support; +namespace Simtabi\Laranail\Ichava\Providers; +use Throwable; use SplFileInfo; use FilesystemIterator; use Illuminate\Support\Str; use RecursiveIteratorIterator; use Composer\InstalledVersions; use RecursiveDirectoryIterator; -use Illuminate\Support\Facades\Blade; use Simtabi\Laranail\Package\Tools\Package; use Simtabi\Laranail\Ichava\Services\IconRegistry; +use Simtabi\Laranail\Ichava\View\ComponentPrefixes; +use Simtabi\Laranail\Ichava\Support\IchavaRegistrar; use Simtabi\Laranail\Ichava\Exceptions\IchavaException; -use Simtabi\Laranail\Ichava\Providers\IchavaServiceProvider; use Simtabi\Laranail\Ichava\Contracts\IchavaPackageInterface; use Simtabi\Laranail\Package\Tools\Providers\PackageServiceProvider; @@ -29,7 +30,7 @@ * @see IchavaServiceProvider * @see IconRegistry */ -abstract class ServiceProvider extends PackageServiceProvider implements IchavaPackageInterface +abstract class PackServiceProvider extends PackageServiceProvider implements IchavaPackageInterface { /** * The composer name of the pack this provider registered, e.g. @@ -227,12 +228,14 @@ protected function bootPackageAboutSections(): self /** * Register a Blade component using the Ichava naming convention. * - * Registers the component under the tag ``. + * Registers ``, and the short + * `` while `components.short_prefix` is on. * Call this from bootingPackage() in your child service provider. * * Example: * ```php - * // Registers: + * // Registers: + * // and: * $this->loadBladeComponent(IconComponent::class, 'icon-sets-tabler'); * ``` * @@ -247,39 +250,19 @@ protected function loadBladeComponent(string $componentClass, string $packageNam throw IchavaException::invalidConfiguration('Package name cannot be empty for Blade component registration'); } - $packageName = Str::lower($packageName); + // `` always, and `` while + // `components.short_prefix` is on (C-08). The package name is the + // provider's own when it has one, so the vendor is the pack's. + $slug = Str::lower($packageName); + $vendorPackage = $this->vendorPackageFor($slug); - // Register as: - // Example: - $alias = "{$packageName}-icon"; + $alias = $this->app->make(ComponentPrefixes::class)->registerPackComponent($vendorPackage, 'icon', $componentClass); - Blade::component($alias, $componentClass); - - // Tell the registry what was actually registered, so its conflict - // detector has something to compare. It reads `blade_component` from - // package metadata, and until now nothing ever put it there. + // Recorded so IconRegistry can detect two packs claiming the same + // alias (the registry's blade_component conflict detector). $this->app->make(IconRegistry::class)->noteBladeComponent(static::class, $alias); } - /** - * Convenience wrapper: register an SVG icon set directory with the global IconRegistry. - * - * Delegates to IconRegistry::fromDirectory() with the current provider class as the - * attribution source. The directory must contain a valid `config.json` file. - * - * Typical usage in bootingPackage(): - * ```php - * public function bootingPackage(): void - * { - * $this->loadBladeComponent(IconComponent::class, 'icon-sets-tabler'); - * $this->registerIconDirectory($this->package->basePath('resources/assets/svg')); - * } - * ``` - * - * @param string $path Absolute path to the icon set directory (must contain config.json) - * - * @throws IchavaException if config.json is missing or malformed - */ protected function registerIconDirectory(string $path): void { $this->app->make(IconRegistry::class)->fromDirectory($path, static::class); @@ -362,6 +345,21 @@ private static function shipsABladeTemplate(string $viewsPath): bool return false; } + /** + * The provider's `vendor/package` when its package is named and ends in + * $slug, else `ichava/{$slug}`. + */ + private function vendorPackageFor(string $slug): string + { + try { + $name = isset($this->package) ? Str::lower($this->package->getSlashNamespace()) : ''; + } catch (Throwable) { + $name = ''; + } + + return str_ends_with($name, '/' . $slug) ? $name : 'ichava/' . $slug; + } + /** * @return array */ diff --git a/src/Services/ConfigurationService.php b/src/Services/ConfigurationService.php index 94a42e91..a9b1c86b 100644 --- a/src/Services/ConfigurationService.php +++ b/src/Services/ConfigurationService.php @@ -5,9 +5,9 @@ namespace Simtabi\Laranail\Ichava\Services; use Illuminate\Support\Str; -use Illuminate\Support\Facades\File; use Simtabi\Laranail\Ichava\Support\Helpers; use Simtabi\Laranail\Ichava\Support\PackConfigData; +use Simtabi\Laranail\Ichava\Support\PackConfigCache; use Simtabi\Laranail\Ichava\Exceptions\IchavaException; /** @@ -27,7 +27,7 @@ final class ConfigurationService * Constructor with dependency injection */ public function __construct( - private IconCacheService $cache, + private PackConfigCache $configCache, ) {} /** @@ -41,12 +41,8 @@ public function loadPackageConfig(string $directoryPath): array { $configPath = $this->resolveConfigPath($directoryPath); - if (! File::exists($configPath)) { - throw IchavaException::missingConfigFile($configPath); - } - - $json = File::get($configPath); - $config = $this->parseJson($json, $configPath); + // One config.json parser in core: Helpers::loadConfigJson() (C-04). + $config = Helpers::loadConfigJson($directoryPath); $this->validatePackageConfig($config, $configPath); return $config; @@ -59,11 +55,7 @@ public function loadPackageConfig(string $directoryPath): array */ public function rememberPackageConfig(string $directoryPath): array { - $cacheKey = $this->getCacheKey($directoryPath); - - $config = $this->cache->rememberConfig($cacheKey, function () use ($directoryPath) { - return $this->loadPackageConfig($directoryPath); - }); + $config = $this->configCache->remember($directoryPath, fn (): array => $this->loadPackageConfig($directoryPath)); return is_array($config) ? $this->stringKeyed($config, $this->resolveConfigPath($directoryPath)) : $this->loadPackageConfig($directoryPath); } @@ -73,15 +65,13 @@ public function rememberPackageConfig(string $directoryPath): array */ public function forgetPackageConfig(string $directoryPath): bool { - $cacheKey = $this->getCacheKey($directoryPath); - - return $this->cache->forgetConfig($cacheKey); + return $this->configCache->forget($directoryPath); } /** * Check if config has variants * - * Reads `metadata.data.variants` first, then the top-level `variants` key. + * Reads `metadata.data.variants`, the one place config.json declares them. * * @param array $config */ @@ -93,7 +83,7 @@ public function hasVariants(array $config): bool /** * Get variants from config * - * Reads `metadata.data.variants` first, then the top-level `variants` key. + * Reads `metadata.data.variants`, the one place config.json declares them. * * @param array $config * @@ -107,7 +97,7 @@ public function getVariants(array $config): array /** * Check if config has categories * - * Reads `metadata.data.categories` first, then the top-level `categories` key. + * Reads `metadata.data.categories`, the one place config.json declares them. * * @param array $config */ @@ -200,38 +190,4 @@ private function stringKeyed(array $config, string $configPath): array return $object; } - - /** - * Parse JSON with error handling - * - * @return array - */ - private function parseJson(string $json, string $configPath): array - { - $config = json_decode($json, true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw IchavaException::invalidConfig( - 'JSON parse error: ' . json_last_error_msg(), - $configPath, - ); - } - - if (! is_array($config)) { - throw IchavaException::invalidConfig( - 'Config must be a JSON object', - $configPath, - ); - } - - return $this->stringKeyed($config, $configPath); - } - - /** - * Generate cache key - */ - private function getCacheKey(string $directoryPath): string - { - return 'config:' . md5($directoryPath); - } } diff --git a/src/Services/DatabaseOperationsService.php b/src/Services/DatabaseOperationsService.php index 932b378a..7cd2fdb3 100644 --- a/src/Services/DatabaseOperationsService.php +++ b/src/Services/DatabaseOperationsService.php @@ -15,6 +15,7 @@ use Simtabi\Laranail\Ichava\Models\IconTerm; use Simtabi\Laranail\Ichava\Support\Helpers; use Simtabi\Laranail\Console\Tools\Support\FileSize; +use Simtabi\Laranail\Ichava\Contracts\DatabaseOperations; /** * DatabaseOperationsService @@ -27,7 +28,7 @@ * * Extracted from BaseDatabaseCommand for maximum reusability. */ -class DatabaseOperationsService +class DatabaseOperationsService implements DatabaseOperations { /** * Smart queue threshold - packages with fewer icons are seeded synchronously diff --git a/src/Services/IconBrowserService.php b/src/Services/IconBrowserService.php index 693d9525..e3019a65 100644 --- a/src/Services/IconBrowserService.php +++ b/src/Services/IconBrowserService.php @@ -6,6 +6,7 @@ use stdClass; use ArrayAccess; +use Illuminate\Support\Str; use InvalidArgumentException; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -21,6 +22,7 @@ use Illuminate\Database\Query\Builder as QueryBuilder; use Simtabi\Laranail\Ichava\Exceptions\IchavaException; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Simtabi\Laranail\Ichava\Contracts\DatabaseOperations; /** * Icon Browser Service @@ -34,7 +36,7 @@ public function __construct( private IconRegistry $registry, private IconCacheService $cacheManager, private IchavaLogger $logger, - private DatabaseOperationsService $database, + private DatabaseOperations $database, ) {} /** @@ -177,15 +179,23 @@ public function generateHelperCode(Icon $icon, string $class = 'w-6 h-6'): strin } /** - * The Blade tag a pack registered with `loadBladeComponent()`, read from Blade's - * live alias map so a pack that registered none never gets an invented tag. + * The Blade tag a pack registered, read from Blade's live alias map so a + * pack that registered none never gets an invented tag: the short + * `{package}-icon` while the short prefix is on, else the canonical + * `{vendor}-{package}::icon`. */ public function packComponentTag(Icon $icon): ?string { - $slug = strtolower(substr($icon->package, (int) strrpos('/' . $icon->package, '/'))); - $alias = "{$slug}-icon"; + $package = strtolower($icon->package); + $aliases = Blade::getClassComponentAliases(); - return array_key_exists($alias, Blade::getClassComponentAliases()) ? $alias : null; + foreach ([Str::afterLast($package, '/') . '-icon', str_replace('/', '-', $package) . '::icon'] as $alias) { + if (array_key_exists($alias, $aliases)) { + return $alias; + } + } + + return null; } /** diff --git a/src/Services/IconCacheService.php b/src/Services/IconCacheService.php index d823faf8..dbbe7cc7 100644 --- a/src/Services/IconCacheService.php +++ b/src/Services/IconCacheService.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Artisan; +use Simtabi\Laranail\Ichava\Support\PackConfigCache; use Simtabi\Laranail\Ichava\Constants\IchavaConstants; /** @@ -27,6 +28,7 @@ final class IconCacheService public function __construct( private IchavaLogger $logger, + private PackConfigCache $configCache, ) { // Use config() directly instead of injecting ConfigurationService $this->ttl = config()->integer('ichava.ichava-core.cache.ttl', IchavaConstants::DEFAULT_CACHE_TTL); @@ -82,7 +84,7 @@ public function clearAll(): array foreach (app(IconRegistry::class)->all() as $metadata) { $path = $metadata['base_path'] ?? null; - if (is_string($path) && app(ConfigurationService::class)->forgetPackageConfig($path)) { + if (is_string($path) && $this->configCache->forget($path)) { $cleared['configs']++; } } @@ -542,7 +544,10 @@ private static function satisfies(mixed $value, ?string $expects): bool } /** - * Get IconWatcherService instance (lazy loading to avoid circular dependency) + * The watcher, resolved on first use. It cannot be constructor-injected: + * the watcher needs the registry, the registry needs SvgDriver, and + * SvgDriver needs this service. The watcher no longer reaches back here + * through ConfigurationService (it forgets configs via PackConfigCache). */ private function watcher(): IconWatcherService { diff --git a/src/Services/IconDiscoveryService.php b/src/Services/IconDiscoveryService.php index 02b4fa52..cca418f2 100644 --- a/src/Services/IconDiscoveryService.php +++ b/src/Services/IconDiscoveryService.php @@ -15,11 +15,12 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Schema; use Simtabi\Laranail\Ichava\Models\Icon; -use Simtabi\Laranail\Ichava\Actions\CountSvgFiles; +use Simtabi\Laranail\Ichava\Support\SvgFileCounter; use Simtabi\Laranail\Ichava\Exceptions\IchavaException; use Simtabi\Laranail\Ichava\Actions\BuildIconUsageSyntax; use Simtabi\Laranail\Ichava\Actions\ClearDiscoveryCaches; use Simtabi\Laranail\Ichava\Actions\SearchIconsInDatabase; +use Simtabi\Laranail\Ichava\Support\DiscoveryCacheGeneration; use Simtabi\Laranail\Ichava\Actions\DiscoverInstalledPackages; /** @@ -49,7 +50,7 @@ public function __construct( protected IconRegistry $registry, protected IconCacheService $cache, protected DiscoverInstalledPackages $discoverInstalled, - protected CountSvgFiles $svgCounter, + protected SvgFileCounter $svgCounter, protected BuildIconUsageSyntax $buildUsageSyntax, protected SearchIconsInDatabase $searchDatabase, protected ClearDiscoveryCaches $clearCaches, @@ -275,7 +276,7 @@ public function getAllCategories(): array */ public function getIconSyntax(string $package, string $name, ?string $variant = null): array { - return ($this->buildUsageSyntax)($package, $this->getPackage($package) ?? [], $name, $variant); + return $this->buildUsageSyntax->handle($package, $this->getPackage($package) ?? [], $name, $variant); } /** @@ -325,7 +326,7 @@ public function clearCache(): void // -- it forgot two keys of six and could not reach two of the rest -- // and the keys and the clear now live in one object that owns the // question "what did we write?". - ($this->clearCaches)(); + $this->clearCaches->handle(); } /** @@ -356,7 +357,7 @@ public function getDiscoveryStats(): array protected function generation(): int { // One definition, on the action that owns invalidation. - return ClearDiscoveryCaches::generation(); + return DiscoveryCacheGeneration::current(); } /** @@ -376,7 +377,7 @@ protected function packages(): array */ protected function scanComposerLock(): array { - return ($this->discoverInstalled)(base_path('composer.lock')); + return $this->discoverInstalled->handle(base_path('composer.lock')); } /** @@ -452,7 +453,7 @@ protected function searchIconsFromDatabase( // Enrichment passes as closures rather than handing the action this // service: package metadata and usage syntax live here, and injecting // the service into its own collaborator would be a cycle. - return ($this->searchDatabase)( + return $this->searchDatabase->handle( query: $query, packages: $packages, categories: $categories, diff --git a/src/Services/IconPackUpdateChecker.php b/src/Services/IconPackUpdateChecker.php index 0385c3e5..17898e77 100644 --- a/src/Services/IconPackUpdateChecker.php +++ b/src/Services/IconPackUpdateChecker.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Event; +use Simtabi\Laranail\Ichava\Contracts\ChecksPackUpdates; use Simtabi\Laranail\Ichava\Constants\JsonConfigConstants; use Simtabi\Laranail\Ichava\Events\IconPackUpdateAvailable; @@ -41,7 +42,7 @@ * reason: ?string, * } */ -class IconPackUpdateChecker +class IconPackUpdateChecker implements ChecksPackUpdates { /** Cache key prefix for HTTP responses. */ protected const CACHE_PREFIX = 'ichava:icon-pack-update-check:'; diff --git a/src/Services/IconRegistry.php b/src/Services/IconRegistry.php index c2686cf3..480fafa3 100644 --- a/src/Services/IconRegistry.php +++ b/src/Services/IconRegistry.php @@ -8,20 +8,23 @@ use Throwable; use Illuminate\Support\Str; use Illuminate\Support\Facades\File; +use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Event; use Simtabi\Laranail\Ichava\Data\IconData; use Simtabi\Laranail\Ichava\Enums\IconKind; -use Simtabi\Laranail\Ichava\Support\Helpers; +use Simtabi\Laranail\Ichava\Data\BulkResult; +use Simtabi\Laranail\Ichava\Data\BulkOptions; use Simtabi\Laranail\Ichava\Enums\KindSource; use Simtabi\Laranail\Ichava\Drivers\SvgDriver; use Illuminate\Contracts\Foundation\Application; use Simtabi\Laranail\Ichava\Data\KindResolution; +use Simtabi\Laranail\Ichava\Data\PackDefinition; use Simtabi\Laranail\Ichava\Support\IconAliasMap; use Simtabi\Laranail\Ichava\Support\KindResolver; use Simtabi\Laranail\Ichava\Support\PathResolver; -use Simtabi\Laranail\Ichava\Actions\CountSvgFiles; -use Simtabi\Laranail\Ichava\Support\PackConfigData; +use Simtabi\Laranail\Ichava\Support\SvgFileCounter; use Simtabi\Laranail\Ichava\Support\IchavaRegistrar; +use Simtabi\Laranail\Ichava\Support\Pack\PackBuilder; use Simtabi\Laranail\Ichava\Contracts\IconSetInterface; use Simtabi\Laranail\Ichava\Exceptions\IchavaException; use Simtabi\Laranail\Ichava\Events\IconRegistrationEvent; @@ -83,6 +86,16 @@ final class IconRegistry */ private ?string $defaultSet = null; + /** + * The definition each set was registered from. + * + * @var array + */ + private array $definitions = []; + + /** The registrar id of the registerMany() run in progress, so its sets share it. */ + private ?string $bulkRegistrarId = null; + /** * Pending registrations (lazy loading) * @@ -101,6 +114,9 @@ public function __construct( private IchavaLogger $logger, private PathResolver $pathResolver, private IconAliasMap $aliases, + private IconCacheService $cache, + private Filesystem $files, + private SvgFileCounter $svgCounter, ) { $this->initializeBootstrap(); } @@ -127,33 +143,60 @@ public function package(string $name): RegistrationConfig } /** - * Register multiple icon set directories from a config array. - * - * Each entry may be a plain path string or an associative array with a 'path' key. - * Invalid or empty paths are silently skipped. Useful for registering custom icon - * sets declared in ichava.ichava-core.custom-icons.sets config. + * Register the sets `ichava.ichava-core.custom-icons.sets` declares. + * + * Each entry is a path, or an array of options keyed by the entry's name: + * + * - `path` (required): a directory holding a config.json, or a directory + * of SVGs with none; + * - `enabled` (default true): false skips the entry; + * - `package`: the `vendor/package` a set without a config.json registers + * as (default `app/{entry key}`); + * - `bulk` with `only`: register every config-bearing sub-directory of + * `path`, or only those named, through registerMany(); + * - every key {@see PackBuilder::withOptions()} reads (`prefix`, + * `default_class`, `default_attributes`, `fallback`, `variants`, + * `default_variant`, `supports_categories`, `blade_namespace`, + * `blade_component`, ...), applied over the set's config.json. * * @param array> $sets - * Example: ['/path/to/set', ['path' => '/other/set', 'label' => 'Custom']] */ public function registerFromConfig(array $sets): void { - foreach ($sets as $set) { - $path = is_array($set) ? ($set['path'] ?? null) : $set; + foreach ($sets as $key => $set) { + $options = is_array($set) ? $set : ['path' => $set]; + $path = $options['path'] ?? null; + + if (($options['enabled'] ?? true) === false || ! is_string($path) || $path === '') { + continue; + } + + if (($options['bulk'] ?? false) === true) { + $only = array_values(array_filter(is_array($options['only'] ?? null) ? $options['only'] : [], is_string(...))); - if (is_string($path) && $path !== '') { - $this->fromDirectory($path); + $this->registerMany( + array_map(fn (PackBuilder $pack): PackBuilder => $pack->withOptions($options), PackBuilder::discover($path, $only)), + new BulkOptions(name: 'custom-icons.' . $key), + ); + + continue; } + + $pack = is_file(rtrim($path, '/\\') . '/config.json') + ? PackBuilder::fromDirectory($path) + : PackBuilder::make(is_string($options['package'] ?? null) ? $options['package'] : 'app/' . Str::slug((string) $key), $path); + + $this->register($pack->withOptions($options)->build()); } } /** * Auto-register an icon set from a directory that contains a config.json file. * - * Reads `{$path}/config.json` to extract the package name, title, prefix, - * variants, categories, and metadata. Counts SVG files in `{$path}/files/` - * to populate the icon total. Immediately registers the set and returns a - * RegistrationConfig for optional further chaining. + * Reads `{$path}/config.json` and counts the SVGs under `{$path}/files/`. + * The same path as register(PackBuilder::fromDirectory($path)->build()), + * which is what this does; the returned RegistrationConfig is already + * registered, and chaining on it changes nothing. * * @param string $path Absolute path to the icon set directory (must contain config.json) * @param string|null $providerClass The service provider that registered this set (for attribution) @@ -162,85 +205,179 @@ public function registerFromConfig(array $sets): void */ public function fromDirectory(string $path, ?string $providerClass = null): RegistrationConfig { - // Load config.json using helper method (no ConfigurationService dependency!) - $configData = $this->loadPackageConfig($path); - $packageName = $this->requiredConfigString($configData, 'package', 'name', $path); - $iconPrefix = $this->requiredConfigString($configData, 'config', 'icon_prefix', $path); - - // Create icon set - $iconSet = IconSetBuilder::make($packageName) - ->setBasePath($path . '/files') - ->prefix($iconPrefix); - - // Add variants if present - if ($this->hasVariants($configData)) { - $variants = array_map(strval(...), array_keys($this->getVariants($configData))); - if (! empty($variants)) { - $iconSet->withVariants($variants); + $definition = PackBuilder::fromDirectory($path) + ->when($providerClass !== null, fn (PackBuilder $pack) => $pack->providedBy((string) $providerClass)) + ->build(); + + $this->register($definition); + + return (new RegistrationConfig($definition->name, $this))->markRegistered(); + } + + /** + * Register one icon set. Every way into the registry -- fromDirectory(), + * package(), the bulk registrar and `custom-icons` -- ends here, so a set + * carries the same metadata whichever door it came through. + * + * @param IconSetInterface|null $set a prebuilt set; null builds one from the definition + */ + public function register(PackDefinition $definition, ?IconSetInterface $set = null): void + { + if ($set === null) { + $set = new IconSetBuilder($definition->name, $this->cache, $this->files) + ->setBasePath($definition->filesPath) + ->prefix($definition->prefix); + + $variants = $definition->variantNames(); + + if ($variants !== []) { + $set->withVariants($variants); + } + + if ($definition->hasCategories) { + $set->withCategories(true); + } + + // `config.defaults` is the one source of a set's default class, + // attributes and fallback, so ``, a pack's own tag + // and render() draw the same markup (P-06). + $set->defaultAttributes($definition->defaultAttributes()); + + if ($definition->defaultClass() !== null) { + $set->defaultClass((string) $definition->defaultClass()); + } + + if ($definition->fallback() !== null) { + $set->fallback($definition->fallback()); + } + + if ($definition->defaultVariant !== null) { + $set->defaultVariant($definition->defaultVariant); } } - // Add categories if present - if ($this->hasCategories($configData)) { - $iconSet->withCategories(true); + $noted = $definition->providerClass === null ? null : ($this->bladeComponents[$definition->providerClass] ?? null); + + $this->registerIconSet( + $definition->name, + $set, + $definition->toMetadata($this->countIconsInDirectory($definition->filesPath), $noted), + ); + + $this->definitions[$definition->name] ??= $definition; + + $this->loadAliasFile($definition->name, $definition->basePath); + + if ($definition->aliases !== []) { + $this->aliases->register($definition->name, $definition->aliases); } + } - // Count icons - $totalIcons = $this->countIconsInDirectory($path . '/files'); - - // Build metadata - $metadata = [ - 'package_name' => $packageName, - 'name' => $this->configValue($configData, 'package', 'title'), - 'description' => $this->configValue($configData, 'package', 'description') ?? '', - 'vendor' => $this->getVendor($configData), - 'version' => $this->configValue($configData, 'package', 'version'), - 'license' => $this->configValue($configData, 'package', 'license') ?? 'Unknown', - 'homepage' => $this->configValue($configData, 'metadata', 'homepage'), - 'repository' => $this->configValue($configData, 'metadata', 'repository'), - 'keywords' => $this->configValue($configData, 'package', 'keywords') ?? [], - 'total' => $totalIcons, - 'base_path' => $path, - 'icon_set_name' => $packageName, - 'provider_class' => $providerClass, - 'blade_component' => $providerClass === null ? null : ($this->bladeComponents[$providerClass] ?? null), - 'prefix' => $iconPrefix, - // What the pack's SVGs are. Read here, from the one config.json - // read registration already makes, so the seeder and the browser - // never need a second reader of the same file. - 'kind' => (PackConfigData::packageKind($configData) ?? IconKind::Icon)->value, - // `kind` above is the pack's effective kind, `icon` when it names - // none. KindResolver needs to tell "declared icon" (row 5) from - // "declared nothing" (row 6), so the declaration is kept too. - 'declared_kind' => PackConfigData::packageKind($configData)?->value, - 'category_kinds' => array_map( - static fn (IconKind $kind): string => $kind->value, - PackConfigData::categoryKinds($configData), - ), - // The rest of the kind resolution order (D6 rows 2 and 3), plus - // what doctor reports about the kind keys, so KindResolver and - // doctor never re-read config.json either. - 'item_kinds' => array_map( - static fn (IconKind $kind): string => $kind->value, - PackConfigData::items($configData), - ), - 'kind_rules' => array_map( - static fn (array $rule): array => ['match' => $rule['match'], 'kind' => $rule['kind']->value], - PackConfigData::kindRules($configData), - ), - 'kind_issues' => PackConfigData::kindIssues($configData), - 'theme_variants' => PackConfigData::themeVariants($configData), - // The variant blocks as declared (name, description, attributes, - // preview icon, colour scheme, order), for VariantData. - 'variant_metadata' => array_filter(PackConfigData::variants($configData), is_array(...)), - ]; + /** + * Register several sets as one run (Proposal 3). + * + * Each item is a definition, or a builder built here so a broken + * config.json fails inside the run. Every set goes through register(), so + * a set registered in bulk is the same as one registered alone. One + * registrar id covers the run: `started`, then each set's `processing` and + * `registered` (or `failed`), then one `completed` with the counts. By + * default a broken set is reported and the rest register; a builder whose + * directory does not exist is skipped. + * + * @param iterable $definitions + */ + public function registerMany(iterable $definitions, ?BulkOptions $options = null): BulkResult + { + $options ??= new BulkOptions; + $items = is_array($definitions) ? array_values($definitions) : iterator_to_array($definitions, false); + $registrarId = Str::uuid()->toString(); + $startedAt = microtime(true); + $announce = ! $this->recentlyAnnounced('bulk:' . $options->name); + $registered = $skipped = $failed = []; - // Register immediately - $registration = $this->registerIconSet($packageName, $iconSet, $metadata); + if ($announce) { + Event::dispatch(IconRegistrationEvent::started($registrarId, $options->name, count($items), 'bulk')); + } + + $previous = $this->bulkRegistrarId; + $this->bulkRegistrarId = $registrarId; + + try { + foreach ($items as $item) { + $label = $item instanceof PackBuilder ? $item->basePath() : $item->name; - $this->loadAliasFile($packageName, $path); + if ($item instanceof PackBuilder && ! is_dir($item->basePath())) { + $skipped[] = $label; + + continue; + } + + $definition = $item instanceof PackDefinition ? $item : null; + + try { + $definition ??= $item instanceof PackBuilder ? $item->build() : $item; + $this->register($definition); + $registered[] = $definition->name; + } catch (Throwable $e) { + if ($definition === null) { + // A set that failed to build never reached + // registerIconSet(), which announces its own failures. + Event::dispatch(IconRegistrationEvent::failed($registrarId, $label, [], $e)); + } - return $registration; + if ($options->failFast) { + throw $e; + } + + $failed[$definition->name ?? $label] = $e->getMessage(); + $this->logger->warning("Bulk registration [{$options->name}] skipped a set that failed", ['set' => $label, 'error' => $e->getMessage()]); + } + } + } finally { + $this->bulkRegistrarId = $previous; + } + + $result = new BulkResult($registered, $skipped, $failed, round((microtime(true) - $startedAt) * 1000, 2)); + + if ($announce) { + $this->markAnnounced('bulk:' . $options->name); + Event::dispatch(IconRegistrationEvent::completed($registrarId, $options->name, $result->counts(), $result->durationMs)); + } + + if ($options->log) { + $this->logger->info(sprintf( + 'Bulk registration [%s]: registered %d set(s), skipped %d, failed %d.', + $options->name, + ...array_values($result->counts()), + )); + } + + return $result; + } + + /** + * The definition a set was registered from, or null when it was registered + * through registerIconSet() with prebuilt metadata. + */ + public function definition(string $packageName): ?PackDefinition + { + return $this->definitions[$packageName] ?? null; + } + + /** + * The definition registered from the set directory $basePath, or null. + */ + public function definitionAt(string $basePath): ?PackDefinition + { + $basePath = rtrim($basePath, '/\\'); + + foreach ($this->definitions as $definition) { + if (rtrim($definition->basePath, '/\\') === $basePath) { + return $definition; + } + } + + return null; } /** @@ -302,7 +439,7 @@ public function registerIconSet( // completed, or failed. Announced at most once per deduplication // window, as `registered` always was: the registry is rebuilt on every // request, and a listener must not hear the same pack every time. - $registrarId = Str::uuid()->toString(); + $registrarId = $this->bulkRegistrarId ?? Str::uuid()->toString(); $announce = ! $this->recentlyAnnounced($name); $startedAt = microtime(true); @@ -344,6 +481,10 @@ public function registerIconSet( if ($announce) { $this->markAnnounced($name); Event::dispatch(IconRegistrationEvent::registered($registrarId, $name, $metadata, 0)); + } + + // A bulk run announces one `completed` for all its sets. + if ($announce && $this->bulkRegistrarId === null) { Event::dispatch(IconRegistrationEvent::completed( $registrarId, $name, @@ -686,6 +827,7 @@ public function unregister(string $packageName): bool unset( $this->packages[$packageName], $this->sets[$iconSetName], + $this->definitions[$packageName], $this->pending[$packageName], $this->pending[$iconSetName], ); @@ -771,9 +913,9 @@ public function getConflicts(): array public function countIconsInDirectory(string $path): int { // One counter for the whole package. This method used to carry its own - // copy, catching Exception where CountSvgFiles learned to catch + // copy, catching Exception where SvgFileCounter learned to catch // Throwable -- so the failure that action was fixed for still escaped here. - return app(CountSvgFiles::class)->recursively($path); + return $this->svgCounter->recursively($path); } /** @@ -1148,108 +1290,6 @@ private function strictConflicts(): bool return (bool) config('ichava.ichava-core.registry.strict_conflicts', false); } - /** - * Read and parse the config.json file from an icon set directory. - * - * Delegates to Helpers::loadConfigJson(). Throws if the file is missing - * or cannot be parsed as valid JSON. - * - * @param string $directoryPath Absolute path to the icon set directory - * - * @return array Parsed config data - * - * @throws IchavaException via Helpers::loadConfigJson() on missing/invalid config - */ - private function loadPackageConfig(string $directoryPath): array - { - return Helpers::loadConfigJson($directoryPath); - } - - /** - * Whether the parsed config.json declares any icon variants. - * - * Reads `metadata.data.variants` first, then the top-level `variants` key. - * - * @param array $config Parsed config.json data - */ - private function hasVariants(array $config): bool - { - return PackConfigData::variants($config) !== []; - } - - /** - * Extract the variants map from parsed config.json data. - * - * @param array $config Parsed config.json data - * - * @return array Variants keyed by variant name - */ - private function getVariants(array $config): array - { - return PackConfigData::variants($config); - } - - /** - * Whether the parsed config.json declares any icon categories. - * - * Reads `metadata.data.categories` first, then the top-level `categories` key. - * - * @param array $config Parsed config.json data - */ - private function hasCategories(array $config): bool - { - return PackConfigData::categories($config) !== []; - } - - /** - * Extract the vendor name from the package name in config.json. - * - * Delegates to Helpers::getVendorFromPackage() which splits on `/`. - * - * @param array $config Parsed config.json data - * - * @return string Vendor name (e.g. 'ichava' from 'ichava/icon-sets-tabler') - */ - private function getVendor(array $config): string - { - $packageName = $this->configValue($config, 'package', 'name'); - - return Helpers::getVendorFromPackage(is_string($packageName) ? $packageName : ''); - } - - /** - * One key of one section of a parsed config.json, or null when either is absent. - * - * @param array $config Parsed config.json data - */ - private function configValue(array $config, string $section, string $key): mixed - { - $values = $config[$section] ?? null; - - return is_array($values) ? ($values[$key] ?? null) : null; - } - - /** - * A config.json value registration cannot proceed without. - * - * @param array $config Parsed config.json data - * - * @throws IchavaException when the value is absent or not a string - */ - private function requiredConfigString(array $config, string $section, string $key, string $directoryPath): string - { - $value = $this->configValue($config, $section, $key); - - if (! is_string($value)) { - throw IchavaException::invalidConfig( - "config.json must declare {$section}.{$key} as a string", - Str::finish($directoryPath, '/') . 'config.json', - ); - } - - return $value; - } - /** * How long registration logs and events are de-duplicated, in seconds. * diff --git a/src/Services/IconSetBuilder.php b/src/Services/IconSetBuilder.php index a0b6e959..207d6433 100644 --- a/src/Services/IconSetBuilder.php +++ b/src/Services/IconSetBuilder.php @@ -27,50 +27,53 @@ * * @phpstan-consistent-constructor */ -class IconSetBuilder implements IconSetInterface +final class IconSetBuilder implements IconSetInterface { // From IchavaSet (base functionality) - protected Filesystem $files; + private Filesystem $files; - protected IconCacheService $cache; + private IconCacheService $cache; - protected IconSetConfig $config; + private IconSetConfig $config; /** @var array */ - protected array $discoveredIcons = []; + // @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) - protected string $name; + private string $name; /** @var string|list|null */ - protected string|array|null $paths = null; + private string|array|null $paths = null; - protected ?string $prefix = null; + private ?string $prefix = null; - protected ?string $defaultVariant = null; + private ?string $defaultVariant = null; /** @var array */ - protected array $variants = []; + private array $variants = []; - protected bool $supportsCategories = false; + private bool $supportsCategories = false; - protected string $defaultClass = 'icon'; + private string $defaultClass = 'icon'; /** @var array */ - protected array $defaultAttributes = []; + private array $defaultAttributes = []; - protected ?string $fallback = null; + private ?string $fallback = null; - protected bool $enableCache = false; + // @phpstan-ignore property.onlyWritten (written and never read since before 2026-11-05; listed for an owner decision, not removed) + private bool $enableCache = false; /** @var IconSetInterface[] Merged icon sets */ - protected array $mergedSets = []; + private array $mergedSets = []; /** @var bool Whether this is a merged set */ - protected bool $isMerged = false; + private bool $isMerged = false; /** @var bool Whether the parent has been initialized */ - protected bool $initialized = false; + // @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 @@ -89,7 +92,7 @@ public function __construct( $this->files = $nameOrFiles; if (! $cache) { - throw IchavaException::dependencyNotInjected('IconCacheService', static::class); + throw IchavaException::dependencyNotInjected('IconCacheService', self::class); } $this->cache = $cache; $this->config = $this->buildConfig(); @@ -98,12 +101,12 @@ public function __construct( $this->name = $nameOrFiles; if (! $files) { - throw IchavaException::dependencyNotInjected('Filesystem', static::class); + throw IchavaException::dependencyNotInjected('Filesystem', self::class); } $this->files = $files; if (! $cache) { - throw IchavaException::dependencyNotInjected('IconCacheService', static::class); + throw IchavaException::dependencyNotInjected('IconCacheService', self::class); } $this->cache = $cache; } @@ -116,7 +119,7 @@ public function __construct( */ public static function make(string $name): static { - return new static( + return new self( $name, app(IconCacheService::class), app(Filesystem::class), @@ -147,7 +150,7 @@ public static function makeFromClass( int $levelsUp = 3, string $append = '', ): static { - $instance = new static($name); + $instance = new self($name); if ($append) { $path = PathResolver::resolvePackagePath($class, $levelsUp, $append); @@ -521,7 +524,7 @@ public function variants(): array * Build the icon set configuration * Can be overridden by extending classes */ - protected function buildConfig(): IconSetConfig + private function buildConfig(): IconSetConfig { // If this is a merged set, use first set's config as base if ($this->isMerged && ! empty($this->mergedSets)) { @@ -551,7 +554,7 @@ protected function buildConfig(): IconSetConfig /** * Build merged configuration from multiple sets */ - protected function buildMergedConfig(): IconSetConfig + private function buildMergedConfig(): IconSetConfig { $baseConfig = $this->mergedSets[0]->config(); @@ -586,7 +589,7 @@ protected function buildMergedConfig(): IconSetConfig /** * Discover a single SVG icon */ - protected function discoverIcon(string $name, ?string $variant, ?string $category): ?IconData + private function discoverIcon(string $name, ?string $variant, ?string $category): ?IconData { $path = $this->buildIconPath($name, $variant, $category); @@ -620,7 +623,7 @@ protected function discoverIcon(string $name, ?string $variant, ?string $categor * * @return array */ - protected function discoverAllIcons(?string $variant, ?string $category): array + private function discoverAllIcons(?string $variant, ?string $category): array { $searchPath = $this->buildSearchPath($variant, $category); $files = $this->files->glob($searchPath . '/*.svg'); @@ -647,7 +650,7 @@ protected function discoverAllIcons(?string $variant, ?string $category): array /** * Build SVG icon file path */ - protected function buildIconPath(string $name, ?string $variant, ?string $category): string + private function buildIconPath(string $name, ?string $variant, ?string $category): string { $parts = [$this->basePath()]; @@ -667,7 +670,7 @@ protected function buildIconPath(string $name, ?string $variant, ?string $catego /** * Build search path for discovery */ - protected function buildSearchPath(?string $variant, ?string $category): string + private function buildSearchPath(?string $variant, ?string $category): string { $parts = [$this->basePath()]; @@ -687,7 +690,7 @@ protected function buildSearchPath(?string $variant, ?string $category): string * * @return array */ - protected function discoverCategories(?string $variant): array + private function discoverCategories(?string $variant): array { $searchPath = $this->buildSearchPath($variant, null); @@ -709,7 +712,7 @@ protected function discoverCategories(?string $variant): array /** * Verify file is an SVG */ - protected function isSvgFile(string $path): bool + private function isSvgFile(string $path): bool { $extension = pathinfo($path, PATHINFO_EXTENSION); @@ -725,7 +728,7 @@ protected function isSvgFile(string $path): bool /** * Cache key for one icon. */ - protected function getCacheKey(string $name, ?string $variant, ?string $category): string + private function getCacheKey(string $name, ?string $variant, ?string $category): string { return $this->namespacedCacheKey('icon', $name, $variant, $category); } @@ -739,7 +742,7 @@ protected function getCacheKey(string $name, ?string $variant, ?string $category * the wrong shape -- a map of icon arrays where get() expects one icon array, * which fromArray() then reads as a single malformed icon. */ - protected function getAllCacheKey(?string $variant, ?string $category): string + private function getAllCacheKey(?string $variant, ?string $category): string { return $this->namespacedCacheKey('set', 'all', $variant, $category); } diff --git a/src/Services/IconSvgReader.php b/src/Services/IconSvgReader.php new file mode 100644 index 00000000..b14c74c7 --- /dev/null +++ b/src/Services/IconSvgReader.php @@ -0,0 +1,150 @@ +cache->remember( + $cacheKey, + function () use ($path, $storedPath, $kind, $package, $baseDir): string { + /* + * The kind's byte budget is enforced at seed time, but the + * file is read again here, on every cache miss -- and it may + * have been replaced or grown since. SvgDriver::load() is the + * one safe reader (C-12): it refuses a symlink, an escape from + * the pack's directory, a file under the minimum size and one + * over this kind's budget, before reading (S2). A refusal + * fails closed, as a sanitiser rejection does. + */ + try { + $raw = $this->driver->load($path, baseDir: $baseDir, maxBytes: $kind->maxFileSize()); + } catch (IchavaException $e) { + $this->audit->warning('svg.sanitiser_rejected', [ + 'path' => $storedPath, + 'package' => $package, + 'reason' => $e->getMessage(), + ]); + + return ''; + } + + try { + $svg = $this->processor; + + /* + * Namespace ids before sanitising, and inside the cache, + * so it costs one pass per icon per cache lifetime. + * + * SVG ids are page-scoped in practice. 2,157 files in the + * corpus share `_Transparent_Rectangle_` and 261 share + * `Layer_1`; render two of them together and the second + * icon's `url(#Layer_1)` resolves to the first one's + * definition, silently. The browser renders 60+ at once. + * The prefix is derived from the path, not the content, so + * it survives an upstream refresh and stays deterministic + * for the content-addressed cache (CR1). + */ + $raw = $svg->namespaceIds($raw, $storedPath); + + /* + * Hand sizing back to the component. An icon shipping + * both a viewBox and hard width/height ignores the size + * it is asked for; 6,146 tabler icons alone ship that + * pair. An icon with neither is + * reported rather than dropped -- it still renders, it + * just has no intrinsic size to scale against. + */ + $raw = $svg->normaliseSizing($raw, function (string $reason) use ($storedPath, $package): void { + $this->audit->warning('svg.unusable_sizing', [ + 'path' => $storedPath, + 'package' => $package, + 'reason' => $reason, + ]); + }); + + /* + * Opt-in (B7): a monochrome icon that hardcodes black + * vanishes on a dark background. Off by default; the flag + * is part of render_version, so turning it on re-keys this + * cache entry by itself. + */ + if ($svg->convertsMonochromeToCurrentColor()) { + $raw = $svg->monochromeToCurrentColor($raw); + } + + return $svg->process($raw, [], false); + } catch (Throwable $e) { + /* + * A file the sanitiser rejects is not served raw as a + * fallback. Failing closed is the point: the rejection means + * it could not be made safe. + */ + $this->audit->warning('svg.sanitiser_rejected', [ + 'path' => $storedPath, + 'package' => $package, + 'reason' => $e->getMessage(), + ]); + + return ''; + } + }, + expects: 'string', + ); + + return is_string($content) ? $content : null; + } +} diff --git a/src/Services/IconWatcherService.php b/src/Services/IconWatcherService.php index 5419b5d5..d98aad58 100644 --- a/src/Services/IconWatcherService.php +++ b/src/Services/IconWatcherService.php @@ -19,6 +19,7 @@ use Simtabi\Laranail\Ichava\Enums\KindSource; use Simtabi\Laranail\Ichava\Events\IconCacheEvent; use Simtabi\Laranail\Ichava\Support\IconClassifier; +use Simtabi\Laranail\Ichava\Support\PackConfigCache; use Simtabi\Laranail\Ichava\Traits\ContainsFilePaths; use Simtabi\Laranail\Ichava\Exceptions\IchavaException; use Symfony\Component\Finder\SplFileInfo as FinderFile; @@ -30,7 +31,7 @@ * Supports both single-set and multi-set layouts via IconPathStructureDetector; * stored paths are always relative for cross-environment portability. */ -class IconWatcherService +final class IconWatcherService { use ContainsFilePaths; @@ -47,6 +48,7 @@ class IconWatcherService public function __construct( protected IconRegistry $registry, protected IchavaLogger $logger, + protected PackConfigCache $configCache, protected ?IconClassifier $classifier = null, ) {} @@ -489,7 +491,7 @@ protected function syncPackage(string $package, array $metadata): array // A pack's config.json may have changed with its files (a category // re-filed as illustrations, a new theme twin); drop the cached copy // so the next read sees it. - app(ConfigurationService::class)->forgetPackageConfig($basePath); + $this->configCache->forget($basePath); // Scan file system $diskIcons = $this->scanDiskIcons($basePath, $package); diff --git a/src/Services/InformationService.php b/src/Services/InformationService.php index bdd77406..31e63634 100644 --- a/src/Services/InformationService.php +++ b/src/Services/InformationService.php @@ -14,6 +14,7 @@ use Simtabi\Laranail\Ichava\Support\Coerce; use Simtabi\Laranail\Ichava\Data\StatusData; use Simtabi\Laranail\Ichava\Constants\IchavaConstants; +use Simtabi\Laranail\Ichava\Contracts\DatabaseOperations; use Simtabi\Laranail\Ichava\Listeners\RecordCacheRebuild; /** @@ -27,15 +28,15 @@ * * Extracted from BaseInformationCommand and IchavaStatusCommand for maximum reusability. */ -class InformationService +final class InformationService { public function __construct( - protected IconRegistry $registry, - protected IchavaLifecycleManager $lifecycle, - protected DatabaseOperationsService $databaseService, - protected IchavaLogger $logger, - protected IconDiscoveryService $discovery, - protected SvgProcessingService $svg, + private IconRegistry $registry, + private IchavaLifecycleManager $lifecycle, + private DatabaseOperations $databaseService, + private IchavaLogger $logger, + private IconDiscoveryService $discovery, + private SvgProcessingService $svg, ) {} /** diff --git a/src/Services/RegistrationConfig.php b/src/Services/RegistrationConfig.php index 100360f4..1bcb9a16 100644 --- a/src/Services/RegistrationConfig.php +++ b/src/Services/RegistrationConfig.php @@ -7,6 +7,8 @@ use Closure; use Throwable; use Simtabi\Laranail\Ichava\Support\Helpers; +use Simtabi\Laranail\Ichava\Data\PackDefinition; +use Simtabi\Laranail\Ichava\Support\Pack\PackBuilder; use Simtabi\Laranail\Ichava\Contracts\IconSetInterface; use Simtabi\Laranail\Ichava\Exceptions\IchavaException; @@ -15,48 +17,51 @@ * * Fluent API for configuring icon package registration. */ -class RegistrationConfig +final class RegistrationConfig { - protected string $name; + private string $name; - protected IconRegistry $registry; + private IconRegistry $registry; - protected bool $registered = false; + private bool $registered = false; // Configuration - protected ?string $path = null; + private ?string $path = null; - protected ?string $prefix = null; + private ?string $prefix = null; - protected ?string $displayName = null; + private ?string $displayName = null; - protected ?string $vendor = null; + private ?string $vendor = null; - protected ?string $description = null; + private ?string $description = null; - protected ?string $version = null; + private ?string $version = null; + + /** The directory of the config.json fromConfigFile() read, whose whole config the set registers with. */ + private ?string $configDirectory = null; /** @var array */ - protected array $variants = []; + private array $variants = []; /** @var array */ - protected array $categories = []; + private array $categories = []; - protected ?string $providerClass = null; + private ?string $providerClass = null; - protected ?IconSetInterface $iconSet = null; + private ?IconSetInterface $iconSet = null; /** @var array */ - protected array $metadata = []; + private array $metadata = []; // Callbacks - protected ?Closure $onBefore = null; + private ?Closure $onBefore = null; - protected ?Closure $onAfter = null; + private ?Closure $onAfter = null; - protected ?Closure $onSuccess = null; + private ?Closure $onSuccess = null; - protected ?Closure $onError = null; + private ?Closure $onError = null; public function __construct(string $name, IconRegistry $registry) { @@ -75,6 +80,7 @@ public function fromConfigFile(string $path): static { // Load config.json using centralized helper $config = Helpers::loadConfigJson(dirname($path)); + $this->configDirectory = dirname($path); $this->path = dirname($path) . '/files'; $this->prefix = self::configString($config, 'config', 'icon_prefix'); @@ -223,38 +229,10 @@ public function execute(): static "no icon directory was given for '{$this->name}'", ); - // Build icon set if not provided - if ($this->iconSet === null) { - $this->iconSet = IconSetBuilder::make($this->name) - ->setBasePath($path) - ->prefix($this->prefix ?? $this->name); - - if (! empty($this->variants)) { - $this->iconSet->withVariants($this->variants); - } - - if (! empty($this->categories)) { - $this->iconSet->withCategories(true); - } - } - - // Build metadata - $metadata = array_merge([ - 'package_name' => $this->name, - 'name' => $this->displayName ?? $this->name, - 'description' => $this->description ?? '', - 'vendor' => $this->vendor ?? '', - 'version' => $this->version ?? '1.0.0', - 'base_path' => $this->path, - 'icon_set_name' => $this->name, - 'provider_class' => $this->providerClass, - 'prefix' => $this->prefix ?? $this->name, - 'total' => $this->registry->countIconsInDirectory($path), - ], $this->metadata); - - // Register - $this->registry->registerIconSet($this->name, $this->iconSet, $metadata); - + // One registration path (C-03): this builds the same definition + // fromDirectory() does, from the fields set on this object, so a + // set registered here carries the full metadata. + $this->registry->register($this->definition($path), $this->iconSet); $this->registered = true; // Fire onSuccess @@ -278,13 +256,6 @@ public function execute(): static return $this; } - /** - * Mark as registered (internal use). - * - * Returns the config so the call can sit in a `return` -- it returned void - * until IconRegistry::registerIconSet() did exactly that, and a second - * registration of one set in a request threw a TypeError. - */ public function markRegistered(): static { $this->registered = true; @@ -320,4 +291,37 @@ private static function configString(array $config, string $section, string $key return is_string($value) ? $value : ''; } + + /** + * Mark as registered (internal use). + * + * Returns the config so the call can sit in a `return` -- it returned void + * until IconRegistry::registerIconSet() did exactly that, and a second + * registration of one set in a request threw a TypeError. + */ + /** + * The definition this configuration describes. $path is the directory of + * SVGs; when it is named `files`, its parent is the set's base directory. + */ + private function definition(string $path): PackDefinition + { + $pack = $this->configDirectory !== null + ? PackBuilder::fromDirectory($this->configDirectory)->named($this->name) + : PackBuilder::make($this->name, $path); + + foreach ($this->variants as $variant) { + $pack->variant((string) $variant); + } + + return $pack + ->prefix($this->prefix ?? $this->name) + ->about('title', $this->displayName ?? $this->name) + ->when($this->description !== null, fn (PackBuilder $pack) => $pack->about('description', (string) $this->description)) + ->when($this->version !== null, fn (PackBuilder $pack) => $pack->about('version', (string) $this->version)) + ->when($this->categories !== [], fn (PackBuilder $pack) => $pack->categories()) + ->when($this->providerClass !== null, fn (PackBuilder $pack) => $pack->providedBy((string) $this->providerClass)) + ->when($this->vendor !== null && $this->vendor !== '', fn (PackBuilder $pack) => $pack->metadata(['vendor' => $this->vendor])) + ->metadata($this->metadata) + ->build(); + } } diff --git a/src/Support/DiscoveryCacheGeneration.php b/src/Support/DiscoveryCacheGeneration.php new file mode 100644 index 00000000..63b3186f --- /dev/null +++ b/src/Support/DiscoveryCacheGeneration.php @@ -0,0 +1,37 @@ + 'fa']`). An empty map + * registers every such sub-directory. * - * @see IconRegistry - * @see ServiceProvider + * ```php + * IchavaRegistrar::register('acme/icons') + * ->basePath($svg) + * ->trackStatistics() + * ->enableLogging() + * ->configure(['fontawesome' => ['prefix' => 'fa'], 'bootstrap' => []]); + * ``` */ final class IchavaRegistrar { @@ -24,13 +34,13 @@ final class IchavaRegistrar private string $basePath = ''; - // @phpstan-ignore property.onlyWritten (set by the fluent API and kept; nothing reads it yet) + /** Prepended to each set's directory name to form its prefix, when the set names none. */ private string $packagePrefix = ''; - // @phpstan-ignore property.onlyWritten (set by the fluent API and kept; nothing reads it yet) + /** Appended to each set's directory name to form its prefix, when the set names none. */ private string $iconSetSuffix = ''; - // @phpstan-ignore property.onlyWritten (set by the fluent API and kept; nothing reads it yet) + /** The Blade namespace each set's component alias is recorded under: `{namespace}-{set}`. */ private string $bladeNamespace = ''; private string $providerClass = ''; @@ -40,15 +50,11 @@ final class IchavaRegistrar /** @var array> */ private array $iconSets = []; - // @phpstan-ignore property.onlyWritten (set by the fluent API and kept; nothing reads it yet) private bool $trackStats = false; - // @phpstan-ignore property.onlyWritten (set by the fluent API and kept; nothing reads it yet) private bool $logging = false; - private int $registered = 0; - - private int $skipped = 0; + private ?BulkResult $result = null; private function __construct(string $name) { @@ -56,22 +62,13 @@ private function __construct(string $name) } /** - * Start building a registrar for the given bundle name. - * - * @param string $name A unique identifier for this icon bundle (e.g. 'icons-bundle'). + * Start a bulk registration, named for the events and logs it produces. */ public static function register(string $name): static { return new self($name); } - /** - * Absolute path to the directory that contains the icon-set sub-directories. - * - * Use PathResolver::svgPathFromIconSet() to resolve this from a relative path: - * - * ->basePath(PathResolver::svgPathFromIconSet('platform/icons-bundle/assets')) - */ public function basePath(string $path): static { $this->basePath = $path; @@ -79,12 +76,6 @@ public function basePath(string $path): static return $this; } - /** - * Optional prefix for icon set namespacing (e.g. 'icons-bundle'). - * - * Extensibility hook: stored on the registrar and available inside a custom - * registerIconSet() override. Not used by the default implementation. - */ public function packagePrefix(string $prefix): static { $this->packagePrefix = $prefix; @@ -92,12 +83,6 @@ public function packagePrefix(string $prefix): static return $this; } - /** - * Optional suffix appended to each icon set namespace (e.g. '-bundle'). - * - * Extensibility hook: stored on the registrar and available inside a custom - * registerIconSet() override. Not used by the default implementation. - */ public function iconSetSuffix(string $suffix): static { $this->iconSetSuffix = $suffix; @@ -105,14 +90,6 @@ public function iconSetSuffix(string $suffix): static return $this; } - /** - * Blade namespace prefix for icon components (e.g. 'icons-bundle'). - * - * Extensibility hook: stored on the registrar and available inside a custom - * registerIconSet() override. Not used by the default implementation, which - * only calls IconRegistry::fromDirectory(). Override registerIconSet() to - * act on this value (e.g. to call Blade::component() per icon set). - */ public function bladeNamespace(string $namespace): static { $this->bladeNamespace = $namespace; @@ -120,12 +97,6 @@ public function bladeNamespace(string $namespace): static return $this; } - /** - * The FQCN of the ServiceProvider calling this registrar. - * Passed to IconRegistry for source-tracking. Defaults to IchavaRegistrar::class. - * - * ->providerClass(self::class) - */ public function providerClass(string $class): static { $this->providerClass = $class; @@ -133,9 +104,7 @@ public function providerClass(string $class): static return $this; } - /** - * Human-readable vendor label (used in logging output). - */ + /** A label for the vendor of the sets, carried in the log line. */ public function vendor(string $vendor): static { $this->vendor = $vendor; @@ -144,17 +113,10 @@ public function vendor(string $vendor): static } /** - * Provide the icon sets array and trigger registration immediately. - * - * Array format: [ 'dir-name' => ['name' => '...', 'prefix' => '...'], ... ] - * - * Each key is a sub-directory name under basePath. Missing directories - * are skipped automatically. + * Register the sets. Values are each set's overrides; an empty map + * registers every config-bearing sub-directory of the base path. * - * This MUST be called to trigger processing, call it after all - * configuration methods, then chain trackStatistics()/enableLogging(). - * - * @param array> $iconSets + * @param array> $iconSets directory name => overrides */ public function configure(array $iconSets): static { @@ -164,10 +126,7 @@ public function configure(array $iconSets): static return $this; } - /** - * Enable registration statistics (registered count, skipped count). - * Totals are included in the log output when enableLogging() is also called. - */ + /** Keep the run's BulkResult, for statistics(). */ public function trackStatistics(): static { $this->trackStats = true; @@ -176,65 +135,68 @@ public function trackStatistics(): static } /** - * Log registration results to the 'ichava' log channel. - * Call after configure() so the counts are already populated. + * Log the run's counts. Called before configure() the registry logs the + * run; called after, the result already recorded is logged now. */ public function enableLogging(): static { - $this->logging = true; - - if ($this->registered > 0 || $this->skipped > 0) { - $message = sprintf( - 'IchavaRegistrar [%s] (%s): registered %d set(s), skipped %d.', - $this->name, - $this->vendor ?: 'unknown vendor', - $this->registered, - $this->skipped, - ); - - app(IchavaLogger::class)->debug($message); + if (! $this->logging && $this->result !== null) { + app(IchavaLogger::class)->info($this->summary($this->result)); } + $this->logging = true; + return $this; } - /** - * Iterate over configured icon sets and register each valid directory. - */ + /** The run's counts, when trackStatistics() was called; null otherwise or before configure(). */ + public function statistics(): ?BulkResult + { + return $this->trackStats ? $this->result : null; + } + private function process(): void { - if (! File::isDirectory($this->basePath)) { + if ($this->basePath === '' || ! is_dir($this->basePath) || ! app()->bound(IconRegistry::class)) { return; } - foreach ($this->iconSets as $dirName => $meta) { - $path = rtrim($this->basePath, '/\\') . DIRECTORY_SEPARATOR . $dirName; + $root = rtrim($this->basePath, '/\\'); + $builders = $this->iconSets === [] + ? PackBuilder::discover($root) + : array_map( + fn (string $set): PackBuilder => PackBuilder::fromDirectory($root . DIRECTORY_SEPARATOR . $set), + array_map(strval(...), array_keys($this->iconSets)), + ); - if (! File::isDirectory($path)) { - $this->skipped++; + foreach ($builders as $builder) { + $this->shape($builder, basename($builder->basePath())); + } - continue; - } + $this->result = app(IconRegistry::class)->registerMany($builders, new BulkOptions(name: $this->name)); - $this->registerIconSet($path); - $this->registered++; + if ($this->logging) { + app(IchavaLogger::class)->info($this->summary($this->result)); } } - /** - * Delegate a single directory to IconRegistry::fromDirectory(). - * No-ops gracefully if IconRegistry is not yet bound (should not happen - * when called from boot(), but safe-guards against mis-use in register()). - */ - private function registerIconSet(string $path): void + /** Apply the registrar-wide settings, then the set's own overrides, which win. */ + private function shape(PackBuilder $builder, string $set): void { - if (! app()->bound(IconRegistry::class)) { - return; - } + $builder + ->providedBy($this->providerClass !== '' ? $this->providerClass : self::class) + ->when($this->packagePrefix !== '' || $this->iconSetSuffix !== '', fn (PackBuilder $pack) => $pack->prefix($this->packagePrefix . $set . $this->iconSetSuffix)) + ->when($this->bladeNamespace !== '', fn (PackBuilder $pack) => $pack->component("{$this->bladeNamespace}-{$set}")) + ->withOptions($this->iconSets[$set] ?? []); + } - app(IconRegistry::class)->fromDirectory( - $path, - $this->providerClass ?: self::class, + private function summary(BulkResult $result): string + { + return sprintf( + 'IchavaRegistrar [%s] (%s): registered %d set(s), skipped %d, failed %d.', + $this->name, + $this->vendor !== '' ? $this->vendor : 'unknown vendor', + ...array_values($result->counts()), ); } } diff --git a/src/Support/IchavaSessionManager.php b/src/Support/IchavaSessionManager.php index 8f361be7..4312dea7 100644 --- a/src/Support/IchavaSessionManager.php +++ b/src/Support/IchavaSessionManager.php @@ -31,14 +31,14 @@ final class IchavaSessionManager implements PreferenceStore private bool $sessionAvailable; - public function __construct() + public function __construct(private readonly IchavaLogger $logger) { $this->capabilities = HostCapabilities::getInstance(); $this->sessionAvailable = $this->capabilities->hasSession(); // Detect session availability once per request if (! $this->sessionAvailable && config('app.debug')) { - app(IchavaLogger::class)->debug('ℹ️ Session not available, using browser storage fallback'); + $this->logger->debug('ℹ️ Session not available, using browser storage fallback'); } } @@ -62,12 +62,12 @@ public function put(string $key, mixed $value): bool session()->put($sessionKey, $value); if (config('app.debug') && config('ichava.ichava-core.logging.session_debug', false)) { - app(IchavaLogger::class)->debug("Session stored: {$key}", ['value' => $value]); + $this->logger->debug("Session stored: {$key}", ['value' => $value]); } return true; } catch (Exception $e) { - app(IchavaLogger::class)->warning('Failed to store in session', [ + $this->logger->warning('Failed to store in session', [ 'key' => $key, 'error' => $e->getMessage(), ]); @@ -93,12 +93,12 @@ public function get(string $key, mixed $default = null): mixed $value = session()->get($sessionKey, $default); if (config('app.debug') && config('ichava.ichava-core.logging.session_debug', false)) { - app(IchavaLogger::class)->debug("Session retrieved: {$key}", ['value' => $value]); + $this->logger->debug("Session retrieved: {$key}", ['value' => $value]); } return $value; } catch (Exception $e) { - app(IchavaLogger::class)->warning('Failed to retrieve from session', [ + $this->logger->warning('Failed to retrieve from session', [ 'key' => $key, 'error' => $e->getMessage(), ]); @@ -121,7 +121,7 @@ public function has(string $key): bool return session()->has($sessionKey); } catch (Exception $e) { - app(IchavaLogger::class)->warning('Failed to check session', [ + $this->logger->warning('Failed to check session', [ 'key' => $key, 'error' => $e->getMessage(), ]); @@ -145,7 +145,7 @@ public function forget(string $key): bool return true; } catch (Exception $e) { - app(IchavaLogger::class)->warning('Failed to forget session key', [ + $this->logger->warning('Failed to forget session key', [ 'key' => $key, 'error' => $e->getMessage(), ]); @@ -191,7 +191,7 @@ public function all(): array return $ichavaPrefs; } catch (Exception $e) { - app(IchavaLogger::class)->warning('Failed to retrieve all session data', [ + $this->logger->warning('Failed to retrieve all session data', [ 'error' => $e->getMessage(), ]); @@ -224,7 +224,7 @@ public function clear(): bool return true; } catch (Exception $e) { - app(IchavaLogger::class)->warning('Failed to clear session', [ + $this->logger->warning('Failed to clear session', [ 'error' => $e->getMessage(), ]); diff --git a/src/Support/Pack/PackBuilder.php b/src/Support/Pack/PackBuilder.php new file mode 100644 index 00000000..dfa6f595 --- /dev/null +++ b/src/Support/Pack/PackBuilder.php @@ -0,0 +1,605 @@ +register( + * PackBuilder::make('acme/social', storage_path('icons/social/files')) + * ->prefix('social') + * ->variant('outline', ['fill' => 'none', 'stroke' => 'currentColor']) + * ->build(), + * ); + * ``` + * + * @see IconRegistry::register() + */ +final class PackBuilder +{ + /** The `about()` keys, and the config.json section each lives in. */ + private const array ABOUT_KEYS = [ + 'title' => 'package', + 'description' => 'package', + 'version' => 'package', + 'license' => 'package', + 'keywords' => 'package', + 'homepage' => 'metadata', + 'repository' => 'metadata', + ]; + + /** @var list fluent kind rules, prepended at build() */ + private array $kindRules = []; + + /** @var array */ + private array $aliases = []; + + /** @var array */ + private array $extraMetadata = []; + + private ?bool $categories = null; + + private ?string $providerClass = null; + + private ?string $bladeComponent = null; + + /** @var list each fluent call, replayed over the base config at build() */ + private array $operations = []; + + /** @var array the effective config.json while build() runs */ + private array $config = []; + + private ?string $defaultVariant = null; + + /** + * @param string|null $directory the directory whose config.json is the base, read at build() + */ + private function __construct( + private ?string $name, + private string $basePath, + private string $filesPath, + private ?string $directory, + ) {} + + /** + * A set described by the config.json in $path; its SVGs are under `{$path}/files`. + * The file is read by build(), so a broken one fails there. + */ + public static function fromDirectory(string $path): self + { + return new self(null, $path, $path . '/files', $path); + } + + /** + * A set with no config.json. $filesPath holds the SVGs; icon paths are stored + * relative to its parent when it is named `files`, the layout a config.json + * set uses, and relative to $filesPath itself otherwise. The prefix defaults + * to the package half of the name. + */ + public static function make(string $vendorPackage, string $filesPath): self + { + $filesPath = rtrim($filesPath, '/\\'); + $basePath = basename($filesPath) === 'files' ? dirname($filesPath) : $filesPath; + + return new self($vendorPackage, $basePath, $filesPath, null) + ->prefix(Str::afterLast($vendorPackage, '/')); + } + + /** + * One builder per sub-directory of $basePath that holds a config.json, in + * name order. $only limits it to those directory names; empty means all. + * + * @param list $only + * + * @return list + */ + public static function discover(string $basePath, array $only = []): array + { + $directories = glob(rtrim($basePath, '/\\') . '/*', GLOB_ONLYDIR) ?: []; + sort($directories); + + $builders = []; + + foreach ($directories as $directory) { + if (($only === [] || in_array(basename($directory), $only, true)) && is_file($directory . '/config.json')) { + $builders[] = self::fromDirectory($directory); + } + } + + return $builders; + } + + /** The directory this set registers from: the one holding config.json, or the files' parent. */ + public function basePath(): string + { + return $this->basePath; + } + + /** The set's `vendor/package` name, replacing the one config.json declares. */ + public function named(string $vendorPackage): self + { + $this->name = $vendorPackage; + $this->operations[] = function () use ($vendorPackage): void { + $this->config['package'] = [...$this->section('package'), 'name' => $vendorPackage]; + }; + + return $this; + } + + /** The prefix icons are written with, `{prefix}::name`. */ + public function prefix(string $prefix): self + { + $this->operations[] = function () use ($prefix): void { + $this->config['config'] = [...$this->section('config'), 'icon_prefix' => $prefix]; + }; + + return $this; + } + + /** + * One descriptive field: title, description, version, license, keywords (a + * list), homepage or repository. + * + * @param string|list $value + * + * @throws IchavaException for any other key + */ + public function about(string $key, string|array $value): self + { + $section = self::ABOUT_KEYS[$key] ?? throw IchavaException::invalidConfiguration( + sprintf("unknown pack field '%s'; expected one of %s", $key, implode(', ', array_keys(self::ABOUT_KEYS))), + ); + + $this->operations[] = function () use ($section, $key, $value): void { + $this->config[$section] = [...$this->section($section), $key => $value]; + }; + + return $this; + } + + /** + * A variant, merged over the one config.json declares under the same name. + * + * @param array $attributes SVG attributes the variant applies, e.g. `['fill' => 'none']` + */ + public function variant(string $name, array $attributes = [], ?string $description = null, ?string $preview = null): self + { + $this->operations[] = function () use ($attributes, $description, $name, $preview): void { + $variants = $this->data('variants'); + $existing = is_array($variants[$name] ?? null) ? $variants[$name] : []; + $existingAttributes = is_array($existing['attributes'] ?? null) ? $existing['attributes'] : []; + + $variants[$name] = array_filter([ + ...$existing, + 'name' => $existing['name'] ?? Str::headline($name), + 'attributes' => [...$existingAttributes, ...$attributes], + 'description' => $description ?? ($existing['description'] ?? null), + 'preview_icon' => $preview ?? ($existing['preview_icon'] ?? null), + ], static fn (mixed $value): bool => $value !== null); + + $this->setData('variants', $variants); + }; + + return $this; + } + + /** Whether icons are filed under category folders; config.json decides when this is not called. */ + public function categories(bool $enabled = true): self + { + $this->categories = $enabled; + + return $this; + } + + /** The kind of the whole set (D6 row 5). */ + public function kind(IconKind $kind): self + { + $this->operations[] = function () use ($kind): void { + $this->config['package'] = [...$this->section('package'), 'kind' => $kind->value]; + }; + + return $this; + } + + /** The kind of one category (D6 row 4). */ + public function categoryKind(string $slug, IconKind $kind): self + { + $this->operations[] = function () use ($kind, $slug): void { + $this->category($slug, ['kind' => $kind->value]); + }; + + return $this; + } + + /** + * A kind rule, `**`, `*` and `?` globs over item paths (D6 row 3). Rules added + * here are tried before config.json's, in the order they are added. + * + * @throws IchavaException when the glob is not a valid KindGlob + */ + public function kindRule(string $glob, IconKind $kind): self + { + if (! KindGlob::isValid($glob)) { + throw IchavaException::invalidConfiguration("kind rule '{$glob}' is not a valid glob"); + } + + $this->kindRules[] = ['match' => $glob, 'kind' => $kind->value]; + + return $this; + } + + /** The kind of one item, by its path under `files/` without `.svg` (D6 row 2). */ + public function itemKind(string $itemPath, IconKind $kind): self + { + $this->operations[] = function () use ($itemPath, $kind): void { + $items = $this->data('items'); + $item = is_array($items[$itemPath] ?? null) ? $items[$itemPath] : []; + $items[$itemPath] = [...$item, 'kind' => $kind->value]; + + $this->setData('items', $items); + }; + + return $this; + } + + /** + * A theme twin: in $categorySlug, `name{$suffix}.svg` is the $theme form of `name.svg`. + */ + public function themeTwin(string $categorySlug, string $theme, string $suffix): self + { + $this->operations[] = function () use ($categorySlug, $theme, $suffix): void { + $categories = $this->data('categories'); + $category = is_array($categories[$categorySlug] ?? null) ? $categories[$categorySlug] : []; + $twins = is_array($category['theme_variants'] ?? null) ? $category['theme_variants'] : []; + + $this->category($categorySlug, ['theme_variants' => [...$twins, $theme => $suffix]]); + }; + + return $this; + } + + /** The CSS class every icon of the set renders with when the caller gives none (`config.defaults.class`). */ + public function defaultClass(string $class): self + { + $this->operations[] = function () use ($class): void { + $this->config['config'] = [...$this->section('config'), 'defaults' => [...$this->defaults(), 'class' => $class]]; + }; + + return $this; + } + + /** + * SVG attributes every icon of the set renders with, merged over the ones + * config.json declares (`config.defaults.attributes`). + * + * @param array $attributes + */ + public function defaultAttributes(array $attributes): self + { + $this->operations[] = function () use ($attributes): void { + $defaults = $this->defaults(); + $current = is_array($defaults['attributes'] ?? null) ? $defaults['attributes'] : []; + $this->config['config'] = [...$this->section('config'), 'defaults' => [...$defaults, 'attributes' => [...$current, ...$attributes]]]; + }; + + return $this; + } + + /** The icon rendered when a name in the set is not found (`config.defaults.fallback`). */ + public function fallback(string $icon): self + { + $this->operations[] = function () use ($icon): void { + $this->config['config'] = [...$this->section('config'), 'defaults' => [...$this->defaults(), 'fallback' => $icon]]; + }; + + return $this; + } + + /** The variant an icon name without one resolves in. */ + public function defaultVariant(string $variant): self + { + $this->defaultVariant = $variant; + + return $this; + } + + /** An old icon path that now renders $keptPath; IconAliasMap's rules apply at registration. */ + public function alias(string $oldPath, string $keptPath): self + { + $this->aliases[$oldPath] = $keptPath; + + return $this; + } + + /** + * @param array $map old path => kept path + */ + public function aliases(array $map): self + { + foreach ($map as $oldPath => $keptPath) { + $this->alias($oldPath, $keptPath); + } + + return $this; + } + + /** The service provider the set is attributed to. */ + public function providedBy(string $providerClass): self + { + $this->providerClass = $providerClass; + + return $this; + } + + /** The Blade alias the set renders through, recorded for conflict detection; registering it is the provider's. */ + public function component(string $bladeAlias): self + { + $this->bladeComponent = $bladeAlias; + + return $this; + } + + /** + * Registry metadata merged over the built metadata, last. + * + * @param array $metadata + */ + public function metadata(array $metadata): self + { + $this->extraMetadata = [...$this->extraMetadata, ...$metadata]; + + return $this; + } + + /** + * Apply an options array, the shape `custom-icons.sets.*` entries and + * registerBulkIconSets() values use. Each key maps to one method: + * `prefix`, `title`/`description`/`version`/`license`/`keywords`/ + * `homepage`/`repository`, `kind`, `variants` (a list of names), + * `default_variant`, `supports_categories`, `default_class`, + * `default_attributes`, `fallback`, and `blade_component` (with + * `blade_namespace`, recorded as `{namespace}-{component}`). Other keys + * (`enabled`, `path`, `bulk`, `only`, `package`) belong to the caller and + * are ignored here. + * + * @param array $options + * + * @throws IchavaException when `kind` names no kind + */ + public function withOptions(array $options): self + { + foreach (self::ABOUT_KEYS as $key => $section) { + $value = $options[$key] ?? null; + + if (is_string($value)) { + $this->about($key, $value); + } elseif ($key === 'keywords' && is_array($value)) { + $this->about($key, array_values(array_filter($value, is_string(...)))); + } + } + + if (is_string($options['prefix'] ?? null) && $options['prefix'] !== '') { + $this->prefix($options['prefix']); + } + + if (array_key_exists('kind', $options) && $options['kind'] !== null) { + $this->kind(IconKind::fromConfig($options['kind']) ?? throw IchavaException::invalidConfiguration( + sprintf("'%s' is not an icon kind", is_scalar($options['kind']) ? (string) $options['kind'] : get_debug_type($options['kind'])), + )); + } + + foreach (is_array($options['variants'] ?? null) ? $options['variants'] : [] as $variant) { + if (is_string($variant) && $variant !== '') { + $this->variant($variant); + } + } + + if (is_string($options['default_variant'] ?? null) && $options['default_variant'] !== '') { + $this->defaultVariant($options['default_variant']); + } + + if (is_bool($options['supports_categories'] ?? null)) { + $this->categories($options['supports_categories']); + } + + if (is_string($options['default_class'] ?? null) && $options['default_class'] !== '') { + $this->defaultClass($options['default_class']); + } + + if (is_array($options['default_attributes'] ?? null)) { + $attributes = []; + + foreach ($options['default_attributes'] as $name => $value) { + $attributes[(string) $name] = $value; + } + + $this->defaultAttributes($attributes); + } + + if (is_string($options['fallback'] ?? null) && $options['fallback'] !== '') { + $this->fallback($options['fallback']); + } + + if (is_string($options['blade_component'] ?? null) && $options['blade_component'] !== '') { + $namespace = is_string($options['blade_namespace'] ?? null) && $options['blade_namespace'] !== '' ? $options['blade_namespace'] . '-' : ''; + $this->component($namespace . $options['blade_component']); + } + + return $this; + } + + /** + * Apply $then when $condition holds. + * + * @param Closure(self): mixed $then + */ + public function when(bool $condition, Closure $then): self + { + if ($condition) { + $then($this); + } + + return $this; + } + + /** + * The value to register: the base config (config.json, or a bare one for + * make()), with every fluent call applied over it in order. + * + * @throws IchavaException when config.json is missing or malformed, or the + * name is not `vendor/package`, or the prefix is empty + */ + public function build(): PackDefinition + { + $this->config = $this->directory !== null + ? Helpers::loadConfigJson($this->directory) + : ['package' => ['name' => (string) $this->name]]; + + if ($this->directory !== null) { + $this->name ??= self::requiredString($this->config, 'package', 'name', $this->directory); + self::requiredString($this->config, 'config', 'icon_prefix', $this->directory); + } + + foreach ($this->operations as $operation) { + $operation(); + } + + $name = (string) $this->name; + + if (preg_match('/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/i', $name) !== 1) { + throw IchavaException::invalidConfiguration("pack name '{$name}' must be vendor/package"); + } + + $prefix = $this->section('config')['icon_prefix'] ?? null; + + if (! is_string($prefix) || $prefix === '') { + throw IchavaException::invalidConfiguration("pack '{$name}' needs a non-empty prefix"); + } + + if ($this->kindRules !== []) { + $kinds = $this->data('kinds'); + $fileRules = is_array($kinds['rules'] ?? null) ? array_values($kinds['rules']) : []; + $this->setData('kinds', [...$kinds, 'rules' => [...$this->kindRules, ...$fileRules]]); + } + + return new PackDefinition( + name: $name, + basePath: $this->basePath, + filesPath: $this->filesPath, + prefix: $prefix, + config: $this->config, + hasCategories: $this->categories ?? PackConfigData::categories($this->config) !== [], + aliases: $this->aliases, + providerClass: $this->providerClass, + bladeComponent: $this->bladeComponent, + extraMetadata: $this->extraMetadata, + defaultVariant: $this->defaultVariant, + ); + } + + /** + * @param array $config + * + * @throws IchavaException + */ + private static function requiredString(array $config, string $section, string $key, string $path): string + { + $values = $config[$section] ?? null; + $value = is_array($values) ? ($values[$key] ?? null) : null; + + if (! is_string($value)) { + throw IchavaException::invalidConfig( + "config.json must declare {$section}.{$key} as a string", + Str::finish($path, '/') . 'config.json', + ); + } + + return $value; + } + + /** @return array `config.defaults` */ + private function defaults(): array + { + $defaults = $this->section('config')['defaults'] ?? null; + + return is_array($defaults) ? $defaults : []; + } + + /** + * @param array $values merged into the category's block + */ + private function category(string $slug, array $values): self + { + $categories = $this->data('categories'); + $category = is_array($categories[$slug] ?? null) ? $categories[$slug] : []; + $categories[$slug] = [...$category, ...$values]; + + return $this->setData('categories', $categories); + } + + /** @return array */ + private function section(string $key): array + { + $section = $this->config[$key] ?? null; + + return is_array($section) ? $section : []; + } + + /** @return array one `metadata.data` block */ + private function data(string $key): array + { + $metadata = is_array($this->config['metadata'] ?? null) ? $this->config['metadata'] : []; + $data = is_array($metadata['data'] ?? null) ? $metadata['data'] : []; + + return is_array($data[$key] ?? null) ? $data[$key] : []; + } + + /** @param array $value */ + private function setData(string $key, array $value): self + { + $this->config = $this->withData($this->config, $key, $value); + + return $this; + } + + /** + * @param array $config + * @param array $value + * + * @return array + */ + private function withData(array $config, string $key, array $value): array + { + $metadata = is_array($config['metadata'] ?? null) ? $config['metadata'] : []; + $data = is_array($metadata['data'] ?? null) ? $metadata['data'] : []; + $data[$key] = $value; + $metadata['data'] = $data; + $config['metadata'] = $metadata; + + return $config; + } +} diff --git a/src/Support/Pack/PackConfigReader.php b/src/Support/Pack/PackConfigReader.php new file mode 100644 index 00000000..4280681a --- /dev/null +++ b/src/Support/Pack/PackConfigReader.php @@ -0,0 +1,43 @@ +|null + */ + public function registered(string $directoryPath): ?array + { + return $this->registry->definitionAt($directoryPath)?->config; + } + + /** + * @return array + */ + public function read(string $directoryPath): array + { + return $this->registered($directoryPath) ?? $this->configs->rememberPackageConfig($directoryPath); + } +} diff --git a/src/Support/Pack/PackSpec.php b/src/Support/Pack/PackSpec.php new file mode 100644 index 00000000..4762e159 --- /dev/null +++ b/src/Support/Pack/PackSpec.php @@ -0,0 +1,189 @@ +set(fn (PackBuilder $set) => $set->kindRule('illustrations/**', IconKind::Illustration)) + * ->addSet('resources/assets/svg-brands', fn (PackBuilder $set) => $set->prefix('brand')) + * ->component('flag', FlagComponent::class); + * } + * ``` + * + * Every set goes through PackBuilder and IconRegistry, the same path as a set + * registered alone, and every component through ComponentPrefixes, so a pack + * tag follows `components.short_prefix` as core's own does. + */ +final class PackSpec +{ + private ?Closure $shape = null; + + /** @var list, shape: ?Closure, bulk: bool}> */ + private array $extraSets = []; + + /** @var array name => component class */ + private array $components = []; + + private string $mainComponent = 'icon'; + + private ?string $configFile = null; + + /** + * @param string $vendorPackage the pack's `vendor/package` + * @param string $packRoot the pack's root directory + * @param string $svgPath the default set's directory, `resources/assets/svg` + * @param class-string $componentClass the pack's main Blade component + * @param string $providerClass the provider the pack's sets are attributed to + */ + private function __construct( + private string $vendorPackage, + private string $packRoot, + private string $svgPath, + private string $componentClass, + private string $providerClass, + ) {} + + /** + * @param class-string $componentClass + */ + public static function forProvider(string $providerClass, string $vendorPackage, string $packRoot, string $componentClass): self + { + $packRoot = rtrim($packRoot, '/\\'); + + return new self($vendorPackage, $packRoot, $packRoot . '/resources/assets/svg', $componentClass, $providerClass); + } + + /** + * Shape the default set, `resources/assets/svg`. + * + * @param Closure(PackBuilder): mixed $shape + */ + public function set(Closure $shape): self + { + $this->shape = $shape; + + return $this; + } + + /** + * Another set in the pack, at a path relative to the pack root, holding its own config.json. + * + * @param (Closure(PackBuilder): mixed)|null $shape + */ + public function addSet(string $relativePath, ?Closure $shape = null): self + { + $this->extraSets[] = ['path' => $this->packRoot . '/' . ltrim($relativePath, '/\\'), 'only' => [], 'shape' => $shape, 'bulk' => false]; + + return $this; + } + + /** + * Every sub-directory of $relativePath holding a config.json, or only those + * named, each shaped by $shape. + * + * @param list $only + * @param (Closure(PackBuilder): mixed)|null $shape + */ + public function sets(string $relativePath, array $only = [], ?Closure $shape = null): self + { + $this->extraSets[] = ['path' => $this->packRoot . '/' . ltrim($relativePath, '/\\'), 'only' => $only, 'shape' => $shape, 'bulk' => true]; + + return $this; + } + + /** + * An extra Blade component: ``, and + * `` while the short prefix is on. + * + * @param class-string $componentClass + */ + public function component(string $name, string $componentClass): self + { + $this->components[$name] = $componentClass; + + return $this; + } + + /** Rename the main component's tag (default `icon`). */ + public function mainComponent(string $name): self + { + $this->mainComponent = $name; + + return $this; + } + + /** Publish `config/{$name}.php` rather than `config/{slug}.php`. */ + public function configFile(string $name): self + { + $this->configFile = $name; + + return $this; + } + + /** The config file name configFile() set, or null for the slug. */ + public function configFileName(): ?string + { + return $this->configFile; + } + + /** + * Register the components, then the sets: the order the registry relies on + * to have the main component's alias when it builds the default set's metadata. + */ + public function registerWith(IconRegistry $registry, ComponentPrefixes $prefixes): void + { + $alias = $prefixes->registerPackComponent($this->vendorPackage, $this->mainComponent, $this->componentClass); + $registry->noteBladeComponent($this->providerClass, $alias); + + foreach ($this->components as $name => $componentClass) { + $prefixes->registerPackComponent($this->vendorPackage, $name, $componentClass); + } + + $registry->register($this->shaped(PackBuilder::fromDirectory($this->svgPath), $this->shape)->build()); + + $extra = []; + + foreach ($this->extraSets as $set) { + $builders = $set['bulk'] ? PackBuilder::discover($set['path'], $set['only']) : [PackBuilder::fromDirectory($set['path'])]; + + foreach ($builders as $builder) { + $extra[] = $this->shaped($builder, $set['shape']); + } + } + + if ($extra !== []) { + $registry->registerMany($extra, new BulkOptions(name: $this->vendorPackage, failFast: true)); + } + } + + /** + * @param (Closure(PackBuilder): mixed)|null $shape + */ + private function shaped(PackBuilder $builder, ?Closure $shape): PackBuilder + { + $builder->providedBy($this->providerClass); + + if ($shape !== null) { + $shape($builder); + } + + return $builder; + } +} diff --git a/src/Support/PackConfigCache.php b/src/Support/PackConfigCache.php new file mode 100644 index 00000000..39178bf6 --- /dev/null +++ b/src/Support/PackConfigCache.php @@ -0,0 +1,54 @@ +cache->store('file')->remember($this->key($directoryPath), self::TTL_SECONDS, $load(...)); + } catch (Throwable) { + return $load(); + } + } + + /** Drop the cached config for $directoryPath; false when nothing was dropped or the store failed. */ + public function forget(string $directoryPath): bool + { + try { + return $this->cache->store('file')->forget($this->key($directoryPath)); + } catch (Throwable) { + return false; + } + } +} diff --git a/src/Support/PathResolver.php b/src/Support/PathResolver.php index 7e43ead4..cc87d7ac 100644 --- a/src/Support/PathResolver.php +++ b/src/Support/PathResolver.php @@ -19,7 +19,7 @@ * § "Icon Path Format" for the path grammar; both slash and dot separators * after `::` are accepted and normalized to slash form. */ -class PathResolver +final class PathResolver { /** * Default SVG assets path constant @@ -28,17 +28,17 @@ class PathResolver private const SVG_PATH = IchavaConstants::SVG_ASSETS_PATH; /** @var non-empty-string */ - protected string $pathSeparator; + private string $pathSeparator; /** @var non-empty-string */ - protected string $variantSeparator; + private string $variantSeparator; /** @var non-empty-string */ - protected string $categorySeparator; + private string $categorySeparator; - protected ?string $iconPathPattern; + private ?string $iconPathPattern; - protected ?string $iconNamePattern; + private ?string $iconNamePattern; public function __construct() { diff --git a/src/Support/RuntimeLimits.php b/src/Support/RuntimeLimits.php new file mode 100644 index 00000000..d6c311ef --- /dev/null +++ b/src/Support/RuntimeLimits.php @@ -0,0 +1,57 @@ +memory(Coerce::string($this->config->get('ichava.ichava-core.runtime.memory_limit', '2G'))) + ->timeout($timeout ?? Coerce::int($this->config->get('ichava.ichava-core.runtime.max_execution_time', 0))); + + if ($this->config->get('ichava.ichava-core.runtime.disable_telescope_in_queue', true) && $this->app->runningInConsole()) { + $configurator->disableTelescope(); + } + + return $configurator; + } + + /** + * Run $operation under Ichava's limits and restore the previous values after. + * + * @template TResult + * + * @param callable(): TResult $operation + * + * @return TResult + */ + public function scope(callable $operation, ?int $timeout = null): mixed + { + return $this->configurator($timeout)->scope($operation); + } +} diff --git a/src/Support/Seeder/IchavaSeeder.php b/src/Support/Seeder/IchavaSeeder.php index 83a87fd9..dc2ac5e8 100644 --- a/src/Support/Seeder/IchavaSeeder.php +++ b/src/Support/Seeder/IchavaSeeder.php @@ -31,11 +31,12 @@ use Simtabi\Laranail\Console\Tools\Support\Status; use Simtabi\Laranail\Ichava\Services\IchavaLogger; use Simtabi\Laranail\Ichava\Services\IconRegistry; +use Simtabi\Laranail\Ichava\Support\RuntimeLimits; use Simtabi\Laranail\Ichava\Commands\DatabaseCommand; use Simtabi\Laranail\Console\Tools\Widgets\MetricTable; use Simtabi\Laranail\Console\Tools\Widgets\StatusBadge; use Simtabi\Laranail\Ichava\Exceptions\IchavaException; -use Simtabi\Laranail\Ichava\Services\DatabaseOperationsService; +use Simtabi\Laranail\Ichava\Contracts\DatabaseOperations; use Simtabi\Laranail\Package\Tools\Services\Database\ChunkedBatchDispatcher; /** @@ -48,7 +49,7 @@ * * @see SeedIconsJob */ -class IchavaSeeder extends Seeder +final class IchavaSeeder extends Seeder { /** * Default number of icons per job. @@ -362,7 +363,8 @@ public function run(): void return; } - $this->seedAllPackages(); + // Seeding runs under Ichava's limits, restored when it returns (C-01). + app(RuntimeLimits::class)->scope(fn () => $this->seedAllPackages()); $this->displayFooter(); } @@ -471,7 +473,7 @@ protected function seedAllPackages(): void // when the pack is small enough that a queue only adds // latency (`database.smart_queue_threshold`, 5,000 icons). $useQueue = config('ichava.ichava-core.database.use_queue', true) - && app(DatabaseOperationsService::class)->shouldUseQueue($iconCount, forceSync: $this->isSyncMode()); + && app(DatabaseOperations::class)->shouldUseQueue($iconCount, forceSync: $this->isSyncMode()); if (! $useQueue) { $this->console()->line(' ' . __('ichava/ichava-core::commands.seeder.seeding_sync', ['force' => $forceMsg, 'size' => $chunkSize]) . ''); diff --git a/src/Support/Seeder/IconTermsSeeder.php b/src/Support/Seeder/IconTermsSeeder.php index b5f6e93b..68a0299a 100644 --- a/src/Support/Seeder/IconTermsSeeder.php +++ b/src/Support/Seeder/IconTermsSeeder.php @@ -32,7 +32,7 @@ * * @see IchavaSeeder Main seeder that orchestrates term + icon seeding */ -class IconTermsSeeder extends Seeder +final class IconTermsSeeder extends Seeder { protected IconRegistry $packageRegistry; @@ -145,22 +145,10 @@ protected function seedPackageCategories(string $packageName, array $packageData */ protected function seedPackageVariants(string $packageName, array $packageData): void { - $basePath = $this->basePathOf($packageData); - - if (! $basePath) { - return; - } - - // Check for config.json - $configPath = $basePath . '/config.json'; - if (! File::exists($configPath)) { - return; - } - - $config = json_decode(File::get($configPath), true); - $metadata = is_array($config) ? ($config['metadata'] ?? null) : null; - $data = is_array($metadata) ? ($metadata['data'] ?? null) : null; - $variants = is_array($data) ? ($data['variants'] ?? []) : []; + // The registry records each pack's variants as `variant_metadata`, + // so this reads them there rather than parsing config.json a second + // time: a variant registered from code is seeded too (C-04). + $variants = $packageData['variant_metadata'] ?? []; if (empty($variants) || ! is_array($variants)) { return; diff --git a/src/Actions/CountSvgFiles.php b/src/Support/SvgFileCounter.php similarity index 97% rename from src/Actions/CountSvgFiles.php rename to src/Support/SvgFileCounter.php index 5d17e8df..503333be 100644 --- a/src/Actions/CountSvgFiles.php +++ b/src/Support/SvgFileCounter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Simtabi\Laranail\Ichava\Actions; +namespace Simtabi\Laranail\Ichava\Support; use Throwable; use SplFileInfo; @@ -34,7 +34,7 @@ * of a method whose contract is to return `0`. Catching `Throwable` here is the * behaviour the original was reaching for. */ -final readonly class CountSvgFiles +final readonly class SvgFileCounter { /** Stop the shallow count here; a caller rendering a tree node needs "many", not the number. */ public const int SHALLOW_CAP = 100; diff --git a/src/Support/SvgInspector.php b/src/Support/SvgInspector.php new file mode 100644 index 00000000..164e7a54 --- /dev/null +++ b/src/Support/SvgInspector.php @@ -0,0 +1,144 @@ +` or `` that a renderer draws as stray + * glyphs and js-icon-components refuses. A pack whose SVGs report nothing + * here is served byte-for-byte as shipped. + */ +final class SvgInspector +{ + /** + * What the policy strips: `doctype`, `entity`, `comment`, `tag {name}`, + * `attribute {element}@{name}`, `reference {name}`, `value {name}`, + * `root is not svg`, or `unparseable`. Empty for a clean file. + * + * @return list + */ + public static function policyViolations(string $raw): array + { + $found = []; + + if (stripos($raw, 'localName; + + if (isset($forbidden[$name]) || ! isset($tags[$name])) { + $found[] = "tag {$name}"; + } + + foreach ($element->attributes ?? [] as $attribute) { + $key = $attribute->nodeName; + $value = (string) $attribute->nodeValue; + + if (! isset($attributes[$key]) && $key !== 'role' && preg_match('/^aria-[a-z]+$/', $key) !== 1) { + $found[] = "attribute {$name}@{$key}"; + } + + if (in_array($key, $references, true) && preg_match($fragment, $value) !== 1) { + $found[] = "reference {$key}"; + } + + if (preg_match('/javascript:|data:|expression\(|behavior:|-moz-binding|url\(\s*[\'"]?[^#\s\'")]/i', $value) === 1) { + $found[] = "value {$key}"; + } + } + }); + + if ($root->localName !== 'svg') { + $found[] = 'root is not svg'; + } + + return array_values(array_unique($found)); + } + + /** + * The trimmed text of every non-blank text or CDATA node directly under + * `` or ``; `['unparseable']` when the source does not parse. + * Text in ``, `` and `` is content, not stray. + * + * @return list + */ + public static function strayTextNodes(string $raw): array + { + $root = self::parse($raw); + + if ($root === null) { + return ['unparseable']; + } + + $found = []; + + self::walk($root, function (DOMElement $element) use (&$found): void { + if (! in_array($element->localName, ['svg', 'g'], true)) { + return; + } + + foreach ($element->childNodes as $child) { + if (($child->nodeType === XML_TEXT_NODE || $child->nodeType === XML_CDATA_SECTION_NODE) && trim((string) $child->nodeValue) !== '') { + $found[] = trim((string) $child->nodeValue); + } + } + }); + + return $found; + } + + private static function parse(string $raw): ?DOMElement + { + $dom = new DOMDocument; + + if (@$dom->loadXML($raw, LIBXML_NONET) === false) { + return null; + } + + return $dom->documentElement; + } + + /** + * @param callable(DOMElement): void $visit + */ + private static function walk(DOMElement $element, callable $visit): void + { + $visit($element); + + foreach ($element->childNodes as $child) { + if ($child instanceof DOMElement) { + self::walk($child, $visit); + } + } + } +} diff --git a/src/Testing/PackConformance.php b/src/Testing/PackConformance.php new file mode 100644 index 00000000..c49e8c2e --- /dev/null +++ b/src/Testing/PackConformance.php @@ -0,0 +1,254 @@ + $constraint) { + $package = (string) $package; + $constraint = is_string($constraint) ? $constraint : ''; + + if (str_starts_with($package, 'ichava/')) { + $found++; + Assert::assertTrue(Semver::satisfies($version, $constraint), "{$section}.{$package} is '{$constraint}', which {$version} does not satisfy."); + } + } + } + + Assert::assertGreaterThan(0, $found, 'composer.json names no ichava/* constraint, so nothing was checked.'); + } + + /** The sync caller calls the reusable sync-pack workflow pinned to one 40-hex commit. */ + public static function syncCallerIsPinned(string $root): void + { + $job = self::section(self::section(self::syncCaller($root), 'jobs'), 'sync'); + $uses = is_string($job['uses'] ?? null) ? $job['uses'] : ''; + $ref = self::section($job, 'with')['toolkit-ref'] ?? null; + $ref = is_string($ref) ? $ref : ''; + + Assert::assertMatchesRegularExpression('#^ichava/icon-sets-sync/\.github/workflows/sync-pack\.yml@[0-9a-f]{40}$#', $uses); + Assert::assertMatchesRegularExpression('/^[0-9a-f]{40}$/', $ref); + Assert::assertSame(explode('@', $uses)[1] ?? null, $ref); + } + + /** The sync caller runs on one schedule and a manual dispatch, never on push. */ + public static function syncCallerTriggers(string $root): void + { + $on = self::syncCaller($root)['on'] ?? null; + + Assert::assertIsArray($on); + Assert::assertSame(['schedule', 'workflow_dispatch'], array_keys($on)); + Assert::assertCount(1, (array) $on['schedule']); + } + + /** The sync caller grants only what the called workflow needs. */ + public static function syncCallerPermissions(string $root): void + { + $workflow = self::syncCaller($root); + + Assert::assertSame(['contents' => 'read'], $workflow['permissions'] ?? null); + $jobs = self::section($workflow, 'jobs'); + + Assert::assertSame(['contents' => 'read', 'issues' => 'write'], self::section($jobs, 'sync')['permissions'] ?? null); + Assert::assertSame(['sync'], array_keys($jobs)); + } + + /** Every workflow declares a top-level `permissions:` block; at least $minFiles are read. */ + public static function workflowsDeclarePermissions(string $root, int $minFiles): void + { + $dir = $root . '/.github/workflows'; + $files = array_merge(glob($dir . '/*.yml') ?: [], glob($dir . '/*.yaml') ?: []); + $missing = []; + + foreach ($files as $file) { + if (preg_match('/^permissions:/m', (string) file_get_contents($file)) !== 1) { + $missing[] = basename($file); + } + } + + Assert::assertGreaterThanOrEqual($minFiles, count($files), 'Too few workflow files were read.'); + Assert::assertSame([], $missing, 'Workflows without a top-level permissions block.'); + } + + /** No SVG under $dir has text directly under `` or ``; at least $minFiles are read. */ + public static function noStrayTextNodes(string $dir, int $minFiles): void + { + self::eachSvg($dir, $minFiles, SvgInspector::strayTextNodes(...), 'Stray text nodes'); + } + + /** Every SVG under $dir is one core's sanitiser leaves as it is; at least $minFiles are read. */ + public static function svgPolicyClean(string $dir, int $minFiles): void + { + self::eachSvg($dir, $minFiles, SvgInspector::policyViolations(...), 'SVGs the policy would change'); + } + + /** + * @param callable(string): list $inspect + */ + private static function eachSvg(string $dir, int $minFiles, callable $inspect, string $message): void + { + $files = self::files($dir, 'svg'); + $offenders = []; + + foreach ($files as $file) { + $found = $inspect((string) file_get_contents($file)); + + if ($found !== []) { + $offenders[] = substr($file, strlen(rtrim($dir, '/')) + 1) . ' => ' . implode(' | ', $found); + } + } + + Assert::assertGreaterThanOrEqual($minFiles, count($files), 'Too few SVG files were read.'); + Assert::assertSame([], $offenders, $message); + } + + /** + * @return list + */ + private static function files(string $dir, string $extension): array + { + if (! is_dir($dir)) { + return []; + } + + $files = []; + + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)) as $file) { + if ($file instanceof SplFileInfo && $file->isFile() && $file->getExtension() === $extension) { + $files[] = $file->getPathname(); + } + } + + sort($files); + + return $files; + } + + /** + * @return array + */ + private static function composer(string $root): array + { + return self::stringKeyed(json_decode((string) file_get_contents($root . '/composer.json'), true, flags: JSON_THROW_ON_ERROR)); + } + + /** + * @return array + */ + private static function syncCaller(string $root): array + { + $path = $root . '/.github/workflows/sync-upstream.yml'; + Assert::assertFileExists($path); + + return self::stringKeyed(Yaml::parseFile($path)); + } + + /** + * One array value of $data, or [] when it is absent or not an array. + * + * @param array $data + * + * @return array + */ + private static function section(array $data, string $key): array + { + return is_array($data[$key] ?? null) ? $data[$key] : []; + } + + /** + * @return array + */ + private static function stringKeyed(mixed $value): array + { + $out = []; + + foreach (is_array($value) ? $value : [] as $key => $item) { + $out[(string) $key] = $item; + } + + return $out; + } +} diff --git a/src/Testing/PackTestCase.php b/src/Testing/PackTestCase.php new file mode 100644 index 00000000..6194be99 --- /dev/null +++ b/src/Testing/PackTestCase.php @@ -0,0 +1,119 @@ + + */ + protected function getPackageProviders($app): array + { + return [IchavaServiceProvider::class, $this->packProvider()]; + } + + /** + * @param Application $app + */ + protected function defineEnvironment($app): void + { + $this->configureDatabase($app); + $this->configureCache($app); + } + + /** + * @param Application $app + */ + protected function configureDatabase($app): void + { + $driver = Env::get('DB_CONNECTION', 'sqlite'); + + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', match ($driver) { + 'pgsql' => [ + 'driver' => 'pgsql', + 'host' => Env::get('DB_HOST', '127.0.0.1'), + 'port' => Env::get('DB_PORT', '5432'), + 'database' => Env::get('DB_DATABASE', 'ichava_test'), + 'username' => Env::get('DB_USERNAME', 'ichava'), + 'password' => Env::get('DB_PASSWORD', 'secret'), + 'charset' => 'utf8', + 'prefix' => '', + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + 'mysql', 'mariadb' => [ + 'driver' => $driver, + 'host' => Env::get('DB_HOST', '127.0.0.1'), + 'port' => Env::get('DB_PORT', '3306'), + 'database' => Env::get('DB_DATABASE', 'ichava_test'), + 'username' => Env::get('DB_USERNAME', 'ichava'), + 'password' => Env::get('DB_PASSWORD', 'secret'), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'strict' => true, + 'engine' => 'InnoDB', + ], + default => [ + 'driver' => 'sqlite', + 'database' => Env::get('DB_DATABASE', ':memory:'), + 'prefix' => '', + + // Off by default on SQLite, and Laravel only enables them when the key is + // present, so without this the engine's cascading deletes go unenforced. + 'foreign_key_constraints' => true, + ], + }); + } + + /** + * @param Application $app + */ + protected function configureCache($app): void + { + // The array store returns the object it was given, so nothing here crosses + // serialize()/unserialize(). A test that depends on that boundary must switch to a + // serialising store itself. + $app['config']->set('cache.default', 'array'); + $app['config']->set('cache.stores.array', [ + 'driver' => 'array', + 'serialize' => false, + ]); + } +} diff --git a/src/View/ComponentPrefixes.php b/src/View/ComponentPrefixes.php new file mode 100644 index 00000000..4e24764f --- /dev/null +++ b/src/View/ComponentPrefixes.php @@ -0,0 +1,148 @@ +`. The short + * prefix, `components.short_prefix` (default `ichava`, so ``), + * is a convenience beside it; null or '' turns it off. It is validated against + * Blade's tag parser at boot, which admits `[\w\-\:\.]` and spends `::` between + * prefix and component, so a slash or `::` in a prefix would truncate the tag + * silently rather than fail. + * + * A pack follows the same two tiers (C-08): `{vendor}-{package}::{name}` always, + * and `{package}-{name}` while the short prefix is on. + */ +final readonly class ComponentPrefixes +{ + public const string CANONICAL = 'ichava-core'; + + public const string COMPONENTS_NAMESPACE = 'Simtabi\\Laranail\\Ichava\\View\\Components'; + + private const string VALID_PREFIX = '/^[a-z][a-z0-9-]*$/'; + + public function __construct( + private ConfigRepository $config, + private BladeCompiler $blade, + ) {} + + /** Whether core registers any Blade tag (`components.enabled`). */ + public function enabled(): bool + { + return $this->config->get('ichava.ichava-core.components.enabled', true) !== false; + } + + /** + * The short prefix, or null when it is off. + * + * @throws IchavaException when it is not a prefix Blade can parse, or is the canonical one + */ + public function shortPrefix(): ?string + { + $prefix = $this->config->get('ichava.ichava-core.components.short_prefix', 'ichava'); + + if ($prefix === null || $prefix === '' || $prefix === false) { + return null; + } + + if (! is_string($prefix) || preg_match(self::VALID_PREFIX, $prefix) !== 1 || $prefix === self::CANONICAL) { + throw IchavaException::invalidConfiguration(sprintf( + "components.short_prefix must be lowercase letters, digits and hyphens, start with a letter, and differ from '%s'; got %s", + self::CANONICAL, + is_string($prefix) ? "'{$prefix}'" : get_debug_type($prefix), + )); + } + + return $prefix; + } + + /** + * The prefixes core registers, canonical first. + * + * @return list + */ + public function registered(): array + { + if (! $this->enabled()) { + return []; + } + + $short = $this->shortPrefix(); + + return $short === null ? [self::CANONICAL] : [self::CANONICAL, $short]; + } + + /** + * Register core's component namespace and `{prefix}::icon` for each prefix. + * + * @return list the prefixes registered, canonical first + */ + public function register(): array + { + $prefixes = $this->registered(); + + foreach ($prefixes as $prefix) { + // The namespace alone would look for `Components\Icon`, so the + // headline component needs its alias too. + $this->blade->componentNamespace(self::COMPONENTS_NAMESPACE, $prefix); + $this->blade->component(IconComponent::class, $prefix . '::icon'); + } + + return $prefixes; + } + + /** + * Register one of a pack's components: `{vendor}-{package}::{name}` always, + * and `{package}-{name}` while the short prefix is on. + * + * @return string the alias the registry records for conflict detection: the short one when registered, else the canonical + */ + public function registerPackComponent(string $vendorPackage, string $name, string $componentClass): string + { + $vendorPackage = strtolower($vendorPackage); + $canonical = str_replace('/', '-', $vendorPackage) . '::' . $name; + $this->blade->component($componentClass, $canonical); + + if ($this->shortPrefix() === null) { + return $canonical; + } + + $short = Str::afterLast($vendorPackage, '/') . '-' . $name; + $this->blade->component($componentClass, $short); + + return $short; + } + + /** + * The class `{short}::icon` resolves to when another provider has replaced + * it with something that is not an IconComponent; null when it is intact + * or the short prefix is off. Doctor's `component.short_prefix_shadowed`. + */ + public function shadowedShortAlias(): ?string + { + $short = $this->enabled() ? $this->shortPrefix() : null; + + if ($short === null) { + return null; + } + + $class = $this->blade->getClassComponentAliases()[$short . '::icon'] ?? null; + + if (! is_string($class) || $class === IconComponent::class || is_subclass_of($class, IconComponent::class)) { + return null; + } + + return $class; + } +} diff --git a/tests/Feature/BrowserDataWiringTest.php b/tests/Feature/BrowserDataWiringTest.php index b1fe8aa8..47c6eeba 100644 --- a/tests/Feature/BrowserDataWiringTest.php +++ b/tests/Feature/BrowserDataWiringTest.php @@ -9,7 +9,6 @@ use Simtabi\Laranail\Ichava\Data\VariantData; use Simtabi\Laranail\Ichava\Events\IconCacheEvent; use Simtabi\Laranail\Ichava\Services\IconRegistry; -use Simtabi\Laranail\Ichava\Support\ServiceProvider; use Simtabi\Laranail\Ichava\Services\IconCacheService; use Simtabi\Laranail\Ichava\Support\FtsLanguageHelper; use Simtabi\Laranail\Ichava\Exceptions\IchavaException; @@ -17,6 +16,7 @@ use Simtabi\Laranail\Ichava\Services\IconBrowserService; use Simtabi\Laranail\Ichava\Support\Seeder\IchavaSeeder; use Simtabi\Laranail\Ichava\Constants\JsonConfigConstants; +use Simtabi\Laranail\Ichava\Providers\PackServiceProvider; use Simtabi\Laranail\Ichava\Services\ConfigurationService; /* @@ -40,7 +40,7 @@ protected static function getConfigPath(): string } } -final class KindFixtureProvider extends ServiceProvider +final class KindFixtureProvider extends PackServiceProvider { public function configurePackage(Package $package): void {} } diff --git a/tests/Feature/BulkRegistrationTest.php b/tests/Feature/BulkRegistrationTest.php new file mode 100644 index 00000000..189c0f4d --- /dev/null +++ b/tests/Feature/BulkRegistrationTest.php @@ -0,0 +1,145 @@ +'; + + foreach ($sets as $set) { + File::ensureDirectoryExists("{$root}/{$set}/files"); + File::put("{$root}/{$set}/files/square.svg", $svg); + File::put("{$root}/{$set}/config.json", json_encode(['package' => ['name' => "bulk/{$set}"], 'config' => ['icon_prefix' => $set]])); + } + + foreach ($broken as $set) { + File::ensureDirectoryExists("{$root}/{$set}"); + File::put("{$root}/{$set}/config.json", '{ not json'); + } + + foreach ($bare as $set) { + File::ensureDirectoryExists("{$root}/{$set}/files"); + } + + return $root; +} + +afterEach(function (): void { + foreach (glob(sys_get_temp_dir() . '/ichava-bulk-*') ?: [] as $dir) { + File::deleteDirectory($dir); + } +}); + +it('discovers one builder per config-bearing sub-directory, in name order', function (): void { + $root = bulkTree(['beta', 'alpha', 'gamma'], bare: ['no-config']); + + expect(array_map(fn (PackBuilder $b): string => basename($b->basePath()), PackBuilder::discover($root)))->toBe(['alpha', 'beta', 'gamma']) + ->and(array_map(fn (PackBuilder $b): string => basename($b->basePath()), PackBuilder::discover($root, ['gamma', 'alpha'])))->toBe(['alpha', 'gamma']); +}); + +it('registers every discovered set exactly as registering it alone would', function (): void { + $root = bulkTree(['alpha', 'beta', 'gamma']); + + $result = app(IconRegistry::class)->registerMany(PackBuilder::discover($root)); + + expect($result)->toBeInstanceOf(BulkResult::class) + ->and($result->registered)->toBe(['bulk/alpha', 'bulk/beta', 'bulk/gamma']) + ->and($result->failed)->toBe([]); + + $bulk = app(IconRegistry::class)->get('bulk/beta'); + app()->forgetInstance(IconRegistry::class); + app(IconRegistry::class)->fromDirectory($root . '/beta'); + $alone = app(IconRegistry::class)->get('bulk/beta'); + + unset($bulk['registered_at'], $alone['registered_at']); + expect($bulk)->toBe($alone); +}); + +it('reports a broken set and registers the rest, or throws with failFast', function (): void { + $root = bulkTree(['alpha', 'gamma'], broken: ['beta']); + + $result = app(IconRegistry::class)->registerMany(PackBuilder::discover($root)); + + expect($result->registered)->toBe(['bulk/alpha', 'bulk/gamma']) + ->and(array_keys($result->failed))->toBe([$root . '/beta']); + + app()->forgetInstance(IconRegistry::class); + expect(fn () => app(IconRegistry::class)->registerMany(PackBuilder::discover($root), new BulkOptions(failFast: true))) + ->toThrow(IchavaException::class); +}); + +it('announces one run: started, each registered, then completed, under one registrar id', function (): void { + Event::fake([IconRegistrationEvent::class]); + $root = bulkTree(['alpha', 'beta', 'gamma']); + + app(IconRegistry::class)->registerMany(PackBuilder::discover($root), new BulkOptions(name: 'fixture-bulk')); + + $events = Event::dispatched(IconRegistrationEvent::class)->map(fn (array $call) => $call[0]); + $actions = $events->map(fn (IconRegistrationEvent $e): string => $e->action)->all(); + + expect($actions[0])->toBe(IconRegistrationEvent::ACTION_STARTED) + ->and(end($actions))->toBe(IconRegistrationEvent::ACTION_COMPLETED) + ->and(array_count_values($actions)[IconRegistrationEvent::ACTION_REGISTERED])->toBe(3) + ->and(array_count_values($actions)[IconRegistrationEvent::ACTION_COMPLETED])->toBe(1) + ->and($events->map(fn (IconRegistrationEvent $e): string => $e->registrarId)->unique()->count())->toBe(1) + ->and($events->filter(fn (IconRegistrationEvent $e): bool => $e->isRegistered())->count())->toBe(3) + ->and($events->last()->metadata['statistics'])->toBe(['registered' => 3, 'skipped' => 0, 'failed' => 0]); +}); + +it('applies the per-set values registerBulkIconSets() is given', function (): void { + $root = bulkTree(['alpha', 'beta']); + + $registrar = IchavaRegistrar::register('fixture/bulk') + ->basePath($root) + ->trackStatistics() + ->configure(['alpha' => ['prefix' => 'a1'], 'beta' => []]); + + expect(app(IconRegistry::class)->get('bulk/alpha')['prefix'])->toBe('a1') + ->and(app(IconRegistry::class)->get('bulk/beta')['prefix'])->toBe('beta') + ->and($registrar->statistics()?->registered)->toBe(['bulk/alpha', 'bulk/beta']); +}); + +it('discovers every set when registerBulkIconSets() is given none', function (): void { + $root = bulkTree(['alpha', 'beta']); + + IchavaRegistrar::register('fixture/bulk')->basePath($root)->configure([]); + + expect(app(IconRegistry::class)->isRegistered('bulk/alpha'))->toBeTrue() + ->and(app(IconRegistry::class)->isRegistered('bulk/beta'))->toBeTrue(); +}); + +it('logs a run whether logging is enabled before or after configure()', function (): void { + $root = bulkTree(['alpha']); + config()->set('ichava.ichava-core.logging.enabled', true); + app()->forgetInstance(Simtabi\Laranail\Ichava\Services\IchavaLogger::class); + $log = Illuminate\Support\Facades\Log::spy(); + $log->shouldReceive('channel')->andReturn($log); + + IchavaRegistrar::register('fixture/before')->basePath($root)->enableLogging()->configure(['alpha' => []]); + app()->forgetInstance(IconRegistry::class); + IchavaRegistrar::register('fixture/after')->basePath($root)->configure(['alpha' => []])->enableLogging(); + + $log->shouldHaveReceived('info')->withArgs(fn (string $message): bool => str_contains($message, 'fixture/before'))->once(); + $log->shouldHaveReceived('info')->withArgs(fn (string $message): bool => str_contains($message, 'fixture/after'))->once(); +}); + +it('registers core test icons through the bulk path on every boot', function (): void { + expect(app(IconRegistry::class)->isRegistered('ichava/test-icons'))->toBeTrue(); +}); diff --git a/tests/Feature/Commands/CacheCommandCharacterizationTest.php b/tests/Feature/Commands/CacheCommandCharacterizationTest.php index 1c0f66ad..af3958eb 100644 --- a/tests/Feature/Commands/CacheCommandCharacterizationTest.php +++ b/tests/Feature/Commands/CacheCommandCharacterizationTest.php @@ -6,8 +6,8 @@ use Illuminate\Contracts\Console\Kernel; use Simtabi\Laranail\Ichava\Commands\CacheCommand; use Symfony\Component\Console\Output\OutputInterface; -use Simtabi\Laranail\Ichava\Actions\ClearDiscoveryCaches; use Simtabi\Laranail\Ichava\Services\CacheOperationsService; +use Simtabi\Laranail\Ichava\Support\DiscoveryCacheGeneration; use Simtabi\Laranail\Ichava\Tests\Support\RunsCommandsForCharacterization; /* @@ -146,7 +146,7 @@ function bindThrowingCacheOperations(): void it('clears the discovery caches, each pack count cache and the watcher fingerprints', function (): void { // clear used to call IconCacheService::forgetPattern(), which never // existed, so it failed on every run. - $generation = ClearDiscoveryCaches::generation(); + $generation = DiscoveryCacheGeneration::current(); [$exit, $display] = $this->runCommand( CACHE_COMMAND, @@ -155,7 +155,7 @@ function bindThrowingCacheOperations(): void ); $this->assertSame(0, $exit); - $this->assertSame($generation + 1, ClearDiscoveryCaches::generation(), 'The discovery caches were not retired.'); + $this->assertSame($generation + 1, DiscoveryCacheGeneration::current(), 'The discovery caches were not retired.'); $this->assertDisplayContains($display, [ '🧹 Clearing icon caches', 'Cleared 3 cache group(s)', @@ -169,12 +169,12 @@ function bindThrowingCacheOperations(): void }); it('clears one pack with --package', function (): void { - $generation = ClearDiscoveryCaches::generation(); + $generation = DiscoveryCacheGeneration::current(); [$exit, $display] = $this->runCommand(CACHE_COMMAND, ['action' => 'clear', '--package' => 'ichava/test-icons']); $this->assertSame(0, $exit); - $this->assertSame($generation + 1, ClearDiscoveryCaches::generation()); + $this->assertSame($generation + 1, DiscoveryCacheGeneration::current()); $this->assertDisplayContains($display, [ 'Clearing cache for package: ichava/test-icons...', 'Cleared 2 cache group(s)', diff --git a/tests/Feature/ComponentNamespaceDeferralTest.php b/tests/Feature/ComponentNamespaceDeferralTest.php index b8fb2511..4e58a71f 100644 --- a/tests/Feature/ComponentNamespaceDeferralTest.php +++ b/tests/Feature/ComponentNamespaceDeferralTest.php @@ -3,81 +3,122 @@ declare(strict_types=1); use Illuminate\Support\Facades\Blade; +use Simtabi\Laranail\Ichava\Services\IconRegistry; +use Simtabi\Laranail\Ichava\View\ComponentPrefixes; +use Simtabi\Laranail\Ichava\Exceptions\IchavaException; +use Simtabi\Laranail\Ichava\View\Components\IconComponent; -/** - * The bare `ichava` Blade component namespace is deferred, deliberately. - * - * This is the enforceable half of the DEFERRED note in - * `IchavaServiceProvider::bootingPackage()`. The note explains why the bare - * slug stays; this pins that it is still there, so the decision cannot be - * reversed by accident inside unrelated work -- a comment is deleted with the - * code it sits next to, and prose does not fail a build. - * - * **If you are reading this because the test failed, that is the design.** You - * are taking Decision B. It is a breaking change to the ecosystem's documented - * public API and it needs its own release, a migration note, and the ~112 - * `` call sites across the estate updated. Update this test and - * the note together, or put the registration back. +/* + * Proposal 1. `ichava-core` is core's canonical Blade prefix and is always + * registered; the short prefix (`` by default) is a configured + * convenience beside it: `components.short_prefix`, validated against Blade's + * own tag parser, or null to turn it off. `components.enabled` false registers + * no core tag at all. Every test reads Blade's live maps, not the provider. * - * Asserted against the live compiler rather than the provider source: grepping - * a registration proves how it was written, not what Blade ended up holding. + * This replaces the "deferred bare namespace" pins: the short form is no longer + * a deferral but the configured default, and the 331 existing + * `` call sites keep working unless a host turns it off. */ -it('still registers the deferred bare component namespace', function (): void { - expect(Blade::getClassComponentNamespaces()) - ->toHaveKey('ichava', 'Simtabi\\Laranail\\Ichava\\View\\Components'); -}); -it('still registers the deferred bare alias for the headline component', function (): void { - // `` is the first thing the README teaches and the single - // most-referenced name in the ecosystem. Core covered it only by rendering - // the tag (IconComponentAttributesTest); nothing asserted the alias key, - // so a rename would have surfaced as a render failure elsewhere rather - // than as a decision being taken here. - expect(Blade::getClassComponentAliases())->toHaveKey('ichava::icon'); +const ICHAVA_COMPONENTS = 'Simtabi\\Laranail\\Ichava\\View\\Components'; + +afterEach(function (): void { + // Blade's maps live on a singleton that outlives the app between tests. + $this->configOverrides = []; }); -/* - * The vendor-scoped prefix, `ichava-core`, sits beside the bare one (V-B11). - * - * Additive only: both prefixes resolve the same classes, and nothing about the - * bare prefix changes. Read from Blade's live maps, then proven by rendering - * the same icon through both tags. - */ -it('registers the vendor-scoped component namespace over the same classes', function (): void { +function coreAliases(): array +{ + return array_filter( + Blade::getClassComponentAliases(), + static fn (string $class): bool => str_starts_with($class, ICHAVA_COMPONENTS . '\\'), + ); +} + +it('registers the canonical and the short prefix by default, over the same class', function (): void { + $aliases = Blade::getClassComponentAliases(); $namespaces = Blade::getClassComponentNamespaces(); - expect($namespaces)->toHaveKey('ichava-core') - ->and($namespaces['ichava-core'])->toBe($namespaces['ichava']); + expect($aliases['ichava-core::icon'] ?? null)->toBe(IconComponent::class) + ->and($aliases['ichava::icon'] ?? null)->toBe(IconComponent::class) + ->and($namespaces['ichava-core'] ?? null)->toBe(ICHAVA_COMPONENTS) + ->and($namespaces['ichava'] ?? null)->toBe(ICHAVA_COMPONENTS) + ->and(app(ComponentPrefixes::class)->registered())->toBe(['ichava-core', 'ichava']); }); -it('registers the vendor-scoped alias for the headline component', function (): void { +it('drops the short prefix when it is null, and keeps the canonical one', function (): void { + $this->configOverrides = ['ichava.ichava-core.components.short_prefix' => null]; + $this->refreshApplication(); + $aliases = Blade::getClassComponentAliases(); expect($aliases)->toHaveKey('ichava-core::icon') - ->and($aliases['ichava-core::icon'])->toBe($aliases['ichava::icon']); + ->and($aliases)->not->toHaveKey('ichava::icon') + ->and(Blade::getClassComponentNamespaces())->not->toHaveKey('ichava') + ->and(fn () => Blade::render(''))->toThrow(InvalidArgumentException::class, 'ichava::icon'); }); -it('adds no bare alias beyond the deferred one', function (): void { - // The scoped prefix must not bring a new generic key with it: every alias - // core registers is either the deferred `ichava::icon` or vendor-scoped. - $core = array_filter( - Blade::getClassComponentAliases(), - static fn (string $class): bool => str_starts_with($class, 'Simtabi\\Laranail\\Ichava\\View\\Components\\'), - ); +it('registers another short prefix in place of ichava', function (): void { + $this->configOverrides = ['ichava.ichava-core.components.short_prefix' => 'icon']; + $this->refreshApplication(); + + expect(Blade::getClassComponentAliases())->toHaveKey('icon::icon') + ->not->toHaveKey('ichava::icon'); +}); - expect(array_keys($core))->not->toBeEmpty(); +it('refuses a short prefix Blade cannot parse, or one that is the canonical prefix', function (string $prefix): void { + $this->configOverrides = ['ichava.ichava-core.components.short_prefix' => $prefix]; - foreach (array_keys($core) as $alias) { - expect($alias === 'ichava::icon' || str_starts_with($alias, 'ichava-core::'))->toBeTrue("unscoped alias {$alias}"); + expect(fn () => $this->refreshApplication())->toThrow(IchavaException::class, 'short_prefix'); +})->with(['a/b', 'a::b', 'ichava-core', 'Bad Prefix']); + +it('registers no core tag when components are disabled', function (): void { + $this->configOverrides = ['ichava.ichava-core.components.enabled' => false]; + $this->refreshApplication(); + + expect(coreAliases())->toBe([]) + ->and(Blade::getClassComponentNamespaces())->not->toHaveKey('ichava-core'); +}); + +it('renders the same bytes through every registered prefix', function (): void { + app(IconRegistry::class)->fromDirectory(dirname(__DIR__) . '/fixtures/KindPack'); + + $short = Blade::render(''); + $canonical = Blade::render(''); + + expect($short)->toContain('and($canonical)->toBe($short); +}); + +it('adds no unscoped alias beyond the configured short one', function (): void { + $short = config('ichava.ichava-core.components.short_prefix'); + + expect(coreAliases())->not->toBeEmpty(); + + foreach (array_keys(coreAliases()) as $alias) { + expect(str_starts_with($alias, 'ichava-core::') || ($short !== null && str_starts_with($alias, "{$short}::")))->toBeTrue("unscoped alias {$alias}"); } }); -it('renders the same bytes through the bare and the scoped tag', function (): void { - app(Simtabi\Laranail\Ichava\Services\IconRegistry::class)->fromDirectory(dirname(__DIR__) . '/fixtures/KindPack'); +it('reports a short alias another provider has overwritten', function (): void { + expect(app(ComponentPrefixes::class)->shadowedShortAlias())->toBeNull(); + + Blade::component('ichava::icon', Illuminate\View\Component::class); + + expect(app(ComponentPrefixes::class)->shadowedShortAlias())->toBe(Illuminate\View\Component::class); +}); + +it('has doctor warn when the short alias is shadowed', function (): void { + $status = function (): ?string { + Illuminate\Support\Facades\Artisan::call('ichava::ichava-core.info', ['type' => 'doctor', '--format' => 'json']); + $report = json_decode(Illuminate\Support\Facades\Artisan::output(), true); + + return collect($report['checks'] ?? [])->firstWhere('id', 'component.short_prefix_shadowed')['status'] ?? null; + }; + + expect($status())->toBe('ok'); - $bare = Blade::render(''); - $scoped = Blade::render(''); + Blade::component('ichava::icon', Illuminate\View\Component::class); - expect($bare)->toContain('and($scoped)->toBe($bare); + expect($status())->toBe('warn'); }); diff --git a/tests/Feature/CoreTranslationsTest.php b/tests/Feature/CoreTranslationsTest.php index 1aca82c2..d66b5f0b 100644 --- a/tests/Feature/CoreTranslationsTest.php +++ b/tests/Feature/CoreTranslationsTest.php @@ -10,7 +10,7 @@ |-------------------------------------------------------------------------- | | Core extends PackageServiceProvider directly, so it does NOT inherit the -| default-on registration that Support\ServiceProvider gives the packs. It has +| default-on registration that Providers\PackServiceProvider gives the packs. It has | to call hasTranslations() itself -- an asymmetry that has already cost one | defect class in this ecosystem. | diff --git a/tests/Feature/CustomIconSetsTest.php b/tests/Feature/CustomIconSetsTest.php new file mode 100644 index 00000000..6a3571e0 --- /dev/null +++ b/tests/Feature/CustomIconSetsTest.php @@ -0,0 +1,89 @@ +'; + + File::ensureDirectoryExists("{$root}/social"); + File::put("{$root}/social/twitter.svg", $svg); + + foreach (['alpha', 'beta'] as $set) { + File::ensureDirectoryExists("{$root}/vendored/{$set}/files"); + File::put("{$root}/vendored/{$set}/files/square.svg", $svg); + File::put("{$root}/vendored/{$set}/config.json", json_encode(['package' => ['name' => "vendored/{$set}"], 'config' => ['icon_prefix' => $set]])); + } + + return $root; +} + +afterEach(function (): void { + foreach (glob(sys_get_temp_dir() . '/ichava-custom-*') ?: [] as $dir) { + File::deleteDirectory($dir); + } +}); + +it('registers a flat directory with every documented key applied, at boot', function (): void { + $root = customIconsTree(); + $this->configOverrides = ['ichava.ichava-core.custom-icons.sets' => [ + 'social' => [ + 'enabled' => true, + 'path' => "{$root}/social", + 'prefix' => 'soc', + 'default_class' => 'social-icon', + 'blade_namespace' => 'ichava', + 'blade_component' => 'social', + ], + ]]; + $this->refreshApplication(); + + $registry = app(IconRegistry::class); + $meta = $registry->get('app/social'); + + expect($meta['prefix'])->toBe('soc') + ->and($meta['blade_component'])->toBe('ichava-social') + ->and($registry->render('app/social::twitter'))->toContain('social-icon'); +}); + +it('skips an entry that is not enabled', function (): void { + $root = customIconsTree(); + + app(IconRegistry::class)->registerFromConfig(['social' => ['enabled' => false, 'path' => "{$root}/social"]]); + + expect(app(IconRegistry::class)->isRegistered('app/social'))->toBeFalse(); +}); + +it('names a set by its package key when one is given, and keeps a plain path entry working', function (): void { + $root = customIconsTree(); + + app(IconRegistry::class)->registerFromConfig([ + 'social' => ['path' => "{$root}/social", 'package' => 'acme/social'], + "{$root}/vendored/alpha", + ]); + + expect(app(IconRegistry::class)->isRegistered('acme/social'))->toBeTrue() + ->and(app(IconRegistry::class)->isRegistered('vendored/alpha'))->toBeTrue(); +}); + +it('registers every set under a bulk entry, limited by only', function (): void { + $root = customIconsTree(); + + app(IconRegistry::class)->registerFromConfig([ + 'vendored' => ['path' => "{$root}/vendored", 'bulk' => true, 'only' => ['beta']], + ]); + + expect(app(IconRegistry::class)->isRegistered('vendored/beta'))->toBeTrue() + ->and(app(IconRegistry::class)->isRegistered('vendored/alpha'))->toBeFalse(); +}); diff --git a/tests/Feature/DiscoveryActionsTest.php b/tests/Feature/DiscoveryActionsTest.php index d051e02f..bec94e3d 100644 --- a/tests/Feature/DiscoveryActionsTest.php +++ b/tests/Feature/DiscoveryActionsTest.php @@ -40,7 +40,7 @@ 'file_path' => 'anchor.svg', ]); - $result = app(SearchIconsInDatabase::class)(query: 'anchor'); + $result = app(SearchIconsInDatabase::class)->handle(query: 'anchor'); expect($result['total'])->toBe(1) ->and($result['items'][0]['name'])->toBe('anchor'); @@ -74,7 +74,7 @@ expect($svc->searchIcons('anchor')['total'] ?? 0)->toBe(0); - app(ClearDiscoveryCaches::class)(); + app(ClearDiscoveryCaches::class)->handle(); expect($svc->searchIcons('anchor')['total'] ?? 0)->toBe(1); }); diff --git a/tests/Feature/IconMorphAliasTest.php b/tests/Feature/IconMorphAliasTest.php new file mode 100644 index 00000000..bfb381b7 --- /dev/null +++ b/tests/Feature/IconMorphAliasTest.php @@ -0,0 +1,56 @@ + Icon::class]); +}); + +it('registers a vendor-scoped morph alias for Icon', function (): void { + expect(Icon::MORPH_ALIAS)->toBe('ichava-icon') + ->and((new Icon)->getMorphClass())->toBe('ichava-icon') + ->and(Relation::getMorphedModel('ichava-icon'))->toBe(Icon::class) + ->and(Relation::getMorphedModel('icon'))->toBeNull(); +}); + +it('moves stored termable rows to the scoped alias and back', function (): void { + $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_05_000001_namespace_icon_morph_alias.php'; + + $terms = collect(['first', 'second'])->map(fn (string $slug): int => IconTerm::query()->create([ + 'type' => IconTerm::TYPE_CATEGORY, 'package' => 'ichava/test-icons', 'name' => $slug, 'slug' => $slug, + ])->id)->all(); + + DB::table('ichava_icon_termables')->insert([ + ['term_id' => $terms[0], 'termable_id' => 10, 'termable_type' => 'icon'], + ['term_id' => $terms[1], 'termable_id' => 11, 'termable_type' => 'App\\Models\\Post'], + ]); + + $migration->up(); + expect(DB::table('ichava_icon_termables')->orderBy('term_id')->pluck('termable_type')->all()) + ->toBe(['ichava-icon', 'App\\Models\\Post']); + + $migration->down(); + expect(DB::table('ichava_icon_termables')->orderBy('term_id')->pluck('termable_type')->all()) + ->toBe(['icon', 'App\\Models\\Post']); +}); + +it('refuses to boot when another provider maps the alias to a different model', function (): void { + $this->extraProviders = [ClaimsIchavaMorphAliasProvider::class]; + + expect(fn () => $this->refreshApplication()) + ->toThrow(IchavaException::class, 'ichava-icon'); +}); diff --git a/tests/Feature/IconSvgReaderTest.php b/tests/Feature/IconSvgReaderTest.php new file mode 100644 index 00000000..f48c11b8 --- /dev/null +++ b/tests/Feature/IconSvgReaderTest.php @@ -0,0 +1,34 @@ +dir = sys_get_temp_dir() . '/ichava-reader-' . bin2hex(random_bytes(4)); + File::ensureDirectoryExists($this->dir); +}); + +afterEach(fn () => File::deleteDirectory($this->dir)); + +it('serves a sanitised SVG and refuses a script', function (): void { + File::put($this->dir . '/a.svg', ''); + + $svg = app(IconSvgReader::class)->read($this->dir . '/a.svg', 'a.svg', 'reader:' . bin2hex(random_bytes(4)), IconKind::Icon, 'acme/reader', $this->dir); + + expect($svg)->toContain('not->toContain('dir . '/a.svg', ''); + + expect(app(IconSvgReader::class)->read($this->dir . '/a.svg', 'a.svg', 'reader:' . bin2hex(random_bytes(4)), IconKind::Icon, 'acme/reader', $this->dir . '/elsewhere'))->toBe(''); +}); diff --git a/tests/Feature/JsonConfigConstantsReadsDefinitionTest.php b/tests/Feature/JsonConfigConstantsReadsDefinitionTest.php new file mode 100644 index 00000000..b9fae4bc --- /dev/null +++ b/tests/Feature/JsonConfigConstantsReadsDefinitionTest.php @@ -0,0 +1,27 @@ + IconsConstants::clearCache()); +afterEach(fn () => IconsConstants::clearCache()); + +it('reads the registered definition, overrides included', function (): void { + $dir = dirname(__DIR__) . '/fixtures/BasePack/resources/assets/svg'; + + expect(IconsConstants::getPrefix())->not->toBe('overridden'); + + app(IconRegistry::class)->register(PackBuilder::fromDirectory($dir)->prefix('overridden')->variant('ghost')->build()); + + expect(IconsConstants::getPrefix())->toBe('overridden') + ->and(IconsConstants::hasVariant('ghost'))->toBeTrue(); +}); diff --git a/tests/Feature/LoggerErrorCallSitesTest.php b/tests/Feature/LoggerErrorCallSitesTest.php index 68a5c138..71363f21 100644 --- a/tests/Feature/LoggerErrorCallSitesTest.php +++ b/tests/Feature/LoggerErrorCallSitesTest.php @@ -7,6 +7,7 @@ use Simtabi\Laranail\Ichava\Events\IconCacheEvent; use Simtabi\Laranail\Ichava\Services\IchavaLogger; use Simtabi\Laranail\Ichava\Services\IconRegistry; +use Simtabi\Laranail\Ichava\Support\PackConfigCache; use Simtabi\Laranail\Ichava\Services\IconCacheService; use Simtabi\Laranail\Ichava\Exceptions\IchavaException; use Simtabi\Laranail\Ichava\Services\IconWatcherService; @@ -58,7 +59,7 @@ protected function migrationPath(): ?string it('logs a directory the watcher cannot scan instead of throwing', function (): void { File::shouldReceive('allFiles')->andThrow(IchavaException::invalidConfiguration('unreadable')); - $watcher = new IconWatcherService(app(IconRegistry::class), app(IchavaLogger::class)); + $watcher = new IconWatcherService(app(IconRegistry::class), app(IchavaLogger::class), app(PackConfigCache::class)); $scan = new ReflectionMethod($watcher, 'scanDiskIcons'); expect($scan->invoke($watcher, '/nowhere', 'tests/pack'))->toBe([]); diff --git a/tests/Feature/PackBuilderTest.php b/tests/Feature/PackBuilderTest.php new file mode 100644 index 00000000..881441bd --- /dev/null +++ b/tests/Feature/PackBuilderTest.php @@ -0,0 +1,155 @@ +'; + + foreach (['shapes/circle', 'shapes/dot', 'illustrations/office', 'illustrations/scene', 'illustrations/scene-dark'] as $icon) { + File::ensureDirectoryExists(dirname("{$dir}/files/{$icon}.svg")); + File::put("{$dir}/files/{$icon}.svg", $svg); + } + + return $dir; +} + +afterEach(function (): void { + foreach (glob(sys_get_temp_dir() . '/ichava-builder-*') ?: [] as $dir) { + File::deleteDirectory($dir); + } +}); + +it('registers a set with no config.json, from code alone', function (): void { + $registry = app(IconRegistry::class); + + $registry->register( + PackBuilder::make('acme/social', plantSet() . '/files') + ->prefix('social') + ->about('title', 'Acme social') + ->variant('outline', ['fill' => 'none'], 'Stroked') + ->build(), + ); + + $meta = $registry->get('acme/social'); + + expect($meta['prefix'])->toBe('social') + ->and($meta['name'])->toBe('Acme social') + ->and($meta['total'])->toBe(5) + ->and($meta['vendor'])->toBe('acme') + ->and(VariantData::fromMetadata('outline', $meta['variant_metadata']['outline'])->attributes)->toBe(['fill' => 'none']) + ->and($registry->has('acme/social::shapes/circle'))->toBeTrue(); +}); + +it('resolves a kind from a fluent rule, tried before config.json rules', function (): void { + $registry = app(IconRegistry::class); + $registry->register(PackBuilder::make('acme/kinds', plantSet() . '/files') + ->kindRule('illustrations/**', IconKind::Illustration) + ->build()); + + expect($registry->kindOf('acme/kinds::illustrations/office'))->toBe(IconKind::Illustration) + ->and($registry->kindOf('acme/kinds::shapes/circle'))->toBe(IconKind::Icon); +}); + +it('resolves a kind per item, per category and for the pack', function (): void { + $registry = app(IconRegistry::class); + $registry->register(PackBuilder::make('acme/rows', plantSet() . '/files') + ->kind(IconKind::Illustration) + ->categoryKind('shapes', IconKind::Icon) + ->itemKind('shapes/dot', IconKind::Illustration) + ->build()); + + expect($registry->kindOf('acme/rows::shapes/circle'))->toBe(IconKind::Icon) + ->and($registry->kindOf('acme/rows::shapes/dot'))->toBe(IconKind::Illustration) + ->and($registry->kindOf('acme/rows::illustrations/office'))->toBe(IconKind::Illustration); +}); + +it('records a theme twin the registry resolves', function (): void { + $registry = app(IconRegistry::class); + $registry->register(PackBuilder::make('acme/twins', plantSet() . '/files') + ->themeTwin('illustrations', 'dark', '-dark') + ->build()); + + expect($registry->themeTwin('acme/twins::illustrations/scene'))->toBe('acme/twins::illustrations/scene-dark'); +}); + +it('registers aliases the renderer follows', function (): void { + $registry = app(IconRegistry::class); + $registry->register(PackBuilder::make('acme/aliased', plantSet() . '/files') + ->alias('shapes/ring', 'shapes/dot') + ->build()); + + expect($registry->aliases('acme/aliased'))->toBe(['shapes/ring' => 'shapes/dot']) + ->and($registry->render('acme/aliased::shapes/ring'))->toContain('{$method}(...$args)->build(); + + expect($read($definition))->toBeTrue(); +})->with([ + 'a scalar override replaces the file value' => ['prefix', ['vp'], fn ($d): bool => $d->prefix === 'vp'], + 'a map entry merges, the file keeps its other keys' => ['variant', ['outline', ['stroke' => 'red']], fn ($d): bool => $d->config['metadata']['data']['variants']['outline']['attributes'] === ['stroke_width' => 2, 'fill' => 'none', 'stroke' => 'red'] + && isset($d->config['metadata']['data']['variants']['filled'])], + 'a fluent rule comes before the file rules' => ['kindRule', ['outline/**', IconKind::Illustration], fn ($d): bool => $d->config['metadata']['data']['kinds']['rules'][0]['match'] === 'outline/**'], +]); + +it('prepends fluent rules to the rules config.json declares', function (): void { + $definition = PackBuilder::fromDirectory(builderFixture('MixedKindPack')) + ->kindRule('**', IconKind::Icon) + ->build(); + $rules = $definition->config['metadata']['data']['kinds']['rules']; + + expect($rules[0])->toBe(['match' => '**', 'kind' => 'icon']) + ->and(array_column(array_slice($rules, 1), 'match'))->toBe(['spot/tiny-*', 'spot/**']); +}); + +it('refuses what it cannot register', function (Closure $build, string $message): void { + expect($build)->toThrow(IchavaException::class, $message); +})->with([ + 'a name that is not vendor/package' => [fn () => PackBuilder::make('social', '/tmp/x')->build(), 'vendor/package'], + 'an empty prefix' => [fn () => PackBuilder::make('acme/x', '/tmp/x')->prefix('')->build(), 'non-empty prefix'], + 'an invalid glob' => [fn () => PackBuilder::make('acme/x', '/tmp/x')->kindRule('a/**b', IconKind::Icon), 'not a valid glob'], + 'an unknown about key' => [fn () => PackBuilder::make('acme/x', '/tmp/x')->about('colour', 'red'), 'unknown pack field'], +]); + +it('gives a set registered through package() the full metadata fromDirectory() gives (C-03)', function (): void { + $registry = app(IconRegistry::class); + $dir = builderFixture('MixedKindPack'); + $fromDirectory = PackBuilder::fromDirectory($dir)->build()->toMetadata(0); + + $registry->package('ichava-fixtures/via-package') + ->fromConfigFile($dir . '/config.json') + ->register(); + + expect(array_keys($registry->get('ichava-fixtures/via-package')))->toContain(...array_keys($fromDirectory)) + ->and($registry->get('ichava-fixtures/via-package')['kind_rules'])->toBe($fromDirectory['kind_rules']); +}); + +it('runs a conditional chain only when its condition holds', function (): void { + $definition = PackBuilder::make('acme/when', '/tmp/x/files') + ->when(false, fn (PackBuilder $b) => $b->prefix('no')) + ->when(true, fn (PackBuilder $b) => $b->prefix('yes')) + ->build(); + + expect($definition->prefix)->toBe('yes') + ->and($definition->basePath)->toBe('/tmp/x'); +}); diff --git a/tests/Feature/PackConfigDefaultsTest.php b/tests/Feature/PackConfigDefaultsTest.php new file mode 100644 index 00000000..6098d02e --- /dev/null +++ b/tests/Feature/PackConfigDefaultsTest.php @@ -0,0 +1,53 @@ +` and not `` or render(), while + * `config.defaults` in config.json had no reader. `config.defaults` is now the + * one source, applied when the set registers, so every way of rendering the + * set draws the same attributes. + */ + +beforeEach(function (): void { + $this->dir = sys_get_temp_dir() . '/ichava-defaults-' . bin2hex(random_bytes(4)); + File::ensureDirectoryExists($this->dir . '/files'); + File::put($this->dir . '/files/line.svg', ''); + File::put($this->dir . '/config.json', json_encode([ + 'package' => ['name' => 'acme/stroked', 'title' => 'Stroked'], + 'config' => [ + 'icon_prefix' => 'stroked', + 'defaults' => ['class' => 'stroked-icon', 'stroke_width' => 2, 'attributes' => ['stroke-linecap' => 'round']], + ], + ])); + app(IconRegistry::class)->register(PackBuilder::fromDirectory($this->dir)->build()); +}); + +afterEach(fn () => File::deleteDirectory($this->dir)); + +it('applies config.defaults when the registry renders', function (): void { + $svg = app(IconRegistry::class)->render('acme/stroked::line'); + + expect($svg)->toContain('stroke-width="2"') + ->toContain('stroke-linecap="round"') + ->toContain('stroked-icon'); +}); + +it('applies config.defaults through the generic Blade tag', function (): void { + $html = Blade::render(''); + + expect($html)->toContain('stroke-width="2"')->toContain('stroke-linecap="round"'); +}); + +it('lets a fluent default override the file one', function (): void { + app()->forgetInstance(IconRegistry::class); + app(IconRegistry::class)->register(PackBuilder::fromDirectory($this->dir)->defaultAttributes(['stroke-linecap' => 'square'])->build()); + + expect(app(IconRegistry::class)->render('acme/stroked::line'))->toContain('stroke-linecap="square"'); +}); diff --git a/tests/Feature/PackSpecTest.php b/tests/Feature/PackSpecTest.php new file mode 100644 index 00000000..4582d0ed --- /dev/null +++ b/tests/Feature/PackSpecTest.php @@ -0,0 +1,71 @@ +app->register(IconsServiceProvider::class); + $this->registry = app(IconRegistry::class); +}); + +it('shapes the default set from code', function (): void { + expect($this->registry->kindOf('ichava/icon-sets-spec-fixture::illustrations/office'))->toBe(IconKind::Illustration) + ->and($this->registry->kindOf('ichava/icon-sets-spec-fixture::shapes/circle'))->toBe(IconKind::Icon) + ->and($this->registry->aliases('ichava/icon-sets-spec-fixture'))->toBe(['shapes/ring' => 'shapes/dot']); +}); + +it('registers a second set and the chosen sets of a directory, attributed to the pack', function (): void { + expect($this->registry->get('ichava/icon-sets-spec-fixture-brands')['prefix'])->toBe('brand') + ->and($this->registry->get('ichava/icon-sets-spec-fixture-brands')['provider_class'])->toBe(IconsServiceProvider::class) + ->and($this->registry->isRegistered('ichava/icon-sets-spec-fixture-two'))->toBeTrue() + ->and($this->registry->isRegistered('ichava/icon-sets-spec-fixture-one'))->toBeFalse(); +}); + +it('registers the main and the extra component, canonical and short', function (): void { + $aliases = Blade::getClassComponentAliases(); + + expect($aliases['ichava-icon-sets-spec-fixture::icon'] ?? null)->toBe(IconComponent::class) + ->and($aliases['icon-sets-spec-fixture-icon'] ?? null)->toBe(IconComponent::class) + ->and($aliases['ichava-icon-sets-spec-fixture::badge'] ?? null)->toBe(TestIconsPackComponent::class) + ->and($aliases['icon-sets-spec-fixture-badge'] ?? null)->toBe(TestIconsPackComponent::class) + ->and($this->registry->get('ichava/icon-sets-spec-fixture')['blade_component'])->toBe('icon-sets-spec-fixture-icon'); +}); + +it('registers the config file under the name the pack gives', function (): void { + $package = (new ReflectionProperty($this->app->getProvider(IconsServiceProvider::class), 'package')) + ->getValue($this->app->getProvider(IconsServiceProvider::class)); + + expect($package->configFileNames)->toContain('spec-custom') + ->not->toContain('icon-sets-spec-fixture'); +}); + +it('leaves a pack that writes no pack() hook exactly as before', function (): void { + $this->app->register(Simtabi\Laranail\Ichava\Tests\Fixtures\BasePack\Providers\IconsServiceProvider::class); + + expect($this->registry->get('ichava/icon-sets-base-fixture')['blade_component'])->toBe('icon-sets-base-fixture-icon') + ->and(Blade::getClassComponentAliases())->toHaveKey('ichava-icon-sets-base-fixture::icon'); +}); + +it('registers only the canonical pack tags when the short prefix is off', function (): void { + $this->configOverrides = ['ichava.ichava-core.components.short_prefix' => null]; + $this->refreshApplication(); + $this->app->register(IconsServiceProvider::class); + + $aliases = Blade::getClassComponentAliases(); + + expect($aliases)->toHaveKey('ichava-icon-sets-spec-fixture::icon') + ->not->toHaveKey('icon-sets-spec-fixture-icon') + ->and(app(IconRegistry::class)->get('ichava/icon-sets-spec-fixture')['blade_component'])->toBe('ichava-icon-sets-spec-fixture::icon'); +}); diff --git a/tests/Feature/RegistrationGoldenTest.php b/tests/Feature/RegistrationGoldenTest.php new file mode 100644 index 00000000..52fdde6f --- /dev/null +++ b/tests/Feature/RegistrationGoldenTest.php @@ -0,0 +1,74 @@ + is_file($dir . '/config.json'))); + sort($dirs); + + return $dirs; +} + +/** @return array */ +function goldenMetadataFor(string $dir): array +{ + $root = dirname(__DIR__, 2); + $registry = app(IconRegistry::class); + + try { + $before = array_keys($registry->all()); + $registry->fromDirectory($dir); + $added = array_values(array_diff(array_keys($registry->all()), $before)); + $name = $added[0] ?? json_decode((string) file_get_contents($dir . '/config.json'), true)['package']['name']; + $metadata = $registry->all()[$name]; + } catch (Throwable $e) { + return ['exception' => $e::class, 'message' => str_replace($root, '', $e->getMessage())]; + } + + unset($metadata['registered_at']); + $metadata['base_path'] = str_replace($root, '', (string) $metadata['base_path']); + ksort($metadata); + + return ['aliases' => $registry->aliases($name), 'metadata' => $metadata]; +} + +it('registers every estate and fixture pack exactly as before the refactor', function (): void { + $root = dirname(__DIR__, 2); + $snapshotPath = $root . '/tests/fixtures/RegistrationGolden.json'; + $dirs = goldenPackDirectories(); + + expect(count($dirs))->toBeGreaterThanOrEqual(31); + + $actual = []; + foreach ($dirs as $dir) { + app()->forgetInstance(IconRegistry::class); + $actual[str_replace($root, '', $dir)] = goldenMetadataFor($dir); + } + + if (getenv('ICHAVA_WRITE_GOLDEN') === '1') { + file_put_contents($snapshotPath, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } + + expect($actual)->toBe(json_decode((string) file_get_contents($snapshotPath), true)); +}); diff --git a/tests/Feature/RuntimeLimitsScopeTest.php b/tests/Feature/RuntimeLimitsScopeTest.php new file mode 100644 index 00000000..fd5d6d81 --- /dev/null +++ b/tests/Feature/RuntimeLimitsScopeTest.php @@ -0,0 +1,65 @@ +hostMemory = ini_get('memory_limit'); + $this->hostTime = ini_get('max_execution_time'); +}); + +afterEach(function (): void { + ini_set('memory_limit', (string) $this->hostMemory); + ini_set('max_execution_time', (string) $this->hostTime); +}); + +it('leaves the host memory and time limits alone when the provider boots', function (): void { + ini_set('memory_limit', '3G'); + ini_set('max_execution_time', '45'); + + $this->refreshApplication(); + + expect(ini_get('memory_limit'))->toBe('3G') + ->and(ini_get('max_execution_time'))->toBe('45'); +}); + +it('applies the merged config inside an ichava operation and restores the host values after', function (): void { + config()->set('ichava.ichava-core.runtime.memory_limit', '2G'); + config()->set('ichava.ichava-core.runtime.max_execution_time', 0); + ini_set('memory_limit', '3G'); + ini_set('max_execution_time', '45'); + + $inside = app(RuntimeLimits::class)->scope(fn (): array => [ini_get('memory_limit'), ini_get('max_execution_time')]); + + expect($inside)->toBe(['2G', '0']) + ->and(ini_get('memory_limit'))->toBe('3G') + ->and(ini_get('max_execution_time'))->toBe('45'); +}); + +it('restores the host values when the operation throws', function (): void { + ini_set('memory_limit', '3G'); + + expect(fn () => app(RuntimeLimits::class)->scope(function (): never { + throw new RuntimeException('boom'); + }))->toThrow(RuntimeException::class); + + expect(ini_get('memory_limit'))->toBe('3G'); +}); + +it('reads the shipped config default, not a code fallback', function (): void { + expect(app(RuntimeLimits::class)->configurator()->getPending()) + ->toMatchArray(['memory_limit' => '2G', 'max_execution_time' => 0]); +}); + +it('lets one operation override the timeout without touching the config', function (): void { + expect(app(RuntimeLimits::class)->configurator(timeout: 120)->getPending()['max_execution_time'])->toBe(120); +}); diff --git a/tests/Feature/ServiceContractsTest.php b/tests/Feature/ServiceContractsTest.php new file mode 100644 index 00000000..a80c620d --- /dev/null +++ b/tests/Feature/ServiceContractsTest.php @@ -0,0 +1,50 @@ +toBe(app(IconPackUpdateChecker::class)) + ->and(app(DatabaseOperations::class))->toBe(app(DatabaseOperationsService::class)); +}); + +it('lets the update command run on any ChecksPackUpdates, no subclass needed', function (): void { + $fake = new class implements ChecksPackUpdates + { + public int $calls = 0; + + public function checkAll(?string $packageFilter = null): array + { + $this->calls++; + + return []; + } + + public function checkPackage(string $packageName): array + { + return []; + } + + public function checkOne(string $packageName): array + { + return []; + } + }; + app()->instance(ChecksPackUpdates::class, $fake); + + Artisan::call('ichava::ichava-core.check-updates', ['--format' => 'json']); + + expect($fake->calls)->toBe(1); +}); diff --git a/tests/Feature/SessionGuestMergeTest.php b/tests/Feature/SessionGuestMergeTest.php index f59d821d..1d1d2dce 100644 --- a/tests/Feature/SessionGuestMergeTest.php +++ b/tests/Feature/SessionGuestMergeTest.php @@ -31,7 +31,7 @@ HostCapabilities::getInstance()->reset(); InMemoryOwnerPreferenceStore::$rows = []; - $this->session = new IchavaSessionManager; + $this->session = new IchavaSessionManager(app(Simtabi\Laranail\Ichava\Services\IchavaLogger::class)); }); afterEach(function (): void { diff --git a/tests/Feature/SvgModelReadsThroughDriverTest.php b/tests/Feature/SvgModelReadsThroughDriverTest.php new file mode 100644 index 00000000..26cba5a7 --- /dev/null +++ b/tests/Feature/SvgModelReadsThroughDriverTest.php @@ -0,0 +1,48 @@ +dir = sys_get_temp_dir() . '/ichava-svg-driver-' . bin2hex(random_bytes(4)); + File::ensureDirectoryExists($this->dir); + + $this->icon = function (string $abs): Icon { + $icon = Icon::create([ + 'package' => 'ichava/test-pack', 'name' => pathinfo($abs, PATHINFO_FILENAME), 'path' => $abs, + 'file_hash' => md5($abs), 'keywords' => [], 'tags' => [], 'attributes' => [], 'metadata' => [], + ]); + $icon->forceFill(['path' => $abs])->save(); + + return $icon->fresh(); + }; +}); + +afterEach(function (): void { + File::deleteDirectory($this->dir); +}); + +it('refuses a file below the minimum SVG size, as the driver does', function (): void { + File::put($this->dir . '/tiny.svg', ''); + + expect(($this->icon)($this->dir . '/tiny.svg')->svg_content)->toBe(''); +}); + +it('refuses a symlinked SVG, as the driver does', function (): void { + File::put($this->dir . '/real.svg', ''); + symlink($this->dir . '/real.svg', $this->dir . '/link.svg'); + + expect(($this->icon)($this->dir . '/link.svg')->svg_content)->toBe('') + ->and(($this->icon)($this->dir . '/real.svg')->svg_content)->toContain(' Icon::class])`, so every -| termables row the seeder writes carries `termable_type = 'icon'`. The +| Core registers `Relation::morphMap(['ichava-icon' => Icon::class])`, so every +| termables row the seeder writes carries `termable_type = 'ichava-icon'`. The | PostgreSQL search clause and the search-text functions compared that column | with the fully-qualified class name instead, so no term ever matched and a | search for a category or variant name found nothing. @@ -110,7 +110,7 @@ function skipUnlessPostgres(): void $definition = DB::selectOne('SELECT pg_get_functiondef(?::regprocedure) AS def', [$function])->def; - expect($definition)->toContain("termable_type = 'icon'") + expect($definition)->toContain("termable_type = '" . Icon::MORPH_ALIAS . "'") ->and($definition)->not->toContain('Models'); })->with([ 'refresh_ichava_icon_search_text(bigint)', diff --git a/tests/Feature/TermsSeederReadsRegistryTest.php b/tests/Feature/TermsSeederReadsRegistryTest.php new file mode 100644 index 00000000..b9af0107 --- /dev/null +++ b/tests/Feature/TermsSeederReadsRegistryTest.php @@ -0,0 +1,32 @@ +fromDirectory($dir); + File::delete($dir . '/config.json'); + + $package = 'ichava-fixtures/variant-pack'; + app(IconTermsSeeder::class)->seedSinglePackage($package, $registry->all()[$package]); + + expect(IconTerm::query()->where('package', $package)->where('type', IconTerm::TYPE_VARIANT)->orderBy('slug')->pluck('slug')->all()) + ->toBe(['filled', 'outline']); + + File::deleteDirectory($dir); +}); diff --git a/tests/Feature/UniformResourceRegistrationTest.php b/tests/Feature/UniformResourceRegistrationTest.php index 812b099f..78be420a 100644 --- a/tests/Feature/UniformResourceRegistrationTest.php +++ b/tests/Feature/UniformResourceRegistrationTest.php @@ -12,7 +12,7 @@ | One registration mechanism, for every resource type |-------------------------------------------------------------------------- | -| Support\ServiceProvider hand-rolled the directory check for translations +| Providers\PackServiceProvider hand-rolled the directory check for translations | alone, so views and configs were never covered. package-tools already ships | loadAllResources() for exactly this, covering six resource types. | @@ -79,7 +79,7 @@ it('does not double-register a config the pack also declares by hand', function () { // The packs still call hasConfigFile() themselves, which auto-discovery now // also does. That redundancy is kept deliberately (see the note in - // Support\ServiceProvider), and the decision rests entirely on upstream + // Providers\PackServiceProvider), and the decision rests entirely on upstream // de-duplicating by name. If it ever appends instead, every pack in this // family starts merging its config twice -- so the assumption is pinned // here rather than trusted. diff --git a/tests/Feature/WatcherCacheDecouplingTest.php b/tests/Feature/WatcherCacheDecouplingTest.php new file mode 100644 index 00000000..98038f7b --- /dev/null +++ b/tests/Feature/WatcherCacheDecouplingTest.php @@ -0,0 +1,36 @@ +resolving(ConfigurationService::class, function () use (&$resolved): void { + $resolved++; + }); + + app(IconWatcherService::class)->rescanPackage('ichava/test-icons'); + + expect($resolved)->toBe(0); +}); + +it('forgets through the same key ConfigurationService remembers under', function (): void { + $dir = dirname(__DIR__, 2) . '/resources/assets/svg/test-icons'; + $cache = app(PackConfigCache::class); + + app(ConfigurationService::class)->rememberPackageConfig($dir); + + expect(cache()->store('file')->has($cache->key($dir)))->toBeTrue() + ->and($cache->forget($dir))->toBeTrue() + ->and(cache()->store('file')->has($cache->key($dir)))->toBeFalse(); +}); diff --git a/tests/Feature/WatcherMissingDirectoryTest.php b/tests/Feature/WatcherMissingDirectoryTest.php index f80d4734..98d66690 100644 --- a/tests/Feature/WatcherMissingDirectoryTest.php +++ b/tests/Feature/WatcherMissingDirectoryTest.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\Log; use Simtabi\Laranail\Ichava\Services\IchavaLogger; use Simtabi\Laranail\Ichava\Services\IconRegistry; +use Simtabi\Laranail\Ichava\Support\PackConfigCache; use Simtabi\Laranail\Ichava\Services\IconWatcherService; /** @@ -34,7 +35,7 @@ $missing = sys_get_temp_dir() . '/ichava-missing-' . bin2hex(random_bytes(6)); expect(is_dir($missing))->toBeFalse(); - $watcher = new IconWatcherService(app(IconRegistry::class), app(IchavaLogger::class)); + $watcher = new IconWatcherService(app(IconRegistry::class), app(IchavaLogger::class), app(PackConfigCache::class)); $scan = new ReflectionMethod($watcher, 'scanDiskIcons'); expect($scan->invoke($watcher, $missing, 'tests/pack'))->toBe([]); diff --git a/tests/Support/ClaimsIchavaMorphAliasProvider.php b/tests/Support/ClaimsIchavaMorphAliasProvider.php new file mode 100644 index 00000000..58bd4649 --- /dev/null +++ b/tests/Support/ClaimsIchavaMorphAliasProvider.php @@ -0,0 +1,22 @@ + IconTerm::class]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index b2b8f9f6..a8b1d305 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -19,6 +19,20 @@ abstract class TestCase extends Orchestra { use RefreshDatabase; + /** + * Providers a test registers after core's, for the next refreshApplication(). + * + * @var list + */ + protected array $extraProviders = []; + + /** + * Config a test sets before the providers boot, for the next refreshApplication(). + * + * @var array + */ + protected array $configOverrides = []; + /** * This test's own file-cache directory, created per application boot. * @@ -61,6 +75,7 @@ protected function getPackageProviders($app): array { return [ IchavaServiceProvider::class, + ...$this->extraProviders, ]; } @@ -78,6 +93,10 @@ protected function getEnvironmentSetUp($app): void // package namespaces its config under the short name it registers, so the // live key carries it. $app['config']->set('ichava.ichava-core.default_set', 'test'); + + foreach ($this->configOverrides as $key => $value) { + $app['config']->set($key, $value); + } } /** diff --git a/tests/Unit/ActionShapeTest.php b/tests/Unit/ActionShapeTest.php new file mode 100644 index 00000000..49140ff9 --- /dev/null +++ b/tests/Unit/ActionShapeTest.php @@ -0,0 +1,27 @@ +toBeGreaterThanOrEqual(6); + + foreach ($files as $file) { + $class = new ReflectionClass('Simtabi\\Laranail\\Ichava\\Actions\\' . basename($file, '.php')); + $public = array_values(array_filter( + array_map(fn (ReflectionMethod $m): string => $m->getName(), $class->getMethods(ReflectionMethod::IS_PUBLIC)), + fn (string $name): bool => $name !== '__construct', + )); + + expect($class->isFinal())->toBeTrue("{$class->getShortName()} is not final") + ->and($public)->toBe(['handle'], "{$class->getShortName()} exposes " . implode(', ', $public)); + } +}); diff --git a/tests/Unit/ContainerReachInTest.php b/tests/Unit/ContainerReachInTest.php new file mode 100644 index 00000000..b498287c --- /dev/null +++ b/tests/Unit/ContainerReachInTest.php @@ -0,0 +1,27 @@ + IconWatcherService edge, which stays lazy because the + * watcher's registry needs SvgDriver, which needs the cache service. + */ + +it('keeps the cleared services free of container reach-ins', function (string $file): void { + $source = (string) file_get_contents(dirname(__DIR__, 2) . '/src/' . $file); + $code = preg_replace('#/\*.*?\*/|//[^\n]*#s', '', $source); + + expect(preg_match_all('/\bapp\(/', (string) $code))->toBe(0); +})->with([ + 'Services/IconRegistry.php', + 'Services/IconWatcherService.php', + 'Services/ConfigurationService.php', + 'Support/IchavaSessionManager.php', + 'Support/PackConfigCache.php', + 'Support/Pack/PackBuilder.php', + 'Support/Pack/PackSpec.php', +]); diff --git a/tests/Unit/FinalByDefaultTest.php b/tests/Unit/FinalByDefaultTest.php new file mode 100644 index 00000000..431ab1fc --- /dev/null +++ b/tests/Unit/FinalByDefaultTest.php @@ -0,0 +1,47 @@ + 'the class every pack component extends', + // icon-browser type-hints both and its tests bind a subclass of the + // checker; they close once icon-browser uses the contracts (C-06). + 'IconPackUpdateChecker' => 'test seam in icon-browser; core consumers use Contracts\ChecksPackUpdates', + 'DatabaseOperationsService' => 'partial mocks in core and icon-browser; core consumers use Contracts\DatabaseOperations', + // Partial mocks in core's command characterization tests. + 'CacheOperationsService' => 'Mockery partial mock in CacheCommandCharacterizationTest', + 'IconSetCatalogService' => 'Mockery partial mock in InstallCommandCharacterizationTest', + 'IchavaLifecycleManager' => 'Mockery mock in LoggerErrorCallSitesTest', + 'IconDiscoveryService' => 'Mockery mock in LoggerErrorCallSitesTest', +]; + +it('makes every concrete class in src final unless it is listed with a reason', function (): void { + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(dirname(__DIR__, 2) . '/src', FilesystemIterator::SKIP_DOTS)); + $inspected = 0; + $open = []; + + foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $inspected++; + + if (preg_match('/^(?:readonly\s+)?class\s+(\w+)/m', (string) file_get_contents($file->getPathname()), $match) === 1) { + $open[] = $match[1]; + } + } + + expect($inspected)->toBeGreaterThanOrEqual(130) + ->and(array_values(array_diff($open, array_keys(ICHAVA_OPEN_CLASSES))))->toBe([]) + ->and(array_values(array_diff(array_keys(ICHAVA_OPEN_CLASSES), $open)))->toBe([]); +}); diff --git a/tests/Unit/IconDiscoveryActionsTest.php b/tests/Unit/IconDiscoveryActionsTest.php index fd2634f0..171033a1 100644 --- a/tests/Unit/IconDiscoveryActionsTest.php +++ b/tests/Unit/IconDiscoveryActionsTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use Illuminate\Filesystem\Filesystem; -use Simtabi\Laranail\Ichava\Actions\CountSvgFiles; +use Simtabi\Laranail\Ichava\Support\SvgFileCounter; use Simtabi\Laranail\Ichava\Actions\BuildIconUsageSyntax; use Simtabi\Laranail\Ichava\Actions\DiscoverInstalledPackages; @@ -35,7 +35,7 @@ function tempTree(): string // ---------------------------------------------------------------- syntax --- it('builds all three usage forms for a pack with a component', function () { - $syntax = (new BuildIconUsageSyntax)('ichava/tabler-icons', [ + $syntax = (new BuildIconUsageSyntax)->handle('ichava/tabler-icons', [ 'prefix' => 'ti', 'blade_component' => 'tabler-icons-icon', ], 'home'); @@ -59,7 +59,7 @@ function tempTree(): string }, E_WARNING); try { - $syntax = (new BuildIconUsageSyntax)('vendor/pack', ['prefix' => 'vp'], 'home'); + $syntax = (new BuildIconUsageSyntax)->handle('vendor/pack', ['prefix' => 'vp'], 'home'); } finally { restore_error_handler(); } @@ -70,13 +70,13 @@ function tempTree(): string }); it('falls back to the package name when a pack declares no prefix', function () { - $syntax = (new BuildIconUsageSyntax)('vendor/pack', [], 'home'); + $syntax = (new BuildIconUsageSyntax)->handle('vendor/pack', [], 'home'); expect($syntax['helper'])->toBe("ichava('vendor/pack:home')"); }); it('appends the variant when one is given', function () { - $syntax = (new BuildIconUsageSyntax)('p', ['prefix' => 'ti'], 'home', 'outline'); + $syntax = (new BuildIconUsageSyntax)->handle('p', ['prefix' => 'ti'], 'home', 'outline'); expect($syntax['helper'])->toBe("ichava('ti:home:outline')"); })->with([['outline'], ['filled']]); @@ -93,7 +93,7 @@ function tempTree(): string ['name' => 'ichava/icon-sets-flag', 'version' => '0.3.0'], ]])); - $found = (new DiscoverInstalledPackages(new Filesystem))($lock); + $found = (new DiscoverInstalledPackages(new Filesystem))->handle($lock); expect(array_column($found, 'name'))->toBe(['ichava/core', 'ichava/icon-sets-flag']) ->and($found[0]['version'])->toBe('0.3.2') @@ -110,7 +110,7 @@ function tempTree(): string file_put_contents($lock, $contents); } - expect((new DiscoverInstalledPackages(new Filesystem))($lock))->toBe([]); + expect((new DiscoverInstalledPackages(new Filesystem))->handle($lock))->toBe([]); (new Filesystem)->deleteDirectory($dir); })->with([ @@ -129,7 +129,7 @@ function tempTree(): string touch($dir . '/nested/deeper/c.svg'); touch($dir . '/nested/not-an-icon.txt'); - expect((new CountSvgFiles(new Filesystem))->recursively($dir))->toBe(3); + expect((new SvgFileCounter(new Filesystem))->recursively($dir))->toBe(3); (new Filesystem)->deleteDirectory($dir); }); @@ -139,22 +139,22 @@ function tempTree(): string touch($dir . '/a.svg'); touch($dir . '/nested/b.svg'); // deeper: must not be counted - $counter = new CountSvgFiles(new Filesystem); + $counter = new SvgFileCounter(new Filesystem); expect($counter->directly($dir))->toBe(1); - for ($i = 0; $i < CountSvgFiles::SHALLOW_CAP + 10; $i++) { + for ($i = 0; $i < SvgFileCounter::SHALLOW_CAP + 10; $i++) { touch($dir . "/icon-{$i}.svg"); } // A pack with 121,314 icons must not be fully walked to render one row. - expect($counter->directly($dir))->toBe(CountSvgFiles::SHALLOW_CAP); + expect($counter->directly($dir))->toBe(SvgFileCounter::SHALLOW_CAP); (new Filesystem)->deleteDirectory($dir); }); it('answers zero for a path that is not a directory', function () { - $counter = new CountSvgFiles(new Filesystem); + $counter = new SvgFileCounter(new Filesystem); expect($counter->recursively('/no/such/path'))->toBe(0) ->and($counter->directly('/no/such/path'))->toBe(0); @@ -170,7 +170,7 @@ function tempTree(): string chmod($dir . '/nested', 0000); try { - expect((new CountSvgFiles(new Filesystem))->recursively($dir))->toBe(0); + expect((new SvgFileCounter(new Filesystem))->recursively($dir))->toBe(0); } finally { chmod($dir . '/nested', 0755); (new Filesystem)->deleteDirectory($dir); diff --git a/tests/Unit/IconSnippetFormatTest.php b/tests/Unit/IconSnippetFormatTest.php index be801b17..93f1a3aa 100644 --- a/tests/Unit/IconSnippetFormatTest.php +++ b/tests/Unit/IconSnippetFormatTest.php @@ -97,6 +97,6 @@ public function variant(string $path, array $map): ?string it('writes term attachments with the registered morph alias', function () { // SeedIconsJob::insertTermables() stores (new Icon)->getMorphClass(); // every reader (relations, getMorphClass joins) queries the same value. - expect((new Icon)->getMorphClass())->toBe('icon'); + expect((new Icon)->getMorphClass())->toBe('ichava-icon'); }); }); diff --git a/tests/Unit/PackConformanceTest.php b/tests/Unit/PackConformanceTest.php new file mode 100644 index 00000000..7fcbae2b --- /dev/null +++ b/tests/Unit/PackConformanceTest.php @@ -0,0 +1,77 @@ + 'ichava-icon-set', 'require' => ['php' => '^8.4', 'ichava/core' => '^0.1']])); + File::put("{$root}/phpstan.neon.dist", "parameters:\n level: max\n"); + File::put("{$root}/src/Providers/IconsServiceProvider.php", "'); + + return $root; +} + +afterEach(function (): void { + foreach (glob(sys_get_temp_dir() . '/ichava-conformance-*') ?: [] as $dir) { + File::deleteDirectory($dir); + } +}); + +it('passes every rule on a conforming pack', function (): void { + $root = conformingPack(); + + PackConformance::requiresOnlyCore($root); + PackConformance::declaresPackType($root); + PackConformance::importsOnlyCore($root, minFiles: 1); + PackConformance::analysesAtLevelMax($root); + PackConformance::analysesWithoutBaseline($root); + PackConformance::siblingConstraintsAdmit($root); + PackConformance::syncCallerIsPinned($root); + PackConformance::syncCallerTriggers($root); + PackConformance::syncCallerPermissions($root); + PackConformance::workflowsDeclarePermissions($root, minFiles: 2); + PackConformance::noStrayTextNodes("{$root}/resources/assets/svg/files", minFiles: 1); + PackConformance::svgPolicyClean("{$root}/resources/assets/svg/files", minFiles: 1); + + expect(true)->toBeTrue(); +}); + +it('fails each rule on the defect it guards', function (Closure $break, Closure $check): void { + $root = conformingPack(); + $break($root); + + expect(fn () => $check($root))->toThrow(AssertionFailedError::class); +})->with([ + 'an extra require' => [fn ($r) => File::put("{$r}/composer.json", json_encode(['type' => 'ichava-icon-set', 'require' => ['php' => '^8.4', 'ichava/core' => '^0.1', 'laranail/console' => '^0.1']])), fn ($r) => PackConformance::requiresOnlyCore($r)], + 'no pack type' => [fn ($r) => File::put("{$r}/composer.json", json_encode(['require' => ['php' => '^8.4', 'ichava/core' => '^0.1']])), fn ($r) => PackConformance::declaresPackType($r)], + 'a laranail import' => [fn ($r) => File::put("{$r}/src/X.php", " PackConformance::importsOnlyCore($r, minFiles: 1)], + 'too few src files' => [fn ($r) => null, fn ($r) => PackConformance::importsOnlyCore($r, minFiles: 5)], + 'a lower level' => [fn ($r) => File::put("{$r}/phpstan.neon.dist", "parameters:\n level: 8\n"), fn ($r) => PackConformance::analysesAtLevelMax($r)], + 'a baseline' => [fn ($r) => File::put("{$r}/phpstan-baseline.neon", ''), fn ($r) => PackConformance::analysesWithoutBaseline($r)], + 'a floor above 0.1.0' => [fn ($r) => File::put("{$r}/composer.json", json_encode(['require' => ['ichava/core' => '^0.1.2']])), fn ($r) => PackConformance::siblingConstraintsAdmit($r)], + 'a floating sync pin' => [fn ($r) => File::put("{$r}/.github/workflows/sync-upstream.yml", str_replace('sync-pack.yml@' . str_repeat('a', 40), 'sync-pack.yml@main', (string) File::get("{$r}/.github/workflows/sync-upstream.yml"))), fn ($r) => PackConformance::syncCallerIsPinned($r)], + 'a push trigger' => [fn ($r) => File::put("{$r}/.github/workflows/sync-upstream.yml", "on:\n push:\n" . substr((string) File::get("{$r}/.github/workflows/sync-upstream.yml"), 4)), fn ($r) => PackConformance::syncCallerTriggers($r)], + 'a workflow with no block' => [fn ($r) => File::put("{$r}/.github/workflows/lint.yml", "on: pull_request\n"), fn ($r) => PackConformance::workflowsDeclarePermissions($r, minFiles: 1)], + 'a stray text node' => [fn ($r) => File::put("{$r}/resources/assets/svg/files/b.svg", ' d '), fn ($r) => PackConformance::noStrayTextNodes("{$r}/resources/assets/svg/files", minFiles: 1)], + 'a script' => [fn ($r) => File::put("{$r}/resources/assets/svg/files/b.svg", ''), fn ($r) => PackConformance::svgPolicyClean("{$r}/resources/assets/svg/files", minFiles: 1)], + 'too few SVGs' => [fn ($r) => null, fn ($r) => PackConformance::svgPolicyClean("{$r}/resources/assets/svg/files", minFiles: 2)], +]); diff --git a/tests/Unit/ProvidersNamespaceTest.php b/tests/Unit/ProvidersNamespaceTest.php new file mode 100644 index 00000000..9bc71bfe --- /dev/null +++ b/tests/Unit/ProvidersNamespaceTest.php @@ -0,0 +1,30 @@ +getFilename(), 'ServiceProvider.php')) { + $providers[] = $file->getPathname(); + } + } + + expect(count($providers))->toBeGreaterThanOrEqual(4); + + foreach ($providers as $path) { + preg_match('/^namespace\s+([^;]+);/m', (string) file_get_contents($path), $match); + + expect(basename(dirname($path)))->toBe('Providers', $path) + ->and(str_ends_with($match[1] ?? '', '\\Providers'))->toBeTrue($path); + } +}); diff --git a/tests/Unit/SvgInspectorTest.php b/tests/Unit/SvgInspectorTest.php new file mode 100644 index 00000000..de8fd741 --- /dev/null +++ b/tests/Unit/SvgInspectorTest.php @@ -0,0 +1,26 @@ +'; + $good = ''; + + expect(SvgInspector::policyViolations($bad))->toContain('comment', 'attribute svg@onload', 'tag script', 'reference href', 'attribute path@data-slot') + ->and(SvgInspector::policyViolations($good))->toBe([]) + ->and(SvgInspector::policyViolations('toBe(['unparseable']) + ->and(SvgInspector::policyViolations(''))->toContain('doctype'); +}); + +it('reports a stray text node and ignores title and desc text', function (): void { + expect(SvgInspector::strayTextNodes("\n\n d\n"))->toBe(['d']) + ->and(SvgInspector::strayTextNodes('Trainfast'))->toBe([]) + ->and(SvgInspector::strayTextNodes('toBe(['unparseable']); +}); diff --git a/tests/fixtures/BasePack/src/Providers/IconsServiceProvider.php b/tests/fixtures/BasePack/src/Providers/IconsServiceProvider.php index b3333858..dfe9cad1 100644 --- a/tests/fixtures/BasePack/src/Providers/IconsServiceProvider.php +++ b/tests/fixtures/BasePack/src/Providers/IconsServiceProvider.php @@ -5,7 +5,7 @@ namespace Simtabi\Laranail\Ichava\Tests\Fixtures\BasePack\Providers; use Simtabi\Laranail\Ichava\View\Components\IconComponent; -use Simtabi\Laranail\Ichava\Support\IconPackServiceProvider; +use Simtabi\Laranail\Ichava\Providers\IconPackServiceProvider; use Simtabi\Laranail\Ichava\Tests\Fixtures\BasePack\Constants\IconsConstants; /** diff --git a/tests/fixtures/RegistrationGolden.json b/tests/fixtures/RegistrationGolden.json new file mode 100644 index 00000000..70066db2 --- /dev/null +++ b/tests/fixtures/RegistrationGolden.json @@ -0,0 +1,1625 @@ +{ + "/resources/assets/svg/test-icons": { + "aliases": [], + "metadata": { + "base_path": "/resources/assets/svg/test-icons", + "blade_component": null, + "category_kinds": [], + "declared_kind": null, + "description": "Internal test icon sets for development, testing, and quality assurance purposes", + "homepage": "https://github.com/simtabi/ichava", + "icon_set_name": "ichava/test-icons", + "item_kinds": [], + "keywords": [ + "icons", + "test", + "development", + "qa", + "internal" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Test Icons Collection", + "package_name": "ichava/test-icons", + "prefix": "test", + "provider_class": null, + "repository": "https://github.com/simtabi/ichava", + "theme_variants": [], + "total": 4, + "variant_metadata": [], + "vendor": "ichava", + "version": "1.0.0" + } + }, + "/tests/fixtures/BasePack/resources/assets/svg": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/BasePack/resources/assets/svg", + "blade_component": null, + "category_kinds": [], + "declared_kind": null, + "description": "A pack built on IconPackServiceProvider", + "homepage": null, + "icon_set_name": "ichava/icon-sets-base-fixture", + "item_kinds": [], + "keywords": [], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Base Fixture", + "package_name": "ichava/icon-sets-base-fixture", + "prefix": "bf", + "provider_class": null, + "repository": null, + "theme_variants": [], + "total": 1, + "variant_metadata": [], + "vendor": "ichava", + "version": "1.0.0" + } + }, + "/tests/fixtures/BrokenKindPack": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/BrokenKindPack", + "blade_component": null, + "category_kinds": [], + "declared_kind": null, + "description": "Every kind mistake doctor reports, in one pack.", + "homepage": null, + "icon_set_name": "ichava-fixtures/broken-kind-pack", + "item_kinds": { + "glyphs/ghost": "illustration" + }, + "keywords": [], + "kind": "icon", + "kind_issues": [ + { + "check": "kind.invalid_value", + "key": "package.kind", + "value": "animation" + }, + { + "check": "kind.invalid_value", + "key": "metadata.data.categories.glyphs.kind", + "value": "bogus" + }, + { + "check": "kind.invalid_glob", + "key": "metadata.data.kinds.rules.0.match", + "value": "a**", + "index": 0, + "pattern": "a**" + }, + { + "check": "kind.invalid_value", + "key": "metadata.data.kinds.rules.1.kind", + "value": "nope" + }, + { + "check": "kind.invalid_value", + "key": "metadata.data.items.glyphs/star.kind", + "value": "sticker" + } + ], + "kind_rules": [ + { + "match": "unused/**", + "kind": "illustration" + }, + { + "match": "scenes/park-dark", + "kind": "illustration" + }, + { + "match": "scenes/*", + "kind": "illustration" + } + ], + "labels": [], + "license": "MIT", + "name": "Broken kind fixture pack", + "package_name": "ichava-fixtures/broken-kind-pack", + "prefix": "brokenfix", + "provider_class": null, + "repository": null, + "theme_variants": { + "scenes": { + "dark": "-dark" + } + }, + "total": 3, + "variant_metadata": [], + "vendor": "ichava-fixtures", + "version": "1.0.0" + } + }, + "/tests/fixtures/HeuristicPack": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/HeuristicPack", + "blade_component": null, + "category_kinds": [], + "declared_kind": null, + "description": "A flag that must not be suggested and a scene that must.", + "homepage": null, + "icon_set_name": "ichava-fixtures/heuristic-pack", + "item_kinds": [], + "keywords": [], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Heuristic fixture pack", + "package_name": "ichava-fixtures/heuristic-pack", + "prefix": "heurfix", + "provider_class": null, + "repository": null, + "theme_variants": [], + "total": 3, + "variant_metadata": [], + "vendor": "ichava-fixtures", + "version": "1.0.0" + } + }, + "/tests/fixtures/IconAliases": { + "aliases": { + "shapes/circle": "shapes/dot", + "shapes/ring": "shapes/dot" + }, + "metadata": { + "base_path": "/tests/fixtures/IconAliases", + "blade_component": null, + "category_kinds": [], + "declared_kind": null, + "description": "Test fixture: a pack that kept one of two identical icons and aliases the dropped name", + "homepage": null, + "icon_set_name": "ichava-fixtures/aliased", + "item_kinds": [], + "keywords": [], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Fixture pack (aliases.json)", + "package_name": "ichava-fixtures/aliased", + "prefix": "fxa", + "provider_class": null, + "repository": null, + "theme_variants": [], + "total": 2, + "variant_metadata": [], + "vendor": "ichava-fixtures", + "version": "1.0.0" + } + }, + "/tests/fixtures/KindPack": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/KindPack", + "blade_component": null, + "category_kinds": { + "scenes": "illustration" + }, + "declared_kind": "icon", + "description": "Icons and illustrations side by side, with a dark theme twin.", + "homepage": null, + "icon_set_name": "ichava-fixtures/kind-pack", + "item_kinds": [], + "keywords": [], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Kind fixture pack", + "package_name": "ichava-fixtures/kind-pack", + "prefix": "kindfix", + "provider_class": null, + "repository": null, + "theme_variants": { + "scenes": { + "dark": "-dark" + } + }, + "total": 4, + "variant_metadata": [], + "vendor": "ichava-fixtures", + "version": "1.0.0" + } + }, + "/tests/fixtures/MixedKindPack": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/MixedKindPack", + "blade_component": null, + "category_kinds": { + "illustrations": "illustration" + }, + "declared_kind": "icon", + "description": "The contract's mixed pack: item, rule, category and pack kinds side by side.", + "homepage": null, + "icon_set_name": "ichava-fixtures/mixed-pack", + "item_kinds": { + "glyphs/hero-banner": "illustration" + }, + "keywords": [], + "kind": "icon", + "kind_issues": [], + "kind_rules": [ + { + "match": "spot/tiny-*", + "kind": "icon" + }, + { + "match": "spot/**", + "kind": "illustration" + } + ], + "labels": [], + "license": "MIT", + "name": "Mixed kind fixture pack", + "package_name": "ichava-fixtures/mixed-pack", + "prefix": "mixfix", + "provider_class": null, + "repository": null, + "theme_variants": { + "illustrations": { + "dark": "-dark" + } + }, + "total": 6, + "variant_metadata": [], + "vendor": "ichava-fixtures", + "version": "1.0.0" + } + }, + "/tests/fixtures/PackConfigShapes/nested": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/PackConfigShapes/nested", + "blade_component": null, + "category_kinds": [], + "declared_kind": null, + "description": "Test fixture: config.json with variants and categories at metadata.data, the shape every pack ships", + "homepage": null, + "icon_set_name": "ichava-fixtures/nested-shape", + "item_kinds": [], + "keywords": [], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Fixture pack (metadata.data shape)", + "package_name": "ichava-fixtures/nested-shape", + "prefix": "fxn", + "provider_class": null, + "repository": null, + "theme_variants": [], + "total": 4, + "variant_metadata": { + "default": { + "name": "Default", + "slug": "default", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": [] + } + }, + "vendor": "ichava-fixtures", + "version": "1.0.0" + } + }, + "/tests/fixtures/PackConfigShapes/top-level": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/PackConfigShapes/top-level", + "blade_component": null, + "category_kinds": [], + "declared_kind": null, + "description": "Test fixture: config.json with variants and categories only at the top level, a shape core does not read", + "homepage": null, + "icon_set_name": "ichava-fixtures/top-level-shape", + "item_kinds": [], + "keywords": [], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Fixture pack (top-level shape)", + "package_name": "ichava-fixtures/top-level-shape", + "prefix": "fxt", + "provider_class": null, + "repository": null, + "theme_variants": [], + "total": 1, + "variant_metadata": [], + "vendor": "ichava-fixtures", + "version": "1.0.0" + } + }, + "/tests/fixtures/RegistrationGolden/bootstrap": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/bootstrap", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "2,078 Bootstrap Icons from v1.13.2, 16px, flat; filled forms keep their -fill suffix.", + "homepage": "https://icons.getbootstrap.com/", + "icon_set_name": "ichava/icon-sets-bootstrap", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "bootstrap", + "bootstrap-icons", + "laravel", + "ichava" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Bootstrap Icons", + "package_name": "ichava/icon-sets-bootstrap", + "prefix": "bi", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-bootstrap", + "theme_variants": [], + "total": 0, + "variant_metadata": [], + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/carbon": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/carbon", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "IBM's Carbon Design System icons: the full set at 32 px and the 16 px redraws.", + "homepage": "https://carbondesignsystem.com/elements/icons/library/", + "icon_set_name": "ichava/icon-sets-carbon", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "carbon", + "ibm", + "carbon-design-system" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT (code) / Apache-2.0 (icons)", + "name": "Carbon Icons", + "package_name": "ichava/icon-sets-carbon", + "prefix": "carbon", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-carbon", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "size-32": { + "name": "32 px", + "slug": "size-32", + "description": "The full set, drawn on the 32 px grid", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": [], + "preview_icon": "add", + "color_scheme": "fill-based" + }, + "size-16": { + "name": "16 px", + "slug": "size-16", + "description": "Glyphs redrawn for 16 px, where upstream ships one", + "default": false, + "icon_suffix": "", + "display_order": 2, + "attributes": [], + "preview_icon": "add", + "color_scheme": "fill-based" + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/emoji": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/emoji", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "Twemoji, Twitter/X's open-source emoji set, as SVG. Unicode 17 / Emoji 17 spec. Categorised by Unicode CLDR groups. OpenMoji is in ichava/icon-sets-openmoji.", + "homepage": null, + "icon_set_name": "ichava/icon-sets-emoji", + "item_kinds": [], + "keywords": [ + "emoji", + "twemoji", + "svg", + "unicode", + "bundle" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT (code) / CC-BY 4.0 (Twemoji assets)", + "name": "Emoji Sets", + "package_name": "ichava/icon-sets-emoji", + "prefix": "emoji", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-emoji", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "default": { + "name": "Default", + "slug": "default", + "description": "Standard Twemoji style", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": [], + "color_scheme": "adaptive" + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/extras": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/extras", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "20 smaller open-licence icon, logo and emoji sets in one pack, one directory per set, each under its own licence (MIT, Apache-2.0, CC0-1.0 or CC BY).", + "homepage": "https://github.com/ichava/icon-sets-extras#readme", + "icon_set_name": "ichava/icon-sets-extras", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "emoji", + "logos", + "bundle", + "open-source" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT (code); icons per set, see ATTRIBUTION.md", + "name": "Ichava Extras", + "package_name": "ichava/icon-sets-extras", + "prefix": "extras", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-extras", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "default": { + "name": "Default", + "slug": "default", + "description": "Each set's own style", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": [], + "color_scheme": "adaptive" + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/flag": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/flag", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "271 country flag SVGs sourced from lipis/flag-icons, available in 4x3 and 1x1 aspect ratios. Replaces the flag half of the deprecated simtabi/laflamoji.", + "homepage": "https://github.com/lipis/flag-icons", + "icon_set_name": "ichava/icon-sets-flag", + "item_kinds": [], + "keywords": [ + "flags", + "country-flags", + "svg", + "lipis", + "iso-3166" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Flag Icons", + "package_name": "ichava/icon-sets-flag", + "prefix": "flag", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-flag", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "4x3": { + "name": "4:3", + "slug": "4x3", + "description": "Canonical 4:3 aspect ratio. Best fit for most flags.", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": [] + }, + "1x1": { + "name": "1:1", + "slug": "1x1", + "description": "Square 1:1 aspect ratio. Useful for badges, round avatars, picker grids.", + "default": false, + "icon_suffix": "", + "display_order": 2, + "attributes": [] + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/fluent": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/fluent", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "Microsoft's Fluent UI System Icons at 24 px, in regular and filled.", + "homepage": "https://github.com/microsoft/fluentui-system-icons", + "icon_set_name": "ichava/icon-sets-fluent", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "fluent", + "fluentui", + "microsoft" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Fluent UI System Icons", + "package_name": "ichava/icon-sets-fluent", + "prefix": "fluent", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-fluent", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "regular": { + "name": "Regular", + "slug": "regular", + "description": "Outlined style at 24 px", + "default": true, + "icon_suffix": "_24_regular", + "display_order": 1, + "attributes": [], + "preview_icon": "home", + "color_scheme": "fill-based" + }, + "filled": { + "name": "Filled", + "slug": "filled", + "description": "Solid style at 24 px", + "default": false, + "icon_suffix": "_24_filled", + "display_order": 2, + "attributes": [], + "preview_icon": "home", + "color_scheme": "fill-based" + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/font-awesome-free": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/font-awesome-free", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "2,883 Font Awesome Free icons (2,001 solid, 273 regular, 609 brands) by @fontawesome, vendored from Font Awesome 7.3.1. Icons CC BY 4.0, attribution required.", + "homepage": "https://fontawesome.com/", + "icon_set_name": "ichava/icon-sets-font-awesome-free", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "font-awesome", + "fontawesome", + "cc-by-4.0", + "brands" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT (code) / CC-BY-4.0 (Font Awesome Free icons)", + "name": "Font Awesome Free", + "package_name": "ichava/icon-sets-font-awesome-free", + "prefix": "fa", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-font-awesome-free", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "solid": { + "name": "Solid", + "slug": "solid", + "description": "Filled glyphs. The largest Free style.", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": [], + "preview_icon": "house", + "color_scheme": "fill-based" + }, + "regular": { + "name": "Regular", + "slug": "regular", + "description": "Outlined glyphs. Free covers only part of the Regular style.", + "default": false, + "icon_suffix": "", + "display_order": 2, + "attributes": [], + "preview_icon": "house", + "color_scheme": "fill-based" + }, + "brands": { + "name": "Brands", + "slug": "brands", + "description": "Logos of companies, products and services. Trademarks of their owners.", + "default": false, + "icon_suffix": "", + "display_order": 3, + "attributes": [], + "preview_icon": "house", + "color_scheme": "fill-based" + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/heroicons": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/heroicons", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "1,288 Heroicons from v2.2.0 in four variants: outline and solid (24px), mini (20px) and micro (16px).", + "homepage": "https://heroicons.com/", + "icon_set_name": "ichava/icon-sets-heroicons", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "heroicons", + "tailwind", + "outline", + "solid", + "laravel", + "ichava" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Heroicons", + "package_name": "ichava/icon-sets-heroicons", + "prefix": "heroicon", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-heroicons", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "outline": { + "name": "Outline", + "slug": "outline", + "description": "24px, 1.5px stroke", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": { + "fill": "none", + "stroke": "currentColor", + "stroke_width": 1.5 + } + }, + "solid": { + "name": "Solid", + "slug": "solid", + "description": "24px, filled", + "default": false, + "icon_suffix": "", + "display_order": 2, + "attributes": { + "fill": "currentColor" + } + }, + "mini": { + "name": "Mini", + "slug": "mini", + "description": "20px, filled, for smaller UI", + "default": false, + "icon_suffix": "", + "display_order": 3, + "attributes": { + "fill": "currentColor" + } + }, + "micro": { + "name": "Micro", + "slug": "micro", + "description": "16px, filled, for dense UI", + "default": false, + "icon_suffix": "", + "display_order": 4, + "attributes": { + "fill": "currentColor" + } + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/iconoir": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/iconoir", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "An open-source library of 24px stroke icons with a smaller solid set, drawn on a 1.5px stroke.", + "homepage": "https://iconoir.com/", + "icon_set_name": "ichava/icon-sets-iconoir", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "iconoir", + "stroke", + "solid", + "open-source" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Iconoir", + "package_name": "ichava/icon-sets-iconoir", + "prefix": "iconoir", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-iconoir", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "regular": { + "name": "Regular", + "slug": "regular", + "description": "Stroked icons on a 1.5px line, transparent fill", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": { + "stroke_width": 1.5, + "fill": "none", + "stroke": "currentColor" + }, + "preview_icon": "heart", + "color_scheme": "stroke-based" + }, + "solid": { + "name": "Solid", + "slug": "solid", + "description": "Solid icons filled with the current colour", + "default": false, + "icon_suffix": "", + "display_order": 2, + "attributes": { + "fill": "currentColor", + "stroke": "none" + }, + "preview_icon": "heart", + "color_scheme": "fill-based" + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/ionicons": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/ionicons", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "Ionic's Ionicons in filled, outline and sharp, with the logo icons under filled.", + "homepage": "https://ionic.io/ionicons", + "icon_set_name": "ichava/icon-sets-ionicons", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "ionicons", + "ionic" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Ionicons", + "package_name": "ichava/icon-sets-ionicons", + "prefix": "ion", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-ionicons", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "filled": { + "name": "Filled", + "slug": "filled", + "description": "Solid style, and the 94 logo icons", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": [], + "preview_icon": "home", + "color_scheme": "fill-based" + }, + "outline": { + "name": "Outline", + "slug": "outline", + "description": "Stroked style", + "default": false, + "icon_suffix": "-outline", + "display_order": 2, + "attributes": [], + "preview_icon": "home", + "color_scheme": "stroke-based" + }, + "sharp": { + "name": "Sharp", + "slug": "sharp", + "description": "Solid style with square corners", + "default": false, + "icon_suffix": "-sharp", + "display_order": 3, + "attributes": [], + "preview_icon": "home", + "color_scheme": "fill-based" + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/lucide": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/lucide", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "2,313 Lucide icons: one 24px stroke style, vendored from lucide-static 1.68.0. ISC, with MIT for the Feather-derived icons.", + "homepage": "https://lucide.dev/", + "icon_set_name": "ichava/icon-sets-lucide", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "lucide", + "feather", + "stroke", + "laravel", + "ichava" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "ISC", + "name": "Lucide Icons", + "package_name": "ichava/icon-sets-lucide", + "prefix": "lucide", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-lucide", + "theme_variants": [], + "total": 0, + "variant_metadata": [], + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/material-symbols": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/material-symbols", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "Google's Material Symbols at weight 400: 3,940 icons in outlined, rounded and sharp, each unfilled and filled.", + "homepage": "https://fonts.google.com/icons", + "icon_set_name": "ichava/icon-sets-material-symbols", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "material", + "material-symbols", + "google" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT (code) / Apache-2.0 (icons)", + "name": "Material Symbols", + "package_name": "ichava/icon-sets-material-symbols", + "prefix": "material-symbols", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-material-symbols", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "outlined": { + "name": "Outlined", + "slug": "outlined", + "description": "Outlined style, weight 400, unfilled", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": [], + "preview_icon": "home", + "color_scheme": "fill-based" + }, + "outlined-fill": { + "name": "Outlined filled", + "slug": "outlined-fill", + "description": "Outlined style, weight 400, filled", + "default": false, + "icon_suffix": "-fill", + "display_order": 2, + "attributes": [], + "preview_icon": "home", + "color_scheme": "fill-based" + }, + "rounded": { + "name": "Rounded", + "slug": "rounded", + "description": "Rounded style, weight 400, unfilled", + "default": false, + "icon_suffix": "", + "display_order": 3, + "attributes": [], + "preview_icon": "home", + "color_scheme": "fill-based" + }, + "rounded-fill": { + "name": "Rounded filled", + "slug": "rounded-fill", + "description": "Rounded style, weight 400, filled", + "default": false, + "icon_suffix": "-fill", + "display_order": 4, + "attributes": [], + "preview_icon": "home", + "color_scheme": "fill-based" + }, + "sharp": { + "name": "Sharp", + "slug": "sharp", + "description": "Sharp style, weight 400, unfilled", + "default": false, + "icon_suffix": "", + "display_order": 5, + "attributes": [], + "preview_icon": "home", + "color_scheme": "fill-based" + }, + "sharp-fill": { + "name": "Sharp filled", + "slug": "sharp-fill", + "description": "Sharp style, weight 400, filled", + "default": false, + "icon_suffix": "-fill", + "display_order": 6, + "attributes": [], + "preview_icon": "home", + "color_scheme": "fill-based" + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/mdi": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/mdi", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "7,447 Material Design Icons by Pictogrammers, one 24px style, vendored from @mdi/svg 7.4.47.", + "homepage": "https://pictogrammers.com/library/mdi/", + "icon_set_name": "ichava/icon-sets-mdi", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "mdi", + "material-design-icons", + "pictogrammers", + "apache-2.0" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT (code) / Apache-2.0 (Material Design Icons assets)", + "name": "Material Design Icons", + "package_name": "ichava/icon-sets-mdi", + "prefix": "mdi", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-mdi", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "default": { + "name": "Default", + "slug": "default", + "description": "The one style: 24px filled glyphs. Outlined designs are separate icons whose names end in -outline.", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": [], + "preview_icon": "home", + "color_scheme": "fill-based" + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/octicons": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/octicons", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "GitHub's Primer icon set at two optical sizes, 16px and 24px. The GitHub logos and product marks are excluded: they are trademarks, not part of the MIT grant.", + "homepage": "https://primer.style/octicons/", + "icon_set_name": "ichava/icon-sets-octicons", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "octicons", + "primer", + "github" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Octicons", + "package_name": "ichava/icon-sets-octicons", + "prefix": "octicon", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-octicons", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "16px": { + "name": "16px", + "slug": "16px", + "description": "Drawn for a 16 by 16 grid, the size GitHub uses in dense UI", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": { + "fill": "currentColor" + }, + "preview_icon": "home", + "color_scheme": "fill-based" + }, + "24px": { + "name": "24px", + "slug": "24px", + "description": "Drawn for a 24 by 24 grid, for larger touch targets", + "default": false, + "icon_suffix": "", + "display_order": 2, + "attributes": { + "fill": "currentColor" + }, + "preview_icon": "home", + "color_scheme": "fill-based" + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/openmoji": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/openmoji", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "OpenMoji emoji in two sets (colour and black outline), Unicode emoji 16.0, grouped by Unicode CLDR category. Moved verbatim from ichava/icon-sets-emoji.", + "homepage": "https://openmoji.org/", + "icon_set_name": "ichava/icon-sets-openmoji", + "item_kinds": [], + "keywords": [ + "emoji", + "openmoji", + "svg", + "unicode" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT (code) / CC-BY-SA 4.0 (OpenMoji assets)", + "name": "OpenMoji", + "package_name": "ichava/icon-sets-openmoji", + "prefix": "openmoji", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-openmoji", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "default": { + "name": "Default", + "slug": "default", + "description": "OpenMoji's own style in each set", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": [], + "color_scheme": "adaptive" + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/phosphor": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/phosphor", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "9,072 Phosphor icons: 1,512 designs in six weights (regular, thin, light, bold, fill, duotone), vendored from @phosphor-icons/core 2.1.1.", + "homepage": "https://phosphoricons.com/", + "icon_set_name": "ichava/icon-sets-phosphor", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "phosphor", + "phosphor-icons", + "duotone" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Phosphor Icons", + "package_name": "ichava/icon-sets-phosphor", + "prefix": "ph", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-phosphor", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "regular": { + "name": "Regular", + "slug": "regular", + "description": "The default line weight.", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": [], + "preview_icon": "house", + "color_scheme": "fill-based" + }, + "thin": { + "name": "Thin", + "slug": "thin", + "description": "The lightest weight.", + "default": false, + "icon_suffix": "-thin", + "display_order": 2, + "attributes": [], + "preview_icon": "house", + "color_scheme": "fill-based" + }, + "light": { + "name": "Light", + "slug": "light", + "description": "Between thin and regular.", + "default": false, + "icon_suffix": "-light", + "display_order": 3, + "attributes": [], + "preview_icon": "house", + "color_scheme": "fill-based" + }, + "bold": { + "name": "Bold", + "slug": "bold", + "description": "The heaviest line weight.", + "default": false, + "icon_suffix": "-bold", + "display_order": 4, + "attributes": [], + "preview_icon": "house", + "color_scheme": "fill-based" + }, + "fill": { + "name": "Fill", + "slug": "fill", + "description": "Solid shapes.", + "default": false, + "icon_suffix": "-fill", + "display_order": 5, + "attributes": [], + "preview_icon": "house", + "color_scheme": "fill-based" + }, + "duotone": { + "name": "Duotone", + "slug": "duotone", + "description": "A line layer over a 20% opacity fill layer, both in currentColor.", + "default": false, + "icon_suffix": "-duotone", + "display_order": 6, + "attributes": [], + "preview_icon": "house", + "color_scheme": "fill-based" + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/remix": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/remix", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "3,188 Remix Icon SVGs in 20 categories, line and fill designs, pinned to Remix Icon v4.8.0, the last release under Apache-2.0.", + "homepage": "https://remixicon.com/", + "icon_set_name": "ichava/icon-sets-remix", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "remix", + "remixicon", + "apache-2.0" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT (code) / Apache-2.0 (Remix Icon v4.8.0 assets)", + "name": "Remix Icon", + "package_name": "ichava/icon-sets-remix", + "prefix": "ri", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-remix", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "default": { + "name": "Default", + "slug": "default", + "description": "Remix Icon's own style. Line and fill designs are separate icons, named -line and -fill.", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": [], + "preview_icon": "home-line", + "color_scheme": "fill-based" + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/simple-icons": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/simple-icons", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "3,255 single-colour brand glyphs from Simple Icons 16.36.0. Artwork CC0-1.0; the brands are their owners' trademarks. 212 icons whose upstream data names another licence are left out.", + "homepage": "https://simpleicons.org/", + "icon_set_name": "ichava/icon-sets-simple-icons", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "simple-icons", + "brands", + "logos", + "laravel", + "ichava" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "CC0-1.0", + "name": "Simple Icons", + "package_name": "ichava/icon-sets-simple-icons", + "prefix": "si", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-simple-icons", + "theme_variants": [], + "total": 0, + "variant_metadata": [], + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/RegistrationGolden/tabler": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/RegistrationGolden/tabler", + "blade_component": null, + "category_kinds": [], + "declared_kind": "icon", + "description": "Over 5,200 pixel-perfect icons for web apps. Free and open source icons designed with attention to detail to make your design stand out.", + "homepage": "https://tabler-icons.io/", + "icon_set_name": "ichava/icon-sets-tabler", + "item_kinds": [], + "keywords": [ + "icons", + "svg", + "tabler", + "outline", + "filled", + "open-source", + "pixel-perfect" + ], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Tabler Icons", + "package_name": "ichava/icon-sets-tabler", + "prefix": "ti", + "provider_class": null, + "repository": "https://github.com/ichava/icon-sets-tabler", + "theme_variants": [], + "total": 0, + "variant_metadata": { + "outline": { + "name": "Outline", + "slug": "outline", + "description": "Outlined icon style with 2px stroke width", + "default": true, + "icon_suffix": "-outline", + "display_order": 1, + "attributes": { + "stroke_width": 2, + "fill": "none", + "stroke": "currentColor" + }, + "preview_icon": "home-outline", + "color_scheme": "stroke-based" + }, + "filled": { + "name": "Filled", + "slug": "filled", + "description": "Solid filled icon style", + "default": false, + "icon_suffix": "-filled", + "display_order": 2, + "attributes": { + "fill": "currentColor", + "stroke": "none" + }, + "preview_icon": "home-filled", + "color_scheme": "fill-based" + } + }, + "vendor": "ichava", + "version": "0.1.0" + } + }, + "/tests/fixtures/SpecPack/resources/assets/svg": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/SpecPack/resources/assets/svg", + "blade_component": null, + "category_kinds": [], + "declared_kind": null, + "description": "", + "homepage": null, + "icon_set_name": "ichava/icon-sets-spec-fixture", + "item_kinds": [], + "keywords": [], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Spec Fixture", + "package_name": "ichava/icon-sets-spec-fixture", + "prefix": "spec", + "provider_class": null, + "repository": null, + "theme_variants": [], + "total": 3, + "variant_metadata": [], + "vendor": "ichava", + "version": "1.0.0" + } + }, + "/tests/fixtures/TranslatedPack/resources/assets/svg": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/TranslatedPack/resources/assets/svg", + "blade_component": null, + "category_kinds": [], + "declared_kind": null, + "description": "Canonical description, from config.json", + "homepage": null, + "icon_set_name": "ichava/fixture-pack", + "item_kinds": [], + "keywords": [], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Fixture Pack", + "package_name": "ichava/fixture-pack", + "prefix": "fx", + "provider_class": null, + "repository": null, + "theme_variants": [], + "total": 1, + "variant_metadata": [], + "vendor": "ichava", + "version": "1.0.0" + } + }, + "/tests/fixtures/VariantPack": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/VariantPack", + "blade_component": null, + "category_kinds": [], + "declared_kind": null, + "description": "Two variants declared out of display order.", + "homepage": null, + "icon_set_name": "ichava-fixtures/variant-pack", + "item_kinds": [], + "keywords": [], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "MIT", + "name": "Variant fixture pack", + "package_name": "ichava-fixtures/variant-pack", + "prefix": "varfix", + "provider_class": null, + "repository": null, + "theme_variants": [], + "total": 2, + "variant_metadata": { + "filled": { + "name": "Filled", + "description": "Solid shapes", + "display_order": 2, + "attributes": { + "fill": "currentColor" + }, + "preview_icon": "filled/home", + "color_scheme": "fill-based" + }, + "outline": { + "name": "Outline", + "description": "Stroked shapes", + "default": true, + "display_order": 1, + "attributes": { + "stroke_width": 2, + "fill": "none" + }, + "preview_icon": "outline/home", + "color_scheme": "stroke-based" + } + }, + "vendor": "ichava-fixtures", + "version": "1.0.0" + } + }, + "/tests/fixtures/ViewfulPack/resources/assets/svg": { + "aliases": [], + "metadata": { + "base_path": "/tests/fixtures/ViewfulPack/resources/assets/svg", + "blade_component": null, + "category_kinds": [], + "declared_kind": null, + "description": "Ships a Blade template.", + "homepage": null, + "icon_set_name": "ichava/viewful-pack", + "item_kinds": [], + "keywords": [], + "kind": "icon", + "kind_issues": [], + "kind_rules": [], + "labels": [], + "license": "Commercial", + "name": "Viewful Pack", + "package_name": "ichava/viewful-pack", + "prefix": "vf", + "provider_class": null, + "repository": null, + "theme_variants": [], + "total": 1, + "variant_metadata": [], + "vendor": "ichava", + "version": "1.0.0" + } + } +} diff --git a/tests/fixtures/RegistrationGolden/bootstrap/config.json b/tests/fixtures/RegistrationGolden/bootstrap/config.json new file mode 100644 index 00000000..c7f6fa9d --- /dev/null +++ b/tests/fixtures/RegistrationGolden/bootstrap/config.json @@ -0,0 +1,71 @@ +{ + "schema_version": "1.0", + "package": { + "name": "ichava/icon-sets-bootstrap", + "title": "Bootstrap Icons", + "description": "2,078 Bootstrap Icons from v1.13.2, 16px, flat; filled forms keep their -fill suffix.", + "keywords": [ + "icons", + "svg", + "bootstrap", + "bootstrap-icons", + "laravel", + "ichava" + ], + "version": "0.1.0", + "upstream_version": "1.13.2", + "type": "single", + "kind": "icon", + "license": "MIT" + }, + "metadata": { + "authors": [ + { + "name": "The Bootstrap Authors", + "role": "Creator -- twbs/icons", + "url": "https://icons.getbootstrap.com/" + } + ], + "homepage": "https://icons.getbootstrap.com/", + "repository": "https://github.com/ichava/icon-sets-bootstrap", + "data": { + "variants": {}, + "categories": {}, + "kinds": { + "rules": [] + }, + "items": {} + } + }, + "config": { + "icon_prefix": "bi" + }, + "upstream": { + "source": { + "type": "npm", + "package": "bootstrap-icons", + "source_path": "icons" + }, + "current_version": "1.13.2", + "version_check_url": "https://registry.npmjs.org/bootstrap-icons/latest", + "license": "MIT", + "cdn": { + "jsdelivr": "https://cdn.jsdelivr.net/npm/bootstrap-icons@{version}/icons/{name}.svg", + "unpkg": "https://unpkg.com/bootstrap-icons@{version}/icons/{name}.svg" + }, + "update_command": { + "type": "npm", + "package": "bootstrap-icons", + "source_path": "icons", + "_note": "Run through ichava/icon-sets-sync (pack recipe icon-sets-bootstrap)." + } + }, + "license": { + "spdx": "MIT", + "file": "resources/assets/svg/licenses/bootstrap/LICENSE", + "upstream_path": "LICENSE", + "sha256": "0fb3e11bd57e896c5a512afd64864d28a37de45d19835016c87ca1ad19ead969", + "checked_at": "2026-11-04", + "checked_version": "1.13.2" + } +} diff --git a/tests/fixtures/RegistrationGolden/bootstrap/files/.gitkeep b/tests/fixtures/RegistrationGolden/bootstrap/files/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/RegistrationGolden/carbon/config.json b/tests/fixtures/RegistrationGolden/carbon/config.json new file mode 100644 index 00000000..a06541db --- /dev/null +++ b/tests/fixtures/RegistrationGolden/carbon/config.json @@ -0,0 +1,90 @@ +{ + "schema_version": "1.0", + "package": { + "name": "ichava/icon-sets-carbon", + "title": "Carbon Icons", + "description": "IBM's Carbon Design System icons: the full set at 32 px and the 16 px redraws.", + "keywords": [ + "icons", + "svg", + "carbon", + "ibm", + "carbon-design-system" + ], + "version": "0.1.0", + "upstream_version": "11.91.0", + "type": "multi", + "kind": "icon", + "license": "MIT (code) / Apache-2.0 (icons)" + }, + "metadata": { + "authors": [ + { + "name": "IBM Corp.", + "role": "Creator -- Carbon Design System icons", + "url": "https://github.com/carbon-design-system/carbon" + } + ], + "homepage": "https://carbondesignsystem.com/elements/icons/library/", + "repository": "https://github.com/ichava/icon-sets-carbon", + "data": { + "variants": { + "size-32": { + "name": "32 px", + "slug": "size-32", + "description": "The full set, drawn on the 32 px grid", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": {}, + "preview_icon": "add", + "color_scheme": "fill-based" + }, + "size-16": { + "name": "16 px", + "slug": "size-16", + "description": "Glyphs redrawn for 16 px, where upstream ships one", + "default": false, + "icon_suffix": "", + "display_order": 2, + "attributes": {}, + "preview_icon": "add", + "color_scheme": "fill-based" + } + }, + "categories": {}, + "kinds": { + "rules": [] + }, + "items": {} + } + }, + "config": { + "icon_prefix": "carbon" + }, + "license": { + "spdx": "Apache-2.0", + "sha256": "037b3e9eaff51477b270e9082f54b99175e4dd893548654f00995045b68294b8", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/carbon/LICENSE", + "source": "https://github.com/carbon-design-system/carbon/blob/v11.119.0/LICENSE", + "notice": "resources/assets/svg/NOTICE" + }, + "upstream": { + "source": { + "type": "npm", + "package": "@carbon/icons", + "_ref": "v11.119.0", + "_repository": "https://github.com/carbon-design-system/carbon" + }, + "current_version": "11.91.0", + "version_check_url": "https://registry.npmjs.org/@carbon/icons/latest", + "license": "Apache-2.0", + "update_command": { + "type": "recipe", + "recipe": "icon-sets-carbon", + "_note": "icon-sets-sync has no config/icon-sets-carbon.json yet; the proposed recipe is pack-recipes/icon-sets-carbon.json in the ichava design folder, and lands with the one icon-sets-sync pull request that adds the v0.1.0 packs. Until then the weekly sync-upstream run has nothing to run." + }, + "_subset": "Sizes 32 and 16 only. Dropped: size 20 (9 files), size 24 (8 files) and the 18 unsized status glyphs at the root of `svg/`. Names are slugged (`chevron--down` is `chevron-down`); the `watson-health/` and `Q/` namespaces become name prefixes (`watson-health-study-view`, `q-h`). No two names collide after slugging." + } +} diff --git a/tests/fixtures/RegistrationGolden/carbon/files/.gitkeep b/tests/fixtures/RegistrationGolden/carbon/files/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/RegistrationGolden/emoji/config.json b/tests/fixtures/RegistrationGolden/emoji/config.json new file mode 100644 index 00000000..79d45306 --- /dev/null +++ b/tests/fixtures/RegistrationGolden/emoji/config.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.0", + "package": { + "name": "ichava/icon-sets-emoji", + "title": "Emoji Sets", + "description": "Twemoji, Twitter/X's open-source emoji set, as SVG. Unicode 17 / Emoji 17 spec. Categorised by Unicode CLDR groups. OpenMoji is in ichava/icon-sets-openmoji.", + "keywords": [ + "emoji", + "twemoji", + "svg", + "unicode", + "bundle" + ], + "version": "0.1.0", + "upstream_version": "15.0.0", + "type": "bulk", + "kind": "icon", + "license": "MIT (code) / CC-BY 4.0 (Twemoji assets)" + }, + "metadata": { + "authors": [ + { + "name": "Twitter, Inc. and other contributors (Twemoji)", + "role": "Creator -- Twemoji", + "url": "https://github.com/jdecked/twemoji" + } + ], + "repository": "https://github.com/ichava/icon-sets-emoji", + "data": { + "variants": { + "default": { + "name": "Default", + "slug": "default", + "description": "Standard Twemoji style", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": {}, + "color_scheme": "adaptive" + } + }, + "categories": { + "twemoji": { + "name": "Twemoji", + "description": "Twitter/X's open-source emoji set (CC-BY 4.0). Recognisable rounded colourful style. Default set.", + "sets": [ + "twemoji" + ] + } + }, + "kinds": { + "rules": [] + }, + "items": {} + } + }, + "config": { + "icon_prefix": "emoji" + }, + "upstream": { + "_comment": "Each emoji set has its own upstream; Twemoji is the only one.", + "source": { + "type": "npm", + "package": "@twemoji/svg", + "_why_npm": "NPM publishes new Twemoji releases the same day as GitHub but with no rate-limit ceiling and a simpler API contract." + }, + "current_version": "15.0.0", + "twemoji_github_tag": "v15.1.0", + "version_check_url": "https://registry.npmjs.org/@twemoji/svg/latest", + "license": "CC-BY 4.0 (Twemoji assets)", + "cdn": { + "twemoji_jsdelivr": "https://cdn.jsdelivr.net/npm/@twemoji/svg@{version}/{codepoint}.svg", + "twemoji_unpkg": "https://unpkg.com/@twemoji/svg@{version}/{codepoint}.svg", + "twemoji_github_raw": "https://raw.githubusercontent.com/jdecked/twemoji/{twemoji_github_tag}/assets/svg/{codepoint}.svg" + }, + "update_command": { + "_note": "Refresh runs in ichava/icon-sets-sync via the `emoji-sets` recipe -- Twemoji (npm) + Unicode CLDR. No pack-side script.", + "type": "recipe", + "recipe": "emoji-sets" + }, + "_vendored_note": "Versions of the upstreams that produced the shipped assets. They move independently and conflating them is what left this pack empty: current_version read 17.0.0, which @twemoji/svg has never published, so `npm pack` failed and no assets were ever written (V4). Twemoji 15.0.0 is npm's latest; Unicode emoji 16.0 is the newest emoji-test.txt that exists and is what the CLDR categorisation used. The jdecked/twemoji repository tags independently of the npm package: npm's 15.0.0 has no v15.0.0 tag, only v15.1.0. `twemoji_github_raw` therefore carries `{twemoji_github_tag}` rather than `{version}` -- a caller substituting only `{version}` leaves a visible placeholder in the URL instead of building a plausible v15.0.0 that 404s.", + "cldr_version": "16.0" + } +} diff --git a/tests/fixtures/RegistrationGolden/emoji/files/.gitkeep b/tests/fixtures/RegistrationGolden/emoji/files/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/RegistrationGolden/extras/config.json b/tests/fixtures/RegistrationGolden/extras/config.json new file mode 100644 index 00000000..fa692bdb --- /dev/null +++ b/tests/fixtures/RegistrationGolden/extras/config.json @@ -0,0 +1,1020 @@ +{ + "schema_version": "1.0", + "package": { + "name": "ichava/icon-sets-extras", + "title": "Ichava Extras", + "description": "20 smaller open-licence icon, logo and emoji sets in one pack, one directory per set, each under its own licence (MIT, Apache-2.0, CC0-1.0 or CC BY).", + "keywords": [ + "icons", + "svg", + "emoji", + "logos", + "bundle", + "open-source" + ], + "version": "0.1.0", + "upstream_version": "per set: upstream.sets..current_version", + "type": "bulk", + "kind": "icon", + "license": "MIT (code); icons per set, see ATTRIBUTION.md" + }, + "metadata": { + "authors": [ + { + "name": "WorkOS (Radix)", + "role": "Creator -- Radix Icons", + "url": "https://www.radix-ui.com/icons" + }, + { + "name": "Cole Bemis", + "role": "Creator -- Feather", + "url": "https://feathericons.com" + }, + { + "name": "Aniket Suvarna (Atisa)", + "role": "Creator -- Boxicons", + "url": "https://boxicons.com" + }, + { + "name": "ByteDance", + "role": "Creator -- IconPark", + "url": "https://iconpark.oceanengine.com" + }, + { + "name": "MingCute Design", + "role": "Creator -- MingCute", + "url": "https://www.mingcute.com" + }, + { + "name": "TDesign (Tencent)", + "role": "Creator -- TDesign Icons", + "url": "https://tdesign.tencent.com" + }, + { + "name": "Siemens AG", + "role": "Creator -- Siemens iX Icons", + "url": "https://ix.siemens.io" + }, + { + "name": "Zendesk", + "role": "Creator -- Zendesk Garden Icons", + "url": "https://garden.zendesk.com" + }, + { + "name": "Kryston Schwarze", + "role": "Creator -- coolicons", + "url": "https://coolicons.cool" + }, + { + "name": "Akveo", + "role": "Creator -- Eva Icons", + "url": "https://akveo.github.io/eva-icons" + }, + { + "name": "Ant UED", + "role": "Creator -- Ant Design Icons", + "url": "https://ant.design/components/icon" + }, + { + "name": "Resolve to Save Lives and the Health Icons contributors", + "role": "Creator -- Health Icons", + "url": "https://healthicons.org" + }, + { + "name": "konpa and the Devicon contributors", + "role": "Creator -- Devicon", + "url": "https://devicon.dev" + }, + { + "name": "Microsoft Corporation", + "role": "Creator -- Fluent Emoji", + "url": "https://github.com/microsoft/fluentui-emoji" + }, + { + "name": "CyCraft", + "role": "Creator -- Pepicons", + "url": "https://pepicons.com" + }, + { + "name": "Lorc, Delapouite and the game-icons.net contributors", + "role": "Creator -- Game Icons", + "url": "https://game-icons.net" + }, + { + "name": "CoreUI (creativeLabs)", + "role": "Creator -- CoreUI Icons Free", + "url": "https://icons.coreui.io" + }, + { + "name": "Wayside Mapping and the Pinhead contributors", + "role": "Creator -- Pinhead", + "url": "https://pinhead.ink" + }, + { + "name": "Gil Barbara", + "role": "Creator -- SVG Logos", + "url": "https://svgporn.com" + }, + { + "name": "Google", + "role": "Creator -- Noto Emoji", + "url": "https://fonts.google.com/noto/specimen/Noto+Color+Emoji" + } + ], + "homepage": "https://github.com/ichava/icon-sets-extras#readme", + "repository": "https://github.com/ichava/icon-sets-extras", + "data": { + "variants": { + "default": { + "name": "Default", + "slug": "default", + "description": "Each set's own style", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": {}, + "color_scheme": "adaptive" + } + }, + "categories": { + "radix-icons": { + "name": "Radix Icons", + "description": "Radix Icons by WorkOS (Radix) (MIT). 15×15, filled.", + "sets": [ + "radix-icons" + ] + }, + "feather": { + "name": "Feather", + "description": "Feather by Cole Bemis (MIT). 24×24, 2px stroke.", + "sets": [ + "feather" + ] + }, + "boxicons": { + "name": "Boxicons", + "description": "Boxicons by Aniket Suvarna (Atisa) (MIT). 24×24; regular (bx-), solid (bxs-), logos (bxl-).", + "sets": [ + "boxicons" + ] + }, + "iconpark": { + "name": "IconPark", + "description": "IconPark by ByteDance (Apache-2.0). 48×48 outline, four themes in the source.", + "sets": [ + "iconpark" + ] + }, + "mingcute": { + "name": "MingCute", + "description": "MingCute by MingCute Design (Apache-2.0). 24×24, regular (line) and filled.", + "sets": [ + "mingcute" + ] + }, + "tdesign": { + "name": "TDesign Icons", + "description": "TDesign Icons by TDesign (Tencent) (MIT). 24×24.", + "sets": [ + "tdesign" + ] + }, + "siemens-ix": { + "name": "Siemens iX Icons", + "description": "Siemens iX Icons by Siemens AG (MIT). Mixed sizes.", + "sets": [ + "siemens-ix" + ] + }, + "garden": { + "name": "Zendesk Garden Icons", + "description": "Zendesk Garden Icons by Zendesk (Apache-2.0). 16×16 and 12×12.", + "sets": [ + "garden" + ] + }, + "coolicons": { + "name": "coolicons", + "description": "coolicons by Kryston Schwarze (MIT). 24×24, line and fill (regular corners).", + "sets": [ + "coolicons" + ] + }, + "eva": { + "name": "Eva Icons", + "description": "Eva Icons by Akveo (MIT). 24×24, fill and outline.", + "sets": [ + "eva" + ] + }, + "ant-design": { + "name": "Ant Design Icons", + "description": "Ant Design Icons by Ant UED (MIT). 1024×1024, outlined, filled and two-tone.", + "sets": [ + "ant-design" + ] + }, + "health-icons": { + "name": "Health Icons", + "description": "Health Icons by Resolve to Save Lives and the Health Icons contributors (CC0-1.0). 48×48, outline and filled.", + "sets": [ + "health-icons" + ] + }, + "devicon": { + "name": "Devicon", + "description": "Devicon by konpa and the Devicon contributors (MIT). 128×128 logos; original, plain, line and wordmark.", + "sets": [ + "devicon" + ] + }, + "fluent-emoji": { + "name": "Fluent Emoji", + "description": "Fluent Emoji by Microsoft Corporation (MIT). Flat and high-contrast, with skin tones.", + "sets": [ + "fluent-emoji" + ] + }, + "pepicons": { + "name": "Pepicons", + "description": "Pepicons by CyCraft (CC BY 4.0). 20×20; pop, print and pencil.", + "sets": [ + "pepicons" + ] + }, + "game-icons": { + "name": "Game Icons", + "description": "Game Icons by Lorc, Delapouite and the game-icons.net contributors (CC BY 3.0 (CC0 for two contributors)). 512×512, white on a black square.", + "sets": [ + "game-icons" + ] + }, + "coreui": { + "name": "CoreUI Icons Free", + "description": "CoreUI Icons Free by CoreUI (creativeLabs) (CC BY 4.0). 512×512 line icons.", + "sets": [ + "coreui" + ] + }, + "pinhead": { + "name": "Pinhead", + "description": "Pinhead by Wayside Mapping and the Pinhead contributors (CC0-1.0). 15×15 map icons.", + "sets": [ + "pinhead" + ] + }, + "svg-logos": { + "name": "SVG Logos", + "description": "SVG Logos by Gil Barbara (CC0-1.0). Full-colour brand logos.", + "sets": [ + "svg-logos" + ] + }, + "noto-emoji": { + "name": "Noto Emoji", + "description": "Noto Emoji by Google (Apache-2.0). Colour emoji, 128×128.", + "sets": [ + "noto-emoji" + ] + } + }, + "kinds": { + "rules": [] + }, + "items": {} + } + }, + "config": { + "icon_prefix": "extras" + }, + "upstream": { + "source": { + "type": "manifest", + "_note": "One upstream per set, under upstream.sets. Each has its own recipe in the design folder (pack-recipes/icon-sets-extras..json)." + }, + "license": "per set", + "sets": { + "radix-icons": { + "name": "Radix Icons", + "owner": "WorkOS (Radix)", + "homepage": "https://www.radix-ui.com/icons", + "repository": "https://github.com/radix-ui/icons", + "source": { + "type": "github-archive", + "owner": "radix-ui", + "repo": "icons", + "archive_url": "https://github.com/radix-ui/icons/archive/refs/tags/website@{version}.tar.gz", + "source_path": "packages/radix-icons/icons", + "_version_note": "archive tag is website@0.0.17; the icons package inside it is 1.3.2" + }, + "current_version": "0.0.17", + "ref": "tag website@0.0.17 (bde33b13aa), packages/radix-icons 1.3.2", + "version_check_url": "https://api.github.com/repos/radix-ui/icons/tags", + "license": { + "spdx": "MIT", + "sha256": "0e80a2d229d2fd4fc7e8636142ec5d0ff0bc031f14c15b682e2ac01dfd5b5138", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/radix-icons/LICENSE", + "read_from": "LICENSE at tag website@0.0.17" + }, + "attribution_required": false, + "trademarks": false, + "icons": 318, + "layout": "packages/radix-icons/icons/*.svg, unchanged names" + }, + "feather": { + "name": "Feather", + "owner": "Cole Bemis", + "homepage": "https://feathericons.com", + "repository": "https://github.com/feathericons/feather", + "source": { + "type": "npm", + "package": "feather-icons", + "source_path": "dist/icons" + }, + "current_version": "4.29.2", + "ref": "npm feather-icons 4.29.2", + "version_check_url": "https://registry.npmjs.org/feather-icons/latest", + "license": { + "spdx": "MIT", + "sha256": "308028e93fcf84972523cdf6e616f73168546b4953895f516d01287f16fe7bee", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/feather/LICENSE", + "read_from": "LICENSE in the npm package" + }, + "attribution_required": false, + "trademarks": false, + "icons": 287, + "layout": "dist/icons/*.svg, unchanged names", + "note": "Lucide is its maintained successor." + }, + "boxicons": { + "name": "Boxicons", + "owner": "Aniket Suvarna (Atisa)", + "homepage": "https://boxicons.com", + "repository": "https://github.com/box-icons/boxicons", + "source": { + "type": "npm", + "package": "boxicons", + "source_path": "svg" + }, + "current_version": "2.1.4", + "ref": "npm boxicons 2.1.4", + "version_check_url": "https://registry.npmjs.org/boxicons/latest", + "license": { + "spdx": "MIT", + "sha256": "1676a8bdd036382b7bdb8a9ec26a55af6bf15035dd4d405169da2ee45b066e22", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/boxicons/LICENSE", + "read_from": "LICENSE in the npm package" + }, + "attribution_required": false, + "trademarks": true, + "icons": 1634, + "layout": "svg/regular, svg/solid and svg/logos merged; the bx-/bxs-/bxl- prefixes already keep names apart", + "note": "The npm manifest's `license` field reads `(CC-BY-4.0 OR OFL-1.1 OR MIT)`; the LICENSE file the package ships is MIT, and MIT is the option taken. The `bxl-` logos depict trademarks." + }, + "iconpark": { + "name": "IconPark", + "owner": "ByteDance", + "homepage": "https://iconpark.oceanengine.com", + "repository": "https://github.com/bytedance/IconPark", + "source": { + "type": "github-archive", + "owner": "bytedance", + "repo": "IconPark", + "archive_url": "https://github.com/bytedance/IconPark/archive/refs/tags/v{version}.tar.gz", + "source_path": "source" + }, + "current_version": "1.4.2", + "ref": "tag v1.4.2 (repository archived)", + "version_check_url": "https://api.github.com/repos/bytedance/IconPark/tags", + "license": { + "spdx": "Apache-2.0", + "sha256": "81268f00802b1a9cffae4df43f78c3a0809845e03fef3da68f26ad2e4c49681e", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/iconpark/LICENSE", + "read_from": "LICENSE at tag v1.4.2" + }, + "attribution_required": false, + "trademarks": false, + "icons": 2658, + "layout": "source//*.svg flattened; names are unique across categories", + "note": "Upstream is archived; 1.4.2 is final." + }, + "mingcute": { + "name": "MingCute", + "owner": "MingCute Design", + "homepage": "https://www.mingcute.com", + "repository": "https://github.com/mingcute-design/mingcute-icons", + "source": { + "type": "github-archive", + "owner": "mingcute-design", + "repo": "mingcute-icons", + "archive_url": "https://github.com/mingcute-design/mingcute-icons/archive/refs/tags/v{version}.tar.gz", + "source_path": "assets/stroke/core" + }, + "current_version": "4.0.0", + "ref": "tag v4.0.0", + "version_check_url": "https://api.github.com/repos/mingcute-design/mingcute-icons/tags", + "license": { + "spdx": "Apache-2.0", + "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/mingcute/LICENSE", + "read_from": "LICENSE at tag v4.0.0" + }, + "attribution_required": false, + "trademarks": false, + "icons": 4526, + "layout": "assets/stroke/core/{regular,filled}//.svg flattened to -regular / -filled, underscores slugged to hyphens", + "note": "The npm package @mingcute/icons ships JavaScript only, so the source is the tagged archive." + }, + "tdesign": { + "name": "TDesign Icons", + "owner": "TDesign (Tencent)", + "homepage": "https://tdesign.tencent.com", + "repository": "https://github.com/Tencent/tdesign-icons", + "source": { + "type": "npm", + "package": "tdesign-icons-svg", + "source_path": "src" + }, + "current_version": "0.4.8", + "ref": "npm tdesign-icons-svg 0.4.8", + "version_check_url": "https://registry.npmjs.org/tdesign-icons-svg/latest", + "license": { + "spdx": "MIT", + "sha256": "b3dbcb89dcf4a11abf1b70d043795a3da0c458af16fefd2ff315d9ff5875312f", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/tdesign/LICENSE", + "read_from": "LICENSE in the npm package" + }, + "attribution_required": false, + "trademarks": false, + "icons": 2358, + "layout": "src/*.svg, unchanged names" + }, + "siemens-ix": { + "name": "Siemens iX Icons", + "owner": "Siemens AG", + "homepage": "https://ix.siemens.io", + "repository": "https://github.com/siemens/ix-icons", + "source": { + "type": "npm", + "package": "@siemens/ix-icons", + "source_path": "svg" + }, + "current_version": "3.7.1", + "ref": "npm @siemens/ix-icons 3.7.1", + "version_check_url": "https://registry.npmjs.org/@siemens/ix-icons/latest", + "license": { + "spdx": "MIT", + "sha256": "42cb46c894aa9267307aa4985f40f44ba669e54d9b6b487960ecd24e1989213b", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/siemens-ix/LICENSE.md", + "read_from": "LICENSE.md in the npm package" + }, + "attribution_required": false, + "trademarks": false, + "icons": 1490, + "layout": "svg/*.svg, unchanged names" + }, + "garden": { + "name": "Zendesk Garden Icons", + "owner": "Zendesk", + "homepage": "https://garden.zendesk.com", + "repository": "https://github.com/zendeskgarden/svg-icons", + "source": { + "type": "npm", + "package": "@zendeskgarden/svg-icons", + "source_path": "src" + }, + "current_version": "8.4.0", + "ref": "npm @zendeskgarden/svg-icons 8.4.0", + "version_check_url": "https://registry.npmjs.org/@zendeskgarden/svg-icons/latest", + "license": { + "spdx": "Apache-2.0", + "sha256": "46ae5f0dc2e06a18cde8b06bdb45abea0b6e28d169e6eff2069536780304cf6e", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/garden/LICENSE.md", + "read_from": "LICENSE.md in the npm package" + }, + "attribution_required": false, + "trademarks": false, + "icons": 932, + "layout": "src/16 and src/12 flattened to -16 / -12", + "note": "9.0.0 exists only as a pre-release (next); the stable 8.4.0 is pinned." + }, + "coolicons": { + "name": "coolicons", + "owner": "Kryston Schwarze", + "homepage": "https://coolicons.cool", + "repository": "https://github.com/krystonschwarze/coolicons", + "source": { + "type": "github-archive", + "owner": "krystonschwarze", + "repo": "coolicons", + "archive_url": "https://github.com/krystonschwarze/coolicons/archive/refs/tags/v{version}.tar.gz", + "source_path": "icons/svg" + }, + "current_version": "5.2.0", + "ref": "tag v5.2.0", + "version_check_url": "https://api.github.com/repos/krystonschwarze/coolicons/tags", + "license": { + "spdx": "MIT", + "sha256": "35d8a4a8e1793b261198949d213583904b11e3826be8618ae9b9dbc6b70f50cc", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/coolicons/LICENSE", + "read_from": "LICENSE at tag v5.2.0" + }, + "attribution_required": false, + "trademarks": false, + "icons": 2648, + "layout": "icons/svg/{line,fill}/regular/*.svg flattened to -line / -fill", + "note": "v5 ships 1,324 icons in 15 style folders (line, fill, bulk, duotone, duocolor x regular, round, sharp): 19,860 files. Only line and fill at regular corners are vendored (2,648), to keep the bundle small; the other 13 folders are the same drawings restyled. v4 was CC-BY-4.0; v5 is MIT." + }, + "eva": { + "name": "Eva Icons", + "owner": "Akveo", + "homepage": "https://akveo.github.io/eva-icons", + "repository": "https://github.com/akveo/eva-icons", + "source": { + "type": "npm", + "package": "eva-icons", + "source_path": "." + }, + "current_version": "1.1.3", + "ref": "npm eva-icons 1.1.3 (tag v1.1.3)", + "version_check_url": "https://registry.npmjs.org/eva-icons/latest", + "license": { + "spdx": "MIT", + "sha256": "4788c563f8871931bce41ede59379a5277ac1a473e289f9100924149ce8bd311", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/eva/LICENSE", + "read_from": "LICENSE.txt at tag v1.1.3 (the npm package ships none)" + }, + "attribution_required": false, + "trademarks": false, + "icons": 490, + "layout": "fill/svg and outline/svg merged; upstream already names outline icons -outline", + "note": "Unmaintained upstream (last push 2023)." + }, + "ant-design": { + "name": "Ant Design Icons", + "owner": "Ant UED", + "homepage": "https://ant.design/components/icon", + "repository": "https://github.com/ant-design/ant-design-icons", + "source": { + "type": "npm", + "package": "@ant-design/icons-svg", + "source_path": "inline-svg" + }, + "current_version": "4.6.0", + "ref": "npm @ant-design/icons-svg 4.6.0 (repo commit 7f2516ac91)", + "version_check_url": "https://registry.npmjs.org/@ant-design/icons-svg/latest", + "license": { + "spdx": "MIT", + "sha256": "5d367fb0a07340571542eb4ee8eb1add62d37b71bad6786a57c2e9a86bf76c70", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/ant-design/LICENSE", + "read_from": "LICENSE at commit 7f2516ac91 (the commit that set packages/icons-svg to 4.6.0; the npm package ships none)" + }, + "attribution_required": false, + "trademarks": false, + "icons": 848, + "layout": "inline-svg/{outlined,filled,twotone} flattened to -outlined / -filled / -twotone" + }, + "health-icons": { + "name": "Health Icons", + "owner": "Resolve to Save Lives and the Health Icons contributors", + "homepage": "https://healthicons.org", + "repository": "https://github.com/resolvetosavelives/healthicons", + "source": { + "type": "npm", + "package": "healthicons", + "source_path": "public/icons/svg" + }, + "current_version": "3.1.0", + "ref": "npm healthicons 3.1.0", + "version_check_url": "https://registry.npmjs.org/healthicons/latest", + "license": { + "spdx": "CC0-1.0", + "sha256": "d36196c6c2b0a983415778adddf81bfe63c51f408e707a36fa2b029d65e92d06", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/health-icons/LICENSE", + "read_from": "LICENSE in the npm package (icons CC0-1.0; website code MIT)" + }, + "attribution_required": false, + "trademarks": false, + "icons": 1552, + "layout": "public/icons/svg/{outline,filled}//.svg flattened to -outline / -filled", + "note": "The 24px redraws (filled-24px, outline-24px; 288 each) are not vendored." + }, + "devicon": { + "name": "Devicon", + "owner": "konpa and the Devicon contributors", + "homepage": "https://devicon.dev", + "repository": "https://github.com/devicons/devicon", + "source": { + "type": "npm", + "package": "devicon", + "source_path": "icons" + }, + "current_version": "2.18.0", + "ref": "npm devicon 2.18.0", + "version_check_url": "https://registry.npmjs.org/devicon/latest", + "license": { + "spdx": "MIT", + "sha256": "121194741d4a915b9f5890fdd6dd95121f9b1f816517c792358d72d7c838d664", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/devicon/LICENSE", + "read_from": "LICENSE in the npm package" + }, + "attribution_required": false, + "trademarks": true, + "icons": 2120, + "layout": "icons//.svg flattened; upstream file names carry the tech and the style" + }, + "fluent-emoji": { + "name": "Fluent Emoji", + "owner": "Microsoft Corporation", + "homepage": "https://github.com/microsoft/fluentui-emoji", + "repository": "https://github.com/microsoft/fluentui-emoji", + "source": { + "type": "github-archive", + "owner": "microsoft", + "repo": "fluentui-emoji", + "archive_url": "https://github.com/microsoft/fluentui-emoji/archive/{version}.tar.gz", + "source_path": "assets" + }, + "current_version": "1ffb34c752ecf5d402f04cfb4b392c77f57c54bc", + "ref": "commit 1ffb34c752 (main, 2026-08-24; upstream has no tags)", + "version_check_url": "https://api.github.com/repos/microsoft/fluentui-emoji/commits/main", + "license": { + "spdx": "MIT", + "sha256": "c2cfccb812fe482101a8f04597dfc5a9991a6b2748266c47ac91b6a5aae15383", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/fluent-emoji/LICENSE", + "read_from": "LICENSE at commit 1ffb34c752" + }, + "attribution_required": false, + "trademarks": false, + "icons": 4740, + "layout": "assets//[/]{Flat,High Contrast}/.svg flattened; upstream file names carry the style and tone", + "note": "The Color style (3,145 files, 132 MB of gradients) and the 3D PNGs are not vendored; Flat (3,145) and High Contrast (1,595) are." + }, + "pepicons": { + "name": "Pepicons", + "owner": "CyCraft", + "homepage": "https://pepicons.com", + "repository": "https://github.com/CyCraft/pepicons", + "source": { + "type": "npm", + "package": "pepicons", + "source_path": "svg" + }, + "current_version": "3.1.6", + "ref": "npm pepicons 3.1.6 (tag v3.1.6)", + "version_check_url": "https://registry.npmjs.org/pepicons/latest", + "license": { + "spdx": "CC-BY-4.0", + "sha256": "dbb19cb7bda19afef40f19094a668dfe61375cfd57c49d33c660bdb76676b5c5", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/pepicons/LICENSE", + "read_from": "LICENSE at tag v3.1.6 (the npm package ships none)" + }, + "attribution_required": true, + "trademarks": false, + "icons": 3825, + "layout": "svg/{pop,print,pencil} flattened to -pop / -print / -pencil" + }, + "game-icons": { + "name": "Game Icons", + "owner": "Lorc, Delapouite and the game-icons.net contributors", + "homepage": "https://game-icons.net", + "repository": "https://github.com/game-icons/icons", + "source": { + "type": "github-archive", + "owner": "game-icons", + "repo": "icons", + "archive_url": "https://github.com/game-icons/icons/archive/{version}.tar.gz", + "source_path": "." + }, + "current_version": "82d948812bfe3f269ef8f731dcdb07b08160edc4", + "ref": "commit 82d948812b (master; upstream has no tags)", + "version_check_url": "https://api.github.com/repos/game-icons/icons/commits/master", + "license": { + "spdx": "CC-BY-3.0", + "sha256": "fac95252798668fab5ea2cb08338f2ebc6bf894a72b19a15af1ca62f0a6d2e79", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/game-icons/license.txt", + "read_from": "license.txt at commit 82d948812b" + }, + "attribution_required": true, + "trademarks": false, + "icons": 4178, + "layout": "/.svg, one directory per contributor so every icon stays attributable", + "note": "badges/ (59) and various-artists/ (2) are not vendored: license.txt does not name them, so no licence is stated for them." + }, + "coreui": { + "name": "CoreUI Icons Free", + "owner": "CoreUI (creativeLabs)", + "homepage": "https://icons.coreui.io", + "repository": "https://github.com/coreui/coreui-icons", + "source": { + "type": "npm", + "package": "@coreui/icons", + "source_path": "svg/free" + }, + "current_version": "3.1.0", + "ref": "npm @coreui/icons 3.1.0", + "version_check_url": "https://registry.npmjs.org/@coreui/icons/latest", + "license": { + "spdx": "CC-BY-4.0", + "sha256": "171b668bd4fa31e5b75141d30290e5ab9eefcc881027ef24d0bd9e91aea8689b", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/coreui/LICENSE", + "read_from": "LICENSE in the npm package" + }, + "attribution_required": true, + "trademarks": false, + "icons": 562, + "layout": "svg/free/*.svg, unchanged names", + "note": "Only the free set is vendored. The brand (828) and flag (198) SVGs are CC0 in the same LICENSE, but the brands are trademarks and the flags duplicate ichava/icon-sets-flag." + }, + "pinhead": { + "name": "Pinhead", + "owner": "Wayside Mapping and the Pinhead contributors", + "homepage": "https://pinhead.ink", + "repository": "https://github.com/waysidemapping/pinhead", + "source": { + "type": "npm", + "package": "@waysidemapping/pinhead", + "source_path": "dist/icons" + }, + "current_version": "16.26.0", + "ref": "npm @waysidemapping/pinhead 16.26.0", + "version_check_url": "https://registry.npmjs.org/@waysidemapping/pinhead/latest", + "license": { + "spdx": "CC0-1.0", + "sha256": "a2010f343487d3f7618affe54f789f5487602331c0a8d03f49e9a7c547cf0499", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/pinhead/LICENSE", + "read_from": "LICENSE in the npm package" + }, + "attribution_required": false, + "trademarks": false, + "icons": 2747, + "layout": "dist/icons/*.svg, unchanged names", + "note": "Pinhead incorporates icons from Temaki, Maki, OpenStreetMap Carto, Osmic, Health Icons and others; its README lists every source as CC0 or public domain." + }, + "svg-logos": { + "name": "SVG Logos", + "owner": "Gil Barbara", + "homepage": "https://svgporn.com", + "repository": "https://github.com/gilbarbara/logos", + "source": { + "type": "github-archive", + "owner": "gilbarbara", + "repo": "logos", + "archive_url": "https://github.com/gilbarbara/logos/archive/{version}.tar.gz", + "source_path": "logos" + }, + "current_version": "22f03ffc4a652309d1fea4ade70f46e9e1d6602c", + "ref": "commit 22f03ffc4a (main; upstream has no releases)", + "version_check_url": "https://api.github.com/repos/gilbarbara/logos/commits/main", + "license": { + "spdx": "CC0-1.0", + "sha256": "36ffd9dc085d529a7e60e1276d73ae5a030b020313e6c5408593a6ae2af39673", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/svg-logos/LICENSE.txt", + "read_from": "LICENSE.txt at commit 22f03ffc4a" + }, + "attribution_required": false, + "trademarks": true, + "icons": 2068, + "layout": "logos/*.svg, unchanged names" + }, + "noto-emoji": { + "name": "Noto Emoji", + "owner": "Google", + "homepage": "https://fonts.google.com/noto/specimen/Noto+Color+Emoji", + "repository": "https://github.com/googlefonts/noto-emoji", + "source": { + "type": "github-archive", + "owner": "googlefonts", + "repo": "noto-emoji", + "archive_url": "https://github.com/googlefonts/noto-emoji/archive/refs/tags/v{version}.tar.gz", + "source_path": "2D/svg" + }, + "current_version": "2026-09-24-unicode18_0", + "ref": "tag v2026-09-24-unicode18_0 (e20cbc2bbe)", + "version_check_url": "https://api.github.com/repos/googlefonts/noto-emoji/releases/latest", + "license": { + "spdx": "Apache-2.0", + "sha256": "611ceab36dae96644ca84e8ace6873821790192bf6f73b0d0624a21b24b4b332", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/noto-emoji/LICENSE", + "read_from": "2D/svg/LICENSE at tag v2026-09-24-unicode18_0" + }, + "attribution_required": false, + "trademarks": false, + "icons": 3580, + "layout": "2D/svg/emoji_u.svg, names slugged (emoji-u1f600)", + "note": "The repository's root LICENSE is OFL-1.1 and covers the fonts; the SVG directory carries its own LICENSE, the Apache 2.0 notice, which is what governs these files. 170 SVGs are not vendored: they declare Adobe Illustrator entities in an internal DTD, which the sanitiser cannot parse, so they cannot be checked.", + "excluded": { + "reason": "Internal DTD declaring Adobe Illustrator entities; the sanitiser cannot parse them, so they are not shipped.", + "names": [ + "emoji-u1f306", + "emoji-u1f307", + "emoji-u1f341", + "emoji-u1f361", + "emoji-u1f367", + "emoji-u1f368", + "emoji-u1f369", + "emoji-u1f374", + "emoji-u1f37d", + "emoji-u1f380", + "emoji-u1f39b", + "emoji-u1f39f", + "emoji-u1f3a6", + "emoji-u1f3a9", + "emoji-u1f3aa", + "emoji-u1f3ac", + "emoji-u1f3cf", + "emoji-u1f3d1", + "emoji-u1f3d3", + "emoji-u1f3d8", + "emoji-u1f3d9", + "emoji-u1f3e8", + "emoji-u1f3e9", + "emoji-u1f3eb", + "emoji-u1f3f5", + "emoji-u1f3f8", + "emoji-u1f40b", + "emoji-u1f428", + "emoji-u1f42d", + "emoji-u1f433", + "emoji-u1f436", + "emoji-u1f43b-200d-2744", + "emoji-u1f43c", + "emoji-u1f454", + "emoji-u1f45b", + "emoji-u1f468-1f3fb-200d-1f393", + "emoji-u1f468-1f3fc-200d-1f393", + "emoji-u1f468-1f3fd-200d-1f393", + "emoji-u1f468-1f3fe-200d-1f393", + "emoji-u1f468-1f3ff-200d-1f393", + "emoji-u1f468-200d-1f393", + "emoji-u1f469-1f3fb-200d-1f393", + "emoji-u1f469-1f3fc-200d-1f393", + "emoji-u1f469-1f3fd-200d-1f393", + "emoji-u1f469-1f3fe-200d-1f393", + "emoji-u1f469-1f3ff-200d-1f393", + "emoji-u1f469-200d-1f393", + "emoji-u1f489", + "emoji-u1f4b1", + "emoji-u1f4b2", + "emoji-u1f4bb", + "emoji-u1f4d6", + "emoji-u1f4da", + "emoji-u1f4de", + "emoji-u1f4e2", + "emoji-u1f4e3", + "emoji-u1f4f3", + "emoji-u1f4f6", + "emoji-u1f4fa", + "emoji-u1f500", + "emoji-u1f501", + "emoji-u1f502", + "emoji-u1f504", + "emoji-u1f508", + "emoji-u1f509", + "emoji-u1f50a", + "emoji-u1f516", + "emoji-u1f518", + "emoji-u1f526", + "emoji-u1f53c", + "emoji-u1f53d", + "emoji-u1f54b", + "emoji-u1f573", + "emoji-u1f578", + "emoji-u1f5b2", + "emoji-u1f5fa", + "emoji-u1f638", + "emoji-u1f639", + "emoji-u1f63b", + "emoji-u1f680", + "emoji-u1f68e", + "emoji-u1f6a2", + "emoji-u1f6a3", + "emoji-u1f6a3-1f3fb", + "emoji-u1f6a3-1f3fb-200d-2640", + "emoji-u1f6a3-1f3fb-200d-2642", + "emoji-u1f6a3-1f3fc", + "emoji-u1f6a3-1f3fc-200d-2640", + "emoji-u1f6a3-1f3fc-200d-2642", + "emoji-u1f6a3-1f3fd", + "emoji-u1f6a3-1f3fd-200d-2640", + "emoji-u1f6a3-1f3fd-200d-2642", + "emoji-u1f6a3-1f3fe", + "emoji-u1f6a3-1f3fe-200d-2640", + "emoji-u1f6a3-1f3fe-200d-2642", + "emoji-u1f6a3-1f3ff", + "emoji-u1f6a3-1f3ff-200d-2640", + "emoji-u1f6a3-1f3ff-200d-2642", + "emoji-u1f6a3-200d-2640", + "emoji-u1f6a3-200d-2642", + "emoji-u1f6a5", + "emoji-u1f6a6", + "emoji-u1f6aa", + "emoji-u1f6b0", + "emoji-u1f6ce", + "emoji-u1f6d9", + "emoji-u1f6f6", + "emoji-u1f930", + "emoji-u1f930-1f3fb", + "emoji-u1f930-1f3fc", + "emoji-u1f930-1f3fd", + "emoji-u1f930-1f3fe", + "emoji-u1f930-1f3ff", + "emoji-u1f94d", + "emoji-u1f952", + "emoji-u1f962", + "emoji-u1f9c8", + "emoji-u1f9d1-1f3fb-200d-1f393", + "emoji-u1f9d1-1f3fc-200d-1f393", + "emoji-u1f9d1-1f3fd-200d-1f393", + "emoji-u1f9d1-1f3fe-200d-1f393", + "emoji-u1f9d1-1f3ff-200d-1f393", + "emoji-u1f9d1-200d-1f393", + "emoji-u1f9fd", + "emoji-u1fa8b", + "emoji-u1fa8d", + "emoji-u1fa90", + "emoji-u1fa9e", + "emoji-u1faa1", + "emoji-u1faa4", + "emoji-u1faa7", + "emoji-u1fac3-1f3fb", + "emoji-u1fac3-1f3fc", + "emoji-u1fac3-1f3fd", + "emoji-u1fac3-1f3fe", + "emoji-u1fac3-1f3ff", + "emoji-u1faeb", + "emoji-u1faf9", + "emoji-u1faf9-1f3fb", + "emoji-u1faf9-1f3fc", + "emoji-u1faf9-1f3fd", + "emoji-u1faf9-1f3fe", + "emoji-u1faf9-1f3ff", + "emoji-u1fafa", + "emoji-u1fafa-1f3fb", + "emoji-u1fafa-1f3fc", + "emoji-u1fafa-1f3fd", + "emoji-u1fafa-1f3fe", + "emoji-u1fafa-1f3ff", + "emoji-u23cf", + "emoji-u23e9", + "emoji-u23ea", + "emoji-u23eb", + "emoji-u23ec", + "emoji-u23ed", + "emoji-u23ee", + "emoji-u23ef", + "emoji-u23f8", + "emoji-u23f9", + "emoji-u23fa", + "emoji-u25aa", + "emoji-u25ab", + "emoji-u25b6", + "emoji-u25c0", + "emoji-u25fd", + "emoji-u25fe", + "emoji-u26d4", + "emoji-u26f0", + "emoji-u26f7", + "emoji-u2734" + ] + } + } + }, + "dropped": { + "vscode-icons": { + "ref": "tag v13.0.0", + "reason": "README at v13.0.0: \"The icons are licensed under the Creative Commons - ShareAlike (CC BY-SA) license\"; only the extension code is MIT. CC-BY-SA is not bundle-eligible." + } + }, + "update_command": { + "type": "recipe", + "recipe": "icon-sets-extras.", + "_note": "Each set has its own icon-sets-sync recipe, icon-sets-extras. (icon-sets-sync#88), which refreshes files// and licenses// and stamps upstream.sets..current_version." + } + }, + "license": { + "spdx": "MIT", + "scope": "package code only", + "icons": "per set: upstream.sets..license" + } +} diff --git a/tests/fixtures/RegistrationGolden/extras/files/.gitkeep b/tests/fixtures/RegistrationGolden/extras/files/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/RegistrationGolden/flag/config.json b/tests/fixtures/RegistrationGolden/flag/config.json new file mode 100644 index 00000000..02b2fd53 --- /dev/null +++ b/tests/fixtures/RegistrationGolden/flag/config.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.0", + "package": { + "name": "ichava/icon-sets-flag", + "title": "Flag Icons", + "description": "271 country flag SVGs sourced from lipis/flag-icons, available in 4x3 and 1x1 aspect ratios. Replaces the flag half of the deprecated simtabi/laflamoji.", + "keywords": [ + "flags", + "country-flags", + "svg", + "lipis", + "iso-3166" + ], + "version": "0.1.0", + "upstream_version": "7.6.0", + "type": "single", + "kind": "icon", + "license": "MIT" + }, + "metadata": { + "authors": [ + { + "name": "Panayiotis Lipiridis (lipis)", + "role": "Creator -- lipis/flag-icons", + "url": "https://github.com/lipis/flag-icons" + } + ], + "homepage": "https://github.com/lipis/flag-icons", + "repository": "https://github.com/ichava/icon-sets-flag", + "data": { + "variants": { + "4x3": { + "name": "4:3", + "slug": "4x3", + "description": "Canonical 4:3 aspect ratio. Best fit for most flags.", + "default": true, + "icon_suffix": "", + "display_order": 1, + "attributes": {} + }, + "1x1": { + "name": "1:1", + "slug": "1x1", + "description": "Square 1:1 aspect ratio. Useful for badges, round avatars, picker grids.", + "default": false, + "icon_suffix": "", + "display_order": 2, + "attributes": {} + } + }, + "categories": { + "country": { + "name": "Country flags", + "description": "ISO 3166-1 alpha-2 country codes (us, gb, de, jp, ...)", + "sets": [ + "country" + ] + } + } + } + }, + "config": { + "icon_prefix": "flag" + }, + "upstream": { + "source": { + "type": "npm", + "package": "flag-icons" + }, + "current_version": "7.6.0", + "version_check_url": "https://registry.npmjs.org/flag-icons/latest", + "license": "MIT", + "cdn": { + "jsdelivr": "https://cdn.jsdelivr.net/npm/flag-icons@{version}/flags/{ratio}/{code}.svg", + "unpkg": "https://unpkg.com/flag-icons@{version}/flags/{ratio}/{code}.svg", + "github_raw": "https://raw.githubusercontent.com/lipis/flag-icons/v{version}/flags/{ratio}/{code}.svg" + }, + "update_command": { + "type": "github-archive", + "_note": "Re-runs the bundled fetch + categorise. Run when assets need refreshing past the snapshot vendored here, which is `current_version`.", + "archive_url": "https://github.com/lipis/flag-icons/archive/refs/tags/v{version}.zip", + "source_path": "flag-icons-{version}/flags" + } + } +} diff --git a/tests/fixtures/RegistrationGolden/flag/files/.gitkeep b/tests/fixtures/RegistrationGolden/flag/files/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/RegistrationGolden/fluent/config.json b/tests/fixtures/RegistrationGolden/fluent/config.json new file mode 100644 index 00000000..67851d33 --- /dev/null +++ b/tests/fixtures/RegistrationGolden/fluent/config.json @@ -0,0 +1,90 @@ +{ + "schema_version": "1.0", + "package": { + "name": "ichava/icon-sets-fluent", + "title": "Fluent UI System Icons", + "description": "Microsoft's Fluent UI System Icons at 24 px, in regular and filled.", + "keywords": [ + "icons", + "svg", + "fluent", + "fluentui", + "microsoft" + ], + "version": "0.1.0", + "upstream_version": "1.1.345", + "type": "multi", + "kind": "icon", + "license": "MIT" + }, + "metadata": { + "authors": [ + { + "name": "Microsoft Corporation", + "role": "Creator -- Fluent UI System Icons", + "url": "https://github.com/microsoft/fluentui-system-icons" + } + ], + "homepage": "https://github.com/microsoft/fluentui-system-icons", + "repository": "https://github.com/ichava/icon-sets-fluent", + "data": { + "variants": { + "regular": { + "name": "Regular", + "slug": "regular", + "description": "Outlined style at 24 px", + "default": true, + "icon_suffix": "_24_regular", + "display_order": 1, + "attributes": {}, + "preview_icon": "home", + "color_scheme": "fill-based" + }, + "filled": { + "name": "Filled", + "slug": "filled", + "description": "Solid style at 24 px", + "default": false, + "icon_suffix": "_24_filled", + "display_order": 2, + "attributes": {}, + "preview_icon": "home", + "color_scheme": "fill-based" + } + }, + "categories": {}, + "kinds": { + "rules": [] + }, + "items": {} + } + }, + "config": { + "icon_prefix": "fluent" + }, + "license": { + "spdx": "MIT", + "sha256": "69bc45dc42b9acb96a69823adbc6ae538374e3c0bde169b855b32c48eaaef52f", + "checked_at": "2026-11-04", + "file": "resources/assets/svg/licenses/fluentui-system-icons/LICENSE", + "source": "https://github.com/microsoft/fluentui-system-icons/blob/73ec879377b1f412f33aca4a128aa967e6c238f0/LICENSE", + "notice": "resources/assets/svg/licenses/fluentui-system-icons/NOTICE" + }, + "upstream": { + "source": { + "type": "npm", + "package": "@fluentui/svg-icons", + "_ref": "73ec879377b1f412f33aca4a128aa967e6c238f0", + "_repository": "https://github.com/microsoft/fluentui-system-icons" + }, + "current_version": "1.1.345", + "version_check_url": "https://registry.npmjs.org/@fluentui/svg-icons/latest", + "license": "MIT", + "update_command": { + "type": "recipe", + "recipe": "icon-sets-fluent", + "_note": "icon-sets-sync has no config/icon-sets-fluent.json yet; the proposed recipe is pack-recipes/icon-sets-fluent.json in the ichava design folder, and lands with the one icon-sets-sync pull request that adds the v0.1.0 packs. Until then the weekly sync-upstream run has nothing to run." + }, + "_subset": "Size 24 only, regular and filled. The other sizes (10, 12, 16, 20, 28, 32, 48) and the `light` and `color` styles are not vendored. Upstream names are `_24_