@loom-forge/forge
v0.4.0
Published
forge · o CONSTRUTOR de componentes (headless): declara componentes novos em JSON — IR, grafo, regras, slots, estado local e a expansão sobre o compositor. Zero dependências.
Maintainers
Readme
@loom-forge/forge
forge · o CONSTRUTOR de componentes, headless. O loom compõe componentes que já existem; o forge declara componentes novos em JSON — com props tipadas, estado local, slots com contrato e um grafo de dependência entre definições. Sem React e sem linguagem de expressão: o binder entra pelo port.
pnpm add @loom-forge/forgeO @tslite/graph é peer (ADR-021): a aplicação fornece uma instância do TSLite, a mesma para todo repo que a compõe. npm 7+ e pnpm 8+ o instalam sozinhos.
{
"component": "UserCard",
"props": { "id": "string", "titulo?": "string" },
"state": { "aberto": { "type": "boolean", "init": false } },
"updates": { "alternar": { "set": { "aberto": "{{ !state.aberto }}" } } },
// um update pode declarar os ARGUMENTOS que aceita: `params: { n: "number" }`
"body": {
"id": "card",
"type": "Card",
"props": { "title": "{{ props.titulo }}" },
"events": { "onClick": { "action": "alternar" } },
"children": [{ "id": "slot", "type": "$slot" }],
},
}Um componente é uma função — exatamente como uma action é uma função. É desse isomorfismo que
sai o resto: entry × privado, puro × sujo, requires/unable, grafo de dependência, propagação
de tags. Ver docs/DESIGN.md.
As camadas
| camada | o quê | arquivo |
| -------------- | ------------------------------------------------------------------ | ---------------------------- |
| definição | ComponentDef + o registry (createComponentDefRegistry) | ir.ts |
| compilação | a definição COMPILADA: texto → ilha, na árvore e fora dela | compile.ts |
| análise | IR + grafo + regras + slots — o relógio LENTO, data-independente | ir/graph/rules/slots |
| expansão | o seam sobre o compositor: custom → árvore pura, no relógio RÁPIDO | expand.ts |
A definição compilada
import {
compileDefinition,
compileNodeOf,
componentSlotsOf,
} from "@loom-forge/forge";
// pelo PORT do binder (runtime): só a forma
const { def: compilada } = compileDefinition(userCard, compileNodeOf(binder));
// pela LINGUAGEM (autoria): a forma, os diagnósticos e as ilhas em memória
const { def, diagnostics, islands } = compileDefinition(userCard, compile);Uma definição tem expressão fora da árvore: o provides, os params de um alias de
commands, o init do estado, o set de um update, os params de um efeito e do cleanup — e
os de cada elo then/catch da cadeia deles (ADR-018). O compile() da linguagem recebe um nó
e só alcança a árvore — então cada consumidor compilava o resto por conta própria, com um
nó-fantasma escrito na mão: um no applyUpdate, um no EffectRunner, um no eject. Três cópias
da mesma pergunta.
componentSlotsOf(def) responde onde eles estão, e o caminho é o endereço
(updates.somar.set, effects.carregar.then.params, effects.carregar.cleanup.params). A tabela COMPONENT_FIELDS classifica
todo campo da definição e é exaustiva por keyof ComponentDef: campo novo não compila sem ser
classificado.
A divisão de conhecimento não muda: o forge diz ONDE a expressão mora e a linguagem entra como
função (CompileNode) — nenhum dos dois importa o outro. O tradutor devolve a forma e, quando tem
o que dizer, os diagnósticos e as ilhas do nó; o forge os reancora no caminho do slot
(updates.somar.set.n, state.n.init), e é assim que um erro de sintaxe num set chega à
autoria com endereço. O port do binder fala só DefNode, e compileNodeOf(binder) é o tradutor
estável dele — a memória do compileDefinition é por definição e por tradutor, então uma
arrow nova por render nunca a acertaria.
[!WARNING] O runtime assume a definição COMPILADA. O
applyUpdatee obindPayloadsó ligam; quem compila é odefineComponent, uma vez, na entrada do registry. Umsetque chegue cru vira o próprio texto no estado, porque o binder trata string como dado (ADR-111).
Definição e IR
import {
createComponentDefRegistry,
compileComponent,
buildSystemIR,
} from "@loom-forge/forge";
const defs = createComponentDefRegistry().register(userCard);
const ir = compileComponent(userCard, { components: defs.names() });
const system = buildSystemIR([ir]); // grafo: outgoing/incoming, tags, ciclos, missing-depsO IR é derivado, nunca editado à mão, nunca persistido como verdade — recomputado do source.
E ele carrega só a árvore normalizada: o que se calcula sobre ela (arestas, deps, tags,
ciclos) é artefato lateral — deriveFacts / factsOf / nodeFactsOf —, com as camadas
transitivas opt-in. O binder deriva, o checker julga.
A unidade do grafo é a definição (UserCard), não o nó-instância; as arestas são induzidas por
nós dentro da árvore (type custom → uses; uma intenção → actions, com o id que ela
EXECUTA — ver Comando). Containment não é dependência. Ver
docs/IR.md.
Grafo
O motor é o @tslite/graph — ponto-fixo
monótono por worklist, Tarjan iterativo, zero dependências. Não o reexportamos: quem precisa das
primitivas importa de lá, direto. O que o buildSystemIR faz é montar o grafo do DOMÍNIO (quais
são os nós, quais arestas contam) e instanciar os reticulados:
| fato | de onde vem | o quê |
| ---------------- | --------------------------- | --------------------------------------------- |
| facts.tags | propagateUnion | tags efetivas/transitivas, canônicas |
| facts.incoming | invertEdges | "quem usa isto" — por onde o dirty sobe |
| facts.cycles | detectCycles | componentes fortemente conexos |
| computeUnable | reachable (em rules.ts) | availability transitivo (o Razor das Actions) |
A Linguagem não tem ciclo — o call graph dela é um DAG —, mas o grafo de quem a consome tem: um componente compõe outro que o compõe de volta. Sobre esse grafo não existe ordem topológica, existe ponto-fixo; e essa peça, que todo consumidor não-trivial acabava copiando, desceu para o TSLite. Este package copiava uma versão quadrática dela (1000 nós em 58 ms, 8000 em 4,7 s), e a cópia morreu quando o dono publicou a peça.
Regras e slots
import {
checkSlots,
checkCategoryPolicy,
computeUnable,
checkForbiddenTags,
checkIdentity,
} from "@loom-forge/forge";checkIdentity acusa um id repetido no documento de um componente (duplicate-id, com os endereços).
O id é a identidade do nó — é por ele que o editor muta, que o patch acha o alvo e que a tela liga o
foco ao documento —, e dois nós com o mesmo id fazem os três apontarem para o primeiro, sem erro
nenhum. Componentes diferentes repetem id à vontade: a expansão os separa por instância.
checkSlots é o que o React não tem: o pai declara o contrato do que aceita como children
(categoria, tags, leaf, cardinalidade). Composição ≠ dependência — A não conhece B, só o contrato,
então isso NÃO cria aresta A→B, e a checagem mora no compositor (quem pôs B dentro de A).
O contrato é por área (slots é nome → SlotContract, e children é a default): cada região
julga o grupo dela, e um filho que aponta para uma área que o pai não declara é slot-unknown —
o erro mais barato de pegar aqui, porque em runtime ele viraria uma prop que o componente ignora e
sumiria da tela. Um pai que não declara contrato nenhum não julga nada: não saber não é saber que
não.
O computeUnable aceita a porta de ação e pergunta a ela, em vez de exigir a lista de
indisponíveis pronta:
computeUnable(sys, { port });
// ação indisponível → quem a invoca é fonte, e todo dependente herda `unable`Ele pergunta só pelo que o grafo de fato invoca — o eixo actions tem só o que SAI, porque update
local é chamada interna (ADR-014), e tem o id RESOLVIDO: um apelido de sessão nunca é perguntado. Porta sem isAvailable não torna nada unable: não saber não é
saber que não. E é um RETRATO: disponibilidade muda em runtime, e isto é o relógio lento — quem
quiser acompanhar, recomputa.
Expansão
import { resolveComponentTree } from "@loom-forge/forge";
resolveComponentTree(node, { registry, binder, componentDefs: defs }, data);
// bind (dados) → expand (custom → árvore pura) → resolveTree (compositor)A expansão é um pré-passo, e é isso que mantém o compositor puro: o resolveTree do core não
sabe o que é um ComponentDef. Cada nó de tipo custom vira o corpo do componente ligado contra as
props, com ids namespaced por instância; os children do call-site são injetados onde o corpo
declara { "type": "$slot" } — resolvidos no escopo do COMPOSITOR, opacos para o componente.
Um corpo pode abrir várias ÁREAS, que é o que um layout de verdade pede (header, menu, main,
rodapé): o corpo declara { "type": "$slot", "props": { "name": "header" } } e o filho do
call-site diz em qual entra com slot — o irmão do placement, e os dois são campos do filho
que falam do pai (um diz onde no layout, o outro em que área). Sem slot, o filho cai na área
default, que é a composição de sempre:
{
"type": "AppShell",
"children": [
{ "id": "topo", "type": "AppHeader", "slot": "header" },
{ "id": "corpo", "type": "HomePage" },
],
}Quem resolve depende de quem é o pai: um ComponentDef, aqui na expansão; uma peça registrada
com regiões, na view — o grupo vira a prop de mesmo nome, e só o corpo vai ao planner. Nó nunca
entra em props: duas posições de árvore seriam duas verdades sobre quem ganha.
Um componente vivo também recebe áreas: o marcador $stateful leva os children do call-site, e
o boundary os repassa pelo children do resolveComponentTree. Sem isso, um layout com estado
perderia os filhos que o compositor lhe deu — e perderia em silêncio.
Ela roda sempre, mesmo sem componentDefs: é também onde as diretivas de escopo (provides,
commands) são consumidas. bodyOf(def) é o corpo com o açúcar do componente — o provides e o
commands dele juntam com os da raiz, e a raiz ganha no mesmo nome —, e é o que o boundary de um
componente vivo expande, com updates dizendo quais nomes resolvem ali dentro.
Cada nó sai com a origem (origins): o componente e o id que o autor deu, do mais de fora para o
mais de dentro. O bodyOf carimba antes do bind, então a cópia de um each preserva o id autorado; a
raiz de um corpo responde também pela chamada que ocupa; e a árvore do host não tem origem. É o que
liga a tela de volta ao documento (ADR-017 do repo).
Contexto
import {
deriveContext,
checkContexts,
requiredContextsOf,
} from "@loom-forge/forge";
const ctx = deriveContext(sys.components.values());
checkContexts(sys, ctx, { ambient: ["@acme/auth"] });
requiredContextsOf(defs); // componente → os nomes que ele exige de FORAuses não é atributo, é dívida: ela sobe pelo grafo até alguém prover, e quem exige sem
declarar está mentindo — erro, no nó por onde escapou. Qualquer nó provê (provides), o
ComponentDef.provides é açúcar para a raiz do corpo, e o contrato tem de ser dado serializável.
O que está provido num ponto é o que ele enxerga depois de MONTADO: um filho de slot enxerga o que o
chamado provê no $slot. Ver docs/CONTEXT.md.
Do runtime, a mesma lista. O requiredContextsOf(defs) devolve, por componente, os nomes que
ele exige de fora — o uses dele mais o que escapou de quem ele compõe, pelo mesmo ponto-fixo. É
o que a view usa como SELETOR: uma seção viva pede ao host só esses nomes, e mudar um contexto
fora da lista não a alcança (ADR-023 do repo). Uma pergunta, dois consumidores — a análise julga a
borda com ela, e o runtime observa por ela.
E é por isso que as duas origens de contexto viajam separadas na expansão: ExpandOptions.contexts
são os valores de AMBIENTE (o que o host provê), e ExpandOptions.installed é o que o DOCUMENTO
instalou no ponto em que o corpo é expandido — o que o boundary de um componente vivo continua de
onde a expansão parou.
Comando
{
"component": "FormSection",
"commands": {
"submeter": { "action": "form/submit", "params": { "canal": "email" } },
"limpar": "form/clear",
},
"body": {
"id": "sec",
"type": "Section",
"children": [{ "id": "slot", "type": "$slot" }],
},
}Um nó declara o vocabulário da subárvore — nome local → ação. Um botão dispara submeter e
não sabe o que isso executa: quem decide é o lugar onde ele monta. A resolução tem três degraus,
update local → vocabulário mais próximo → porta raiz, e segue as regras do provides: o nó
instala para baixo, o corpo do chamado é a subárvore do nó de chamada, e o filho de slot enxerga o
vocabulário do $slot onde monta. A expansão consome a diretiva, então a view recebe o id. Os
params do alias são aplicação parcial: entram por baixo dos do disparo. E o efeito resolve pelo
vocabulário de onde o componente montou.
A mesma pergunta tem resposta sem rodar:
import { mountingOf, settledRoute } from "@loom-forge/forge";
buildSystemIR(irs, { commands: host }); // o eixo `actions` já sai com o id resolvido
const mounting = mountingOf(sys.components.values(), { host }); // o que cada um instala no slotUm nome que nenhum vocabulário do sistema declara só pode chegar à porta, e é aresta de quem o
escreveu. Um que algum vocabulário declara depende de onde o componente monta: ele escapa, como
o uses de um contexto, e vira aresta de quem o resolve — na borda, é o vocabulário do host
(commands) que responde. routeIntent/settledRoute são a resposta para UMA intenção, e é delas
que o check e o eject perguntam. Ver o ADR-015 do repo.
Efeito
import { checkEffects } from "@loom-forge/forge";Um efeito é um events-out cujo gatilho é uma mudança, não um gesto: mesma action, mesmos
params, mesma aresta no grafo, mesma tipagem — e a mesma cadeia: then/catch valem num efeito
(e no cleanup dele), e é assim que "carregar no mount" guarda o que carregou (ADR-018 do repo). O
event do efeito em si é undefined; o do then, o que a ação devolveu. Declarar efeito torna o componente vivo, e
infere a tag effectful, que propaga. E o laço — observar o que a própria ação escreve — se
prova sem rodar, porque os dois lados são declarados. Ver
docs/STATE.md §7.
Estado local
import {
isLive,
initState,
applyUpdate,
STATEFUL_TYPE,
} from "@loom-forge/forge";Componente puro (sem updates nem effects) expande estaticamente. Componente vivo não expande: emite
o marcador $stateful (dado, headless) e quem o materializa é a view —
@loom-forge/forge-react. O estado é sempre local à instância;
100 <Counter/> são 100 escopos isolados. Nunca global. Ver docs/STATE.md.
Os vizinhos
O forge depende do compositor, nunca o contrário. Ele não conhece React (a view é o
@loom-forge/forge-react), não conhece a linguagem de expressão (o
binder entra pelo port BindingEngine, implementado pelo
@loom-forge/tslite) e não conhece a camada de tipos (que é o
@loom-forge/check, e depende DELE, não o contrário).
