zh CN DSL 语法 - chiba233/yumeDSL GitHub Wiki

DSL 语法

快速开始 | API 参考

yume-dsl-rich-text 的标签语法一共三种形式。记住下面这张图就够了:


三种标签形式

┌─ Inline ──────────────────────────────────┐
│  $$bold(Hello $$italic(world)$$)$$        │
│                                           │
│  → 在文本流中开闭                           │
│  → 内容递归解析(嵌套标签会被识别)            │
│  → 最常用:粗体、斜体、链接                   │
│                                           │
│  简写(1.3):$$bold(Hello italic(world))$$ │
│  → 需启用 implicitInlineShorthand          │
│  → 仅 `)` 关闭,无需 `$$` 包裹              │
└───────────────────────────────────────────┘

┌─ Raw ─────────────────────────────────────┐
│  $$code(typescript)%                      │
│  const x = 1;                             │
│  %end$$                                   │
│                                           │
│  → 内容不解析,原样保留                      │
│  → 关闭标记 %end$$ 必须独占一行              │
│  → 适合代码块、数学公式                      │
└───────────────────────────────────────────┘

┌─ Block ───────────────────────────────────┐
│  $$info(Note)*                            │
│  This is $$bold(important)$$ info.        │
│  *end$$                                   │
│                                           │
│  → 内容递归解析(和 inline 一样)             │
│  → 关闭标记 *end$$ 必须独占一行              │
│  → 适合提示框、警告栏、折叠区块               │
└───────────────────────────────────────────┘

一句话对比

形式 语法 内容递归解析? 关闭方式 典型用途
Inline $$tag(content)$$ )$$ 在文本流中 粗体、链接、行内标注
Inline 简写 tag(content) ) 在文本流中 同上(需启用 implicitInlineShorthand,1.3 起)
Raw $$tag(arg)% ... %end$$ %end$$ 独占一行 代码、数学公式
Block $$tag(arg)* ... *end$$ *end$$ 独占一行 提示框、折叠区块

标签名

允许:a-zA-Z0-9_-,首字符不能是数字或 -

✅ bold  myTag  h1  code_block  custom-tag
❌ 1tag  -tag

想允许冒号、点号等?→ 自定义标签名字符


Inline 标签

$$tagName(content)$$

最常用的形式。内容递归解析,标签可以嵌套:

$$bold(Hello $$italic(beautiful)$$ world)$$

→ bold 里面套了一个 italic,完全合法。想嵌多深嵌多深(默认上限 50 层)。

隐式 inline 简写

1.3 起 — 需要启用 implicitInlineShorthand 选项。

在 inline 参数区内,已注册的标签名可以用更短的 name(...) 形式——无需 $$ 前缀:

$$bold(Hello italic(world))$$

等价于:

$$bold(Hello $$italic(world)$$)$$

规则:

  • 仅在 inline 参数区内生效,顶层文本不受影响。
  • 完整 DSL 语法($$tag(...)$$)始终优先于简写。
  • 简写参数中的字面括号需要转义:\(\)
  • handlers 中已注册且支持 inline form 的标签会被识别为简写。

Token 竞争与就近匹配

解析器是一个栈顶优先的步进状态机。逐字符从左往右扫描,始终只看当前栈顶帧的关闭 token。两条核心规则:

  1. 就近匹配(nearest match):栈顶帧优先消费 token。匹配到就关闭,匹配不到就跳过当文本。不预判未来,不做全局前瞻。
  2. 当前位置 token 竞争时,长 token 优先:当简写的关闭 token())恰好是完整 endTag()$$)的前缀,且当前位置完整 endTag 命中时,endTag 优先——简写让步,降级为纯文本。这是词法层的局部判优,不是语法层的前瞻。

为什么需要 token 判优?

简写的关闭 token ) 是完整关闭 token )$$ 的前缀。如果不做判优,简写会把 )$$ 拆成 ) + $$,破坏外层标签的关闭 token 原子性。

$$bold(bold(hi)$$)$$
                ^
