@kavishkagaya/json-render-docx
v0.1.1
Published
DOCX renderer for @json-render/core. JSON specs become Word (.docx) documents.
Maintainers
Readme
@kavishkagaya/json-render-docx
A DOCX renderer for json-render — turn a json-render spec into a Word (.docx) document, the same way @json-render/react-pdf turns one into a PDF.
json-render lets you generate UIs and documents from a single, catalog-constrained JSON spec. There are official renderers for React, React Native, PDF, HTML email, images, video, and terminals — but none for DOCX. This package fills that gap: it reuses the shared @json-render/core spec contract and engine semantics (visibility, $bindState/$item bindings, repeat) and maps the tree onto native docx primitives.
Not affiliated with Vercel. The
@json-render/*scope is owned by Vercel Labs; this is a community renderer built on the public@json-render/core.
Install
npm install @kavishkagaya/json-render-docx @json-render/core docx zodQuick start
import { renderToBuffer } from '@kavishkagaya/json-render-docx';
import type { Spec } from '@json-render/core';
const spec: Spec = {
root: 'doc',
elements: {
doc: { type: 'Document', props: { title: 'Service Agreement' }, children: ['sec'] },
sec: { type: 'Section', props: { size: 'A4' }, children: ['h', 'p', 'tbl'] },
h: { type: 'Heading', props: { text: 'Service Agreement', level: 1 }, children: [] },
p: { type: 'Paragraph', props: { text: 'This agreement is made between the parties.' }, children: [] },
tbl: {
type: 'Table',
props: {
columns: [{ header: 'Item', width: 60 }, { header: 'Price', width: 40, align: 'right' }],
rows: [['Consulting', '1000'], ['Support', '500']],
},
children: [],
},
},
};
const buffer = await renderToBuffer(spec);
// write it, stream it, or hand it to a downloadRender API
import { renderToBuffer, renderToBlob, renderToStream, renderToFile } from '@kavishkagaya/json-render-docx';
await renderToBuffer(spec, options); // Node Buffer
await renderToBlob(spec, options); // browser Blob (download path)
renderToStream(spec, options); // pipe to an HTTP response
await renderToFile(spec, './out.docx', options); // straight to disk (Node)options: { registry?, includeStandard?, state?, context? }.
state— initial state model for$bindState, visibility, andrepeat.registry— custom components, merged over the standard ones.context— a renderer-wide value handed to every component (e.g. a theme). DOCX is walked bottom-up with no React context, so this is how you thread shared config.
Standard components
The same set @json-render/react-pdf ships (Document, Row, Column, Heading, Text, Table, List, Image, Link, Divider, Spacer, PageNumber), matched here so a document-shaped spec is portable between PDF and DOCX. Page — react-pdf's name for its page unit — is a registered alias for Section, DOCX's real page unit; use either name.
| Component | Maps to (docx) |
|-----------|------------------|
| Document (root) | Document — metadata + list numbering |
| Section (alias: Page) | a section: page size, orientation, margins |
| Heading | Paragraph + HeadingLevel |
| Text | TextRun (inline; block-wrapped if standalone) |
| Paragraph | Paragraph (nest Text/Link as runs) — DOCX-only, see below |
| Link | ExternalHyperlink |
| Table | Table / TableRow / TableCell |
| Row | borderless single-row table (flexbox emulation); gap becomes cell margins on the shared inner edge |
| Column | vertical flow (the default); also the element to attach repeat/visible to when they apply to several siblings at once |
| List | bulleted or numbered paragraphs |
| Image | ImageRun (data URI/base64) |
| Divider | bottom-bordered paragraph |
| Spacer | spacing-only paragraph |
| PageBreak | PageBreak |
| PageNumber | PAGE / NUMPAGES fields |
Styling is configurable the same way react-pdf does it: per-instance props on each component (color, thickness, spacingBefore/spacingAfter, headerBackground, striped, ...) — not a shared theme/context mechanism. No renderer in the json-render ecosystem has one; matching that keeps specs portable across renderers rather than inventing a docx-only convention.
Every component is the same kind of function — standardComponents.Paragraph(props, children) works whether you're the engine or a custom component composing it.
Prefer Text over Paragraph for body copy if the spec also needs to render as PDF. Verified directly: rendered the same spec through both this package and @json-render/react-pdf — react-pdf has no Paragraph component at all, so any element using it is silently dropped there (confirmed missing from both the PDF's text and a rendered screenshot), while Text used at block level renders correctly on both (it's automatically wrapped in its own paragraph here, and Text already covers bold/italic/underline/strike/color/size/font). Reach for Paragraph only for genuinely DOCX-specific needs — indentLeft, or nesting several Text/Link runs into one paragraph.
Custom catalog
A component is one typed function — (props, children, context) => DocxRenderResult — nothing more. The engine calls it that way when a spec references it, and it's the exact same call a component makes to compose a sibling component. There's no separate "builder" API to learn: dropping a raw docx primitive in and calling a base component you already have work the same way.
import { defineCatalog } from '@json-render/core';
import { schema, standardComponentDefinitions } from '@kavishkagaya/json-render-docx/server';
import { defineRegistry, renderToBuffer, standardComponents } from '@kavishkagaya/json-render-docx';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
SignatureBlock: {
props: z.object({ name: z.string(), title: z.string().nullable() }),
slots: [],
description: 'A signature line: a divider, a bold name, a role underneath.',
},
},
});
const { registry } = defineRegistry(catalog, {
components: {
...standardComponents,
// Composed from the base components you already have — call them
// directly, same as the engine does.
SignatureBlock: (props) => ({
kind: 'group',
nodes: [
standardComponents.Divider?.({ thickness: 1 }, []),
standardComponents.Paragraph?.({ text: props.name, bold: true }, []),
props.title ? standardComponents.Text?.({ text: props.title, color: '#6b7280' }, []) : null,
].filter((n) => n != null),
}),
},
});
const buffer = await renderToBuffer(spec, { registry });If a base component's props don't expose what you need (e.g. arbitrary block content per table cell), drop to raw docx directly instead — a component returns a DocxNode (or an array / null):
{ kind: 'run', run }— inlineTextRun/ExternalHyperlink{ kind: 'block', block }— aParagraphorTable{ kind: 'section', section }— anISectionOptions(a page){ kind: 'document', document }— a fully-builtdocx.Document(root only){ kind: 'group', nodes }— a flat list of the above
The assembler coalesces consecutive runs into paragraphs and bare blocks into a default section, so you rarely have to think about it.
Server-safe import
Import the schema and catalog for AI prompt generation / spec validation without pulling in the docx runtime:
import { schema, standardComponentDefinitions } from '@kavishkagaya/json-render-docx/server';Limitations
Word is not CSS. Two deliberate, documented gaps:
- No flexbox.
Rowis emulated with a borderless table;Columnis plain vertical flow. There is noflex/align/justifylayout. - No remote image fetch. Images must be data URIs or base64. An
http(s)srcdegrades to a labelled hyperlink rather than failing.
License
MIT © Kavishka Rambukwella
