feat: initial version

Release-As: 0.1.0
This commit is contained in:
2026-04-26 23:18:05 +03:00
commit b153959e1b
39 changed files with 3333 additions and 0 deletions

34
.github/workflows/ci.yml vendored Normal file
View File

@@ -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

18
.github/workflows/release-please.yml vendored Normal file
View File

@@ -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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/vendor/
composer.phar
composer.lock
/.phpunit.cache/
/.php-cs-fixer.cache
/tmp

4
.phpactor.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "/phpactor.schema.json",
"language_server_phpstan.enabled": true
}

21
LICENSE Normal file
View File

@@ -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.

23
Makefile Normal file
View File

@@ -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

135
README.md Normal file
View File

@@ -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

43
composer.json Normal file
View File

@@ -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
}

358
docs/examples.md Normal file
View File

@@ -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 <<<HTML
<!DOCTYPE html>
<html>
<head>
<title>{$title->value}</title>
{$this->renderTags($headTags)}
</head>
<body>{$payload->html}</body>
</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) => [
"<meta name=\"description\" content=\"{$ctx->page->excerpt}\">",
"<meta property=\"og:title\" content=\"{$ctx->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 '<script type="application/ld+json">{"@type":"WebPage"}</script>';
}
}
```
### 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) => [
"<meta name=\"description\" content=\"{$ctx->page->excerpt}\">",
]);
// Analytics plugin registers with its own named handler
$analytics = $registry->registerHandler('analytics');
$analytics->hook('cms', 'head-tags')
->handle(fn (PageContext $ctx) => [
'<script src="/analytics.js"></script>',
]);
// 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 . '<meta name="generator" content="seo-plugin">'
));
}
}
```
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

495
docs/guide.md Normal file
View File

@@ -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 "<div class=\"widget\"><h3>{$widget->title}</h3>{$widget->html}</div>";
}
```
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, '<p><a><strong>')));
// 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<string, SourceStore>
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<string, HookPoint>
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<HandlerEntry>
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."
}
```

10
lefthook.yml Normal file
View File

@@ -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

6
phpstan.neon Normal file
View File

@@ -0,0 +1,6 @@
parameters:
level: 8
paths:
- src
excludePaths:
- src/Integration/