简写 bold( 的 ) 恰好是 )$$ 的一部分
→ 如果简写抢走 ),)$$ 被拆开,外层 $$bold(...) 永远关不上
→ 判优:)$$ 完整命中,简写让步,整段 bold(hi 降级为文本
→ 外层 $$bold(...) 正常关闭 ✅

让步后的重扫

简写让步时,标签头降级为纯文本,父帧从简写的 argStart 位置重新扫描。这意味着父帧和简写消费的是同一个 )$$

$$bold(bold(hi)$$)$$
$$bold(   → push bold (wants )$$)
bold(     → push shorthand (wants ))
hi        → text
)$$       → shorthand 想要 ),但 )$$ 完整命中
           → 让步,"bold(" 降级为文本,bold 从 argStartI 重扫
hi        → text(bold 重扫)
)$$       → bold 关闭 ✓(同一个 )$$)
)$$       → 孤立关闭标记,纯文本

闭合的是 $$bold(bold(hi)$$,内容为 bold(hi。尾部 )$$ 是多余的孤立标记。

) 不与 )$$ 重叠时——简写正常关闭

$$bold(bold(hi $$italic(world)$$))$$
$$bold(     → push bold (wants )$$)
bold(       → push shorthand (wants ))
hi          → text
$$italic(   → push italic (wants )$$)
world       → text
)$$         → italic 关闭 ✓
)           → shorthand 想要 ),后面是 )$$ 不是 $$
             → 当前位置 ) 不构成 )$$ → shorthand 正常关闭 ✓
)$$         → bold 关闭 ✓

结果:bold → [shorthand bold → [text "hi ", italic("world")]]。三层都正确闭合。

简写让步后重扫——两个 )$$ 各归其主

$$bold(bold(hi $$italic(world)$$)$$

注意尾部结构:world + )$$(italic 的关闭)+ )$$(bold 的关闭)。

$$bold(     → push bold (wants )$$)
bold(       → push shorthand (wants ))
hi          → text
$$italic(   → push italic (wants )$$)
world       → text
)$$         → italic 关闭 ✓
)$$         → shorthand 想要 ),但 )$$ 完整命中
             → 让步,"bold(" 降级为文本,bold 从 argStartI 重扫
hi          → text(bold 重扫)
$$italic(   → push italic (wants )$$)(bold 重扫识别到同一段)
world       → text
)$$         → italic 关闭 ✓(第一个 )$$)
)$$         → bold 关闭 ✓(第二个 )$$)

结果:bold 内容为 "bold(hi " + italic("world")。shorthand 降级为文本,italic 被重新解析,两个 )$$ 分别关闭了 italic 和 bold。

简写互相嵌套

$$bold(a(b(c)))$$
$$bold(   → push bold (wants )$$)
a(        → push shorthand a (wants ))
b(        → push shorthand b (wants ))
c         → text
)         → b 关闭 ✓(后面是 ))$$,不构成 )$$)
)         → a 关闭 ✓(后面仍是 ))$$,不构成 )$$)
)$$       → bold 关闭 ✓

每层的 ) 都不与 )$$ 重叠,各自就近匹配,完美。

经典案例(按当前版本实测)

以下案例均基于 parseRichText + extractText 实测。

默认语法($$ / ) / )$$

输入 implicitInlineShorthand=false implicitInlineShorthand=true
$$bold(bold(1))$$ bold(1) 1
$$bold(Hello italic(world))$$ Hello italic(world) Hello world
$$bold(天気がbold(い$$italic(い)$$)から)$$散歩しましょう 天気がbold(いい)から散歩しましょう 天気がいいから散歩しましょう

说明:默认语法下,shorthand 仅在 inline 参数区生效;开关只影响 name(...) 这一路径。

