Documents as components.
DOCX as output.
Compose documents from small typed components, using the JSX you already write. Easily incorporate AI to write or make decisions about the contents. Take advantage of our engine to generate documents at scale.
npx docxcelerate init my-documents {
"schemaVersion": "docxcelerate.config/v0",
"activePreset": "local",
"presets": {
"local": {
"build": { "outDir": "build" },
"upload": {
"endpoint": "",
"method": "POST",
"headers": {},
"body": "document"
}
},
"staging": {
"build": { "outDir": "build" },
"upload": {
"endpoint": "https://documents.staging.example.com/api/letters",
"method": "POST",
"headers": { "Authorization": "Bearer ${LETTERS_TOKEN}" },
"body": "document"
}
}
}
} import { Cell, Paragraph, type Row as RowComponent, Row, useDeriver } from "docxcelerate/template";
import { chargeLine } from "../derivers.ts";
import type { InvoiceLine } from "../types.ts";
/**
* One line of the charges table.
*
* A row of its own component rather than four cells written inline, because
* the figures have to be derived and a deriver is a hook: hooks belong to a
* component, and the callback inside a `.map()` is not one. Published, this
* becomes the body of the loop the engine walks — one row written once,
* standing for however many the request turns out to have.
*/
export const ChargeRow: RowComponent<{ line: InvoiceLine }> = async ({ line }) => {
const figures = await useDeriver(chargeLine, [line.qty, line.rate]);
return (
<Row>
<Cell variant="lineItem">
<Paragraph>{line.desc}</Paragraph>
<Paragraph variant="chargeNote">{line.meta}</Paragraph>
</Cell>
<Cell variant="money">{figures.qty}</Cell>
<Cell variant="money">{figures.rate}</Cell>
<Cell variant="money">{figures.amount}</Cell>
</Row>
);
}; import {
Cell,
Row,
Section,
type Section as SectionComponent,
Table,
useState,
} from "docxcelerate/template";
import { ChargeRow } from "./charge-row.node.tsx";
import type { InvoiceData } from "../types.ts";
/**
* What is being charged for, line by line.
*
* The rows are a `.map()`, which is the whole point: built against real data it
* walks the lines and the preview shows seven rows, and published it becomes
* one loop the engine walks — an invoice with three lines and one with thirty
* are the same document. Nothing here knows which of the two is happening.
*/
export const Charges: SectionComponent = () => {
const [state] = useState((data: InvoiceData) => ({ lines: data.lines }));
return (
<Section id="charges" title="Charges" showTitle={false}>
<Table
id="lines"
columns={[
{ width: "auto" },
{ width: 16, align: "right" },
{ width: 24, align: "right" },
{ width: 26, align: "right" },
]}
>
<Row header>
<Cell>Description</Cell>
<Cell>Qty</Cell>
<Cell>Rate</Cell>
<Cell>Amount</Cell>
</Row>
{state.lines.map((line) => <ChargeRow line={line} />)}
</Table>
</Section>
);
}; import {
Cell,
type Nodes,
Paragraph,
Row,
Table,
useDeriver,
useState,
} from "docxcelerate/template";
import { invoiceDates } from "../derivers.ts";
import type { InvoiceData } from "../types.ts";
/**
* What is left to say once the total is settled: where the rest of it lives.
*
* An invoice that turns the page has to say so on the page it turns from,
* or the reader takes the total for the end of the document and files it
* without the account details. The due date sits opposite, because that is
* the other thing someone reads off the bottom of page one.
*/
export const Closer: Nodes = async () => {
const [state] = useState((data: InvoiceData) => ({
issueDate: data.issueDate,
dueDate: data.dueDate,
}));
const dates = await useDeriver(invoiceDates, [state.issueDate, state.dueDate]);
return (
<Table id="closer" columns={[{ width: "auto" }, { width: 46, align: "right" }]}>
<Row>
<Cell id="closer-note">
<Paragraph variant="muted">
Payment details, terms and a scan-to-pay code are on page 2.
</Paragraph>
</Cell>
<Cell id="closer-due">
<Paragraph variant="label">Due {dates.due}</Paragraph>
</Cell>
</Row>
</Table>
);
}; import {
Paragraph,
useSetPlaceholders,
useSetPrompts,
useState,
} from "docxcelerate/template";
import type { InvoiceData } from "../types.ts";
/**
* The paragraph a human would otherwise write every month.
*
* The figures are already in the table below, so this is the one part of an
* invoice that has to be composed rather than laid out — what the work was
* for, in the client's terms. It is the engine's to write per recipient, which
* is why the node carries prompts instead of text.
*
* The negative prompt is doing real work: an engine handed a table of charges
* will restate the total unless told not to, and a total that disagrees with
* the one printed below it is worse than no summary at all.
*/
export const EngagementSummary: Paragraph = () => {
const [state] = useState((data: InvoiceData) => ({
client: data.billedTo.name,
lead: data.deliveryLead,
}));
useSetPrompts({
systemPrompt:
"You write the covering note on a consultancy invoice. Plain, specific, " +
"and short. You are writing to a finance team who did not attend the work.",
generalPrompt:
`Write three sentences for ${state.client} summarising what this month's ` +
"work delivered, leading with whatever the largest line paid for.",
// The lines are named by pointing at them rather than by being pasted in.
// Spelling them out here would mean walking the list while building, which
// publishing refuses — there is no list until a request arrives — and the
// engine is writing into a document that carries the table anyway.
infoPrompt: "The work billed is itemised in the charges table below, each " +
`line with its own note. The delivery lead is ${state.lead}.`,
negativePrompt:
"Do not restate any figure, total or rate — they are in the table below. " +
"Do not thank the client, and do not mention the invoice itself.",
});
useSetPlaceholders(
"Sprint 14 closed out the repairs booking endpoints and the account and " +
"statements views in the tenant portal. Most of this invoice is the " +
"eight-day API build; the document automation line covers the arrears " +
"letter and statement templates now generating in production. September " +
"continues under the support retainer.",
);
return <Paragraph id="engagement-summary" variant="summary" />;
}; export { Charges } from "./charges.node.tsx";
export { Closer } from "./closer.node.tsx";
export { EngagementSummary } from "./engagement-summary.node.tsx";
export { InvoiceMeta } from "./invoice-meta.node.tsx";
export { Letterhead } from "./letterhead.node.tsx";
export { Parties } from "./parties.node.tsx";
export { Payment } from "./payment.node.tsx";
export { PaymentLetterhead } from "./payment-letterhead.node.tsx";
export { RunningFooter, RunningHeader } from "./running.node.tsx";
export { ScanToPay } from "./scan-to-pay.node.tsx";
export { Terms } from "./terms.node.tsx";
export { Totals } from "./totals.node.tsx"; import {
Cell,
Paragraph,
Row,
Section,
type Section as SectionComponent,
Table,
useDeriver,
useState,
} from "docxcelerate/template";
import { invoiceDates } from "../derivers.ts";
import type { InvoiceData } from "../types.ts";
/**
* The dates band, and the status the invoice carries.
*
* One row, not two: each cell holds its label over its value, which is how the
* design draws it. Two rows put every label on one line and every value on the
* next, so a long PO reference pushed all four values down together.
*
* The status is a decision rather than a label: a settled invoice must not
* print a payment deadline, and one still owing must not claim to be paid.
* Each outcome is written as its own cell so that the build compiles the
* ternary into a condition — a ternary picking between two *strings* would be
* a value, and a value is settled once, at build time, for everybody. Both
* cells travel, each carrying the test that selects it, and the engine chooses
* per recipient.
*/
export const InvoiceMeta: SectionComponent = async () => {
const [state] = useState((data: InvoiceData) => ({
issueDate: data.issueDate,
dueDate: data.dueDate,
poReference: data.poReference,
paid: data.paid,
}));
// Formatting a value read from the request is exactly what a deriver is for:
// `useFormat` would settle it here, once, for everybody.
const dates = await useDeriver(invoiceDates, [state.issueDate, state.dueDate]);
return (
<Section id="invoice-meta" title="Invoice details" showTitle={false}>
<Table
id="meta-band"
variant="band"
// The status is a pill, so its column is measured rather than left to
// take up the slack: an `"auto"` column here made the box as wide as
// whatever the three dates did not use. The slack goes to the PO
// reference instead, which is plain text and does not mind.
columns={[{ width: 38 }, { width: 42 }, { width: "auto" }, { width: 44, align: "right" }]}
>
<Row>
<Cell id="issue" variant="bandCell">
<Paragraph variant="label">Issue date</Paragraph>
<Paragraph>{dates.issue}</Paragraph>
</Cell>
<Cell id="due" variant="bandCell">
<Paragraph variant="label">Due date</Paragraph>
<Paragraph>{dates.due}</Paragraph>
</Cell>
<Cell id="po" variant="bandCell">
<Paragraph variant="label">PO reference</Paragraph>
<Paragraph>{state.poReference}</Paragraph>
</Cell>
{state.paid
? <Cell id="status-paid" variant="badge-done">Paid</Cell>
: <Cell id="status-awaiting" variant="badge">Awaiting payment</Cell>}
</Row>
</Table>
</Section>
);
}; import { Cell, Image, type Nodes, Paragraph, Row, Table, useState } from "docxcelerate/template";
import { senderMarkPng, senderMarkSvg } from "../assets.ts";
import type { InvoiceData } from "../types.ts";
/**
* Who sent this, and which invoice it is.
*
* A three-column table rather than a run of paragraphs, because the two halves
* have to sit level: the mark and the name on the left, the wordmark and the
* reference hard right. Paragraphs would stack them, and a letterhead that
* stacks is a letterhead that has stopped being one.
*
* The reference is the string that has to be exact — it is repeated on the
* payment page and quoted on every transfer.
*/
export const Letterhead: Nodes = () => {
const [state] = useState((data: InvoiceData) => ({
name: data.sender.name,
trade: data.sender.trade,
reference: data.reference,
}));
return (
<>
<Table
id="letterhead"
columns={[{ width: 14 }, { width: "auto" }, { width: 62, align: "right" }]}
>
<Row>
<Cell id="sender-mark">
<Image
id="sender-mark-image"
src={senderMarkSvg}
fallbackSrc={senderMarkPng}
alt={state.name}
width={28}
height={28}
/>
</Cell>
<Cell id="sender" variant="lineItem">
<Paragraph variant="senderName">{state.name}</Paragraph>
<Paragraph variant="muted">{state.trade}</Paragraph>
</Cell>
<Cell id="wordmark" variant="lineItem">
<Paragraph variant="wordmark">Invoice</Paragraph>
<Paragraph variant="reference">{state.reference}</Paragraph>
</Cell>
</Row>
</Table>
{/*
The rule the running strip draws on every other page. Page one has no
running header — its letterhead is the top of the page — so the line
under it belongs to the letterhead rather than to the furniture.
*/}
<Paragraph id="letterhead-rule" variant="rule" />
</>
);
}; import {
Cell,
Paragraph,
Row,
Section,
type Section as SectionComponent,
Table,
useState,
} from "docxcelerate/template";
import type { InvoiceData } from "../types.ts";
/**
* Who is being billed, beside who is billing them.
*
* A two-column table rather than two runs of paragraphs, because the two
* addresses have to sit level however many lines each one has — a five-line
* address next to a three-line one is the case that breaks anything else.
*
* The section is titled for both parties. The columns are already headed
* "Billed to" and "From", and a section heading repeating one of them printed
* the same two words twice, a few millimetres apart.
*/
export const Parties: SectionComponent = () => {
const [state] = useState((data: InvoiceData) => ({
billedTo: data.billedTo,
sender: data.sender,
}));
return (
<Section id="parties" title="Parties" showTitle={false}>
<Table id="parties-grid" columns={[{ width: "auto" }, { width: "auto" }]}>
{/*
`label`, not `header`. A header row draws the theme's navy bar, which
is right for the charges table and wrong here — the design sets these
two as small tracked capitals over their columns, with no bar. Naming
a variant is what stops the bar: the navy is only the default for a
header row that resolves to nothing else.
*/}
<Row>
<Cell variant="label">Billed to</Cell>
<Cell variant="label">From</Cell>
</Row>
<Row>
<Cell id="billed-to" variant="addressCell">
<Paragraph>{state.billedTo.name}</Paragraph>
<Paragraph>{state.billedTo.attn}</Paragraph>
{state.billedTo.addressLines.map((line) => <Paragraph>{line}</Paragraph>)}
</Cell>
<Cell id="billed-from" variant="addressCell">
<Paragraph>{state.sender.name}</Paragraph>
{state.sender.addressLines.map((line) => <Paragraph>{line}</Paragraph>)}
<Paragraph>{state.sender.email}</Paragraph>
</Cell>
</Row>
</Table>
</Section>
);
}; import { Cell, Image, type Nodes, Paragraph, Row, Table, useState } from "docxcelerate/template";
import { senderMarkPng, senderMarkSvg } from "../assets.ts";
import type { InvoiceData } from "../types.ts";
/**
* The top of the payment page: the mark, the sender, and what this page is.
*
* Body content rather than running furniture, because it belongs to this page
* alone — the running strip says which invoice every page belongs to, and
* saying it twice on the one page that also carries a wordmark would be the
* reference three times over.
*
* It reads PAYMENT rather than INVOICE: the sheet can be handed to whoever
* pays without the sheet that says what for, and it should say which of the
* two it is.
*/
export const PaymentLetterhead: Nodes = () => {
const [state] = useState((data: InvoiceData) => ({
name: data.sender.name,
reference: data.reference,
}));
return (
<Table
id="payment-letterhead"
columns={[{ width: 10 }, { width: "auto" }, { width: 62, align: "right" }]}
>
<Row>
<Cell id="payment-mark">
<Image
id="payment-mark-image"
src={senderMarkSvg}
fallbackSrc={senderMarkPng}
alt={state.name}
width={18}
height={18}
/>
</Cell>
<Cell id="payment-sender">
<Paragraph variant="senderName">{state.name}</Paragraph>
</Cell>
<Cell id="payment-wordmark">
<Paragraph variant="wordmark">Payment</Paragraph>
<Paragraph variant="reference">{state.reference}</Paragraph>
</Cell>
</Row>
</Table>
);
}; import {
Cell,
Paragraph,
Row,
Section,
type Section as SectionComponent,
Table,
useDeriver,
useState,
} from "docxcelerate/template";
import { invoiceTotals } from "../derivers.ts";
import type { InvoiceData } from "../types.ts";
/**
* Where the money goes, and what to quote when sending it.
*
* The amount is repeated here on purpose. Someone paying an invoice is looking
* at this page, not the one before it, and a payment page that makes them turn
* back to find the figure is a payment page that gets the figure wrong.
*/
export const Payment: SectionComponent = async () => {
const [state] = useState((data: InvoiceData) => ({
bank: data.sender.bank,
reference: data.reference,
lines: data.lines,
rate: data.vatRate,
}));
// The same one pass over the lines as the totals table: two derivations of
// one figure are two figures that can disagree.
const totals = await useDeriver(invoiceTotals, [state.lines, state.rate]);
return (
<Section id="payment" title="Pay by bank transfer">
<Table id="bank-details" columns={[{ width: 40 }, { width: "auto" }]}>
<Row>
<Cell variant="lineItem">Account name</Cell>
<Cell variant="lineItem">{state.bank.accountName}</Cell>
</Row>
<Row>
<Cell>Sort code</Cell>
<Cell variant="money">{state.bank.sortCode}</Cell>
</Row>
<Row>
<Cell>Account no</Cell>
<Cell variant="money">{state.bank.accountNumber}</Cell>
</Row>
<Row>
<Cell>IBAN</Cell>
<Cell variant="money">{state.bank.iban}</Cell>
</Row>
<Row>
<Cell>BIC</Cell>
<Cell variant="money">{state.bank.bic}</Cell>
</Row>
<Row>
<Cell>Amount</Cell>
<Cell variant="money">{totals.total}</Cell>
</Row>
</Table>
{/*
One cell, three paragraphs — not three shaded paragraphs. Consecutive
shaded paragraphs each draw their own box with the paragraph gap
showing between them, so the panel the design draws as one card comes
out as three stacked tiles.
*/}
<Table id="reference-panel" columns={[{ width: "auto" }]}>
<Row>
<Cell id="reference-panel-cell" variant="panel">
<Paragraph variant="label">Payment reference</Paragraph>
<Paragraph variant="money">{state.reference}</Paragraph>
<Paragraph variant="muted">
Quote this reference on every transfer, so the payment reconciles on receipt.
</Paragraph>
</Cell>
</Row>
</Table>
</Section>
);
}; import {
Cell,
Image,
type Nodes,
PageNumber,
Paragraph,
Row,
Table,
useState,
} from "docxcelerate/template";
import { markPng, markSvg } from "../assets.ts";
import type { InvoiceData } from "../types.ts";
/**
* The strip at the top of every page: who sent this, and which invoice.
*
* Running furniture rather than the first thing in the body, because page two
* needs it as much as page one — a payment page that does not say which
* invoice it belongs to is a page that gets filed against the wrong account.
*/
export const RunningHeader: Nodes = () => {
const [state] = useState((data: InvoiceData) => ({
name: data.sender.name,
reference: data.reference,
}));
return (
<>
<Table id="running-head" columns={[{ width: "auto" }, { width: 60, align: "right" }]}>
<Row>
<Cell id="running-sender">{state.name}</Cell>
<Cell id="running-reference">{state.reference}</Cell>
</Row>
</Table>
<Paragraph id="head-rule" variant="rule" />
</>
);
};
/**
* The dark strip at the foot of every page.
*
* The strip is the table's, not each cell's: a bar is one thing crossing the
* page, and three cells that each paint themselves navy is three boxes that
* happen to touch.
*
* The credit is the design's second decision, and it is written as an ordinary
* `&&`: whether a given sender's invoice carries it is theirs to set, so the
* build compiles it into a condition rather than settling it once for everyone.
*/
export const RunningFooter: Nodes = () => {
const [state] = useState((data: InvoiceData) => ({
registration: data.sender.registration,
showCredit: data.showCredit,
}));
return (
<Table
id="running-foot"
variant="footerBar"
columns={[{ width: "auto" }, { width: 52 }, { width: 29, align: "right" }]}
>
<Row>
<Cell id="foot-registration">{state.registration}</Cell>
<Cell id="foot-credit">
{state.showCredit && (
<Paragraph id="credit-line">
<Image
id="credit-mark"
src={markSvg}
fallbackSrc={markPng}
alt=""
width={8}
height={8}
/>
{" Generated with Docxcelerate"}
</Paragraph>
)}
</Cell>
<Cell id="foot-page" variant="footerEdge">
<PageNumber id="foot-page-number" />
</Cell>
</Row>
</Table>
);
}; import { Image, useDeriver, useState } from "docxcelerate/template";
import { paymentQr } from "../derivers.ts";
import type { InvoiceData } from "../types.ts";
/**
* The code that opens a transfer with the reference already set.
*
* Computed, not composed. It was a `generalPrompt` asking an engine to draw a
* payment QR, which is the wrong instrument: a QR is a deterministic encoding
* of a string, and a drawn one does not scan. The design says as much itself —
* the chip under the card reads "deriver: payment.qr".
*
* Two derivations of the same code, because Word will not embed an SVG on its
* own: the screen takes the vector and the `.docx` takes the raster, which is
* what `fallbackSrc` is for. The node is no longer dynamic — it has a source
* and no prompts, which is the honest classification.
*/
export const ScanToPay: Image = async () => {
const [state] = useState((data: InvoiceData) => ({
reference: data.reference,
iban: data.sender.bank.iban,
}));
// Both hooks are reached before either is awaited: hooks run in call order,
// and an await between them would put the second one outside the component.
const vector = useDeriver(paymentQr, [state.iban, "", state.reference, "svg"]);
const raster = useDeriver(paymentQr, [state.iban, "", state.reference, "png"]);
const svg = await vector;
const png = await raster;
return (
<Image
id="scan-to-pay"
variant="card"
src={svg}
fallbackSrc={png}
alt={`Scan to pay invoice ${state.reference}`}
width={108}
height={108}
/>
);
}; import { Paragraph, Section, type Section as SectionComponent, useState } from "docxcelerate/template";
import type { InvoiceData } from "../types.ts";
/** The terms, and who to ask about them. */
export const Terms: SectionComponent = () => {
const [state] = useState((data: InvoiceData) => ({
email: data.sender.email,
lead: data.deliveryLead,
}));
return (
<Section id="terms" title="Terms & notes">
<Paragraph id="terms-payment">
Payment within 14 days of the invoice date. Accounts unpaid at 30 days accrue interest
at 8% above the Bank of England base rate, per the Late Payment of Commercial Debts Act.
</Paragraph>
{/* Set quietly: the terms above are the obligation, this is where to ask
about it, and the design draws the second one a shade back. */}
<Paragraph id="terms-contact" variant="muted">
Send remittance advice to {state.email}. Queries about this invoice go to your delivery
lead, {state.lead}.
</Paragraph>
</Section>
);
}; import { Cell, type Nodes, Row, Table, useDeriver, useState } from "docxcelerate/template";
import { invoiceTotals } from "../derivers.ts";
import type { InvoiceData } from "../types.ts";
/**
* Subtotal, VAT, and what is actually owed.
*
* Every figure is computed in the one initializer, from the same lines the
* table above prints. That is deliberate: a total worked out in a second place
* is a total that can disagree with the first, and on an invoice that is the
* error nobody forgives.
*
* The block carries no heading of its own: the row a reader stops on says
* TOTAL DUE, and a section titled "Total" above it is the same word twice.
*
* The bar is named cell by cell rather than as a row, and the row is not a
* header. `header` is what sets a row in tracked capitals through `headingRun`
* — which is a heading's treatment, not a total's — and naming the row would
* paint the empty spacer navy too, running the bar across the whole page where
* the design stops it above the figures it adds up.
*/
export const Totals: Nodes = async () => {
const [state] = useState((data: InvoiceData) => ({
lines: data.lines,
rate: data.vatRate,
}));
// The arithmetic reaches the engine rather than being settled here: the
// lines belong to a request nobody has made yet.
const totals = await useDeriver(invoiceTotals, [state.lines, state.rate]);
return (
<Table
id="totals-table"
columns={[{ width: "auto" }, { width: 50 }, { width: 34, align: "right" }]}
>
<Row>
<Cell></Cell>
<Cell variant="panel">Subtotal</Cell>
<Cell variant="panel">{totals.subtotal}</Cell>
</Row>
<Row>
<Cell></Cell>
<Cell variant="panel">VAT ({totals.rate})</Cell>
<Cell variant="panel">{totals.vat}</Cell>
</Row>
<Row>
<Cell></Cell>
<Cell variant="totalRow">Total due</Cell>
<Cell variant="totalRow">{totals.total}</Cell>
</Row>
</Table>
);
}; /**
* The pictures this document carries, as data URIs.
*
* A path would not survive. The model is JSON handed to an engine that writes
* the document somewhere else, and `../assets/mark.svg` means nothing there —
* a data URI carries the bytes, so the picture travels with the document.
*
* Each mark comes in two forms. The SVG is what a screen draws, because it
* stays sharp at any size. The PNG is what Word embeds, because Word will not
* take an SVG without a raster beside it.
*/
/** Fernhill's own mark: a navy tile with the initial cut out of it. */
export const senderMarkSvg = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgcm9sZT0iaW1nIj48dGl0bGU+RmVybmhpbGwgU3lzdGVtczwvdGl0bGU+PHJlY3Qgd2lkdGg9IjEyOCIgaGVpZ2h0PSIxMjgiIHJ4PSIxMCIgZmlsbD0iIzJjM2Q4ZiIvPjxyZWN0IHg9IjQ0IiB5PSIzMCIgd2lkdGg9IjEzIiBoZWlnaHQ9IjY4IiBmaWxsPSIjZmZmZmZmIi8+PHJlY3QgeD0iNDQiIHk9IjMwIiB3aWR0aD0iNDIiIGhlaWdodD0iMTIiIGZpbGw9IiNmZmZmZmYiLz48cmVjdCB4PSI0NCIgeT0iNTYiIHdpZHRoPSIzMiIgaGVpZ2h0PSIxMiIgZmlsbD0iI2ZmZmZmZiIvPjwvc3ZnPg==";
/** The same tile as a raster, for the Word file. */
export const senderMarkPng = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABjElEQVR4nO3SwU3EQBBFQQdECsS4IYO4gRbM8a39q6V3n+mu4ziZt/fHh67f2Y0dfiiH198Q6kcpRlA/SCGA+jGKEdQPUQigfoRiBPUDBIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAGgNwFWm3hMA8dR7AiCeek8AxFPvCYB46j0BEE+9JwDiqfcEQDz1ngCIp94TAPHUewIgnnpPAMRT7wmACx4DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKKf+PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABw/6MBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAC737VABgPgPEAGA+A8QAYD4DxABgPgPEAGA+A8QAYD4DxLgdAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgADQDwBfUz9C4fEB2O34PvVjFB4fgL2eAECw06/HB+H+/Xt4EO7Z2Y0/AXUJp3Hr+VEhAAAAAElFTkSuQmCC";
/** The Docxcelerate mark, reversed out for the dark footer. */
export const markSvg = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xIC0zLjA3IDQ5LjE0IDQ5LjE0IiB3aWR0aD0iNDkuMTQiIGhlaWdodD0iNDkuMTQiIHJvbGU9ImltZyI+PHRpdGxlPkRvY3hjZWxlcmF0ZTwvdGl0bGU+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTIuMzA5NCAtMTIpIHNrZXdYKC0xMikiPjxyZWN0IHg9IjE0IiB5PSIxMiIgd2lkdGg9IjM4IiBoZWlnaHQ9IjciIGZpbGw9IiNmZmZmZmYiLz48cmVjdCB4PSIxNCIgeT0iMjQiIHdpZHRoPSIyOCIgaGVpZ2h0PSI3IiBmaWxsPSIjZmZmZmZmIi8+PHJlY3QgeD0iMTQiIHk9IjM2IiB3aWR0aD0iNDIiIGhlaWdodD0iNyIgZmlsbD0iI2ZmZmZmZiIvPjxyZWN0IHg9IjE0IiB5PSI0OCIgd2lkdGg9IjIxIiBoZWlnaHQ9IjciIGZpbGw9IiNmZmZmZmYiLz48L2c+PC9zdmc+";
/** The same mark as a raster, for the Word file. */
export const markPng = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABn0lEQVR4nO3ZwVEFAQgFQfNPWlPQquXPIt0hvOHG1xcAAADAw755DQdwnAM4zgEcJ/5hH4/vAN7FARznAI5zAMeJf9jH4zuAd3EAxzmA4xzAceIf9vH4DuBdHMBxDuC45AAAAAAAAAD4Z+qv1wZ1o1H1uG9X9xlVj7tB3WhUPe4GdaNR9bgb1I1G1eNuUDcaUw+7Qd1oVD3uBnWjUfW4G9SNRtXjblA3GlWPu0HdaEw97AZ1o1H1uBvUjUbV425QNxpVj7tB3WhUPe7b1X1G1eNuUDcaVY+7Qd0IAAAAAAAA4Jfq9yp/5wCOE/84B3CcAzjssfgOYCcHcJz4xzmA4xzAYY/FdwA7OYDjxD/OARznAA57LL4D2MkBHCf+cY8dAAAAAAAAAP9M/cmaVG+7Qh1pUr3tCnWkSfW2K9SRptS7rlBHmlRvu0IdaVK97Qp1pEn1tivUkSbV265QR5pS77pCHWlSve0KdaRJ9bYr1JEm1duuUEeaUu+6Rh1qSr3rCnWkSfW2K9SRJtXbrlBHmlRvu0IdaUq9KwAAAMAb/AC6wCcDReOi2wAAAABJRU5ErkJggg=="; import { deriver } from "docxcelerate";
import type { InvoiceLine } from "./types.ts";
/**
* What the invoice adds up to.
*
* The arithmetic has to be a deriver rather than a `useState` initializer,
* because the lines do not exist until a request does: `reduce` walks entries,
* and at publish time there are none to walk. Computed in the initializer this
* document builds locally and is refused on publish — which makes it a preview,
* not a template.
*
* All three figures come out of one deriver, from one pass over the lines. A
* total worked out in a second place is a total that can disagree with the
* first, and on an invoice that is the error nobody forgives.
*
* No `placeholder`, so it runs in the preview too: the preview then shows the
* real arithmetic rather than a stand-in, and the two cannot drift.
*/
export const invoiceTotals = deriver({
name: "invoiceTotals",
run: (lines: InvoiceLine[], vatRate: number) => {
const subtotal = lines.reduce((total, line) => total + line.qty * line.rate, 0);
const vat = subtotal * vatRate;
const money = (value: number) =>
new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(value);
// Formatted here rather than returned raw and formatted at the call site:
// reading a derived number to print it is reading request data, and the
// publish path refuses that exactly as it refuses the arithmetic.
return {
subtotal: money(subtotal),
vat: money(vat),
total: money(subtotal + vat),
rate: new Intl.NumberFormat("en-GB", { style: "percent" }).format(vatRate),
};
},
});
/**
* The dates, written the way a reader reads them.
*
* `useFormat`'s `date` reads the value while building, which publishing
* refuses for the same reason as `reduce`: the value belongs to a request that
* has not been made. Formatting is exactly what a deriver is for.
*/
export const invoiceDates = deriver({
name: "invoiceDates",
run: (issueDate: string, dueDate: string) => {
const written = (value: string) =>
new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric" })
.format(new Date(value));
return { issue: written(issueDate), due: written(dueDate) };
},
});
/**
* One charge line, in the figures a reader sees.
*
* Both the arithmetic and the formatting have to happen here. Inside the loop
* that walks the lines, `line.qty * line.rate` is a computation on a value
* that does not exist yet, and `currency(line.rate)` is a reading of one —
* publishing refuses both, for the same reason.
*/
export const chargeLine = deriver({
name: "chargeLine",
run: (qty: number, rate: number) => {
const money = (value: number) =>
new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(value);
return {
qty: new Intl.NumberFormat("en-GB", { minimumFractionDigits: 1 }).format(qty),
rate: money(rate),
amount: money(qty * rate),
};
},
});
/**
* The scan-to-pay code.
*
* A QR is a deterministic encoding of a string, not a picture a model should
* invent — asked for as a prompt it comes back as something that looks like a
* code and does not scan, which on an invoice is worse than no code at all.
* So it is derived, from the account details and the reference it encodes.
*
* `format` picks the rendering because Word will not embed an SVG on its own:
* the screen gets the vector and the `.docx` gets the raster, which is what
* the image's `fallbackSrc` is for.
*
* No `placeholder`, so it runs in the preview too — the preview shows a real,
* scannable code rather than a stand-in for one.
*/
export const paymentQr = deriver({
name: "paymentQr",
run: async (iban: string, amount: string, reference: string, format: string) => {
const QRCode = (await import("qrcode")).default;
// EPC069-12: the payment URI European banking apps read from a QR.
const payload = [
"BCD",
"002",
"1",
"SCT",
"",
"",
iban.replace(/\s+/g, ""),
amount,
"",
reference,
].join("\n");
const options = { margin: 0, width: 288 } as const;
return format === "svg"
? `data:image/svg+xml;utf8,${encodeURIComponent(await QRCode.toString(payload, { ...options, type: "svg" }))}`
: await QRCode.toDataURL(payload, options);
},
}); import { defineDocumentProject } from "docxcelerate/document";
import { documentTemplate } from "./document.tsx";
import { invoiceStyle } from "./invoice-style.ts";
import { previewData } from "./preview-data.ts";
import type { InvoiceData } from "./types.ts";
export default defineDocumentProject<InvoiceData>({
id: "invoice",
name: "Invoice",
version: "1.0.0",
template: documentTemplate,
style: invoiceStyle,
previewData,
}); import { Document, PageBreak, Section, template } from "docxcelerate/template";
import {
Charges,
Closer,
EngagementSummary,
InvoiceMeta,
Letterhead,
Parties,
Payment,
PaymentLetterhead,
RunningFooter,
RunningHeader,
ScanToPay,
Terms,
Totals,
} from "./nodes/index.ts";
import type { InvoiceData } from "./types.ts";
/**
* Structure only: which nodes, in which order, and where the page turns.
*
* The break is part of what this document is, not a way of nudging a paragraph
* off the bottom of a page. What is owed goes on one page and how to pay it on
* the next, so that either can be handed to someone on its own.
*
* Page one carries no running header: the letterhead already is the top of the
* page, and printing both names the sender twice. Page two needs one, because a
* payment page that does not say which invoice it belongs to gets filed against
* the wrong account — so the header runs everywhere except the first page.
*/
export const documentTemplate = template<InvoiceData>(
<Document
id="invoice"
title="Invoice"
header={<RunningHeader />}
firstHeader={false}
footer={<RunningFooter />}
>
<Letterhead />
<InvoiceMeta />
<Parties />
<Section id="summary" title="Engagement summary">
<EngagementSummary />
</Section>
<Charges />
<Totals />
<Closer />
<PageBreak id="to-payment" />
<PaymentLetterhead />
<Payment />
<Section id="scan" title="Scan to pay">
<ScanToPay />
</Section>
<Terms />
</Document>,
); import type { DocumentStyle } from "docxcelerate";
/**
* Fernhill's house style: navy, tight, and set for figures.
*
* An invoice is read in two passes — what is owed, then what for — so the
* headings are small capitals a reader skims past rather than titles that stop
* them, and the body is set a little tighter than a letter's. The navy is the
* sender's, not the site's: a document carries its own brand.
*
* The blocks below are the other half of that. A component says a node is a
* `band` or a `badge`; this is where the theme decides what those look like, so
* no colour is ever written into a node.
*/
export const invoiceStyle: DocumentStyle = {
preset: "fernhill-invoice",
page: {
size: "A4",
orientation: "portrait",
margins: { topMm: 16, rightMm: 16, bottomMm: 16, leftMm: 16 },
// The footer strip is a bar, not a line of small print: it bleeds to the
// left and right edges of the paper, so floating it above the bottom one
// left a band of white under a bar that reaches every other edge. Word
// stands a footer 12.5mm off the paper unless a document says otherwise,
// and this one has reason to.
footerMm: 0,
},
typography: {
bodyFont: "Aptos",
headingFont: "Aptos",
bodySizePt: 10,
bodyLineHeight: 1.45,
color: "1C2340",
},
palette: {
heading: "2C3D8F",
accent: "2C3D8F",
muted: "5A6482",
rule: "D9DDEB",
page: "FFFFFF",
},
paragraph: { spacingAfterPt: 6 },
// The letterhead carries the wordmark beside the reference, so a second
// title above it would be the document naming itself twice.
showTitle: false,
title: {
letterSpacingEm: 0.14,
fontSizePt: 23,
weight: "regular",
spacingBeforePt: 0,
spacingAfterPt: 10,
color: "2C3D8F",
transform: "uppercase",
},
sectionHeading: {
letterSpacingEm: 0.12,
fontSizePt: 7.5,
weight: "bold",
spacingBeforePt: 12,
spacingAfterPt: 4,
color: "2C3D8F",
transform: "uppercase",
},
blocks: {
/**
* The tinted strip of dates under the letterhead.
*
* It stands on the text column rather than bleeding into the margins. A
* table that reaches past them carries its first label out to the paper's
* edge and leaves the status pill ending where no other column does —
* and Word will not indent a table out of its margins in any case, so a
* band that bled would be a band only the preview could draw.
*/
band: {
valign: "center",
fill: "F4F6FD",
borderSides: ["bottom"],
border: "E3E7F5",
paddingPt: 10,
},
/** A tinted box: the totals, the payment reference. */
panel: {
lineHeight: 1.2,
fill: "F4F6FD",
paddingPt: 9,
},
/** An outlined box that is not tinted — the scan-to-pay card. */
card: {
border: "D9DDEB",
paddingPt: 12,
},
/** The status pill, when there is still something to pay. */
badge: {
fill: "FBF0DC",
border: "E5C78A",
color: "8A5A06",
paddingPt: 5,
fontSizePt: 7,
weight: "bold",
transform: "uppercase",
letterSpacingEm: 0.1,
},
/** The same pill, once it is settled. */
"badge-done": {
fill: "2C3D8F",
border: "2C3D8F",
color: "FFFFFF",
paddingPt: 5,
fontSizePt: 7,
weight: "bold",
transform: "uppercase",
letterSpacingEm: 0.1,
},
/** The line under the letterhead, drawn edge to edge of the paper. */
rule: {
fill: "2C3D8F",
bleed: true,
// A strip, not a line of type: three pixels of navy, which is what the
// design draws and what a depth stated in points says outright.
heightPt: 2.25,
spacingAfterPt: 0,
},
/**
* The dark strip at the foot of every page.
*
* It bleeds: a bar with a white gutter either side of it is not a bar, it
* is a box. Set on one line, because the strip is one line of small print
* rather than a block of it.
*/
footerBar: {
valign: "center",
fill: "1E2A66",
// The design's rgba(255,255,255,0.85) over #1E2A66, composited. Not an
// approximation — the same colour, worked out once at build time.
color: "D5D8E4",
bleed: true,
// The strip's depth is padding, not leading: it holds one line of small
// print and the design still draws it 55px deep.
paddingPt: 15,
fontSizePt: 7.5,
lineHeight: 1.2,
},
/**
* The last cell of the footer bar.
*
* The bar runs to the paper's edge; its page number stops where the design
* stops it. Saying so on the cell beats a spacer column the document does
* not otherwise have — and the bar's fill and centring still reach it.
*/
footerEdge: {
paddingSidesPt: { right: 46 },
},
/** The row a reader's eye stops on. */
totalRow: {
lineHeight: 1.2,
fill: "1E2A66",
color: "FFFFFF",
weight: "bold",
fontSizePt: 11,
},
/** A small capital label above a value. */
label: {
lineHeight: 1.2,
color: "2C3D8F",
fontSizePt: 7,
weight: "bold",
transform: "uppercase",
letterSpacingEm: 0.12,
},
/** The sender's name at the top of the page. */
senderName: {
fontSizePt: 13.5,
weight: "bold",
color: "1C2340",
},
/** The word INVOICE, set light and opened right up. */
wordmark: {
fontSizePt: 23,
color: "2C3D8F",
transform: "uppercase",
letterSpacingEm: 0.14,
},
/** The invoice number under it. */
reference: {
fontSizePt: 8.5,
color: "5A6482",
},
/**
* Every other charge row, tinted.
*
* Named rather than applied: the renderer counts rows as it draws them, so
* this survives publishing, where a row does not know it is odd.
*/
rowAlt: {
fill: "F7F8FD",
},
/**
* The covering note, held to a measure.
*
* Prose run across the whole text column is prose the eye loses its place
* tracking back from. The measure narrows the column from the right, so
* the table below it still stands exactly where it stood.
*/
summary: {
maxWidthMm: 158,
fontSizePt: 9,
lineHeight: 1.45,
},
/** The note under a description, and anything else set quietly. */
chargeNote: {
color: "5A6482",
fontSizePt: 8.5,
lineHeight: 1.6,
},
muted: {
color: "5A6482",
fontSizePt: 8.5,
// Tighter than the line it sits under: it is an aside, and prose leading
// under a description is what made a two-line charge row three deep.
lineHeight: 1.1,
},
/**
* A figure in a column of figures.
*
* Proportional digits are each their own width, so a column of them lines
* up on nothing. Consolas gives every digit the same width and the column
* reads as a column — which is the point, the face is only how it is got.
*/
money: {
font: "Consolas",
lineHeight: 1.2,
borderSides: [],
},
/**
* A stacked cell: an address, a name over a value.
*
* A charge row's leading is set for a description with a note under it;
* a five-line address on the same setting is airier than the design draws
* it, and the two are not the same kind of thing.
*/
addressCell: {
lineHeight: 1.45,
},
/** A band cell: a table row's leading. The band says the rest. */
bandCell: {
lineHeight: 1.2,
},
/**
* A row of a table, set as a row rather than as prose.
*
* Body leading is for paragraphs a reader travels through; a table is
* scanned down instead. The number is what puts a charge row at the 54px
* the design draws it at, now that a leading means the same thing in both
* engines.
*/
lineItem: {
lineHeight: 1.62,
// Stripes instead of rules: a table wearing both is wearing belt and
// braces. An empty side list says so — it is a decision, not an omission.
borderSides: [],
},
},
}; import type { InvoiceData } from "./types.ts";
export const previewData: InvoiceData = {
reference: "INV-2026-0142",
issueDate: "2026-08-21",
dueDate: "2026-09-04",
poReference: "PO-BHA-2214",
paid: false,
showCredit: true,
sender: {
name: "Fernhill Systems Ltd",
trade: "Software consultancy · Manchester",
addressLines: ["Unit 9, Carding Mill", "Manchester M4 5JW"],
email: "accounts@fernhill.systems",
registration: "Registered in England & Wales No. 09184472 · VAT GB 312 4477 08",
bank: {
accountName: "Fernhill Systems Ltd",
sortCode: "04-00-72",
accountNumber: "18732209",
iban: "GB29 FRNH 0400 7218 7322 09",
bic: "FRNHGB2L",
},
},
billedTo: {
name: "Brackenfield Housing Association",
attn: "Attn Maya Oyelaran, Finance",
addressLines: ["4 Millrace Court", "Leeds LS2 7QF"],
},
lines: [
{ desc: "Discovery and scoping workshop", meta: "Tenant portal programme", qty: 2, rate: 760 },
{ desc: "API development — Sprint 14", meta: "Repairs booking endpoints", qty: 8, rate: 760 },
{ desc: "Tenant portal front-end build", meta: "Account and statements views", qty: 6, rate: 760 },
{
desc: "Document automation",
meta: "Docxcelerate templates: arrears letters, statements",
qty: 4,
rate: 760,
},
{ desc: "CI and release automation", meta: "GitHub Actions, staged deploys", qty: 1.5, rate: 760 },
{ desc: "Accessibility audit and fixes", meta: "WCAG 2.2 AA across the portal", qty: 2, rate: 680 },
{ desc: "Production support retainer", meta: "August 2026", qty: 1, rate: 950 },
],
vatRate: 0.2,
deliveryLead: "Priya Raman",
}; export interface InvoiceLine {
/** What the work was. */
desc: string;
/** The note printed under it — which sprint, which system. */
meta: string;
/** Days, or whatever the rate is per. */
qty: number;
/** The rate that quantity is charged at. */
rate: number;
}
/** Where money is sent, printed on the payment page. */
export interface BankDetails {
accountName: string;
sortCode: string;
accountNumber: string;
iban: string;
bic: string;
}
/** The party sending the invoice. */
export interface Sender {
name: string;
/** The line under the name — what they do, and where. */
trade: string;
addressLines: string[];
email: string;
/** Company number and VAT number, printed in the footer. */
registration: string;
bank: BankDetails;
}
/** The party being billed. */
export interface BilledTo {
name: string;
/** Who the invoice is for the attention of. */
attn: string;
addressLines: string[];
}
export interface InvoiceData {
reference: string;
issueDate: string;
dueDate: string;
poReference: string;
/** Whether this has been settled. Decides the status the invoice carries. */
paid: boolean;
/** Whether the footer credits the tool that wrote this. */
showCredit: boolean;
sender: Sender;
billedTo: BilledTo;
lines: InvoiceLine[];
/** As a fraction — 0.2 for the UK's twenty percent. */
vatRate: number;
/** Who queries about the invoice go to. */
deliveryLead: string;
} import { Paragraph, useState } from "docxcelerate/template";
import type { OfferData } from "../types.ts";
/**
* Formatting is ordinary TypeScript — no template language, so an Oxford comma
* and a singular/plural switch cost one line each. It happens in the state
* initializer, which is where a component does its thinking.
*
* Reading the list here settles it during the build. That is right for a
* document produced from data you hold; a document published to an engine would
* reach for a deriver instead, so the wording follows a list nobody has yet.
*/
export const Conditions: Paragraph = () => {
const [state] = useState((data: OfferData) => ({
list: formatList(data.conditions),
verb: data.conditions.length === 1 ? "condition is" : "conditions are",
}));
return (
<Paragraph id="conditions-summary">
This offer is conditional. The {state.verb} {state.list}. We will confirm your place
automatically once your results reach us.
</Paragraph>
);
};
function formatList(items: string[]): string {
if (items.length <= 1) return items[0] ?? "";
return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
} import { Paragraph, useState } from "docxcelerate/template";
import type { OfferData } from "../types.ts";
/**
* An optional field changes the paragraph rather than leaving a gap — the kind
* of branch that is awkward in a mail-merge and is an `if` here. Each arm has
* its own id, so the resolved document says which one this applicant got.
*/
export const Fees: Paragraph = () => {
const [state] = useState((data: OfferData) => ({
feeStatus: data.feeStatus,
tuitionFee: data.tuitionFee,
scholarship: data.scholarship,
}));
const assessment =
`You have been assessed as a ${state.feeStatus} fee payer, so tuition for ` +
`your first year will be ${state.tuitionFee}.`;
if (!state.scholarship) {
return (
<Paragraph id="fees">
{assessment} Details of loans, bursaries and hardship funding are in the enclosed
funding guide.
</Paragraph>
);
}
return (
<Paragraph id="fees-with-scholarship">
{assessment} We are also pleased to award you the {state.scholarship.name}, worth{" "}
{state.scholarship.amount} for the duration of your course. No separate application
is needed.
</Paragraph>
);
}; import { Paragraph, useState } from "docxcelerate/template";
import type { OfferData } from "../types.ts";
export const Greeting: Paragraph = () => {
const [state] = useState((data: OfferData) => ({ name: data.applicantName }));
return <Paragraph id="greeting">Dear {state.name},</Paragraph>;
}; export { Conditions } from "./conditions.node.tsx";
export { Fees } from "./fees.node.tsx";
export { Greeting } from "./greeting.node.tsx";
export { NextSteps } from "./next-steps.node.tsx";
export { Offer } from "./offer.node.tsx";
export { SignOff } from "./signoff.node.tsx";
export { TutorNote } from "./tutor-note.node.tsx"; import { Paragraph, useState } from "docxcelerate/template";
import type { OfferData } from "../types.ts";
export const NextSteps: Paragraph = () => {
const [state] = useState((data: OfferData) => ({
replyBy: data.replyBy,
college: data.college,
}));
return (
<Paragraph id="how-to-accept">
To accept, reply through the applicant portal by {state.replyBy}. If you would like
to visit {state.college} before deciding, our open afternoons run every Thursday
through April and you are very welcome.
</Paragraph>
);
}; import { Paragraph, useState } from "docxcelerate/template";
import type { OfferData } from "../types.ts";
export const Offer: Paragraph = () => {
const [state] = useState((data: OfferData) => ({
programme: data.programme,
college: data.college,
startDate: data.startDate,
offerRef: data.offerRef,
}));
return (
<Paragraph id="offer">
I am delighted to offer you a place on the {state.programme} at {state.college},
beginning {state.startDate}. Your application reference is {state.offerRef}; please
quote it in any correspondence with us.
</Paragraph>
);
}; import { Paragraph, useState } from "docxcelerate/template";
import type { OfferData } from "../types.ts";
export const SignOff: Paragraph = () => {
const [state] = useState((data: OfferData) => ({
name: data.signatory.name,
title: data.signatory.title,
college: data.college,
}));
return (
<Paragraph id="sign-off">
Yours sincerely, {state.name}, {state.title}, {state.college}.
</Paragraph>
);
}; import { Paragraph, useSetPlaceholders, useSetPrompts, useState } from "docxcelerate/template";
import type { OfferData } from "../types.ts";
/**
* The one paragraph worth generating. Everything else in this document is
* deterministic, so this is the only node that needs an engine at all.
*/
export const TutorNote: Paragraph = () => {
const [state] = useState((data: OfferData) => ({
applicantName: data.applicantName,
interviewer: data.interviewer,
portfolioTheme: data.portfolioTheme,
}));
useSetPrompts({
systemPrompt:
"You are an admissions tutor. Be warm but never effusive, and never promise outcomes.",
generalPrompt: `Write two warm, specific sentences from ${state.interviewer} to ` +
`${state.applicantName}, referring to their interest in ${state.portfolioTheme}. ` +
`Mention one thing they should read before term starts.`,
negativePrompt: "Do not restate the offer, the conditions, or the reply deadline.",
});
useSetPlaceholders(
`A short personal note from ${state.interviewer} about ${state.applicantName}'s interview.`,
);
return <Paragraph id="tutor-note" />;
}; import { cleanMinimalDocumentStyle } from "docxcelerate";
import { defineDocumentProject } from "docxcelerate/document";
import { documentTemplate } from "./document.tsx";
import { previewData } from "./preview-data.ts";
import type { OfferData } from "./types.ts";
export default defineDocumentProject<OfferData>({
id: "offer-of-admission",
name: "Offer of Admission",
version: "1.0.0",
template: documentTemplate,
style: cleanMinimalDocumentStyle,
previewData,
}); import { Document, Section, template } from "docxcelerate/template";
import {
Conditions,
Fees,
Greeting,
NextSteps,
Offer,
SignOff,
TutorNote,
} from "./nodes/index.ts";
import type { OfferData } from "./types.ts";
export const documentTemplate = template<OfferData>(
<Document id="offer-of-admission" title="Offer of Admission">
<Section id="your-offer" title="Your offer">
<Greeting />
<Offer />
<TutorNote />
</Section>
<Section id="conditions" title="Conditions">
<Conditions />
</Section>
<Section id="fees-and-funding" title="Fees and funding">
<Fees />
</Section>
<Section id="next-steps" title="Next steps">
<NextSteps />
<SignOff />
</Section>
</Document>,
); import type { OfferData } from "./types.ts";
export const previewData: OfferData = {
applicantName: "Maya Oyelaran",
programme: "MEng Structural Engineering",
college: "Ashcroft College",
startDate: "28 September 2026",
offerRef: "ADM-2026-4417",
conditions: [
"grades A*AA at A-level, including Mathematics and Physics",
"IELTS 7.0 overall, with no component below 6.5",
"a satisfactory enhanced DBS check",
],
replyBy: "15 May 2026",
portfolioTheme: "timber gridshell roofs and low-carbon structural systems",
interviewer: "Dr Priya Raman",
tuitionFee: "£9,535",
feeStatus: "Home",
scholarship: { name: "Ashcroft Engineering Scholarship", amount: "£3,000 per year" },
signatory: { name: "Dr Priya Raman", title: "Director of Undergraduate Admissions" },
}; export interface OfferData {
applicantName: string;
programme: string;
college: string;
startDate: string;
offerRef: string;
conditions: string[];
replyBy: string;
/** Context for the tutor's note — never printed verbatim. */
portfolioTheme: string;
interviewer: string;
tuitionFee: string;
feeStatus: "Home" | "International";
scholarship?: { name: string; amount: string };
signatory: { name: string; title: string };
} import { Paragraph, useState } from "docxcelerate/template";
import type { RepairsData } from "../types.ts";
/**
* An absent optional field should change the sentence, not print "undefined"
* or leave a blank line. Two arms, two ids — so the resolved document records
* which of the two this resident was sent.
*/
export const Access: Paragraph = () => {
const [state] = useState((data: RepairsData) => ({ notes: data.accessNotes }));
if (state.notes) {
return (
<Paragraph id="access-noted">
We hold the following access note for your home: {state.notes} Please let us know
if this is out of date.
</Paragraph>
);
}
return (
<Paragraph id="access-default">
Someone aged 18 or over needs to be home for the visit. If that is not possible,
call us and we will arrange access another way.
</Paragraph>
);
}; import { Paragraph, useState } from "docxcelerate/template";
import type { RepairsData } from "../types.ts";
export const Appointment: Paragraph = () => {
const [state] = useState((data: RepairsData) => ({
trade: data.trade,
address: data.address,
visitDate: data.visitDate,
visitWindow: data.visitWindow,
jobRef: data.jobRef,
}));
return (
<Paragraph id="appointment-details">
We have booked a {state.trade} to visit {state.address} on {state.visitDate},{" "}
{state.visitWindow}. Your job reference is {state.jobRef}.
</Paragraph>
);
}; import { Paragraph, useState } from "docxcelerate/template";
import type { RepairsData } from "../types.ts";
export const Greeting: Paragraph = () => {
const [state] = useState((data: RepairsData) => ({ name: data.residentName }));
return <Paragraph id="greeting">Dear {state.name},</Paragraph>;
}; export { Access } from "./access.node.tsx";
export { Appointment } from "./appointment.node.tsx";
export { Greeting } from "./greeting.node.tsx";
export { WhatToExpect } from "./what-to-expect.node.tsx"; import { Paragraph, useSetPlaceholders, useSetPrompts, useState } from "docxcelerate/template";
import type { RepairsData } from "../types.ts";
export const WhatToExpect: Paragraph = () => {
const [state] = useState((data: RepairsData) => ({ trade: data.trade }));
useSetPrompts({
systemPrompt:
"You write for social housing residents. Use plain English and short sentences.",
generalPrompt: `In three short sentences, explain what a ${state.trade} will do ` +
`during a routine repair visit, how long it usually takes, and what the ` +
`resident should move out of the way beforehand.`,
negativePrompt: "Do not repeat the appointment date or the job reference.",
});
useSetPlaceholders(`What to expect during a ${state.trade}'s visit.`);
return <Paragraph id="what-to-expect" />;
}; import { cleanMinimalDocumentStyle } from "docxcelerate";
import { defineDocumentProject } from "docxcelerate/document";
import { documentTemplate } from "./document.tsx";
import { previewData } from "./preview-data.ts";
import type { RepairsData } from "./types.ts";
export default defineDocumentProject<RepairsData>({
id: "repairs-appointment",
name: "Repair Appointment",
version: "2.1.0",
template: documentTemplate,
style: cleanMinimalDocumentStyle,
previewData,
}); import { Document, Section, template } from "docxcelerate/template";
import { Access, Appointment, Greeting, WhatToExpect } from "./nodes/index.ts";
import type { RepairsData } from "./types.ts";
export const documentTemplate = template<RepairsData>(
<Document id="repairs-appointment" title="Your repair appointment">
<Section id="appointment" title="Your appointment">
<Greeting />
<Appointment />
</Section>
<Section id="on-the-day" title="On the day">
<WhatToExpect />
<Access />
</Section>
</Document>,
); import type { RepairsData } from "./types.ts";
export const previewData: RepairsData = {
residentName: "Tomasz Wójcik",
address: "12 Bracken Road, Leeds LS6 3QT",
jobRef: "RPR-88213",
trade: "plumber",
visitDate: "Tuesday 25 August",
visitWindow: "between 8am and 12pm",
accessNotes: "Key safe at the side gate; the code is with the scheme manager.",
}; export interface RepairsData {
residentName: string;
address: string;
jobRef: string;
trade: "plumber" | "electrician" | "joiner";
visitDate: string;
visitWindow: string;
accessNotes?: string;
} import { Paragraph, useState } from "docxcelerate/template";
import type { PolicyData } from "../types.ts";
export const Greeting: Paragraph = () => {
const [state] = useState((data: PolicyData) => ({ name: data.holderName }));
return <Paragraph id="greeting">Dear {state.name},</Paragraph>;
}; export { Greeting } from "./greeting.node.tsx";
export { PremiumChange } from "./premium-change.node.tsx";
export { Renewal } from "./renewal.node.tsx";
export { Shopping } from "./shopping.node.tsx"; import { Paragraph, useFormat, useState } from "docxcelerate/template";
import type { PolicyData } from "../types.ts";
/**
* A price rise is the sentence customers actually read. Working out the
* direction and the percentage in the state initializer means the wording can
* never contradict the figures printed beside it, because both come from the
* same computation.
*/
export const PremiumChange: Paragraph = () => {
const { currency } = useFormat("en-GB");
const [state] = useState((data: PolicyData) => {
const delta = data.newPremium - data.lastPremium;
return {
delta,
lastPremium: data.lastPremium,
newPremium: data.newPremium,
percent: Math.abs((delta / data.lastPremium) * 100).toFixed(1),
};
});
if (Math.abs(state.delta) < 0.01) {
return (
<Paragraph id="premium-held">
Your premium is unchanged at {currency(state.newPremium)} a year.
</Paragraph>
);
}
return (
<Paragraph id="premium-change">
Your premium has {state.delta > 0 ? "risen" : "fallen"} by {state.percent}%, from{" "}
{currency(state.lastPremium)} to {currency(state.newPremium)} a year. That works out
at {currency(state.newPremium / 12)} a month.
</Paragraph>
);
}; import { Paragraph, useFormat, useState } from "docxcelerate/template";
import type { PolicyData } from "../types.ts";
export const Renewal: Paragraph = () => {
const { currency } = useFormat("en-GB");
const [state] = useState((data: PolicyData) => ({
cover: data.cover.toLowerCase(),
policyNumber: data.policyNumber,
renewalDate: data.renewalDate,
excess: data.excess,
}));
return (
<Paragraph id="renewal-terms">
Your {state.cover} policy {state.policyNumber} renews on {state.renewalDate}. Unless
you tell us otherwise, cover continues automatically with an excess of{" "}
{currency(state.excess)}.
</Paragraph>
);
}; import { Paragraph, useSetPlaceholders, useSetPrompts, useState } from "docxcelerate/template";
import type { PolicyData } from "../types.ts";
export const Shopping: Paragraph = () => {
const [state] = useState((data: PolicyData) => ({ name: data.holderName }));
useSetPrompts({
systemPrompt:
"You write regulated insurance correspondence. Be neutral and never discourage switching.",
generalPrompt: `Write two sentences reminding ${state.name} that they can compare ` +
`this renewal against other quotes, and that doing so will not affect ` +
`their existing cover.`,
negativePrompt: "Do not quote a price, a percentage, or a competitor name.",
});
useSetPlaceholders("A short note on comparing this renewal with other quotes.");
return <Paragraph id="shopping-around" />;
}; import { cleanMinimalDocumentStyle } from "docxcelerate";
import { defineDocumentProject } from "docxcelerate/document";
import { documentTemplate } from "./document.tsx";
import { previewData } from "./preview-data.ts";
import type { PolicyData } from "./types.ts";
export default defineDocumentProject<PolicyData>({
id: "policy-renewal",
name: "Policy Renewal",
version: "0.4.2",
template: documentTemplate,
style: cleanMinimalDocumentStyle,
previewData,
}); import { Document, Section, template } from "docxcelerate/template";
import { Greeting, PremiumChange, Renewal, Shopping } from "./nodes/index.ts";
import type { PolicyData } from "./types.ts";
export const documentTemplate = template<PolicyData>(
<Document id="policy-renewal" title="Your renewal">
<Section id="renewal" title="Your renewal">
<Greeting />
<Renewal />
<PremiumChange />
</Section>
<Section id="your-options" title="Your options">
<Shopping />
</Section>
</Document>,
); import type { PolicyData } from "./types.ts";
export const previewData: PolicyData = {
holderName: "Eleanor Whitfield",
policyNumber: "HC-2291-8840",
cover: "Contents and buildings",
renewalDate: "1 October 2026",
lastPremium: 284.4,
newPremium: 311.16,
excess: 250,
}; export interface PolicyData {
holderName: string;
policyNumber: string;
cover: "Contents" | "Buildings" | "Contents and buildings";
renewalDate: string;
lastPremium: number;
newPremium: number;
excess: number;
} Real document preview being rendered, no screenshots and no hidden tricks.
A document is a tree of small components, rendered by something that understands paper. You get the ergonomics of a component model. The person on the other end gets a Word file.
Author, Docxcelerate
Write documents like websites
A document is a tree of typed components. If you have written React you already know the shape of this: props, composition, small files. A frontend engineer is productive on the first afternoon rather than learning a template language first.
AI at the component level
AI goes in through hooks, inside the components you already write. A component hands the model its context and what you want written, so it produces that one part of the document while everything around it stays deterministic. You decide how much is generated, a component at a time.
Documents live in your repo
Because a document is source, changing a sentence is a pull request that gets diffed, reviewed, and stays attributable a year later when someone asks who altered the arrears wording. Tests assert a document renders what you expect, so CI catches the mistake before a recipient does.
Publish once. Scale it out.
The engine is where documents are actually written. It fills in your data, runs the AI, and returns the finished document. Any kind of node can use AI, not only paragraphs. The model's answer either becomes the text, written from the information you give it, or makes a decision the document depends on.
You publish a template to the engine once. After that, any system can call its API with a set of data and get a document back. A free engine is available to self-host. The managed cloud runs the complete one, with a lot the free version does not have, and is coming soon.
- 01
Build
The framework turns your document into a package. This step runs on your machine.
documents/offer-of-admission/build/manifest.jsonpreview.jsondocument.json - 02
Publish
You send the package to an engine. The engine stores it and gives it a name.
docxcelerate.config.json"upload": { "endpoint": "https://documents.example.com/api/letters"}→ document.json stored, and given an address - 03
Write
Your application sends a set of data. The engine returns the finished document.
POST /api/letters{ applicantName, conditions, interviewer }→ 200 offer-of-admission.docx
Get a head start on your next document.
The registry holds themes and document components. `dxcl add` copies the file into your project. There is no dependency and no version to track.
Built for the reports you already write by hand
A firm producing the same report hundreds of times a month already has the layout and most of the wording. What changes is the person it is written for. Rebuild that report as components, keep every run identical, and hand a model only the parts that depend on who is reading it.
One template, every recipient
Publish the template once, then call it per person. One report or a hundred thousand is the same call, repeated.
1.0.0
The same document every time
Anything you did not mark as generated renders identically on every run. Only the parts you chose can vary.
Called by the systems you run
Your CRM, case system or billing platform posts its data and gets a .docx back. Nobody exports a spreadsheet or opens Word.
Reproducible a year later
Templates are versioned, so any document can be rebuilt from the same template and the same data. An audit gets a build, not an archive.
- offer-of-admission@1.0.0
- offer-of-admission@1.1.0
- offer-of-admission@2.0.0
Open source, and built to stay that way
The framework, the renderers, the node model and the CLI are MIT licensed and developed in the open. Read the code that writes your documents, fork it, or vendor it into your own build.
The engine is free to self-host, so running documents at scale never depends on a vendor staying in business or a price list staying the same. Our paid cloud adds the premium features on top of that same free core, so hosting and enterprise scale are ready from the first document you write. It is the convenience, not the way in.
MIT
Copyright (c) 2026 Docxcelerate
Permission is hereby granted, free of charge, to any person obtaining a copy of this software, to use, copy, modify, merge, publish, distribute, sublicense and sell copies of it.
Fork it Vendor it Ship it
Read it →Everything is documented
Every node type, every CLI flag, and every file a build writes, with previews rendered by the real renderer, so nothing on the page can describe a helper that no longer exists.
Hand the whole thing to your agent
One Markdown file teaches a coding agent how documents are put together here: the component model, the rules that catch agents out, and every command. Drop it in and ask for a document instead of writing the first one yourself.
.claude/skills/docxcelerate/ ---
name: docxcelerate
description: Write and maintain Docxcelerate documents — DOCX letters composed from typed JSX components, with prose an engine generates per recipient. Use when a workspace has docxcelerate.config.json, documents/*/document.project.ts or *.node.tsx files, when code imports from docxcelerate, docxcelerate/template or docxcelerate/document, or when asked to create a document, add or edit a node, write prompts for generated prose, style the packed .docx, or publish a document to the engine.
---
# Docxcelerate
A document is **a JSX tree plus a data type**. Components return nodes; building
the tree against data produces a `DocumentModel` — plain JSON, no styling and no
layout. Renderers turn that JSON into a `.docx` or a preview page.
If you know a frontend framework, the shape maps over: `document.tsx` is the
entrypoint, `nodes/*.node.tsx` are components, `useState` is where data enters,
and everything else is ordinary TypeScript.
The word "template" here means a document tree, not a string-substitution
language. There is no template language — an `if` is an `if`, `.map()` is
`.map()`, and formatting is a function call.
## Read this before writing code
Four rules cause nearly every mistake an agent makes in this framework.
1. **`useState` is the only door data comes through.** Its initializer receives
the document data. Nothing else reaches for it.
2. **Every hook runs before the first `await` and before any branch or return.**
Same rule as React, same reason. There is no `useMemo` — compute in the
`useState` initializer, which runs once by construction.
3. **Static or dynamic is inferred, never declared.** A node given text (or an
`Image` a `src`, or a `Graph` its `data`) resolves locally. A node given
prompts is filled in by the engine. Supplying both on one element is an error.
4. **Never add a `@jsxImportSource` pragma comment.** The workspace
`tsconfig.json` already sets `jsxImportSource: "docxcelerate/template"` for
every file. A pragma is only for a foreign project that points
`jsxImportSource` somewhere else.
## Where things live
```text
my-documents/ # dxcl init writes this — an ordinary Vite project
docxcelerate.config.json # build + upload presets, workspace-wide
documents/
tenancy-renewal/
document.project.ts # the entrypoint; ties everything below together
document.tsx # structure only — which nodes, which sections, what order
types.ts # the data contract
preview-data.ts # one realistic instance of that contract
document-style.ts # fonts, spacing, margins for the packed .docx
nodes/
greeting.node.tsx # one node per file
index.ts # re-exports every node
derivers/index.ts # named functions the engine runs per document
```
The split is the point — each file answers one question. Keep prose out of
`document.tsx`; a template that inlines its text stops being readable about
halfway down.
Imports: **`docxcelerate/template`** for authoring (elements, hooks, `template`),
**`docxcelerate/document`** for `defineDocumentProject` and style types,
**`docxcelerate`** for `buildDocument` and the domain types.
## A node
```tsx
import { Paragraph, useFormat, useState } from "docxcelerate/template";
import type { TenancyData } from "../types.ts";
export const Balance: Paragraph = () => {
const { currency } = useFormat();
const [state] = useState((data: TenancyData) => ({
name: data.recipientName,
due: data.balanceDue,
}));
if (state.due === 0) {
return <Paragraph id="balance-settled">Nothing outstanding, {state.name}.</Paragraph>;
}
return <Paragraph id="balance-arrears">You owe {currency(state.due)}.</Paragraph>;
};
```
`Paragraph` is both the element and the component type, so
`const Balance: Paragraph` declares what this yields — returning a `<Section>`
from it is a compile error. Give each branch arm **its own id**: that is what
lets a resolved document record which one this recipient got.
The `if` publishes: the build compiles it into a condition, so both arms travel
to the engine and it decides per recipient. The `currency()` call does not —
computing on request data needs a deriver. See
[references/publishing.md](references/publishing.md).
## A node whose prose is generated
Set prompts instead of text, and a placeholder so previews stay readable.
```tsx
import { Paragraph, useSetPlaceholders, useSetPrompts, useState } from "docxcelerate/template";
import type { OfferData } from "../types.ts";
export const TutorNote: Paragraph = () => {
const [state] = useState((data: OfferData) => ({
applicant: data.applicantName,
interviewer: data.interviewer,
}));
useSetPrompts({
systemPrompt: "You are an admissions tutor. Warm, never effusive. Promise nothing.",
generalPrompt: `Write two specific sentences from ${state.interviewer} about ` +
`${state.applicant}'s interview.`,
negativePrompt: "Do not restate the offer, the conditions, or the reply deadline.",
});
useSetPlaceholders(`A short note from ${state.interviewer}.`);
return <Paragraph id="tutor-note" />;
};
```
Only `generalPrompt` is required. `infoPrompt` is context the model should have
but not restate; `negativePrompt` is what to avoid; `systemPrompt` is role and
tone; `examplePrompt` is a finished answer to match rather than a description of
one, which is the cheapest way to pin down an opening, an order and a length. All
six slots (those five plus `placeholder`) can also be given as props, which reads
better when they are short — and props win over the hook, so a caller can
override what a shared hook set around it.
Previews resolve dynamic nodes to their placeholder, never to generated prose.
Nothing leaves the machine, and the same build gives the same page every time.
## The elements
| Element | Holds | Notes |
| --- | --- | --- |
| `Document` | sections and nodes | `id` and `title` both required; one per template; `header`/`footer` take running furniture |
| `Section` | any nodes, including sections | the **only** container; `title` required and becomes a heading |
| `Paragraph` | text children, or prompts | `text` prop says the same as children |
| `Image` | `src`, `alt`, `width`, `height`, or prompts | `src` becomes `path`; only a `data:` URI travels — see below |
| `Graph` | `graphType`, `data`, `title`, `caption`, or prompts | packed as a real Word chart, never a picture of one — the numbers travel with it |
| `Table` | `Row`s, and any `.map()` producing them | `columns` declared once, in mm or `"auto"`, with `align` |
| `Row` | `Cell`s | `header` marks a heading row; only *leading* ones repeat across pages |
| `Cell` | text, or paragraphs when a line is not enough | `span`, and `align` when it departs from its column |
| `TableOfContents` | nothing | a marker; renderers print the title and stop |
| `PageBreak` | nothing | for a break that is part of what the document *is* |
| `PageNumber` | nothing | `format` (`current`/`total`/`currentOfTotal`) and `separator`; counted by the renderer |
**An `<Image>` only travels if it carries its bytes.** A `data:` URI does; a
path or a URL draws on screen, where a browser can fetch it, but packs into Word
as a note rather than a picture — the packer never reaches for a file, because
the engine writing the document is not on the machine the file was on. Word will
not embed an SVG alone either, so give one a `fallbackSrc` raster: the screen
draws the SVG and the Word file gets the raster.
Every element also takes **`variant`** — a name the theme looks up, never an
appearance: `<Cell variant="badge">`, `<Paragraph variant="band">`. The colours
live in the style's `blocks`, so a document restyles without a node changing,
and a name the theme has not heard of draws as an ordinary block rather than
failing. Never write a colour into a component.
A block style says `fill`, `color`, `border` (with `borderWidthPt`,
`borderSides`), `paddingPt`, `fontSizePt`, `weight`, `transform`,
`letterSpacingEm` and `bleed`. **All of them mean the same thing on screen and
in the `.docx`** — a fill is shading, a border is a real border, a bleed is a
negative indent past the margin. If a property cannot be expressed in Word it
does not exist here, because a style that quietly did nothing in the format the
framework produces is worse than one that was never offered.
Ids are addresses: an engine targets a node by id and two build artifacts line
up in a diff by id, so **treat a rename as a breaking change**. **Do not write
ids by default.** A node without one is named after its heading, or after the
component that yielded it — `<Greeting />` becomes `greeting`, a section titled
"Fees and funding" becomes `fees-and-funding` — and repeats are numbered
(`greeting-2`). Those names come from what a node is rather than where it sits,
so they survive insertion and reordering. Write one only to pin an address a
request asks for by name. This also keeps
`.map()` and branches from demanding names you do not have. Reusing an id is an
error reported with both positions.
`{condition && <Node />}` reads the way it does everywhere else, and publishes
the way an `if` does — the build compiles it into a condition rather than
deciding once. Falsy children are skipped, so a `&&` yielding anything other
than a node still just drops out. There is no `key` prop — an element accepts
only its own props, so `key={…}` is a type error. Text lives only inside a
`<Paragraph>`, and a paragraph holds text rather than elements.
## Commands
```sh
npx docxcelerate init my-documents # scaffold a workspace and npm install it
npm run dev # preview on 127.0.0.1:4507
dxcl document new tenancy-renewal --title "Tenancy Renewal"
dxcl document node documents/tenancy-renewal next-steps --type paragraph
```
`npx docxcelerate`, not `npx dxcl` — npx resolves the package name and the
binary inside is `dxcl`. Any command run with no arguments asks for what it
needs instead of failing.
`dxcl document node` writes `nodes/<name>.node.tsx` and updates
`nodes/index.ts`. It deliberately does **not** place the node in the template —
after generating one, add it to `document.tsx` where it belongs.
Every flag is in [references/cli.md](references/cli.md).
## Before writing a node from scratch
The package ships a small registry of themes and prebuilt nodes. `dxcl list`
prints it, `dxcl show <id>` prints one entry in full, and `dxcl add <id>`
installs it.
```sh
dxcl list # 5 themes, 6 components
dxcl show payment-summary # what it does and what it reads
dxcl add slate-report letterhead # a theme and a node, into this project
```
A component is **copied in as source** — `nodes/<name>.node.tsx`, re-exported
from `nodes/index.ts`. It has no version and is never upgraded behind you, so
editing it afterwards is the expected next step rather than a fork. Two things
it leaves to you, both printed as follow-up when it installs: the fields it
reads have to be added to `types.ts` and `preview-data.ts`, and the node itself
has to be placed in `document.tsx`.
A theme is written out as `document-style.ts`, which `document.project.ts`
already passes through — so the next preview is themed. It replaces a
`document-style.ts` nothing has touched; once you have edited that file,
replacing it needs `--force`.
Reach for the registry first when a request names something ordinary — a
letterhead, an address block, a signature, small print. Every entry is listed in
[references/cli.md](references/cli.md) and browsable at
[docxcelerate.com/components](https://docxcelerate.com/components/).
## Publishing changes the rules
Everything above assumes you hold the data. Publishing to the engine builds the
artifact **once**, against stand-ins for a request nobody has made yet, so a
decision that depends on request data has to travel to the engine instead:
- **Interpolating** a value publishes fine — it becomes a `{{data.x}}` token.
**Computing** on one does not; use a **deriver**, which the engine runs per
document. Never hand-write a `{{data.…}}` token; interpolation produces it.
- **A preview never waits.** It is rebuilt on every save, so generated nodes show
the placeholder `useAi` required and derivers that declared a `placeholder`
stand in rather than run. Cheap derivers still run, so the figures are real.
Give any deriver that renders, reads or fetches a `placeholder`; leave it off
for a total or a currency format.
- **`.map()` over request data is how a loop is written.** It walks the list when
the data is real and is published as a loop the engine walks when it is not.
Never hand-write a `{{ctx.…}}` token inside one — the entry writes its own
references. Anything needing the entries first (`.filter`, `.length`, `for…of`)
belongs in a deriver.
- **A decision is written as an ordinary conditional.** An `if` that returns, a
ternary, and `cond && <Node />` are all compiled into the condition the engine
evaluates per document, so both arms travel with the test that selects them.
A conditional picking a *value* rather than a node is a deriver's job. This
needs the transform in the build — `docxcelerateTransform()` for Vite,
`docxcelerateEsbuildTransform()` for esbuild, from `docxcelerate/transform`.
Without it the decision is made once, at build time, for every recipient.
Read [references/publishing.md](references/publishing.md) before touching a
document that ships to an engine, or when a document is right in preview and
wrong in production.
## Before you call it done
- Each branch arm has its own id; no id is used twice; no `key` props.
- Hooks all called before any `await`, branch or `return`.
- No node carries both text and prompts.
- Every dynamic node has a placeholder, and the document still reads with
placeholders in place. If it does not, the structure is doing too little work.
- `preview-data.ts` uses the longest name and largest figure you actually
expect — short names and placeholder cities hide layout problems.
- New nodes are exported from `nodes/index.ts` **and** placed in `document.tsx`.
- `npm run documents:check` type-checks every document.
## Going deeper
- [references/api.md](references/api.md) — every entrypoint, element prop, hook and build function
- [references/patterns.md](references/patterns.md) — copyable recipes: repeats, graphs, house style, shared state, computed sections
- [references/publishing.md](references/publishing.md) — derivers, build artifacts, preview vs engine vs final
- [references/cli.md](references/cli.md) — every `dxcl` command and flag
- [docxcelerate.com/docs](https://docxcelerate.com/docs/start-here/) — the full documentation Copy the folder into .claude/skills/ for one project, or ~/.claude/skills/ for all of them. It loads itself when a document project turns up. Save it as a rule file and Cursor reads it in that project. Or @-mention the file in chat when you want it. Paste it into .github/copilot-instructions.md and Copilot applies it across the repository. Codex, Gemini CLI, Aider and Cline all read AGENTS.md at the root of the repo. Paste it in, or link to it if your agent opens files.
It is plain Markdown, and four reference files sit beside it in skills/docxcelerate/.