12
phpunit.xml Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Tests">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Latch\Builders;
use Latch\Exceptions\ReservedTagException;
use Latch\HandlerEntry;
use Latch\SourceStore;
/**
* Fluent builder for configuring and registering a handler.
*/
final class HandlerBuilder
{
private int $priority = 10;
private bool $exclusive = false;
private ?\Closure $condition = null;
/** @var list<string> */
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;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Latch\Builders;
use Latch\HookPoint;
use Latch\HookType;
use Latch\SourceStore;
/**
* Fluent builder for declaring hook points on a source.
*/
final class SourceBuilder
{
public function __construct(
private readonly SourceStore $source,
) {}
/**
* Declare a filter point.
*
* @param class-string $payloadClass
*/
public function filter(string $name, string $payloadClass): self
{
$this->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;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Latch\Contracts;
use Latch\HandlerInfo;
use Latch\HookHandler;
use Latch\HookSource;
use Latch\SourceStore;
interface HookRegistryInterface
{
/**
* Register a named source. Each name can only be registered once.
*
* @param class-string|null $class Optional class associated with this source
* @param list<string> $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<string> $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<string, SourceStore>
*/
public function sources(): array;
/**
* List all registered handlers.
*
* @return array<string, HandlerInfo>
*/
public function handlers(): array;
/**
* Find handlers that have a specific capability tag.
*
* @return list<HandlerInfo>
*/
public function handlersByTag(string $tag): array;
/**
* Find sources that have a specific capability tag.
*
* @return list<SourceStore>
*/
public function sourcesByTag(string $tag): array;
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Latch\Exceptions;
final class DuplicateHandlerException extends \RuntimeException
{
public function __construct(string $name)
{
parent::__construct(
"Handler '{$name}' is already registered. Reuse the existing HookHandler instance instead."
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Latch\Exceptions;
final class DuplicateSourceException extends \RuntimeException
{
public function __construct(string $id)
{
parent::__construct(
"Source '{$id}' is already registered. Reuse the existing HookSource instance instead."
);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Latch\Exceptions;
final class HookPointNotFoundException extends \RuntimeException
{
public function __construct(string $sourceId, string $point)
{
parent::__construct("Hook point '{$point}' not found in source '{$sourceId}'.");
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Latch\Exceptions;
use Latch\HookType;
final class HookTypeMismatchException extends \RuntimeException
{
public function __construct(string $sourceId, string $point, HookType $expected, HookType $actual)
{
parent::__construct(
"Hook point '{$point}' in source '{$sourceId}' is of type '{$expected->value}', but was invoked as '{$actual->value}'."
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Latch\Exceptions;
final class ReservedTagException extends \RuntimeException
{
public function __construct(string $tag)
{
parent::__construct(
"Tag '{$tag}' uses the reserved 'handler:' prefix. Use registerHandler() to set handler identity."
);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Latch\Exceptions;
final class SourceNotFoundException extends \RuntimeException
{
public function __construct(string $sourceId)
{
parent::__construct("Hook source '{$sourceId}' not found.");
}
}

38
src/HandlerEntry.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Latch;
/**
* A registered handler for a hook point.
*/
final class HandlerEntry
{
/**
* @param \Closure $handler The handler callable
* @param int $priority Lower runs first
* @param bool $exclusive If true, short-circuits remaining handlers after this one
* @param \Closure|null $condition When set, handler is skipped if condition returns false
* @param list<string> $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);
}
}

28
src/HandlerInfo.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Latch;
/**
* Metadata about a registered handler, used for capability discovery.
*/
final class HandlerInfo
{
/**
* @param string $name The unique handler name
* @param list<string> $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);
}
}

63
src/HookHandler.php Normal file
View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Latch;
use Latch\Builders\HandlerBuilder;
use Latch\Exceptions\ReservedTagException;
/**
* A named handler scope that auto-tags all hooks with handler:{name} and any additional tags.
*/
final class HookHandler
{
/** @var list<string> */
private array $autoTags;
/**
* @param \Closure(string): SourceStore $sourceResolver
* @param list<string> $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<string> $tags
*/
private static function validateTags(array $tags): void
{
foreach ($tags as $tag) {
if (str_starts_with($tag, 'handler:')) {
throw new ReservedTagException($tag);
}
}
}
}

24
src/HookPoint.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Latch;
/**
* Represents a declared extension point within a hook source.
*
* @template T of object
*/
final class HookPoint
{
/**
* @param string $name The point name
* @param HookType $type The hook type (filter, action, collect)
* @param class-string<T> $payloadClass The expected payload/context class
*/
public function __construct(
public readonly string $name,
public readonly HookType $type,
public readonly string $payloadClass,
) {}
}

121
src/HookRegistry.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Latch;
use Latch\Contracts\HookRegistryInterface;
use Latch\Exceptions\DuplicateHandlerException;
use Latch\Exceptions\DuplicateSourceException;
use Latch\Exceptions\SourceNotFoundException;
final class HookRegistry implements HookRegistryInterface
{
private static ?self $instance = null;
/** @var array<string, SourceStore> */
private array $sources = [];
/** @var array<string, HandlerInfo> */
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<string, SourceStore>
*/
public function sources(): array
{
return $this->sources;
}
/**
* List all registered handlers.
*
* @return array<string, HandlerInfo>
*/
public function handlers(): array
{
return $this->handlers;
}
/**
* Find handlers that have a specific capability tag.
*
* @return list<HandlerInfo>
*/
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<SourceStore>
*/
public function sourcesByTag(string $tag): array
{
return array_values(array_filter(
$this->sources,
fn (SourceStore $s) => $s->hasTag($tag),
));
}
}

166
src/HookSource.php Normal file
View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Latch;
use Latch\Builders\SourceBuilder;
use Latch\Exceptions\HookTypeMismatchException;
/**
* A registered source that owns extension points and invokes hooks.
*/
final class HookSource
{
private readonly SourceBuilder $builder;
public readonly string $id;
public function __construct(
private readonly SourceStore $source,
) {
$this->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<string> $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<string> $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<string> $tags When non-empty, only handlers with at least one matching tag are invoked
* @return list<mixed>
*/
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) !== [];
}
}

12
src/HookType.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Latch;
enum HookType: string
{
case Filter = 'filter';
case Action = 'action';
case Collect = 'collect';
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Latch\Integration\Laravel;
use Illuminate\Support\ServiceProvider;
use Latch\Contracts\HookRegistryInterface;
use Latch\HookRegistry;
class LatchServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(HookRegistryInterface::class, HookRegistry::class);
$this->app->alias(HookRegistryInterface::class, HookRegistry::class);
$this->app->alias(HookRegistryInterface::class, 'latch');
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Latch\Integration\Nextcloud;
use Latch\Builders\HandlerBuilder;
use Latch\Contracts\HookRegistryInterface;
use Latch\Exceptions\ReservedTagException;
use Latch\HookHandler;
use OCP\EventDispatcher\IEventDispatcher;
/**
* Wraps a HookHandler to support hooking into both local and remote sources.
*
* When the target source exists in the local registry, delegates to the inner
* HookHandler (normal behavior). When the source is in another app, returns a
* BridgedHandlerBuilder that registers an NC event listener instead.
*/
final class BridgedHandler
{
public readonly string $name;
/** @var list<string> */
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);
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Latch\Integration\Nextcloud;
use Latch\Exceptions\ReservedTagException;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventDispatcher;
/**
* Mirrors HandlerBuilder for remote sources.
*
* Instead of registering a HandlerEntry on a local SourceStore, this builder
* registers an NC event listener that handles hooks dispatched from another app.
*/
final class BridgedHandlerBuilder
{
private int $priority = 10;
private bool $exclusive = false;
private ?\Closure $condition = null;
/** @var list<string> */
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,
);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Latch\Integration\Nextcloud;
use Latch\HookRegistry;
use Latch\SourceStore;
use OCP\EventDispatcher\IEventDispatcher;
/**
* Drop-in replacement for HookRegistry that bridges all hooks through NC's event dispatcher.
*
* Returns BridgedSource and BridgedHandler instances that mirror the core API
* but also dispatch/listen through NC events for cross-app communication.
*/
final class BridgedRegistry
{
private readonly HookRegistry $registry;
public function __construct(
private readonly IEventDispatcher $dispatcher,
) {
$this->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<string> $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<string> $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<string, SourceStore>
*/
public function sources(): array
{
return $this->registry->sources();
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Latch\Integration\Nextcloud;
use Latch\HookSource;
use Latch\HookType;
use OCP\EventDispatcher\IEventDispatcher;
/**
* Wraps a HookSource to also dispatch through Nextcloud's event dispatcher.
*
* Mirrors the full HookSource API (declaration + invocation). Local handlers
* are invoked directly through the underlying HookSource. Remote handlers
* (in other apps) are reached through NC events.
*/
final class BridgedSource
{
public readonly string $id;
public function __construct(
private readonly HookSource $source,
private readonly IEventDispatcher $dispatcher,
) {
$this->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<string> $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<string> $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<string> $tags
* @return list<mixed>
*/
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);
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Latch\Integration\Nextcloud;
use Latch\HookRegistry;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Server;
/**
* Bootstrap helper for Nextcloud apps.
*
* Returns a BridgedRegistry that mirrors the core HookRegistry API but
* bridges all hooks through NC's event dispatcher for cross-app communication.
*
* Source app:
*
* public function boot(IBootContext $context): void
* {
* $registry = LatchBootstrap::registry();
*
* $this->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();
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Latch\Integration\Nextcloud;
use OCP\EventDispatcher\Event;
/**
* Event used to bridge Latch hooks through Nextcloud's event dispatcher.
*
* Works across app autoloader boundaries because PHP resolves methods
* on the actual object at runtime, not the declared type hint.
*/
class LatchEvent extends Event
{
private object $payload;
/** @var list<mixed> */
private array $collected = [];
/** @var list<string> */
private array $tags;
/**
* @param list<string> $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<mixed> $items
*/
public function addCollected(array $items): void
{
$this->collected = [...$this->collected, ...$items];
}
/**
* @return list<mixed>
*/
public function getCollected(): array
{
return $this->collected;
}
/**
* @return list<string>
*/
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}";
}
}

98
src/SourceStore.php Normal file
View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Latch;
/**
* Holds a source's declared hook points.
*/
final class SourceStore
{
/** @var array<string, HookPoint<object>> */
private array $points = [];
/** @var array<string, list<HandlerEntry>> */
private array $handlers = [];
/**
* @param list<string> $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<object> $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<object>
*/
public function getPoint(string $name): HookPoint
{
if (! isset($this->points[$name])) {
throw new Exceptions\HookPointNotFoundException($this->id, $name);
}
return $this->points[$name];
}
/**
* @return array<string, HookPoint<object>>
*/
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<string> $tags When non-empty, only handlers with at least one matching tag are returned
* @return list<HandlerEntry>
*/
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;
}
}

719
tests/HookRegistryTest.php Normal file
View File

@@ -0,0 +1,719 @@
<?php
declare(strict_types=1);
use Latch\Exceptions\DuplicateHandlerException;
use Latch\Exceptions\DuplicateSourceException;
use Latch\Exceptions\HookPointNotFoundException;
use Latch\Exceptions\HookTypeMismatchException;
use Latch\Exceptions\ReservedTagException;
use Latch\Exceptions\SourceNotFoundException;
use Latch\HookRegistry;
use Latch\HookType;
beforeEach(function () {
$this->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([]);
});

3
tests/Pest.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
declare(strict_types=1);