Editor SDK

@nuforge/editor is a high-level SDK for building your own visual page builder on top of the runtime. Every operation is undoable — under the hood each method wraps nu.change, so it composes cleanly with the runtime's history. Note: the nuforge site does not host an editor of its own. This SDK is the toolkit you use to build one.

Getting started

Create an editor by wrapping a Nu runtime instance:

import { createEditor, defineBlock, el, text } from '@nuforge/editor';

const editor = createEditor(nu);

Mutations

Every mutation below is undoable. Each is a thin, intention-revealing wrapper around nu.change:

insertNode(parent, child, index?)
moveNode(dragged, target, position) // position is 'before' | 'after' | 'inside'
removeNode(node)
setExpression(node, key, source)
setText(node, value)
setTag(node, tag)
setProp(node, key, expr)
removeProp(node, key)
updateProps(node, map)
addClass(node, name)
removeClass(node, name)
setClassCondition(node, name, source)
setCondition(node, source) // sets @if
clearCondition(node)
setEach(node, listSource, itemName, indexName?) // sets @each
clearEach(node)

Components & state

Manage components and their reactive state. Renaming a component also rewrites every <Name/> reference to it:

addComponent(name)
renameComponent(component, name) // also updates <Name/> references
removeComponent(component)
duplicateComponent(component)
addState(component, name, initSource)
setStateInit(...)
removeState(...)
addComponentProp(component, name, initSource)
setComponentPropInit(...)
removeComponentProp(...)

Queries

Read the current tree without mutating it. These are useful for rendering panels, breadcrumbs, and drag targets:

getComponents()
getComponent(name)
getRootTemplate(component)
getChildren(node)
getSiblings(node)
getNodeIndex(node)
getNodePath(node)

Blocks

The editor keeps a registry of insertable building blocks. Register a block with defineBlock, then insert it into any parent node:

editor.blocks.register(
  defineBlock({
    id: 'button',
    label: 'Button',
    create: () => el('button', {}, [text('Click me')]),
  }),
);

editor.insertBlock('button', parentNode);

Inside a block's create you build template nodes with the helper builders: el(tag, props?, children?) constructs an element node, text(value) constructs a text node, and lit(value) constructs a literal value.

Commands & subscriptions

Register named commands and subscribe to changes so your UI stays in sync:

editor.commands.register({ id, label, run });
editor.commands.run(id);

editor.onChange((entries) => {});
editor.onStructureChange(() => {});

Use editor.onChange for fine-grained updates and editor.onStructureChange when the tree's shape changes (nodes inserted, moved, or removed).

React canvas

The @nuforge/editor/react subpath provides a selectable, style-isolated iframe canvas:

import { IframeCanvas, templateIdFromElement, useStructureRevision } from '@nuforge/editor/react';

<IframeCanvas frame={frame} selectedId={selectedId} onSelect={setSelectedId} />

IframeCanvas renders the frame inside a style-isolated iframe, so the host application's CSS never leaks into the canvas (and vice versa). When the user clicks an element, it maps the clicked DOM element back to its template id with templateIdFromElement and reports it through onSelect. Use useStructureRevision to re-read the canvas when the tree's structure changes.

