npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@loom-forge/core

v0.3.0

Published

loom · o kernel HEADLESS: DefNode → ResolvedNode via bind → resolve → plan. Zero React, zero ambiente, zero dependências. Os ports (bind/validação/ações/endereço/tipos) são os seams de DI.

Readme

@loom-forge/core

loom · o kernel headless. Zero React, zero ambiente, zero dependências. Transforma definição (+ dados) numa ResolvedNode (dados puros) via bind → resolve → plan. É o contrato que todos os outros packages falam.

pnpm add @loom-forge/core
import {
  createDescriptorRegistry,
  createTypeResolver,
  resolveTree,
  fragmentPlanner,
  sectionPlanner,
  gridFormPlanner,
  defaultPlanners,
  walk,
  findById,
  pathToId,
  mapNodes,
  identityBinder,
  noopValidator,
} from "@loom-forge/core";

Modelo

DefNode — a unidade da definição

interface DefNode {
  id: string; // identidade ESTÁVEL (keys, endereço, edição). Nunca posicional.
  type?: string; // discriminador → registry (ausente quando `el` dá a tag)
  el?: string; // tag intrínseca — porta de emergência, não caminho (ADR-012)
  props?: Record<string, unknown>;
  layout?: string; // qual layout arruma os FILHOS deste nó (default: o do engine)
  children?: DefNode[];
  placement?: unknown; // posição DESTE nó no layout do pai — opaca pro core
  slot?: string; // a ÁREA do pai que este nó preenche (`"header"`). Ausente = a default
  events?: Record<string, EventBinding>; // events-out: nomeDaProp → intenção
  fallback?: "drop" | DefNode; // forward-compat: type desconhecido → isto ("drop" = nada)
  requires?: Record<string, string>; // gate de capability: `cap → versão` ou `cap → "*"` (presença)
  version?: string;

  // as DIRETIVAS — consumidas no caminho, nunca chegam à view
  each?: unknown; // repete o nó por item (ou re-escopa, se objeto)
  as?: string; // o nome que o `each` liga para o item
  when?: unknown; // dropa o nó se falsy
  provides?: Record<string, unknown>; // contextos para a SUBÁRVORE
  commands?: Record<string, CommandAlias>; // vocabulário de comando da SUBÁRVORE

  // de onde o nó veio, de fora para dentro — escrito pela expansão, nunca de autoria (ADR-017)
  origins?: readonly NodeOrigin[];
}

interface NodeOrigin {
  component: string; // o componente cujo documento escreveu o nó
  id: string; // o id que o autor deu — o resolvido é outro
}

interface EventBinding {
  action: string;
  // os params da ação — e é POR AQUI que entra tudo o que a intenção recebe: a raiz `event` é
  // o valor recebido (o que o widget emitiu; o `data` da ação anterior num `then`; o `error`
  // num `catch`), e o autor escreve o mapeamento. Avaliados no DESPACHO, não no render.
  params?: unknown;
  // o que roda quando esta intenção RESOLVE: `success: true` no host (`event` = `data`), ou o
  // estado escrito num update local (`event` = `undefined`)
  then?: EventBinding;
  // o que roda quando o despacho ao host FALHA (`event` = `error`: `{ code, message?, data? }`)
  catch?: EventBinding;
  // os params que o autor deixa para a camada de COMANDO preencher (token, default, drawer)
  resolve?: readonly string[];
  // update do componente que escreveu a intenção — marca da RESOLUÇÃO, nunca de autoria (ADR-016)
  local?: true;
  // o escopo do render em que o nó foi ligado — escrito pelo `bind`, nunca de autoria
  scope?: Readonly<Record<string, unknown>>;
}

// o que um nome de comando executa — a forma longa é a aplicação PARCIAL
type CommandAlias = string | { action: string; params?: unknown };

Tudo o que uma intenção recebe entra pelo params, e o autor vê o que entra (ADR-018 do repo). O valor que o widget emite é a raiz event dos params — { campo: "cpf", valor: "{{ event }}" }, um sub-campo ({{ event.valido }}), uma transformação —, e o componente registrado emite valor, nunca o evento do DOM, e declara o que emite em descriptor.meta.emits (é o tipo de event no check; sem declaração, unknown).

