Skip to content
Docxcelerate

Start Here

Writing nodes

A node is a small component that takes its data and returns what it wants to say.

A document is a tree of components. Each one returns a node — a paragraph, an image, a graph — and each lives in its own file under nodes/.

Why one file each? Because documents get long. A template with all its text written inline stops being readable about halfway down.

Generate one

dxcl document node documents/welcome next-steps --type paragraph

That writes nodes/next-steps.node.tsx and adds it to nodes/index.ts. --type takes paragraph, image or graph. Run the command with no arguments and it will ask you instead.

It deliberately doesn’t place the node for you — only you know where it belongs. Open document.tsx and add the component at the right point:

import { Document, Section, template } from "docxcelerate/template";
import * as Nodes from "./nodes/index.ts";
import type { DocumentData } from "./types.ts";

export const documentTemplate = template<DocumentData>(
  <Document id="welcome" title="Welcome">
    <Section id="opening" title="Opening">
      <Nodes.Greeting />
    </Section>
    <Section id="closing" title="Closing">
      <Nodes.NextSteps />
    </Section>
  </Document>,
);

Save, and the preview reloads with the new node in place.

Data comes in through useState

import { Paragraph, useState } from "docxcelerate/template";
import type { DocumentData } from "../types.ts";

export const Greeting: Paragraph = () => {
  const [state] = useState((data: DocumentData) => ({
    name: data.recipientName,
  }));

  return <Paragraph id="greeting">Hello {state.name},</Paragraph>;
};

useState is where your data enters the component, and the only place it can.

The payoff is that everything a node depends on is written down in one declaration, instead of being scattered through the code that reads it.

The initializer is ordinary TypeScript. There’s no template language to learn, so conditionals, formatting and imports all just work — and an if that returns a different paragraph does exactly what it looks like.

One thing that might look odd: Paragraph is both the element and the component type. So const Greeting: Paragraph declares what this node returns, and returning a <Section> from it is a compile error.

Text you want generated

Some paragraphs can’t be written in advance, because what they should say depends on who is receiving them. For those, set prompts instead of text — plus a placeholder, so your preview stays readable:

export const TutorNote: Paragraph = () => {
  const [state] = useState((data: DocumentData) => ({
    applicantName: data.applicantName,
    interviewer: data.interviewer,
  }));

  useSetPrompts({
    generalPrompt: `Write two warm sentences about ${state.applicantName}'s interview.`,
  });
  useSetPlaceholders(`A note from ${state.interviewer}.`);

  return <Paragraph id="tutor-note" />;
};

There’s nothing to declare and no mode to pick.

A node that has its text can be produced on your machine. A node that has only prompts needs the engine. Your component decides which it is simply by what it supplies, the build works the rest out, and the package tells the engine which nodes it has to resolve.

Supplying both on one element is an error, not a coin toss.

You can also pass prompts as props, which reads better when they’re short. Props win over the hook, so a caller can override whatever a shared hook set around it.

Five prompt slots are available. Only generalPrompt is required:

SlotPurpose
generalPromptWhat the node should say
infoPromptContext the model should have but not restate
negativePromptWhat to avoid
systemPromptRole and tone instructions
examplePromptWhat a good answer looks like, written out

examplePrompt is the one worth reaching for first. Telling a model what shape you want leaves it guessing; showing it one gives it something to copy. An example locks down the parts you don’t want changing — how it opens, what order things come in, how long it is, how formal it sounds — so the model only fills in what genuinely differs between documents. Write it as finished text, not as a fill-in-the-blanks form, or it just describes the shape again.

Image and Graph work the same way — give one src or data and it resolves locally; give it prompts and it does not.

What the preview shows

Building for preview resolves prompted nodes to their placeholders, never to generated text. Previews come out the same every time and cost nothing: you can work on structure and styling without a single request leaving your machine.

Placeholders are a useful habit, too. If a document reads badly with placeholders in place, that’s usually a sign its structure is doing too little work.

usePlaceholderData gives you stand-in names, dates and figures, seeded from where the component sits — so the same node shows the same values every time. A preview that reshuffles itself on every build is one nobody can proofread.

Ids

An id is how an engine addresses a node, and how two build artifacts line up when you diff them. Treat renaming one as a breaking change.

You can usually leave it out. A node without an id is named after what it is, which is what stops branches and lists from forcing you to invent names for nodes nobody refers to.

The one thing you can’t do is use an id twice. Two nodes claiming the same id is an error, reported with both positions, rather than a race the later one wins.

Where to go next


Edit this page on GitHub ↗ Read this page as Markdown