It's a flexible primitive, not a fixed UI:

  • renderBox — build the overlay yourself from the computed selected / hovered / drop rects and selectedLabel (the selected element's tag). Draw borders, a tag badge, or an action toolbar (set pointerEvents: 'auto' on interactive bits). Omit it for no overlay.
  • srcDoc — supply the iframe's initial document, so its <head> can load web fonts, a CDN stylesheet, <script> tags, etc. The frame is portaled into the <body>.
  • onDropOnNode(targetId, position, event) — enable drop-on-canvas; combine with editor.insertBlockAt(blockId, target, position) to drop palette blocks (the drop rect drives a live indicator).

See the step-by-step builder guide for working code.

External components are decorated the same way as regular tags: their root element carries data-nu-id too, so they're independently selectable and highlightable in the canvas like any other node — no special-casing needed on the host side.

useCanvasWindow()

import { useCanvasWindow } from '@nuforge/editor/react';

const { doc, window } = useCanvasWindow();

Resolves the real window/document a canvas-rendered element visually lives in. IframeCanvas portals its content into an <iframe>, but a portal only relocates DOM nodes — the JavaScript building them still runs in the host page's realm, so a library reading a bare window/document global (scroll listeners, matchMedia, ResizeObserver/IntersectionObserver defaults) would otherwise silently watch the host page instead of the iframe the user scrolls. Call it from inside an external component to get the right realm regardless of render path — inside IframeCanvas it's the iframe's own { doc, window }; anywhere else (e.g. a direct, non-iframe <NuFrame/>) it's just the ambient globals. See Externals → hook-based external components for a worked example.

RemoteCanvasHost

IframeCanvas portals the frame into an iframe's DOM, but the JavaScript building it still runs in your app's realm — useCanvasWindow() fixes the window/document half of that for your own code, but it can't fix a third-party library whose own internals bind to bare globals at module scope in a way you can't redirect (an animation library's scroll-linked internals, for instance). For that class of problem, RemoteCanvasHost renders a genuinely src-navigated iframe instead — a real separate page with its own JS realm, so every module (React, third-party libraries, your own code) evaluates fresh against the canvas's own globals:

// host page
import { RemoteCanvasHost } from '@nuforge/editor/react';

<RemoteCanvasHost
  nu={nu}
  activeComponent="App"
  src="/canvas/my-project"
  onSelect={setSelectedId}
  onDropOnNode={(targetId, position) => editor.moveNode(dragged, targetId, position)}
/>;
// the /canvas/my-project route, its own bundle
import { createCanvasReceiver } from '@nuforge/editor';
import { NuProvider, NuFrame } from '@nuforge/react';

const receiver = createCanvasReceiver({ src: '/canvas/my-project', externals });
receiver.postReady();
// receiver.nu / receiver.volatile.activeComponent feed a Frame + <NuFrame/>

Since a real navigation puts the canvas in a separate realm, there's no shared object identity to render from — state flows one way, host → canvas, over a BroadcastChannel as t.flatten'd JSON, and the canvas's Nu is a merge-patched replica (t.merge, the same primitive @nuforge/collaboration uses for its CRDT binding — no CRDT needed here, since there's exactly one writer). Interaction (select/hover/drop/keydown/link-click) relays the other way as small typed messages instead of native DOM listeners spanning the boundary.

The canvas's nu is still a real, loaded, mutable Nu — a DSL onClick handler rendered from it (e.g. a live "preview mode" toggle) runs and mutates it locally exactly like any other Nu. That's intentional: it's simply never relayed back, so the next init/patch from the host overwrites it. Force a fresh full init (not just an incremental patch) on every activeComponent or preview-mode transition so a local-only mutation never lingers once you're back in edit mode.

Clipboard

Node-level copy / cut / paste / duplicate — all undoable:

editor.copyNode(node); // or editor.cutNode(node) to copy + remove
editor.canPaste(); // true

// paste a fresh copy (new ids) before / after / inside a target
const pasted = editor.pasteNode(target, 'after');

// duplicate a node right after itself
const copy = editor.duplicateNode(node);

pasteNode and duplicateNode return the inserted node (with fresh ids), so you can immediately select it.

Canvas drag-and-drop

dropPositionFromPointer turns a pointer position into a before / after / inside drop position against an element's rect — the reusable core of canvas DnD. Use it to drive a drop indicator and the final mutation:

import { dropPositionFromPointer } from '@nuforge/editor';

function onDragOver(e: React.DragEvent, targetEl: HTMLElement) {
  const rect = targetEl.getBoundingClientRect();
  const position = dropPositionFromPointer(e.clientY, rect, { allowInside: true });
  // → highlight the drop indicator for `position`
}

function onDrop(draggedNode, targetNode, position) {
  editor.moveNode(draggedNode, targetNode, position); // or editor.insertBlock(id, targetNode)
}

Schema-driven inspector

A block declares its editable props as a schema (props: PropSchemaField[]). <PropFields> renders a control per field (text / number / checkbox / select / expression), so you don't hand-write a form per block:

import { PropFields } from '@nuforge/editor/react';

<PropFields
  fields={block.props ?? []}
  values={{ label: 'Click me', disabled: false }}
  onChange={(name, value) => editor.setProp(node, name, t.literal({ value }))}
/>;

PropFields is headless — style it with className and your own CSS, and wire onChange to editor.setProp / setExpression.

Validating programmatic mutations (e.g. AI-driven edits)

A human editing through the UI only ever produces mutations the UI itself can express — an AI (or any other programmatic caller building expressions from free text, like a tool-call referencing an identifier that may not exist) can produce a mutation that's structurally valid but throws when actually rendered: a reference to a val, prop, or external that doesn't exist.

Nu's evaluator already turns a render-time throw into an t.ErrorSystemView node in place of the failing subtree, rather than crashing the whole tree (computeTag catches per-element) — so after any mutation that embeds a new expression into the live tree (an event handler, a binding, a newly-inserted node — never needed for a plain-literal mutation like setProp with a literal value, moveNode, or setClassList, since a literal can't reference an undefined identifier), walk the resulting Frame.view for ErrorSystemView nodes and roll back with nu.undo() if you find one — so the caller never leaves a visibly broken node on the canvas without being told:

import { t, type Frame, type Nu } from 'nuforge';

function collectViewErrors(view: t.View, out: string[] = [], seen = new Set<t.View>()): string[] {
  if (seen.has(view)) return out;
  seen.add(view);
  if (t.is(view, t.ErrorSystemView)) out.push(view.error);
  if (t.is(view, t.NuComponentView)) {
    for (const child of view.render) collectViewErrors(child, out, seen);
  }
  const withChildren = view as unknown as { children?: t.View[] };
  if (Array.isArray(withChildren.children)) {
    for (const child of withChildren.children) collectViewErrors(child, out, seen);
  }
  return out;
}

function checkedOk(nu: Nu, componentName: string): { ok: true } | { ok: false; errors: string[] } {
  // Reuse a live frame if the component is already mounted in the canvas;
  // otherwise stand up a transient one just to check, then dispose it.
  let frame: Frame | undefined = nu.frames.find((f) => f.opts.component?.name === componentName);
  let transient: Frame | undefined;
  if (!frame) frame = transient = nu.createFrame({ component: { name: componentName } });
  try {
    const errors = collectViewErrors(frame.view);
    if (errors.length) {
      nu.undo();
      return { ok: false, errors };
    }
    return { ok: true };
  } finally {
    if (transient) nu.removeFrame(transient);
  }
}

Feed errors back to the caller (e.g. as a tool-call error an AI can see and correct on its next turn) rather than surfacing a raw exception — a render error almost always means the mutation referenced something not actually in scope, which is exactly the kind of mistake a retry can fix. If mutations can target a component that isn't the currently-active page (e.g. a reusable component with no live frame of its own), resolve the OWNING component first — walking nu.getParentNode up from the mutated node until you hit a t.NuComponent — rather than always validating against whichever page happens to be on screen.

Next steps

  • Runtime — the reactive core the editor mutates.
  • Codegen — turn the edited AST into shippable code.