Skip to Content
PackagesAutomation

Automation

The nodes and mechanics for document automation: content that can be hidden, content that repeats, shared content that stays in sync, and fields that show a value. The package evaluates nothing. Your app owns the logic (conditions, data, the evaluator, the rule editor), computes the results and hands them to the package’s commands.

PackageContents
@stesura/automationNode specs, apply and structure commands, plugins, selectors, materialize, DOCX and markdown adapters. Framework-free.
@stesura/automation-reactcreateAutomationUIExtension(): node views, the field view, the Automation group (Insert tab, or a tab of its own), hooks.

Nodes

NodeContentAttrs
conditional_block / conditional_inlineBlocks (tables included) / inline contentid, hidden
repeat_block / repeat_inlineOne template, then itemsid; inline only: separator, lastSeparator, terminator
repeat_block_template / repeat_inline_templateThe authored content each item copieshidden
repeat_block_item / repeat_inline_itemOne copy of the templatekey
component_block / component_inlineA copy of shared contentid, componentId, version, locked (default true)
field (core)Atomname, fallback, value
  • Inline wrappers hold boundary_open_node inline* boundary_close_node: the boundaries give each edge a caret position inside and outside. They are empty in the DOM and in text.
  • Ids: block wrappers get id from core’s unique-id attribute; inline wrappers declare their own. The id is the join key: keep your logic in your own table keyed by node id (fields by name). A copied conditional gets a fresh id, so it has no logic until you give it some.
  • field is a core node: insert it with insertField({ name, fallback }) from @stesura/core/commands. It shows its value, or {{name}} without one.
  • Inline repeat punctuation is rendered by the node view and the exports, never stored as text: separator between items, lastSeparator between the last two, terminator after the last.

hidden is a core global attribute. Core leaves hidden subtrees out of numbering, the TOC, cross-references (a reference into hidden content shows as missing), note numbering, spacing, search, proofreading, pagination and the AI tools.

Setup

The nodes go into the schema with withAutomation, the plugins into extraPlugins, the node views and toolbar group into uiExtensions:

import { automationPlugins, withAutomation } from "@stesura/automation"; import { createAutomationUIExtension } from "@stesura/automation-react"; import { stesuraSchema } from "@stesura/core/schema"; import { StesuraEditor, useLocalEditor } from "@stesura/editor-react"; // Module scope: one schema and one extension for every mount. export const schema = stesuraSchema(withAutomation()); const uiExtensions = [createAutomationUIExtension()]; export const Editor = () => { const { editorState, dispatch, pluginFactory } = useLocalEditor({ schema, extraPlugins: () => automationPlugins({ mode: "template" }), }); return <StesuraEditor state={editorState} dispatchTransaction={dispatch} schema={schema} pluginFactory={pluginFactory} uiExtensions={uiExtensions} />; };
  • withAutomation(props?) merges the automation nodes and the hidden grants into stesuraSchema props, keeping your own nodes, marks and globalAttributes: stesuraSchema(withAutomation({ nodes: myNodes })).
  • automationPlugins({ mode? }) returns the mode, inline-cursor, guard and diagnostics plugins. mode is the starting mode, "template" by default.
  • Every mount needs the same schema: the PDF export page, and the collaboration server, which rejects steps that touch nodes its schema lacks.

Applying results

The apply commands write your results into the document. Each is one transaction, kept out of undo history and never tracked as a change; numbering and cross-references re-run. Each returns false when nothing changes.

CommandBehaviour
setVisibility(visibleById)Map<id, boolean>: true shows, false hides, an absent id is left alone. Applies to every node with hidden except repeat templates.
setRepeatItems(repeatId, keys)Reconciles the repeat’s items by key: a missing key gets a copy of the template, an absent key’s item goes, the order follows keys, kept items keep their edits.
setFieldValues(values)A Map<name, string | null>, or a resolver ({ name, frames, pos }) => value for fields inside repeat items. A string shows it, null goes back to {{name}}, undefined leaves the field.
setComponentContent(componentId, content, version?)Replaces the content (a Fragment) of every component whose componentId matches, bypassing the lock, and its version when given.
setDiagnostics(byId)Replaces your diagnostics, Map<id, Diagnostic[]>, shown on the nodes. Plugin state only. Read with getDiagnostics(state, id).

Run setRepeatItems first: it creates the copies the other commands then address. collectAutomationNodes(doc) lists every conditional, repeat and component (by id) and field (by name) in document order, with frames (the enclosing repeat items, outermost first: { repeatId, itemKey, index }) and inTemplate:

// docs: no-verify (items, customer and isVisible are your app's) import { collectAutomationNodes, setFieldValues, setRepeatItems, setVisibility } from "@stesura/automation"; import type { Command } from "prosemirror-state"; const run = (command: Command) => command(view.state, view.dispatch); run(setRepeatItems("line-items", items.map((item) => item.sku))); const visible = new Map<string, boolean>(); for (const entry of collectAutomationNodes(view.state.doc)) { if (entry.kind === "conditional") visible.set(entry.id, isVisible(entry.id, entry.frames)); } run(setVisibility(visible)); run(setFieldValues(new Map([["customer", customer.name]])));

Ids of repeat copies

A node copied from a template into item key gets deriveItemNodeId(templateNodeId, key), and the item itself deriveItemNodeId(repeatId, key). The ids are a pure function of their inputs: compute the id of a template conditional’s copy instead of storing provenance, and two actors applying the same keys produce the same steps.

  • nodeIdOf(node): the node’s id, or null.
  • templateNodeIdOf(doc, derivedId): the reverse, the template node (or repeat) a copy’s id came from. One level up; call again for nested repeats.
  • findNodeById(doc, id, types): the first { node, pos } of one of types (e.g. CONDITIONAL_NODES, REPEAT_NODES, COMPONENT_NODES) with that id.
  • itemKeyOf(node), getFrames($pos), isInTemplate($pos): a repeat item’s key, the frames around a position, whether it sits in a template.

