IntroducciónVisión general
NUCLO / 12

Idioma

Documentação

Documentação do Nuclo

Uma referência prática para instalar o Nuclo, construir com atualizações explícitas, estilizar com CSS tipado e renderizar HTML no servidor.

Início rápidonpm create nuclo@latest
Início rápido
v0.3.29
Versão atual
175
Construtores de tags
~18.4 KB
Núcleo, em gzip
Explícito
Modelo de runtime

Visão geral

#

Nuclo é uma pequena biblioteca DOM para TypeScript. Você constrói a página com funções simples como div() e button(), mantém o estado em variáveis comuns e chama update() quando a página deve mudar.

Nuclo não tem virtual DOM, compilador, proxies nem signals.

main.ts
import 'nuclo'

let count = 0

const App = () =>
  div(
    h1(() => `Count: ${count}`),
    button('Add one', { onClick: () => { count++; update() } }),
  )

render(App, document.getElementById('app')!)

O modelo mental

  1. Construa a UI com construtores de tags.
  2. Passe uma função para cada valor que depende do estado.
  3. Altere o estado com JavaScript comum.
  4. Chame update(). Nuclo executa as funções de novo e aplica as mudanças no DOM.

Alterar uma variável não toca o DOM. A página só muda quando você chama update().

Início rápido

#

create-nuclo gera um projeto Vite e TypeScript pronto para o Nuclo:

$npm create nuclo@latest
$bun create nuclo
$pnpm create nuclo

Ele pede o nome do projeto. Para pular a pergunta, passe o nome e as flags. Com npm, coloque -- antes das flags:

terminal
$ npm create nuclo@latest my-app -- --template basic --yes

Depois instale e inicie o servidor de desenvolvimento:

terminal
$ cd my-app
$ npm install
$ npm run dev

Opções

  • [project-name]: a pasta de destino. O padrão é nuclo-app.
  • -t, --template <name>: o template a copiar. Hoje só existe basic.
  • -y, --yes: nunca perguntar, usar os padrões.
  • -f, --force: escrever em uma pasta que não está vazia.
  • -h, --help: imprimir a ajuda.

Instalação e Imports

#

Para adicionar o Nuclo a um projeto existente:

terminal
$ npm install nuclo

Ou use pnpm add nuclo, yarn add nuclo ou bun add nuclo. Nuclo não tem dependências.

Roteamento não faz parte do pacote principal. Ele é distribuído separadamente como nuclo-router, que você instala ao lado de nuclo quando precisar.

Globais

import 'nuclo' coloca o seguinte em globalThis:

  • 175 construtores de tags: 112 para HTML (div, span, button, …) e 63 para SVG, com o sufixo Svg (svgSvg, pathSvg, …). Veja Construtores de tags.
  • 16 helpers: update, scope, when, list, region, into, on, render, hydrate, forceUpdate, css, cx, createCss, variants, keyframes e globalStyle.

O import precisa rodar antes de qualquer código que chame um construtor. Módulos ES executam na ordem dos imports, então isto falha:

main.ts
import { App } from './app' // app.ts calls div() → ReferenceError: div is not defined
import 'nuclo'

Coloque import 'nuclo' primeiro no seu arquivo de entrada, ou importe-o em cada arquivo que usa os globais.

Imports nomeados

Os helpers também são exports nomeados. São as mesmas funções dos globais. getCssText() e resetStyles() existem apenas como exports nomeados. Construtores de tags existem apenas como globais, então import { div } from 'nuclo' não funciona.

main.ts
import { render, update, getCssText } from 'nuclo'

Pontos de entrada

  • nuclo: registra os globais e exporta os 16 helpers mais getCssText e resetStyles.
  • nuclo/ssr: renderToString, renderManyToString, renderToStringWithContainer e getCssText. Veja Renderização no servidor.
  • nuclo/polyfill: um DOM pequeno para Node e Bun. Veja nuclo/polyfill.
  • nuclo/types: apenas tipos. Veja TypeScript.

TypeScript

#

Nuclo traz seus próprios tipos. Você precisa de duas coisas:

  • "moduleResolution": "bundler". node16 e nodenext ainda não são suportados.
  • A lib DOM. O TypeScript a inclui por padrão quando você não define lib.

Se qualquer arquivo do projeto importa 'nuclo', todos os arquivos veem os tipos globais. Você não precisa de mais nada. Se nenhum arquivo o importa, por exemplo em um tsconfig separado para testes, adicione os tipos você mesmo:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["nuclo/types"]
  }
}

Ou adicione /// <reference types="nuclo/types" /> a um arquivo .d.ts.

Tipos úteis

Estes tipos são globais, então você os usa sem import:

  • NodeModFn<Tag>: o que um construtor de tag retorna. Use como tipo de retorno de um componente.
  • NodeModLike<Tag>: qualquer coisa que um construtor de tag aceita como argumento.
  • ExpandedElement<Tag>: o que render() e hydrate() retornam.
  • ElementTagName: qualquer nome de tag HTML.
  • ExpandedElementAttributes<Tag>: o objeto de atributos que um construtor de tag aceita, incluindo handlers on*, onMount e onDestroy. Use em componentes que repassam atributos.
  • MountCallback e DestroyCallback: handlers de onMount e onDestroy.
  • WhenBuilder, ListModifier: o que when() e list() retornam.
  • RegionOptions, RegionType: as opções que region() recebe.
  • StyleResult, Style, ThemeConfig, CssInstance e os demais tipos de estilização. Estes também podem ser importados: import type { StyleResult } from 'nuclo'.
badge.ts
function Badge(text: string): NodeModFn<'span'> {
  return span(text)
}

ExpandedElement<Tag> marca todos os membros do DOM como opcionais. Faça um cast quando precisar do tipo completo do elemento: render(App, root) as HTMLDivElement.

Toda tag HTML é uma função global com o mesmo nome: div, span, input, … A única exceção é <var>, que é var_ porque var é uma palavra reservada. Toda tag SVG tem o sufixo Svg: svgSvg, pathSvg, circleSvg, …

Chamar um construtor de tag ainda não cria um elemento. div('Hi') retorna um builder: uma função que cria o elemento depois. Nuclo o chama quando você o monta com render() ou o coloca dentro de outro builder, when() ou list(). Cada uso cria um elemento novo.

card.ts
let name = 'Ada'

const Card = () =>
  div(
    { id: 'card', className: 'card' },
    h2('Profile'),
    p(() => `Hello, ${name}`),
    button('Rename', { onClick: () => { name = 'Grace'; update() } }),
  )

render(Card, document.body)
Ao vivoUm construtor recebe atributos, filhos estáticos e texto dinâmico. Renomear altera o estado e depois chama update().Abrir ↗

Profile

Hello, Ada

Argumentos

Um builder recebe qualquer número de argumentos e os aplica da esquerda para a direita:

  • string, number, bigint: um nó de texto.
  • () => value: texto que muda em update(). Veja Valores dinâmicos.
  • null, undefined: ignorados.
  • true, false: renderizados como o texto "true" ou "false". Use when() para condições.
  • Um objeto simples: atributos. Veja Atributos.
  • Um resultado de css(), cx() ou variants(): adiciona suas classes.
  • on(...): um listener de evento ou hook de ciclo de vida.
  • Outro builder, when() ou list(): um filho.
  • Um Node do DOM: anexado como está.
  • (el, index) => …: um modificador personalizado. Veja abaixo.

Armadilhas

  • div(isAdmin && span('Admin')) renderiza o texto "false". Escreva div(when(() => isAdmin, span('Admin'))).
  • Arrays não são filhos. Espalhe-os: ul(...names.map((n) => li(n))). Para listas que mudam, use list().
  • Uma variável local com nome de tag esconde o construtor. Dentro de function Row(label: string), label(...) não é chamável. Renomeie locais como label, title, data, p, a e i.
  • Navegadores expõem todo elemento com id como uma variável global. Se a página contém <main id="main"> antes do Nuclo carregar, Nuclo deixa esse global no lugar, então main(...) lança um TypeError. Use outros ids.

Modificadores personalizados

Uma função que declara pelo menos um parâmetro roda uma vez, na ordem dos argumentos, enquanto o elemento é construído. Seu primeiro argumento é o elemento. O segundo é um índice interno, então não dependa dele. Se ela retorna uma string ou number, isso vira texto. Um Node é anexado. Um objeto é aplicado como atributos.

example.ts
const field = input((el) => {
  el.autocomplete = 'off'
})

Obtendo o elemento

render() retorna o elemento raiz. Dentro de uma árvore, use on("mount", (el) => …) ou um modificador personalizado. Você também pode chamar a função builder você mesmo para criar um elemento desanexado:

example.ts
const el = div('Hello')() as HTMLDivElement
document.body.append(el)

Anexe-o antes do próximo update(). Um nó que não está no documento quando update() roda para de atualizar para sempre.

SVG

Construtores SVG recebem os mesmos argumentos. Nuclo define todo atributo SVG com setAttribute, então nomes como "stroke-width" funcionam como escritos.

icon.ts
const Icon = () =>
  svgSvg(
    { viewBox: '0 0 24 24', width: 24, height: 24, className: 'icon' },
    circleSvg({ cx: 12, cy: 12, r: 10, fill: 'currentColor' }),
  )

Todos os construtores

HTML: a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption cite code col colgroup data datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label legend li link main map mark menu meta meter nav noscript object ol optgroup option output p picture pre progress q rp rt ruby s samp script search section select slot small source span strong style sub summary sup table tbody td template textarea tfoot th thead time title tr track u ul var_ video wbr

SVG: aSvg animateSvg animateMotionSvg animateTransformSvg circleSvg clipPathSvg defsSvg descSvg ellipseSvg feBlendSvg feColorMatrixSvg feComponentTransferSvg feCompositeSvg feConvolveMatrixSvg feDiffuseLightingSvg feDisplacementMapSvg feDistantLightSvg feDropShadowSvg feFloodSvg feFuncASvg feFuncBSvg feFuncGSvg feFuncRSvg feGaussianBlurSvg feImageSvg feMergeSvg feMergeNodeSvg feMorphologySvg feOffsetSvg fePointLightSvg feSpecularLightingSvg feSpotLightSvg feTileSvg feTurbulenceSvg filterSvg foreignObjectSvg gSvg imageSvg lineSvg linearGradientSvg markerSvg maskSvg metadataSvg mpathSvg pathSvg patternSvg polygonSvg polylineSvg radialGradientSvg rectSvg scriptSvg setSvg stopSvg styleSvg svgSvg switchSvg symbolSvg textSvg textPathSvg titleSvg tspanSvg useSvg viewSvg

Um componente é uma função simples que retorna um builder, como button(...). Não há API de componentes. Nada é criado até você renderizá-lo ou colocá-lo em uma árvore.

counter.ts
function Counter(label: string) {
  let count = 0 // one per call
  return button(() => `${label}: ${count}`, {
    onClick: () => { count++; update() },
  })
}

render(() => div(Counter('A'), Counter('B')))
Ao vivoUm componente é uma função simples. Cada chamada Counter() mantém a própria contagem.Abrir ↗

Estado declarado dentro da função pertence àquela instância. Estado declarado fora é compartilhado por todas as instâncias. forceUpdate() chama os componentes de novo, o que reinicia o estado declarado dentro deles.

Filhos são apenas argumentos:

card.ts
function Card(heading: string, ...children: NodeModLike<'div'>[]) {
  return div({ className: 'card' }, h2(heading), ...children)
}

render(() => Card('Hi', p('one'), p('two')))

Atributos

#

Passe um objeto simples para definir atributos. Você pode passar vários objetos. Para a mesma chave, o último valor vence, exceto className e style, que são mesclados. Um valor pode ser estático, ou uma função sem parâmetros que roda de novo em cada update().

example.ts
let busy = false

const save = button(
  { type: 'submit', disabled: () => busy, 'aria-busy': () => String(busy), 'data-id': 42 },
  'Save',
)
Ao vivoValores de atributos podem ser funções. O segundo botão inverte busy e chama update(); o disabled e o aria-busy de Save acompanham.Abrir ↗

Como um valor é definido

  • Se o elemento HTML tem uma propriedade com aquele nome (value, checked, disabled, htmlFor, tabIndex, …), Nuclo define a propriedade. Caso contrário, chama setAttribute(key, String(value)), por exemplo para "aria-*" e "data-*".
  • Em elementos SVG, Nuclo sempre chama setAttribute.
  • null e undefined são ignorados. Um valor dinâmico que retorna null ou undefined mantém o valor antigo. Para limpar um valor, retorne "", ou false para uma propriedade booleana.
  • Booleanos funcionam para propriedades booleanas do DOM, como disabled em um botão, checked, hidden e required. Em qualquer outro lugar, true e false são escritos como as strings "true" e "false".
  • Use chaves "data-*". dataset não é suportado.
  • innerHTML não é escapado, e a renderização no servidor não o emite. Evite-o.

Classes

