---
title: "自定义节点 DSL 构建器（TypeScript）"
description: "一个带类型、流畅的 TypeScript API，用于编写 DOCX 自定义节点 DSL。提供编译时包含安全性、每个属性的自动补全，以及与 REST API 消费的相同线缆格式。"
canonical_url: "https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl-builder"
---

# 自定义节点 DSL 构建器（TypeScript）

一个带类型、流畅的 TypeScript API，用于编写 DOCX 自定义节点 DSL。提供编译时包含安全性、每个属性的自动补全，以及与 REST API 消费的相同线缆格式。

> **Beta 功能:**
>
> 构建器与 [JSON
> DSL](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md) 一样，属于同一个 `customNodeDsl` 功能范畴。两者都以 `dslVersion: "1.0"` 发布，并生成完全相同的线格式输出。如果你依赖此功能，请固定精确的包版本。

- **1. 激活试用或订阅**

  在你的账户中开始 [免费试用](https://cloud.tiptap.dev/v2?trial=true) 或 [订阅 Start
  计划](https://cloud.tiptap.dev/v2/billing)。
- **2. 从私有注册表安装**

  按照 [设置指南](https://tiptap.zhcndoc.com/guides/pro-extensions.md) 通过身份验证连接到 Tiptap 的私有 npm 注册表。

DSL 构建器是编写 [custom-nodes DSL](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md) 的 **TypeScript 友好**方式。它是一个小巧、流式、不可变的 API，会编译为线格式所期望的 **相同 JSON**：每次链式调用都会返回一个新的构建器，而任意链上的 `.toJSON()` 都会生成 DSL 编译器可接受的字面量载荷。

你将获得：

- **每个元素属性都有自动补全。** `paragraph({ … })` 会提示 `style`、`alignment`、`spacing`、`border`、`shading` 等，这正是 DSL 编译器验证的字段集合。不再需要从文档里猜测属性名。
- **编译期包含安全。** `paragraph().children(childrenBlock())` 会报 TypeScript 错误：段落只接受内联子元素。`table().rows(paragraph())` 也会报错：`Table.rows` 需要 `TableRow` 构建器。错误在编辑时就会触发，而不是运行时。
- **必填属性检查。** `externalHyperlink({})` 会报 TypeScript 错误：线格式要求 `link` 属性必填，而构建器类型会强制这一点。
- **线格式兼容性。** 输出与手写 JSON 完全一致。同一份载荷可用于编辑器、Node.js 服务器，或作为 [Conversion REST API](https://tiptap.zhcndoc.com/conversion/export/docx/rest-api.md) 中的 `customNodeDsl` 字段。

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

---

## 安装与导入

该构建器通过子路径导入发布，位于 DOCX 导出流水线其余部分所在的同一包中：

```bash
npm i @tiptap-pro/extension-export-docx@^0.18.0
```

```ts
// 主入口：运行时导出器和可配置扩展。
import { ExportDocx, exportDocx } from '@tiptap-pro/extension-export-docx'

// 构建器子路径：可进行 tree-shaking。那些从不使用 DSL 的客户
// 不会为此付出代价。
import {
  docxNode,
  paragraph,
  textRun,
  externalHyperlink,
  table,
  tableRow,
  tableCell,
  pageBreak,
  childrenInline,
  childrenBlock,
  childrenTableRow,
  childrenTableCell,
  iff,
  switch_,
  fragment,
  textOp,
  ref,
  template,
  op,
  unit,
  switchValue,
  marks,
} from '@tiptap-pro/extension-export-docx/dsl'
```

该子路径别名可在 **Vite、Next.js、Webpack 5+、esbuild、Rollup，以及所有现代打包工具** 中使用。较旧的工具链可能需要一个显式支持 `exports` 字段的解析器。

---

## 何时使用构建器

| 你是…                                                 | 使用…                                                                                                              |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| 使用 TypeScript 编写 DOCX 规则，并需要自动补全和重构支持。              | **构建器。**                                                                                                         |
| 将 DSL 作为 JSON 正文发送到 `POST /v2/convert/export/docx`。 | [JSON DSL](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md)：直接从 JSON 文件传递 `customNodeDsl`。 |
| 在编辑器扩展中，希望使用完整的 JavaScript（闭包、IO 等）。                | 基于函数的 [custom-nodes API](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes.md)。                     |
| 从配置服务动态加载规则。                                        | JSON DSL（反序列化配置并直接传递）。                                                                                           |

构建器会生成 JSON，因此你可以将两者结合使用：在设计时构建，将 `.toJSON()` 持久化到磁盘，再把 JSON 发送到服务器。或者在运行时读取 JSON 并直接传给 `exportDocx`。两个方向都没有锁定。

---

## 快速开始

一个 12 行的 `hintbox` 规则，渲染为带样式的 DOCX 段落：

```ts
import { ExportDocx } from '@tiptap-pro/extension-export-docx'
import { childrenInline, docxNode, paragraph } from '@tiptap-pro/extension-export-docx/dsl'

ExportDocx.configure({
  customNodeDsl: {
    dslVersion: '1.0',
    nodes: [
      docxNode('hintbox')
        .block()
        .emit(
          paragraph({ style: 'Hintbox' }) //
            .children(childrenInline({ marks: 'default' })),
        )
        .toJSON(),
    ],
  },
})
```

这与 [JSON DSL 快速开始示例](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md#a-first-example) 使用的是相同的载荷。传输的字节完全一致，只是编写层不同。

---

## 顶层构建器：`docxNode`

`docxNode(type)` 会为一种 ProseMirror 节点类型开始一条规则。该链条必须且只能以 `emit(...)`、`emitNothing()` 或 `drop()` 中的**恰好一个**结束。在选择策略之前调用 `toJSON()` 会抛出错误。

```ts
docxNode('hintbox') // ┐
  .block() //          │  可选：显式声明 nodeKind
  .emit(paragraph()) //│  要渲染为 PM 节点位置中的内容
  .toJSON() //         ┘  字面量 CustomNodeRule JSON
```

| 方法               | 作用                                                               |
| ---------------- | ---------------------------------------------------------------- |
| `.block()`       | 将规则标记为块级（用于校验器的包含性检查）。                                           |
| `.inline()`      | 将规则标记为行内级。                                                       |
| `.auto()`        | 让校验器根据 emit 的形状推断类型（默认值）。                                        |
| `.emit(node)`    | 将匹配到的 PM 节点渲染为 `node`。接受单个 render-node 构建器、字面量 `RenderNode`，或数组。 |
| `.emitNothing()` | `emit: null`：对该 PM 节点不输出任何内容，但仍会遍历其后代。                           |
| `.drop()`        | `render: null`：丢弃匹配到的 PM 节点及其**所有后代**。                           |
| `.toJSON()`      | 将该链条具体化为线上的 `CustomNodeRule` JSON。                               |

每个方法都会返回一个**新的构建器**，因此可以重复使用部分链条，而不会出现别名带来的意外：

```ts
const base = docxNode('mention').inline()
const withColor = base.emit(textRun({ color: '4472C4' }))
const dropped = base.drop()
// `base` 保持不变。
```

---

## 元素工厂

v1 目录中的每个元素都有一个对应工厂。每个工厂都会返回一个可链式调用的构建器，其 `.toJSON()` 会生成线格式的 [`ElementNode`](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md#elementnode)。

### `paragraph(props?)`

接受行内子元素的块级元素。

```ts
paragraph({
  style: '提示框',
  alignment: 'center',
  spacing: { before: 240, after: 240, line: 240, lineRule: 'auto' },
  border: {
    top: { style: 'single', size: 4, color: 'B8D8FF', space: 5 },
    bottom: { style: 'single', size: 4, color: 'B8D8FF', space: 5 },
    left: { style: 'single', size: 4, color: 'B8D8FF', space: 5 },
    right: { style: 'single', size: 4, color: 'B8D8FF', space: 5 },
  },
  shading: { type: 'clear', fill: 'E6F3FF' },
}).children(childrenInline({ marks: 'default' }))
```

| 方法                         | 用途                                                           |
| -------------------------- | ------------------------------------------------------------ |
| `.children(spec)`          | 设置行内子元素（`ChildrenInlineBuilder`、单个渲染节点构建器，或数组）。块级子元素会在编译时失败。 |
| `.inheritOverrides(value)` | 当为 `false` 时，跳过该段落的全局 `paragraphOverrides` 组合。默认值为 `true`。   |
| `.toJSON()`                | 线格式 `ElementNode`。                                           |

规范映射： [Paragraph element prop schema](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md#paragraph)。

`border` 和 `shading` 属性允许自定义节点在 DSL 元素上携带装饰性外观（如边框、提示框、hintbox），而无需单独命名的 `styleOverrides` 样式。另见下面的 [将装饰内联表达](#express-decoration-inline)。

### `textRun(props?)`

行内叶子节点，没有 `children`。使用 `applyMarks` 来继承规则的 PM 节点标记。

```ts
textRun({
  text: template('@{node.attrs.label}'),
  color: '4472C4',
  size: 24, // 半磅
  highlight: 'cyan',
  style: 'Hyperlink', // 往返一致性标记
}).applyMarks('node')
```

| 方法                         | 用途                                                                                                                |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `.applyMarks(policy)`      | `'node'`（继承规则的 PM 节点标记）或一个 `marks('node')...` 构建器，用于精细控制。这里有意拒绝 `'default'` 和 `'none'`（见 [标记策略](#mark-policies)）。 |
| `.inheritOverrides(value)` | 当为 `false` 时，跳过该运行的 `textRunOverrides`。默认值为 `true`。                                                               |
| `.toJSON()`                | 线格式 `ElementNode`。                                                                                                |

规范映射： [TextRun element prop schema](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md#textrun)。

### `externalHyperlink(props)`

包裹一个或多个 `TextRun` 子元素的行内元素。`link` 属性在类型层面是**必需**的，因此 `externalHyperlink({})` 会导致 TypeScript 错误。

```ts
externalHyperlink({ link: ref('node.attrs.href') }).children([
  textRun({ text: ref('node.attrs.label'), style: 'Hyperlink' }) //
    .applyMarks('node'),
])
```

链接值在运行时也会进行验证，必须以 `http`、`https`、`mailto` 或 `tel` 开头（长度上限为 2048 字符）。其他协议会在编译时以 `DOCX_DSL_INVALID_PROP` 报错拒绝。

### `table(props?)` / `tableRow(props?)` / `tableCell(props?)`

`table()` 通过 `.rows(...)` 接受 `TableRow` 构建器（或等价的 `.children([row, row, ...])`）。`tableRow()` 通过 `.children([cell, cell, ...])` 接受 `TableCell` 构建器。`tableCell()` 接受块级内容（段落、嵌套表格）或 `childrenBlock()`。

```ts
table({
  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' },
  },
}).rows(
  tableRow().children([
    tableCell({
      shading: { type: 'clear', fill: 'FFF1CC' },
      verticalAlign: 'top',
    }).children([paragraph().children(childrenInline({ marks: 'default' }))]),
  ]),
)
```

线格式会在内部将 `Table` 的子元素映射到 docx 的 `rows` 构造参数，因此你不需要自己编写 `rows: ...`。

### `pageBreak()`

叶子工厂：发出一个包含 docx 分页符运行的块级段落。若要在段落内部插入行内分页符，请改用 `textRun({ break: 1 })`。

```ts
docxNode('explicitPageBreak').block().emit(pageBreak()).toJSON()
```

---

## 子项辅助器

在元素的 `.children(...)` 中，你可以传入以下任一项：

- 一个 **显式的渲染节点构建器数组**（`[paragraph(), table(), ...]`）。
- 一个 **`childrenX()` 委托**，让运行时遍历 PM 节点的 `content` 数组，并通过标准转换器分发每个子节点。

| 辅助器                     | 放置位置                                                             | 传输格式                                                                 |
| ----------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------- |
| `childrenInline(opts?)` | `paragraph().children(...)`, `externalHyperlink().children(...)` | `{ "$children": { "as": "inline", "marks"?: ... } }`                 |
| `childrenBlock(opts?)`  | `tableCell().children(...)`                                      | `{ "$children": { "as": "block", "wrapInlineInParagraph"?: bool } }` |
| `childrenTableRow()`    | `table().children(...)`（`.rows(...)` 的同义形式）                      | `{ "$children": { "as": "table-row" } }`                             |
| `childrenTableCell()`   | `tableRow().children(...)`                                       | `{ "$children": { "as": "table-cell" } }`                            |

```ts
// 内联标记策略：对每个文本子节点运行标准标记流水线。
paragraph({ style: 'Body' }).children(childrenInline({ marks: 'default' }))

// 带内联换行的块委托：将松散的内联子节点缓冲到默认段落中，
// 这样表格单元格始终只包含块内容。
tableCell().children(childrenBlock({ wrapInlineInParagraph: true }))
```

`childrenInline()` 接受一个 `marks` 选项，该选项可传入一个 `MarkPolicy`（`'default'`、`'none'`、`'node'`）或一个 `marks(...)` 构建器链（见 [标记策略](#mark-policies)）。

---

## 渲染树原语

除了元素之外，还有四种渲染树形状可用于组合动态输出。

### `iff({ test, then, else? })`

结构化条件判断。`else` 默认值为 `null`（不输出任何内容）。

```ts
iff({
  test: ref('node.attrs.featured'),
  then: paragraph({ style: 'Featured' }).children(childrenInline({ marks: 'default' })),
  else: paragraph().children(childrenInline({ marks: 'default' })),
})
```

`test` 使用 v1 的真值规则强制转换为布尔值：`false`、`null`、`undefined`、`0`、`""` 为假值，其他值都为真值。

### `switch_({ on, cases, default? })`

多路条件判断。查找时会与 `cases` 的键进行精确匹配。`on` 在运行时必须解析为字符串。

```ts
switch_({
  on: ref('node.attrs.variant'),
  cases: {
    warning: paragraph({ style: 'CalloutWarning' }),
    info: paragraph({ style: 'CalloutInfo' }),
  },
  default: paragraph({ style: 'Callout' }),
})
```

末尾的下划线是因为 `switch` 在 JavaScript 中是保留字。还有一个用于选择属性值的 **value-form** `switchValue(...)`（见下方的 [值表达式](#value-expressions)）。

### `fragment(...children)`

输出多个同级渲染节点。传给元素 `.children([...])` 的裸数组也能完成同样的工作；`fragment` 适用于“分片”本身就是整个输出的情况。

```ts
docxNode('keyValue')
  .block()
  .emit(
    fragment(
      paragraph({ style: 'Key' }).children([textRun({ text: ref('node.attrs.key') })]),
      paragraph({ style: 'Value' }).children(childrenInline({ marks: 'default' })),
    ),
  )
  .toJSON()
```

### `textOp(value, opts?)`

用于从值表达式方便地发出单个文本运行。与 v1 中的 `{ element: 'TextRun', props: { text: value } }` 等价，并会应用标记策略，但写起来更简短。

```ts
textOp(template('@{node.attrs.label}'))
textOp(ref('node.attrs.title'), { default: '(未命名)', marks: 'none' })
```

---

## 值表达式

凡是接受属性值（或 `$text`、`$if.test`、`$switch.on`、`$op.args`）的地方，你都可以传入字面量 **或** 下面这些带类型的表达式之一。

### `ref(path, opts?)`

从源 PM 节点读取一个值。允许的路径：`node`、`node.type`、`node.attrs`、`node.attrs.<key>`、`node.text`、`node.textContent`。其他路径会以 [`DOCX_DSL_INVALID_REF`](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md#error-codes) 报错拒绝。

```ts
ref('node.attrs.color')
ref('node.attrs.color', { default: '4472C4', transform: 'hexNoHash' })
ref('node.textContent')
```

`transform` 选项可以接受单个 `TransformName` 或数组。参见 JSON DSL 参考页中的 [转换表](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md#transforms)。

### `template(s)`

字符串插值，始终返回字符串。替换内容使用 `{path}`（路径语法与 `ref` 相同）；`{{` 和 `}}` 用于转义字面量花括号。

```ts
template('@{node.attrs.label}') //        →  '@alice'
template('size: {node.attrs.size}px') //  →  'size: 24px'
template('{{escaped}} braces') //          →  '{escaped} braces'
```

解析后的长度上限为 `maxTemplateLength`（默认 2 000）。超过上限会在运行时以 `DOCX_DSL_RESOURCE_LIMIT` 拒绝。

### `op(name, ...args)`

类型化计算。操作是封闭列表，且参数个数严格固定（参见 [op 参数个数表](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md#op---typed-compute)）。

```ts
op('mul', ref('node.attrs.size'), 2)
op('coalesce', ref('node.attrs.color'), ref('node.attrs.fallbackColor'), '000000')
op('and', ref('node.attrs.visible'), op('not', ref('node.attrs.hidden')))
```

没有 `concat`；字符串请使用 `template(...)`。也没有隐式数值转换，所以 `op('add', '3', 1)` 会被拒绝。

### `unit(name, value)`

连接到一个封闭列表的单位 / 转换辅助器，其底层使用的正是转换器内部的同一组函数。结果类型与该辅助器的签名一致。

```ts
unit('pixelsToHalfPoints', 16) //                          →  24（半磅）
unit('pointsToTwips', 12) //                               →  240（twips）
unit('normalizeColor', ref('node.attrs.backgroundColor')) //→  '4F46E5'
```

完整列表：[单位分发表](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md#unit---unit--coercion-helper)。

### `switchValue({ on, cases, default? })`

值形式的 `$switch`：通过字符串匹配选择一个属性值（而不是渲染节点）。

```ts
paragraph({
  style: switchValue({
    on: ref('node.attrs.variant'),
    cases: { warning: 'CalloutWarning', info: 'CalloutInfo' },
    default: 'Callout',
  }),
})
```

校验器会根据周围上下文来区分值形式与渲染树形式：`switchValue` 只能用于属性值中，而 `switch_` 只能用于 `RenderNode` 插槽中。

---

## 标记策略

`marks(mode)` 构建器会创建一个可链式调用的 [`MarkPolicy`](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md#marks-system) 对象。适用于你需要比字符串简写更精细控制的场景。

```ts
import { childrenInline, marks } from '@tiptap-pro/extension-export-docx/dsl'

childrenInline({
  marks: marks('default') //
    .disable('bold', 'italic')
    .overrides({
      underline: { props: { color: 'EA580C' } },
      highlight: { replace: true, props: { color: 'FFE066' } },
    }),
})
```

| 方法                                               | 用途                                                                    |
| ------------------------------------------------ | --------------------------------------------------------------------- |
| `marks(mode)`                                    | 开始构建器。`mode` 为 `'default'`（对子节点自身的标记应用标准管线）或 `'node'`（使用规则的 PM 节点标记）。 |
| `.disable(...marks)`                             | 完全跳过这些标记。它们的标准映射不会运行，任何匹配的 `overrides` 条目也会被忽略。                       |
| `.overrides({ markType: { props?, replace? } })` | 按标记进行调整。`replace: true` 会抑制该标记的标准映射；`props` 会在其基础上合并。                 |
| `.toJSON()`                                      | 生成传输格式的 `MarkPolicyObject`。容器辅助器会自动为你调用它。                             |

### `applyMarks` 的适用范围更窄

`textRun().applyMarks(...)` 和 `externalHyperlink().applyMarks(...)` 仅接受 `'node'` 或 `marks('node')...` 构建器。这里的 wire format 会拒绝 `'default'` 和 `'none'`，因为它们只适用于子节点标记。

构建器会在运行时强制执行这一点：将 `marks('default')` 传给 `applyMarks` 时会抛出有帮助的错误，而不是生成一个日后会导致 DSL 编译器失败的负载。

```ts
textRun().applyMarks('node') //                       ✓
textRun().applyMarks(marks('node').disable('code')) // ✓
textRun().applyMarks(marks('default')) //              ✗ 在构建时抛出
```

---

## 类型系统保证

构建器的 TypeScript 接口会在编辑阶段强制执行运行时本应捕获的每一条包含规则。以下都是编译错误：

```ts
// 段落只接受行内子节点。
paragraph().children(childrenBlock())
//                  ^^^^^^^^^^^^^^^^
// Type 'ChildrenBlockBuilder' is not assignable to type 'ParagraphChildSpec'.

// Table.rows 需要 TableRow 构建器。
table().rows(paragraph())
//           ^^^^^^^^^^^
// Type 'ParagraphBuilder' is not assignable to type 'TableRowBuilder'.

// TableRow 的子节点必须是 TableCell 构建器。
tableRow().children([tableRow()])
//                   ^^^^^^^^^^
// Type 'TableRowBuilder' is not assignable to type 'TableCellBuilder'.

// TableCell 的子节点是块级；会拒绝行内子节点委托。
tableCell().children(childrenInline())
//                   ^^^^^^^^^^^^^^^^

// TextRun 是叶子节点，没有子节点。
textRun({ text: 'hi' }).children
//                       ^^^^^^^^
// Property 'children' does not exist on type 'TextRunBuilder'.

// externalHyperlink.link 是必需的。
externalHyperlink({})
//                ^^
// Property 'link' is missing in type '{}' but required in type 'ExternalHyperlinkProps'.

// applyMarks 拒绝 'default' / 'none'。
textRun().applyMarks('default')
//                   ^^^^^^^^^
// Argument of type '"default"' is not assignable to parameter of type 'ApplyMarksPolicy | MarkPolicyBuilder'.
```

这些规则与运行时的 [`DOCX_DSL_INVALID_CONTEXT`](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md#error-codes) 规则一一对应：约束相同，只是更早暴露。

## 内联表达装饰

`styleOverrides` 的一种常见模式是：先声明一个带边框和底纹的命名 `Hintbox` 段落样式，然后在 DSL 规则中通过 `style: 'Hintbox'` 引用它。现在，`paragraph()` 构建器直接暴露了 `border` 和 `shading` 属性，因此装饰可以直接内联在元素上——不再需要 `styleOverrides` 块。

`styleOverrides` 的一种常见模式是：先声明一个带边框和底纹的命名 `Hintbox` 段落样式，然后在 DSL 规则中通过 `style: 'Hintbox'` 引用它。现在，`paragraph()` 构建器直接暴露了 `border` 和 `shading` 属性，因此装饰可以直接内联在元素上——不再需要 `styleOverrides` 块。

The named-style label still has a job: **round-trip identity**. The DOCX writer emits `<w:pStyle w:val="Hintbox"/>` whether or not `Hintbox` is declared in `styles.xml`. The import side reads that pStyle to reconstruct the matching custom node.

```ts
docxNode('hintbox')
  .block()
  .emit(
    paragraph({
      // 往返一致性标记。
      style: 'Hintbox',
      // 装饰现在直接写在这里；不需要 `styleOverrides`。
      spacing: { before: 240, after: 240 },
      border: {
        top: { style: 'single', size: 1, color: 'B8D8FF', space: 5 },
        bottom: { style: 'single', size: 1, color: 'B8D8FF', space: 5 },
        left: { style: 'single', size: 1, color: 'B8D8FF', space: 5 },
        right: { style: 'single', size: 1, color: 'B8D8FF', space: 5 },
      },
      // `clear` 底纹让 `fill` 作为背景透出来。
      // `solid` 会以 100% 的覆盖率绘制前景（默认 `auto` → 黑色）
      // 并完全遮住 `fill`。见下方的 OOXML 陷阱。
      shading: { type: 'clear', fill: 'E6F3FF' },
    }).children(childrenInline({ marks: 'default' })),
  )
  .toJSON()
```

This pairs naturally with [`cssStyles.extractFromDocument`](https://tiptap.zhcndoc.com/conversion/export/docx/css-to-docx.md): let CSS handle headings, body fonts, lists, and other generic selectors; let the DSL handle anything tied to a custom node type.

> **OOXML 陷阱：\`solid\` 底纹会绘制前景:**
>
> 在 OOXML 中，带有 `w:val="solid"` 的 `<w:shd>` 会以 100% 覆盖率绘制**前景**。`fill`
> 属性是**背景**（位于前景之后）。当 `color` 未设置时，前景默认为 `auto`，而 Word 会将其渲染为**黑色**，
> 从而完全遮住 `fill`。如果要设置段落背景色，请使用 **`type: 'clear'`**（空图案，`fill` 会透出来）。
> 同样的陷阱也存在于 JSON DSL 中；这是 OOXML 的语义问题，不是构建器的 bug。

---

## 示例详解

以下五个示例与 [JSON DSL 参考页](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md#putting-it-together--worked-examples) 中出现的完全相同，这里通过构建器来编写。

### 提示框：带内联装饰的块级段落

```ts
docxNode('hintbox')
  .block()
  .emit(
    paragraph({
      style: 'Hintbox',
      spacing: { before: 240, after: 240 },
      border: {
        top: { style: 'single', size: 1, color: 'B8D8FF', space: 5 },
        bottom: { style: 'single', size: 1, color: 'B8D8FF', space: 5 },
        left: { style: 'single', size: 1, color: 'B8D8FF', space: 5 },
        right: { style: 'single', size: 1, color: 'B8D8FF', space: 5 },
      },
      shading: { type: 'clear', fill: 'E6F3FF' },
    }).children(childrenInline({ marks: 'default' })),
  )
  .toJSON()
```

### 提及：带模板文本和继承标记的内联 TextRun

```ts
docxNode('mention')
  .inline()
  .emit(
    textRun({
      text: template('@{node.attrs.label}'),
      color: ref('node.attrs.color', { default: '4472C4', transform: 'hexNoHash' }),
    }).applyMarks('node'),
  )
  .toJSON()
```

加粗的 mention 会渲染为 `new TextRun({ text: '@alice', color: '4472C4', bold: true })`。

### 标注框：带属性驱动底纹和切换样式的单元格表格

```ts
docxNode('calloutBox')
  .block()
  .emit(
    table({
      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' },
      },
    }).rows(
      tableRow().children([
        tableCell({
          shading: {
            type: 'clear',
            fill: unit('normalizeColor', ref('node.attrs.backgroundColor', { default: 'E6F3FF' })),
          },
          margins: {
            top: unit('pointsToTwips', 8),
            bottom: unit('pointsToTwips', 8),
            left: unit('pointsToTwips', 10),
            right: unit('pointsToTwips', 10),
          },
        }).children([
          paragraph({
            style: switchValue({
              on: ref('node.attrs.variant'),
              cases: { warning: 'CalloutWarning', info: 'CalloutInfo' },
              default: 'Callout',
            }),
          }).children(childrenInline({ marks: 'default' })),
        ]),
      ]),
    ),
  )
  .toJSON()
```

### 代码块：抑制冲突子标记的段落

```ts
docxNode('codeBlock')
  .block()
  .emit(
    paragraph({ style: 'Code' }).children(
      childrenInline({ marks: marks('default').disable('bold', 'italic') }),
    ),
  )
  .toJSON()
```

### 自定义链接：包装 TextRun 的内联 ExternalHyperlink

```ts
docxNode('customLink')
  .inline()
  .emit(
    externalHyperlink({ link: ref('node.attrs.href') }).children([
      textRun({ text: ref('node.attrs.label'), style: 'Hyperlink' }) //
        .applyMarks('node'),
    ]),
  )
  .toJSON()
```

---

## 并排对比：构建器 vs 手写 JSON

构建器生成的 JSON 在传输层面是等价的。下面是同一个 hintbox 规则的两种写法：

**构建器**

```ts
docxNode('hintbox')
  .block()
  .emit(
    paragraph({ style: 'Hintbox' }) //
      .children(childrenInline({ marks: 'default' })),
  )
  .toJSON()
```

**JSON**

```jsonc
{
  "type": "hintbox",
  "nodeKind": "block",
  "render": {
    "emit": {
      "element": "Paragraph",
      "props": { "style": "Hintbox" },
      "children": { "$children": { "as": "inline", "marks": "default" } },
    },
  },
}
```

`builder.toJSON()` 和手写对象在深度上是相等的，这一点已由包自身的 parity tests 验证。请选择最适合你创作环境的写法；线上的格式是相同的。

---

## 与 `cssStyles` 的组合

该构建器与 [`cssStyles`](https://tiptap.zhcndoc.com/conversion/export/docx/css-to-docx.md) 配合得很好，可以清晰地分离职责：

- **CSS** 描述通用选择器样式（标题、段落、列表、引用块），这些样式已经存在于编辑器的样式表中。
- **DSL** 描述自定义节点渲染：任何需要 Tiptap 节点类型、且无法用通用 CSS 选择器表达的内容。

```ts
ExportDocx.configure({
  cssStyles: {
    extractFromDocument: true, // 从当前样式表中提取 .tiptap 规则
    baseFontSize: 16,
  },
  customNodeDsl: {
    dslVersion: '1.0',
    nodes: [
      docxNode('hintbox')
        .block()
        .emit(
          paragraph({
            style: 'Hintbox', // 往返一致性
            border: { top: { style: 'single', size: 1, color: 'B8D8FF', space: 5 } /* ... */ },
            shading: { type: 'clear', fill: 'E6F3FF' },
          }).children(childrenInline({ marks: 'default' })),
        )
        .toJSON(),
    ],
  },
})
```

采用这种方式后，你可以完全移除 `styleOverrides` 块。随附的 `ExportDocxCustomNodeDslBuilder` 演示正是使用这一模式。

---

## 冲突策略和运行时错误

构建器只是一个轻量的编写层，因此 [JSON DSL 参考页面](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md#errors) 中描述的所有运行时约束都完全适用：

- 当两个 API 中都出现相同的 `type` 时，基于函数的 [`customNodes`](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes.md) 会优先生效（每次导出调用中，每个冲突类型只记录一次警告）。
- DSL 错误会返回带有 [结构化信封](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md#error-responses-rest) 的结果，其中包含 `code` + `dslPath` +（对于运行时错误）`nodePath` + `nodeType`。
- [资源上限](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md#resource-limits) 会在编译期和运行时强制执行，可通过 `customNodeDslLimits` 在 `exportDocx` 上调整，也可通过 `DOCX_DSL_MAX_*` 环境变量在 REST 接口上调整。

构建器额外增加了**一项**运行时检查：将 `marks('default')` 构建器传给 `applyMarks` 时，会抛出清晰的错误，而不是生成一个稍后会以 `DOCX_DSL_INVALID_SHAPE` 失败的负载。其余所有内容都会直接流向标准 DSL 编译器。

---

## TypeScript 参考

所有构建器、辅助函数以及属性形状接口都从 `@tiptap-pro/extension-export-docx/dsl` 导出，供直接复用：

```ts
import type {
  // 顶层规则
  CustomNodeRuleBuilder,

  // 元素构建器
  ParagraphBuilder,
  TextRunBuilder,
  ExternalHyperlinkBuilder,
  TableBuilder,
  TableRowBuilder,
  TableCellBuilder,

  // 子节点辅助
  ChildrenInlineBuilder,
  ChildrenBlockBuilder,
  ChildrenTableRowBuilder,
  ChildrenTableCellBuilder,

  // 渲染节点抽象
  RenderNodeBuilder,

  // 标记策略链
  MarkPolicyBuilder,

  // 各元素属性接口
  ParagraphProps,
  TextRunProps,
  ExternalHyperlinkProps,
  TableProps,
  TableRowProps,
  TableCellProps,

  // 通用形状
  BorderSide,
  Prop,
} from '@tiptap-pro/extension-export-docx/dsl'
```

`Prop<T>` 是一个辅助类型，使每个叶子节点都可以接受字面量值（`number`、`string` 等）或 `ValueExpr`（`ref(...)`、`template(...)`、`op(...)` 等）。

---

## 相关内容

- [Custom-nodes DSL (JSON reference)](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes-dsl.md)：wire format 的完整规范性描述。builder 只是其上的一层语法糖；有关运行时语义、错误代码和资源限制，请查阅参考文档。
- [Function-based custom nodes](https://tiptap.zhcndoc.com/conversion/export/docx/custom-nodes.md)：仅用于浏览器内自定义的 JavaScript 回调变体。
- [CSS to DOCX](https://tiptap.zhcndoc.com/conversion/export/docx/css-to-docx.md)：与 builder 自然配合，用于通用与自定义的分离。
- [Style overrides](https://tiptap.zhcndoc.com/conversion/export/docx/styles.md)：手动命名样式声明。对于希望向 Word 的样式选择器公开的字符/段落样式，它仍然有用；但仅仅用于装饰自定义节点时已不再需要。
- [DOCX REST API](https://tiptap.zhcndoc.com/conversion/export/docx/rest-api.md)：相同的 `customNodeDsl` 载荷，通过 HTTP 发送。