Your own transactions

markApply(tr) marks a transaction you build yourself as an automation apply: out of undo history (addToHistory: false), skipped by track changes (skipTrackChanges), counters re-run, and let through the lock guard. Use it for results your app writes outside the commands above. isAutomationApply(tr) tells whether a transaction is one.

Template and document mode

  • template: authoring. Wrappers show their chrome, nothing is hidden, templates are visible and editable, fields show {{name}}.
  • document: the applied result. No chrome, conditionals hidden as you decided, templates hidden.

Switch with setAutomationMode(mode) and read with getAutomationMode(state). Entering document hides every repeat template; entering template reveals everything, clearing conditional hidden too, so re-apply your visibility after entering document mode. The mode is per-editor plugin state, orthogonal to the edit/review/view editor mode, while hidden is stored: collaborators share it, so pick the mode per document, not per user.

Structure commands work in template mode only: insertConditional(kind), wrapInConditional(kind), insertRepeat(kind) (a template holding the selection, no items), unwrapAutomationNode(pos), deleteAutomationNode(pos), with kind "block" | "inline". addRepeatItem(repeatId, key, index?) and removeRepeatItem(repeatId, key) are ordinary, undoable edits in either mode.

Locked components

A component holds a copy of shared content, identified by componentId and version. There is no insert command: create the node with your schema and insert it. While locked is true (the default), the guard plugin refuses any edit inside it: typing, formatting, a deletion that starts or ends inside it. Deleting the whole component, and attribute changes on the component itself, are allowed. Applies, undo/redo, collaboration steps, appended transactions and transactions marked skipTrackChanges pass. Refresh the content with setComponentContent, and flag a stale version through setDiagnostics. The guard also refuses a table that an automation wrapper would put past the nesting cap (a table in a nested table’s cell).

Headless rendering and export

materialize(json, { schema }) turns an applied document into a plain one: hidden content and templates dropped, wrappers replaced by their content (inline repeats with their punctuation as text), valued fields turned into text, references into dropped content turned into their text, orphaned note bodies removed. It applies no data: run the commands first. It returns { doc, warnings } (field-without-value, reference-to-removed-content, emptied-container), and throws when the result is not a valid document. A field without a value stays a field.

A server job needs no view and no plugins:

import { materialize, setFieldValues, setRepeatItems, setVisibility, withAutomation } from "@stesura/automation"; import { stesuraSchema } from "@stesura/core/schema"; import type { JSONContent } from "@stesura/core/types"; import { EditorState, type Command } from "prosemirror-state"; const schema = stesuraSchema(withAutomation()); type RenderInput = { itemKeys: string[]; visible: Map<string, boolean>; values: Map<string, string> }; export const render = (template: JSONContent, input: RenderInput) => { let state = EditorState.create({ schema, doc: schema.nodeFromJSON(template) }); const run = (command: Command) => command(state, (tr) => { state = state.apply(tr); }); run(setRepeatItems("line-items", input.itemKeys)); // first: it creates the copies run(setVisibility(input.visible)); run(setFieldValues(input.values)); return materialize(state.doc.toJSON(), { schema }); // { doc, warnings } };

For the exports of a live document:

  • DOCX: pass automationDocx as the export options: downloadDocx(state, comments, name, automationDocx) or createDocxUIExtension({ exportOptions: automationDocx }). It materializes first, so hidden notes and references never reach Word.
  • Markdown: registerMarkdownExtensions(schema, automationMarkdown). One-way: wrappers unwrap, hidden content and templates vanish.

Diagnostics

setDiagnostics(byId) shows your problems on nodes ({ severity: "error" | "warning" | "info", message, code? }). The package adds structural ones it can see itself, with a code: xref-into-conditional (a visible cross-reference points into a conditional) and note-shared-outside-conditional. Read both with getDiagnostics(state, id). Without the diagnostics plugin, setDiagnostics returns false.

Collaboration

Apply on one actor per change (a server job or a designated client). Since ids are derived, two actors applying the same inputs produce identical steps, so a race is harmless but doubles the traffic.

Editor UI

createAutomationUIExtension(options?) contributes node views for every automation node (chrome in template mode only), the boundaries and field, and the Insert tab’s Automation group (insert conditional or repeat, block or inline, and the template/document toggle). Call it once at module scope. Options:

// docs: no-verify (openRuleEditor, rules and applyAll are your app's) import { createAutomationUIExtension } from "@stesura/automation-react"; const automationExtension = createAutomationUIExtension({ // The chrome's edit button; hidden when absent. onEditNode: ({ kind, id }) => openRuleEditor(kind, id), // Replaces the chrome label, e.g. with a summary of the condition. renderLabel: ({ id }) => rules.get(id ?? "")?.summary, // After the toolbar switched modes: re-apply your results for "document". onModeChange: (mode) => { if (mode === "document") applyAll(); }, // "insert" (default): the group goes on the Insert tab. "tab": an Automation tab of its own. placement: "tab", });

With placement: "tab", other extensions add to the Automation tab with tabKey: "automation" (toolbarDefaultExtensions or toolbarGroupExtensions), e.g. a group of your own components next to the Automation group.

kind is "conditional" | "repeat" | "template" | "item" | "component"; id is null for a template. Inside node views, useAutomationMode() reads the mode and useNodeDiagnostics(id) a node’s diagnostics. AutomationGroup, automationProbes, automationSelectors and FieldNodeView are exported for custom toolbars and views.

Next steps

Last updated on