Use className. Strings de classe estáticas, resultados de css() e um className dinâmico são todos mesclados. Um className dinâmico mantém as classes estáticas, e quando retorna "" só as classes estáticas permanecem.

example.ts
let active = false
const base = css({ p: 8 })

const item = li(base, { className: 'item' }, { className: () => (active ? 'active' : '') }, 'Home')
// class="<base> item", then "<base> item active" after active = true; update()

Não use a chave class em elementos HTML. Ela define o atributo bruto e substitui toda classe aplicada antes dela, incluindo classes de css().

Estilos inline

style recebe um objeto com chaves em camelCase, ou uma função que retorna um.

example.ts
let progress = 30

const bar = div({ style: () => ({ width: `${progress}%`, backgroundColor: 'teal' }) })
  • Nuclo não adiciona unidades. Escreva "12px", não 12. Propriedades sem unidade, como opacity e zIndex, aceitam números.
  • "", null e undefined limpam uma propriedade.
  • Um style dinâmico define apenas as chaves que retorna. Uma chave que ele deixa de retornar mantém o valor antigo, então retorne "" para limpá-la.
  • Strings de estilo ("color: red") e propriedades personalizadas ("--gap") não são suportadas. Use css() em vez disso. Ele aceita propriedades personalizadas via raw.
fnAPI pública
// DOM events such as "click". El and Ev are inferred from the tag and the event name. function on(type: DOMEventName, listener: (this: El, e: Ev) => unknown, options?: boolean | AddEventListenerOptions): NodeModFn // Custom events function on<K extends string, E extends Event = Event>(type: K, listener: (this: El, e: E) => unknown, options?: boolean | AddEventListenerOptions): NodeModFn // Lifecycle, see Lifecycle function on(type: "mount", listener: MountCallback): NodeModFn function on(type: "destroy", listener: DestroyCallback): NodeModFn

Há duas formas de ouvir eventos: atributos on* e o modificador on().

Atributos on*

Em um objeto de atributos, uma função sob uma chave como onClick (on seguido de letra maiúscula) é um handler de evento, não um valor dinâmico.

search.ts
let query = ''

const search = input({
  placeholder: 'Search',
  value: () => query,
  onInput: (e) => { query = e.currentTarget.value; update() },
  onKeyDown: (e) => { if (e.key === 'Escape') { query = ''; update() } },
})
  • O TypeScript infere o tipo do evento a partir da chave e do elemento. e.currentTarget e this são o elemento.
  • Nuclo define a propriedade nativa, então onClick vira el.onclick. Há um handler por evento: um onClick posterior substitui um anterior.
  • onDoubleClick e onDblClick significam ambos dblclick. Chaves em minúsculas, como onclick, são erros de tipo.
  • Um nome que não é um evento conhecido escuta sua forma em minúsculas: onMyEvent escuta "myevent".
  • Erros lançados pelo handler não são capturados.
Ao vivoonInput e onKeyDown são atributos simples. Digite e pressione Escape para limpar.Abrir ↗

Type something, then press Escape.

on()

on() retorna um modificador que chama addEventListener no elemento.

panel.ts
const panel = div(
  on('scroll', onScroll, { passive: true }),
  on('click', logClick),
  on('click', closeMenu),
)
  • Cada on() adiciona um listener. Todos rodam, em ordem.
  • options vai direto para addEventListener: capture, once, passive, signal.
  • Passe on() diretamente a um builder e o tipo do elemento é inferido: input(on('input', (e) => e.currentTarget.value)).
  • Se um listener lança um erro, Nuclo o registra com console.error, e os outros listeners ainda rodam.
  • Nuclo remove o listener quando remove o elemento. No servidor, on() não faz nada.
  • Você pode reutilizar um mesmo valor de on() em vários elementos.

Para um evento personalizado, passe o tipo do evento como genérico:

picker.ts
type PickEvent = CustomEvent<{ id: number }>

const picker = ul(on<'pick', PickEvent>('pick', (e) => choose(e.detail.id)))

Qual usar

Use atributos on* no caso comum. Use on() quando precisar de mais de um listener para um evento, de opções de listener, ou de um nome de evento personalizado como "my-event".

fnAPI pública
// As attributes { onMount?: (el: El) => void | (() => void), onDestroy?: (el: El) => void } // Or with on() function on(type: "mount", listener: (el: El) => void | (() => void)): NodeModFn function on(type: "destroy", listener: (el: El) => void): NodeModFn

onMount roda uma vez, depois que o elemento é inserido. onDestroy roda uma vez, quando Nuclo remove o elemento. Escreva-os como atributos ou com on(). As duas formas se comportam igual, e o elemento é tipado a partir da sua tag.

timer.ts
let seconds = 0

const Timer = () =>
  span(
    {
      onMount: () => {
        const id = setInterval(() => { seconds++; update() }, 1000)
        return () => clearInterval(id) // runs when the span is removed
      },
    },
    () => `${seconds}s`,
  )

const search = input(on('mount', (el) => el.focus()))
Ao vivoonMount inicia um intervalo e retorna sua limpeza. Desmontar executa a limpeza, então a contagem pausa até o timer montar de novo.Abrir ↗

Quando eles rodam

  • onMount roda no fim da chamada de render(), hydrate(), update() ou forceUpdate() que inseriu o elemento. Nunca roda enquanto a árvore está sendo construída.
  • Se onMount retorna uma função, essa função roda na destruição, depois dos callbacks onDestroy do elemento.
  • onDestroy roda quando Nuclo remove o elemento: uma linha de list() é removida, um ramo de when() troca, um into() sai de sua região, ou forceUpdate() o descarta. Roda logo antes da remoção, então o elemento ainda está no documento.

Remoção direta do DOM

Se você remove um elemento por conta própria, com node.remove() ou innerHTML = '', seu onDestroy e seus cleanups nunca rodam, e timers continuam rodando. Nuclo não tem unmount(). Para desmontar um app com limpeza, envolva-o em when():

main.ts
let mounted = true
render(() => div(when(() => mounted, App())), root)

mounted = false
update() // App's onDestroy hooks and cleanups run

Ordem e erros

  • Você pode registrar quantos hooks quiser, em qualquer das formas. Eles rodam na ordem de registro.
  • onDestroy sempre roda primeiro nos filhos, depois no pai.
  • Elementos montam na ordem do seu primeiro hook onMount, que segue a ordem dos argumentos, e cada elemento roda todos os seus hooks juntos. Passe o hook do pai antes dos filhos se eles dependerem dele.
  • Um hook que lança um erro é registrado com console.error. Os outros hooks ainda rodam.
  • O callback deve retornar nada ou uma função de cleanup, então não o torne async. Uma Promise retornada nunca é usada como cleanup. Inicie trabalho assíncrono dentro dele: { onMount: (el) => { void load(el) } }.
  • Hooks nunca rodam no servidor. No cliente, rodam depois de hydrate().
Nota: hooks não são effects. Eles nunca rodam de novo quando o estado muda. Cada um roda no máximo uma vez por elemento.

Atualizações explícitas

#

Nuclo nunca atualiza a página por conta própria. Altere seu estado e então chame update().

counter.ts
let count = 0

const Counter = () =>
  button(() => `Clicked ${count} times`, {
    onClick: () => { count++; update() },
  })

render(Counter)
Ao vivocount++ só altera a variável. O texto se atualiza no próximo update(), vindo do botão ou de qualquer outra coisa nesta página.Abrir ↗

Clicked 0 times

Altere quanto estado quiser e então chame update() uma vez:

example.ts
let first = 'Ada'
let last = 'Lovelace'

render(() => p(() => `${first} ${last}`))

first = 'Grace'
last = 'Hopper'
update() // one pass, both changes

Chame update() a partir de handlers de eventos, timers, callbacks de fetch ou onMount. Não o chame de dentro de um valor dinâmico.

Valores dinâmicos

#

Uma função sem parâmetros é um valor dinâmico. Nuclo a executa uma vez ao construir o elemento, e de novo em cada update(). Use valores dinâmicos para filhos de texto, valores de atributos, style e className. Para filhos que aparecem, desaparecem ou se repetem, use when() e list().

example.ts
let name = 'Alice'
let busy = false

const Profile = () =>
  div(
    h1('Welcome'),              // static
    p(() => `Hello, ${name}`),  // dynamic text
    button({ disabled: () => busy }, 'Save'),
  )
Ao vivoEdite o nome. O texto estático foi fixado quando o elemento foi construído; o dinâmico roda de novo a cada update(). forceUpdate() reconstrói a árvore, então o texto estático também se atualiza.Abrir ↗

static: Hello, Alice

dynamic: Hello, Alice

Nuclo verifica a quantidade de parâmetros (fn.length). Uma função que declara um parâmetro é um modificador personalizado e roda apenas uma vez. (x = 0) => … e (...args) => … têm length 0, então são dinâmicas.

Texto dinâmico

  • Uma string, number, bigint ou boolean renderiza como String(value). () => ok && 'Saved' mostra "false", então escreva () => (ok ? 'Saved' : '').
  • null e undefined renderizam como texto vazio.
  • Um resultado de css() ou cx() na primeira execução torna a função uma classe dinâmica, não texto. Veja cx().
  • Um nó, builder, array ou objeto simples não renderiza nada, e nunca atualiza.

Atributos dinâmicos

  • Nuclo escreve um valor apenas quando ele difere do último valor que Nuclo escreveu. Ele não compara com o DOM ao vivo, então se um usuário digita em um input e sua função retorna o mesmo valor de antes, o texto digitado permanece.
  • Um resultado null ou undefined não escreve nada, então o valor antigo permanece. className é a exceção: qualquer resultado falsy remove as classes dinâmicas e mantém as estáticas.
  • Uma função de style aplica o objeto retornado em cada atualização.

Erros

Se um valor dinâmico lança um erro, Nuclo o captura e o resto da atualização ainda roda. O texto fica vazio ou mantém o valor antigo, e um atributo mantém o valor antigo. Ambos se recuperam no próximo update() bem-sucedido. A exceção: uma função de texto que lança um erro na primeira execução fica vazia para sempre.

fnAPI pública
function update(...scopeIds: string[]): void

Executa uma passagem síncrona sobre todos os valores dinâmicos da página. Com ids, atualiza apenas o que está dentro dos elementos marcados com esses ids de scope(). Cada passagem executa estes passos em ordem:

  1. list(): lê os itens de novo e adiciona, move ou remove linhas.
  2. when(): verifica as condições de novo. Um ramo só é reconstruído quando um ramo diferente se torna ativo.
  3. Atributos dinâmicos, incluindo style e className.
  4. Texto dinâmico.
  5. onMount para os elementos que os passos 1 e 2 inseriram.
  • Ele executa tudo. Todo valor dinâmico roda em cada chamada, mesmo quando seus dados não mudaram. Apenas a escrita no DOM é pulada. Mantenha valores dinâmicos rápidos e sem efeitos colaterais.
  • Apenas nós no documento. Se um nó não está no documento quando update() roda, Nuclo para de rastreá-lo. Ele nunca mais atualiza, mesmo depois que você o anexa. Isso vale para list() e when() também. Renderize em um contêiner que está no documento.
  • Valores estáticos nunca mudam. Strings simples e valores de atributos são fixados quando o elemento é construído. Para reconstruí-los, use forceUpdate().
  • Erros. Erros de texto e atributos são capturados, e a passagem continua. Se uma condição de when() lança um erro, o erro é registrado e aquele when() para de atualizar. Se uma função items ou renderItem de list() lança um erro, o erro escapa de update() e os passos restantes não rodam.
  • Servidor. update() não faz nada no servidor.
Ao vivoCada update() executa cada valor dinâmico, mesmo quando seus dados não mudaram. Outras demos nesta página também aumentam a contagem.Abrir ↗

This dynamic value has run 1 times.

fnAPI pública
function scope(...ids: string[]): NodeModFn

Dá ao elemento que o recebe um ou mais ids de escopo. update('id') então atualiza apenas o que está dentro daquele elemento, incluindo os atributos do próprio elemento.

example.ts
let cartItems: string[] = []
let user = 'Ada'

const App = () =>
  div(
    header(() => `Hi, ${user}`),
    aside(scope('cart'), span(() => `Items: ${cartItems.length}`)),
  )

render(App)

cartItems.push('apple')
user = 'Grace'
update('cart') // "Items: 1", the header still says "Hi, Ada"
update()       // everything, the header now says "Hi, Grace"
Ao vivoOs dois botões adicionam um item e renomeiam o usuário. update('cart') atualiza só o painel com escopo.Abrir ↗
Hi, Ada
Items: 0
  • Um elemento pode ter vários ids: scope('cart', 'sidebar').
  • update('a', 'b') atualiza tudo dentro de a ou b. Se vários elementos usam o mesmo id, todos eles atualizam.
  • Com escopos aninhados, update('outer') inclui o escopo interno. update('inner') não toca o externo.
  • Um id que nenhum elemento no documento possui não atualiza nada e não gera erro, então verifique a grafia.
  • scope() não faz nada no servidor.
