@loom-forge/forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +278 -0
- package/dist/index.d.ts +746 -0
- package/dist/index.js +1078 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# @loom-forge/forge
|
|
2
|
+
|
|
3
|
+
**forge** · o CONSTRUTOR de componentes, headless. O loom compõe componentes que já existem;
|
|
4
|
+
o forge **declara componentes novos** em JSON — com props tipadas, estado local, slots com
|
|
5
|
+
contrato e um grafo de dependência entre definições. Sem React e sem linguagem de expressão: o
|
|
6
|
+
binder entra pelo port.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pnpm add @loom-forge/forge
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```jsonc
|
|
13
|
+
{
|
|
14
|
+
"component": "UserCard",
|
|
15
|
+
"props": { "id": "string", "titulo?": "string" },
|
|
16
|
+
"state": { "aberto": { "type": "boolean", "init": false } },
|
|
17
|
+
"updates": { "alternar": { "set": { "aberto": "{{ !state.aberto }}" } } },
|
|
18
|
+
// um update pode declarar os ARGUMENTOS que aceita: `params: { n: "number" }`
|
|
19
|
+
"body": {
|
|
20
|
+
"id": "card",
|
|
21
|
+
"type": "Card",
|
|
22
|
+
"props": { "title": "{{ props.titulo }}" },
|
|
23
|
+
"events": { "onClick": { "action": "alternar" } },
|
|
24
|
+
"children": [{ "id": "slot", "type": "$slot" }],
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Um componente é uma função — exatamente como uma action é uma função.** É desse isomorfismo que
|
|
30
|
+
sai o resto: entry × privado, puro × sujo, `requires`/`unable`, grafo de dependência, propagação
|
|
31
|
+
de tags. Ver [docs/DESIGN.md](./docs/DESIGN.md).
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## As camadas
|
|
36
|
+
|
|
37
|
+
| camada | o quê | arquivo |
|
|
38
|
+
| -------------- | ------------------------------------------------------------------ | ---------------------------- |
|
|
39
|
+
| **definição** | `ComponentDef` + o registry (`createComponentDefRegistry`) | `ir.ts` |
|
|
40
|
+
| **compilação** | a definição COMPILADA: texto → ilha, na árvore e fora dela | `compile.ts` |
|
|
41
|
+
| **análise** | IR + grafo + regras + slots — o relógio LENTO, data-independente | `ir`/`graph`/`rules`/`slots` |
|
|
42
|
+
| **expansão** | o seam sobre o compositor: custom → árvore pura, no relógio RÁPIDO | `expand.ts` |
|
|
43
|
+
|
|
44
|
+
### A definição compilada
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import {
|
|
48
|
+
compileDefinition,
|
|
49
|
+
compileNodeOf,
|
|
50
|
+
componentSlotsOf,
|
|
51
|
+
} from "@loom-forge/forge";
|
|
52
|
+
|
|
53
|
+
// pelo PORT do binder (runtime): só a forma
|
|
54
|
+
const { def: compilada } = compileDefinition(userCard, compileNodeOf(binder));
|
|
55
|
+
// pela LINGUAGEM (autoria): a forma, os diagnósticos e as ilhas em memória
|
|
56
|
+
const { def, diagnostics, islands } = compileDefinition(userCard, compile);
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Uma definição tem expressão **fora da árvore**: o `provides`, os `params` de um alias de
|
|
60
|
+
`commands`, o `init` do estado, o `set` de um update, os `params` de um efeito e do `cleanup` — e
|
|
61
|
+
os de cada elo `then`/`catch` da cadeia deles (ADR-018). O `compile()` da linguagem recebe um nó
|
|
62
|
+
e só alcança a árvore — então cada consumidor compilava o resto por conta própria, com um
|
|
63
|
+
nó-fantasma escrito na mão: um no `applyUpdate`, um no `EffectRunner`, um no eject. Três cópias
|
|
64
|
+
da mesma pergunta.
|
|
65
|
+
|
|
66
|
+
`componentSlotsOf(def)` responde onde eles estão, e o **caminho é o endereço**
|
|
67
|
+
(`updates.somar.set`, `effects.carregar.then.params`, `effects.carregar.cleanup.params`). A tabela `COMPONENT_FIELDS` classifica
|
|
68
|
+
todo campo da definição e é exaustiva por `keyof ComponentDef`: campo novo não compila sem ser
|
|
69
|
+
classificado.
|
|
70
|
+
|
|
71
|
+
**A divisão de conhecimento não muda:** o forge diz ONDE a expressão mora e a linguagem entra como
|
|
72
|
+
função (`CompileNode`) — nenhum dos dois importa o outro. O tradutor devolve a forma e, quando tem
|
|
73
|
+
o que dizer, os diagnósticos e as ilhas do nó; o forge os **reancora** no caminho do slot
|
|
74
|
+
(`updates.somar.set.n`, `state.n.init`), e é assim que um erro de sintaxe num `set` chega à
|
|
75
|
+
autoria com endereço. O port do binder fala só `DefNode`, e `compileNodeOf(binder)` é o tradutor
|
|
76
|
+
estável dele — a memória do `compileDefinition` é por definição **e** por tradutor, então uma
|
|
77
|
+
arrow nova por render nunca a acertaria.
|
|
78
|
+
|
|
79
|
+
> [!WARNING]
|
|
80
|
+
> **O runtime assume a definição COMPILADA.** O `applyUpdate` e o `bindPayload` só ligam; quem
|
|
81
|
+
> compila é o `defineComponent`, uma vez, na entrada do registry. Um `set` que chegue cru vira o
|
|
82
|
+
> próprio texto no estado, porque o binder trata string como dado (ADR-111).
|
|
83
|
+
|
|
84
|
+
### Definição e IR
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import {
|
|
88
|
+
createComponentDefRegistry,
|
|
89
|
+
compileComponent,
|
|
90
|
+
buildSystemIR,
|
|
91
|
+
} from "@loom-forge/forge";
|
|
92
|
+
|
|
93
|
+
const defs = createComponentDefRegistry().register(userCard);
|
|
94
|
+
const ir = compileComponent(userCard, { components: defs.names() });
|
|
95
|
+
const system = buildSystemIR([ir]); // grafo: outgoing/incoming, tags, ciclos, missing-deps
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
O IR é **derivado, nunca editado à mão, nunca persistido como verdade** — recomputado do source.
|
|
99
|
+
E ele carrega só a árvore **normalizada**: o que se calcula sobre ela (arestas, deps, tags,
|
|
100
|
+
ciclos) é artefato **lateral** — `deriveFacts` / `factsOf` / `nodeFactsOf` —, com as camadas
|
|
101
|
+
transitivas opt-in. O binder deriva, o checker julga.
|
|
102
|
+
A unidade do grafo é a **definição** (`UserCard`), não o nó-instância; as arestas são induzidas por
|
|
103
|
+
nós dentro da árvore (`type` custom → `uses`; uma intenção → `actions`, com o id que ela
|
|
104
|
+
EXECUTA — ver [Comando](#comando)). **Containment não é dependência.** Ver
|
|
105
|
+
[docs/IR.md](./docs/IR.md).
|
|
106
|
+
|
|
107
|
+
### Grafo
|
|
108
|
+
|
|
109
|
+
O motor é o **[`@tslite/graph`](https://www.npmjs.com/package/@tslite/graph)** — ponto-fixo
|
|
110
|
+
monótono por worklist, Tarjan iterativo, zero dependências. Não o reexportamos: quem precisa das
|
|
111
|
+
primitivas importa de lá, direto. O que o `buildSystemIR` faz é montar o grafo do DOMÍNIO (quais
|
|
112
|
+
são os nós, quais arestas contam) e instanciar os reticulados:
|
|
113
|
+
|
|
114
|
+
| fato | de onde vem | o quê |
|
|
115
|
+
| ---------------- | --------------------------- | --------------------------------------------- |
|
|
116
|
+
| `facts.tags` | `propagateUnion` | tags efetivas/transitivas, canônicas |
|
|
117
|
+
| `facts.incoming` | `invertEdges` | "quem usa isto" — por onde o dirty sobe |
|
|
118
|
+
| `facts.cycles` | `detectCycles` | componentes fortemente conexos |
|
|
119
|
+
| `computeUnable` | `reachable` (em `rules.ts`) | availability transitivo (o Razor das Actions) |
|
|
120
|
+
|
|
121
|
+
A Linguagem não tem ciclo — o call graph dela é um DAG —, mas o grafo de quem a consome tem: um
|
|
122
|
+
componente compõe outro que o compõe de volta. Sobre esse grafo não existe ordem topológica,
|
|
123
|
+
existe ponto-fixo; e essa peça, que todo consumidor não-trivial acabava **copiando**, desceu para o
|
|
124
|
+
TSLite. Este package copiava uma versão quadrática dela (1000 nós em 58 ms, 8000 em 4,7 s), e a
|
|
125
|
+
cópia morreu quando o dono publicou a peça.
|
|
126
|
+
|
|
127
|
+
### Regras e slots
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import {
|
|
131
|
+
checkSlots,
|
|
132
|
+
checkCategoryPolicy,
|
|
133
|
+
computeUnable,
|
|
134
|
+
checkForbiddenTags,
|
|
135
|
+
checkIdentity,
|
|
136
|
+
} from "@loom-forge/forge";
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`checkIdentity` acusa um id repetido no documento de um componente (`duplicate-id`, com os endereços).
|
|
140
|
+
O id é a identidade do nó — é por ele que o editor muta, que o patch acha o alvo e que a tela liga o
|
|
141
|
+
foco ao documento —, e dois nós com o mesmo id fazem os três apontarem para o primeiro, sem erro
|
|
142
|
+
nenhum. Componentes diferentes repetem id à vontade: a expansão os separa por instância.
|
|
143
|
+
|
|
144
|
+
`checkSlots` é o que o React não tem: o **pai declara o contrato** do que aceita como children
|
|
145
|
+
(categoria, tags, leaf, cardinalidade). Composição ≠ dependência — A não conhece B, só o contrato,
|
|
146
|
+
então isso NÃO cria aresta A→B, e a checagem mora no compositor (quem pôs B dentro de A).
|
|
147
|
+
|
|
148
|
+
O `computeUnable` aceita a **porta de ação** e pergunta a ela, em vez de exigir a lista de
|
|
149
|
+
indisponíveis pronta:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
computeUnable(sys, { port });
|
|
153
|
+
// ação indisponível → quem a invoca é fonte, e todo dependente herda `unable`
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Ele pergunta só pelo que o grafo de fato invoca — o eixo `actions` tem só o que SAI, porque update
|
|
157
|
+
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 é
|
|
158
|
+
saber que não.** E é um RETRATO: disponibilidade muda em runtime, e isto é o relógio lento — quem
|
|
159
|
+
quiser acompanhar, recomputa.
|
|
160
|
+
|
|
161
|
+
### Expansão
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { resolveComponentTree } from "@loom-forge/forge";
|
|
165
|
+
|
|
166
|
+
resolveComponentTree(node, { registry, binder, componentDefs: defs }, data);
|
|
167
|
+
// bind (dados) → expand (custom → árvore pura) → resolveTree (compositor)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
A expansão é um **pré-passo**, e é isso que mantém o compositor puro: o `resolveTree` do core não
|
|
171
|
+
sabe o que é um `ComponentDef`. Cada nó de tipo custom vira o corpo do componente ligado contra as
|
|
172
|
+
props, com ids namespaced por instância; os children do call-site são injetados onde o corpo
|
|
173
|
+
declara `{ "type": "$slot" }` — resolvidos no escopo do COMPOSITOR, opacos para o componente.
|
|
174
|
+
|
|
175
|
+
Ela roda **sempre**, mesmo sem `componentDefs`: é também onde as diretivas de escopo (`provides`,
|
|
176
|
+
`commands`) são consumidas. `bodyOf(def)` é o corpo com o açúcar do componente — o `provides` e o
|
|
177
|
+
`commands` dele juntam com os da raiz, e a raiz ganha no mesmo nome —, e é o que o boundary de um
|
|
178
|
+
componente vivo expande, com `updates` dizendo quais nomes resolvem ali dentro.
|
|
179
|
+
|
|
180
|
+
Cada nó sai com a **origem** (`origins`): o componente e o id que o autor deu, do mais de fora para o
|
|
181
|
+
mais de dentro. O `bodyOf` carimba antes do bind, então a cópia de um `each` preserva o id autorado; a
|
|
182
|
+
raiz de um corpo responde também pela chamada que ocupa; e a árvore do host não tem origem. É o que
|
|
183
|
+
liga a tela de volta ao documento (ADR-017 do repo).
|
|
184
|
+
|
|
185
|
+
### Contexto
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { deriveContext, checkContexts } from "@loom-forge/forge";
|
|
189
|
+
|
|
190
|
+
const ctx = deriveContext(sys.components.values());
|
|
191
|
+
checkContexts(sys, ctx, { ambient: ["@acme/auth"] });
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`uses` não é atributo, é **dívida**: ela sobe pelo grafo até alguém prover, e quem exige sem
|
|
195
|
+
declarar está mentindo — erro, no nó por onde escapou. Qualquer nó provê (`provides`), o
|
|
196
|
+
`ComponentDef.provides` é açúcar para a raiz do corpo, e o contrato tem de ser dado serializável.
|
|
197
|
+
O que está provido num ponto é o que ele enxerga depois de MONTADO: um filho de slot enxerga o que o
|
|
198
|
+
chamado provê no `$slot`. Ver [docs/CONTEXT.md](./docs/CONTEXT.md).
|
|
199
|
+
|
|
200
|
+
### Comando
|
|
201
|
+
|
|
202
|
+
```jsonc
|
|
203
|
+
{
|
|
204
|
+
"component": "FormSection",
|
|
205
|
+
"commands": {
|
|
206
|
+
"submeter": { "action": "form/submit", "params": { "canal": "email" } },
|
|
207
|
+
"limpar": "form/clear",
|
|
208
|
+
},
|
|
209
|
+
"body": {
|
|
210
|
+
"id": "sec",
|
|
211
|
+
"type": "Section",
|
|
212
|
+
"children": [{ "id": "slot", "type": "$slot" }],
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Um nó declara o **vocabulário** da subárvore — `nome local → ação`. Um botão dispara `submeter` e
|
|
218
|
+
não sabe o que isso executa: quem decide é o lugar onde ele monta. A resolução tem três degraus,
|
|
219
|
+
**update local → vocabulário mais próximo → porta raiz**, e segue as regras do `provides`: o nó
|
|
220
|
+
instala para baixo, o corpo do chamado é a subárvore do nó de chamada, e o filho de slot enxerga o
|
|
221
|
+
vocabulário do `$slot` onde monta. A expansão consome a diretiva, então a view recebe o id. Os
|
|
222
|
+
`params` do alias são aplicação parcial: entram por baixo dos do disparo. E o efeito resolve pelo
|
|
223
|
+
vocabulário de onde o componente montou.
|
|
224
|
+
|
|
225
|
+
A mesma pergunta tem resposta sem rodar:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { mountingOf, settledRoute } from "@loom-forge/forge";
|
|
229
|
+
|
|
230
|
+
buildSystemIR(irs, { commands: host }); // o eixo `actions` já sai com o id resolvido
|
|
231
|
+
const mounting = mountingOf(sys.components.values(), { host }); // o que cada um instala no slot
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Um nome que nenhum vocabulário do sistema declara só pode chegar à porta, e é aresta de quem o
|
|
235
|
+
escreveu. Um que algum vocabulário declara depende de onde o componente monta: ele **escapa**, como
|
|
236
|
+
o `uses` de um contexto, e vira aresta de quem o resolve — na borda, é o vocabulário do host
|
|
237
|
+
(`commands`) que responde. `routeIntent`/`settledRoute` são a resposta para UMA intenção, e é delas
|
|
238
|
+
que o `check` e o `eject` perguntam. Ver o ADR-015 do repo.
|
|
239
|
+
|
|
240
|
+
### Efeito
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
import { checkEffects } from "@loom-forge/forge";
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Um efeito é um **events-out cujo gatilho é uma mudança**, não um gesto: mesma `action`, mesmos
|
|
247
|
+
`params`, mesma aresta no grafo, mesma tipagem — e a mesma cadeia: `then`/`catch` valem num efeito
|
|
248
|
+
(e no `cleanup` dele), e é assim que "carregar no mount" guarda o que carregou (ADR-018 do repo). O
|
|
249
|
+
`event` do efeito em si é `undefined`; o do `then`, o que a ação devolveu. Declarar efeito torna o componente **vivo**, e
|
|
250
|
+
infere a tag `effectful`, que propaga. E o laço — observar o que a própria ação escreve — se
|
|
251
|
+
**prova sem rodar**, porque os dois lados são declarados. Ver
|
|
252
|
+
[docs/STATE.md §7](./docs/STATE.md).
|
|
253
|
+
|
|
254
|
+
### Estado local
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
import {
|
|
258
|
+
isLive,
|
|
259
|
+
initState,
|
|
260
|
+
applyUpdate,
|
|
261
|
+
STATEFUL_TYPE,
|
|
262
|
+
} from "@loom-forge/forge";
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Componente **puro** (sem `updates` nem `effects`) expande estaticamente. Componente **vivo** não expande: emite
|
|
266
|
+
o marcador `$stateful` (dado, headless) e quem o materializa é a view —
|
|
267
|
+
[`@loom-forge/forge-react`](../forge-react/README.md). O estado é sempre **local à instância**;
|
|
268
|
+
100 `<Counter/>` são 100 escopos isolados. Nunca global. Ver [docs/STATE.md](./docs/STATE.md).
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Os vizinhos
|
|
273
|
+
|
|
274
|
+
O forge **depende do compositor**, nunca o contrário. Ele não conhece React (a view é o
|
|
275
|
+
[`@loom-forge/forge-react`](../forge-react/README.md)), não conhece a linguagem de expressão (o
|
|
276
|
+
binder entra pelo port `BindingEngine`, implementado pelo
|
|
277
|
+
[`@loom-forge/tslite`](../tslite/README.md)) e não conhece a camada de tipos (que é o
|
|
278
|
+
[`@loom-forge/check`](../check/README.md), e depende DELE, não o contrário).
|