Nodes
Paragraph
Prose, rendered from your data or resolved from prompts at request time.
The workhorse. A static paragraph returns a string from your typed data; 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`.
- Helpers
- paragraph
- Node kind
- paragraph
- Category
- Text
- Resolves
- Both
- Children
- None. Paragraphs are leaves.
| Option | Type | What it does |
|---|---|---|
id required | string | Stable address for the node. Generation endpoints target it and build artifacts diff on it, so treat a rename as a breaking change. |
render required | (data, availableTokens) => string | Static only. Receives your typed data and the token budget, returns the text. May be async. |
placeholder | (data, availableTokens) => string | Dynamic only. What previews show in place of generated content. Optional, but a letter that reads badly without one cannot be reviewed. |
generalPrompt required | (data, availableTokens) => string | Dynamic only. What this node should say. |
infoPrompt | (data, availableTokens) => string | Dynamic only. Context the model should have but should not restate. |
negativePrompt | (data, availableTokens) => string | Dynamic only. What to avoid — claims, tones, or facts it must not invent. |
systemPrompt | (data, availableTokens) => string | Dynamic only. Role and voice, applied ahead of the other prompts. |
derivers | DeriverInvocation[] | Values computed before the node resolves, written to derived.* and readable from a template token. Built with derive(). |
Writing one
render receives your typed data and the token budget, and returns a string.
There is no template language between them, so formatting, branching and
pluralisation are all ordinary TypeScript:
import { paragraph } from "docxcelerate";
import type { MemberData } from "../types.ts";
export const Greeting = paragraph<MemberData>({
id: "greeting",
render: (data) => `Dear ${data.memberName},`,
});
It may be async — but a node that fetches is a node that can fail mid-build,
so prefer putting the value in your data first.
The token budget
The second argument to render, placeholder and every prompt is
availableTokens: the budget this node was allotted, 2000 unless you set
availableTokens in the build options. Static nodes can ignore it. Dynamic ones
should spend it:
generalPrompt: (data, availableTokens) =>
`Explain the change. At most ${Math.floor(availableTokens / 4)} words.`,
Branching belongs in the render
A document that says one of three things is one node with three outcomes, not three nodes behind conditions. The id stays put, the tree keeps its shape whoever the recipient is, and the logic is code you can test.
Dynamic paragraphs
A dynamic paragraph swaps render for prompts and a placeholder. 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.ts Data in, a line of text out.
import { paragraph } from "docxcelerate";
import type { SampleData } from "../sample-data.ts";
/** The smallest useful node: an id, and a render that turns data into a line. */
export const Greeting = paragraph<SampleData>({
id: "greeting",
render: (data) => `Dear ${data.memberName},`,
}); 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.ts One node, several outcomes — and the id stays put.
import { paragraph } from "docxcelerate";
import { money, type SampleData } from "../sample-data.ts";
/**
* Branching lives in the render, not the template: one node and one id,
* whichever branch a member falls down.
*/
export const PriceChange = paragraph<SampleData>({
id: "price-change",
render: (data) => {
const delta = data.newPrice - data.lastPrice;
if (delta === 0) {
return `Your ${data.plan} membership renews at ${money(data.newPrice)} ` +
`a year — the same price you paid last year.`;
}
const direction = delta > 0 ? "rising" : "falling";
const percent = Math.abs((delta / data.lastPrice) * 100).toFixed(1);
return `Your ${data.plan} membership is ${direction} by ${percent}%, from ` +
`${money(data.lastPrice)} to ${money(data.newPrice)} a year. That is ` +
`${money(data.newPrice / 12)} a month from ${data.renewsOn}.`;
},
}); 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.ts A prompt and a placeholder. Previews show the placeholder, labelled.
import { paragraph } from "docxcelerate";
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.
*/
export const NextSteps = paragraph<SampleData>({
id: "next-steps",
placeholder: (data) =>
`Your membership renews automatically on ${data.renewsOn}. ` +
`Nothing is needed from you unless you want to change plan.`,
generalPrompt: (data) =>
`In two sentences, tell ${data.memberName} that their membership renews ` +
`automatically on ${data.renewsOn} and how to change plan before then.`,
}); 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.ts System, general, info and negative, each doing one job.
import { paragraph } from "docxcelerate";
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. `availableTokens` is the budget the build allotted this node.
*/
export const Apology = paragraph<SampleData>({
id: "pool-closure",
placeholder: () =>
`The main pool is closed for resurfacing until 12 October. ` +
`The teaching pool and all land-based classes are running as normal.`,
systemPrompt: () =>
`You write for a public leisure centre. Plain British English, second ` +
`person, no marketing language.`,
generalPrompt: (data, availableTokens) =>
`Apologise to ${data.memberName} for the main pool closure and say what ` +
`is still open. At most ${Math.floor(availableTokens / 4)} words.`,
infoPrompt: (data) =>
`The main pool at ${data.centreName} is resurfacing until 12 October. ` +
`The teaching pool, gym and classes are unaffected. Members on ` +
`${data.plan} paying ${money(data.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.`,
}); 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 which helper you called. - An empty string returns an empty paragraph, not no node. To drop the node, leave it out of the tree.
- Renderers escape the text. A paragraph cannot smuggle markup into the page, and is not the place for formatting.