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:
| Slot | Purpose |
|---|---|
generalPrompt | What the node should say |
infoPrompt | Context the model should have but not restate |
negativePrompt | What to avoid |
systemPrompt | Role and tone instructions |
examplePrompt | What 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
- Document projects — the files around your nodes
- Documents and nodes — the component model in full, including hooks
- The node model — every node type, with previews of each