commit b153959e1bb26511b49ae04ee80d090e9c267819 Author: Chen Asraf Date: Sun Apr 26 23:18:05 2026 +0300 feat: initial version Release-As: 0.1.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b850423 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['8.2', '8.3', '8.4'] + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run tests + run: vendor/bin/pest + + - name: Run static analysis + run: vendor/bin/phpstan analyse + + - name: Check code style + run: vendor/bin/pint --test diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..1bd06e9 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,18 @@ +name: Release Please + +on: + push: + branches: + - master + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + release-type: php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1d4abb --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +composer.phar +composer.lock +/.phpunit.cache/ +/.php-cs-fixer.cache +/tmp diff --git a/.phpactor.json b/.phpactor.json new file mode 100644 index 0000000..4d42bbb --- /dev/null +++ b/.phpactor.json @@ -0,0 +1,4 @@ +{ + "$schema": "/phpactor.schema.json", + "language_server_phpstan.enabled": true +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fb7ad81 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 Latch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1ddd390 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +COMPOSER := $(shell command -v composer 2>/dev/null || echo php composer.phar) + +.PHONY: install install-hooks test analyze fix + +composer.phar: + php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" + php composer-setup.php + php -r "unlink('composer-setup.php');" + +install: + $(COMPOSER) install + +install-hooks: + lefthook install + +test: + vendor/bin/pest + +analyze: + vendor/bin/phpstan analyse + +fix: + vendor/bin/pint diff --git a/README.md b/README.md new file mode 100644 index 0000000..d71ab9d --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# Latch + +A cross-package hook/filter registry system for PHP. Apps register themselves as "hook sources" and +declare typed extension points. Other packages attach handlers to those points. The source app +queries the registry at runtime to collect, transform, or broadcast through those handlers. + +## Requirements + +- PHP ^8.2 + +## Installation + +```bash +composer require chenasraf/latch +``` + +## Quick Start + +```php +use Latch\HookRegistry; + +$registry = new HookRegistry(); + +// 1. Source declares extension points +$cms = $registry->registerSource('cms') + ->filter('render-html', RenderPayload::class) + ->action('page-published', PageEvent::class) + ->collect('nav-items', NavContext::class); + +// 2. Register a named handler and attach to those points +$seo = $registry->registerHandler('seo'); + +$seo->hook('cms', 'render-html') + ->priority(5) + ->handle(fn (RenderPayload $p) => $p->withHtml(minify($p->html))); + +$seo->hook('cms', 'nav-items') + ->when(fn (NavContext $ctx) => $ctx->user->isAdmin()) + ->handle(fn (NavContext $ctx) => [new NavItem('Admin', '/admin')]); + +// 3. Source invokes at runtime +$payload = $cms->apply('render-html', new RenderPayload($html)); +$cms->dispatch('page-published', new PageEvent($page, $user)); +$navItems = $cms->collectFromHandlers('nav-items', new NavContext($user)); +``` + +## Hook Types + +| Type | Source invokes | Handler receives | Handler returns | +| ------- | -------------- | -------------------------- | ------------------------------- | +| Filter | `apply()` | The payload object | A modified payload (chained) | +| Action | `dispatch()` | The payload object | Nothing (return value ignored) | +| Collect | `collectFromHandlers()`| An optional context object | An array of items (merged flat) | + +## Handler Options + +```php +$handler = $registry->registerHandler('my-plugin'); + +$handler->hook('source', 'point') + ->priority(5) // Lower runs first (default: 10) + ->exclusive() // Short-circuits remaining handlers after this one + ->when(fn ($p) => ...) // Skip handler if condition returns false + ->tag('admin', 'ui') // Additional tags for introspection and filtering + ->handle(fn ($p) => ...); +``` + +All hooks are automatically tagged with `handler:{name}`. Sources can target specific handlers: + +```php +$cms->dispatch('page-published', $event, ['handler:seo']); +``` + +Each handler name can only be registered once. Pass the `HookHandler` instance via DI to +reuse it across your package. + +## Capability Discovery + +Both sources and handlers can declare capability tags at registration. The registry can then be +queried to find either side by capability — without knowing names in advance. + +```php +// Sources declare what they provide +$registry->registerSource('cms', tags: ['content-management', 'publishing']); + +// Handlers declare what they provide +$registry->registerHandler('seo', ['content-enhancement']); +$registry->registerHandler('minifier', ['content-enhancement']); +$registry->registerHandler('analytics', ['tracking']); + +// Discover handlers by capability +$enhancers = $registry->handlersByTag('content-enhancement'); +// → [HandlerInfo('seo'), HandlerInfo('minifier')] + +// Discover sources by capability +$cmsSources = $registry->sourcesByTag('content-management'); +// → [SourceStore('cms')] +``` + +This enables a discovery pattern where either side can find compatible counterparts at runtime, +present them to the user, and target the chosen one. + +## Existence Checks + +```php +$registry->hasSource('cms'); // Does this source exist? +$cms->hasHandlers('render-html'); // Does anyone handle this point? +``` + +## Framework Integration + +| Framework | Setup | +| --------- | ----- | +| Laravel | Auto-discovered — inject `HookRegistryInterface` anywhere | +| Nextcloud | Each app bundles Latch; use `LatchBootstrap::registry()` for cross-app hooks via NC events | +| Plain PHP | `HookRegistry::getInstance()` for shared singleton, or `new HookRegistry()` | + +## Documentation + +- **[Guide](docs/guide.md)** — Full API reference for sources, handlers, introspection, and error handling +- **[Examples](docs/examples.md)** — End-to-end examples and framework integration walkthroughs + +## Development + +```bash +make install # Install dependencies +make install-hooks # Set up lefthook git hooks +make test # Run tests (Pest) +make analyze # Static analysis (PHPStan level 8) +make fix # Code style (Laravel Pint) +``` + +## License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ff9d2a5 --- /dev/null +++ b/composer.json @@ -0,0 +1,43 @@ +{ + "name": "chenasraf/latch", + "description": "Cross-package hook/filter registry system for PHP", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Chen Asraf" + } + ], + "require": { + "php": "^8.2" + }, + "require-dev": { + "pestphp/pest": "^2.0", + "phpstan/phpstan": "^1.10", + "laravel/pint": "^1.0" + }, + "autoload": { + "psr-4": { + "Latch\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Latch\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "extra": { + "laravel": { + "providers": [ + "Latch\\Integration\\Laravel\\LatchServiceProvider" + ] + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..715ef97 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,358 @@ +# Examples + +## End-to-End: CMS + SEO Plugin + +A CMS app that declares extension points, and an SEO plugin that hooks into them. + +### Source side — CMS app + +```php +use Latch\HookRegistry; + +class CmsApp +{ + private HookSource $cms; + + public function __construct(private HookRegistry $registry) + { + $this->cms = $registry->registerSource('cms') + ->filter('render-html', RenderPayload::class) + ->filter('page-title', TitlePayload::class) + ->action('page-published', PageEvent::class) + ->collect('head-tags', PageContext::class); + } + + public function renderPage(Page $page): string + { + $payload = new RenderPayload($page->html); + $payload = $this->cms->apply('render-html', $payload); + + $title = new TitlePayload($page->title); + $title = $this->cms->apply('page-title', $title); + + $headTags = $this->cms->collectFromHandlers( + 'head-tags', new PageContext($page) + ); + + return << + + + {$title->value} + {$this->renderTags($headTags)} + + {$payload->html} + + HTML; + } + + public function publishPage(Page $page, User $actor): void + { + $page->publish(); + + $this->cms->dispatch( + 'page-published', new PageEvent($page, $actor) + ); + } + + private function renderTags(array $tags): string + { + return implode("\n ", $tags); + } +} +``` + +### Handler side — SEO plugin + +```php +use Latch\HookRegistry; + +class SeoPlugin +{ + public function __construct(HookRegistry $registry) + { + // Register a named handler - all hooks auto-tagged with 'handler:seo' + $seo = $registry->registerHandler('seo'); + + $seo->hook('cms', 'head-tags') + ->priority(1) + ->handle(fn (PageContext $ctx) => [ + "page->excerpt}\">", + "page->title}\">", + ]); + + $seo->hook('cms', 'page-title') + ->priority(50) + ->handle(fn (TitlePayload $t) => $t->withValue( + $t->value . ' | My Site' + )); + + $seo->hook('cms', 'render-html') + ->priority(90) + ->handle(fn (RenderPayload $p) => $p->withHtml( + $p->html . $this->jsonLdScript($p) + )); + + $seo->hook('cms', 'page-published') + ->handle(fn (PageEvent $e) => SitemapQueue::push($e->page->url)); + } + + private function jsonLdScript(RenderPayload $payload): string + { + return ''; + } +} +``` + +### Wiring + +```php +$registry = new HookRegistry(); + +$cms = new CmsApp($registry); +$seo = new SeoPlugin($registry); + +echo $cms->renderPage($page); +``` + +## Tag Filtering: Targeted Invocation + +When multiple handlers are registered for the same point, the source can use tag filtering to only +invoke specific ones. Pass a list of tags to `apply()`, `dispatch()`, or `collectFromHandlers()` and only +handlers with at least one matching tag will run. + +Using `registerHandler()`, each plugin gets auto-tagged with `handler:{name}`, making targeted +invocation straightforward. + +```php +// Source registers with head-tags collect point +$cms = $registry->registerSource('cms') + ->collect('head-tags', PageContext::class); + +// SEO plugin registers with a named handler +$seo = $registry->registerHandler('seo'); +$seo->hook('cms', 'head-tags') + ->handle(fn (PageContext $ctx) => [ + "page->excerpt}\">", + ]); + +// Analytics plugin registers with its own named handler +$analytics = $registry->registerHandler('analytics'); +$analytics->hook('cms', 'head-tags') + ->handle(fn (PageContext $ctx) => [ + '', + ]); + +// Collect from all handlers (default) +$allTags = $cms->collectFromHandlers('head-tags', $context); + +// Collect only from the SEO handler +$seoTags = $cms->collectFromHandlers('head-tags', $context, ['handler:seo']); +``` + +### Capability discovery + +Instead of hard-coding handler names, the source can discover handlers by capability tag and then +target the chosen one. + +```php +// Handlers declare capabilities at registration +$email = $registry->registerHandler('email', ['notification-delivery']); +$push = $registry->registerHandler('push', ['notification-delivery']); + +// Both hook into the same points +$cms = $registry->registerSource('cms') + ->action('notify-subscribers', NotifyPayload::class); + +$email->hook('cms', 'notify-subscribers') + ->handle(fn (NotifyPayload $p) => EmailService::send($p)); + +$push->hook('cms', 'notify-subscribers') + ->handle(fn (NotifyPayload $p) => PushService::send($p)); + +// Step 1: Discover who can deliver notifications +$providers = $registry->handlersByTag('notification-delivery'); +// → [HandlerInfo('email', [...]), HandlerInfo('push', [...])] + +// Step 2: Present them to the user +foreach ($providers as $p) { + echo "{$p->name}\n"; // 'email', 'push' +} + +// Step 3: Dispatch to the user's choice +$chosen = $userChoice; // e.g. 'email' +$cms->dispatch( + 'notify-subscribers', $payload, ["handler:{$chosen}"] +); +``` + +This pattern lets the source app find compatible handlers at runtime without importing +anything from the handler packages — only the capability tag string needs to be agreed upon. + +### Handler discovering sources + +The reverse works too — a handler can discover sources by capability and hook into all of them: + +```php +// Sources declare capabilities +$registry->registerSource('cms', null, ['content-management']) + ->filter('render-html', RenderPayload::class); + +$registry->registerSource('blog', null, ['content-management']) + ->filter('render-html', RenderPayload::class); + +// Handler discovers all content-management sources and hooks into each +$minifier = $registry->registerHandler('minifier'); + +$sources = $registry->sourcesByTag('content-management'); +foreach ($sources as $source) { + $minifier->hook($source->id, 'render-html') + ->handle(fn (RenderPayload $p) => $p->withHtml(minify($p->html))); +} +``` + +## Laravel Integration + +The service provider is auto-discovered. Inject `HookRegistryInterface` anywhere Laravel resolves +dependencies. + +### Source — CMS service provider + +```php +use Latch\Contracts\HookRegistryInterface; + +class CmsServiceProvider extends ServiceProvider +{ + public function boot(HookRegistryInterface $registry): void + { + $cms = $registry->registerSource('cms') + ->filter('render-html', RenderPayload::class) + ->action('page-published', PageEvent::class) + ->collect('nav-items', NavContext::class); + } +} +``` + +### Handler — SEO service provider + +```php +class SeoServiceProvider extends ServiceProvider +{ + public function boot(HookRegistryInterface $registry): void + { + $seo = $registry->registerHandler('seo'); + + $seo->hook('cms', 'render-html') + ->priority(90) + ->handle(fn (RenderPayload $p) => $p->withHtml( + $p->html . '' + )); + } +} +``` + +You can also resolve via the `latch` alias: + +```php +$registry = app('latch'); +``` + +## Nextcloud Integration + +Each Nextcloud app bundles its own copy of Latch via `composer require`. Cross-app communication +is handled transparently by `LatchBootstrap::registry()`, which returns a bridged registry that +routes hooks through NC's shared `IEventDispatcher`. No shared autoloader or extra app needed. + +The API is identical to plain Latch — the only difference is using `LatchBootstrap::registry()` +instead of `new HookRegistry()`. + +### Source — app that owns the hooks + +```php +namespace OCA\CmsApp\AppInfo; + +use Latch\Integration\Nextcloud\LatchBootstrap; +use Latch\Integration\Nextcloud\BridgedSource; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IRegistrationContext; + +class Application extends App implements IBootstrap +{ + private BridgedSource $cms; + + public function __construct() + { + parent::__construct('cms'); + } + + public function register(IRegistrationContext $context): void {} + + public function boot(IBootContext $context): void + { + $registry = LatchBootstrap::registry(); + + // Same API as plain Latch — declaration and invocation work the same way + $this->cms = $registry->registerSource('cms') + ->action('page-published', PageEvent::class) + ->collect('head-tags', PageContext::class); + } + + public function publishPage(Page $page, User $actor): void + { + // Dispatches to both local handlers and handlers in other apps + $this->cms->dispatch( + 'page-published', + new PageEvent($page, $actor) + ); + } +} +``` + +### Handler — another Nextcloud app + +The handler app hooks into a remote source using the same `hook()` API. If the source doesn't +exist in the local registry, it's treated as remote and the handler listens via NC events. + +```php +namespace OCA\SeoPlugin\AppInfo; + +use Latch\Integration\Nextcloud\LatchBootstrap; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IRegistrationContext; + +class Application extends App implements IBootstrap +{ + public function __construct() + { + parent::__construct('seo'); + } + + public function register(IRegistrationContext $context): void {} + + public function boot(IBootContext $context): void + { + $registry = LatchBootstrap::registry(); + $handler = $registry->registerHandler('seo'); + + // Same API — hook into a source by name, even if it's in another app + $handler->hook('cms', 'page-published') + ->handle(fn ($payload) => SitemapQueue::push($payload->page->url)); + } +} +``` + +### How it works + +Each app bundles its own copy of Latch. The bridged registry uses NC's `IEventDispatcher` +(shared across all apps) with conventional event names like `latch:cms:page-published`. +Payloads work across autoloader boundaries because PHP resolves methods on the actual object +at runtime, not the type hint. + +All three hook types are supported through the bridge: +- **Actions** — fire-and-forget through NC events +- **Filters** — the event carries the payload; each listener mutates it and the source reads it back +- **Collectors** — each listener adds items to the event; the source collects them after dispatch diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..becbdbf --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,495 @@ +# Guide + +## Concepts + +Latch has two sides: + +- **Source side** — the app that owns the extension points. It declares what hooks exist, what + payload types they expect, and invokes them at the right moment. +- **Handler side** — any package that wants to extend the source. It attaches callables to the + declared points. + +Sources must be registered before handlers can attach. Hooking into an undeclared source or point +throws immediately — there are no silent no-ops. + +## Source Side + +### Registering a source + +A source declares its identity and extension points upfront. Each point has a name, a type, and the +class of payload/context it works with. + +```php +use Latch\HookRegistry; + +$registry = new HookRegistry(); + +$cms = $registry->registerSource('cms') + ->filter('render-html', RenderPayload::class) + ->filter('page-title', TitlePayload::class) + ->action('page-published', PageEvent::class) + ->action('page-deleted', PageEvent::class) + ->collect('sidebar-widgets', SidebarContext::class) + ->collect('admin-menu', MenuContext::class); +``` + +You can optionally pass a class name and capability tags: + +```php +$registry->registerSource('cms', CmsApplication::class, ['content-management', 'publishing']); +``` + +### Invoking filters + +Filters run a chain of handlers, each receiving the payload and returning a modified version. Use +immutable value objects with `with*` methods for clean chaining. + +```php +class RenderPayload +{ + public function __construct( + public readonly string $html, + public readonly array $meta = [], + ) {} + + public function withHtml(string $html): self + { + return new self($html, $this->meta); + } + + public function withMeta(string $key, mixed $value): self + { + return new self($this->html, [...$this->meta, $key => $value]); + } +} + +$payload = new RenderPayload($rawHtml); +$payload = $cms->apply('render-html', $payload); +echo $payload->html; +``` + +If no handlers are registered, the payload is returned unchanged. + +### Invoking actions + +Actions are fire-and-forget. The source broadcasts an event; handler return values are ignored. + +```php +class PageEvent +{ + public function __construct( + public readonly Page $page, + public readonly User $actor, + ) {} +} + +$cms->dispatch('page-published', new PageEvent($page, $currentUser)); +``` + +### Invoking collectors + +Collectors gather contributions from all handlers. Each handler returns an array; results are +merged into a single flat list. + +```php +class SidebarContext +{ + public function __construct( + public readonly Page $page, + public readonly User $viewer, + ) {} +} + +class Widget +{ + public function __construct( + public readonly string $title, + public readonly string $html, + ) {} +} + +$widgets = $cms->collectFromHandlers('sidebar-widgets', new SidebarContext($page, $viewer)); + +foreach ($widgets as $widget) { + echo "