fnAPI pública
function when(condition: boolean | (() => boolean), ...content: NodeModLike[]): WhenBuilder interface WhenBuilder { when(condition: boolean | (() => boolean), ...content: NodeModLike[]): WhenBuilder else(...content: NodeModLike[]): WhenBuilder }

Renderiza conteúdo com base em uma condição. Nuclo verifica as condições de novo em cada update(). A primeira condição verdadeira vence. Se nenhuma for verdadeira, o conteúdo de .else() é renderizado. Sem .else(), nada é renderizado.

status.ts
let status: 'loading' | 'error' | 'ready' = 'loading'

const Status = () =>
  div(
    when(() => status === 'loading', p('Loading…'))
      .when(() => status === 'error', p('Something went wrong'))
      .else(p('Ready')),
  )
Ao vivoA primeira condição verdadeira vence; caso contrário, .else() renderiza. Um novo ramo é construído do zero.Abrir ↗

Loading…

Condições

  • Para estado, passe uma função. Um booleano simples é lido uma vez: when(open, …) nunca muda.
  • Em TypeScript a função deve retornar um booleano. Escreva () => items.length > 0 ou () => user != null.

O que atualiza

  • Enquanto o mesmo ramo permanece ativo, seus elementos são mantidos. Valores dinâmicos, listas e blocos when() dentro dele continuam atualizando.
  • Quando um ramo diferente se torna ativo, o conteúdo antigo é removido e seus hooks onDestroy rodam. O novo ramo é construído do zero e seus hooks onMount rodam. O estado dos elementos antigos, como texto de input ou foco, é perdido.

Conteúdo do ramo

Coloque filhos em um ramo: texto, texto dinâmico, builders, list() e when() aninhados. Passe builders como p('Hi'), não nós do DOM: um nó do DOM para de atualizar depois que seu ramo se esconde uma vez.

Objetos de atributos e on() em um ramo se aplicam ao elemento pai, e permanecem depois que o ramo se esconde. Para um atributo condicional, use um atributo dinâmico: { className: () => (active ? 'active' : '') }.

Encadeamento

.when() e .else() retornam um novo builder e não alteram o original. Se você chamar .else() duas vezes, a última chamada vence.

fnAPI pública
function list<T>( items: () => readonly T[] | Iterable<T>, renderItem: (item: T, index: number) => ListRenderResult, ): ListModifier

Renderiza uma linha por item. Em cada update(), Nuclo chama items() de novo e casa os novos itens com as linhas existentes por identidade (===).

fruits.ts
let fruits = ['Apple', 'Banana']

const Fruits = () => ul(list(() => fruits, (fruit) => li(fruit)))

render(Fruits)

fruits.push('Cherry')
update()

Como as linhas são casadas

  • Um item que ainda está lá mantém sua linha. Nuclo move a linha se necessário, e não chama renderItem de novo.
  • Um item novo recebe uma linha nova. Um item removido perde sua linha, e seus hooks onDestroy rodam.
  • Itens duplicados recebem cada um a sua própria linha.
  • Você pode alterar o array no lugar (push, splice, sort) ou retornar um novo array.

Uma linha mantida nunca é renderizada de novo, então use valores dinâmicos para os campos que mudam:

todos.ts
const todos = [{ text: 'Write docs', done: false }]

const Todos = () =>
  ul(
    list(() => todos, (todo) =>
      li({ className: () => (todo.done ? 'done' : '') }, () => todo.text),
    ),
  )

render(Todos)

todos[0].done = true
update() // same <li>, its class is now "done"

Cópias são itens novos, então substituir o array por todos.map((t) => ({ ...t })) reconstrói todas as linhas.

Ao vivoLinhas são associadas por identidade. Marque uma linha e depois Inverta: o mesmo <li> se move, com checkbox e tudo.Abrir ↗

O que renderItem retorna

  • Retorne um elemento, como li(...), tr(...) ou um builder SVG. Retorne null ou undefined para pular um item. Strings, nós de texto e fragments não renderizam nada.
  • Não retorne when() ou list() diretamente. Coloque-os dentro do elemento da linha: li(todo.text, when(() => todo.done, ' ✓')).
  • Para esconder itens, filtre em items(), não em renderItem.
  • Mantenha renderItem sem efeitos colaterais. Nuclo pode chamá-lo mais de uma vez para o mesmo item.

O índice

index é a posição do item quando sua linha foi criada. Ele não é atualizado quando as linhas se movem. Para a posição atual, use um valor dinâmico: li(() => `${names.indexOf(name) + 1}. ${name}`).

Iteráveis

Qualquer iterável funciona: arrays, Set, generators e map.values(). Retornar o mesmo array ou Set a cada vez é aceitável. Um iterator, como um generator ou map.values(), se esgota depois de uma passagem, então crie-o dentro de items() em cada chamada. Iterar um Map diretamente cria novos arrays [key, value] a cada vez, então todas as linhas são reconstruídas. Use map.values() em vez disso.

Posicionamento

As linhas aparecem onde list() está entre seus irmãos, entre dois marcadores de comentário. Os outros filhos do pai não são tocados. list() também funciona dentro de builders SVG.

region() e into()