O que a ação devolve é a próxima intenção: then roda no sucesso com event = data (tipado pelo resultOf da porta), catch roda na falha com event = error. Um update local também resolve (o estado foi escrito), então a cadeia iniciar (local) → host → then/catch (local) é um mecanismo só, lida como uma promise chain. Sem catch, a falha vai só ao onActionResult do host — e o host recebe TODO resultado, com ou sem continuação.

runIntent(binding, event, runtime); // executa a intenção e a cadeia dela; nunca devolve nada
intentParams(binder, binding, event); // os params avaliados no despacho: escopo do render + event
intentsOf(binding); // a intenção e cada elo da cadeia, para quem roteia ou desenha grafo
dispatchIntent(port, id, params, onResult); // o despacho de UMA ação ao host, normalizado

O IntentRuntime é o que quem executa (a view, o boundary) entrega ao runIntent: params (o binder avaliando sobre scope + event), apply (o update local do componente vivo), a port, o onResult e o pending — true do despacho ao resultado, que o boundary expõe como raiz pending[ação] do escopo de um componente vivo.

Descriptor — metadado headless de um type (NUNCA contém o componente)

interface Descriptor {
  type: string;
  defaultProps?: Record<string, unknown>;
  schema?: unknown; // JSON Schema / Schema do TSLite — validação & form-builder
  layout?: string; // layout default pros filhos deste type
  meta?: Record<string, unknown>; // pra editores (label/categoria/ícone)
}

ResolvedNode — a saída (dados puros, agnóstica de ambiente)

type ResolvedNode =
  | {
      kind: "node";
      id;
      type;
      props;
      events?;
      layout?: ResolvedLayout;
      children: ResolvedNode[];
      slot?; // a ÁREA do pai — a view a entrega como prop, e ela não vai ao planner
      origins?; // a origem atravessa até a view — é o que liga a tela ao documento
    }
  | { kind: "drop"; id }
  | { kind: "unknown"; id; type };

interface ResolvedLayout {
  name: string;
  plan: unknown;
} // plan pareado com o renderer por `name`

Registry de descriptors

createDescriptorRegistry(initial?: Descriptor[]): DescriptorRegistry

| Método | Descrição | | ----------------------------------------- | ------------------------------------------- | | register(descriptor) / register(type) | adiciona (forma completa ou nu). Encadeável | | get(type) · has(type) · list() | leitura |

const registry = createDescriptorRegistry()
  .register({ type: "Page", layout: "section" })
  .register("Field");

Resolver — estratégia de resolução

createTypeResolver(options?: { capabilities?: Record<string, string> }): Resolver

interface Resolver { resolve(node: DefNode, ctx: { registry: DescriptorRegistry }): ResolveResult }
type ResolveResult =
  | { kind: "render"; descriptor: Descriptor; props: Record<string, unknown> }
  | { kind: "drop" }
  | { kind: "unknown"; node: DefNode };
  • Mescla descriptor.defaultProps SOB node.props.
  • requires: { feat: "*" } (presença) ou { feat: "1.2" } (versão mínima), contra capabilities.
  • fallback: "drop" (nada) ou outro nó (resolvido no lugar); senão unknown.

Pipeline

resolveTree(node: DefNode, opts: ResolveTreeOptions, data?: unknown): ResolvedNode

interface ResolveTreeOptions {
  registry: DescriptorRegistry;
  resolver?: Resolver;              // default: createTypeResolver()
  binder?: BindingEngine;           // default: identityBinder
  planners?: Record<string, LayoutPlanner>; // default: defaultPlanners()
  defaultLayout?: string;           // default: "fragment"
}

bind (pré-passo) → resolve (type→descriptor+props) → plan (layout). Nós fallback:"drop" somem da árvore E do plano; unknown permanece (a view decide a degradação).

shareIdentity(anterior, nova) — o que não mudou volta como o MESMO objeto

shareIdentity(previous: ResolvedNode | undefined, next: ResolvedNode): ResolvedNode;