自定义语法(tagPrefix="=", tagOpen="<", tagClose=">", endTag=">="

输入 implicitInlineShorthand=false implicitInlineShorthand=true
=bold<bold<>= bold< bold<
=bold<bold<1>>= bold<1> 1
=bold<bold<=bold<>=>= bold< bold<
=bold<天気がbold<い=italic<い>=>から>=散歩しましょう 天気がbold<いい>から散歩しましょう 天気がいいから散歩しましょう
=bold<天気がbold<いlink<baidu.com>=>から>=散歩しましょう 天気がbold<いlink<baidu.com>から>=散歩しましょう 天気がbold<いlink<baidu.com>から>=散歩しましょう

说明:错误输入的恢复目标是“局部成功、局部失败”的最近边界恢复;不承诺与历史版本的错误输入输出逐字符一致。

Ownership 经典毒样本(必须让 shorthand 失效)

输入:

=bold<天気がbold<いlink<baidu.com>=>から>=散歩しましょう

期望(当前规范):

天気がbold<いlink<baidu.com>から>=散歩しましょう

直觉上 link<baidu.com> 看起来是一个完整的 shorthand 标签。但按规则推导:

  1. baidu.com 后面的 >= 是完整 endTag,不是”link 的 > + 下一个 =”。
  2. 完整闭合优先>= 不可拆分,归属外层 =bold<...>=,外层关闭。
  3. 内层 bold<link< 都没有自己的独立关闭符 → 局部降级为文本

如果反过来让 > 先赢(让 link 关闭),则 shorthand 关闭后变成 root 上下文——但 root 不能有 shorthand,整个推导链自相矛盾。* endTag 优先不是偏好选择,是逻辑上唯一自洽的规则。*

用户要让 link 正常关闭,只需在 >= 之间加空格,使其不构成 endTag:

=bold<天気がbold<いlink<baidu.com> =>から>=散歩しましょう

规则明确,用户可控。

详见 ParseOptions — implicitInlineShorthand 了解配置方式。


Raw 标签

$$tagName(arg)%
raw content — 不会被解析
就算写 $$fake(tags)$$ 也原样保留
%end$$

括号里的 arg 是可选参数(比如代码语言名)。%end$$ 必须独占一行。

例子——代码块:

$$code(typescript)%
function greet(name: string) {
    return `Hello, ${name}!`;
}
%end$$

handler 收到:arg = "typescript"content = "function greet(name: string) {...}"


Block 标签

$$tagName(arg)*
内容会递归解析——可以在里面用其他标签
*end$$

*end$$ 必须独占一行。用于大块的结构化容器。

例子——信息提示框:

$$info(Note)*
This is $$bold(important)$$ information.
*end$$

管道参数

在标签的括号里,用 | 分隔参数:

$$link(Click here | https://example.com)$$
$$link(Click here | https://example.com | _blank)$$

raw/block 标签也能用:

$$code(typescript | line-numbers)%
const x = 1;
%end$$

handler 侧用 parsePipeArgs / PipeArgs 拆分参数。

转义管道

字面的 |\| 转义:

$$tag(a\|b | c)$$
→ 两个参数:  "a|b" 和 "c"

转义序列

反斜杠 \ 是默认转义字符。转义只对当前上下文中有结构语义的 token 生效——在某个位置没有特殊含义的 token,反斜杠不会被消费,原样保留。

按上下文的转义范围

默认语法下,tagOpen = (tagClose = )endTag = )$$$$ 是 tagPrefix,不是独立 token,不可转义。

上下文 可转义 token 说明
Root(顶层文本) ((tagOpen)、)(tagClose)、)$$(endTag) 只有能开启或关闭标签的 token 需要转义。|\\ 等在 root 无特殊含义,反斜杠原样保留
Args(标签参数区) tagOpen(()、tagClose())、endTag()$$)、tagDivider(|)、escapeChar(\\)、rawOpen()%)、blockOpen()* 参数区语法最丰富,支持的转义最多。但 rawClose(%end$$)和 blockClose(*end$$)在参数区无意义,不可转义
Block content(block 标签体) tagOpen(()、tagClose())、endTag()$$)、blockClose(\*end$$ block 体内递归解析,需要转义结构 token + 自身的关闭标记
Raw content rawClose(\%end$$ parseStructural 不会在 raw 内生成 escape 节点——内容原样保留。parseRichText 会识别 \%end$$ 并将其转义为 %end$$ 输出

参数区常用转义

写法 输出 干嘛用
\( ( 防止被当成标签开头
\) ) 防止被当成标签关闭
\)$$ )$$ 防止被当成结束标签
| | 防止被当成参数分隔符
\\ \ 输出一个字面反斜杠

Block 体内常用转义

