Nodes
Paragraph
Text, written from your data or filled in from prompts at request time.
The workhorse. A static paragraph holds the text as its children; a dynamic one carries prompts and a placeholder, and is filled at request time. Both land as the same node kind, differing only by `mode` — which is inferred from what you supply, never declared. Supplying both text and a prompt on one element is an error rather than a precedence rule.
- Helpers
- Paragraph, useSetPrompts, useSetPlaceholders
- Node kind
- paragraph
- Category
- Text
- Resolves
- Both
- Children
- None. Paragraphs are leaves.
| Option | Type | What it does |
|---|---|---|
id | string | Stable address for the node. Engines target it and build artifacts diff on it, so treat a rename as a breaking change. Optional: a node without one takes an id from where it sits, which is what keeps branches and loops from forcing you to invent names. Two nodes claiming one id is an error rather than a race. |
children | string | Static only. The text, interpolated the way any JSX children are. A node given its own text is static, whatever prompts a hook set around it. |
text | string | Static only. The same thing as children, for when a computed string reads better as a prop than as a body. |
align | "left" | "center" | "right" | "justify" | How the lines sit in the text column. Say it here when the alignment is what the paragraph *is* — a date ranged right, a standfirst centred. Leave it out and let a variant carry it when the alignment is what the theme thinks that kind of block looks like. A node that states both wins over its block, the same way a cell wins over its column. |
placeholder | string | Dynamic only. What previews show in place of generated content. Also settable with useSetPlaceholders. Optional, but a document that reads badly without one cannot be reviewed. |
generalPrompt required | string | Dynamic only. What this node should say. |
infoPrompt | string | Dynamic only. Context the model should have but should not restate. |
negativePrompt | string | Dynamic only. What to avoid — claims, tones, or facts it must not invent. |
systemPrompt | string | Dynamic only. Role and voice, applied ahead of the other prompts. |
examplePrompt | string | Dynamic only. What a good answer looks like, written out as finished text. Shown last, because it is what the answer gets measured against. |
derivers | DeriverInvocation[] | Values the engine computes before the node resolves, written to derived.* and readable from a template token. Built with derive(). These survive publishing and run per document — use them for anything computed from request data. useDeriver runs one during the build instead. |
Writing one
The text is the element’s children, interpolated the way JSX children always are. There’s no template language, so formatting, branching and pluralisation are all ordinary TypeScript — done in the state initializer, which is where a component does its thinking:
import { Paragraph, useState } from "docxcelerate/template";
import type { MemberData } from "../types.ts";
export const Greeting: Paragraph = () => {
const [state] = useState((data: MemberData) => ({ name: data.memberName }));
return <Paragraph id="greeting">Dear {state.name},</Paragraph>;
};
A component may be async — but one that fetches is one that can fail
mid-build, so prefer putting the value in your data first. If you do await,
every hook must be called before the first await.
The token budget
useAvailableTokens() is the budget this build allotted, 2000 unless you set
availableTokens in the build options. Static nodes can ignore it. Dynamic ones
should spend it:
const availableTokens = useAvailableTokens();
useSetPrompts({
generalPrompt: `Explain the change. At most ${Math.floor(availableTokens / 4)} words.`,
});
Branching
If a document says one of three things, that’s one component with three outcomes — not three components. Give each arm its own id:
if (state.settled) {
return <Paragraph id="balance-settled">Nothing outstanding.</Paragraph>;
}
return <Paragraph id="balance-arrears">A balance remains.</Paragraph>;
Giving each arm its own id is what lets the resolved document record which one this recipient got, and they are required once the branch is published — an engine stores both arms, and an id addresses exactly one node.
When the outcomes differ only in wording, computing the string and returning one node keeps one id, which is the older habit and still a good one:
const [state] = useState((data: MemberData) => ({
line: data.settled ? "Nothing outstanding." : "A balance remains.",
}));
return <Paragraph id="balance">{state.line}</Paragraph>;
How it sits on the page
align is the one appearance a paragraph states for itself, because alignment
is often what the paragraph is rather than how it looks — a date is ranged
right in every theme, the way a money column is:
<Paragraph id="issued" align="right">{state.issuedOn}</Paragraph>
Everything else about how a paragraph is set belongs to the theme, reached by
name through variant. The block decides; the node only says which block it is:
blocks: {
standfirst: { align: "center", spacingBeforePt: 4, spacingAfterPt: 14 },
quote: { indentMm: 10, indentRightMm: 10 },
heading: { keepWithNext: true },
contents: { tabStopsMm: [{ at: 170, align: "right", leader: "dot" }] },
}
<Paragraph id="lead" variant="standfirst">What this letter is about.</Paragraph>
<Paragraph id="terms" variant="contents">{"What the agreement covers\t3"}</Paragraph>
A node that states its own align wins over its block’s, the same way a cell
wins over its column. What a block can set, beyond fill, border and type:
align | how the lines sit in the column |
spacingBeforePt, spacingAfterPt | space above and below, in points |
indentMm, indentRightMm | inset from the left and right margins |
firstLineIndentMm | the first line only — a book’s paragraph mark |
hangingIndentMm | the first line pulled back from the rest |
keepWithNext, keepLines | never the last thing on a page; never split across one |
tabStopsMm | [{ at, align?, leader? }] from the left margin. Put a \t in the text to reach one |
lineHeight | leading, as a multiple of the font size |
Each of these means the same thing on screen as it does in Word, and that is checked rather than assumed: every property here has a conformance case that packs a document, opens it in Word, and holds the preview to what Word draws to the millimetre. Two limits fall out of Word’s own format and are worth knowing — padding needs a border to hang on, and a left border pushes the rule outward rather than the text inward.
Dynamic paragraphs
A dynamic paragraph has no text of its own; it carries prompts and a
placeholder instead, set with useSetPrompts and useSetPlaceholders or given
as props. Preview builds resolve it to the placeholder and label it; request-time
builds send the prompts. Only generalPrompt is required — the fourth variant
below shows what the other three are for.
Variants
Static
src/nodes/paragraph/static.node.tsx Data in, a line of text out.
import { Paragraph, useState } from "docxcelerate/template";
import type { SampleData } from "../sample-data.ts";
/**
* The smallest useful node: data taken into state, and a line built from it.
* `useState` is where data enters a component, and the only place it does.
*/
export const Greeting: Paragraph = () => {
const [state] = useState((data: SampleData) => ({ name: data.memberName }));
return <Paragraph id="greeting">Dear {state.name},</Paragraph>;
}; What it resolves to
The node as it appears in the DocumentModel: the JSON a renderer is handed. No styling, no layout.
{
"id": "greeting",
"kind": "paragraph",
"mode": "static",
"text": "Dear Adaeze Nkemelu,"
} Static, with branching
src/nodes/paragraph/conditional.node.tsx One node, several outcomes — and the id stays put.
import { Paragraph, useState } from "docxcelerate/template";
import { money, type SampleData } from "../sample-data.ts";
/**
* Branching is an ordinary `if`. The component decides what it is before it
* says anything, and each arm returns the node that arm means — one id per
* outcome, so a reader of the resolved document can tell which one they got.
*/
export const PriceChange: Paragraph = () => {
const [state] = useState((data: SampleData) => {
const delta = data.newPrice - data.lastPrice;
return {
plan: data.plan,
renewsOn: data.renewsOn,
lastPrice: data.lastPrice,
newPrice: data.newPrice,
delta,
percent: Math.abs((delta / data.lastPrice) * 100).toFixed(1),
};
});
if (state.delta === 0) {
return (
<Paragraph id="price-held">
Your {state.plan} membership renews at {money(state.newPrice)} a year — the same
price you paid last year.
</Paragraph>
);
}
return (
<Paragraph id="price-change">
Your {state.plan} membership is {state.delta > 0 ? "rising" : "falling"} by{" "}
{state.percent}%, from {money(state.lastPrice)} to {money(state.newPrice)} a year.
That is {money(state.newPrice / 12)} a month from {state.renewsOn}.
</Paragraph>
);
}; What it resolves to
The node as it appears in the DocumentModel: the JSON a renderer is handed. No styling, no layout.
{
"id": "price-change",
"kind": "paragraph",
"mode": "static",
"text": "Your Peak Anytime membership is rising by 5.1%, from £468 to £492 a year. That is £41 a month from 1 October 2026."
} Dynamic
src/nodes/paragraph/dynamic.node.tsx A prompt and a placeholder. Previews show the placeholder, labelled.
import { Paragraph, useSetPlaceholders, useSetPrompts, useState } from "docxcelerate/template";
import type { SampleData } from "../sample-data.ts";
/**
* The minimum a dynamic paragraph needs: one prompt, and a placeholder so the
* preview still reads as a letter. Setting prompts is what makes the node
* dynamic — nothing declares a mode.
*/
export const NextSteps: Paragraph = () => {
const [state] = useState((data: SampleData) => ({
name: data.memberName,
renewsOn: data.renewsOn,
}));
useSetPrompts({
generalPrompt:
`In two sentences, tell ${state.name} that their membership renews ` +
`automatically on ${state.renewsOn} and how to change plan before then.`,
});
useSetPlaceholders(
`Your membership renews automatically on ${state.renewsOn}. ` +
`Nothing is needed from you unless you want to change plan.`,
);
return <Paragraph id="next-steps" />;
}; What it resolves to
The node as it appears in the DocumentModel: the JSON a renderer is handed. No styling, no layout.
{
"id": "next-steps",
"kind": "paragraph",
"mode": "dynamic",
"text": "Your membership renews automatically on 1 October 2026. Nothing is needed from you unless you want to change plan."
} What the endpoint is asked
Resolved against the same sample data. A preview build stops at the placeholder; a request-time build sends these.
- general
- In two sentences, tell Adaeze Nkemelu that their membership renews automatically on 1 October 2026 and how to change plan before then.
Dynamic, all four prompts
src/nodes/paragraph/prompted.node.tsx System, general, info and negative, each doing one job.
import {
Paragraph,
useAvailableTokens,
useSetPlaceholders,
useSetPrompts,
useState,
} from "docxcelerate/template";
import { money, type SampleData } from "../sample-data.ts";
/**
* All four slots: general says what to write, info supplies facts without
* asking for them back, negative fences off the failure modes, system fixes
* the voice. `useAvailableTokens` is the budget the build allotted this node.
*/
export const Apology: Paragraph = () => {
const availableTokens = useAvailableTokens();
const [state] = useState((data: SampleData) => ({
name: data.memberName,
centreName: data.centreName,
plan: data.plan,
newPrice: data.newPrice,
}));
useSetPrompts({
systemPrompt:
`You write for a public leisure centre. Plain British English, second ` +
`person, no marketing language.`,
generalPrompt: `Apologise to ${state.name} for the main pool closure and say what ` +
`is still open. At most ${Math.floor(availableTokens / 4)} words.`,
infoPrompt: `The main pool at ${state.centreName} is resurfacing until 12 October. ` +
`The teaching pool, gym and classes are unaffected. Members on ` +
`${state.plan} paying ${money(state.newPrice)} a year get two guest passes ` +
`as compensation.`,
negativePrompt:
`Do not promise a refund, do not give a reopening date beyond 12 October, ` +
`and do not restate the price.`,
});
useSetPlaceholders(
`The main pool is closed for resurfacing until 12 October. ` +
`The teaching pool and all land-based classes are running as normal.`,
);
return <Paragraph id="pool-closure" />;
}; What it resolves to
The node as it appears in the DocumentModel: the JSON a renderer is handed. No styling, no layout.
{
"id": "pool-closure",
"kind": "paragraph",
"mode": "dynamic",
"text": "The main pool is closed for resurfacing until 12 October. The teaching pool and all land-based classes are running as normal."
} What the endpoint is asked
Resolved against the same sample data. A preview build stops at the placeholder; a request-time build sends these.
- system
- You write for a public leisure centre. Plain British English, second person, no marketing language.
- general
- Apologise to Adaeze Nkemelu for the main pool closure and say what is still open. At most 500 words.
- info
- The main pool at Riverside Leisure Centre is resurfacing until 12 October. The teaching pool, gym and classes are unaffected. Members on Peak Anytime paying £492 a year get two guest passes as compensation.
- negative
- Do not promise a refund, do not give a reopening date beyond 12 October, and do not restate the price.
Notes
- Both modes resolve to
kind: "paragraph". A renderer readsmodeif it cares at all; it never branches on how the node was written. - A node given its own text is static, whatever prompts a shared hook set around it. Supplying both on one element is an error.
- An empty string returns an empty paragraph, not no node. To drop the node,
return nothing —
false,nullandundefinedare all skipped. - Renderers escape the text. A paragraph cannot smuggle markup into the page, and is not the place for formatting.