Guides

Render: export a Block or a Document to a file

The Renderer port with its render targets, the SSE route that streams progress and stores the result as an asset, the client call, and the block action and command that offer it.

A render target turns one Typed block, or a whole Document, into a file: Markdown, a PDF, a slide deck, a spoken recording. The editor ships one target, markdown. A host or an add-on contributes the others. The render capability connects the pieces:

PieceWhat it doesPort (capability)
Rendererlists the targets a user may use and renders one input to one targetrender
GET /api/editor/render/targetsthe targets with the Block types each accepts and its MIME typerender
POST /api/editor/renderone render, streamed as server-sent events; the result is stored as an asset of the Documentrender and assets
EditorBackend['render']targets() and render(target, input, { onProgress })render
Block action render and command render-documentmenus of the targets that take the Block type, or the whole Documentrender

A render lives as long as its request. There is no job store: the server streams progress while the target runs, stores the file, and ends the stream. A client that disconnects aborts the target through the AbortSignal that Renderer.render receives.

Add a target

A target (RenderTarget, @skaile/forge-editor/adapters/render) is a RenderTargetInfo (id, label, accepts, mime) plus run. accepts lists Block type ids, and 'document' for a whole Document. run receives the input (documentId, blockId?, content, options?), a progress reporter and an AbortSignal, and returns { data, mime, fileName }. createRenderer(targets) makes the Renderer of the host's ports.render. A target whose id repeats an earlier one is dropped, so a host can list its own before the defaults.

docs/snippets/render-target.ts
import { createRenderer, markdownTarget, type RenderTarget } from '@skaile/forge-editor/adapters/render'
import type { EditorPorts } from '@skaile/forge-editor/server'

// A render target names the Block types (or 'document') it accepts and the MIME type it produces. `run` reports its
// steps through `progress` and returns the file; a thrown error reaches the client as the `error` event.
const plainText: RenderTarget = {
  id: 'plain-text',
  label: 'Text (.txt)',
  accepts: ['document'],
  mime: 'text/plain',
  async run({ content }, progress) {
    progress({ phase: 'collect', percent: 50 })
    const text = JSON.stringify(content)
    return { data: new TextEncoder().encode(text), mime: 'text/plain', fileName: 'document.txt' }
  },
}

// The renderer is one port: the host passes its targets, the built-in `markdown` among them.
export const render: NonNullable<EditorPorts['render']> = createRenderer([markdownTarget(), plainText])

A host that needs its own target logic, for example a per-user list, implements the Renderer interface directly instead of calling createRenderer. See the Renderer port reference.

The built-in markdown target

markdownTarget(options?) renders a Typed block or a whole Document as Markdown, the same text the block action "Als Markdown exportieren" downloads: every Typed block becomes a heading with its description, tags and body beneath it. It accepts 'document' and every Block type, takes schema and blockTypes options for a host with its own Block types, and needs no editor, only the schema.

The route

POST /api/editor/render takes { target, input }. The server checks the request before the stream starts:

StatusCause
400malformed body or a Document ID that is not valid
401no user
403the user may not read the Document
404unknown target
422the target does not accept this Block type, or a whole Document
501the host has no render or no assets port

After that the answer is a text/event-stream with the events progress ({ phase, percent? }), then done ({ asset, fileName, mime }) or error ({ message }). The result is stored through ports.assets.storeAsset as an asset of input.documentId. asset is the asset name: read it with the asset route (GET /api/editor/assets/:documentId/:asset), like any upload.

The client

backend.render is present when the server serves the render capability. backend.render.render(target, input, { onProgress, signal }) resolves with { asset, fileName, mime, url } and rejects with the server's message when the render fails. The loop over the stream is postSse (core/sse.ts), the same helper the AI streams use (postAiSse calls it), so there is one parser for server-sent events in the client.

In the editor

The composites feature adds both entries when the backend serves render, and hides them otherwise:

  • the block action „Erzeugen als …“ (render) on a Typed block, a menu of the targets whose accepts names the Block type;
  • the command „Dokument erzeugen als …“ (render-document, ⌘K palette), a menu of the targets that accept 'document'.

A notice reports the phase and percent of the running render, then the end. The finished file downloads at once.

Copyright © 2026