A resolução é pura e refaz a árvore a cada dado novo. Esta passada compara a árvore nova com a anterior e devolve o objeto anterior onde o valor é o mesmo — o resultado é igual, e o que muda é a identidade. É o que dá a um consumidor como responder mudou alguma coisa aqui dentro? sem olhar dentro: um === na raiz da subárvore.

  • os filhos são pareados por id, então reordenar não invalida ninguém;
  • um nó é reusado quando ele e todos os descendentes são iguais;
  • props, plano de layout e o escopo guardado numa intenção entram na comparação — um params avaliado no despacho contra outro escopo é outro despacho;
  • valor é comparado por dado: identidade primeiro (o objeto do host passa direto), e campo a campo só no que a ligação constrói. Uma instância (Date, Map) vale por identidade.

Quem chama é a view, guardando a árvore anterior — é assim que o @loom-forge/react pula o render de uma subárvore inteira, e que o boundary de um componente vivo vira fronteira de verdade (ADR-022 do repo).


Layout — planners (metade headless do arrange)

interface PlannerChild {
  id: string;
  placement?: unknown;
}
interface LayoutPlanner {
  name: string;
  plan(children: PlannerChild[]): unknown; // dados do plano
  placementSchema?: unknown; // a FORMA do placement que ele lê, em JSON Schema
}

O planner arruma o CORPO, não as áreas. Um filho que declarou slot é uma região do pai (header, footer), e região não é filho arrumado: ele não entra no plano, e a view o entrega como a prop de mesmo nome. Sem essa divisão, um grid-form tentaria dar coluna ao rodapé. Numa tag intrínseca (el) não há região declarada, então lá todo filho é corpo.

| Planner | Placement | Plan | | -------------------------------------- | ----------------------------------------------------- | ------------------------------------- | | fragmentPlanner() | { index? } | { order: string[] } | | sectionPlanner({ direction?, gap? }) | { index? } | { direction, gap?, order } | | gridFormPlanner({ columns?, gap? }) | { span?: number \| ResponsiveSpan, break?, index? } | { columns, gap, responsive, cells } |

interface ResponsiveSpan {
  base: number;
  sm?: number;
  md?: number;
  lg?: number;
  xl?: number;
}
interface GridFormCell {
  id;
  span: number;
  spans: FilledSpans;
  break?;
} // span = base (back-compat/inline)

defaultPlanners() → { fragment, section, "grid-form" }. O renderer correspondente (view) vive no runtime; os dois são pareados por name.

O placementSchema é o contrato do filho no eixo de arrumação — o que o descriptor.schema é no de props. Um editor monta os campos de posição a partir dele sem saber que layout é, e um layout que não o publica deixa quem edita com o objeto cru. Os três planners daqui publicam.


Tree — utilitários puros (pro core e pro editor)

| Função | Descrição | | ---------------------------- | ------------------------------------------------- | | walk(node, visit, parent?) | pré-ordem; visit(node, parent) (null na raiz) | | findById(root, id) | primeiro nó com o id | | pathToId(root, id) | caminho de ids até o alvo, ou null | | mapNodes(node, fn) | mapeia imutável bottom-up |


Ports — os seams (DI)

O core depende destas interfaces, nunca de uma lib — é por isso que ele não tem dependência nenhuma. Cada adapter é um package à parte, e quem não usa um não paga por ele.

| Port | Contrato | Adapter | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | BindingEngine | compile(node) → DefNode (opcional) · bind(node, scope) → DefNode | @loom-forge/tslite · default identityBinder | | ValidationProvider | validate(schema, value) → Issue[] \| null | AJV/Zod (DI) · default noopValidator | | 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) | | AddressPort | diff(a, b) · patch(base, diff) | @loom-forge/address | | TypeChecker | subtype(a, b): boolean | @loom-forge/check | | CapabilityPort | get(name) · has(name) | escape imperativo (host) | | ContextStore | snapshot(names) · subscribe(names, onChange) · names() | o host · staticContextStore / createContextStore |

O contexto é dependência DECLARADA (ADR-022 e ADR-023 do repo)

