Export custom nodes with the JSON DSL

Available in Start planBetav0.17.0

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, … */
  ],
}
FieldTypeDescription
dslVersion"1.0"Required exact literal. Any other value rejects with DOCX_DSL_UNKNOWN_VERSION.
nodesCustomNodeRule[]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 */
  },
}
FieldTypeDefaultDescription
typestringrequiredThe 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.
renderRenderProgram | nullrequiredWhat 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:

ShapeDiscriminatorMeaning
nullliteralRender nothing here.
arraybare JS arrayImplicit fragment — render each item in order.
{ "element": …, … }element keyBuild a docx.js element via the adapter registry.
{ "$children": { … } }$children keyWalk the PM node's child content and dispatch each child.
{ "$text": …, … }$text keyConvenience for emitting a single text run.
{ "$fragment": [ … ] }$fragment keyExplicit fragment of sibling render nodes.
{ "$if": { … } }$if keyStructural conditional.
{ "$switch": { … } }$switch keyStructural 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,
}
FieldTypeDefaultDescription
elementElementNamerequiredOne of the allowed elements (see the Element catalog).
propsRecord<string, ValueExpr>{}Element properties. Each value can be a literal or any value expression. Unknown prop keys reject against the element's prop schema.
childrenRenderNode | RenderNode[]undefinedChild render nodes. The element's adapter dictates what is allowed (see Containment).
applyMarksApplyMarksPolicyundefinedValid only on inline elements. Merges the rule's own PM-node marks onto the run. See Marks.
inheritOverridesbooleantrueWhen 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" } }
FieldTypeDefaultDescription
as"block" | "inline" | "table-row" | "table-cell"requiredExpected child kind. Validated against the parent element's childKind. Mismatches reject with DOCX_DSL_INVALID_CONTEXT.
marksMarkPolicy"default"Valid only when as: "inline". How to map marks on text children. See Marks.
wrapInlineInParagraphbooleanfalseValid 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)" }
FieldTypeDefaultDescription
$textValueExprrequiredResolves to a string. Non-strings are coerced via String().
marks"default" | "none""default""default" runs the standard mark pipeline; "none" skips it.
defaultstringundefinedSubstitute 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,
  },
}
FieldTypeDefaultDescription
testValueExprrequiredCoerced to boolean. false, null, undefined, 0, "" are falsy; everything else truthy.
thenRenderNoderequiredBranch rendered when test is truthy.
elseRenderNodenullBranch 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.

ElementKindAllowed inChildren kindNotes
Paragraphblockdocument, blockinlineStandard text block.
TextRuninlineinline(leaf)Standard text run.
ExternalHyperlinkinlineinlineinline (TextRun-only)Wraps one or more TextRuns in a hyperlink.
Tableblockdocument, blocktable-rowChildren populate rows, not children.
TableRowtable-rowtable-rowtable-cellOne row of a table.
TableCelltable-celltable-cellblockOne cell of a row; contains block content.
PageBreakblockdocument, 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 →blockinlinetable-rowtable-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).

{ "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",
}
FieldTypeDefaultDescription
$refstringrequiredDotted path. Identifier segments only (/^[a-zA-Z_][a-zA-Z0-9_]*$/); no array indexing, no wildcards.
defaultValueExprundefinedSubstitute when the path resolves to null or undefined.
transformTransformName | TransformName[]undefinedOne or more whitelisted post-processors applied after resolution.

Allowed paths:

PathReturns
nodeThe whole PM node object.
node.typeThe PM node type name.
node.attrsThe whole attrs object.
node.attrs.<key>The named attribute (only one level deep — node.attrs.style.color is not allowed in v1).
node.textThe PM node's text field (only meaningful for text nodes).
node.textContentConcatenation of all descendant text nodes — equivalent to ProseMirror's node.textContent.

Other paths reject:

  • node.content, node.marksDOCX_DSL_INVALID_REF (no traversal model in v1).
  • loop.*, $parent, $siblings, $depth, $rootDOCX_DSL_RESERVED_SHAPE (reserved for future versions).
  • __proto__, prototype, constructor segments 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] }
OpArityNotes
add, mul≥ 2 (variadic)Numeric. Mixing types rejects.
sub, divexactly 2Numeric.
eq, ne, lt, le, gt, geexactly 2Both sides must be the same primitive type (numbers or strings).
and, or≥ 2Short-circuiting on truthiness.
notexactly 1Boolean.
coalesce≥ 2Returns 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.

UnitInputOutput
pixelsToHalfPointsnumber (px)number (half-points)
pixelsToPointsnumber (px)number (points)
pointsToHalfPointsnumber (pt)number (half-points)
pointsToTwipsnumber (pt)number (twips)
lineHeightToDocxnumber (multiplier)number (docx line value)
universalMeasureToTwipsstring "1.5cm"/"10pt"/… or numbernumber (twips)
normalizeColorstring (any CSS color)6-char hex string without #, or null
inchesToTwipsnumber (in)number (twips)
cmToTwipsnumber (cm)number (twips)
mmToTwipsnumber (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):

TransformBehavior
hexNoHashStrip leading #; the remainder must match /^[0-9A-Fa-f]{6}$/. Rejects non-strings or invalid hex.
lower, upper, trimStandard string ops. Reject non-strings.
parseIntStrictparseInt(value, 10); rejects NaN.
parseFloatStrictparseFloat(value); rejects NaN.
booleanStrict: accepts true/false, "true"/"false" (case-insensitive). Rejects everything else.
nullableStringEmpty/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:

