Skip to content
Docxcelerate

Nodes

The node model

What every node has in common, what makes them differ, and where each one is documented.

A document is a tree of nodes. Every node comes down to three things: an id, a kind, and a rule for producing its content.

<Paragraph          // the kind
  id="greeting"     // the address
>
  Dear {state.name},  {/* the rule */}
</Paragraph>

Resolved against your data, it becomes plain JSON:

{ "id": "greeting", "kind": "paragraph", "mode": "static", "text": "Dear Adaeze Nkemelu," }

No styling, no layout. Those get applied later, by whichever renderer you hand the document to.

The catalog

Each type has a page of its own, with every option it takes and a preview of each way it can be written.

Structure

Section Groups nodes under a titled heading.

The only construct that nests today. Its title carries into the document outline, so the structure you write is the structure the reader sees.

export const Opening: Section = () => (
  <Section id="opening" title="Your renewal">
    <Greeting />
    <PriceChange />
  </Section>
);

Section reference, with previews →

TableOfContents A marker for a contents list, ahead of the renderers that build one.

The kind is part of the document schema and both renderers accept it. What is missing is downstream rather than in the authoring: the element places the marker and carries its title, and building the entries from the surrounding section titles is a renderer's job.

export const Contents: TableOfContents = () => (
  <TableOfContents id="contents" title="What is in this letter" />
);

Table of contents reference, with previews →

Text

Paragraph A block of prose, written from your data or generated from prompts.

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

export const Greeting: Paragraph = () => {
  const [state] = useState((data: SampleData) => ({ name: data.memberName }));

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

Paragraph reference, with previews →

Media

Image A picture resolved from your data or described by a prompt.

A static image points at something you hold — a signature, a logo, a site photograph — with every field able to vary per recipient. A dynamic image describes what is wanted and leaves the endpoint to make it.

export const Signature: Image = () => {
  const [state] = useState((data: SampleData) => ({
    src: data.signatureUrl,
    manager: data.managerName,
  }));

  return (
    <Image
      id="signature"
      src={state.src}
      alt={`Signed by ${state.manager}`}
      width={180}
      height={60}
    />
  );
};

Image reference, with previews →

Shape A drawn rectangle with the document's own words on top of it.

Word draws a real rectangle and the paragraphs sit on its fill rather than beside it. What separates it from a paragraph with a background is the size: a shape is a box you decided the dimensions of and does not grow with its text, which is exactly what a banner needs — one that changed depth with its wording would be a different notice on every letter. When the box *should* grow with what is in it, a `Table` with one cell is the thing to reach for.

export const RenewalBanner: ShapeComponent = () => {
  const { date } = useFormat("en-GB");
  const [state] = useState((data: SampleData) => ({
    plan: data.plan,
    renewsOn: data.renewsOn,
  }));

  return (
    <Shape id="renewal-banner" variant="bannerAttention" height={44}>
      <Paragraph id="renewal-banner-line">
        {state.plan} renews on {date(state.renewsOn)}
      </Paragraph>
    </Shape>
  );
};

Shape reference, with previews →

clipArt Named visual blocks — rules, marks, callouts — drawn by the renderer. Planned

Not built yet. Nothing is fetched: the node names a shape and the renderer draws it, so it stays sharp in the DOCX and ships no asset.

Data

Graph A real Word chart, declared as data.

Charts are declared, never drawn: `graphType` fixes the form, `data` carries the payload. What lands in the `.docx` is a DrawingML chart part with every value cached in it — the chart Word builds from Insert → Chart — so a reader can select it, restyle it and open its numbers. Nothing is rasterised, which is why charts cost the package no drawing library and no native module.

export const VisitsByMonth: Graph = () => {
  const [state] = useState((data: SampleData) => ({
    centreName: data.centreName,
    months: data.visitsByMonth.map((entry) => entry.month),
    visits: data.visitsByMonth.map((entry) => entry.visits),
  }));

  return (
    <Graph
      id="visits-by-month"
      title={`Visits to ${state.centreName}`}
      graphType="bar"
      valueAxisTitle="Visits"
      data={{
        categories: state.months,
        series: [{ label: "Visits", values: state.visits }],
      }}
      caption={`Your visits to ${state.centreName}, last six months`}
    />
  );
};

Graph reference, with previews →

Table A grid of cells, with the columns declared once.

The columns belong to the table, because every row shares them — a table whose columns do not line up is not a table. Everything else is an ordinary node: a `.map()` produces rows, a condition drops one, and each names itself. That is what lets a published invoice carry one loop the engine walks rather than a table full of special cases.

Helpers: Table, Row, Cell

export const VisitLog: Table = () => {
  const { number } = useFormat("en-GB");
  const [state] = useState((data: SampleData) => ({ months: data.visitsByMonth }));

  return (
    <Table id="visit-log" columns={[{ width: "auto" }, { width: 28, align: "right" }]}>
      <Row header>
        <Cell>Month</Cell>
        <Cell>Visits</Cell>
      </Row>
      {state.months.map((entry) => (
        <Row>
          <Cell>{entry.month}</Cell>
          <Cell>{number(entry.visits)}</Cell>
        </Row>
      ))}
    </Table>
  );
};

Table reference, with previews →

Ids are addresses

A node’s id is how a generation endpoint targets that particular paragraph, and how two build artifacts line up when you diff them. Rename one and you break whatever points at it from outside — much like renaming an API route.

Keep them unique across the document. It costs you nothing and makes logs readable.

Where content comes from

Every node either holds its content or carries prompts for producing it. You don’t declare which — the component decides by what it supplies.

Give a paragraph text (or an Image a src, or a Graph its data) and it resolves on your machine. Give it prompts and a placeholder instead, and it gets filled in at request time.

Both land as the same kind, and differ by mode in the built document. mode is output rather than input: the build works it out from what you supplied, then uses it to tell an engine which nodes it still has to resolve.

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

Writing nodes covers the prompt slots and what previews show in their place.

Nesting

Section groups other nodes under a heading, and it accepts any kind — including other sections.

Table holds nodes too: rows, and cells with nodes inside them. Both work on the same principle. A container holds the components you already write, rather than introducing a second content model beside them.

About these previews

Every preview on these pages is a real build.

src/nodes/ in this site’s repository holds one file per variant, written against the published package. A build step resolves each one through buildDocument and renders it with the same renderer dxcl dev serves. The source you see is the file that ran, and the JSON is what came back.

So when a node type changes, these pages break loudly rather than going quietly out of date.


Edit this page on GitHub ↗ Read this page as Markdown