import { createContextStore, staticContextStore } from "@loom-forge/core";

const store = createContextStore({ auth, route }); // o host tem estado vivo
store.set("route", next); // acorda quem declarou `route` — e só

staticContextStore(() => values); // o host só tem valores: a projeção é que é estável

O contexto é o único escopo dinâmico do modelo, e recebê-lo como fotografia faz todo consumidor depender da identidade do mapa inteiro — trocar um nome invalida quem nunca o declarou. O port inverte isso: quem consome diz os nomes (o context.uses, mais o que os filhos dele exigem) e recebe a projeção deles.

  • snapshot(names) é estável por identidade enquanto nenhum daqueles nomes muda. É o que um useSyncExternalStore exige, e o que transforma "não mudou" em trabalho pulado;
  • subscribe é o que faz o consumidor deixar de ser visitado, e não só de recalcular. O store estático não notifica — quem passa valores está re-renderizando de qualquer forma;
  • names() é a borda declarada: o que o host tem para dar. É o que a RAIZ do documento pede, porque ali uma expressão pode ler qualquer nome provido.

A porta de ação, sem adapter

O ActionPort é a projeção estrutural do Tier 0 do @node-actions/protocol (ADR-114): um runtime que cumpra o contrato — o commander, a lite, o engine de diretivas via toProtocolRuntime — é assignável a ele como está, e um objeto do host com invoke também. O core não importa o protocolo (ADR-105), e não há package de adapter (ADR-115):

createEngine({
  actions: commander, // ou { invoke(id, params) { … } }
  onActionResult: (id, result) => {
    if (!result.success) toast(result.error?.code);
  },
});
  • invoke devolve ENVELOPE, e falha de domínio é DADO: { success: false, error: { code } } volta pelo onActionResult e pelo catch do documento — nunca como exceção. void é sucesso.
  • Um runtime que LANÇA não derruba o clique: o dispatchIntent converte o throw e a rejeição em { success: false, aborted: true, abortedBy: "throw", error: { code: "UNHANDLED_ERROR" } } — bug de programa, não falha de domínio, e o catch é dele.
  • O Tier 1 é lido só quando existe (isAvailable, paramsOf, resultOf, metadataOf): o check tipa o call-site pelo paramsOf e o event de um then pelo resultOf; o grafo lê o isAvailable. Quem não publica não afirmou nada, e isso é silêncio, não erro.
  • Discrimine por error.code, nunca por regex na mensagem.

Ver docs/ARCHITECTURE.md para o porquê de cada um, e docs/DECISIONS.md para os ADRs — os do caminho de efeito são o ADR-103, 104, 114 e 115.


Escopo de comando

import {
  commandScope,
  resolveIntent,
  aliasOf,
  EMPTY_COMMANDS,
} from "@loom-forge/core";

| Função | Descrição | | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | | commandScope(herdado, nó) | o vocabulário em vigor ABAIXO do nó — o herdado, com o commands dele por cima (o mais próximo ganha) | | 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 | | aliasOf(alias) | a forma curta normalizada: "form/clear" → { action: "form/clear" } |

É a resposta de RUNTIME para "que ação este nome executa?", e quem a aplica é a expansão do @loom-forge/forge. A resposta sem rodar — que precisa saber onde o componente monta — é do forge também. O que viaja é nome, nunca função (ADR-015 do repo).


A tabela de slots

O core também exporta a forma do DefNode como dado — FIELDS (o papel de cada campo), SLOTS (onde uma expressão pode morar, e o que o slot espera) e TREE (por onde a travessia desce). É uma tabela exaustiva por tipo, e é o que faz a compilação, o bind e o typecheck concordarem sobre onde cabe código sem nenhum deles farejar texto. events e commands são mapas de intenções: o que compila é o params de cada entrada (shape intentParams). E cada slot declara directives — se {% %} e {# #} são marcação ali, além do {{ }}; hoje é false em todos, e a compilação pergunta à tabela em vez de a um padrão.

slot é data, como o as de um each: os dois são NOMES que o autor escolhe, não expressão — um nomeia o item do laço, o outro a área do pai que o nó preenche.