@loom-forge/tslite
v0.4.0
Published
loom · o adapter de BIND via TSLite: compile() (expressão em texto → ilha #expr) e o binder que a executa, gateado por CapabilityProfile, eval-free, com a stdlib do @tslite/std no escopo.
Maintainers
Readme
@loom-forge/tslite
loom · a linguagem de expressão da definição. Duas fases, e elas são dois relógios: a
compilação traduz o texto autorado ("{{ props.nome }}") numa ilha #expr, e o bind
executa o que ela produziu contra um escopo. Implementa o port BindingEngine do
@loom-forge/core.
pnpm add @loom-forge/tsliteOs @tslite/* que ele usa são 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+ os instalam sozinhos; uma faixa que não cruza vira aviso na instalação, e não uma segunda cópia.
import { compile, createTsliteBinder } from "@loom-forge/tslite";
const { def, diagnostics } = compile(autorada); // texto → ilha #expr. NÃO avalia.
const engine = createEngine({
binder: createTsliteBinder({ globals: { upper } }),
});Quem só consolida (um servidor que compila a definição no save) importa o subpath, que não
carrega o check nem a stdlib de runtime — só o que interpreta a string e a lista do que o deploy
recusaria:
import { compile } from "@loom-forge/tslite/compile";compile(def) — a fase de compilação
compile(root: DefNode, opts?: { profile?: CapabilityProfile }):
{ def: DefNode; diagnostics: CompileDiagnostic[]; islands: CompiledIsland[] }A definição autorada carrega a expressão como texto e é o SoT — é o que o autor edita, o
canvas desenha e o editor diffa. A compilada carrega o AST como ilha #expr no lugar, e é
derivada. A forma é a mesma dos dois lados, então o endereço (#0.1.props.title) vale para ambos
e não há source map.
- não avalia — não tem dados e não computa valor;
- não lança — o campo que não compila fica com o texto original e sai um diagnóstico, com o
endereço do campo, o caminho como lista (
path, índice e chave distintos), o texto do campo (source) e o trecho culpado nele (span). Os códigos são os do TSLite:parse-error·lex-error·island-not-a-value·unserializable-expression·node-not-allowed·forbidden-identifier·dynamic-computed; e o nossomissing-binding; - quem interpreta a string é o
parseValuedo@tslite/template, com odirectivesque o slot declara (SLOTSdo core; hojefalseem todos —{%e{#são texto). Só string é ambígua;{{ e }}ocupando a string inteira preserva o tipo dee; texto com ilha rende string; o que não parseia, não rende valor ({{ }}) ou não volta pelofromJsondo@tslite/jsonfica texto. A string é derivada uma vez: a forma serializada vai paradef— a ilha é o nó como está, normalizada pelo@tslite/estree(sem posição e sem o type-node sintático; oSchemade cada anotação fica) —, e asislandssaem em memória, comlocno texto do campo — é o que ochecktipa com span, sem reparsear; - com o
profilede quem executa, acusa o que o bind recusaria — pela mesma lista que ocompiledo@tslite/interpretusa para recusar (compileViolations): nó fora do profile e o que o sanitize barra, com a precedência de lá. Semprofile, isso não é julgado: quem só traduz ou tipa não executa. Uma bancada compila com oDEFAULT_PROFILE, que é o do binder, e a recusa aparece no campo, e não no console; - onde cabe expressão é a tabela de slots (
FIELDS/SLOTS/TREE, exportada pelo core):props,events[*].params,placement,each,when. Campo fora dela não tem porta.
A marca da ilha (EXPR_MARKER) e o predicado (isExprMarker) são do @tslite/ast, e é de lá
que o @loom-forge/check e o eject os importam — a pergunta "isto é uma
ilha?" tem uma resposta só, e ela é do dono do marcador.
createTsliteBinder(opts?) — o bind
import { createTsliteBinder } from "@loom-forge/tslite";
const engine = createEngine({
binder: createTsliteBinder({ globals: { upper } }),
});Expande a definição contra data: {{expr}} em strings (parser TS-like) e ilhas #expr (serializável).
Pipeline 100% TSLite, sem new Function: a fase compile() traduz o texto em ilha #expr;
o bind faz fromJson → compile (gateado pelo
CapabilityProfile + sanitize) → runner.
Os params de uma intenção não são avaliados no render. Um evento (events.onX), um efeito
e cada elo da cadeia then/catch (ADR-018 do root) saem do bind com o params compilado
e o escopo do render guardado no binding; quem avalia é o despacho, sobre esse escopo mais a raiz
event — o valor emitido pelo widget, o data de um then, o error de um catch. É o
intentParams do @loom-forge/core, ligando um nó-fantasma pelo mesmo binder. A compilação
endereça cada elo pelo caminho dele (#0.events.onClick.then.params.valido).
interface TsliteBinderOptions {
globals?: Record<string, unknown>; // helpers no escopo
profile?: CapabilityProfile; // o que a definição pode programar (default: DEFAULT_PROFILE)
boundary?: { runtime }; // a metade de runtime de um `createBoundary` do @tslite/validate
budget?: Budget | false; // custo por avaliação: { steps?, depth?, size? } (default: EXPRESSION_BUDGET)
onError?(error, source); // falhou ao EXECUTAR (unknown-name, host lançou, orçamento estourado)
onReject?(error, source); // recusada na COMPILAÇÃO (node-not-allowed, sanitize)
}Seguro por construção: o compile só vê o escopo passado — globais (fetch/process) não
existem. Política = dado: o CapabilityProfile é serializável e viaja com a definição. O
default, DEFAULT_PROFILE (PRESETS["pure-fp"]), libera expressão, chamada ao host e lambda —
inclusive com corpo em bloco — e fecha mutação, laço, throw e async.
Com lambda, uma expressão consegue não terminar, e por isso o bind vem com orçamento: o
EXPRESSION_BUDGET do @tslite/capability (steps 1 milhão · depth 256 · size 10 milhões)
entra quando nem a opção nem o profile trazem um. Os números foram medidos aqui (ADR-114) e
provados lá: um filter sobre 10 mil linhas custa ~90 mil passos, e uma recursão infinita para
em depth. Apertar ou afrouxar é trocar o
profile ou o orçamento, não ligar flags:
import { PRESETS } from "@tslite/capability";
createTsliteBinder({ profile: PRESETS.data }); // fecha a lambda: "JSON com lógica"
createTsliteBinder({ budget: { steps: 10_000, depth: 32 } }); // orçamento mais apertado
createTsliteBinder({ budget: false }); // sem contagem, e sem proteção contra recursão sem fimO compile também roda o sanitize fail-closed por default: a referência à autoridade ambiente
(eval, Function, process, globalThis…) é recusada. Nome de chave nenhum é: a Linguagem
não tem protótipo, um valor é dado e só tem as chaves que declara, e constructor ou __proto__
são nomes como quaisquer outros — ausentes, valem undefined. Ver
docs/ARCHITECTURE.
Diretivas de template (each / when)
Estruturais (mudam a árvore, não só valores) — tornam listas/condicionais declarativas no JSON:
{
"type": "List",
"children": [
// each array → repete o nó por item; o item ganha o nome do `as` (+ `$index`)
{
"type": "Row",
"each": "{{ data.users }}",
"as": "u",
"props": { "name": "{{ u.name }}" },
// when → dropa se falsy (por item dentro do each)
"when": "{{ u.active }}",
},
],
}
// each objeto re-escopa a subárvore: { "each": "{{ data.user }}", "as": "u", "props": { "n": "{{ u.name }}" } }- ids dos repetidos são estáveis:
row.${item.id ?? $index}. asé obrigatório — sem ele o item não teria nome, e a compilação acusamissing-binding.- as diretivas são consumidas (não aparecem na saída resolvida) e guardadas (mesma Linguagem do bind);
- o escopo do item estende o do pai — um
eachaninhado enxerga o item de fora, e a raizdatacontinua alcançável de qualquer profundidade.
A camada de tipos não está aqui — vive em
@loom-forge/check, para o bind de runtime nunca arrastar@tslite/checker/json-schema/infer. Ali é que se prova que umwhenrendebooleane que{{ item.nomee }}não existe na coleção.
Stdlib
O escopo carrega os 53 operadores do @tslite/std por default (length, map,
filter, prop, pick…). Não é conveniência: a Linguagem é FP e não tem protótipo, então
xs.length é erro e a forma é length(xs). Sob o profile default a composição point-free
funciona inteira — map(prop("nome"), xs), filter(propEq("ativo", true), xs) —, e a lambda
também: map((p) => p.nome, xs). A ordem é a do Ramda, com a função primeiro.
O mesmo vocabulário aparece tipado no @loom-forge/check (vanilla, as
assinaturas) — é o que faz o checker provar o que o executor vai rodar.
O asserter, quando há um
O check pode tipar {{ parseAs(Address, props.bruto) }} contra um catálogo de tipos nomeados.
Tipar é metade; provar é runtime, e runtime é aqui. As duas metades saem de uma
declaração — o createBoundary do @tslite/validate monta, do mesmo catálogo, o que o checker
precisa e o que o interpret precisa —, e o host entrega a metade certa a cada lado:
import { createBoundary } from "@tslite/validate";
import { vanilla } from "@tslite/std";
const boundary = createBoundary({
types: { Address },
reserved: Object.keys(vanilla),
});
createTsliteBinder({ boundary }); // a metade de runtime: o catálogo e o `parseAs`
buildEnv(def, { boundary }); // no `check`: a metade de tipos, do mesmo objetoNão vem embutido: quem não usa asserter não deve pagar o validador, e este package não o importa
— a opção recebe só o runtime da fronteira. Ver a fronteira do unknown no
@loom-forge/check.
