@loom-forge/core
v0.3.0
Published
loom · o kernel HEADLESS: DefNode → ResolvedNode via bind → resolve → plan. Zero React, zero ambiente, zero dependências. Os ports (bind/validação/ações/endereço/tipos) são os seams de DI.
Maintainers
Readme
@loom-forge/core
loom · o kernel headless. Zero React, zero ambiente, zero dependências. Transforma
definição (+ dados) numa ResolvedNode (dados puros) via bind → resolve → plan. É o contrato
que todos os outros packages falam.
pnpm add @loom-forge/coreimport {
createDescriptorRegistry,
createTypeResolver,
resolveTree,
fragmentPlanner,
sectionPlanner,
gridFormPlanner,
defaultPlanners,
walk,
findById,
pathToId,
mapNodes,
identityBinder,
noopValidator,
} from "@loom-forge/core";Modelo
DefNode — a unidade da definição
interface DefNode {
id: string; // identidade ESTÁVEL (keys, endereço, edição). Nunca posicional.
type?: string; // discriminador → registry (ausente quando `el` dá a tag)
el?: string; // tag intrínseca — porta de emergência, não caminho (ADR-012)
props?: Record<string, unknown>;
layout?: string; // qual layout arruma os FILHOS deste nó (default: o do engine)
children?: DefNode[];
placement?: unknown; // posição DESTE nó no layout do pai — opaca pro core
slot?: string; // a ÁREA do pai que este nó preenche (`"header"`). Ausente = a default
events?: Record<string, EventBinding>; // events-out: nomeDaProp → intenção
fallback?: "drop" | DefNode; // forward-compat: type desconhecido → isto ("drop" = nada)
requires?: Record<string, string>; // gate de capability: `cap → versão` ou `cap → "*"` (presença)
version?: string;
// as DIRETIVAS — consumidas no caminho, nunca chegam à view
each?: unknown; // repete o nó por item (ou re-escopa, se objeto)
as?: string; // o nome que o `each` liga para o item
when?: unknown; // dropa o nó se falsy
provides?: Record<string, unknown>; // contextos para a SUBÁRVORE
commands?: Record<string, CommandAlias>; // vocabulário de comando da SUBÁRVORE
// de onde o nó veio, de fora para dentro — escrito pela expansão, nunca de autoria (ADR-017)
origins?: readonly NodeOrigin[];
}
interface NodeOrigin {
component: string; // o componente cujo documento escreveu o nó
id: string; // o id que o autor deu — o resolvido é outro
}
interface EventBinding {
action: string;
// os params da ação — e é POR AQUI que entra tudo o que a intenção recebe: a raiz `event` é
// o valor recebido (o que o widget emitiu; o `data` da ação anterior num `then`; o `error`
// num `catch`), e o autor escreve o mapeamento. Avaliados no DESPACHO, não no render.
params?: unknown;
// o que roda quando esta intenção RESOLVE: `success: true` no host (`event` = `data`), ou o
// estado escrito num update local (`event` = `undefined`)
then?: EventBinding;
// o que roda quando o despacho ao host FALHA (`event` = `error`: `{ code, message?, data? }`)
catch?: EventBinding;
// os params que o autor deixa para a camada de COMANDO preencher (token, default, drawer)
resolve?: readonly string[];
// update do componente que escreveu a intenção — marca da RESOLUÇÃO, nunca de autoria (ADR-016)
local?: true;
// o escopo do render em que o nó foi ligado — escrito pelo `bind`, nunca de autoria
scope?: Readonly<Record<string, unknown>>;
}
// o que um nome de comando executa — a forma longa é a aplicação PARCIAL
type CommandAlias = string | { action: string; params?: unknown };Tudo o que uma intenção recebe entra pelo params, e o autor vê o que entra (ADR-018 do
repo). O valor que o widget emite é a raiz event dos params — { campo: "cpf", valor:
"{{ event }}" }, um sub-campo ({{ event.valido }}), uma transformação —, e o componente
registrado emite valor, nunca o evento do DOM, e declara o que emite em
descriptor.meta.emits (é o tipo de event no check; sem declaração, unknown).
O que a ação devolve é a próxima intenção: then roda no sucesso com event = data
(tipado pelo resultOf da porta), catch roda na falha com event = error. Um update local
também resolve (o estado foi escrito), então a cadeia iniciar (local) → host → then/catch
(local) é um mecanismo só, lida como uma promise chain. Sem catch, a falha vai só ao
onActionResult do host — e o host recebe TODO resultado, com ou sem continuação.
runIntent(binding, event, runtime); // executa a intenção e a cadeia dela; nunca devolve nada
intentParams(binder, binding, event); // os params avaliados no despacho: escopo do render + event
intentsOf(binding); // a intenção e cada elo da cadeia, para quem roteia ou desenha grafo
dispatchIntent(port, id, params, onResult); // o despacho de UMA ação ao host, normalizadoO IntentRuntime é o que quem executa (a view, o boundary) entrega ao runIntent: params
(o binder avaliando sobre scope + event), apply (o update local do componente vivo), a
port, o onResult e o pending — true do despacho ao resultado, que o boundary expõe como
raiz pending[ação] do escopo de um componente vivo.
Descriptor — metadado headless de um type (NUNCA contém o componente)
interface Descriptor {
type: string;
defaultProps?: Record<string, unknown>;
schema?: unknown; // JSON Schema / Schema do TSLite — validação & form-builder
layout?: string; // layout default pros filhos deste type
meta?: Record<string, unknown>; // pra editores (label/categoria/ícone)
}ResolvedNode — a saída (dados puros, agnóstica de ambiente)
type ResolvedNode =
| {
kind: "node";
id;
type;
props;
events?;
layout?: ResolvedLayout;
children: ResolvedNode[];
slot?; // a ÁREA do pai — a view a entrega como prop, e ela não vai ao planner
origins?; // a origem atravessa até a view — é o que liga a tela ao documento
}
| { kind: "drop"; id }
| { kind: "unknown"; id; type };
interface ResolvedLayout {
name: string;
plan: unknown;
} // plan pareado com o renderer por `name`Registry de descriptors
createDescriptorRegistry(initial?: Descriptor[]): DescriptorRegistry| Método | Descrição |
| ----------------------------------------- | ------------------------------------------- |
| register(descriptor) / register(type) | adiciona (forma completa ou nu). Encadeável |
| get(type) · has(type) · list() | leitura |
const registry = createDescriptorRegistry()
.register({ type: "Page", layout: "section" })
.register("Field");Resolver — estratégia de resolução
createTypeResolver(options?: { capabilities?: Record<string, string> }): Resolver
interface Resolver { resolve(node: DefNode, ctx: { registry: DescriptorRegistry }): ResolveResult }
type ResolveResult =
| { kind: "render"; descriptor: Descriptor; props: Record<string, unknown> }
| { kind: "drop" }
| { kind: "unknown"; node: DefNode };- Mescla
descriptor.defaultPropsSOBnode.props. requires:{ feat: "*" }(presença) ou{ feat: "1.2" }(versão mínima), contracapabilities.fallback:"drop"(nada) ou outro nó (resolvido no lugar); senãounknown.
Pipeline
resolveTree(node: DefNode, opts: ResolveTreeOptions, data?: unknown): ResolvedNode
interface ResolveTreeOptions {
registry: DescriptorRegistry;
resolver?: Resolver; // default: createTypeResolver()
binder?: BindingEngine; // default: identityBinder
planners?: Record<string, LayoutPlanner>; // default: defaultPlanners()
defaultLayout?: string; // default: "fragment"
}bind (pré-passo) → resolve (type→descriptor+props) → plan (layout). Nós fallback:"drop" somem
da árvore E do plano; unknown permanece (a view decide a degradação).
shareIdentity(anterior, nova) — o que não mudou volta como o MESMO objeto
shareIdentity(previous: ResolvedNode | undefined, next: ResolvedNode): ResolvedNode;A resolução é pura e refaz a árvore a cada dado novo. Esta passada compara a árvore nova com a
anterior e devolve o objeto anterior onde o valor é o mesmo — o resultado é igual, e o que muda
é a identidade. É o que dá a um consumidor como responder mudou alguma coisa aqui dentro? sem
olhar dentro: um === na raiz da subárvore.
- os filhos são pareados por
id, então reordenar não invalida ninguém; - um nó é reusado quando ele e todos os descendentes são iguais;
- props, plano de layout e o escopo guardado numa intenção entram na comparação — um
paramsavaliado no despacho contra outro escopo é outro despacho; - valor é comparado por dado: identidade primeiro (o objeto do host passa direto), e campo a
campo só no que a ligação constrói. Uma instância (
Date,Map) vale por identidade.
Quem chama é a view, guardando a árvore anterior — é assim que o @loom-forge/react pula o render
de uma subárvore inteira, e que o boundary de um componente vivo vira fronteira de verdade
(ADR-022 do repo).
Layout — planners (metade headless do arrange)
interface PlannerChild {
id: string;
placement?: unknown;
}
interface LayoutPlanner {
name: string;
plan(children: PlannerChild[]): unknown; // dados do plano
placementSchema?: unknown; // a FORMA do placement que ele lê, em JSON Schema
}O planner arruma o CORPO, não as áreas. Um filho que declarou slot é uma região do pai
(header, footer), e região não é filho arrumado: ele não entra no plano, e a view o entrega
como a prop de mesmo nome. Sem essa divisão, um grid-form tentaria dar coluna ao rodapé. Numa
tag intrínseca (el) não há região declarada, então lá todo filho é corpo.
| Planner | Placement | Plan |
| -------------------------------------- | ----------------------------------------------------- | ------------------------------------- |
| fragmentPlanner() | { index? } | { order: string[] } |
| sectionPlanner({ direction?, gap? }) | { index? } | { direction, gap?, order } |
| gridFormPlanner({ columns?, gap? }) | { span?: number \| ResponsiveSpan, break?, index? } | { columns, gap, responsive, cells } |
interface ResponsiveSpan {
base: number;
sm?: number;
md?: number;
lg?: number;
xl?: number;
}
interface GridFormCell {
id;
span: number;
spans: FilledSpans;
break?;
} // span = base (back-compat/inline)defaultPlanners() → { fragment, section, "grid-form" }. O renderer correspondente (view) vive no
runtime; os dois são pareados por name.
O placementSchema é o contrato do filho no eixo de arrumação — o que o descriptor.schema é no de
props. Um editor monta os campos de posição a partir dele sem saber que layout é, e um layout que não
o publica deixa quem edita com o objeto cru. Os três planners daqui publicam.
Tree — utilitários puros (pro core e pro editor)
| Função | Descrição |
| ---------------------------- | ------------------------------------------------- |
| walk(node, visit, parent?) | pré-ordem; visit(node, parent) (null na raiz) |
| findById(root, id) | primeiro nó com o id |
| pathToId(root, id) | caminho de ids até o alvo, ou null |
| mapNodes(node, fn) | mapeia imutável bottom-up |
Ports — os seams (DI)
O core depende destas interfaces, nunca de uma lib — é por isso que ele não tem dependência nenhuma. Cada adapter é um package à parte, e quem não usa um não paga por ele.
| Port | Contrato | Adapter |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| BindingEngine | compile(node) → DefNode (opcional) · bind(node, scope) → DefNode | @loom-forge/tslite · default identityBinder |
| ValidationProvider | validate(schema, value) → Issue[] \| null | AJV/Zod (DI) · default noopValidator |
| ActionPort | o Tier 0 do contrato de actions: invoke(id, params?, ctx?) → ActionOutcome (void \| ActionResult \| Promise) · has? · o Tier 1 opcional | o próprio runtime (@node-actions/*), ou um objeto do host — sem adapter (ADR-115) |
| AddressPort | diff(a, b) · patch(base, diff) | @loom-forge/address |
| TypeChecker | subtype(a, b): boolean | @loom-forge/check |
| CapabilityPort | get(name) · has(name) | escape imperativo (host) |
| ContextStore | snapshot(names) · subscribe(names, onChange) · names() | o host · staticContextStore / createContextStore |
O contexto é dependência DECLARADA (ADR-022 e ADR-023 do repo)
import { createContextStore, staticContextStore } from "@loom-forge/core";
const store = createContextStore({ auth, route }); // o host tem estado vivo
store.set("route", next); // acorda quem declarou `route` — e só
staticContextStore(() => values); // o host só tem valores: a projeção é que é estávelO contexto é o único escopo dinâmico do modelo, e recebê-lo como fotografia faz todo
consumidor depender da identidade do mapa inteiro — trocar um nome invalida quem nunca o
declarou. O port inverte isso: quem consome diz os nomes (o context.uses, mais o que os
filhos dele exigem) e recebe a projeção deles.
snapshot(names)é estável por identidade enquanto nenhum daqueles nomes muda. É o que umuseSyncExternalStoreexige, e o que transforma "não mudou" em trabalho pulado;subscribeé o que faz o consumidor deixar de ser visitado, e não só de recalcular. O store estático não notifica — quem passa valores está re-renderizando de qualquer forma;names()é a borda declarada: o que o host tem para dar. É o que a RAIZ do documento pede, porque ali uma expressão pode ler qualquer nome provido.
A porta de ação, sem adapter
O ActionPort é a projeção estrutural do Tier 0 do @node-actions/protocol (ADR-114): um
runtime que cumpra o contrato — o commander, a lite, o engine de diretivas via
toProtocolRuntime — é assignável a ele como está, e um objeto do host com invoke também. O
core não importa o protocolo (ADR-105), e não há package de adapter (ADR-115):
createEngine({
actions: commander, // ou { invoke(id, params) { … } }
onActionResult: (id, result) => {
if (!result.success) toast(result.error?.code);
},
});invokedevolve ENVELOPE, e falha de domínio é DADO:{ success: false, error: { code } }volta peloonActionResulte pelocatchdo documento — nunca como exceção.voidé sucesso.- Um runtime que LANÇA não derruba o clique: o
dispatchIntentconverte othrowe a rejeição em{ success: false, aborted: true, abortedBy: "throw", error: { code: "UNHANDLED_ERROR" } }— bug de programa, não falha de domínio, e ocatché dele. - O Tier 1 é lido só quando existe (
isAvailable,paramsOf,resultOf,metadataOf): ochecktipa o call-site peloparamsOfe oeventde umthenpeloresultOf; o grafo lê oisAvailable. Quem não publica não afirmou nada, e isso é silêncio, não erro. - Discrimine por
error.code, nunca por regex na mensagem.
Ver docs/ARCHITECTURE.md para o porquê de cada um, e docs/DECISIONS.md para os ADRs — os do caminho de efeito são o ADR-103, 104, 114 e 115.
Escopo de comando
import {
commandScope,
resolveIntent,
aliasOf,
EMPTY_COMMANDS,
} from "@loom-forge/core";| Função | Descrição |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| commandScope(herdado, nó) | o vocabulário em vigor ABAIXO do nó — o herdado, com o commands dele por cima (o mais próximo ganha) |
| resolveIntent(binding, escopo, updates?) | a intenção roteada: nome de update local fica; alias vira o id, com os params dele por baixo dos do disparo; o resto passa |
| aliasOf(alias) | a forma curta normalizada: "form/clear" → { action: "form/clear" } |
É a resposta de RUNTIME para "que ação este nome executa?", e quem a aplica é a expansão do
@loom-forge/forge. A resposta sem rodar — que precisa saber onde o componente
monta — é do forge também. O que viaja é nome, nunca função (ADR-015 do repo).
A tabela de slots
O core também exporta a forma do DefNode como dado — FIELDS (o papel de cada campo),
SLOTS (onde uma expressão pode morar, e o que o slot espera) e TREE (por onde a travessia
desce). É uma tabela exaustiva por tipo, e é o que faz a compilação, o bind e o typecheck
concordarem sobre onde cabe código sem nenhum deles farejar texto. events e commands são mapas
de intenções: o que compila é o params de cada entrada (shape intentParams). E cada slot declara
directives — se {% %} e {# #} são marcação ali, além do {{ }}; hoje é false em todos, e a
compilação pergunta à tabela em vez de a um padrão.
slot é data, como o as de um each: os dois são NOMES que o autor escolhe, não expressão —
um nomeia o item do laço, o outro a área do pai que o nó preenche.
