@loom-forge/eject
v0.4.1
Published
eject · a definição JSON traduzida para o componente React que ela descreve — o MAPEAMENTO (o que `each` significa em React); quem imprime é o @tslite/format.
Maintainers
Readme
@loom-forge/eject
A definição JSON traduzida para o componente React que ela descreve. Dev/CI e tooling — nunca no caminho de render.
pnpm add -D @loom-forge/ejectOs @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 { ejectComponent } from "@loom-forge/eject";
const { code, notes } = ejectComponent(def);O que é nosso, e o que não é
O printer não é daqui. Quem imprime é o @tslite/format,
o emissor de FONTE do TSLite: contrato invertido do printer de execução — preserva
tipo, preserva JSX, imprime comentário, quebra por largura. Aqui não há parser,
precedência nem quebra de linha.
O que é nosso é o mapeamento: o que each significa em React, o que vira
useState, de onde sai a key. Isso ninguém mais sabe.
Do @tslite/checker ele usa só a entry @tslite/checker/types — a álgebra de tipos, que é o que
imprime a declaração de props; o walk do AST, que é o resto do checker, não entra.
| no JSON | no TSX |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| props declaradas | type XProps = { … } + o parâmetro anotado |
| el: "div" | <div> · type: "Card" → <Card> |
| props.children | o FILHO, nunca um atributo |
| each + as | .map((item, index) => …) com key={item.id ?? index} |
| when | {cond && …} — por item quando há laço |
| $slot | uma ÁREA é uma prop de nó: {props.children} na default, {props.header} numa { name: "header" } |
| state | useState por chave, com o setter |
| updates[x].set | um useCallback que chama os setters — deps do que o set lê |
| updates[x].params | o parâmetro da função, tipado |
| effects[x] | useEffect, com on virando o array de dependências e cleanup no return |
| events → update local | a chamada da função |
| event nos params | o PARÂMETRO do handler: onChange={(event) => editar({ campo: "cpf", valor: event })} |
| events → ação do host | invoke("nome", params) |
| then / catch | async + const r = await invoke(...); !r.success roda o catch com r.error, !r.aborted o then com r.data |
| pending["x/y"] | um useState de pendentes, ligado e desligado em volta do await |
| apelido de commands | invoke("id", { ...ligados, ...passados }) — sai resolvido |
| texto com {{ }} no meio | template literal |
A key repete a identidade do motor (item.id ?? índice), e não é escolha de
estilo: é o que faz o TSX e a árvore resolvida concordarem sobre qual nó é qual.
Cada ÁREA do corpo vira uma prop de nó, que é como o React já as escreve. Um layout que abre
regiões sai com uma prop por região, e o tipo das props as declara como ReactNode opcional:
type ShellProps = { header?: ReactNode; children?: ReactNode };
export const Shell = (props: ShellProps) => (
<div>
{props.header}
{props.children}
</div>
);A default se chama children porque é o nome que o React lhe dá; uma nomeada
({ type: "$slot", props: { name: "header" } }) sai com o nome que o autor escreveu. Quem
preenche cada área é o call-site, pelo campo slot do filho — e isso não aparece aqui, porque no
TSX quem compõe é quem escreve o JSX.
O update é um useCallback, e as dependências saem do que o set LÊ — a regra do
exhaustive-deps, derivada da expressão (o freeVars do @tslite/behavior), não escrita à mão:
const alternar = useCallback(() => {
setAberto((aberto) => !aberto); // escreve o que lê → functional update, e a chave sai das deps
}, []);
const aplicar = useCallback(() => {
setTotal(count * props.fator); // lê OUTRA chave e um membro de props → os dois nas deps
}, [count, props.fator]);O setter é estável e params é argumento: nenhum dos dois entra. O functional update é a
forma que não perde um disparo quando dois entram no mesmo batch — e é a que o React Compiler
produziria. O useEffect continua com o on do documento como dependências: é o contrato do
modelo, não o do lint.
Uma intenção é uma promise chain, e o TSX lê como uma (ADR-018 do repo). O que a intenção
RECEBE é a raiz event dos params — o valor que o widget emitiu, o data da ação anterior num
then, o error num catch — e o autor escreveu o mapeamento; o handler emitido é a tradução
literal dele:
onChange={(event) => editar({ campo: "cpf", valor: event })}
onClick={async () => {
iniciar();
setPending((p) => ({ ...p, "corretor/validar-email": true }));
const r = await invoke("corretor/validar-email", { email: props.email });
setPending((p) => ({ ...p, "corretor/validar-email": false }));
if (!r.success) falhou({ motivo: r.error.code });
else if (!r.aborted) marcar({ valido: r.data.valido });
}}O then de um update local roda depois do set, com event = undefined — é o iniciar()
seguido do invoke acima. Um fluxo cancelado (aborted com sucesso) não roda nem then nem
catch, e é o !r.aborted que o diz. Uma intenção que não lê event sai como () => …, e uma
que não espera nada continua síncrona. O pending só existe num componente vivo — é o boundary
que o guarda no motor, e aqui é um useState do próprio componente, declarado quando alguma
expressão o lê; a partir daí todo despacho ao host o liga e desliga, como o motor faz.
Dentro de um useEffect, cuja função não pode ser async, a cadeia que espera roda numa IIFE
(void (async () => { … })()).
Ele consome a definição COMPILADA
A fase de compilação (@loom-forge/tslite/compile) já respondeu onde uma ilha
começa e termina, e já decidiu que texto interpolado vira template literal.
Segmentar de novo aqui seria a terceira resposta para a mesma pergunta.
As expressões de um update e de um efeito não moram na árvore, e quem as
alcança é o compileDefinition do @loom-forge/forge — o
mesmo que o runtime usa. O eject não tem caminho próprio de compilação: ele recebe
a definição compilada e traduz.
O que cada intenção EXECUTA também não é pergunta daqui: vem da rota do forge
(settledRoute), a mesma que o grafo e o check usam. Passe a montagem do sistema
para que o eject saiba o que o documento sozinho não decide:
ejectComponent(def, { mounting: mountingOf(sys.components.values()) });Sem ela, o documento é o sistema: o que ele declara sai resolvido, e o resto sai como id.
O que a tradução NÃO alcança sai em notes
Nunca em silêncio. Hoje:
placementelayout— quem posiciona é o renderer do layout; o TSX mostra os filhos, não o arranjo que o planner calcula. O caminho é um emitter pareado por nome, ao lado do renderer;provides— seria um Provider ao redor;- o vocabulário de comando — o que o documento resolve sai resolvido; um nome que depende de
onde o componente monta (o
$slotde uma sessão, um vocabulário de outro componente) sai como nome, e no React pediria um vocabulário de comando do host. Quem declaracommandsganha a nota pelo mesmo motivo; - nome livre que venha da stdlib ou do host (
length,map) — no TSX precisaria de um import que o JSON não declara; - um nó com
props.childrenE filhos na árvore — no motor a árvore sobrescreve a prop, em silêncio; - um campo que não compila — ele ficou com o texto que o autor escreveu, e sai assim no TSX, como string. A nota traz o endereço e o código do diagnóstico da compilação;
- os imports das peças —
<Card>sai semimport, porque de qual módulo ele vem é conhecimento do host. O seam previsto é um resolvedor (descriptor.metaou ummoduleOfinjetado), como opropsOfdocheck.
[!WARNING] O TSX gerado não é o que o palco renderiza. O motor resolve por
bind → expand → resolve; este package traduz. São dois caminhos, e sem um teste de paridade que execute o ejetado e compare com o render, eles divergem em silêncio. O teste existe como plano, não como código — ver oTODO.mddo root.
Porta de mão única
O cabeçalho do arquivo emitido diz isso, e é contrato: editar o TSX não volta para
o JSON. {xs.map(f)} até levantaria para um each, mas {fazAlgo(xs)} não
levanta para nada — a volta não existe em geral, e inventá-la poria código na
árvore.
