zh CN DSL 语法 - chiba233/yumeDSL GitHub Wiki
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-z、A-Z、0-9、_、-,首字符不能是数字或 -。
✅ bold myTag h1 code_block custom-tag
❌ 1tag -tag
想允许冒号、点号等?→ 自定义标签名字符
$$tagName(content)$$
最常用的形式。内容递归解析,标签可以嵌套:
$$bold(Hello $$italic(beautiful)$$ world)$$
→ bold 里面套了一个 italic,完全合法。想嵌多深嵌多深(默认上限 50 层)。
1.3 起 — 需要启用
implicitInlineShorthand选项。
在 inline 参数区内,已注册的标签名可以用更短的 name(...) 形式——无需 $$ 前缀:
$$bold(Hello italic(world))$$
等价于:
$$bold(Hello $$italic(world)$$)$$
规则:
- 仅在 inline 参数区内生效,顶层文本不受影响。
- 完整 DSL 语法(
$$tag(...)$$)始终优先于简写。 - 简写参数中的字面括号需要转义:
\(和\)。 - 仅
handlers中已注册且支持 inline form 的标签会被识别为简写。
解析器是一个栈顶优先的步进状态机。逐字符从左往右扫描,始终只看当前栈顶帧的关闭 token。两条核心规则:
- 就近匹配(nearest match):栈顶帧优先消费 token。匹配到就关闭,匹配不到就跳过当文本。不预判未来,不做全局前瞻。
-
当前位置 token 竞争时,长 token 优先:当简写的关闭 token(
))恰好是完整 endTag()$$)的前缀,且当前位置完整 endTag 命中时,endTag 优先——简写让步,降级为纯文本。这是词法层的局部判优,不是语法层的前瞻。
简写的关闭 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(...)这一路径。
| 输入 | 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>から>=散歩しましょう |
说明:错误输入的恢复目标是“局部成功、局部失败”的最近边界恢复;不承诺与历史版本的错误输入输出逐字符一致。
输入:
=bold<天気がbold<いlink<baidu.com>=>から>=散歩しましょう期望(当前规范):
天気がbold<いlink<baidu.com>から>=散歩しましょう直觉上 link<baidu.com> 看起来是一个完整的 shorthand 标签。但按规则推导:
-
baidu.com后面的>=是完整 endTag,不是”link 的>+ 下一个=”。 -
完整闭合优先 —
>=不可拆分,归属外层=bold<...>=,外层关闭。 - 内层
bold<和link<都没有自己的独立关闭符 → 局部降级为文本。
如果反过来让 > 先赢(让 link 关闭),则 shorthand 关闭后变成 root 上下文——但 root 不能有 shorthand,整个推导链自相矛盾。*
endTag 优先不是偏好选择,是逻辑上唯一自洽的规则。*
用户要让 link 正常关闭,只需在 > 和 = 之间加空格,使其不构成 endTag:
=bold<天気がbold<いlink<baidu.com> =>から>=散歩しましょう规则明确,用户可控。
详见 ParseOptions — implicitInlineShorthand 了解配置方式。
$$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) {...}"。
$$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$$ 输出 |
| 写法 | 输出 | 干嘛用 |
|---|---|---|
\( |
( |
防止被当成标签开头 |
\) |
) |
防止被当成标签关闭 |
\)$$ |
)$$ |
防止被当成结束标签 |
| |
| |
防止被当成参数分隔符 |
\\ |
\ |
输出一个字面反斜杠 |
| 写法 | 输出 | 干嘛用 |
|---|---|---|
\( |
( |
防止被当成标签开头 |
\)$$ |
)$$ |
防止被当成结束标签 |
\*end$$ |
*end$$ |
防止被当成 block 关闭标记 |
| 写法 | 输出 | 干嘛用 |
|---|---|---|
\( |
( |
防止被当成标签开头 |
\)$$ |
)$$ |
防止被当成结束标签 |
注意: 在 root 中
\|、\\、\%end$$、\*end$$等写法不会被转义——反斜杠原样保留,因为这些 token 在 root 没有结构语义。
转义字符本身可以通过 SyntaxConfig.escapeChar 换成别的。详见 自定义语法。
解析器在任何异常情况下都不会崩溃或抛出异常。所有不符合预期的输入都会被降级为字面纯文本,并在可能时报告错误。
所有降级行为都由两条核心规则推导而出,没有特判:
- 就近匹配 — 栈顶帧优先消费 token。关闭符归属最近的匹配帧,不跳过、不前瞻。
-
完整闭合优先 — 当短 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 是空对象 {}
|
允许(透传) |
这是最常见的"坑"。 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}),
},
};注意:
inlinehandler 不需要做什么复杂逻辑——它只是一个"门票",让解析器允许该标签进入 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 修复)。
当参数区域内的括号不配平时(如内容中包含裸括号、或漏写闭合括号),解析器无法通过快速扫描找到参数区结束位置。此时:
- 不会把整个标签退化为纯文本
- 强制进入 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