FalsyTruthy
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:

  1. MarkPolicy on $children and $text — controls how the standard pipeline maps marks on text children.
  2. applyMarks on 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"]
}
FieldDefaultDescription
moderequired (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>].replacefalseWhen 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"] },
        },
      },
    },
  },
}
{
  "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:

ScenarioOutcome
Same type in bothFunction wins. One console.warn is emitted per export call per conflicting type.
Type only in customNodesFunction renders.
Type only in customNodeDsl.nodesDSL renders.
Type in neitherExisting "Custom node not found" behaviour; node is dropped with a console error.
Duplicate type within customNodeDsl.nodesCompile 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.

LimitDefaultDescription
maxRules128Maximum number of node rules in a single DSL document.
maxRenderDepth32Maximum recursion depth during render-tree compile and runtime traversal.
maxRenderNodes1024Maximum compiled render-node count in a single program.
maxValueDepth16Maximum recursion depth inside value expressions ($ref defaults, $op args, etc.).
maxStringLength10 000Maximum length of a string prop after evaluation.
maxTemplateLength2 000Maximum length of a $template result after substitution.
maxOpArgs32Maximum argument count for any single $op.
maxTableRows1 024Maximum number of TableRow children a Table may emit.
maxTableCellsPerRow64Maximum 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

CodeWhen
DOCX_DSL_UNKNOWN_VERSIONdslVersion is present but not "1.0".
DOCX_DSL_INVALID_SHAPEThe JSON does not match the expected shape (missing required field, wrong type).
DOCX_DSL_DUPLICATE_NODE_TYPETwo rules in nodes declare the same type.
DOCX_DSL_UNKNOWN_ELEMENTelement is not in the v1 catalog.
DOCX_DSL_UNKNOWN_OPERATION$op is not in the closed list.
DOCX_DSL_RESERVED_SHAPEA key reserved for future versions appeared in the payload.
DOCX_DSL_INVALID_PROPAn element prop fails its schema (unknown key, wrong type).
DOCX_DSL_INVALID_ENUMAn 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_TRANSFORMtransform name is not in the closed list.
DOCX_DSL_INVALID_CONTEXTA render node is in a slot its element kind isn't allowed in (containment violation).
DOCX_DSL_INVALID_OP_ARITYAn $op was passed the wrong number of arguments.
DOCX_DSL_RUNTIME_TYPE_MISMATCHA value-expression result didn't match the expected type at runtime (e.g. $switch.on resolved to a number).
DOCX_DSL_RESOURCE_LIMITA configurable cap was exceeded (depth, node count, string length, …).
DOCX_DSL_RENDER_FAILEDThe adapter instantiate step threw — generally indicates a docx.js incompatibility, surfaced with the offending dslPath.

HTTP status mapping (REST)

ClassStatus
Compile errors (caught by the validator)400
Runtime errors (caught while rendering)422
Resource caps exceeded at compile400
Resource caps exceeded at runtime422
docx.js itself fails (rare)422 (existing FAILED_TO_EXPORT_DOCX_FILE path)

Behavior matrix (REST)

ScenarioHTTPBody code
No customNodeDsl field200unchanged DOCX response
Valid customNodeDsl, all rules render200DOCX
Missing dslVersion400DOCX_DSL_INVALID_SHAPE
Wrong dslVersion400DOCX_DSL_UNKNOWN_VERSION
customNodeDsl.nodes exceeds maxRules400DOCX_DSL_RESOURCE_LIMIT
Reserved root key (requiresStyles, etc.)400DOCX_DSL_RESERVED_SHAPE
Invalid containment400DOCX_DSL_INVALID_CONTEXT
Unknown element name400DOCX_DSL_UNKNOWN_ELEMENT
Invalid $ref path400DOCX_DSL_INVALID_REF
Required attribute resolves to undefined, prop schema rejects422DOCX_DSL_INVALID_PROP
Depth cap exceeded at runtime422DOCX_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.

ThreatMitigation
Arbitrary code executionNo 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 segmentsPath-segment guard rejects them with DOCX_DSL_INVALID_REF.
OOM via deep nestingmaxRenderDepth: 32 enforced at compile and per recursive call.
OOM via large payloadsmaxRules, maxRenderNodes, maxStringLength, maxTemplateLength caps.
OOM via tablesmaxTableRows: 1024, maxTableCellsPerRow: 64.
Quadratic time on $template resolutionmaxTemplateLength cap on resolved length; maxOpArgs: 32 on op argument lists.
Malformed docx outputPer-element Zod prop schemas catch most issues at compile; runtime values are type-checked before docx.js constructors.
Unauthorized media fetchesThe DSL has no ImageRun.data field; image data flows only through the built-in image PM node.
Malicious URLsExternalHyperlink.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 fieldTypeDefaultDescription
customNodeDslObject (CustomNodeDsl)undefinedCustom-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 $each in 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 $children to 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 image PM node.
  • Document-level style contributions. The DSL describes how nodes render. Named styles still live in styleOverrides. The reserved requiresStyles and contributedStyles root keys mark the future surface.
  • Editing a dslVersion payload 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.

  • 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 cssStyles layer 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.