Export custom nodes with the JSON DSL
Beta feature
The DSL is stabilising but may still see additive refinements before general availability. Pin
exact package versions if you depend on it. The wire format is versioned (dslVersion: "1.0");
future versions will not change this document's shape silently.
The function-based custom-node API lets you render any custom Tiptap node into DOCX by writing a small JavaScript function. That works great inside the editor extension, but functions cannot be sent over HTTP — so the DOCX REST API historically had no way to render custom nodes at all.
The customNodeDsl option closes that gap. It is a small, serializable JSON language for describing how a custom node should render into DOCX. The same JSON works in both surfaces: pass it to ExportDocx.configure({ customNodeDsl }) in the browser, or as the customNodeDsl field in the REST request body.
When to use it
| You want to… | Use |
|---|---|
Render a custom node when calling editor.exportDocx() in the browser, with full JavaScript flexibility. | The function API (customNodes). |
Render a custom node when calling POST /v2/convert/export/docx. | customNodeDsl (this page). |
| Share the same custom-node export logic between your browser app and a server-side render. | customNodeDsl (this page). |
| Render the same custom node from both surfaces, with one source of truth. | customNodeDsl (this page). |
Both APIs can coexist on the editor extension. When the same node type appears in both customNodes and customNodeDsl, the function definition wins and a single console.warn is emitted per export call. Existing function-based customers see no behavior change.
Authoring in TypeScript? Use the builder
This page documents the JSON wire format — the canonical reference for what travels over the
convert REST surface. If you're authoring rules in a TypeScript codebase, consider the DSL
builder — a fluent, typed API that produces the
exact same JSON, with autocomplete on every prop and compile-time containment safety. The builder
ships under the @tiptap-pro/extension-export-docx/dsl subpath import.
A first example
A custom hintbox block node that should render as a styled paragraph in DOCX:
import { ExportDocx } from '@tiptap-pro/extension-export-docx'
ExportDocx.configure({
customNodeDsl: {
dslVersion: '1.0',
nodes: [
{
type: 'hintbox',
nodeKind: 'block',
render: {
emit: {
element: 'Paragraph',
props: { style: 'Hintbox' },
children: { $children: { as: 'inline', marks: 'default' } },
},
},
},
],
},
})The same JSON over REST:
curl --output example.docx -X POST "https://api.tiptap.dev/v2/convert/export/docx" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-App-Id: YOUR_APP_ID" \
-H "Content-Type: application/json" \
-d '{
"doc": "{\"type\":\"doc\",\"content\":[{\"type\":\"hintbox\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}",
"exportType": "blob",
"customNodeDsl": {
"dslVersion": "1.0",
"nodes": [
{
"type": "hintbox",
"nodeKind": "block",
"render": {
"emit": {
"element": "Paragraph",
"props": { "style": "Hintbox" },
"children": { "$children": { "as": "inline", "marks": "default" } }
}
}
}
]
}
}'The Hintbox paragraph style itself is declared via styleOverrides, exactly the same way you would declare it for the function API.
Top-level document
{
"dslVersion": "1.0",
"nodes": [
/* CustomNodeRule, … */
],
}| Field | Type | Description |
|---|---|---|
dslVersion | "1.0" | Required exact literal. Any other value rejects with DOCX_DSL_UNKNOWN_VERSION. |
nodes | CustomNodeRule[] | Required. One rule per ProseMirror node type. Maximum 128 entries. Duplicate type values reject with DOCX_DSL_DUPLICATE_NODE_TYPE. |
The following root keys are reserved for future versions and reject today with DOCX_DSL_RESERVED_SHAPE instead of being silently ignored: requiresStyles, contributedStyles, externalRefs, limits. This makes future activations safe — payloads that compile today will keep compiling exactly the same way tomorrow.
CustomNodeRule
A single rule describes one ProseMirror node type:
{
"type": "hintbox",
"nodeKind": "block",
"render": {
/* RenderProgram */
},
}| Field | Type | Default | Description |
|---|---|---|---|
type | string | required | The PM node type this rule matches (e.g. "hintbox", "mention"). |
nodeKind | "block" | "inline" | "auto" | "auto" | Declared kind of the source PM node. "auto" lets the validator infer from the rule's emit shape. |
render | RenderProgram | null | required | What to render. null drops the matching PM node and all its descendants. |
To emit nothing for the wrapping node while letting children render normally, use render: { emit: { "$children": { "as": "block" } } } instead of render: null.
RenderProgram
{
"emit": /* RenderNode | RenderNode[] | null */
}A RenderProgram must declare at least an emit. A program with neither emit nor a (reserved) contribute field rejects with DOCX_DSL_INVALID_SHAPE.
emit: null renders nothing for this PM node, but does not drop descendants. To drop descendants entirely, set render: null on the rule itself.
RenderNodes
Every entry under emit (and the children of ElementNode) is a RenderNode. There are seven shapes, all discriminated structurally — there is no kind tag on the wire:
| Shape | Discriminator | Meaning |
|---|---|---|
null | literal | Render nothing here. |
| array | bare JS array | Implicit fragment — render each item in order. |
{ "element": …, … } | element key | Build a docx.js element via the adapter registry. |
{ "$children": { … } } | $children key | Walk the PM node's child content and dispatch each child. |
{ "$text": …, … } | $text key | Convenience for emitting a single text run. |
{ "$fragment": [ … ] } | $fragment key | Explicit fragment of sibling render nodes. |
{ "$if": { … } } | $if key | Structural conditional. |
{ "$switch": { … } } | $switch key | Structural multi-way conditional. |
An object that mixes two $-prefixed keys (for example { "$text": …, "$children": … }) rejects with DOCX_DSL_INVALID_SHAPE. Pick one.
ElementNode
The most common shape — instantiate a docx.js element:
{
"element": "Paragraph",
"props": { "style": "Hintbox" },
"children": { "$children": { "as": "inline", "marks": "default" } },
"applyMarks": "node",
"inheritOverrides": true,
}| Field | Type | Default | Description |
|---|---|---|---|
element | ElementName | required | One of the allowed elements (see the Element catalog). |
props | Record<string, ValueExpr> | {} | Element properties. Each value can be a literal or any value expression. Unknown prop keys reject against the element's prop schema. |
children | RenderNode | RenderNode[] | undefined | Child render nodes. The element's adapter dictates what is allowed (see Containment). |
applyMarks | ApplyMarksPolicy | undefined | Valid only on inline elements. Merges the rule's own PM-node marks onto the run. See Marks. |
inheritOverrides | boolean | true | When false, skip the global *Overrides bundle (e.g. paragraphOverrides) for this element only. |
$children
Walks node.content and dispatches each child through the standard converter:
{ "$children": { "as": "inline", "marks": "default" } }| Field | Type | Default | Description |
|---|---|---|---|
as | "block" | "inline" | "table-row" | "table-cell" | required | Expected child kind. Validated against the parent element's childKind. Mismatches reject with DOCX_DSL_INVALID_CONTEXT. |
marks | MarkPolicy | "default" | Valid only when as: "inline". How to map marks on text children. See Marks. |
wrapInlineInParagraph | boolean | false | Valid only when as: "block". When true, consecutive inline children are buffered into a default Paragraph rather than rejecting. Useful for table cells consuming inline-only content. |
$text
Convenience for emitting a single text run:
{ "$text": { "$ref": "node.attrs.label" }, "marks": "default", "default": "(unnamed)" }| Field | Type | Default | Description |
|---|---|---|---|
$text | ValueExpr | required | Resolves to a string. Non-strings are coerced via String(). |
marks | "default" | "none" | "default" | "default" runs the standard mark pipeline; "none" skips it. |
default | string | undefined | Substitute string when the resolved value is "", null, or undefined. |
{ "$text": e } with marks: "default" is equivalent to { "element": "TextRun", "props": { "text": e } } with the standard mark pipeline applied — but shorter to write.
$fragment
Explicit fragment of sibling render nodes. A bare array does the same job:
{ "$fragment": [ /* RenderNode, … */ ] }
// — or, equivalently —
[ /* RenderNode, … */ ]$if
Structural conditional:
{
"$if": {
"test": { "$ref": "node.attrs.featured" },
"then": { "element": "Paragraph", "props": { "style": "Featured" } },
"else": null,
},
}| Field | Type | Default | Description |
|---|---|---|---|
test | ValueExpr | required | Coerced to boolean. false, null, undefined, 0, "" are falsy; everything else truthy. |
then | RenderNode | required | Branch rendered when test is truthy. |
else | RenderNode | null | Branch rendered when test is falsy. |
$switch
Multi-way conditional. Lookup is exact-match against cases keys — there is no regex or glob matching:
{
"$switch": {
"on": { "$ref": "node.attrs.variant" },
"cases": {
"warning": { "element": "Paragraph", "props": { "style": "CalloutWarning" } },
"info": { "element": "Paragraph", "props": { "style": "CalloutInfo" } },
},
"default": { "element": "Paragraph", "props": { "style": "Callout" } },
},
}on must resolve to a string at runtime; non-strings reject with DOCX_DSL_RUNTIME_TYPE_MISMATCH. default is the fallback when no case matches; if omitted, the default is null (render nothing).
$switch is also valid as a value expression — see Value-form $switch.
Element catalog
The DSL ships with a closed set of supported elements. Adding new elements is a versioned change. Unknown names reject with DOCX_DSL_UNKNOWN_ELEMENT.
| Element | Kind | Allowed in | Children kind | Notes |
|---|---|---|---|---|
Paragraph | block | document, block | inline | Standard text block. |
TextRun | inline | inline | (leaf) | Standard text run. |
ExternalHyperlink | inline | inline | inline (TextRun-only) | Wraps one or more TextRuns in a hyperlink. |
Table | block | document, block | table-row | Children populate rows, not children. |
TableRow | table-row | table-row | table-cell | One row of a table. |
TableCell | table-cell | table-cell | block | One cell of a row; contains block content. |
PageBreak | block | document, block | (leaf) | Emits a paragraph containing a docx page-break run. For an inline page break inside a paragraph use { "element": "TextRun", "props": { "break": 1 } }. |
ImageRun is not part of v1. Image data flows through the built-in image PM node only — your DSL rule can $children into it, but the DSL itself never originates image bytes. This is a security guarantee on the REST surface.
Containment
The validator rejects every illegal parent/child combination at compile time, with the exact dslPath of the offending node.
| Parent slot ↓ \ child kind → | block | inline | table-row | table-cell |
|---|---|---|---|---|
| Document body | ✓ | |||
Paragraph.children | ✓ | |||
ExternalHyperlink.children | ✓ (TextRun only) | |||
Table.rows | ✓ | |||
TableRow.children | ✓ | |||
TableCell.children | ✓ | (only with wrapInlineInParagraph) |
Mismatch example:
{
"error": "Element \"Paragraph\" cannot appear in \"inline\" slot.",
"code": "DOCX_DSL_INVALID_CONTEXT",
"dslPath": "nodes[1].render.emit.children[0]",
}Element properties
Every element validates its props against a strict schema. Unknown keys reject with DOCX_DSL_INVALID_PROP.
Paragraph
{
"style": "Hintbox",
"alignment": "left" | "center" | "right" | "justified" | "justify" | "both",
"heading": "heading1" | "heading2" | "heading3" | "heading4" | "heading5" | "heading6",
"spacing": { "before": 120, "after": 120, "line": 240, "lineRule": "auto" | "exact" | "atLeast" },
"numbering":{ "reference": "bullet-list" | "ordered-list", "level": 0, "instance": 1 },
"indent": { "left": 360, "right": 0, "firstLine": 360, "hanging": 0 },
"pageBreakBefore": false
}Spacing and indent values are in twips (1 twip = 1/20 of a point). Use the $unit helpers — pointsToTwips, inchesToTwips, etc. — if you'd rather express them in friendlier units.
TextRun
{
"text": "hello",
"bold": true,
"italics": true,
"underline": true,
"underline": { "type": "single" | "double" | "thick" | "dotted" | "dash" | "wave", "color": "4F46E5" },
"strike": true,
"doubleStrike": true,
"superScript": true,
"subScript": true,
"size": 24,
"color": "1F2937",
"font": "Inter",
"highlight": "yellow",
"shading": { "type": "solid" | "clear", "fill": "F3F4F6", "color": "1F2937" },
"break": 1,
"style": "InlineCode"
}size is in half-points (so a 12pt run is size: 24). color, shading fill and color, and underline color are 6-character hex strings without # (use the hexNoHash transform or the normalizeColor unit if your data has a # prefix).
ExternalHyperlink
{ "link": "https://tiptap.dev" }The link value is required and validated to start with one of http, https, mailto, tel. Any other protocol is rejected at compile time. The link is capped at 2048 characters.
ExternalHyperlink requires at least one TextRun child; an empty hyperlink rejects at runtime with DOCX_DSL_INVALID_CONTEXT.
Table
{
"width": { "size": 100, "type": "pct" | "auto" | "dxa" | "nil" },
"layout": "fixed" | "autofit",
"columnWidths": [2400, 2400, 2400],
"margins": { "top": 100, "bottom": 100, "left": 120, "right": 120 },
"borders": {
"top": { "style": "single", "size": 4, "color": "B8D8FF" },
"bottom": { "style": "single", "size": 4, "color": "B8D8FF" },
"left": { "style": "single", "size": 4, "color": "B8D8FF" },
"right": { "style": "single", "size": 4, "color": "B8D8FF" },
"insideHorizontal": { "style": "single", "size": 4, "color": "B8D8FF" },
"insideVertical": { "style": "single", "size": 4, "color": "B8D8FF" }
}
}Table children populate the docx.js rows argument — you don't need to write rows yourself. A table requires at least one row (an empty table rejects at runtime).
TableRow
{
"tableHeader": false,
"cantSplit": false,
"height": { "value": 480, "rule": "auto" | "exact" | "atLeast" }
}A row requires at least one cell.
TableCell
{
"width": { "size": 50, "type": "pct" | "auto" | "dxa" | "nil" },
"columnSpan": 1,
"rowSpan": 1,
"shading": { "type": "solid" | "clear", "fill": "FFF1CC", "color": "1F2937" },
"borders": { "top": …, "bottom": …, "left": …, "right": … },
"margins": { "top": 100, "bottom": 100, "left": 120, "right": 120 },
"verticalAlign": "top" | "center" | "bottom"
}PageBreak
{}PageBreak takes no props. It emits a block-level paragraph containing a docx page-break run.
Value expressions
Anywhere a prop value (or a $text value, $if.test, $switch.on, etc.) is accepted, you can pass a literal or one of the typed value expressions below. Values that aren't expressions — strings, numbers, booleans, null, arrays, plain objects — pass through verbatim. Plain objects are walked recursively, so nested structures like spacing can carry expressions in any leaf.
The expression language is deliberately small. There is no string concatenation operator (use $template), no array indexing (use exact attribute paths), no eval, no general computation. The five primitives below cover what real custom-node renderers need.
$ref — read a PM-node attribute
{
"$ref": "node.attrs.color",
"default": "4F46E5",
"transform": "hexNoHash",
}| Field | Type | Default | Description |
|---|---|---|---|
$ref | string | required | Dotted path. Identifier segments only (/^[a-zA-Z_][a-zA-Z0-9_]*$/); no array indexing, no wildcards. |
default | ValueExpr | undefined | Substitute when the path resolves to null or undefined. |
transform | TransformName | TransformName[] | undefined | One or more whitelisted post-processors applied after resolution. |
Allowed paths:
| Path | Returns |
|---|---|
node | The whole PM node object. |
node.type | The PM node type name. |
node.attrs | The whole attrs object. |
node.attrs.<key> | The named attribute (only one level deep — node.attrs.style.color is not allowed in v1). |
node.text | The PM node's text field (only meaningful for text nodes). |
node.textContent | Concatenation of all descendant text nodes — equivalent to ProseMirror's node.textContent. |
Other paths reject:
node.content,node.marks→DOCX_DSL_INVALID_REF(no traversal model in v1).loop.*,$parent,$siblings,$depth,$root→DOCX_DSL_RESERVED_SHAPE(reserved for future versions).__proto__,prototype,constructorsegments anywhere in the path →DOCX_DSL_INVALID_REF(prototype-pollution guard).- Function-valued resolutions →
DOCX_DSL_INVALID_REF.
$template — string interpolation
{ "$template": "@{node.attrs.label}" }Always returns a string. Substitutions use {path} (same path grammar as $ref). {{ and }} escape literal braces. The resolved length is capped at maxTemplateLength (default 2000); exceeding it rejects with DOCX_DSL_RESOURCE_LIMIT at runtime.
$op — typed compute
{ "$op": "mul", "args": [{ "$ref": "node.attrs.size" }, 2] }| Op | Arity | Notes |
|---|---|---|
add, mul | ≥ 2 (variadic) | Numeric. Mixing types rejects. |
sub, div | exactly 2 | Numeric. |
eq, ne, lt, le, gt, ge | exactly 2 | Both sides must be the same primitive type (numbers or strings). |
and, or | ≥ 2 | Short-circuiting on truthiness. |
not | exactly 1 | Boolean. |
coalesce | ≥ 2 | Returns the first non-null, non-undefined argument. |
There is no concat op — use $template for strings. There is no implicit numeric coercion — { "$op": "add", "args": ["3", 1] } rejects with DOCX_DSL_RUNTIME_TYPE_MISMATCH.
$unit — unit / coercion helper
{ "$unit": "pointsToTwips", "value": 12 }Bridges to a closed list of helpers. The result type matches the helper's signature.
| Unit | Input | Output |
|---|---|---|
pixelsToHalfPoints | number (px) | number (half-points) |
pixelsToPoints | number (px) | number (points) |
pointsToHalfPoints | number (pt) | number (half-points) |
pointsToTwips | number (pt) | number (twips) |
lineHeightToDocx | number (multiplier) | number (docx line value) |
universalMeasureToTwips | string "1.5cm"/"10pt"/… or number | number (twips) |
normalizeColor | string (any CSS color) | 6-char hex string without #, or null |
inchesToTwips | number (in) | number (twips) |
cmToTwips | number (cm) | number (twips) |
mmToTwips | number (mm) | number (twips) |
These are the same helpers used by the function-based customNodes API, exposed for DSL authors so the wire format matches the editor extension's runtime semantics exactly.
Value-form $switch
$switch is also legal as a value expression — useful for choosing a prop value from an attribute:
{
"color": {
"$switch": {
"on": { "$ref": "node.attrs.variant" },
"cases": { "warning": "F59E0B", "info": "0EA5E9" },
"default": "1F2937",
},
},
}The validator picks the value form vs the structural render-tree form by surrounding context. Inside props you get a value expression; in a RenderNode slot you get the structural form.
Transforms
Applied after $ref resolution (or to default when the path was missing):
| Transform | Behavior |
|---|---|
hexNoHash | Strip leading #; the remainder must match /^[0-9A-Fa-f]{6}$/. Rejects non-strings or invalid hex. |
lower, upper, trim | Standard string ops. Reject non-strings. |
parseIntStrict | parseInt(value, 10); rejects NaN. |
parseFloatStrict | parseFloat(value); rejects NaN. |
boolean | Strict: accepts true/false, "true"/"false" (case-insensitive). Rejects everything else. |
nullableString | Empty/whitespace string → null; otherwise trimmed string. |
hexNoHash is not a CSS-color normalizer (it does not understand rgb(), named colors, #abc shorthand, etc.). Use { "$unit": "normalizeColor", "value": … } if you need that.
Truthiness rules
For $if.test and the boolean ops and / or / not / coalesce:
| Falsy | Truthy |
|---|---|
false, null, undefined, 0, "" | everything else |
There is no implicit type coercion outside of these explicit rules.
Marks system
Marks (bold, italic, underline, strike, code, subscript, superscript, textStyle, highlight, link) are the source of much of DOCX's run-level formatting. The DSL gives you two mark hooks:
MarkPolicyon$childrenand$text— controls how the standard pipeline maps marks on text children.applyMarkson inline elements (TextRun,ExternalHyperlink) — controls how the rule's own PM-node marks merge onto the emitted run.
MarkPolicy (on $children / $text)
"marks": "default" // standard Tiptap mark mapping (the default)
"marks": "none" // ignore all marks
"marks": "node" // use the rule's own PM node's marks instead of each child's
"marks": {
"mode": "default" | "node",
"overrides": {
"bold": { "props": { "color": "DC2626" }, "replace": false },
"italic": { "replace": true }
},
"disable": ["highlight"]
}| Field | Default | Description |
|---|---|---|
mode | required (in object form) | "default" runs the standard mark pipeline; "node" runs it against the rule's own PM-node marks. |
overrides[<mark>].props | {} | Extra TextRun props to apply when this mark is present. |
overrides[<mark>].replace | false | When true, suppress the standard mapping for this mark and apply only the override props. |
disable | [] | Marks to skip entirely. Their standard mappings do not run and any matching overrides entry is ignored. |
MarkPolicy: "default" is the default for $children: { as: "inline" } and $text — the same standard pipeline a normal text run would receive.
applyMarks (on inline elements)
applyMarks is distinct from MarkPolicy: it always uses the rule's own PM-node marks (the marks on the custom node itself), not the children's marks. Because there's no other choice, the only valid mode is "node":
"applyMarks": "node"
"applyMarks": {
"mode": "node",
"overrides": { "underline": { "props": { "color": "EA580C" } } },
"disable": ["highlight"]
}'default' and 'none' are deliberately rejected on applyMarks — they only have meaning for child marks.
When applyMarks is omitted, the rule's PM-node marks are ignored. Most explicitly styled inline custom nodes want this default — you set the colors and font yourself, and let the wrapping marks fall away.
Override precedence
When several layers want to set the same property, the last one in this ordered ladder wins:
1. base docx defaults
2. cssStyles-derived styles (the experimental cssStyles option)
3. styleOverrides (named DOCX styles)
4. global *Overrides options (paragraphOverrides, textRunOverrides, …)
5. built-in Tiptap node/mark mapping (the standard pipeline)
6. DSL child-pipeline mark overrides (MarkPolicy.overrides[<mark>])
7. DSL applyMarks props on inline elements
8. explicit DSL element props
inheritOverrides: false on an ElementNode skips layer 4 for that element only — useful when you want a single hint-box paragraph to opt out of paragraphOverrides without affecting any other paragraph.
Nested objects (spacing, borders, shading) are replaced at each layer, not deep-merged. If you set spacing: { before: 120 } and the underlying paragraphOverrides has spacing: { line: 240 }, the result is just { before: 120 }. Same convention as the existing extension options.
Putting it together — worked examples
Hintbox (block, custom style, standard children)
{
"type": "hintbox",
"nodeKind": "block",
"render": {
"emit": {
"element": "Paragraph",
"props": { "style": "Hintbox" },
"children": { "$children": { "as": "inline", "marks": "default" } },
},
},
}Pair with a Hintbox paragraph entry under styleOverrides.paragraphStyles.
Mention (inline, templated text, mark inheritance)
{
"type": "mention",
"nodeKind": "inline",
"render": {
"emit": {
"element": "TextRun",
"props": {
"text": { "$template": "@{node.attrs.label}" },
"color": { "$ref": "node.attrs.color", "default": "4472C4", "transform": "hexNoHash" },
},
"applyMarks": "node",
},
},
}A bold mention renders as new TextRun({ text: '@alice', color: '4472C4', bold: true }).
Callout box (table, conditional style, attribute-driven shading)
{
"type": "calloutBox",
"nodeKind": "block",
"render": {
"emit": {
"element": "Table",
"props": {
"width": { "size": 100, "type": "pct" },
"borders": {
"top": { "style": "single", "size": 4, "color": "B8D8FF" },
"bottom": { "style": "single", "size": 4, "color": "B8D8FF" },
"left": { "style": "single", "size": 4, "color": "B8D8FF" },
"right": { "style": "single", "size": 4, "color": "B8D8FF" },
},
},
"children": [
{
"element": "TableRow",
"children": [
{
"element": "TableCell",
"props": {
"shading": {
"type": "clear",
"fill": {
"$unit": "normalizeColor",
"value": { "$ref": "node.attrs.backgroundColor", "default": "E6F3FF" },
},
},
"margins": {
"top": { "$unit": "pointsToTwips", "value": 8 },
"bottom": { "$unit": "pointsToTwips", "value": 8 },
"left": { "$unit": "pointsToTwips", "value": 10 },
"right": { "$unit": "pointsToTwips", "value": 10 },
},
},
"children": [
{
"element": "Paragraph",
"props": {
"style": {
"$switch": {
"on": { "$ref": "node.attrs.variant" },
"cases": { "warning": "CalloutWarning", "info": "CalloutInfo" },
"default": "Callout",
},
},
},
"children": { "$children": { "as": "inline", "marks": "default" } },
},
],
},
],
},
],
},
},
}Code block (suppress conflicting child marks)
{
"type": "codeBlock",
"nodeKind": "block",
"render": {
"emit": {
"element": "Paragraph",
"props": { "style": "Code" },
"children": {
"$children": {
"as": "inline",
"marks": { "mode": "default", "disable": ["bold", "italic"] },
},
},
},
},
}Custom link (inline hyperlink with mark inheritance)
{
"type": "customLink",
"nodeKind": "inline",
"render": {
"emit": {
"element": "ExternalHyperlink",
"props": { "link": { "$ref": "node.attrs.href" } },
"children": [
{
"element": "TextRun",
"props": {
"text": { "$ref": "node.attrs.label" },
"style": "Hyperlink",
},
"applyMarks": "node",
},
],
},
},
}Conflict policy with the function API
When the editor extension is configured with both customNodes and customNodeDsl:
| Scenario | Outcome |
|---|---|
Same type in both | Function wins. One console.warn is emitted per export call per conflicting type. |
Type only in customNodes | Function renders. |
Type only in customNodeDsl.nodes | DSL renders. |
| Type in neither | Existing "Custom node not found" behaviour; node is dropped with a console error. |
Duplicate type within customNodeDsl.nodes | Compile error: DOCX_DSL_DUPLICATE_NODE_TYPE. |
This is the contract: a customer with no DSL knowledge upgrades the package version with no code changes and observes identical output. Adding customNodeDsl only adds rules; it never overrides existing function-based custom nodes.
The REST surface does not accept the function API at all — only customNodeDsl.
Resource limits
The DSL is untrusted JSON over the public REST surface. Resource caps are enforced at compile and runtime. Legitimate custom-node programs should never hit them.
| Limit | Default | Description |
|---|---|---|
maxRules | 128 | Maximum number of node rules in a single DSL document. |
maxRenderDepth | 32 | Maximum recursion depth during render-tree compile and runtime traversal. |
maxRenderNodes | 1024 | Maximum compiled render-node count in a single program. |
maxValueDepth | 16 | Maximum recursion depth inside value expressions ($ref defaults, $op args, etc.). |
maxStringLength | 10 000 | Maximum length of a string prop after evaluation. |
maxTemplateLength | 2 000 | Maximum length of a $template result after substitution. |
maxOpArgs | 32 | Maximum argument count for any single $op. |
maxTableRows | 1 024 | Maximum number of TableRow children a Table may emit. |
maxTableCellsPerRow | 64 | Maximum number of TableCell children a single TableRow may emit. |
Tuning limits in the editor extension
Pass customNodeDslLimits?: Partial<CustomNodeDslLimits> alongside customNodeDsl:
ExportDocx.configure({
customNodeDsl,
customNodeDslLimits: {
maxRules: 256,
maxRenderDepth: 48,
},
})The wire format itself has no limits field. The reserved root key limits rejects with DOCX_DSL_RESERVED_SHAPE so server-administered limits cannot be subverted by the client payload.
Errors
Every error carries a stable code and a dslPath pointing into the offending JSON. Runtime errors additionally carry nodePath (path into the PM document) and nodeType.
{
"error": "Expected TextRun.size to be number, got string.",
"code": "DOCX_DSL_INVALID_PROP",
"dslPath": "nodes[2].render.emit.props.size",
"nodePath": "doc.content[4].content[2]",
"nodeType": "mention",
}Error codes
| Code | When |
|---|---|
DOCX_DSL_UNKNOWN_VERSION | dslVersion is present but not "1.0". |
DOCX_DSL_INVALID_SHAPE | The JSON does not match the expected shape (missing required field, wrong type). |
DOCX_DSL_DUPLICATE_NODE_TYPE | Two rules in nodes declare the same type. |
DOCX_DSL_UNKNOWN_ELEMENT | element is not in the v1 catalog. |
DOCX_DSL_UNKNOWN_OPERATION | $op is not in the closed list. |
DOCX_DSL_RESERVED_SHAPE | A key reserved for future versions appeared in the payload. |
DOCX_DSL_INVALID_PROP | An element prop fails its schema (unknown key, wrong type). |
DOCX_DSL_INVALID_ENUM | An enum prop received a value outside the closed list. |
DOCX_DSL_INVALID_REF | $ref path is malformed, points outside the allowed roots, or hits a forbidden segment. |
DOCX_DSL_INVALID_TEMPLATE | $template malformed (e.g. unbalanced braces). |
DOCX_DSL_INVALID_UNIT | $unit name is not in the closed list. |
DOCX_DSL_INVALID_TRANSFORM | transform name is not in the closed list. |
DOCX_DSL_INVALID_CONTEXT | A render node is in a slot its element kind isn't allowed in (containment violation). |
DOCX_DSL_INVALID_OP_ARITY | An $op was passed the wrong number of arguments. |
DOCX_DSL_RUNTIME_TYPE_MISMATCH | A value-expression result didn't match the expected type at runtime (e.g. $switch.on resolved to a number). |
DOCX_DSL_RESOURCE_LIMIT | A configurable cap was exceeded (depth, node count, string length, …). |
DOCX_DSL_RENDER_FAILED | The adapter instantiate step threw — generally indicates a docx.js incompatibility, surfaced with the offending dslPath. |
HTTP status mapping (REST)
| Class | Status |
|---|---|
| Compile errors (caught by the validator) | 400 |
| Runtime errors (caught while rendering) | 422 |
| Resource caps exceeded at compile | 400 |
| Resource caps exceeded at runtime | 422 |
| docx.js itself fails (rare) | 422 (existing FAILED_TO_EXPORT_DOCX_FILE path) |
Behavior matrix (REST)
| Scenario | HTTP | Body code |
|---|---|---|
No customNodeDsl field | 200 | unchanged DOCX response |
Valid customNodeDsl, all rules render | 200 | DOCX |
Missing dslVersion | 400 | DOCX_DSL_INVALID_SHAPE |
Wrong dslVersion | 400 | DOCX_DSL_UNKNOWN_VERSION |
customNodeDsl.nodes exceeds maxRules | 400 | DOCX_DSL_RESOURCE_LIMIT |
Reserved root key (requiresStyles, etc.) | 400 | DOCX_DSL_RESERVED_SHAPE |
| Invalid containment | 400 | DOCX_DSL_INVALID_CONTEXT |
| Unknown element name | 400 | DOCX_DSL_UNKNOWN_ELEMENT |
Invalid $ref path | 400 | DOCX_DSL_INVALID_REF |
Required attribute resolves to undefined, prop schema rejects | 422 | DOCX_DSL_INVALID_PROP |
| Depth cap exceeded at runtime | 422 | DOCX_DSL_RESOURCE_LIMIT |
The DSL is fail-fast: any error aborts the export with no partial output. Silent partial output produces invalid DOCX that is harder to debug than an explicit error.
Security model
The REST surface accepts arbitrary customNodeDsl from authenticated clients. The DSL is designed so no combination of valid inputs can reach docx.js internals, the host filesystem, or the network.
| Threat | Mitigation |
|---|---|
| Arbitrary code execution | No eval, no Function, no dynamic imports. Closed adapter / op / unit / transform whitelists. |
| Property access outside PM node | $ref resolver walks node.* only; no array indexing, no prototype walk. |
Prototype pollution via __proto__ / prototype / constructor segments | Path-segment guard rejects them with DOCX_DSL_INVALID_REF. |
| OOM via deep nesting | maxRenderDepth: 32 enforced at compile and per recursive call. |
| OOM via large payloads | maxRules, maxRenderNodes, maxStringLength, maxTemplateLength caps. |
| OOM via tables | maxTableRows: 1024, maxTableCellsPerRow: 64. |
Quadratic time on $template resolution | maxTemplateLength cap on resolved length; maxOpArgs: 32 on op argument lists. |
| Malformed docx output | Per-element Zod prop schemas catch most issues at compile; runtime values are type-checked before docx.js constructors. |
| Unauthorized media fetches | The DSL has no ImageRun.data field; image data flows only through the built-in image PM node. |
| Malicious URLs | ExternalHyperlink.link is whitelisted to http, https, mailto, tel. |
REST API addendum
The DSL travels on the existing POST /v2/convert/export/docx endpoint as a sibling of the existing styleOverrides, pageSize, pageMargins, headers, and footers fields. See the REST API reference for headers, response shape, and the rest of the body schema.
| Body field | Type | Default | Description |
|---|---|---|---|
customNodeDsl | Object (CustomNodeDsl) | undefined | Custom-node render rules. |
When the request comes in multipart/form-data (the form variant of the endpoint), customNodeDsl is sent as a JSON-stringified value in the form field of the same name, exactly like styleOverrides. The service parses it and forwards the result to the export pipeline.
What's intentionally not here
These are explicit boundaries, not bugs:
- Loops over array attributes. There is no
$eachin v1. The shape is reserved (DOCX_DSL_RESERVED_SHAPE) so it can be added without breaking any payloads compiled today. Until then, model variable-length collections by emitting them from the parent (block) and using$childrento dispatch their items. - Cross-rule attribute access. A child rule cannot read its parent rule's PM node. The escape hatch is
$children: { as: "inline", marks: "node" }on the parent rule, which forwards the parent's marks to the child run. - Free-form expressions. The DSL is not Turing-complete and does not embed Jexl / JSONata /
eval. All operations are typed and capped. - Image data origination. Image bytes never come from the DSL; they come from the built-in
imagePM node. - Document-level style contributions. The DSL describes how nodes render. Named styles still live in
styleOverrides. The reservedrequiresStylesandcontributedStylesroot keys mark the future surface. - Editing a
dslVersionpayload between versions.dslVersion: "1.0"is an exact literal. Future minor versions will be additive (new optional fields, new operations) and will accept v1.0 payloads unchanged. Major version bumps will require explicit migration.
Related
- Editor-extension custom nodes (function API) — the JavaScript-function variant for in-browser customisation.
- Style overrides — declare named DOCX styles like
Hintbox,CalloutWarning, that DSL rules reference by id. - CSS to DOCX — compose with the DSL: the
cssStyleslayer feeds heading and paragraph styles, while DSL rules cover your custom-node types. - DOCX REST API — request shape, headers, response, and error envelope for the underlying endpoint.
- Editor extension overview — full configuration surface for
ExportDocx.configure.