---
title: "Export custom nodes with the JSON DSL"
description: "A serializable JSON description of how Tiptap custom nodes should render into DOCX. Works in both the editor extension and the Conversion REST API."
canonical_url: "https://tiptap.dev/docs/conversion/export/docx/custom-nodes-dsl"
---

# Export custom nodes with the JSON DSL

A serializable JSON description of how Tiptap custom nodes should render into DOCX. Works in both the editor extension and the Conversion REST API.

> **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.

- **1. Activate trial or subscribe**

  Start a [free trial](https://cloud.tiptap.dev/v2?trial=true) or [subscribe to the Start
  plan](https://cloud.tiptap.dev/v2/billing) in your account.
- **2. Install from private registry**

  To install this frontend extension, authenticate to Tiptap's private npm registry by following
  the [setup guide](https://tiptap.dev/docs/guides/pro-extensions.md).
- **3. (REST only) Configure Convert app**

  To use the Convert REST API retrieve your App ID and Convert secret from [your
  dashboard](https://cloud.tiptap.dev/v2/cloud/convert).

The [function-based custom-node API](https://tiptap.dev/docs/conversion/export/docx/custom-nodes.md) 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](https://tiptap.dev/docs/conversion/export/docx/rest-api.md) 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.

> **Interactive demo:** [ExportDocxCustomNodeDsl](https://embed-pro.tiptap.dev/preview/Extensions/ExportDocxCustomNodeDsl)

---

## 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](https://tiptap.dev/docs/conversion/export/docx/custom-nodes.md) (`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](https://tiptap.dev/docs/conversion/export/docx/custom-nodes-dsl-builder.md) — 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:

```ts
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:

```bash
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](https://tiptap.dev/docs/conversion/export/docx/styles.md), exactly the same way you would declare it for the function API.

---

## Top-level document

```jsonc
{
  "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:

```jsonc
{
  "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

```jsonc
{
  "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:

```jsonc
{
  "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](#element-catalog)).                                                                                 |
| `props`            | `Record<string, ValueExpr>`  | `{}`        | Element properties. Each value can be a literal or any [value expression](#value-expressions). 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](#containment)).                                                      |
| `applyMarks`       | `ApplyMarksPolicy`           | `undefined` | Valid only on inline elements. Merges the rule's own PM-node marks onto the run. See [Marks](#marks-system).                                               |
| `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:

```jsonc
{ "$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](#marks-system).                                                                                               |
| `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:

```jsonc
{ "$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:

```jsonc
{ "$fragment": [ /* RenderNode, … */ ] }
// — or, equivalently —
[ /* RenderNode, … */ ]
```

### `$if`

Structural conditional:

```jsonc
{
  "$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:

```jsonc
{
  "$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`](#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 `TextRun`s 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:

```jsonc
{
  "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

```jsonc
{
  "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

```jsonc
{
  "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

```jsonc
{ "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

```jsonc
{
  "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

```jsonc
{
  "tableHeader": false,
  "cantSplit":   false,
  "height":      { "value": 480, "rule": "auto" | "exact" | "atLeast" }
}
```

A row requires at least one cell.

#### TableCell

```jsonc
{
  "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

```jsonc
{}
```

`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

```jsonc
{
  "$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`, `constructor` segments anywhere in the path → `DOCX_DSL_INVALID_REF` (prototype-pollution guard).
- Function-valued resolutions → `DOCX_DSL_INVALID_REF`.

### `$template` — string interpolation

```jsonc
{ "$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

```jsonc
{ "$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

```jsonc
{ "$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:

```jsonc
{
  "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:

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`)

```jsonc
"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"`:

```jsonc
"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)

```jsonc
{
  "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`](https://tiptap.dev/docs/conversion/export/docx/styles.md).

### Mention (inline, templated text, mark inheritance)

```jsonc
{
  "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)

```jsonc
{
  "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)

```jsonc
{
  "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)

```jsonc
{
  "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`:

```ts
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`.

```jsonc
{
  "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](https://tiptap.dev/docs/conversion/export/docx/rest-api.md) for headers, response shape, and the rest of the body schema.

| Body field      | Type                                              | Default     | Description               |
| --------------- | ------------------------------------------------- | ----------- | ------------------------- |
| `customNodeDsl` | `Object` ([`CustomNodeDsl`](#top-level-document)) | `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 `$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`](https://tiptap.dev/docs/conversion/export/docx/styles.md). 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.

---

## Related

- [Editor-extension custom nodes (function API)](https://tiptap.dev/docs/conversion/export/docx/custom-nodes.md) — the JavaScript-function variant for in-browser customisation.
- [Style overrides](https://tiptap.dev/docs/conversion/export/docx/styles.md) — declare named DOCX styles like `Hintbox`, `CalloutWarning`, that DSL rules reference by id.
- [CSS to DOCX](https://tiptap.dev/docs/conversion/export/docx/css-to-docx.md) — compose with the DSL: the `cssStyles` layer feeds heading and paragraph styles, while DSL rules cover your custom-node types.
- [DOCX REST API](https://tiptap.dev/docs/conversion/export/docx/rest-api.md) — request shape, headers, response, and error envelope for the underlying endpoint.
- [Editor extension overview](https://tiptap.dev/docs/conversion/export/docx/editor-extension.md) — full configuration surface for `ExportDocx.configure`.