写法 输出 干嘛用
\( ( 防止被当成标签开头
\)$$ )$$ 防止被当成结束标签
\*end$$ *end$$ 防止被当成 block 关闭标记

Root 常用转义

写法 输出 干嘛用
\( ( 防止被当成标签开头
\)$$ )$$ 防止被当成结束标签

注意: 在 root 中 \|\\\%end$$\*end$$ 等写法不会被转义——反斜杠原样保留,因为这些 token 在 root 没有结构语义。

转义字符本身可以通过 SyntaxConfig.escapeChar 换成别的。详见 自定义语法


优雅降级规则

解析器在任何异常情况下都不会崩溃或抛出异常。所有不符合预期的输入都会被降级为字面纯文本,并在可能时报告错误。

降级原则

所有降级行为都由两条核心规则推导而出,没有特判:

  1. 就近匹配 — 栈顶帧优先消费 token。关闭符归属最近的匹配帧,不跳过、不前瞻。
  2. 完整闭合优先 — 当短 token(如 ))恰好是长 token(如 )$$)的前缀,且长 token 在当前位置完整命中时,长 token 赢。

由此派生出降级策略:局部错误,局部降级

  • 写对的部分保留结构,写错的部分仅在出错的那一层降级为纯文本。
  • 错误的爆炸半径被精确控制在最近一层,不波及父级或兄弟节点。
  • 解析器不猜测用户意图,不做"最大化保留"的启发式恢复——确定性优先于表面上的"好看"。

例——shorthand 让步保护外层结构:

$$bold(bold(hi)$$)$$

bold(hi 后面的 )$$ 既是 shorthand 的 ) 也是外层 bold 的 )$$。完整闭合优先 → )$$ 归属外层 bold,shorthand bold( 无法关闭,降级为纯文本。外层正常闭合,输出 bold 内容为 bold(hi。尾部 )$$ 是孤立标记。

如果反过来让 ) 先赢,shorthand 关闭后 )$$ 被拆成 ) + $$,外层 bold 的关闭符被破坏,永远关不上——整棵树崩溃。

总览

情况 行为 错误码
handler 未实现用户写的形式 整段标记降级为纯文本 无(静默降级)
标签未注册(不在 handlers 中) 按 inline 形式尝试解析
嵌套超过 depthLimit 标签头降级为纯文本 DEPTH_LIMIT
括号不配平(漏写闭合括号) 强制进入 inline 子帧逐字符扫描 视内层情况而定
关闭标记缺失(到 EOF 未闭合) 内容回退为纯文本 INLINE_NOT_CLOSED / SHORTHAND_NOT_CLOSED / RAW_NOT_CLOSED / BLOCK_NOT_CLOSED
%end$$ / *end$$ 格式错误 内容回退为纯文本 RAW_CLOSE_MALFORMED / BLOCK_CLOSE_MALFORMED
出现孤立的 )$$ 当纯文本输出 UNEXPECTED_CLOSE
简写 ))$$ 在同一位置竞争 简写让步,标签头降级为纯文本 无(仅让步,不单独报错)

形式不匹配降级

handler 只声明了部分形式时,用户写了未声明的形式 → 整段标记降级为字面文本,不报错。

// handler 只声明了 raw
code: { raw: (arg, content) => ... }

// 用户写了 inline 形式 → 降级为纯文本
$$code(hello world)$$
→ 输出纯文本: "$$code(hello world)$$"

判定规则(supportsInlineForm 决策表,从上往下第一条命中即停止):

条件 结果
全局 allowInline = false 拒绝 inline
handler 不存在 + 标签未注册 允许 inline(透传)
handler 不存在 + 标签已注册 拒绝(被 allowForms 过滤掉了)
handler 声明了 inline 允许
handler 只声明了 raw 和/或 block 拒绝
handler 是空对象 {} 允许(透传)

⚠️ 在 inline 参数区内嵌套 raw / block 标签

这是最常见的"坑"。 raw / block 标签要嵌套在 inline 参数区内,handler 必须同时声明 inline

原因

inline 参数区的子扫描逐字符前进,遇到嵌套标签时先检查该标签是否支持 inline 形式(supportsInlineForm )。只有通过检查的标签才能进入子帧;进入子帧后,解析器才有机会看到 )%)* 从而切换到 raw / block 分支。

                    ┌── 必须先通过 supportsInlineForm ──┐
                    │                                    │
