mirror of
https://github.com/chenasraf/latch.git
synced 2026-05-17 17:28:08 +00:00
359 lines
10 KiB
Markdown
359 lines
10 KiB
Markdown
# 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
|