{$widget->title}

{$widget->html}
"; +} +``` + +The context argument is optional — pass `null` or omit it if handlers don't need context: + +```php +$items = $cms->collectFromHandlers('admin-menu'); +``` + +If no handlers are registered, an empty array is returned. + +## Handler Side + +### Registering a handler + +Every handler must be registered with a name using `registerHandler()`. This creates a +`HookHandler` that you use to attach hooks. All hooks are automatically tagged with +`handler:{name}`. + +```php +$seo = $registry->registerHandler('seo'); + +$seo->hook('cms', 'render-html') + ->handle(fn (RenderPayload $p) => $p->withHtml( + str_replace('{{year}}', date('Y'), $p->html) + )); +``` + +Each name can only be registered once. Attempting to register the same name again throws +`DuplicateHandlerException`. Pass the `HookHandler` instance around via DI or store it as +a property to reuse across your package. + +### Additional tags + +You can pass additional tags at registration time: + +```php +$seo = $registry->registerHandler('seo', ['premium']); +// All hooks get both 'handler:seo' and 'premium' +``` + +Or add more tags later with `globalTags()` - these apply to all hooks created after the call: + +```php +$seo = $registry->registerHandler('seo'); + +$seo->hook('cms', 'head-tags')->handle(...); +// Tagged: ['handler:seo'] + +$seo->globalTags('v2'); + +$seo->hook('cms', 'render-html')->handle(...); +// Tagged: ['handler:seo', 'v2'] +``` + +Per-hook tags still work and are merged with the auto-tags: + +```php +$seo->hook('cms', 'page-published') + ->tag('sitemap') + ->handle(...); +// Tagged: ['handler:seo', 'sitemap'] +``` + +The `handler:` prefix is reserved and cannot be used manually via `tag()` or `globalTags()`. + +### Priority + +Handlers run in priority order — lower numbers run first. Default is `10`. + +```php +$seo = $registry->registerHandler('seo'); + +// Runs first — sanitize early +$seo->hook('cms', 'render-html') + ->priority(1) + ->handle(fn (RenderPayload $p) => $p->withHtml(strip_tags($p->html, '