#
fnAPI pública
function region(options: RegionOptions): NodeModFn function into(id: string, ...content: NodeModLike[]): NodeModFn function into(regions: Record<string, NodeModLike | NodeModLike[]>): NodeModFn interface RegionOptions { id: string type?: 'latest' | 'stack' // default: 'latest' empty?: NodeModLike }

region() marca um lugar nomeado na árvore. into() o preenche de qualquer outro lugar, em qualquer profundidade. O layout não recebe o conteúdo como prop, e o componente que constrói o conteúdo não sabe onde ele vai parar.

app.ts
const Layout = () =>
  div(
    aside(nav(a({ href: '/' }, 'Home'))),
    main(region({ id: 'main', empty: p('Nothing open.') })),
  )

// Anywhere in the tree, before or after the region
const Article = () => into('main', article(h1('Hello')))

render(() => div(Layout(), Article()))

Como funciona

  • into() deixa apenas um comentário onde é escrito. Seu conteúdo é renderizado entre os marcadores da região.
  • A ordem não importa. Um into() construído antes da sua região espera e aparece no momento em que a região é construída. As views aparecem na ordem em que foram escritas.
  • O conteúdo vive enquanto a view viver. Quando o when() que contém um into() fecha, ou sua linha de list() é removida, o conteúdo sai da região. Abri-lo de novo adiciona uma cópia, não duas.
  • Quando uma região desaparece, por exemplo quando seu when() fecha, suas views voltam a esperar. Uma região construída depois com o mesmo id as mostra.
  • Um into() cujo id não corresponde a nenhuma região não renderiza nada e não gera erro, então verifique a grafia.
  • Ids são únicos na árvore. Uma segunda região construída sob um id ativo registra um aviso.
  • Regiões funcionam com renderização no servidor. Em hydrate(), cada view reivindica o HTML que o servidor renderizou em vez de construí-lo de novo.

Opções

  • id: o nome que as chamadas de into() usam para encontrar a região.
  • type: 'latest' (o padrão) mostra apenas a view mais recente. 'stack' mostra todas as views, em ordem de chegada.
  • empty: renderizado enquanto a região não contém views.

Regiões empilhadas

Com type: 'stack', uma view que abre sobre outra não reconstrói a que está embaixo. Seu DOM, foco e estado de formulário permanecem. Use isso para uma camada como um diálogo sobre uma página.

layers.ts
let open = false

const App = () =>
  div(
    region({ id: 'main', type: 'stack' }),
    into('main', form(input({ placeholder: 'Keeps its text' }))),
    when(() => open, into('main', div('On top'))),
  )

Várias regiões de uma vez

Passe um objeto para preencher mais de uma região a partir de um único into(). Um valor pode ser um filho ou um array de filhos. Ambas as formas aninham: um into() dentro do conteúdo de outra view mira sua própria região.

page.ts
const Page = () =>
  into({
    main: article(h1('Eager route')),
    sidebar: [h2('Related'), ul(li('One'), li('Two'))],
  })

Um diálogo aberto do fundo da árvore

A região de overlay fica na raiz. Qualquer componente, em qualquer profundidade, abre um diálogo envolvendo um into() em um when(). Fechar o when() remove o diálogo da região.

dialog.ts
let confirming = false

const App = () =>
  div(
    Page(),
    div({ className: 'overlay' }, region({ id: 'overlay' })),
  )

// Deep inside Page()
const DeleteButton = () =>
  div(
    button('Delete', { onClick: () => { confirming = true; update() } }),
    when(() => confirming,
      into('overlay',
        dialog({ open: true },
          p('Delete this item?'),
          button('Cancel', { onClick: () => { confirming = false; update() } }),
        ),
      ),
    ),
  )

Toasts a partir de linhas de lista

Cada linha de um list() é uma view. Uma região empilhada as mostra em ordem, e remover um item do array remove seu toast.

toasts.ts
const toasts: { text: string }[] = []

const notify = (text: string) => {
  toasts.push({ text })
  update()
}

const dismiss = (toast: { text: string }) => {
  toasts.splice(toasts.indexOf(toast), 1)
  update()
}

const App = () =>
  div(
    button('Save', { onClick: () => notify('Saved.') }),
    div({ className: 'toasts' }, region({ id: 'toasts', type: 'stack' })),
    list(() => toasts, (toast) =>
      into('toasts', div(toast.text, button('×', { onClick: () => dismiss(toast) }))),
    ),
  )

Um título de página definido pela página

Com o padrão type: 'latest', a view mais recente vence. O layout é dono do título, e a página que estiver aberta o nomeia. Uma página sem into('title', …) deixa o texto de empty.

title.ts
let page: 'home' | 'settings' = 'home'

const Layout = () =>
  div(
    h1(region({ id: 'title', empty: 'My app' })),
    when(() => page === 'home', Home()).else(Settings()),
  )

const Home = () => p('Welcome.')

const Settings = () =>
  div(
    into('title', 'Settings'),
    form(/* … */),
  )

Valores dinâmicos dentro de uma view

O conteúdo de um into() é conteúdo Nuclo comum. Funções nele são valores dependentes de estado e se atualizam em update(), onde quer que a região esteja.

status.ts
const selected = new Set<string>()

const Layout = () =>
  div(
    header(region({ id: 'status' })),
    Table(),
  )

const Table = () =>
  div(
    into('status', () => `${selected.size} selected`),
    list(() => rows, (row) =>
      label(
        input({ type: 'checkbox', onChange: (e) => {
          e.currentTarget.checked ? selected.add(row.id) : selected.delete(row.id)
          update()
        } }),
        row.name,
      ),
    ),
  )

Uma região que vai e volta

O into() do dashboard é escrito uma vez. Enquanto o usuário está deslogado não há região main, então a view espera. Fazer login constrói a região e a view aparece. Fazer logout remove a região e a view volta a esperar.

auth.ts
let user: { name: string } | null = null

const App = () =>
  div(
    when(() => user !== null,
      main(region({ id: 'main' })),
    ).else(
      LoginForm(),
    ),
    into('main', Dashboard()),
  )

Uma view com sua própria região

Uma view pode conter um region(), e outras views podem mirá-lo. Aqui a página do editor traz consigo uma região tabs, e cada aba a preenche. Quando o editor fecha, sua região vai junto.

editor.ts
let tab: 'code' | 'preview' = 'code'

const Editor = () =>
  into('main',
    div(
      nav(
        button('Code', { onClick: () => { tab = 'code'; update() } }),
        button('Preview', { onClick: () => { tab = 'preview'; update() } }),
      ),
      section(region({ id: 'tabs' })),
    ),
    when(() => tab === 'code', into('tabs', textarea()))
      .else(into('tabs', iframe({ src: '/preview' }))),
  )

Exemplo ao vivo

region-app.ts
import 'nuclo'

let page: 'home' | 'article' | null = 'home'
let dialog = false

const go = (next: typeof page) => () => {
  page = next
  dialog = false
  update()
}

const Layout = () =>
  div(
    nav(
      button('Home', { onClick: go('home') }),
      button('Article', { onClick: go('article') }),
      button('Close page', { onClick: go(null) }),
    ),
    main(
      region({ id: 'main', type: 'stack', empty: p('Nothing open.') }),
    ),
    aside(region({ id: 'side', empty: p('No related links.') })),
  )

// Pages fill the layout's regions from outside it.
const Home = () =>
  into('main',
    input({ placeholder: 'Type here, then open the dialog' }),
    button('Open dialog', {
      onClick: () => { dialog = true; update() },
    }),
  )

const Article = () =>
  into({
    main: article(h2('Article'), p('One into() fills both regions.')),
    side: [h2('Related'), ul(li('One'), li('Two'))],
  })

// Stacks over Home, so Home keeps its DOM and the text you typed.
const Dialog = () =>
  into('main',
    div(
      p('On top of Home.'),
      button('Close', { onClick: () => { dialog = false; update() } }),
    ),
  )

export const RegionApp = () =>
  div(
    Layout(),
    when(() => page === 'home', Home()),
    when(() => page === 'article', Article()),
    when(() => dialog, Dialog()),
  )
Ao vivoUm layout com uma região principal empilhada e uma região lateral, preenchidas de fora dele. Digite em Home, abra o diálogo e feche-o: o texto permanece. Article preenche as duas regiões com um só into().Abrir ↗

No related links.

fnAPI pública
function forceUpdate(): void function forceUpdate(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element): ExpandedElement

update() só executa valores dinâmicos. Valores estáticos, como o texto em h1(labels[lang].title), são fixados quando a árvore é construída. forceUpdate() chama seus componentes de novo e ajusta o DOM ao vivo para corresponder, valores estáticos incluídos. Use-o para mudanças raras e globais no app, como trocar o idioma. Use update() para todo o resto.

i18n.ts
const labels = { en: { title: 'Hello' }, pt: { title: 'Olá' } }
let lang: keyof typeof labels = 'en'

const App = () => div(h1(labels[lang].title))
render(App, root)

lang = 'pt'
forceUpdate() // same <h1> element, now "Olá"
Ao vivoupdate() atualiza só a linha dinâmica de lang. forceUpdate() chama App de novo e aplica as mudanças no mesmo <h1>. A forma explícita reconstrói só esta árvore, não a página inteira.Abrir ↗

lang = 'en'

Hello

Static text, fixed on build.

Sem argumentos, forceUpdate() reconstrói toda raiz renderizada com render(App) ou hydrate(App). Raízes renderizadas com render(App()) são puladas. Se um componente lança um erro, o erro é registrado e aquela raiz mantém seu DOM antigo. Um erro lançado depois, enquanto a nova construção é aplicada, também é registrado, mas as partes já ajustadas permanecem ajustadas.

O que permanece e o que muda

  • Filhos são casados em ordem, por tag. Um elemento correspondente é reutilizado, então o foco e os valores digitados em inputs permanecem, e seus hooks onMount não rodam de novo. Assim que a tag de um filho difere, esse filho e todos os irmãos seguintes são construídos do zero.
  • Texto, atributos, classes, estilos, listeners, ramos de when() e linhas de list() seguem a nova construção.
  • Novos elementos são criados, e seus hooks onMount rodam. Elementos que a nova construção não produz mais são removidos, e seus hooks onDestroy rodam.
  • Linhas de list() também são casadas por posição, não por item, então um elemento de linha reutilizado pode acabar mostrando um item diferente.
  • Atributos que a nova construção não define mais não são removidos.
Atenção: estado declarado dentro de uma função de componente é reiniciado, porque a função roda de novo. Mantenha fora do componente o estado que precisa sobreviver.

Forma explícita. forceUpdate(App(), parent) reconstrói uma árvore em parent e retorna sua raiz. Ele não registra a raiz para chamadas futuras. Como hydrate(), espera que a raiz seja o primeiro nó em parent. Se o primeiro nó tem uma tag diferente, ele é substituído.

No servidor, forceUpdate() não faz nada.

render()

#
fnAPI pública
function render(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element, index?: number): ExpandedElement

Constrói sua árvore e anexa seu elemento raiz a parent. parent tem como padrão document.body. Retorna o elemento raiz.

main.ts
import 'nuclo'
import { App } from './app.ts'

render(App, document.getElementById('app')!)
  • Você pode passar o componente (render(App)) ou o builder que ele retorna (render(App())). O DOM é o mesmo. Apenas render(App) permite que forceUpdate() reconstrua a raiz depois, então prefira essa forma.
  • Ele anexa. Não limpa parent, então chamá-lo duas vezes adiciona duas cópias.
  • Todo onMount da árvore já rodou quando render() retorna.
  • Renderize em um elemento que está no documento. Veja update().
  • A raiz deve ser um elemento, como div(...), não when() ou list().
  • index é interno. Ele não escolhe onde o elemento é inserido. Deixe-o de fora.

Um componente passado como render(App) não deve ter parâmetros. Um parâmetro obrigatório é um erro de tipo. Um opcional passa na checagem de tipos, mas render() lança um erro em tempo de execução. Envolva um componente que recebe props:

main.ts
const Page = (props: { name: string }) => div(h1('Hi ', props.name))

render(() => Page({ name: 'Ana' }), root)

hydrate()

#
fnAPI pública
function hydrate(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element): ExpandedElement

Assume o HTML produzido por renderToString(). Ele reutiliza os elementos existentes, anexa listeners de eventos e conecta valores dinâmicos, when() e list(), para que update() funcione depois. Então executa os hooks onMount. parent tem como padrão document.body. Retorna o elemento raiz.

client.ts
import 'nuclo'
import { App } from './app.ts'

hydrate(App, document.getElementById('root')!)
  • Use o mesmo componente, e os mesmos dados, que o servidor.
  • Hidrate em um contêiner dedicado. A raiz do app deve ser seu primeiro nó, embora espaços em branco sejam aceitáveis. Se outro nó vem primeiro, Nuclo substitui esse nó e deixa a cópia do servidor para trás.
  • Como em render(), passe App, não App(), para que forceUpdate() possa reconstruir a raiz depois. App não deve ter parâmetros. Com um opcional, hydrate() silenciosamente não anexa nada. Envolva um componente que recebe props: hydrate(() => Page(props), root).
  • Se não há HTML do servidor, use render().

Quando o servidor e o cliente diferem

O cliente vence:

  • Uma tag de raiz diferente: esse nó é substituído por DOM novo. Um contêiner vazio: DOM novo é anexado.
  • Texto diferente é corrigido. Itens de list() e ramos de when() diferentes seguem o cliente.
  • Filhos extras do servidor são removidos, e os que faltam são criados.
  • Atributos que o servidor escreveu mas o cliente não define são deixados como estão.

renderToString() escreve comentários HTML como marcadores, como <!-- text-0 --> antes de cada nó de texto. hydrate() precisa deles, então não remova os comentários do HTML do servidor. Sem os marcadores, a hidratação apaga as linhas de list(), e list() e when() param de atualizar.

Renderização no servidor

#
fnAPI pública
// import { ... } from 'nuclo/ssr' function renderToString(input: RenderableInput): string function renderManyToString(inputs: RenderableInput[]): string[] function renderToStringWithContainer(input: RenderableInput, containerTag?: string, containerAttrs?: Record<string, string>): string function getCssText(): string type RenderableInput = NodeModFn | (() => NodeModFn) | Element | Node | null | undefined

Renderize um componente em uma string HTML no servidor. No navegador, chame hydrate() com o mesmo componente para tornar esse HTML interativo. Runtimes de servidor não têm document, então importe nuclo/polyfill primeiro.

app.ts
let count = 0
const heading = css({ color: 'red' })

export const App = () =>
  main(
    h1(heading, () => `Count: ${count}`),
    button({ onClick: () => { count++; update() } }, '+1'),
  )
server.ts
import 'nuclo/polyfill'
import 'nuclo'
import { renderToString, getCssText } from 'nuclo/ssr'
import { App } from './app.ts'

const html = renderToString(App)
const page = `<!doctype html>
<html>
  <head><style id="nuclo-styles">${getCssText()}</style></head>
  <body>
    <div id="root">${html}</div>
    <script type="module" src="/client.js"></script>
  </body>
</html>`
client.ts
import 'nuclo'
import { App } from './app.ts'

hydrate(App, document.getElementById('root')!)

Mantenha id="nuclo-styles" na tag style. O cliente reutiliza esse elemento e adiciona apenas regras novas. Veja getCssText().

O que roda no servidor

  • Cada chamada de renderToString() constrói a árvore e executa cada valor dinâmico uma vez.
  • Handlers de eventos, onMount e onDestroy nunca rodam. update(), scope() e forceUpdate() não fazem nada.
  • A renderização é síncrona. Carregue seus dados primeiro, depois renderize.
  • Nada permanece registrado depois de uma renderização, então um processo pode atender muitas requisições. Passe os dados da requisição como argumentos, como renderToString(Page(user)). Variáveis no nível do módulo são compartilhadas por todas as requisições.
  • Não chame render() no servidor. Ele anexa ao document.body compartilhado.

renderToString()

Recebe um builder (App()), um componente (App) ou um nó do DOM. null e undefined retornam "". A raiz deve ser um elemento. Um componente passado como renderToString(Page) não deve ter parâmetros, então passe Page(user) para um que recebe dados.

example.ts
renderToString(p('Hello'))
// '<p><!-- text-0 -->Hello</p>'
  • Texto e valores de atributos são escapados como HTML. Texto dentro de script() e style() é escrito como está, apenas com as tags de fechamento quebradas, então nunca coloque entrada do usuário ali.
  • Ele não lança erros. Se o componente, uma função de list() ou uma condição de when() lança um erro, ou o polyfill está ausente, ele registra o erro e retorna "". Verifique se o resultado está vazio caso a requisição deva falhar. Uma função de texto ou atributo que lança um erro apenas renderiza vazio.

renderManyToString()

O mesmo que inputs.map(renderToString).

renderToStringWithContainer()

Envolve a saída em um elemento. containerTag tem como padrão "div", e containerAttrs, {}. Valores de atributos são escapados. Nomes de atributos são escritos como fornecidos, então use nomes HTML como class. Nunca passe entrada do usuário como a tag.

example.ts
renderToStringWithContainer(p('Hi'), 'section', { id: 'main', class: 'page' })
// '<section id="main" class="page"><p><!-- text-0 -->Hi</p></section>'

nuclo/polyfill

#
import 'nuclo/polyfill' // Named exports: // document, Event, CustomEvent, Node, Element, HTMLElement, // NucloDocument, NucloElement, NucloText, NucloNode

Um DOM pequeno para que o Nuclo possa construir árvores em Node e Bun. Importe-o uma vez, antes da sua primeira renderização. A maioria dos apps só precisa do import simples.

  • Quando não há window, ele define globalThis.document, Node, Element e HTMLElement, mas apenas os que estão faltando. Ele não cria window.
  • Em um navegador, ou sob jsdom, não faz nada.
  • Event e CustomEvent são os construtores do próprio runtime. Node, Element e HTMLElement são aliases das classes do polyfill.

É um DOM para construir árvores, não um navegador. Não tem innerHTML nem getElementById, querySelector() retorna null, e listeners de eventos são ignorados. Use renderToString() para obter o HTML.

css()

#
fnAPI pública
function css(style: Style): StyleResult function css(name: string, style: Style): StyleResult type StyleResult = { className: string }

Transforma um objeto de estilo tipado em classes CSS geradas. Passe o resultado a qualquer construtor de tag. No navegador, as regras vão para um único elemento <style id="nuclo-styles">. No servidor, getCssText() as retorna.

card.ts
const card = css({
  p: 16,
  rounded: 8,
  bg: '#fff7ed',
  hover: { bg: '#ffedd5' },
})

const Card = () => div(card, 'Simple card')
  • css() cria uma classe para as declarações base, mais uma classe para cada chave de pseudo-estado, seletor e bloco de media. O card acima recebe duas classes.
  • Os nomes de classe são hashes do conteúdo. Estilos idênticos compartilham uma classe, e o servidor e o cliente produzem os mesmos nomes.
  • Defina os estilos uma vez, no nível do módulo. css() faz cache por objeto, então um objeto novo a cada chamada é processado de novo toda vez.
  • Alterar um objeto de estilo depois de passá-lo a css() não tem efeito.
  • Todo estilo permanece registrado pela vida da página ou do processo do servidor. Para valores que mudam por item, como uma largura vinda de dados, use o atributo style.

Estilos nomeados

Passe um nome primeiro para obter um nome de classe legível:

title.ts
const pageTitle = css('page-title', { text: 24, hover: { color: 'red' } })

pageTitle.className // "page-title page-title-<hash>"
  • A classe base é exatamente o nome. Os outros blocos recebem name-<hash>.
  • O nome precisa ser um nome de classe válido. Caso contrário, o Nuclo avisa e gera um.
  • Os nomes são globais. Se dois estilos diferentes usam o mesmo nome, o Nuclo avisa.

Usando o resultado

example.ts
const card = css({ p: 8 })

div(card)                           // best
div(card, { className: 'featured' }) // className merges
div(card, { class: 'featured' })     // replaces: only "featured" is left

String(card) e card.className retornam ambos os nomes de classe.

Objetos de Estilo

#

O objeto que você passa a css() usa propriedades CSS comuns em camelCase, um conjunto de atalhos e chaves aninhadas para pseudo-estados, seletores e media queries. O TypeScript autocompleta todas elas. Propriedades fora do conjunto tipado, como borderTopLeftRadius, fill ou paddingInline, vão em raw (veja abaixo).

Valores

  • Números viram px, exceto 0 e propriedades sem unidade: zIndex, opacity, fontWeight, lineHeight, flex, flexGrow, flexShrink, order, aspectRatio, gridColumn e gridRow. Em raw, zoom, scale, column-count, orphans, widows, tab-size e animation-iteration-count também são sem unidade.
  • Strings são usadas como escritas. null, undefined e false são ignorados.
  • lineHeight (e leading) é sem unidade, então leading: 24 significa 24 vezes o tamanho da fonte. Escreva "24px" para pixels.
example.ts
css({ p: 16, m: 0, opacity: 0.5, leading: 1.5, z: 10, w: '50%' })
// padding:16px; margin:0; opacity:0.5; line-height:1.5; z-index:10; width:50%

Atalhos

ChaveCSS
p, pt, pr, pb, plpadding, e cada lado
px / pypadding esquerdo e direito / superior e inferior
m, mt, mr, mb, ml, mx, myo mesmo para margin
w, h, minW, maxW, minH, maxHwidth, height, e seus min e max
sizewidth e height
bgbackground
text / font / weightfont-size / font-family / font-weight
leading / trackingline-height / letter-spacing
align / items / justifytext-align / align-items / justify-content
z / rounded / shadow / selectz-index / border-radius / box-shadow / user-select

Estas chaves recebem true:

ChaveCSS
rowdisplay: flex; flex-direction: row
coldisplay: flex; flex-direction: column
centeralign-items: center; justify-content: center (não define display)
truncateoverflow: hidden; text-overflow: ellipsis; white-space: nowrap

Pseudo-estados

hover, focus, focusVisible, focusWithin, active, visited, disabled, enabled, checked, required, invalid, valid, readOnly, first (:first-child), last (:last-child), only (:only-child), odd, even, empty, placeholderShown, placeholder, before, after, selection, marker, firstLine e firstLetter. Eles aninham: hover: { focus: {…} } gera :hover:focus.

Seletores e at-rules

Uma chave que começa com & é um seletor, e cada & vira a classe. Uma chave que começa com @ é uma at-rule: @media, @container ou @supports.

menu.ts
const menu = css({
  p: 8,
  '& > li': { py: 4 },
  '& > li:hover': { color: 'red' },
  '&:is(.dark *)': { color: 'white' },
  '@media (min-width: 768px)': { p: 16 },
})
  • A chave precisa começar com &. Uma chave como ".dark &" é ignorada, então escreva "&:is(.dark *)".
  • Dois blocos @media aninhados combinam com and. Uma query iniciada por um tipo de mídia (print, only screen) é colocada primeiro, porque a gramática só permite um tipo no início. Um par que não pode ser achatado — dois tipos de mídia, ou um not inicial — avisa e usa apenas a query interna. Qualquer outra at-rule aninhada substitui a externa.

raw

Use raw para qualquer propriedade, com o nome escrito exatamente como no CSS. Números ainda recebem px, então passe strings:

example.ts
css({ raw: { '--gap': '8px', '-webkit-line-clamp': '2' } })

Qualquer outra chave plana é convertida para kebab-case e escrita como está, então um erro de digitação como colr: 'red' vira uma declaração que o navegador descarta. Um objeto aninhado sob uma chave que não é um pseudo-estado, um screen, ou uma chave & ou @ é ignorado. O TypeScript reporta ambos.

fnAPI pública
function cx(...inputs: ClassInput[]): StyleResult type ClassInput = StyleResult | string | false | null | undefined | ClassInput[]

Combina estilos. Quando duas entradas definem a mesma propriedade, a entrada posterior vence.

example.ts
const base = css({ p: 8, color: 'red' })
const blue = css({ color: 'blue' })

cx(base, blue)                  // padding: 8px; color: blue
cx(base, false, null, 'extra')  // base's classes plus "extra"
div(cx(base, blue), 'Blue text')
  • Entradas falsy são ignoradas, arrays são achatados, e strings são divididas e deduplicadas.
  • Classes geradas para o mesmo bloco (base, o mesmo pseudo-estado, a mesma media query) se fundem em uma nova classe. Outros nomes de classe passam inalterados.
  • Apenas cx() conhece a ordem dos argumentos. Com div(base, blue), ambas as classes são adicionadas, e a regra criada por último vence. Coloque estilos que se sobrescrevem em uma única chamada de cx().
  • Quando o bloco de um estilo nomeado se funde com o bloco de outra entrada, a classe resultante é name-<hash>, não o nome puro. Blocos que não se fundem mantêm sua classe, então cx(pageTitle, 'extra') ainda tem page-title.

Classes dinâmicas

Passe uma função que sempre retorna cx(...):

toggle.ts
const baseButton = css({ px: 12, py: 8, border: '1px solid #ccc' })
const activeButton = css({ bg: '#ff3f00', color: 'white' })

let active = false

const Toggle = () =>
  button(
    () => cx(baseButton, active && activeButton),
    () => (active ? 'Active' : 'Inactive'),
    { onClick: () => { active = !active; update() } },
  )

A função precisa retornar um resultado de estilo na primeira execução. () => active && activeButton renderiza o texto "false" em vez de uma classe.

createCss()

#
fnAPI pública
function createCss<const T extends ThemeConfig>(theme?: T): CssInstance<T> // CssInstance<T> = { css, cx, variants, keyframes, globalStyle, theme }

Retorna seus próprios css, cx, variants, keyframes e globalStyle, vinculados a um tema. Um tema dá nomes a valores: com o tema abaixo, borderColor: 'border' escreve #e5e7eb, e o TypeScript autocompleta os nomes. Cada entrada em screens vira uma chave de estilo para uma media query, como md: {…}.

theme.ts
export const { css, cx, variants } = createCss({
  colors: { primary: '#ff3f00', border: '#e5e7eb' },
  radii: { card: '12px' },
  screens: { md: '(min-width: 768px)' },
})

const panel = css({
  p: 12,
  rounded: 'card',
  border: '1px solid',
  borderColor: 'border',
  md: { p: 24 },
})
Chave do temaUsada por
colorsbg, backgroundColor, color, borderColor e cada lado, outlineColor, caretColor, textDecorationColor
fontsfont, fontFamily
shadowsshadow, boxShadow
radiirounded, borderRadius
screensnovas chaves de estilo, como md: {…}
  • Um nome do tema só é resolvido quando é o valor inteiro. border: "1px solid primary" não é resolvido.
  • Um valor de screen sem @ vira @media <valor>. Um valor que começa com @ é usado como escrito, por exemplo "@container (min-width: 400px)".
  • Screens existem apenas em instâncias criadas com createCss(). O css() global não tem nenhum, mas aceita chaves @media.
  • Todas as instâncias e os helpers globais compartilham uma única folha de estilos. As regras de media dos screens são emitidas na ordem em que os screens são declarados, não na ordem em que você os usa, então liste screens min-width do menor para o maior. Use uma instância de createCss() por app, para que essa ordem seja a do seu tema.
  • theme no resultado é o tema que você passou.

variants()

#
fnAPI pública
function variants(config: { base?: Style variants?: { [group: string]: { [value: string]: Style } } defaultVariants?: { [group: string]: value } compoundVariants?: ({ [group: string]: value } & { css: Style })[] }): (props?: { [group: string]: value }) => StyleResult

Constrói uma função que retorna um estilo para um conjunto de opções, como a intenção e o tamanho de um botão.

button.ts
const buttonClass = variants({
  base: { px: 12, py: 8, rounded: 6 },
  variants: {
    intent: { primary: { bg: '#2563eb' }, danger: { bg: '#dc2626' } },
    size: { sm: { text: 12 }, lg: { text: 18 } },
    block: { true: { w: '100%' } },
  },
  defaultVariants: { intent: 'primary', size: 'sm' },
  compoundVariants: [{ intent: 'danger', size: 'lg', css: { weight: 700 } }],
})

button(buttonClass(), 'Save')                                                // primary, sm
button(buttonClass({ intent: 'danger', size: 'lg', block: true }), 'Delete') // bold, full width
  • Os estilos se fundem nesta ordem: base, o valor escolhido de cada grupo, depois cada compound que corresponder. Estilos posteriores vencem por propriedade.
  • Uma opção ausente, undefined ou null usa o padrão. Um grupo sem padrão é ignorado.
  • Um grupo com apenas as chaves true e false recebe um booleano.
  • Um compound se aplica quando todos os grupos que ele lista correspondem, incluindo os padrões.
  • Cada seleção é construída uma vez e armazenada em cache.

keyframes() & globalStyle()

#
fnAPI pública
function keyframes(frames: KeyframeFrames): string function globalStyle(selector: string, style: FlatStyle): void

keyframes() registra uma animação e retorna seu nome gerado. As chaves dos frames são from, to e porcentagens, como "50%" ou "0%, 100%". Frames aceitam apenas propriedades planas. Frames idênticos retornam o mesmo nome.

fade.ts
const fadeIn = keyframes({
  from: { opacity: 0 },
  to: { opacity: 1 },
})

const panel = css({ animation: `${fadeIn} 200ms ease-out` })

globalStyle() adiciona uma regra para qualquer seletor:

reset.ts
globalStyle('*, *::before, *::after', { boxSizing: 'border-box' })
globalStyle('body', { m: 0, font: 'system-ui, sans-serif' })
  • O seletor é usado exatamente como escrito.
  • Apenas propriedades planas funcionam. Chaves de pseudo-estado, screens e chaves @ são ignoradas, então globalStyle() não pode escrever regras de media.
  • A mesma regra é adicionada uma vez. Um estilo diferente para o mesmo seletor adiciona uma segunda regra, e a posterior vence.

getCssText() & resetStyles()

#
fnAPI pública
function getCssText(): string // from 'nuclo/ssr' or 'nuclo' function resetStyles(): void // from 'nuclo'

getCssText() retorna todas as regras que css(), cx(), variants(), keyframes() e globalStyle() criaram neste processo: regras base primeiro, depois os blocos @media, @container e @supports. Use-a para renderização no servidor.

  • Chame-a depois de renderToString(). Regras criadas durante a renderização são incluídas.
  • Ela cobre o processo inteiro, não uma página ou requisição, e só cresce. Isso é seguro, porque os nomes de classe são hashes do conteúdo.
  • Coloque-a em <style id="nuclo-styles">. O cliente reutiliza esse elemento e pula as regras que já estão nele. Com outro id, ou nenhum, o cliente adiciona uma segunda folha de estilos com todas as regras de novo.
  • O texto não é escapado, então nunca coloque entrada não confiável em valores de estilo.

resetStyles() é um helper de teste. Remove todas as regras e o elemento #nuclo-styles. Estilos criados antes mantêm seus nomes de classe mas perdem seu CSS. Não a chame em um app ou em um servidor.

setup.test.ts
import { resetStyles } from 'nuclo'

beforeEach(() => resetStyles())

nuclo-router

#
Pacote separado: nuclo-router não faz parte do pacote principal do Nuclo. Instale-o junto com nuclo. É uma versão preview, então sua API ainda pode mudar.

Roteamento para o Nuclo: renderização no servidor, hidratação, rotas com code splitting e pré-carregamento em idle, construído sobre list() e update(). Cerca de 4.9 KB gzipado, sem dependências além do próprio Nuclo e sem efeitos colaterais, então é removido pelo tree-shaking quando não usado.

terminal
$ npm install nuclo nuclo-router

Ou use pnpm add, yarn add ou bun add com os mesmos dois nomes. Requer nuclo 0.3 ou posterior como peer dependency.

O roteador decide o que carregar. O app diz onde com router.outlet(): um list() sobre a pilha de camadas, uma linha por camada, então uma página comum é uma única linha. O servidor e o cliente constroem a mesma árvore, então hydrate() reivindica os nós do servidor em vez de substituí-los. Todo o resto é history, um listener de click delegado e requestIdleCallback.

Um import

main.ts
export const router = createRouter(routes)   // define once, import anywhere
await router.start()                          // resolve the current URL
render(App, document.getElementById('app')!) // App places router.outlet() and reads router.path, router.go(), …

start() resolve os módulos da rota correspondente antes de retornar, então a primeira árvore está completa tanto no servidor quanto no cliente. Depois disso o roteador lê diretamente a rota que iniciou: router.path, router.pending, router.go().

Um app completo

routes.ts
// isomorphic: imported by the browser and the server
import { createRouter } from 'nuclo-router'

export const router = createRouter({
  '/':           () => import('./pages/Home.ts'),
  '/docs':       () => import('./pages/Docs.ts'),
  '/blog/:slug': () => import('./pages/Post.ts'),
  '*':           () => import('./pages/NotFound.ts'),
})

export const App = () =>
  div(
    header(a({ href: router.href('/') }, 'Home'), a({ href: router.href('/docs') }, 'Docs')),
    main(
      when(() => router.pending, Spinner()),
      when(() => router.error !== null, ErrorView()),
      router.outlet(),   // where the pages land
    ),
    footer('© nuclo'),
  )
pages/Post.ts
import type { RouteContext } from 'nuclo-router'
import { router } from '../routes.ts'

// A page is plain content
export default function Post(ctx: RouteContext) {
  // A query-only change keeps the page, so the query is read live.
  return article(h1(ctx.params.slug), p(() => (router.search.has('preview') ? 'draft' : '')))
}
main.ts
import 'nuclo'
import { router, App } from './routes.ts'

await router.start()
hydrate(App, document.getElementById('app')!)   // or render() without SSR
server.ts
import 'nuclo/polyfill'
import 'nuclo'
import { renderToString } from 'nuclo/ssr'
import { router, App } from './routes.ts'

// one Route per request
const route = await router.start(request.url)
const html = route.run(() => renderToString(App))

Essa é toda a integração. Os links funcionam porque um <a href> é só um <a href>. Um listener delegado em document intercepta os links que o roteador possui e deixa todos os outros para o navegador.

No servidor, start() retorna um Route por requisição e não mexe no roteador, então requisições concorrentes não conseguem ver a rota uma da outra. route.run() torna esse Route aquele que o roteador, e portanto o outlet, lê durante a renderização. No navegador há um Route por carregamento de página, e start() o torna o do próprio roteador. Nenhum run() é necessário.

Uma página recebe seu RouteContext e suas PageProps, como (ctx, { layer, data, outlet, beforeLeave }), e pode receber menos argumentos. Ela retorna qualquer coisa que list() aceite. Para preencher também outra parte do layout, retorne um into() de dentro da página, como div(article(…), into('sidebar', nav(…))). Esse conteúdo sai junto com a página. Veja Páginas & Regiões.

PadrãoCorresponde aparams
/, /docs/introexatamente esse caminho{}
/blog/:slug/blog/hello{ slug: 'hello' }
/u/:id/edit/u/7/edit{ id: '7' }
/files/*rest/files/a/b.txt{ rest: 'a/b.txt' }
*qualquer coisa sem correspondência, o seu 404{ '*': '…' }

A precedência é: estáticos, depois dinâmicos em ordem de declaração, depois catch-all com o prefixo mais longo primeiro. Então '*' só corresponde ao que nada mais corresponde e pode ficar em qualquer lugar da tabela. Barras finais, barras duplicadas e percent-encoding são normalizados, e os valores dos params chegam decodificados. A correspondência é uma comparação de segmentos por padrão. Nenhuma regex é construída ou executada.

Rotas relativas

Um padrão escrito com ./ é relativo ao pai que o declara, e um href escrito com ./ é relativo à rota ativa. Juntos eles formam uma seção reutilizável que significa a mesma coisa sob qualquer pai. O valor de uma tabela é um carregador ou outra tabela, cujas chaves são relativas a ela:

records/routes.ts
import type { RouteTable } from 'nuclo-router'

// a section, with no idea where it lives
export const recordSection: RouteTable = {
  '/':             () => import('./Record.ts'),   // the parent path itself
  './preview':     () => import('./Preview.ts'),
  './preview/raw': () => import('./Raw.ts'),
}
routes.ts
// declare it wherever it belongs
createRouter({
  '/': () => import('./pages/Home.ts'),
  '/invoices/:id':  recordSection,
  '/customers/:id': recordSection,
  '*': () => import('./pages/NotFound.ts'),
})

Essas duas linhas produzem todos os seis padrões sem nenhum caminho escrito duas vezes. Ambos os pais recebem os mesmos objetos de carregador, então compartilham um único cache de módulos e um único import().

  • Dentro de uma tabela filha, './x', '/x' e 'x' significam a mesma coisa, '/' significa o próprio caminho do pai, e '*rest' dá à seção seu próprio catch-all. O aninhamento vai tão fundo quanto você quiser.
  • Chaves de nível superior são usadas como escritas, então '*' continua '*'.
  • Um filho sob um pai *catch-all é um erro de createRouter(), já que um catch-all precisa encerrar seu padrão.

Páginas pai e outlet()

Um pai e seus filhos formam uma hierarquia real. O pai renderiza, e seu filho renderiza dentro dele, onde quer que ele chame outlet():

records/Record.ts
import type { PageProps, RouteContext } from 'nuclo-router'

// a parent page takes its outlet from (ctx, { outlet })
export default function Record(ctx: RouteContext, { outlet }: PageProps) {
  return div(Header(ctx), input({ id: 'notes' }), main(outlet()))
}

Navegar entre filhos não reconstrói o pai. Seu nó DOM é o mesmo nó, então seu foco, sua posição de scroll, seu input digitado pela metade e qualquer estado em sua closure sobrevivem.

  • Uma página sem rota filha não renderiza nada do seu outlet, então um layout pode chamá-lo incondicionalmente. Um pai que tem um filho e nunca chama outlet() o engole.
  • Não há rotas index. '/' é o próprio layout, então o caminho do pai renderiza o layout com um outlet vazio em vez de uma página index separada dentro dele. Coloque o conteúdo inicial no layout, ou dê a ele um caminho filho real.

A mesma chamada então funciona a partir de qualquer dos pais, e a partir de uma página que não sabe em qual deles está:

example.ts
router.href('./preview')      // '/invoices/42/preview' or '/customers/7/preview'
await router.go('./preview')
await layer.push('./preview') // the usual shape: a child layer

./x, ../x, . e .. são resolvidos contra o caminho da rota atual, não contra o "diretório" da URL. A partir de /invoices/42, ./preview é /invoices/42/preview, onde um navegador diria /invoices/preview, e uma barra final na URL não muda nada. ../ sobe um segmento e para na raiz. A query e o hash são preservados, base é adicionado de novo, e o caminho absoluto resolvido é o que entra no history. Este é o mesmo desvio da resolução de URL que o React Router faz para <Link to="./x">.

  • Apenas ./ e ../ são relativos à rota. Um preview puro em um href fica sujeito às regras comuns do navegador.
  • Construa links relativos com router.href('./preview') em vez de escrever href="./preview" à mão. href() resolve no momento da renderização, então o markup entregue é absoluto e continua correto para um crawler, para um clique com o botão do meio e com JavaScript desabilitado.

O que é mantido, e o que é reconstruído

NóNa navegação
um pai cujo padrão e caminho não mudarammantido: mesmo nó, e seu load() não é executado de novo
a página correspondentemantida quando seu padrão e caminho não mudaram e ela não tem load(), então uma mudança de query ou hash a mantém. Uma rota com carregador é atualizada a cada visita.
um pai cujo caminho mudoureconstruído, junto com tudo abaixo dele
uma camada empilhadarenderiza apenas a página correspondente. Seus pais já estão montados na linha abaixo dela.

Uma página mantida mantém o ctx com que foi construída. Esse é o significado de ctx em todo lugar: leia router.* para valores ao vivo. Estado dirigido pela query, como filtros, paginação e abas, lê router.search dentro de um valor dinâmico e escreve com router.go('?page=2', { replace: true }). A página, seu foco e seu input digitado pela metade ficam no lugar.

Páginas & Regiões

#

router.outlet() coloca a página em um lugar. Uma página que também precisa de uma sidebar, uma barra de ferramentas ou um título em outro ponto do layout o preenche com into(). O layout marca o lugar com region(), e a página retorna um into() ao lado do seu próprio conteúdo. O layout nunca recebe conteúdo de página como prop, e a navegação nunca o reconstrói.

app.ts
export const App = () =>
  div(
    aside(
      input({ placeholder: 'Filter' }),   // keeps its text across navigations
      region({ id: 'sidebar', empty: p('Nothing to show.') }),
    ),
    main(router.outlet()),
  )
pages/Post.ts
import type { PageProps, RouteContext } from 'nuclo-router'

export default function Post(_ctx: RouteContext, { data: post }: PageProps<Post>) {
  return div(
    article(h1(post.title), p(post.body)),
    into('sidebar', nav(h2('In this post'), ul(list(() => post.headings, (h) => li(h))))),
  )
}

O conteúdo da página é uma linha do list() do outlet, e um into() vive enquanto a linha que o contém viver. Então o conteúdo da sidebar chega com a página e sai com ela: navegar para uma página sem um into('sidebar', …) mostra o conteúdo empty da região de novo. A página pode usar seu ctx e seu data para ambas as partes, já que ambas são construídas pela mesma função.

O que acontece na navegação

  • Uma página mantida mantém suas regiões. Uma mudança de query ou hash que mantém a página também não toca no conteúdo da região. Leia router.search dentro de um valor dinâmico se a sidebar depender dele.
  • Uma página reconstruída as preenche de novo. Quando a rota muda, a linha antiga é removida e as views into() da nova página assumem. Nada fica para trás.
  • Uma camada empilhada adiciona uma view. A camada é uma linha posterior do outlet, então seu into() é uma view mais nova da mesma região. Com o padrão type: 'latest' ela cobre a view da página até a camada fechar, e então a view da página aparece de novo. Com type: 'stack' ambas aparecem.
  • Uma página pai também pode preencher regiões. Suas views permanecem enquanto seus filhos mudam, porque o pai não é reconstruído.
  • A renderização no servidor funciona como está. O servidor constrói a página dentro de route.run(), então suas views into() estão no HTML, e hydrate() as reivindica.

Várias regiões a partir de uma página

Passe um objeto para preencher mais de uma região de uma vez. O conteúdo principal da página continua passando pelo outlet, então retorne-o ao lado do into():

pages/Report.ts
export default function Report(ctx: RouteContext) {
  return div(
    table(…),
    into({
      toolbar: [button('Export'), button('Print')],
      sidebar: nav(a({ href: router.href('./summary') }, 'Summary')),
    }),
  )
}

Qual ferramenta para qual trabalho

  • outlet() é para uma rota filha: conteúdo que a URL decide, renderizado dentro de sua página pai. Veja Rotas Aninhadas.
  • into() é para a página correspondente se espalhar pelo layout. O conteúdo pertence à página e usa seus dados.
  • matches() é para o layout decidir apenas a partir da URL, com sua própria tabela pequena e sem página envolvida. Veja matches().

Tudo isso pertence à única rota correspondente. O roteador não dirige duas áreas independentes a partir de uma URL, então uma sidebar com suas próprias rotas é uma região que uma página preenche, não um segundo outlet. Uma sidebar que realmente quer seu próprio roteador, carregamento e fallback mantém um não iniciado e resolve com match() — veja Múltiplos Roteadores & Carregamento Combinado.

Carregadores de Rota & Code Splitting

#

Um módulo de página pode exportar uma função load. Ela roda antes da página ser construída, em toda navegação para essa rota, e o que ela retorna é entregue à página como data:

pages/Post.ts
import type { DataLoader, PageProps, RouteContext } from 'nuclo-router'

export const load: DataLoader<Post> = (ctx) => fetchPost(ctx.params.slug)

export default function Post(_ctx: RouteContext, { data: post }: PageProps<Post>) {
  return article(h1(post.title), p(post.body))
}

A tabela de rotas não muda. O carregador viaja com a página. Ele recebe o RouteContext correspondente, então params, search e path estão todos disponíveis. Pode ser síncrono ou assíncrono, e roda também no servidor, então uma página renderizada no servidor é entregue com seus dados já no HTML em vez de buscá-los depois da hidratação.

O módulo é cacheado. Os dados não. O chunk de uma rota é buscado uma vez e reutilizado, e seu carregador roda de novo em toda navegação. É por isso que /blog/a e /blog/b não podem ser confundidos, e por que um carregador que falhou pode simplesmente ser tentado de novo.

SituaçãoComportamento
enquanto ele rodarouter.pending é true e a página em que você estava continua na tela
ele lança ou rejeitacai em router.error. go() continua nunca rejeitando, e a navegação pode ser tentada de novo.
durante start()a rejeição sai de start(). Ainda não há Route onde colocá-la.
uma camada empilhadaroda seu próprio carregador, com seus próprios dados
o pré-carregador em idleaquece apenas módulos. Não tem contexto para passar, e estaria buscando dados para uma rota que você talvez nunca abra.
navegar de novo para a mesma URLo carregador roda de novo e a linha é reconstruída. Uma rota sem carregador mantém sua linha.

Para revalidar, navegue para onde você já está sem adicionar uma entrada no history:

example.ts
await router.go(router.url, { replace: true })   // runs load() again

Iniciando os dados junto com o chunk

Um load exportado pelo módulo da página só pode rodar depois que esse módulo chegou, então uma navegação a frio busca o chunk e depois os dados. Para iniciar ambos de uma vez, declare o carregador na tabela com withLoad():

routes.ts
import { createRouter, withLoad } from 'nuclo-router'
import { loadPost } from './data.ts'   // small, in the main bundle

createRouter({
  '/blog/:slug': withLoad(() => import('./pages/Post.ts'), loadPost),
})

Ele substitui o load do próprio módulo e roda a cada visita como ele. Uma rejeição enquanto o chunk ainda está carregando cai em router.error como qualquer outra.

Não há serialização dos dados do servidor no HTML, por design. O load do cliente roda de novo na hidratação. Se isso importar, faça o servidor emitir route.data, por exemplo como window.__DATA__, e o carregador usá-lo primeiro. route.data são apenas os dados da página correspondente. O resultado do load() de um pai não está nele.

Code splitting e pré-carregamento

O valor de uma rota é sempre uma função que retorna o componente da página, então o componente é alcançado de forma lazy:

routes.ts
'/a': () => import('./A.ts'),                      // default export (+ optional load)
'/b': () => import('./B.ts').then(m => m.BPage),   // named export
'/c': () => CPage,                                 // eager, not split
'/d': () => ({ default: DPage, load: loadD }),     // eager, module-shaped

Apenas as formas com formato de módulo podem carregar um export load. Escolher um componente nomeado com .then(m => m.BPage) entrega ao roteador apenas a função.

Cada módulo é importado uma vez e cacheado. Navegações concorrentes para a mesma rota compartilham um único import(). Depois que o usuário deixa a página quieta por idleTimeout (um segundo por padrão; sem toques, teclas, roda do mouse ou scroll), as rotas restantes são carregadas em segundo plano, uma por fatia de idle (requestIdleCallback). Qualquer entrada, ou uma navegação pela qual o usuário está esperando, pausa isso até a página ficar idle de novo.

Uma rota pré-carregada ou eager nunca liga router.pending e aparece antes do próximo paint: nenhum frame de spinner, nenhum layout shift. router.pending só é true para um módulo ou carregador que precisa ser aguardado, e a página de saída fica na tela enquanto isso. Um chunk que falha ao carregar aparece como router.error, nunca como uma rejeição não tratada, e pode simplesmente ser tentado de novo.

API do Roteador

#
fnAPI pública
// import { ... } from 'nuclo-router' function createRouter(table: RouteTable, options?: RouterOptions): Router function withLoad(loader: RouteLoader, load: DataLoader): RouteLoader interface RouterOptions { base?: string; preload?: boolean; idleTimeout?: number; onNavigate?: (ctx: RouteContext, kind: NavigationKind) => void }

createRouter(table, options?)

table mapeia um padrão a um carregador, ou a uma tabela aninhada. Opções:

OpçãoPadrão
base'/'Prefixo de URL sob o qual o app é servido, como '/docs'. Só corresponde em um limite de segmento: /docs/intro está dentro de '/docs', /docsearch não. URLs fora dele não correspondem a nada, então start() rejeita (seu 404 do lado do servidor) e links para elas ficam para o navegador.
preloadtrueCarregar os módulos das rotas restantes em segundo plano quando a página ficar idle. false desliga.
idleTimeout1000Milissegundos sem toques, teclas, roda do mouse ou scroll antes do pré-carregamento começar, ou retomar depois que o usuário esteve ocupado.
onNavigate(ctx, kind) => void, chamado depois de toda navegação resolvida no navegador, incluindo a inicial, quando o DOM já foi atualizado. kind é 'start', 'push', 'replace', 'pop' (Voltar, Avançar, close()) ou 'layer'. O gancho para document.title, meta tags, foco, scroll e analytics. Nunca chamado durante SSR.

Retorna o roteador: start(), match(), outlet(), matches(), e todos os membros de Route exceto run(), lidos diretamente do Route ativo. Esse é aquele que o último start() retornou no navegador, ou aquele cujo run() está executando no servidor. Ler um membro sem nenhum dos dois lança um erro. A tabela de rotas, o matcher compilado e o cache de módulos vivem no roteador, então são compartilhados entre requisições em um servidor.

router.start(url?)

Resolve url (padrão: location.href no navegador) para seus módulos de página e retorna uma Promise<Route>. Rejeita quando nada corresponde e a tabela não tem rota '*'. Em um servidor, esse é o seu 404. No navegador também torna o Route o ativo do roteador, anexa os listeners de popstate e de click delegado e agenda o pré-carregamento. Um segundo start() aposenta o Route anterior para você. No servidor não toca em nada compartilhado, então renderize dentro de route.run().

router.match(url?)

Resolve uma URL contra a tabela sem carregar nada: sem import, sem history, sem listeners, sem Route. Retorna um RouteContext, ou null quando a URL está fora de base, não corresponde a nenhum padrão ou, no navegador, está em outra origem. Apenas URLs absolutas, já que a resolução de ./ precisa de uma rota ativa.

example.ts
// 404 before importing a single chunk
if (!router.match(request.url)) return new Response(null, { status: 404 })

// "is this link ours?" for a custom link component or a prefetch rule
const owned = router.match(href) !== null

router.outlet()

As páginas da rota ativa como um list(). Coloque-o uma vez, onde as páginas devem ir. Uma camada empilhada é uma linha posterior dele. Está vazio antes de start() resolver e depois de stop(). No servidor renderiza o Route cujo run() está executando.

router.matches(table)

Renderiza a view de uma segunda tabela que o caminho da rota ativa corresponde, como um list() que você coloca em qualquer lugar. Veja matches().

Route

Todos os membros exceto run() também estão no roteador, que é onde um app os lê.

Membro
path, pattern, params, search, hash, urla correspondência ativa. search é um URLSearchParams. search, hash e url são ao vivo.
pendinguma navegação está esperando um módulo ou um carregador
erroro último carregamento que falhou, limpo pela próxima navegação
datao que o load() da página correspondente retornou, para um servidor serializar. O de um pai não está nele.
depthquantas camadas estão na pilha (1 para uma página comum)
go(href, { replace })navegar, carregando o módulo se necessário. Nunca rejeita: um módulo que falha, rejeitando ou lançando, cai em error.
push(href)abrir uma rota como uma nova camada por cima, resolvendo com o que ela fechar. Aceita ./. Rejeita se não houver correspondência ou se o módulo falhar.
href(path)prefixar um caminho com base, para a({ href }). Resolve ./ e ../ contra a rota ativa.
stop()desanexar todos os listeners, descartar camadas abertas, esvaziar o outlet. Idempotente. Um Route parado ainda responde com sua última correspondência, e go() não faz nada.
run(fn)chamar fn com este Route como o ativo do roteador e retornar seu resultado: route.run(() => renderToString(App)) no servidor. Apenas síncrono. O Route deixa de ser ativo quando fn retorna.

RouteContext

O primeiro argumento de uma página: path, pattern, params, search, hash, url. É o contexto com que a página foi construída. Leia router.* para valores ao vivo. Eles diferem apenas quando a query ou o hash mudou sem uma reconstrução.

params é { [name]: string } por padrão. Uma página pode tipá-lo a partir do seu próprio padrão com Params, que transforma '/blog/:slug' em { slug: string } e '/files/*rest' em { rest: string }:

pages/Post.ts
import type { Params, RouteContext } from 'nuclo-router'

export default function Post(ctx: RouteContext<Params<'/blog/:slug'>>) {
  return article(h1(ctx.params.slug))   // slug: string, nothing else exists
}

PageProps

O segundo argumento de uma página. Desestruture o que a página precisar.

Prop
layeronde a página está na pilha. Veja Layer abaixo.
datao que o load() da rota retornou, ou undefined sem um. Tipado por PageProps<TData>.
outletonde uma página pai renderiza sua rota filha. Veja Rotas Aninhadas.
beforeLeaveregistra um guard consultado antes desta página ser descartada. Veja Guards de saída.

Layer

Onde uma página está na pilha, e sua saída.

Membro
depth0 para a página base, 1 em diante para camadas empilhadas
push(href)abrir outra camada de dentro de uma página, igual a Route.push()
close(result?)fechar esta camada e tudo acima dela, resolvendo o push() que a abriu. Sem argumento significa descartada. Sem efeito na profundidade 0.

DataLoader e withLoad()

DataLoader é (ctx: RouteContext) => TData | Promise<TData>, o export opcional load de um módulo de página. withLoad(loader, load) cria uma rota cujo load começa junto com o import do seu chunk em vez de depois dele. Veja Carregadores de Rota.

Tipos

Todo tipo é um export nomeado de nuclo-router, então uma página, um carregador ou um guard pode ser tipado sem importar de um caminho.

TipoO que é
Params<'/blog/:slug'>o objeto de params para um padrão. RouteParams é a forma não tipada, e Match é o resultado do matcher.
PageComponentuma página: (ctx, { layer, data, outlet, beforeLeave }) => …
RouteModuleum módulo de página: o próprio componente, ou { default, load? } como import() produz
RouteLoader() => RouteModule | Promise<RouteModule>, opcionalmente carregando um load de withLoad()
RouteTablea tabela que createRouter() recebe: padrão para carregador, ou para uma tabela aninhada
RouterOptions{ base?, preload?, idleTimeout?, onNavigate? }
NavigateOptions{ replace? }, o segundo argumento de go()
NavigationKind'start' | 'push' | 'replace' | 'pop' | 'layer', o segundo argumento de onNavigate
Outleto tipo de outlet() de PageProps
LeaveGuard(to) => boolean | void | Promise<…>, o que beforeLeave() recebe
MatchViewo que uma view de matches() é: params entram, conteúdo ou import() de uma página sai

Camadas & Guards de Saída

#

push() abre uma rota por cima da atual em vez de substituí-la, e resolve com o que essa camada fechar. Como as páginas são um único list() sobre a pilha, abrir uma camada é um append. A página abaixo não é renderizada de novo, então seu DOM, seu foco e seu formulário preenchido pela metade continuam lá quando a camada fecha.

Isso faz o fluxo "preciso de um registro que ainda não existe" funcionar sem jogar fora o formulário que o usuário estava preenchendo:

pages/Edit.ts
// the page underneath
const created = await layer.push<Option>('/options/new')
if (created) { options.push(created); selected = created.id; update() }
pages/NewOption.ts
// the pushed page: (ctx, { layer })
layer.close(created)   // resolves the push() above with the new option
layer.close()          // dismissed: resolves with undefined

Camadas aninham. Um diálogo que precisa do seu próprio diálogo chama layer.push() e o aguarda, sem nenhuma referência ao Route.

  • A URL é a camada do topo. push() adiciona uma entrada no history, então uma camada é linkável e Voltar a fecha, resolvendo seu push() como descartado, exatamente como Cancelar. Voltar e close() são um único caminho de código: close() registra seu resultado e volta no history, e a profundidade em history.state é a autoridade. Navegadores mantêm cerca de 50 entradas por aba, então depois de um longo passeio por camadas a entrada abaixo pode ter sumido: então close() fecha as camadas no lugar e reescreve a entrada atual para a página que ficou visível.
  • Uma navegação comum limpa a pilha. Um clique em link ou go() não é uma camada. Substitui tudo e descarta toda camada aberta, então nenhum chamador fica aguardando.
  • Um carregamento a frio é uma página independente. A URL nomeia a camada do topo mas não a pilha abaixo dela, então abrir a URL de uma camada diretamente a renderiza na profundidade 1 com layer.depth === 0 e um close() inerte. Uma rota empilhada precisa fazer sentido sozinha. Ramifique em layer.depth para oferecer um link de saída em vez de um botão Cancelar morto.
  • A promise não sobrevive a um reload, porque vive em uma closure. Trate o resultado como um atalho por um fluxo que também funciona pelo caminho longo.
  • Nada fica aguardando. stop() descarta toda camada aberta, e uma camada abandonada por uma navegação resolve com undefined.

Guards de saída

Uma página que guarda algo que vale manter, como um formulário preenchido pela metade, registra um guard. Ele é consultado antes da página ser descartada, e retornar false (ou uma promise dele) mantém a página:

pages/Edit.ts
import type { PageProps, RouteContext } from 'nuclo-router'

export default function Edit(_ctx: RouteContext, { beforeLeave }: PageProps) {
  let dirty = false
  beforeLeave(() => !dirty || confirm('Discard your changes?'))
  return form(input({ onInput: () => (dirty = true) }), …)
}
  • Só quando a página sai. Uma navegação filha sob um layout, ou uma mudança de query ou hash que mantém a página, não consulta. Um guard vive e morre com sua página, então não há nada para desregistrar.
  • Todas as saídas. Um link, go(), Voltar e Avançar, o close() de uma camada, e go() para uma URL que o roteador não possui (saindo do app, onde to é null). Um Voltar ou Avançar recusado é desfeito, então a barra de endereço fica na página. Fechar a aba ou recarregar é trabalho do beforeunload.
  • to é o RouteContext para onde a navegação está indo. Guards rodam do mais interno primeiro e param na primeira recusa. Um que lança erro mantém a página. close(result) também passa pelo guard, então limpe sua flag antes de fechar com um resultado.
  • O roteador só consegue desfazer entradas de history que ele mesmo escreveu. Um Voltar ou Avançar para uma que ele não escreveu, como um link #fragment nativo ou o pushState de outro script, não é consultado.

matches() & Sub-roteadores

#

router.outlet() renderiza a página. router.matches(table) renderiza outra coisa que depende de onde você está, como uma sidebar, breadcrumbs ou uma barra de ferramentas, a partir da sua própria tabela pequena, sem carregar uma rota ou adicionar uma página:

app.ts
div(
  aside(
    router.matches({
      '/':           () => nav('Home links'),
      '/blog/new':   () => nav('Drafting tips'),
      '/blog/:slug': ({ slug }) => nav(`More like ${slug}`),
      '*':           () => import('./Sidebar.ts'),   // code-split, rendered once loaded
    }),
  ),
  main(router.outlet()),
)
  • Mesma correspondência da tabela de rotas. Os padrões, a precedência, a correspondência de caminho bruto e a remoção de base são os do próprio roteador, então uma view e uma página sempre concordam sobre a URL. Ele lê o caminho da camada do topo, como router.path.
  • Uma view é (params, ctx) => conteúdo, onde params é tipado a partir do seu padrão e ctx é o RouteContext com o pattern e os params da view. Ela pode, em vez disso, retornar o import() de um módulo de página, que é chamado com ctx quando chegar.
  • Nada corresponde, nada renderiza. Adicione uma view '*' como fallback. Está vazio antes de start() e depois de stop(), como o outlet.
  • Só renderiza de novo quando a rota muda, então uma view mantém seu DOM através de qualquer coisa que mantenha a página.
  • Uma view lazy não renderiza nada até seu módulo chegar. Um import() que falha é registrado com console.error, não define router.error, e é tentado de novo na próxima navegação. Um update() comum não tenta de novo.
  • No servidor uma view lazy não entrega nada, já que seu módulo chega no cliente. Use uma view síncrona para qualquer coisa que o primeiro HTML precise conter.

Sub-roteadores

Rotas aninham, então um sub-roteador normalmente é só uma tabela aninhada. Um roteador: a própria página '/' da seção é o seu chrome, e seus filhos renderizam no outlet() dessa página, então o chrome sobrevive à navegação dentro da seção:

routes.ts
createRouter({ '/': …, '/docs': docsRoutes, '*': … })

Ou dê à funcionalidade um roteador próprio, criado mas nunca iniciado. O pai possui uma única entrada catch-all e o filho resolve o restante contra seus próprios padrões, params e fallback:

routes.ts
// parent table
'/docs/*rest': () => import('./docs/Shell.ts'),
docs/Shell.ts
const docs = createRouter(docsRoutes, { base: '/docs' })
const hit = docs.match()                       // { pattern: '/:topic', … }
const page = await docsRoutes[hit.pattern]()   // the app's own lookup

Nunca iniciá-lo é o ponto. start() é o que anexa os listeners, e dois roteadores iniciados entram em conflito. A troca é que o filho é dono da resolução, então os carregadores de rota, que um roteador iniciado executa, ficam por sua conta chamar. Esse formato também reconstrói o shell quando o restante muda, porque o roteador vê uma rota e o shell resolve o resto sozinho. Uma tabela aninhada é o que lhe dá um pai que o roteador mantém montado, então prefira-a a menos que a funcionalidade realmente precise do seu próprio roteador, base e fallback.

Múltiplos Roteadores & Carregamento Combinado

#

Um app pode ter tantos roteadores quantas áreas tiver. A regra que mantém isso simples: inicie exatamente um por documento, e deixe todos os outros roteadores não iniciados, resolvendo URLs com match(). Apenas o roteador iniciado possui a URL, o history e o listener de clique. Os outros são tabelas às quais você faz perguntas — e cada área pode carregar no seu próprio ritmo, com seu próprio spinner.

Carregamento combinado: uma cadeia de carregadores

Toda página em uma cadeia correspondente pode exportar load(). Em uma navegação a frio para uma rota filha, o carregador do layout e o carregador da página rodam em paralelo, e um único router.pending cobre a cadeia inteira: a página anterior fica na tela até o par resolver, então uma árvore carregada pela metade nunca é pintada. Cada componente recebe seu próprio data:

routes.ts
'/shop': {
  '/':          () => import('./pages/Shop.ts'),    // layout, has a load()
  './:section': () => import('./pages/Section.ts'), // page, has a load()
},
pages/Shop.ts
import type { DataLoader, PageProps, RouteContext } from 'nuclo-router'

export const load: DataLoader<ShopData> = async () => {
  await wait(500)                              // a slow API
  return { sections: ['toys', 'books', 'music'] }
}

// the layout gets its own data, and its outlet for the child page
export default function Shop(_ctx: RouteContext, { data, outlet }: PageProps<ShopData>) {
  return div(
    h1('Shop'),
    nav(...data.sections.map((s) => a({ href: router.href(`/shop/${s}`) }, s))),
    main(outlet()),
  )
}
pages/Section.ts
export const load: DataLoader<SectionData> = async (ctx) => {
  await wait(900)                              // slower than the layout's
  return { section: ctx.params.section, items: catalog[ctx.params.section] }
}

// the page gets its own data: the two loaders ran beside each other
export default function Section(_ctx: RouteContext, { data }: PageProps<SectionData>) {
  return article(h2(data.section), ul(...data.items.map((i) => li(i))))
}

Ambos os carregadores começam juntos, não um depois do outro. router.pending é true enquanto qualquer um dos dois é aguardado, e false no resto do tempo — uma rota pré-carregada ou eager nunca o liga. Mover-se entre seções mantém o layout, então apenas o carregador da seção roda de novo; os dados do layout, seu DOM e um input digitado pela metade sobrevivem. Navegar de novo para a mesma URL roda de novo o carregador da folha e reconstrói sua linha, exatamente como com uma única página.

Carregamento múltiplo: um segundo roteador com seu próprio spinner

Uma área independente — uma sidebar, uma bandeja, uma região ao vivo — recebe um roteador próprio, criado mas nunca iniciado, e sua própria flag de carregamento. router.pending pertence ao roteador iniciado e não diz nada sobre essa área, então as duas podem carregar lado a lado e qualquer uma pode terminar primeiro:

sidebar.ts
import { createRouter } from 'nuclo-router'

// Created but never started: start() is what attaches the history and click
// listeners, and only the main router is started. match() attaches nothing.
const standIn = () => ({ default: () => div() })   // never rendered

const sidebarRouter = createRouter({
  '/shop':          standIn,
  '/shop/:section': standIn,
  '*':              standIn,
}, { base: '/docs' })              // the same base as the main router

export const sidebar = { loading: false, info: null as SidebarInfo | null }

export async function refreshSidebar(at: string) {
  const hit = sidebarRouter.match(at)          // the second router resolves
  if (!hit || (hit.pattern !== '/shop' && hit.pattern !== '/shop/:section')) {
    sidebar.info = null; update(); return
  }
  sidebar.loading = true                       // its own flag, its own spinner
  sidebar.info = null; update()
  const data = await fetchSidebar(hit.params.section)
  sidebar.loading = false; sidebar.info = data
  update()
}
routes.ts
createRouter(routes, {
  onNavigate: (ctx, kind) => {
    announce(kind)
    refreshSidebar(ctx.url)   // every navigation re-resolves the sidebar
  },
})

O segundo roteador é consultado, nunca comandado: match() lê a URL e retorna um contexto, e nada sobre a sidebar jamais entra na tabela de rotas principal. onNavigate roda depois de toda navegação resolvida, incluindo a inicial, então é o único lugar que mantém uma área independente em sincronia com a URL.

  • pending pertence ao roteador iniciado. Um roteador nunca iniciado não tem Route, nem pending, nem error. A área acompanha suas próprias flags, e um segundo spinner pode ficar ao lado do primeiro. Na demo a sidebar carrega por 800ms enquanto os carregadores da rota rodam por 900ms, então a página pode terminar antes da sidebar ou o contrário.
  • match() não anexa nada. Qualquer número de roteadores não iniciados pode compartilhar o documento; cada um tem sua própria tabela, base e cache de módulos, e nenhum deles toca no history. Custam uma tabela e um matcher.
  • Seus carregadores nunca rodam. O roteador iniciado executa o load() das páginas que ele corresponde. Um roteador nunca iniciado apenas resolve, então se suas views precisam de dados, busque-os você mesmo — o refreshSidebar() da demo. Se você quer carregadores, uma tabela aninhada ou uma região que uma página preenche mantém esse trabalho com o roteador iniciado.
  • Proteja-se contra respostas obsoletas. Um fetch lento da sidebar não deve aterrissar na seção da qual você já saiu. Incremente um contador de geração por atualização e descarte respostas que não correspondem mais, do mesmo jeito que o próprio roteador aposenta navegações ultrapassadas.
  • Uma view lazy de matches() também não tem pending. Ela não renderiza nada até seu módulo chegar. Três sistemas de carregamento, três relógios independentes: o pending da rota, suas próprias flags, e uma view lazy que simplesmente ainda não está lá.

Valores Computados

#

O Nuclo não tem uma API de valores computados. Use uma função comum, e chame-a a partir de valores dinâmicos:

example.ts
let items = ['apple', 'banana', 'cherry']
let filter = ''

const visible = () => items.filter((item) => item.includes(filter))

const Fruits = () =>
  div(
    p(() => `Showing ${visible().length} of ${items.length}`),
    ul(list(visible, (item) => li(item))),
  )

filter = 'an'
update()

Assíncrono & Carregamento

#

Use async/await comuns, e chame update() cada vez que o estado mudar. Para um fetch, isso significa uma vez antes do await e uma vez depois:

example.ts
let status: 'idle' | 'loading' | 'done' | 'error' = 'idle'
let result: unknown = null

async function loadData() {
  status = 'loading'
  update()
  try {
    result = await fetch('/api/data').then((r) => r.json())
    status = 'done'
  } catch {
    status = 'error'
  }
  update()
}

Boas Práticas

#
  • Agrupe as mudanças. Altere todo o estado que precisar, depois chame update() uma vez.
  • Mantenha os valores dinâmicos rápidos e puros. Todos eles rodam em cada update().
  • Use scope() para atualizações pequenas e frequentes, como digitação ou timers.
  • Renderize dentro do documento. Nós que não estão no documento quando update() roda param de atualizar.
  • Use when() e list() para filhos que mudam, não uma função que retorna nós.
  • Use className, não class, e use () => cx(...) ou { className: () => … } para classes dinâmicas.
  • Defina os estilos uma vez, no nível do módulo.
  • Renderização no servidor: renderize primeiro, depois coloque getCssText() em <style id="nuclo-styles">. Passe os dados da requisição como argumentos, e mantenha os comentários HTML na saída.

Índice da API

#

Toda função pública, e de onde ela vem.

nuclo (global e export nomeado)

nuclo (apenas export nomeado)

nuclo/ssr

  • renderToString(input), renderManyToString(inputs), renderToStringWithContainer(input, tag?, attrs?) e getCssText().

nuclo/polyfill

nuclo-router (pacote separado)

Apenas globais

  • 175 construtores de tags: div, span, …, var_, e svgSvg, pathSvg, ….
  • Tipos: NodeModFn, NodeModLike, ExpandedElement e mais. Tipos de estilização como StyleResult também podem ser importados de 'nuclo'.

Chaves especiais

Perguntas Frequentes

#

Alterar o estado atualiza a interface?

Não. O DOM fica como está até você chamar update().

O Nuclo usa signals, proxies ou um DOM virtual?

Não. O estado continua JavaScript puro. Quando uma árvore é renderizada, o Nuclo cria elementos e nós de texto reais. update() executa todo valor dinâmico registrado, ou os que estão dentro dos escopos indicados, e aplica as mudanças nesses mesmos nós onde um valor mudou. Não há grafo de dependências nem árvore virtual.

Por que minha página mostra "false"?

Booleanos são renderizados como texto. div(ok && span('Done')) mostra "false". Use when(() => ok, span('Done')) para filhos, e ok ? 'Done' : '' para texto.

Por que a linha da minha lista não muda quando eu edito o item?

Linhas de itens que ainda estão na lista são mantidas, não renderizadas de novo. Use valores dinâmicos como () => todo.text para campos que mudam. Veja list().

Por que parte da minha interface para de atualizar?

Normalmente seus nós não estavam no documento quando update() rodou. O Nuclo para de rastrear esses nós, mesmo depois que você os anexa, então renderize em um container que esteja no documento. Duas outras causas: um when() cuja condição lança erro para de atualizar, e uma função de texto que lança erro na primeira execução fica vazia. Veja update().

Quando devo usar forceUpdate()?

Apenas para mudanças raras em valores estáticos, como trocar o idioma. Para todo o resto, use update(). Veja forceUpdate().

Como coloco uma página em um layout sem passá-la como prop?

Marque o lugar com region({ id: 'main' }) e faça a página retornar into('main', …). Veja region() & into().

Como desmonto um app?

Envolva-o em when() e desligue a condição. Remover o DOM por conta própria não executa onDestroy. Veja Ciclo de Vida.

O Nuclo inclui um roteador?

Não no pacote principal. O roteamento é distribuído separadamente como nuclo-router, com renderização no servidor, hidratação, rotas com code splitting e pré-carregamento em idle. Instale-o com npm install nuclo nuclo-router. Veja nuclo-router.

O Nuclo tem effects?

Não. onMount e onDestroy rodam uma vez por elemento, atrelados à inserção e à remoção. Nada roda de novo porque algum estado mudou.