@loom-forge/core 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 +338 -0
- package/dist/index.d.ts +552 -0
- package/dist/index.js +442 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# @loom-forge/core
|
|
2
|
+
|
|
3
|
+
**loom** · o **kernel headless**. Zero React, zero ambiente, **zero dependências**. Transforma
|
|
4
|
+
`definição (+ dados)` numa `ResolvedNode` (dados puros) via `bind → resolve → plan`. É o contrato
|
|
5
|
+
que todos os outros packages falam.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @loom-forge/core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import {
|
|
13
|
+
createDescriptorRegistry,
|
|
14
|
+
createTypeResolver,
|
|
15
|
+
resolveTree,
|
|
16
|
+
fragmentPlanner,
|
|
17
|
+
sectionPlanner,
|
|
18
|
+
gridFormPlanner,
|
|
19
|
+
defaultPlanners,
|
|
20
|
+
walk,
|
|
21
|
+
findById,
|
|
22
|
+
pathToId,
|
|
23
|
+
mapNodes,
|
|
24
|
+
identityBinder,
|
|
25
|
+
noopValidator,
|
|
26
|
+
} from "@loom-forge/core";
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Modelo
|
|
32
|
+
|
|
33
|
+
### `DefNode` — a unidade da definição
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
interface DefNode {
|
|
37
|
+
id: string; // identidade ESTÁVEL (keys, endereço, edição). Nunca posicional.
|
|
38
|
+
type?: string; // discriminador → registry (ausente quando `el` dá a tag)
|
|
39
|
+
el?: string; // tag intrínseca — porta de emergência, não caminho (ADR-012)
|
|
40
|
+
props?: Record<string, unknown>;
|
|
41
|
+
layout?: string; // qual layout arruma os FILHOS deste nó (default: o do engine)
|
|
42
|
+
children?: DefNode[];
|
|
43
|
+
placement?: unknown; // posição DESTE nó no layout do pai — opaca pro core
|
|
44
|
+
events?: Record<string, EventBinding>; // events-out: nomeDaProp → intenção
|
|
45
|
+
fallback?: "drop" | DefNode; // forward-compat: type desconhecido → isto ("drop" = nada)
|
|
46
|
+
requires?: Record<string, string>; // gate de capability: `cap → versão` ou `cap → "*"` (presença)
|
|
47
|
+
version?: string;
|
|
48
|
+
|
|
49
|
+
// as DIRETIVAS — consumidas no caminho, nunca chegam à view
|
|
50
|
+
each?: unknown; // repete o nó por item (ou re-escopa, se objeto)
|
|
51
|
+
as?: string; // o nome que o `each` liga para o item
|
|
52
|
+
when?: unknown; // dropa o nó se falsy
|
|
53
|
+
provides?: Record<string, unknown>; // contextos para a SUBÁRVORE
|
|
54
|
+
commands?: Record<string, CommandAlias>; // vocabulário de comando da SUBÁRVORE
|
|
55
|
+
|
|
56
|
+
// de onde o nó veio, de fora para dentro — escrito pela expansão, nunca de autoria (ADR-017)
|
|
57
|
+
origins?: readonly NodeOrigin[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface NodeOrigin {
|
|
61
|
+
component: string; // o componente cujo documento escreveu o nó
|
|
62
|
+
id: string; // o id que o autor deu — o resolvido é outro
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface EventBinding {
|
|
66
|
+
action: string;
|
|
67
|
+
// os params da ação — e é POR AQUI que entra tudo o que a intenção recebe: a raiz `event` é
|
|
68
|
+
// o valor recebido (o que o widget emitiu; o `data` da ação anterior num `then`; o `error`
|
|
69
|
+
// num `catch`), e o autor escreve o mapeamento. Avaliados no DESPACHO, não no render.
|
|
70
|
+
params?: unknown;
|
|
71
|
+
// o que roda quando esta intenção RESOLVE: `success: true` no host (`event` = `data`), ou o
|
|
72
|
+
// estado escrito num update local (`event` = `undefined`)
|
|
73
|
+
then?: EventBinding;
|
|
74
|
+
// o que roda quando o despacho ao host FALHA (`event` = `error`: `{ code, message?, data? }`)
|
|
75
|
+
catch?: EventBinding;
|
|
76
|
+
// os params que o autor deixa para a camada de COMANDO preencher (token, default, drawer)
|
|
77
|
+
resolve?: readonly string[];
|
|
78
|
+
// update do componente que escreveu a intenção — marca da RESOLUÇÃO, nunca de autoria (ADR-016)
|
|
79
|
+
local?: true;
|
|
80
|
+
// o escopo do render em que o nó foi ligado — escrito pelo `bind`, nunca de autoria
|
|
81
|
+
scope?: Readonly<Record<string, unknown>>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// o que um nome de comando executa — a forma longa é a aplicação PARCIAL
|
|
85
|
+
type CommandAlias = string | { action: string; params?: unknown };
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Tudo o que uma intenção recebe entra pelo `params`, e o autor vê o que entra** (ADR-018 do
|
|
89
|
+
repo). O valor que o widget emite é a raiz `event` dos `params` — `{ campo: "cpf", valor:
|
|
90
|
+
"{{ event }}" }`, um sub-campo (`{{ event.valido }}`), uma transformação —, e o componente
|
|
91
|
+
registrado emite **valor**, nunca o evento do DOM, e declara o que emite em
|
|
92
|
+
`descriptor.meta.emits` (é o tipo de `event` no `check`; sem declaração, `unknown`).
|
|
93
|
+
|
|
94
|
+
O que a ação **devolve** é a próxima intenção: `then` roda no sucesso com `event` = `data`
|
|
95
|
+
(tipado pelo `resultOf` da porta), `catch` roda na falha com `event` = `error`. Um update local
|
|
96
|
+
também resolve (o estado foi escrito), então a cadeia `iniciar (local) → host → then/catch
|
|
97
|
+
(local)` é um mecanismo só, lida como uma promise chain. Sem `catch`, a falha vai só ao
|
|
98
|
+
`onActionResult` do host — e o host recebe TODO resultado, com ou sem continuação.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
runIntent(binding, event, runtime); // executa a intenção e a cadeia dela; nunca devolve nada
|
|
102
|
+
intentParams(binder, binding, event); // os params avaliados no despacho: escopo do render + event
|
|
103
|
+
intentsOf(binding); // a intenção e cada elo da cadeia, para quem roteia ou desenha grafo
|
|
104
|
+
dispatchIntent(port, id, params, onResult); // o despacho de UMA ação ao host, normalizado
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
O `IntentRuntime` é o que quem executa (a view, o boundary) entrega ao `runIntent`: `params`
|
|
108
|
+
(o binder avaliando sobre `scope` + `event`), `apply` (o update local do componente vivo), a
|
|
109
|
+
`port`, o `onResult` e o `pending` — `true` do despacho ao resultado, que o boundary expõe como
|
|
110
|
+
raiz `pending[ação]` do escopo de um componente vivo.
|
|
111
|
+
|
|
112
|
+
### `Descriptor` — metadado headless de um `type` (NUNCA contém o componente)
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
interface Descriptor {
|
|
116
|
+
type: string;
|
|
117
|
+
defaultProps?: Record<string, unknown>;
|
|
118
|
+
schema?: unknown; // JSON Schema / Schema do TSLite — validação & form-builder
|
|
119
|
+
layout?: string; // layout default pros filhos deste type
|
|
120
|
+
meta?: Record<string, unknown>; // pra editores (label/categoria/ícone)
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### `ResolvedNode` — a saída (dados puros, agnóstica de ambiente)
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
type ResolvedNode =
|
|
128
|
+
| {
|
|
129
|
+
kind: "node";
|
|
130
|
+
id;
|
|
131
|
+
type;
|
|
132
|
+
props;
|
|
133
|
+
events?;
|
|
134
|
+
layout?: ResolvedLayout;
|
|
135
|
+
children: ResolvedNode[];
|
|
136
|
+
origins?; // a origem atravessa até a view — é o que liga a tela ao documento
|
|
137
|
+
}
|
|
138
|
+
| { kind: "drop"; id }
|
|
139
|
+
| { kind: "unknown"; id; type };
|
|
140
|
+
|
|
141
|
+
interface ResolvedLayout {
|
|
142
|
+
name: string;
|
|
143
|
+
plan: unknown;
|
|
144
|
+
} // plan pareado com o renderer por `name`
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Registry de descriptors
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
createDescriptorRegistry(initial?: Descriptor[]): DescriptorRegistry
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
| Método | Descrição |
|
|
156
|
+
| ----------------------------------------- | ------------------------------------------- |
|
|
157
|
+
| `register(descriptor)` / `register(type)` | adiciona (forma completa ou nu). Encadeável |
|
|
158
|
+
| `get(type)` · `has(type)` · `list()` | leitura |
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const registry = createDescriptorRegistry()
|
|
162
|
+
.register({ type: "Page", layout: "section" })
|
|
163
|
+
.register("Field");
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Resolver — estratégia de resolução
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
createTypeResolver(options?: { capabilities?: Record<string, string> }): Resolver
|
|
172
|
+
|
|
173
|
+
interface Resolver { resolve(node: DefNode, ctx: { registry: DescriptorRegistry }): ResolveResult }
|
|
174
|
+
type ResolveResult =
|
|
175
|
+
| { kind: "render"; descriptor: Descriptor; props: Record<string, unknown> }
|
|
176
|
+
| { kind: "drop" }
|
|
177
|
+
| { kind: "unknown"; node: DefNode };
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
- **Mescla** `descriptor.defaultProps` SOB `node.props`.
|
|
181
|
+
- **`requires`**: `{ feat: "*" }` (presença) ou `{ feat: "1.2" }` (versão mínima), contra `capabilities`.
|
|
182
|
+
- **`fallback`**: `"drop"` (nada) ou outro nó (resolvido no lugar); senão `unknown`.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Pipeline
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
resolveTree(node: DefNode, opts: ResolveTreeOptions, data?: unknown): ResolvedNode
|
|
190
|
+
|
|
191
|
+
interface ResolveTreeOptions {
|
|
192
|
+
registry: DescriptorRegistry;
|
|
193
|
+
resolver?: Resolver; // default: createTypeResolver()
|
|
194
|
+
binder?: BindingEngine; // default: identityBinder
|
|
195
|
+
planners?: Record<string, LayoutPlanner>; // default: defaultPlanners()
|
|
196
|
+
defaultLayout?: string; // default: "fragment"
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`bind` (pré-passo) → `resolve` (type→descriptor+props) → `plan` (layout). Nós `fallback:"drop"` somem
|
|
201
|
+
da árvore E do plano; `unknown` permanece (a view decide a degradação).
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Layout — planners (metade headless do _arrange_)
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
interface PlannerChild {
|
|
209
|
+
id: string;
|
|
210
|
+
placement?: unknown;
|
|
211
|
+
}
|
|
212
|
+
interface LayoutPlanner {
|
|
213
|
+
name: string;
|
|
214
|
+
plan(children: PlannerChild[]): unknown; // dados do plano
|
|
215
|
+
placementSchema?: unknown; // a FORMA do placement que ele lê, em JSON Schema
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
| Planner | Placement | Plan |
|
|
220
|
+
| -------------------------------------- | ----------------------------------------------------- | ------------------------------------- |
|
|
221
|
+
| `fragmentPlanner()` | `{ index? }` | `{ order: string[] }` |
|
|
222
|
+
| `sectionPlanner({ direction?, gap? })` | `{ index? }` | `{ direction, gap?, order }` |
|
|
223
|
+
| `gridFormPlanner({ columns?, gap? })` | `{ span?: number \| ResponsiveSpan, break?, index? }` | `{ columns, gap, responsive, cells }` |
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
interface ResponsiveSpan {
|
|
227
|
+
base: number;
|
|
228
|
+
sm?: number;
|
|
229
|
+
md?: number;
|
|
230
|
+
lg?: number;
|
|
231
|
+
xl?: number;
|
|
232
|
+
}
|
|
233
|
+
interface GridFormCell {
|
|
234
|
+
id;
|
|
235
|
+
span: number;
|
|
236
|
+
spans: FilledSpans;
|
|
237
|
+
break?;
|
|
238
|
+
} // span = base (back-compat/inline)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`defaultPlanners()` → `{ fragment, section, "grid-form" }`. O renderer correspondente (view) vive no
|
|
242
|
+
runtime; os dois são pareados por `name`.
|
|
243
|
+
|
|
244
|
+
O `placementSchema` é o contrato do filho no eixo de arrumação — o que o `descriptor.schema` é no de
|
|
245
|
+
props. Um editor monta os campos de posição a partir dele sem saber que layout é, e um layout que não
|
|
246
|
+
o publica deixa quem edita com o objeto cru. Os três planners daqui publicam.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Tree — utilitários puros (pro core e pro editor)
|
|
251
|
+
|
|
252
|
+
| Função | Descrição |
|
|
253
|
+
| ---------------------------- | ------------------------------------------------- |
|
|
254
|
+
| `walk(node, visit, parent?)` | pré-ordem; `visit(node, parent)` (`null` na raiz) |
|
|
255
|
+
| `findById(root, id)` | primeiro nó com o id |
|
|
256
|
+
| `pathToId(root, id)` | caminho de ids até o alvo, ou `null` |
|
|
257
|
+
| `mapNodes(node, fn)` | mapeia imutável bottom-up |
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Ports — os seams (DI)
|
|
262
|
+
|
|
263
|
+
O core depende destas **interfaces**, nunca de uma lib — é por isso que ele não tem dependência
|
|
264
|
+
nenhuma. Cada adapter é um package à parte, e quem não usa um não paga por ele.
|
|
265
|
+
|
|
266
|
+
| Port | Contrato | Adapter |
|
|
267
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
268
|
+
| `BindingEngine` | `compile(node) → DefNode` (opcional) · `bind(node, scope) → DefNode` | [`@loom-forge/tslite`](../tslite/README.md) · default `identityBinder` |
|
|
269
|
+
| `ValidationProvider` | `validate(schema, value) → Issue[] \| null` | AJV/Zod (DI) · default `noopValidator` |
|
|
270
|
+
| `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) |
|
|
271
|
+
| `AddressPort` | `diff(a, b)` · `patch(base, diff)` | [`@loom-forge/address`](../address/README.md) |
|
|
272
|
+
| `TypeChecker` | `subtype(a, b): boolean` | [`@loom-forge/check`](../check/README.md) |
|
|
273
|
+
| `CapabilityPort` | `get(name)` · `has(name)` | escape imperativo (host) |
|
|
274
|
+
|
|
275
|
+
### A porta de ação, sem adapter
|
|
276
|
+
|
|
277
|
+
O `ActionPort` é a projeção **estrutural** do Tier 0 do `@node-actions/protocol` (ADR-114): um
|
|
278
|
+
runtime que cumpra o contrato — o commander, a `lite`, o engine de diretivas via
|
|
279
|
+
`toProtocolRuntime` — é assignável a ele como está, e um objeto do host com `invoke` também. O
|
|
280
|
+
core não importa o protocolo (ADR-105), e não há package de adapter (ADR-115):
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
createEngine({
|
|
284
|
+
actions: commander, // ou { invoke(id, params) { … } }
|
|
285
|
+
onActionResult: (id, result) => {
|
|
286
|
+
if (!result.success) toast(result.error?.code);
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- **`invoke` devolve ENVELOPE, e falha de domínio é DADO:** `{ success: false, error: { code } }`
|
|
292
|
+
volta pelo `onActionResult` e pelo `catch` do documento — nunca como exceção. `void` é sucesso.
|
|
293
|
+
- **Um runtime que LANÇA não derruba o clique:** o `dispatchIntent` converte o `throw` e a rejeição
|
|
294
|
+
em `{ success: false, aborted: true, abortedBy: "throw", error: { code: "UNHANDLED_ERROR" } }` —
|
|
295
|
+
bug de programa, não falha de domínio, e o `catch` é dele.
|
|
296
|
+
- **O Tier 1 é lido só quando existe** (`isAvailable`, `paramsOf`, `resultOf`, `metadataOf`): o
|
|
297
|
+
`check` tipa o call-site pelo `paramsOf` e o `event` de um `then` pelo `resultOf`; o grafo lê o
|
|
298
|
+
`isAvailable`. Quem não publica não afirmou nada, e isso é silêncio, não erro.
|
|
299
|
+
- **Discrimine por `error.code`, nunca por regex na mensagem.**
|
|
300
|
+
|
|
301
|
+
Ver [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) para o porquê de cada um, e
|
|
302
|
+
[docs/DECISIONS.md](./docs/DECISIONS.md) para os ADRs — os do caminho de efeito são o ADR-103,
|
|
303
|
+
104, 114 e 115.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Escopo de comando
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
import {
|
|
311
|
+
commandScope,
|
|
312
|
+
resolveIntent,
|
|
313
|
+
aliasOf,
|
|
314
|
+
EMPTY_COMMANDS,
|
|
315
|
+
} from "@loom-forge/core";
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
| Função | Descrição |
|
|
319
|
+
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
320
|
+
| `commandScope(herdado, nó)` | o vocabulário em vigor ABAIXO do nó — o herdado, com o `commands` dele por cima (o mais próximo ganha) |
|
|
321
|
+
| `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 |
|
|
322
|
+
| `aliasOf(alias)` | a forma curta normalizada: `"form/clear"` → `{ action: "form/clear" }` |
|
|
323
|
+
|
|
324
|
+
É a resposta de RUNTIME para "que ação este nome executa?", e quem a aplica é a expansão do
|
|
325
|
+
[`@loom-forge/forge`](../forge/README.md). A resposta sem rodar — que precisa saber onde o componente
|
|
326
|
+
monta — é do forge também. O que viaja é nome, nunca função (ADR-015 do repo).
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## A tabela de slots
|
|
331
|
+
|
|
332
|
+
O core também exporta a **forma do `DefNode` como dado** — `FIELDS` (o papel de cada campo),
|
|
333
|
+
`SLOTS` (onde uma expressão pode morar, e o que o slot espera) e `TREE` (por onde a travessia
|
|
334
|
+
desce). É uma tabela exaustiva por tipo, e é o que faz a compilação, o bind e o typecheck
|
|
335
|
+
concordarem sobre onde cabe código sem nenhum deles farejar texto. `events` e `commands` são mapas
|
|
336
|
+
de intenções: o que compila é o `params` de cada entrada (shape `intentParams`). E cada slot declara
|
|
337
|
+
`directives` — se `{% %}` e `{# #}` são marcação ali, além do `{{ }}`; hoje é `false` em todos, e a
|
|
338
|
+
compilação pergunta à tabela em vez de a um padrão.
|