'))); + +// Runs last — final formatting pass +$seo->hook('cms', 'render-html') + ->priority(100) + ->handle(fn (RenderPayload $p) => $p->withHtml(nl2br($p->html))); +``` + +### Exclusive handlers + +An exclusive handler short-circuits the chain. No lower-priority handlers run after it. This works +on all three hook types. + +```php +$cache = $registry->registerHandler('cache'); + +$cache->hook('cms', 'render-html') + ->priority(0) + ->exclusive() + ->when(fn (RenderPayload $p) => Cache::has("page:{$p->html}")) + ->handle(fn (RenderPayload $p) => $p->withHtml(Cache::get("page:{$p->html}"))); +``` + +### Conditional handlers + +Use `when()` to attach a condition. The handler is skipped (not removed) when the condition returns +false. Skipped handlers do not break the chain — subsequent handlers continue normally. + +```php +$dashboard = $registry->registerHandler('dashboard'); + +// Only run for logged-in users +$dashboard->hook('cms', 'sidebar-widgets') + ->when(fn (SidebarContext $ctx) => $ctx->viewer->isAuthenticated()) + ->handle(fn (SidebarContext $ctx) => [ + new Widget('Your Drafts', renderDraftsWidget($ctx->viewer)), + ]); + +// Only run in production +$dashboard->hook('cms', 'render-html') + ->when(fn () => getenv('APP_ENV') === 'production') + ->handle(fn (RenderPayload $p) => $p->withHtml(minifyHtml($p->html))); +``` + +### Tag filtering + +When invoking a hook, the source can pass a list of tags to only run matching handlers. A handler +matches if it has at least one of the requested tags. + +```php +// Only run the SEO handler +$payload = $cms->apply('render-html', $payload, ['handler:seo']); + +// Only run handlers with a custom tag +$items = $cms->collectFromHandlers('admin-menu', $context, ['premium']); +``` + +If no tags are passed (the default), all handlers run as usual. This is useful when the source +wants to target a specific handler - for example, letting the user choose which app handles a +particular action. + +### Full handler example + +```php +$debug = $registry->registerHandler('debug'); + +$debug->hook('cms', 'sidebar-widgets') + ->priority(5) + ->exclusive() + ->when(fn (SidebarContext $ctx) => $ctx->viewer->isAdmin()) + ->tag('admin') + ->handle(fn (SidebarContext $ctx) => [ + new Widget('Debug Panel', renderDebugPanel()), + new Widget('Cache Stats', renderCacheStats()), + ]); +``` + +## Existence Checks + +Both sides can check whether the other exists before committing to work. + +### Handler side — check if a source exists + +Useful for optional integrations where the source package may not be installed: + +```php +$minifier = $registry->registerHandler('minifier'); + +if ($registry->hasSource('cms')) { + $minifier->hook('cms', 'render-html') + ->handle(fn (RenderPayload $p) => $p->withHtml(minify($p->html))); +} +``` + +### Source side — check if anyone is listening + +Useful for skipping expensive payload construction when no handlers are registered: + +```php +if ($cms->hasHandlers('render-html')) { + $payload = new RenderPayload($this->buildExpensiveHtml()); + $payload = $cms->apply('render-html', $payload); +} +``` + +Both methods validate their arguments — `hasHandlers()` throws `SourceNotFoundException` or +`HookPointNotFoundException` if the source or point doesn't exist. + +## Capability Discovery + +Both sources and handlers can declare capability tags at registration time, describing what they +provide rather than who they are. The registry can then be queried by tag to find matching +counterparts from either side. + +### Declaring capabilities + +Sources declare tags as the third argument to `registerSource()`: + +```php +$cms = $registry->registerSource('cms', null, ['content-management', 'publishing']); +``` + +Handlers declare tags as the second argument to `registerHandler()`: + +```php +$seo = $registry->registerHandler('seo', ['content-enhancement', 'html-processing']); +$analytics = $registry->registerHandler('analytics', ['tracking']); +$minifier = $registry->registerHandler('minifier', ['content-enhancement']); +``` + +Handler capability tags are separate from per-hook tags set via `tag()` — they describe the +handler itself, not individual hooks. + +### Querying handlers by capability + +Use `handlersByTag()` to find all handlers that declared a given capability: + +```php +$enhancers = $registry->handlersByTag('content-enhancement'); +// → [HandlerInfo('seo', [...]), HandlerInfo('minifier', [...])] +``` + +Each result is a `HandlerInfo` with `name`, `tags`, and a `hasTag()` helper: + +```php +foreach ($enhancers as $info) { + echo $info->name; // 'seo' + echo $info->hasTag('tracking'); // false +} +``` + +### Querying sources by capability + +Use `sourcesByTag()` to find all sources that declared a given capability: + +```php +$contentSources = $registry->sourcesByTag('content-management'); +// → [SourceStore('cms')] +``` + +Each result is a `SourceStore` with `id`, `class`, `tags`, and a `hasTag()` helper. This lets +handlers discover which sources are available without hard-coding source IDs: + +```php +foreach ($contentSources as $source) { + echo $source->id; // 'cms' + echo $source->hasTag('publishing'); // true +} +``` + +### Listing all handlers + +Use `handlers()` to get all registered handlers keyed by name: + +```php +$all = $registry->handlers(); +// → ['seo' => HandlerInfo, 'analytics' => HandlerInfo, ...] +``` + +### Discovery + targeted invocation + +Capability discovery pairs naturally with tag filtering. Discover available providers, let the +user (or your app logic) pick one, then target it: + +```php +// 1. Find all handlers that provide notification delivery +$providers = $registry->handlersByTag('notification-delivery'); + +// 2. Pick one (user choice, config, first match, etc.) +$chosen = $providers[0]->name; // e.g. 'email' + +// 3. Target only that handler when dispatching +$cms->dispatch('notify-subscribers', $payload, ["handler:{$chosen}"]); +``` + +Handlers can discover sources the same way: + +```php +// A handler finds sources it can extend +$sources = $registry->sourcesByTag('content-management'); + +foreach ($sources as $source) { + $handler->hook($source->id, 'render-html') + ->handle(fn (RenderPayload $p) => $p->withHtml(minify($p->html))); +} +``` + +## Introspection + +### List all sources + +```php +$sources = $registry->sources(); +// Returns: array + +foreach ($sources as $id => $source) { + echo "{$id} (class: {$source->class})\n"; +} +``` + +### List hook points for a source + +```php +$points = $registry->sources()['cms']->points(); +// Returns: array + +foreach ($points as $name => $point) { + echo "{$name}: {$point->type->value} ({$point->payloadClass})\n"; +} +// render-html: filter (RenderPayload) +// page-published: action (PageEvent) +// sidebar-widgets: collect (SidebarContext) +``` + +### List handlers for a point + +Handlers are returned sorted by priority (lower first): + +```php +$handlers = $registry->sources()['cms']->getHandlers('render-html'); +// Returns: list + +foreach ($handlers as $handler) { + echo "priority={$handler->priority}" + . " exclusive=" . ($handler->exclusive ? 'yes' : 'no') + . " tags=" . implode(',', $handler->tags) + . "\n"; +} +``` + +## Error Handling + +Latch throws specific exceptions for invalid usage: + +| Exception | When | +| ---------------------------- | --------------------------------------------------------------------------------- | +| `SourceNotFoundException` | Hooking into, invoking, or checking an unregistered source ID | +| `HookPointNotFoundException` | Referencing a point name that wasn't declared on the source | +| `HookTypeMismatchException` | Calling `apply()` on an action point, `dispatch()` on a filter, etc. | +| `DuplicateHandlerException` | Calling `registerHandler()` with a name that's already registered | +| `DuplicateSourceException` | Calling `registerSource()` with a name that's already registered | +| `ReservedTagException` | Using the `handler:` prefix in `tag()` or `globalTags()` (reserved for auto-tags) | + +```php +use Latch\Exceptions\SourceNotFoundException; +use Latch\Exceptions\HookPointNotFoundException; +use Latch\Exceptions\HookTypeMismatchException; +use Latch\Exceptions\DuplicateHandlerException; +use Latch\Exceptions\DuplicateSourceException; +use Latch\Exceptions\ReservedTagException; + +try { + $cms->apply('some-point', $payload); +} catch (HookPointNotFoundException $e) { + // "Hook point 'some-point' not found in source 'cms'." +} + +try { + $handler = $registry->registerHandler('seo'); + $handler->hook('cms', 'nonexistent-point'); +} catch (HookPointNotFoundException $e) { + // "Hook point 'nonexistent-point' not found in source 'cms'." +} + +try { + // 'page-published' is an action, not a filter + $cms->apply('page-published', $payload); +} catch (HookTypeMismatchException $e) { + // "Hook point 'page-published' in source 'cms' is of type 'action', but was invoked as 'filter'." +} + +try { + $registry->registerHandler('seo'); + $registry->registerHandler('seo'); // already registered +} catch (DuplicateHandlerException $e) { + // "Handler 'seo' is already registered." +} + +try { + $registry->registerSource('cms'); + $registry->registerSource('cms'); // already registered +} catch (DuplicateSourceException $e) { + // "Source 'cms' is already registered." +} +``` diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..5aa7d4e --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,10 @@ +pre-commit: + parallel: true + commands: + lint: + glob: "*.php" + run: vendor/bin/phpstan analyse --no-progress + fix: + glob: "*.php" + run: vendor/bin/pint {staged_files} + stage_fixed: true diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..ffadbd5 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 8 + paths: + - src + excludePaths: + - src/Integration/ diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..4c2aa0d --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,12 @@ + + + + + tests + + + diff --git a/src/Builders/HandlerBuilder.php b/src/Builders/HandlerBuilder.php new file mode 100644 index 0000000..25bb667 --- /dev/null +++ b/src/Builders/HandlerBuilder.php @@ -0,0 +1,96 @@ + */ + private array $tags = []; + + public function __construct( + private readonly SourceStore $source, + private readonly string $point, + ) {} + + public function priority(int $priority): self + { + $this->priority = $priority; + + return $this; + } + + public function exclusive(): self + { + $this->exclusive = true; + + return $this; + } + + /** + * @param callable(object): bool $condition + */ + public function when(callable $condition): self + { + $this->condition = $condition(...); + + return $this; + } + + public function tag(string ...$tags): self + { + foreach ($tags as $tag) { + if (str_starts_with($tag, 'handler:')) { + throw new ReservedTagException($tag); + } + } + + $this->tags = [...$this->tags, ...array_values($tags)]; + + return $this; + } + + /** + * @internal Used by HookHandler to set auto-tags including handler: prefix. + */ + public function autoTag(string ...$tags): self + { + $this->tags = [...$this->tags, ...array_values($tags)]; + + return $this; + } + + /** + * Register the handler callable. This finalizes the builder. + * + * @param callable $handler The handler function to register + */ + public function handle(callable $handler): HandlerEntry + { + $hookHandler = new HandlerEntry( + handler: $handler(...), + priority: $this->priority, + exclusive: $this->exclusive, + condition: $this->condition, + tags: $this->tags, + ); + + $this->source->addHandler($this->point, $hookHandler); + + return $hookHandler; + } +} diff --git a/src/Builders/SourceBuilder.php b/src/Builders/SourceBuilder.php new file mode 100644 index 0000000..74be3da --- /dev/null +++ b/src/Builders/SourceBuilder.php @@ -0,0 +1,60 @@ +source->addPoint(new HookPoint($name, HookType::Filter, $payloadClass)); + + return $this; + } + + /** + * Declare an action point. + * + * @param class-string $payloadClass + */ + public function action(string $name, string $payloadClass): self + { + $this->source->addPoint(new HookPoint($name, HookType::Action, $payloadClass)); + + return $this; + } + + /** + * Declare a collect point. + * + * @param class-string $payloadClass + */ + public function collect(string $name, string $payloadClass): self + { + $this->source->addPoint(new HookPoint($name, HookType::Collect, $payloadClass)); + + return $this; + } + + public function getSource(): SourceStore + { + return $this->source; + } +} diff --git a/src/Contracts/HookRegistryInterface.php b/src/Contracts/HookRegistryInterface.php new file mode 100644 index 0000000..b87fa38 --- /dev/null +++ b/src/Contracts/HookRegistryInterface.php @@ -0,0 +1,62 @@ + $tags Capability tags for discovery + */ + public function registerSource(string $id, ?string $class = null, array $tags = []): HookSource; + + /** + * Register a named handler. All hooks created through it are auto-tagged + * with handler:{name} and any additional tags. Each name can only be registered once. + * + * @param list $tags Additional tags applied to all hooks from this handler + */ + public function registerHandler(string $name, array $tags = []): HookHandler; + + /** + * Check whether a source is registered. + */ + public function hasSource(string $id): bool; + + /** + * List all registered sources. + * + * @return array + */ + public function sources(): array; + + /** + * List all registered handlers. + * + * @return array + */ + public function handlers(): array; + + /** + * Find handlers that have a specific capability tag. + * + * @return list + */ + public function handlersByTag(string $tag): array; + + /** + * Find sources that have a specific capability tag. + * + * @return list + */ + public function sourcesByTag(string $tag): array; +} diff --git a/src/Exceptions/DuplicateHandlerException.php b/src/Exceptions/DuplicateHandlerException.php new file mode 100644 index 0000000..f859c30 --- /dev/null +++ b/src/Exceptions/DuplicateHandlerException.php @@ -0,0 +1,15 @@ +value}', but was invoked as '{$actual->value}'." + ); + } +} diff --git a/src/Exceptions/ReservedTagException.php b/src/Exceptions/ReservedTagException.php new file mode 100644 index 0000000..356f07a --- /dev/null +++ b/src/Exceptions/ReservedTagException.php @@ -0,0 +1,15 @@ + $tags Arbitrary tags for introspection + */ + public function __construct( + public readonly \Closure $handler, + public readonly int $priority = 10, + public readonly bool $exclusive = false, + public readonly ?\Closure $condition = null, + public readonly array $tags = [], + ) {} + + /** + * Check whether this handler should run for the given payload/context. + */ + public function shouldRun(?object $context = null): bool + { + if ($this->condition === null) { + return true; + } + + return (bool) ($this->condition)($context); + } +} diff --git a/src/HandlerInfo.php b/src/HandlerInfo.php new file mode 100644 index 0000000..1f97778 --- /dev/null +++ b/src/HandlerInfo.php @@ -0,0 +1,28 @@ + $tags Capability tags declared at registration (excludes handler:{name} auto-tag) + */ + public function __construct( + public readonly string $name, + public readonly array $tags = [], + ) {} + + /** + * Check whether this handler has a specific capability tag. + */ + public function hasTag(string $tag): bool + { + return in_array($tag, $this->tags, true); + } +} diff --git a/src/HookHandler.php b/src/HookHandler.php new file mode 100644 index 0000000..3f0e148 --- /dev/null +++ b/src/HookHandler.php @@ -0,0 +1,63 @@ + */ + private array $autoTags; + + /** + * @param \Closure(string): SourceStore $sourceResolver + * @param list $tags Additional tags applied to all hooks from this handler + */ + public function __construct( + private readonly \Closure $sourceResolver, + public readonly string $name, + array $tags = [], + ) { + self::validateTags($tags); + $this->autoTags = ["handler:{$this->name}", ...$tags]; + } + + /** + * Add additional tags to all future hooks from this handler. + */ + public function globalTags(string ...$tags): self + { + $tags = array_values($tags); + self::validateTags($tags); + $this->autoTags = [...$this->autoTags, ...$tags]; + + return $this; + } + + public function hook(string $sourceId, string $point): HandlerBuilder + { + $source = ($this->sourceResolver)($sourceId); + $source->getPoint($point); + + return (new HandlerBuilder($source, $point)) + ->autoTag(...$this->autoTags); + } + + /** + * @param list $tags + */ + private static function validateTags(array $tags): void + { + foreach ($tags as $tag) { + if (str_starts_with($tag, 'handler:')) { + throw new ReservedTagException($tag); + } + } + } +} diff --git a/src/HookPoint.php b/src/HookPoint.php new file mode 100644 index 0000000..91f2d50 --- /dev/null +++ b/src/HookPoint.php @@ -0,0 +1,24 @@ + $payloadClass The expected payload/context class + */ + public function __construct( + public readonly string $name, + public readonly HookType $type, + public readonly string $payloadClass, + ) {} +} diff --git a/src/HookRegistry.php b/src/HookRegistry.php new file mode 100644 index 0000000..a20c122 --- /dev/null +++ b/src/HookRegistry.php @@ -0,0 +1,121 @@ + */ + private array $sources = []; + + /** @var array */ + private array $handlers = []; + + /** + * Get the shared singleton instance. + */ + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self; + } + + return self::$instance; + } + + /** + * Reset the singleton instance. Intended for testing only. + */ + public static function resetInstance(): void + { + self::$instance = null; + } + + public function registerSource(string $id, ?string $class = null, array $tags = []): HookSource + { + if (isset($this->sources[$id])) { + throw new DuplicateSourceException($id); + } + + $source = new SourceStore($id, $class, $tags); + $this->sources[$id] = $source; + + return new HookSource($source); + } + + public function registerHandler(string $name, array $tags = []): HookHandler + { + if (isset($this->handlers[$name])) { + throw new DuplicateHandlerException($name); + } + + $this->handlers[$name] = new HandlerInfo($name, $tags); + + $resolver = function (string $sourceId): SourceStore { + if (! isset($this->sources[$sourceId])) { + throw new SourceNotFoundException($sourceId); + } + + return $this->sources[$sourceId]; + }; + + return new HookHandler($resolver, $name, $tags); + } + + public function hasSource(string $id): bool + { + return isset($this->sources[$id]); + } + + /** + * @return array + */ + public function sources(): array + { + return $this->sources; + } + + /** + * List all registered handlers. + * + * @return array + */ + public function handlers(): array + { + return $this->handlers; + } + + /** + * Find handlers that have a specific capability tag. + * + * @return list + */ + public function handlersByTag(string $tag): array + { + return array_values(array_filter( + $this->handlers, + fn (HandlerInfo $h) => $h->hasTag($tag), + )); + } + + /** + * Find sources that have a specific capability tag. + * + * @return list + */ + public function sourcesByTag(string $tag): array + { + return array_values(array_filter( + $this->sources, + fn (SourceStore $s) => $s->hasTag($tag), + )); + } +} diff --git a/src/HookSource.php b/src/HookSource.php new file mode 100644 index 0000000..4576e8a --- /dev/null +++ b/src/HookSource.php @@ -0,0 +1,166 @@ +id = $source->id; + $this->builder = new SourceBuilder($source); + } + + // --- Point declaration --- + + /** + * Declare a filter point. + * + * @param class-string $payloadClass + */ + public function filter(string $name, string $payloadClass): self + { + $this->builder->filter($name, $payloadClass); + + return $this; + } + + /** + * Declare an action point. + * + * @param class-string $payloadClass + */ + public function action(string $name, string $payloadClass): self + { + $this->builder->action($name, $payloadClass); + + return $this; + } + + /** + * Declare a collect point. + * + * @param class-string $payloadClass + */ + public function collect(string $name, string $payloadClass): self + { + $this->builder->collect($name, $payloadClass); + + return $this; + } + + // --- Invocation --- + + /** + * Apply a filter chain on this source's hook point. + * + * @param list $tags When non-empty, only handlers with at least one matching tag are invoked + */ + public function apply(string $point, object $payload, array $tags = []): object + { + $hookPoint = $this->source->getPoint($point); + + if ($hookPoint->type !== HookType::Filter) { + throw new HookTypeMismatchException($this->id, $point, $hookPoint->type, HookType::Filter); + } + + $handlers = $this->source->getHandlers($point, $tags); + + foreach ($handlers as $handler) { + if (! $handler->shouldRun($payload)) { + continue; + } + + $payload = ($handler->handler)($payload); + + if ($handler->exclusive) { + break; + } + } + + return $payload; + } + + /** + * Dispatch an action on this source's hook point. + * + * @param list $tags When non-empty, only handlers with at least one matching tag are invoked + */ + public function dispatch(string $point, object $payload, array $tags = []): void + { + $hookPoint = $this->source->getPoint($point); + + if ($hookPoint->type !== HookType::Action) { + throw new HookTypeMismatchException($this->id, $point, $hookPoint->type, HookType::Action); + } + + $handlers = $this->source->getHandlers($point, $tags); + + foreach ($handlers as $handler) { + if (! $handler->shouldRun($payload)) { + continue; + } + + ($handler->handler)($payload); + + if ($handler->exclusive) { + break; + } + } + } + + /** + * Collect contributions from this source's hook point. + * + * @param list $tags When non-empty, only handlers with at least one matching tag are invoked + * @return list + */ + public function collectFromHandlers(string $point, ?object $context = null, array $tags = []): array + { + $hookPoint = $this->source->getPoint($point); + + if ($hookPoint->type !== HookType::Collect) { + throw new HookTypeMismatchException($this->id, $point, $hookPoint->type, HookType::Collect); + } + + $handlers = $this->source->getHandlers($point, $tags); + $results = []; + + foreach ($handlers as $handler) { + if (! $handler->shouldRun($context)) { + continue; + } + + $items = ($handler->handler)($context); + $results = [...$results, ...$items]; + + if ($handler->exclusive) { + break; + } + } + + return $results; + } + + /** + * Check whether this source has handlers registered for a given point. + */ + public function hasHandlers(string $point): bool + { + $this->source->getPoint($point); + + return $this->source->getHandlers($point) !== []; + } +} diff --git a/src/HookType.php b/src/HookType.php new file mode 100644 index 0000000..0ea1e16 --- /dev/null +++ b/src/HookType.php @@ -0,0 +1,12 @@ +app->singleton(HookRegistryInterface::class, HookRegistry::class); + $this->app->alias(HookRegistryInterface::class, HookRegistry::class); + $this->app->alias(HookRegistryInterface::class, 'latch'); + } +} diff --git a/src/Integration/Nextcloud/BridgedHandler.php b/src/Integration/Nextcloud/BridgedHandler.php new file mode 100644 index 0000000..a159035 --- /dev/null +++ b/src/Integration/Nextcloud/BridgedHandler.php @@ -0,0 +1,74 @@ + */ + private array $extraTags = []; + + public function __construct( + private readonly HookHandler $handler, + private readonly IEventDispatcher $dispatcher, + private readonly HookRegistryInterface $registry, + ) { + $this->name = $handler->name; + } + + /** + * Add additional tags to all future hooks from this handler. + * + * @return $this + */ + public function globalTags(string ...$tags): self + { + $tagValues = array_values($tags); + + foreach ($tagValues as $tag) { + if (str_starts_with($tag, 'handler:')) { + throw new ReservedTagException($tag); + } + } + + $this->handler->globalTags(...$tags); + $this->extraTags = [...$this->extraTags, ...$tagValues]; + + return $this; + } + + /** + * Hook into a source's extension point. + * + * If the source exists locally, returns a normal HandlerBuilder. + * If the source is remote (in another app), returns a BridgedHandlerBuilder + * that registers via NC events. + */ + public function hook(string $sourceId, string $point): HandlerBuilder|BridgedHandlerBuilder + { + if ($this->registry->hasSource($sourceId)) { + return $this->handler->hook($sourceId, $point); + } + + $allTags = ["handler:{$this->name}", ...$this->extraTags]; + + return (new BridgedHandlerBuilder($this->dispatcher, $sourceId, $point)) + ->autoTag(...$allTags); + } +} diff --git a/src/Integration/Nextcloud/BridgedHandlerBuilder.php b/src/Integration/Nextcloud/BridgedHandlerBuilder.php new file mode 100644 index 0000000..63d53e5 --- /dev/null +++ b/src/Integration/Nextcloud/BridgedHandlerBuilder.php @@ -0,0 +1,143 @@ + */ + private array $tags = []; + + public function __construct( + private readonly IEventDispatcher $dispatcher, + private readonly string $sourceId, + private readonly string $point, + ) {} + + public function priority(int $priority): self + { + $this->priority = $priority; + + return $this; + } + + public function exclusive(): self + { + $this->exclusive = true; + + return $this; + } + + /** + * @param callable(object): bool $condition + */ + public function when(callable $condition): self + { + $this->condition = $condition(...); + + return $this; + } + + public function tag(string ...$tags): self + { + foreach ($tags as $tag) { + if (str_starts_with($tag, 'handler:')) { + throw new ReservedTagException($tag); + } + } + + $this->tags = [...$this->tags, ...array_values($tags)]; + + return $this; + } + + /** + * @internal Used by BridgedHandler to set auto-tags including handler: prefix. + */ + public function autoTag(string ...$tags): self + { + $this->tags = [...$this->tags, ...array_values($tags)]; + + return $this; + } + + /** + * Register the handler via NC event listener. This finalizes the builder. + */ + public function handle(callable $handler): void + { + $callback = $handler(...); + $condition = $this->condition; + $handlerTags = $this->tags; + $exclusive = $this->exclusive; + + $this->dispatcher->addListener( + LatchEvent::eventName($this->sourceId, $this->point), + function (Event $event) use ($callback, $condition, $handlerTags, $exclusive): void { + if (! method_exists($event, 'getHookType')) { + return; + } + + // Tag filtering: skip if the source requested specific tags and we don't match + if (method_exists($event, 'getTags')) { + $requestedTags = $event->getTags(); + if ($requestedTags !== [] && array_intersect($handlerTags, $requestedTags) === []) { + return; + } + } + + $payload = $event->getPayload(); + + // Condition check + if ($condition !== null && ! $condition($payload)) { + return; + } + + $hookType = $event->getHookType(); + + switch ($hookType) { + case 'filter': + $result = $callback($payload); + if ($result !== null) { + $event->setPayload($result); + } + break; + + case 'action': + $callback($payload); + break; + + case 'collect': + $items = $callback($payload); + if (is_array($items)) { + $event->addCollected($items); + } + break; + } + + // Exclusive: stop other NC listeners from running + if ($exclusive && method_exists($event, 'stopPropagation')) { + $event->stopPropagation(); + } + }, + $this->priority, + ); + } +} diff --git a/src/Integration/Nextcloud/BridgedRegistry.php b/src/Integration/Nextcloud/BridgedRegistry.php new file mode 100644 index 0000000..91e6799 --- /dev/null +++ b/src/Integration/Nextcloud/BridgedRegistry.php @@ -0,0 +1,66 @@ +registry = HookRegistry::getInstance(); + } + + /** + * Register a named source. Returns a BridgedSource that dispatches through + * both local handlers and NC events. + * + * @param class-string|null $class + * @param list $tags Capability tags for discovery + */ + public function registerSource(string $id, ?string $class = null, array $tags = []): BridgedSource + { + $source = $this->registry->registerSource($id, $class, $tags); + + return new BridgedSource($source, $this->dispatcher); + } + + /** + * Register a named handler. Returns a BridgedHandler that can hook into + * both local sources and remote sources (in other apps) via NC events. + * + * @param list $tags + */ + public function registerHandler(string $name, array $tags = []): BridgedHandler + { + $handler = $this->registry->registerHandler($name, $tags); + + return new BridgedHandler($handler, $this->dispatcher, $this->registry); + } + + public function hasSource(string $id): bool + { + return $this->registry->hasSource($id); + } + + /** + * @return array + */ + public function sources(): array + { + return $this->registry->sources(); + } +} diff --git a/src/Integration/Nextcloud/BridgedSource.php b/src/Integration/Nextcloud/BridgedSource.php new file mode 100644 index 0000000..266a3ea --- /dev/null +++ b/src/Integration/Nextcloud/BridgedSource.php @@ -0,0 +1,123 @@ +id = $source->id; + } + + // --- Point declaration (delegates to inner source) --- + + /** + * @param class-string $payloadClass + * @return $this + */ + public function filter(string $name, string $payloadClass): self + { + $this->source->filter($name, $payloadClass); + + return $this; + } + + /** + * @param class-string $payloadClass + * @return $this + */ + public function action(string $name, string $payloadClass): self + { + $this->source->action($name, $payloadClass); + + return $this; + } + + /** + * @param class-string $payloadClass + * @return $this + */ + public function collect(string $name, string $payloadClass): self + { + $this->source->collect($name, $payloadClass); + + return $this; + } + + // --- Invocation (local + NC events) --- + + /** + * Apply a filter chain - runs local handlers first, then NC event listeners. + * + * @param list $tags + */ + public function apply(string $point, object $payload, array $tags = []): object + { + $payload = $this->source->apply($point, $payload, $tags); + + $event = new LatchEvent($this->id, $point, HookType::Filter->value, $payload, $tags); + $this->dispatcher->dispatch(LatchEvent::eventName($this->id, $point), $event); + + return $event->getPayload(); + } + + /** + * Dispatch an action - runs local handlers and NC event listeners. + * + * @param list $tags + */ + public function dispatch(string $point, object $payload, array $tags = []): void + { + $this->source->dispatch($point, $payload, $tags); + + $event = new LatchEvent($this->id, $point, HookType::Action->value, $payload, $tags); + $this->dispatcher->dispatch(LatchEvent::eventName($this->id, $point), $event); + } + + /** + * Collect from local handlers and NC event listeners. + * + * @param list $tags + * @return list + */ + public function collectFromHandlers(string $point, ?object $context = null, array $tags = []): array + { + $results = $this->source->collectFromHandlers($point, $context, $tags); + + $event = new LatchEvent( + $this->id, + $point, + HookType::Collect->value, + $context ?? new \stdClass, + $tags, + ); + $this->dispatcher->dispatch(LatchEvent::eventName($this->id, $point), $event); + + return [...$results, ...$event->getCollected()]; + } + + /** + * Check local handlers. Note: cannot check remote handlers via NC events. + */ + public function hasHandlers(string $point): bool + { + return $this->source->hasHandlers($point); + } +} diff --git a/src/Integration/Nextcloud/LatchBootstrap.php b/src/Integration/Nextcloud/LatchBootstrap.php new file mode 100644 index 0000000..0043bfe --- /dev/null +++ b/src/Integration/Nextcloud/LatchBootstrap.php @@ -0,0 +1,64 @@ +cms = $registry->registerSource('cms') + * ->action('page-published', PageEvent::class) + * ->collect('head-tags', PageContext::class); + * } + * + * Handler app: + * + * public function boot(IBootContext $context): void + * { + * $registry = LatchBootstrap::registry(); + * $handler = $registry->registerHandler('seo'); + * + * $handler->hook('cms', 'page-published') + * ->handle(fn ($payload) => SitemapQueue::push($payload->page->url)); + * } + */ +final class LatchBootstrap +{ + private static ?BridgedRegistry $registry = null; + + /** + * Get the bridged registry for this Nextcloud instance. + */ + public static function registry(): BridgedRegistry + { + if (self::$registry === null) { + $dispatcher = Server::get(IEventDispatcher::class); + self::$registry = new BridgedRegistry($dispatcher); + } + + return self::$registry; + } + + /** + * Reset all state. Intended for testing only. + */ + public static function reset(): void + { + self::$registry = null; + HookRegistry::resetInstance(); + } +} diff --git a/src/Integration/Nextcloud/LatchEvent.php b/src/Integration/Nextcloud/LatchEvent.php new file mode 100644 index 0000000..873f398 --- /dev/null +++ b/src/Integration/Nextcloud/LatchEvent.php @@ -0,0 +1,101 @@ + */ + private array $collected = []; + + /** @var list */ + private array $tags; + + /** + * @param list $tags + */ + public function __construct( + private readonly string $sourceId, + private readonly string $point, + private readonly string $hookType, + object $payload, + array $tags = [], + ) { + parent::__construct(); + $this->payload = $payload; + $this->tags = $tags; + } + + public function getSourceId(): string + { + return $this->sourceId; + } + + public function getPoint(): string + { + return $this->point; + } + + public function getHookType(): string + { + return $this->hookType; + } + + public function getPayload(): object + { + return $this->payload; + } + + /** + * Replace the payload (used by filter handlers to chain transformations). + */ + public function setPayload(object $payload): void + { + $this->payload = $payload; + } + + /** + * Add items to the collected results (used by collect handlers). + * + * @param list $items + */ + public function addCollected(array $items): void + { + $this->collected = [...$this->collected, ...$items]; + } + + /** + * @return list + */ + public function getCollected(): array + { + return $this->collected; + } + + /** + * @return list + */ + public function getTags(): array + { + return $this->tags; + } + + /** + * Build the conventional event name for NC's event dispatcher. + */ + public static function eventName(string $sourceId, string $point): string + { + return "latch:{$sourceId}:{$point}"; + } +} diff --git a/src/SourceStore.php b/src/SourceStore.php new file mode 100644 index 0000000..ad9286a --- /dev/null +++ b/src/SourceStore.php @@ -0,0 +1,98 @@ +> */ + private array $points = []; + + /** @var array> */ + private array $handlers = []; + + /** + * @param list $tags Capability tags for discovery + */ + public function __construct( + public readonly string $id, + public readonly ?string $class = null, + public readonly array $tags = [], + ) {} + + /** + * Check whether this source has a specific capability tag. + */ + public function hasTag(string $tag): bool + { + return in_array($tag, $this->tags, true); + } + + /** + * @param HookPoint $point + */ + public function addPoint(HookPoint $point): void + { + $this->points[$point->name] = $point; + } + + public function hasPoint(string $name): bool + { + return isset($this->points[$name]); + } + + /** + * @return HookPoint + */ + public function getPoint(string $name): HookPoint + { + if (! isset($this->points[$name])) { + throw new Exceptions\HookPointNotFoundException($this->id, $name); + } + + return $this->points[$name]; + } + + /** + * @return array> + */ + public function points(): array + { + return $this->points; + } + + public function addHandler(string $point, HandlerEntry $handler): void + { + if (! $this->hasPoint($point)) { + throw new Exceptions\HookPointNotFoundException($this->id, $point); + } + + $this->handlers[$point][] = $handler; + } + + /** + * Get handlers for a point, sorted by priority (lower first). + * + * @param list $tags When non-empty, only handlers with at least one matching tag are returned + * @return list + */ + public function getHandlers(string $point, array $tags = []): array + { + $handlers = $this->handlers[$point] ?? []; + + if ($tags !== []) { + $handlers = array_values(array_filter( + $handlers, + fn (HandlerEntry $h) => array_intersect($h->tags, $tags) !== [], + )); + } + + usort($handlers, fn (HandlerEntry $a, HandlerEntry $b) => $a->priority <=> $b->priority); + + return $handlers; + } +} diff --git a/tests/HookRegistryTest.php b/tests/HookRegistryTest.php new file mode 100644 index 0000000..c1b3347 --- /dev/null +++ b/tests/HookRegistryTest.php @@ -0,0 +1,719 @@ +registry = new HookRegistry; +}); + +// --- Source Registration --- + +it('registers a source', function () { + $source = $this->registry->registerSource('my-app'); + + expect($source->id)->toBe('my-app'); + expect($this->registry->sources())->toHaveKey('my-app'); +}); + +it('registers a source with a class', function () { + $this->registry->registerSource('my-app', 'App\\MyApp'); + + expect($this->registry->sources()['my-app']->class)->toBe('App\\MyApp'); +}); + +it('throws when registering a source with the same name twice', function () { + $this->registry->registerSource('my-app'); + $this->registry->registerSource('my-app'); +})->throws(DuplicateSourceException::class); + +it('declares filter, action, and collect points on a source', function () { + $this->registry->registerSource('my-app') + ->filter('before-render', stdClass::class) + ->action('user-created', stdClass::class) + ->collect('menu-items', stdClass::class); + + $source = $this->registry->sources()['my-app']; + + expect($source->points())->toHaveCount(3); + expect($source->getPoint('before-render')->type)->toBe(HookType::Filter); + expect($source->getPoint('user-created')->type)->toBe(HookType::Action); + expect($source->getPoint('menu-items')->type)->toBe(HookType::Collect); +}); + +// --- Handler Registration --- + +it('registers a handler on a hook point', function () { + $this->registry->registerSource('my-app')->filter('transform', stdClass::class); + + $handler = $this->registry->registerHandler('plugin'); + $handler->hook('my-app', 'transform') + ->handle(fn (object $p) => $p); + + $source = $this->registry->sources()['my-app']; + expect($source->getHandlers('transform'))->toHaveCount(1); +}); + +it('throws when hooking into a non-existent source', function () { + $handler = $this->registry->registerHandler('plugin'); + $handler->hook('missing', 'anything'); +})->throws(SourceNotFoundException::class); + +it('throws when hooking into a non-existent point', function () { + $this->registry->registerSource('my-app'); + + $handler = $this->registry->registerHandler('plugin'); + $handler->hook('my-app', 'missing'); +})->throws(HookPointNotFoundException::class); + +it('throws when registering a handler with the same name twice', function () { + $this->registry->registerHandler('seo'); + $this->registry->registerHandler('seo'); +})->throws(DuplicateHandlerException::class); + +// --- Filter Chain --- + +it('applies a filter chain in priority order', function () { + $source = $this->registry->registerSource('app') + ->filter('transform', stdClass::class); + + $handler = $this->registry->registerHandler('plugin'); + + $handler->hook('app', 'transform') + ->priority(20) + ->handle(fn (stdClass $p) => (object) ['value' => $p->value.'-second']); + + $handler->hook('app', 'transform') + ->priority(5) + ->handle(fn (stdClass $p) => (object) ['value' => $p->value.'-first']); + + $result = $source->apply('transform', (object) ['value' => 'start']); + + expect($result->value)->toBe('start-first-second'); +}); + +it('throws type mismatch when applying filter on action point', function () { + $source = $this->registry->registerSource('app') + ->action('event', stdClass::class); + + $source->apply('event', new stdClass); +})->throws(HookTypeMismatchException::class); + +// --- Action Dispatch --- + +it('dispatches an action to all handlers', function () { + $source = $this->registry->registerSource('app') + ->action('event', stdClass::class); + + $calls = []; + + $a = $this->registry->registerHandler('plugin-a'); + $a->hook('app', 'event') + ->handle(function (stdClass $p) use (&$calls) { + $calls[] = 'a'; + }); + + $b = $this->registry->registerHandler('plugin-b'); + $b->hook('app', 'event') + ->handle(function (stdClass $p) use (&$calls) { + $calls[] = 'b'; + }); + + $source->dispatch('event', new stdClass); + + expect($calls)->toBe(['a', 'b']); +}); + +it('throws type mismatch when dispatching action on filter point', function () { + $source = $this->registry->registerSource('app') + ->filter('transform', stdClass::class); + + $source->dispatch('transform', new stdClass); +})->throws(HookTypeMismatchException::class); + +// --- Collect --- + +it('collects contributions from all handlers', function () { + $source = $this->registry->registerSource('app') + ->collect('items', stdClass::class); + + $a = $this->registry->registerHandler('plugin-a'); + $a->hook('app', 'items') + ->handle(fn () => ['a', 'b']); + + $b = $this->registry->registerHandler('plugin-b'); + $b->hook('app', 'items') + ->handle(fn () => ['c']); + + $result = $source->collectFromHandlers('items'); + + expect($result)->toBe(['a', 'b', 'c']); +}); + +it('passes context to collect handlers', function () { + $source = $this->registry->registerSource('app') + ->collect('items', stdClass::class); + + $handler = $this->registry->registerHandler('plugin'); + $handler->hook('app', 'items') + ->handle(fn (?stdClass $ctx) => [$ctx?->prefix.'-item']); + + $result = $source->collectFromHandlers('items', (object) ['prefix' => 'admin']); + + expect($result)->toBe(['admin-item']); +}); + +it('throws type mismatch when collecting on filter point', function () { + $source = $this->registry->registerSource('app') + ->filter('transform', stdClass::class); + + $source->collectFromHandlers('transform'); +})->throws(HookTypeMismatchException::class); + +// --- Priority Ordering --- + +it('runs handlers in priority order', function () { + $source = $this->registry->registerSource('app') + ->action('event', stdClass::class); + + $order = []; + + $handler = $this->registry->registerHandler('plugin'); + + $handler->hook('app', 'event') + ->priority(30) + ->handle(function () use (&$order) { + $order[] = 'c'; + }); + + $handler->hook('app', 'event') + ->priority(10) + ->handle(function () use (&$order) { + $order[] = 'a'; + }); + + $handler->hook('app', 'event') + ->priority(20) + ->handle(function () use (&$order) { + $order[] = 'b'; + }); + + $source->dispatch('event', new stdClass); + + expect($order)->toBe(['a', 'b', 'c']); +}); + +// --- Exclusive Short-Circuit --- + +it('stops after an exclusive handler', function () { + $source = $this->registry->registerSource('app') + ->action('event', stdClass::class); + + $calls = []; + + $handler = $this->registry->registerHandler('plugin'); + + $handler->hook('app', 'event') + ->priority(1) + ->exclusive() + ->handle(function () use (&$calls) { + $calls[] = 'first'; + }); + + $handler->hook('app', 'event') + ->priority(20) + ->handle(function () use (&$calls) { + $calls[] = 'second'; + }); + + $source->dispatch('event', new stdClass); + + expect($calls)->toBe(['first']); +}); + +it('stops after an exclusive filter handler', function () { + $source = $this->registry->registerSource('app') + ->filter('transform', stdClass::class); + + $handler = $this->registry->registerHandler('plugin'); + + $handler->hook('app', 'transform') + ->priority(1) + ->exclusive() + ->handle(fn (stdClass $p) => (object) ['value' => 'exclusive']); + + $handler->hook('app', 'transform') + ->priority(20) + ->handle(fn (stdClass $p) => (object) ['value' => 'should-not-run']); + + $result = $source->apply('transform', (object) ['value' => 'start']); + + expect($result->value)->toBe('exclusive'); +}); + +it('stops after an exclusive collect handler', function () { + $source = $this->registry->registerSource('app') + ->collect('items', stdClass::class); + + $handler = $this->registry->registerHandler('plugin'); + + $handler->hook('app', 'items') + ->priority(1) + ->exclusive() + ->handle(fn () => ['only-this']); + + $handler->hook('app', 'items') + ->priority(20) + ->handle(fn () => ['not-this']); + + $result = $source->collectFromHandlers('items'); + + expect($result)->toBe(['only-this']); +}); + +// --- Conditional Skipping --- + +it('skips handler when condition returns false', function () { + $source = $this->registry->registerSource('app') + ->action('event', stdClass::class); + + $calls = []; + + $a = $this->registry->registerHandler('plugin-a'); + $a->hook('app', 'event') + ->when(fn () => false) + ->handle(function () use (&$calls) { + $calls[] = 'skipped'; + }); + + $b = $this->registry->registerHandler('plugin-b'); + $b->hook('app', 'event') + ->when(fn () => true) + ->handle(function () use (&$calls) { + $calls[] = 'ran'; + }); + + $source->dispatch('event', new stdClass); + + expect($calls)->toBe(['ran']); +}); + +it('skips conditional filter handler without breaking chain', function () { + $source = $this->registry->registerSource('app') + ->filter('transform', stdClass::class); + + $a = $this->registry->registerHandler('plugin-a'); + $a->hook('app', 'transform') + ->priority(1) + ->when(fn () => false) + ->handle(fn (stdClass $p) => (object) ['value' => 'skipped']); + + $b = $this->registry->registerHandler('plugin-b'); + $b->hook('app', 'transform') + ->priority(2) + ->handle(fn (stdClass $p) => (object) ['value' => $p->value.'-applied']); + + $result = $source->apply('transform', (object) ['value' => 'start']); + + expect($result->value)->toBe('start-applied'); +}); + +// --- Tags --- + +it('stores tags on handlers', function () { + $this->registry->registerSource('app')->action('event', stdClass::class); + + $handler = $this->registry->registerHandler('plugin'); + $hookHandler = $handler->hook('app', 'event') + ->tag('admin', 'ui') + ->handle(fn () => null); + + expect($hookHandler->tags)->toBe(['handler:plugin', 'admin', 'ui']); +}); + +// --- Tag Filtering --- + +it('filters action handlers by tag', function () { + $source = $this->registry->registerSource('app') + ->action('event', stdClass::class); + + $calls = []; + + $seo = $this->registry->registerHandler('seo-plugin'); + $seo->hook('app', 'event') + ->handle(function () use (&$calls) { + $calls[] = 'seo-plugin'; + }); + + $analytics = $this->registry->registerHandler('analytics-plugin'); + $analytics->hook('app', 'event') + ->handle(function () use (&$calls) { + $calls[] = 'analytics-plugin'; + }); + + $source->dispatch('event', new stdClass, ['handler:seo-plugin']); + + expect($calls)->toBe(['seo-plugin']); +}); + +it('filters filter handlers by tag', function () { + $source = $this->registry->registerSource('app') + ->filter('transform', stdClass::class); + + $seo = $this->registry->registerHandler('seo-plugin'); + $seo->hook('app', 'transform') + ->handle(fn (stdClass $p) => (object) ['value' => $p->value.'-seo']); + + $other = $this->registry->registerHandler('other'); + $other->hook('app', 'transform') + ->handle(fn (stdClass $p) => (object) ['value' => $p->value.'-other']); + + $result = $source->apply('transform', (object) ['value' => 'start'], ['handler:seo-plugin']); + + expect($result->value)->toBe('start-seo'); +}); + +it('filters collect handlers by tag', function () { + $source = $this->registry->registerSource('app') + ->collect('items', stdClass::class); + + $seo = $this->registry->registerHandler('seo-plugin'); + $seo->hook('app', 'items') + ->handle(fn () => ['meta-tags', 'sitemap']); + + $other = $this->registry->registerHandler('other'); + $other->hook('app', 'items') + ->handle(fn () => ['nope']); + + $result = $source->collectFromHandlers('items', null, ['handler:seo-plugin']); + + expect($result)->toBe(['meta-tags', 'sitemap']); +}); + +it('runs all handlers when no tag filter is given', function () { + $source = $this->registry->registerSource('app') + ->action('event', stdClass::class); + + $calls = []; + + $seo = $this->registry->registerHandler('seo-plugin'); + $seo->hook('app', 'event') + ->handle(function () use (&$calls) { + $calls[] = 'seo-plugin'; + }); + + $other = $this->registry->registerHandler('other'); + $other->hook('app', 'event') + ->handle(function () use (&$calls) { + $calls[] = 'other'; + }); + + $source->dispatch('event', new stdClass); + + expect($calls)->toBe(['seo-plugin', 'other']); +}); + +// --- Registered Handlers --- + +it('auto-tags hooks with handler name', function () { + $this->registry->registerSource('app')->action('event', stdClass::class); + + $handler = $this->registry->registerHandler('seo'); + $hookHandler = $handler->hook('app', 'event') + ->handle(fn () => null); + + expect($hookHandler->tags)->toBe(['handler:seo']); +}); + +it('auto-tags hooks with handler name and additional tags', function () { + $this->registry->registerSource('app')->action('event', stdClass::class); + + $handler = $this->registry->registerHandler('seo', ['premium']); + $hookHandler = $handler->hook('app', 'event') + ->handle(fn () => null); + + expect($hookHandler->tags)->toBe(['handler:seo', 'premium']); +}); + +it('applies auto-tags to all hooks from a registered handler', function () { + $this->registry->registerSource('app') + ->action('event-a', stdClass::class) + ->action('event-b', stdClass::class); + + $handler = $this->registry->registerHandler('analytics', ['tracking']); + + $a = $handler->hook('app', 'event-a')->handle(fn () => null); + $b = $handler->hook('app', 'event-b')->handle(fn () => null); + + expect($a->tags)->toBe(['handler:analytics', 'tracking']); + expect($b->tags)->toBe(['handler:analytics', 'tracking']); +}); + +it('merges auto-tags with per-hook tags', function () { + $this->registry->registerSource('app')->action('event', stdClass::class); + + $handler = $this->registry->registerHandler('seo'); + $hookHandler = $handler->hook('app', 'event') + ->tag('extra') + ->handle(fn () => null); + + expect($hookHandler->tags)->toBe(['handler:seo', 'extra']); +}); + +it('adds global tags after construction', function () { + $this->registry->registerSource('app') + ->action('event-a', stdClass::class) + ->action('event-b', stdClass::class); + + $handler = $this->registry->registerHandler('seo'); + $a = $handler->hook('app', 'event-a')->handle(fn () => null); + + $handler->globalTags('premium', 'v2'); + $b = $handler->hook('app', 'event-b')->handle(fn () => null); + + expect($a->tags)->toBe(['handler:seo']); + expect($b->tags)->toBe(['handler:seo', 'premium', 'v2']); +}); + +it('filters by handler auto-tag', function () { + $source = $this->registry->registerSource('app') + ->action('event', stdClass::class); + + $calls = []; + + $seo = $this->registry->registerHandler('seo'); + $seo->hook('app', 'event') + ->handle(function () use (&$calls) { + $calls[] = 'seo'; + }); + + $analytics = $this->registry->registerHandler('analytics'); + $analytics->hook('app', 'event') + ->handle(function () use (&$calls) { + $calls[] = 'analytics'; + }); + + $source->dispatch('event', new stdClass, ['handler:seo']); + + expect($calls)->toBe(['seo']); +}); + +// --- Reserved Tag Protection --- + +it('throws when manually using handler: tag prefix', function () { + $this->registry->registerSource('app')->action('event', stdClass::class); + + $handler = $this->registry->registerHandler('plugin'); + $handler->hook('app', 'event') + ->tag('handler:fake') + ->handle(fn () => null); +})->throws(ReservedTagException::class); + +it('throws when passing handler: tag to registerHandler additional tags', function () { + $this->registry->registerHandler('seo', ['handler:spoof']); +})->throws(ReservedTagException::class); + +it('throws when passing handler: tag to globalTags', function () { + $handler = $this->registry->registerHandler('seo'); + $handler->globalTags('handler:spoof'); +})->throws(ReservedTagException::class); + +// --- Introspection --- + +it('lists all sources', function () { + $this->registry->registerSource('app-a'); + $this->registry->registerSource('app-b'); + + $sources = $this->registry->sources(); + + expect($sources)->toHaveCount(2); + expect(array_keys($sources))->toBe(['app-a', 'app-b']); +}); + +it('lists hook points per source', function () { + $this->registry->registerSource('app') + ->filter('f1', stdClass::class) + ->action('a1', stdClass::class); + + $points = $this->registry->sources()['app']->points(); + + expect($points)->toHaveCount(2); + expect($points['f1']->type)->toBe(HookType::Filter); + expect($points['a1']->type)->toBe(HookType::Action); +}); + +it('lists handlers per point', function () { + $this->registry->registerSource('app')->filter('transform', stdClass::class); + + $handler = $this->registry->registerHandler('plugin'); + + $handler->hook('app', 'transform') + ->priority(5) + ->handle(fn ($p) => $p); + + $handler->hook('app', 'transform') + ->priority(15) + ->handle(fn ($p) => $p); + + $handlers = $this->registry->sources()['app']->getHandlers('transform'); + + expect($handlers)->toHaveCount(2); + expect($handlers[0]->priority)->toBe(5); + expect($handlers[1]->priority)->toBe(15); +}); + +// --- Existence Checks --- + +it('reports whether a source exists', function () { + expect($this->registry->hasSource('app'))->toBeFalse(); + + $this->registry->registerSource('app'); + + expect($this->registry->hasSource('app'))->toBeTrue(); +}); + +it('reports whether a point has handlers', function () { + $source = $this->registry->registerSource('app') + ->filter('transform', stdClass::class); + + expect($source->hasHandlers('transform'))->toBeFalse(); + + $handler = $this->registry->registerHandler('plugin'); + $handler->hook('app', 'transform') + ->handle(fn ($p) => $p); + + expect($source->hasHandlers('transform'))->toBeTrue(); +}); + +it('throws HookPointNotFoundException when checking handlers on missing point', function () { + $source = $this->registry->registerSource('app'); + + $source->hasHandlers('missing'); +})->throws(HookPointNotFoundException::class); + +// --- Exception Cases --- + +it('throws HookPointNotFoundException for apply on missing point', function () { + $source = $this->registry->registerSource('app'); + + $source->apply('missing', new stdClass); +})->throws(HookPointNotFoundException::class); + +it('returns payload unchanged when no handlers registered', function () { + $source = $this->registry->registerSource('app') + ->filter('transform', stdClass::class); + + $payload = (object) ['value' => 'unchanged']; + $result = $source->apply('transform', $payload); + + expect($result->value)->toBe('unchanged'); +}); + +it('returns empty array when no collect handlers registered', function () { + $source = $this->registry->registerSource('app') + ->collect('items', stdClass::class); + + $result = $source->collectFromHandlers('items'); + + expect($result)->toBe([]); +}); + +// --- Capability Discovery --- + +it('lists all registered handlers', function () { + $this->registry->registerHandler('seo'); + $this->registry->registerHandler('analytics', ['tracking']); + + $handlers = $this->registry->handlers(); + + expect($handlers)->toHaveCount(2); + expect($handlers)->toHaveKeys(['seo', 'analytics']); + expect($handlers['seo']->name)->toBe('seo'); + expect($handlers['analytics']->tags)->toBe(['tracking']); +}); + +it('finds handlers by capability tag', function () { + $this->registry->registerHandler('seo', ['content-enhancement']); + $this->registry->registerHandler('analytics', ['tracking']); + $this->registry->registerHandler('minifier', ['content-enhancement']); + + $results = $this->registry->handlersByTag('content-enhancement'); + + expect($results)->toHaveCount(2); + expect($results[0]->name)->toBe('seo'); + expect($results[1]->name)->toBe('minifier'); +}); + +it('returns empty array when no handlers match tag', function () { + $this->registry->registerHandler('seo', ['content-enhancement']); + + $results = $this->registry->handlersByTag('nonexistent'); + + expect($results)->toBe([]); +}); + +it('exposes hasTag on handler info', function () { + $this->registry->registerHandler('seo', ['content-enhancement', 'html-processing']); + + $info = $this->registry->handlers()['seo']; + + expect($info->hasTag('content-enhancement'))->toBeTrue(); + expect($info->hasTag('html-processing'))->toBeTrue(); + expect($info->hasTag('tracking'))->toBeFalse(); +}); + +// --- Source Capability Discovery --- + +it('registers a source with capability tags', function () { + $this->registry->registerSource('cms', null, ['content-management', 'publishing']); + + $source = $this->registry->sources()['cms']; + + expect($source->tags)->toBe(['content-management', 'publishing']); +}); + +it('finds sources by capability tag', function () { + $this->registry->registerSource('cms', null, ['content-management']); + $this->registry->registerSource('blog', null, ['content-management']); + $this->registry->registerSource('analytics', null, ['tracking']); + + $results = $this->registry->sourcesByTag('content-management'); + + expect($results)->toHaveCount(2); + expect($results[0]->id)->toBe('cms'); + expect($results[1]->id)->toBe('blog'); +}); + +it('returns empty array when no sources match tag', function () { + $this->registry->registerSource('cms', null, ['content-management']); + + $results = $this->registry->sourcesByTag('nonexistent'); + + expect($results)->toBe([]); +}); + +it('exposes hasTag on source store', function () { + $this->registry->registerSource('cms', null, ['content-management', 'publishing']); + + $source = $this->registry->sources()['cms']; + + expect($source->hasTag('content-management'))->toBeTrue(); + expect($source->hasTag('publishing'))->toBeTrue(); + expect($source->hasTag('tracking'))->toBeFalse(); +}); + +it('defaults to empty tags on source', function () { + $this->registry->registerSource('cms'); + + $source = $this->registry->sources()['cms']; + + expect($source->tags)->toBe([]); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..174d7fd --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,3 @@ +