Render-target-agnostic live components for the Milpa PHP framework — the same component definition renders to web AND terminal; state, data sources, and an event-driven interception seam, no HTML or ANSI in the component itself.
milpa/live is the render-target-agnostic core of Milpa's live component system: a
component owns its contract (props/state schema, declared actions), its initial
state (mount()), and how it reacts to client-originated actions (handle()) — but
never how it turns into markup or terminal output. That's a
ComponentRendererInterface's
job, paired with the component at the call site. One component, any number of renderers.
composer require milpa/liveA minimal component plus two renderers — one for HTML, one for TUI — sharing the exact
same mount()/handle() logic:
use Milpa\Live\Contracts\Component\ComponentDefinitionInterface;
use Milpa\Live\Contracts\Rendering\ComponentRendererInterface;
use Milpa\Live\ValueObjects\{
ComponentContext, ComponentContract, InteractionRequest,
InteractionResult, RenderRequest, RenderResult, RenderTarget, StateSnapshot,
};
final class CounterComponent implements ComponentDefinitionInterface
{
public static function contract(): ComponentContract
{
return new ComponentContract(name: 'counter', contractVersion: '1.0.0', actions: ['increment' => []]);
}
public function mount(array $props, ComponentContext $context): StateSnapshot
{
return new StateSnapshot(
componentId: $context->componentId,
componentName: 'counter',
version: '1.0.0',
data: ['count' => (int) ($props['start'] ?? 0)],
);
}
public function handle(InteractionRequest $request): InteractionResult
{
return new InteractionResult(state: new StateSnapshot(
componentId: $request->state->componentId,
componentName: $request->state->componentName,
version: $request->state->version,
data: ['count' => $request->state->data['count'] + 1],
));
}
}
final class HtmlCounterRenderer implements ComponentRendererInterface
{
public function supportsTarget(RenderTarget $target): bool
{
return $target === RenderTarget::HTML;
}
public function render(ComponentDefinitionInterface $component, RenderRequest $request): RenderResult
{
$state = $request->state ?? $component->mount($request->props, $request->context);
return new RenderResult(
output: sprintf('<button data-count="%d">Count: %d</button>', $state->data['count'], $state->data['count']),
state: $state,
format: RenderTarget::HTML,
);
}
}
final class TuiCounterRenderer implements ComponentRendererInterface
{
public function supportsTarget(RenderTarget $target): bool
{
return $target === RenderTarget::TUI;
}
public function render(ComponentDefinitionInterface $component, RenderRequest $request): RenderResult
{
$state = $request->state ?? $component->mount($request->props, $request->context);
return new RenderResult(output: "[ Count: {$state->data['count']} ]", state: $state, format: RenderTarget::TUI);
}
}
$component = new CounterComponent();
$context = new ComponentContext('demo-1');
$html = (new HtmlCounterRenderer())->render($component, new RenderRequest(context: $context, target: RenderTarget::HTML));
echo $html->output; // <button data-count="0">Count: 0</button>
$tui = (new TuiCounterRenderer())->render($component, new RenderRequest(context: $context, target: RenderTarget::TUI));
echo $tui->output; // [ Count: 0 ]CounterComponent never printed a single tag or escape code — both renderers turned the
exact same StateSnapshot into their own output, independently.
That's the thesis this package is built around: a component definition is a pure
description of state and behavior; rendering is a separate, swappable concern. A
ComponentRendererInterface declares which RenderTarget(s)
it supports (HTML, TUI, or the forward-looking ANSI) and turns a mounted
StateSnapshot into output for that target — nothing in ComponentDefinitionInterface
ever needs to know which renderer, or how many, will consume it.
milpa/live ships the component contracts, the mount/handle lifecycle, data sources, and
the event-driven interception seam (component.mounting/mounted,
component.handling/handled, component.rendering/rendered — see
LiveEventEmitter) — but no HTML and no ANSI
renderer. The web surface (AutocompleteHtmlRenderer and friends) lives in
milpa/live-web; a TUI renderer is a live candidate in the Milpa lab. This package is
the seam both build on, not either surface itself.
A plugin declares its view — its components, the client behaviour they need, their CSS —
and the host's single runtime reconciles it: one Alpine, one milpa-live, one boot, one
endpoint, one signing key per page (greenhouse decisions/0211). This package ships the
render-agnostic half of that contract; milpa/live-web ships the HTML half (the compiler that
collects assets, LiveBoot that emits them once, MilpaLive.register() on the client).
ClientAssets(Milpa\Live\ValueObjects\ClientAssets) — the value:scriptsandstylesURL lists the plugin serves from its own routes.merge()/with()deduplicate by URL and keep first-seen order;empty(),isEmpty(),toArray().DeclaresClientAssets(Milpa\Live\Contracts\Rendering\DeclaresClientAssets) — a sibling ofComponentRendererInterfacethat an HTML renderer implements to declare the files its output depends on. Never theComponentDefinitionInterface: the component contract stays render-target-agnostic, and the TUI renderer of the same component has nothing to declare.RenderResult::clientAssets()— the typed channel a compiler fills by merging every declaring renderer's assets (ClientAssets::merge, so a shared module is emitted once). Empty when no renderer declared any. The legacy string-keyedRenderResult::$assetsbag is untouched and still merged witharray_mergeby compilers.CompositeComponentRegistry(Milpa\Live\Runtime\CompositeComponentRegistry) — oneComponentRegistryInterfaceover ordered, labelled layers (['host' => …, 'billing' => …]) so one endpoint serves every plugin's components and a cross-component effect resolves across them. First layer wins onhas()/get();register()writes to the one layer named writable (or throwsLogicExceptionwhen none);names()is the union in layer order. Shadowing is never silent: the same name bound to different definitions in two layers (a different class, or two instances of a class that carries state) throwsComponentNameConflictExceptionat construction, naming the component and both layers. The same instance in two layers is fine, and so are two instances of a stateless class (no instance property at all). The check is identity or statelessness — never a structural compare, which recurses into whatever the component holds and turns a collaborator pointing back at it into an uncatchable fatal instead of a named exception. The shipped components hold a dispatcher, so twonew TextareaComponent()under one name in two layers are a conflict: a plugin reuses the host's instance or names its own component.ListsComponents(Milpa\Live\Contracts\Component\ListsComponents) —names(): list<string>, implemented byInMemoryComponentRegistryand the composite. A layer that does not list is still resolved but takes no part in conflict detection ornames().ComponentRendererRegistry::registerFor()/resolveFor()— the pair of the composite (amilpa/live-webLiveEndpointorXhtmlComponentCompileraccepts it in place of a name-keyed array): a renderer registered for a component name answers for that name at its target, elsenull— exactly what the array answered. The target-wideregister()/resolve()is a separate question and is deliberately not the fallback: every shipped HTML renderer is single-family and throws for the rest, so a fallback would hand a plugin's component to a renderer that refuses it and turn a missing registration into an uncaught exception in the endpoint. A host with a general renderer registers it for each name it serves.
use Milpa\Live\Runtime\CompositeComponentRegistry;
use Milpa\Live\Rendering\ComponentRendererRegistry;
$components = new CompositeComponentRegistry(['host' => $hostRegistry, 'billing' => $billingRegistry], writable: 'host');
$renderers = new ComponentRendererRegistry();
$renderers->registerFor('invoice-list', $billingHtmlRenderer); // implements DeclaresClientAssets → its .js/.css travel with every compileEverything in this section is additive: no existing contract changes shape. RenderResult
gained an optional trailing constructor parameter (clientAssets) with a default, so every
existing renderer compiles; InMemoryComponentRegistry gained names();
ComponentRendererRegistry gained registerFor()/resolveFor(). Nothing is required of a
renderer that has no client files to declare.
- PHP ≥ 8.3
milpa/core≥ 0.9, < 1.0psr/log^3
Full API reference: getmilpa.github.io/live — generated straight from the source DocBlocks and dressed with the Milpa design system.
Contributions are welcome — see CONTRIBUTING.md. Please report security issues via SECURITY.md, and note that this project follows a Code of Conduct.
Apache-2.0 © Rodrigo Vicente - TeamX Agency.
Milpa is designed, built, and maintained by Rodrigo Vicente - TeamX Agency.