$$bold( ... $$code(arg)%...%end$$ ... )$$
              │                                    
              └── code 只有 raw,没有 inline            
                  → 进不了子帧 → 整段 $$code(...)%...%end$$ 变纯文本

错误示范

// ❌ code 只有 raw → 嵌套在 inline 参数区内时全部降级为纯文本
const handlers = {
    bold: {inline: (tokens) => ({type: "bold", value: tokens})},
    code: {raw: (arg, content) => ({type: "code", value: content})},
};

// $$bold(前面 $$code(ts)%const x = 1;%end$$ 后面)$$
// → bold 的 children 里 code 那段变成了纯文本

正确做法

// ✅ code 加上 inline 声明 → 解析器能进入子帧,然后识别 )% 切换到 raw
const handlers = {
    bold: {inline: (tokens) => ({type: "bold", value: tokens})},
    code: {
        inline: (tokens) => ({type: "code", value: tokens}),  // ← 加这个
        raw: (arg, content) => ({type: "code", value: content}),
    },
};

注意: inline handler 不需要做什么复杂逻辑——它只是一个"门票",让解析器允许该标签进入 inline 子帧。如果你的标签在语义上不该有 inline 形式,可以让 inline handler 返回一个降级输出(比如原样输出文本)。

同理,block 标签要嵌套在 inline 参数区内,也需要声明 inline

// ✅ warn 同时声明了 inline 和 block → 可以嵌套在 inline 参数区内使用 block 形式
warn: {
    inline: (tokens) => ({type: "warn", value: tokens}),
        block
:
    (arg, content) => ({type: "warn", value: content}),
}

嵌套深度限制

超过 depthLimit(默认 50)时,标签头降级为纯文本,报告 DEPTH_LIMIT 错误。外层标签不受影响。

// depthLimit: 3 时
$$a($$b($$c($$d(too deep)$$)$$)$$)$$
                 ^^^^^^^^^^^^^^^^
                 这段降级为纯文本 "$$d(too deep)$$"

shorthand 形式同样受 depthLimit 约束(1.3.1 修复)。


括号不配平(1.3.1 修复)

当参数区域内的括号不配平时(如内容中包含裸括号、或漏写闭合括号),解析器无法通过快速扫描找到参数区结束位置。此时:

  • 不会把整个标签退化为纯文本
  • 强制进入 inline 子帧,逐字符扫描寻找真正的关闭标记()$$
  • 只有最内层不配平的标签受影响,外层标签完整保留
$$bold(Hello $$italic(world)$$)$$
→ 正常解析 ✅

$$bold(Hello $$italic(world)$$ )$$
                               ^ 多了空格但括号配平 → 正常 ✅

$$bold(some text with ( inside)$$
                       ^ 裸括号 → 强制 inline fallback → bold 仍然正确关闭 ✅

未闭合标签

标签打开后到 EOF 都没有找到关闭标记:

  • inline:内容回退为纯文本,报告 INLINE_NOT_CLOSED
  • raw:内容回退为纯文本,报告 RAW_NOT_CLOSED
  • block:内容回退为纯文本,报告 BLOCK_NOT_CLOSED
  • shorthand:内容回退为纯文本,报告 SHORTHAND_NOT_CLOSED(若外层 full-form 同时未闭合,可能与 INLINE_NOT_CLOSED 同时出现)
$$bold(never closed
→ 纯文本: "$$bold(never closed"
→ 错误: INLINE_NOT_CLOSED

关闭标记格式错误

%end$$ / *end$$ 如果不在独占一行的位置:

$$code(ts)%
const x = 1;  %end$$     ← 不在行首
%end$$                     ← 这才对

报告 RAW_CLOSE_MALFORMED / BLOCK_CLOSE_MALFORMED,内容回退为纯文本。


孤立关闭标记

出现在没有对应开头的 )$$

Hello )$$ world
→ 纯文本: "Hello )$$ world"
→ 错误: UNEXPECTED_CLOSE
⚠️ **GitHub.com Fallback** ⚠️