mirror of
https://github.com/chenasraf/latch.git
synced 2026-05-17 17:28:08 +00:00
feat: initial version
Release-As: 0.1.0
This commit is contained in:
34
.github/workflows/ci.yml
vendored
Normal file
34
.github/workflows/ci.yml
vendored
Normal 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
18
.github/workflows/release-please.yml
vendored
Normal 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
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/vendor/
|
||||
composer.phar
|
||||
composer.lock
|
||||
/.phpunit.cache/
|
||||
/.php-cs-fixer.cache
|
||||
/tmp
|
||||
4
.phpactor.json
Normal file
4
.phpactor.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "/phpactor.schema.json",
|
||||
"language_server_phpstan.enabled": true
|
||||
}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
23
Makefile
Normal 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
135
README.md
Normal 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
43
composer.json
Normal 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
358
docs/examples.md
Normal 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
495
docs/guide.md
Normal 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
10
lefthook.yml
Normal 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
6
phpstan.neon
Normal file
@@ -0,0 +1,6 @@
|
||||
parameters:
|
||||
level: 8
|
||||
paths:
|
||||
- src
|
||||
excludePaths:
|
||||
- src/Integration/
|
||||
12
phpunit.xml
Normal file
12
phpunit.xml
Normal 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>
|
||||
96
src/Builders/HandlerBuilder.php
Normal file
96
src/Builders/HandlerBuilder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
60
src/Builders/SourceBuilder.php
Normal file
60
src/Builders/SourceBuilder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
62
src/Contracts/HookRegistryInterface.php
Normal file
62
src/Contracts/HookRegistryInterface.php
Normal 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;
|
||||
}
|
||||
15
src/Exceptions/DuplicateHandlerException.php
Normal file
15
src/Exceptions/DuplicateHandlerException.php
Normal 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/Exceptions/DuplicateSourceException.php
Normal file
15
src/Exceptions/DuplicateSourceException.php
Normal 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/Exceptions/HookPointNotFoundException.php
Normal file
13
src/Exceptions/HookPointNotFoundException.php
Normal 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}'.");
|
||||
}
|
||||
}
|
||||
17
src/Exceptions/HookTypeMismatchException.php
Normal file
17
src/Exceptions/HookTypeMismatchException.php
Normal 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}'."
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/Exceptions/ReservedTagException.php
Normal file
15
src/Exceptions/ReservedTagException.php
Normal 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/Exceptions/SourceNotFoundException.php
Normal file
13
src/Exceptions/SourceNotFoundException.php
Normal 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
38
src/HandlerEntry.php
Normal 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
28
src/HandlerInfo.php
Normal 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
63
src/HookHandler.php
Normal 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
24
src/HookPoint.php
Normal 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
121
src/HookRegistry.php
Normal 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
166
src/HookSource.php
Normal 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
12
src/HookType.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Latch;
|
||||
|
||||
enum HookType: string
|
||||
{
|
||||
case Filter = 'filter';
|
||||
case Action = 'action';
|
||||
case Collect = 'collect';
|
||||
}
|
||||
19
src/Integration/Laravel/LatchServiceProvider.php
Normal file
19
src/Integration/Laravel/LatchServiceProvider.php
Normal 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');
|
||||
}
|
||||
}
|
||||
74
src/Integration/Nextcloud/BridgedHandler.php
Normal file
74
src/Integration/Nextcloud/BridgedHandler.php
Normal 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);
|
||||
}
|
||||
}
|
||||
143
src/Integration/Nextcloud/BridgedHandlerBuilder.php
Normal file
143
src/Integration/Nextcloud/BridgedHandlerBuilder.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
66
src/Integration/Nextcloud/BridgedRegistry.php
Normal file
66
src/Integration/Nextcloud/BridgedRegistry.php
Normal 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();
|
||||
}
|
||||
}
|
||||
123
src/Integration/Nextcloud/BridgedSource.php
Normal file
123
src/Integration/Nextcloud/BridgedSource.php
Normal 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);
|
||||
}
|
||||
}
|
||||
64
src/Integration/Nextcloud/LatchBootstrap.php
Normal file
64
src/Integration/Nextcloud/LatchBootstrap.php
Normal 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();
|
||||
}
|
||||
}
|
||||
101
src/Integration/Nextcloud/LatchEvent.php
Normal file
101
src/Integration/Nextcloud/LatchEvent.php
Normal 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
98
src/SourceStore.php
Normal 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
719
tests/HookRegistryTest.php
Normal 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
3
tests/Pest.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
Reference in New Issue
Block a user