zh CN 教程 link 标签 - chiba233/yumeDSL GitHub Wiki
本教程将带你从一个空项目开始,一步一步实现 $$link(url | display text)$$ 标签。每一步都会解释为什么这样做,而不仅仅是怎么做。读完之后,你会理解解析器如何识别标签、处理器如何赋予标签语义、管道工具函数如何拆分参数。
前置要求: 你了解 TypeScript 和 npm,但从未使用过 yume-dsl-rich-text。
我们要实现的标签在源文本中长这样:
$$link(https://example.com | click here)$$
解析后产出的 token 结构如下:
{
"type": "link",
"url": "https://example.com",
"value": [
{ "type": "text", "value": "click here", "id": "rt-1" }
],
"id": "rt-0"
}渲染器(由你自己编写)可以把这个 token 转换成 HTML:
<a href="https://example.com">click here</a>解析器的职责是生成 token 树。渲染永远是你的事。这种分离是刻意的设计:同一棵 token 树可以驱动 Vue 组件、React 元素、纯 HTML 字符串、终端 ANSI 输出,或者其他任何目标。
mkdir link-tag-tutorial && cd link-tag-tutorial
npm init -y
npm install yume-dsl-rich-text如果你使用 TypeScript(推荐),还需要安装 tsx 以便直接运行 .ts 文件:
npm install -D typescript tsx创建 tutorial.ts:
import { createParser } from "yume-dsl-rich-text";
// 创建一个完全没有处理器的解析器
const dsl = createParser({
handlers: {},
});
const tokens = dsl.parse("Hello, world!");
console.log(JSON.stringify(tokens, null, 2));运行:
npx tsx tutorial.ts[
{ "type": "text", "value": "Hello, world!", "id": "rt-0" }
]整个输入变成了一个文本 token。为什么? 因为解析器没有注册任何处理器。当它在输入中遇到 $$ 时,会检查 $$ 后面的字符是否构成一个存在于 handlers 映射中的标签名。映射为空,什么也匹配不上,所以 $$ 被当作普通文本收入文本缓冲区。
这是一个重要原则:未识别的标签会优雅降级为纯文本。解析器永远不会因为遇到未知标签而抛出错误,它只是把这些字符原样传递。
现在试试解析一段看起来像标签的文本:
const tokens2 = dsl.parse("Visit $$link(https://example.com)$$ today");
console.log(JSON.stringify(tokens2, null, 2));输出:
[
{ "type": "text", "value": "Visit $$link(https://example.com)$$ today", "id": "rt-0" }
]仍然是纯文本。解析器看到了 $$link(,但 "link" 不在处理器映射中,所以整个序列被当作普通字符处理。没有错误,没有崩溃——只是文本。
在挑战 link 之前,先从更简单的 bold 标签开始。这样可以在不涉及管道参数的情况下单独观察解析器的工作机制。
更新 tutorial.ts:
import {
createParser,
createSimpleInlineHandlers,
} from "yume-dsl-rich-text";
const dsl = createParser({
handlers: {
...createSimpleInlineHandlers(["bold"]),
},
});
const tokens = dsl.parse("This is $$bold(important)$$ text.");
console.log(JSON.stringify(tokens, null, 2));[
{ "type": "text", "value": "This is ", "id": "rt-0" },
{
"type": "bold",
"value": [
{ "type": "text", "value": "important", "id": "rt-2" }
],
"id": "rt-1"
},
{ "type": "text", "value": " text.", "id": "rt-3" }
]现在有三个 token。来理解 TextToken 的每个字段:
| 字段 | 类型 | 含义 |
|---|---|---|
type |
string |
语义类型。纯文本为 "text",其他由处理器返回(这里是 "bold")。 |
value |
string | TextToken[] |
"text" token 的 value 是字符串;标签 token 的 value 是子 token 数组(括号内解析出的内容)。 |
id |
string |
此 token 在本次解析中的唯一标识。默认格式为 rt-0、rt-1 等。可用于 UI 元素的 key(React 的 key、Vue 的 :key)。 |
为什么 bold 的 value 是数组? 因为 $$bold(...)$$ 括号内的内容会被递归解析。"important" 这个词变成了 bold token 内部的一个文本 token。如果你写 $$bold(very $$italic(important)$$)$$,bold token 的 value 数组里会包含一个文本 token("very ")和一个 italic token,而 italic token 的 value 又包含 "important"。层层嵌套,全是树。
这是本教程最重要的部分。让我们逐字符追踪解析器的行为。理解了这些,后面一切都会豁然开朗。
输入是:This is $$bold(hello)$$ text.
阶段 1 —— 扫描普通文本
解析器维护一个光标位置(i)和一个文本缓冲区。它逐字符遍历输入:
-
i=0:T—— 不是特殊序列,追加到缓冲区。 -
i=1:h—— 同上。 - ……一直到
This is扫描完毕…… -
i=8:$—— 这是tagPrefix($$)的第一个字符。解析器检查:当前位置是否出现了$$?是的。这可能是一个标签吗?
阶段 2 —— 尝试标签识别
- 解析器读取
$$之后的字符:b、o、l、d。这些都是有效的标签名字符(默认为小写 ASCII 字母)。 -
i=14:(—— 这是tagOpen。解析器现在确认这是一个标签开头,标签名为"bold"。 -
关键检查:
"bold"是否在handlers映射中?是的——我们用createSimpleInlineHandlers注册了它。如果没有注册,解析器会放弃标签尝试,把$$bold(当作普通字符放入文本缓冲区。
阶段 3 —— 寻找匹配的闭合标记
- 内容从位置 15(
(之后)开始。 - 解析器向前扫描,寻找闭合标记
)$$(endTag),同时追踪嵌套括号。如果你写了$$bold(a (parenthesized) note)$$,解析器不会被内部的)误导——它会追踪深度。 - 在位置 20 找到
)$$。(和)$$之间的内容是"hello"。
阶段 4 —— 递归解析内容
- 解析器用相同的处理器和配置递归解析内容
"hello"。由于"hello"不包含任何标签语法,产出一个文本 token:{ type: "text", value: "hello" }。
阶段 5 —— 调用处理器
- 解析器查找
"bold"处理器并调用其inline方法,传入递归解析得到的 token 数组和DslContext。 -
createSimpleInlineHandlers创建的处理器是这样的:inline: (tokens, ctx) => ({ type: "bold", value: materializeTextTokens(tokens, ctx), })
-
materializeTextTokens会对文本叶子节点中的 DSL 转义序列进行反转义(例如\|变成|)。对于纯文本"hello",什么都不会变。 - 处理器返回一个
TokenDraft:{ type: "bold", value: [{ type: "text", value: "hello", id: "rt-2" }] }。
阶段 6 —— 包装并继续
- 解析器给 draft 分配一个
id,生成完整的TextToken。 - 它把文本缓冲区(
"This is ")刷新为一个文本 token,追加 bold token,然后从)$$之后继续扫描。 - 剩余的
text.变成另一个文本 token。
为什么这很重要: 解析器的标签识别完全依赖于 handlers 映射中有哪些名称。语法符号($$、(、)$$)是固定的,但标签名是你定义的。注册了 "bold" 解析器才能看到 bold 标签。不注册的话,同样的字符就是纯文本。
现在开始构建 link。先写一个简单版本,把整个内容当作 URL,暂时不做管道拆分。
import {
createParser,
extractText,
materializeTextTokens,
} from "yume-dsl-rich-text";
const dsl = createParser({
handlers: {
link: {
inline: (tokens, ctx) => ({
type: "link",
url: extractText(tokens),
value: materializeTextTokens(tokens, ctx),
}),
},
},
});
const tokens = dsl.parse("Visit $$link(https://example.com)$$");
console.log(JSON.stringify(tokens, null, 2));输出:
[
{ "type": "text", "value": "Visit ", "id": "rt-0" },
{
"type": "link",
"url": "https://example.com",
"value": [
{ "type": "text", "value": "https://example.com", "id": "rt-2" }
],
"id": "rt-1"
}
]发生了什么:
-
extractText(tokens)遍历 token 树,把所有文本内容拼成一个扁平字符串。我们用它获取url字段,因为 URL 是纯字符串。 -
materializeTextTokens(tokens, ctx)返回对转义序列做了反转义的 token 数组。我们用它获取value,因为显示文本将来可能包含嵌套标签。
问题在于:url 和 value 是一样的。显示文本就是 URL。这对真正的链接没什么用——你需要的是 $$link(https://example.com | click here)$$,让 URL 和显示文本分开。
在 $$tag(...)$$ 内部,| 字符是管道分隔符。它用来分隔参数。例如:
$$link(https://example.com | click here)$$
这里有两个段:
- 段 0:
https://example.com - 段 1:
click here
重要: 解析器本身不会拆分管道。它把 ( 和 )$$ 之间的全部内容作为一个 token 流来解析。| 字符以字面文本的形式出现在你的处理器收到的 token 中。管道拆分发生在你的处理器中,借助工具函数完成。
为什么这样设计? 因为不是每个标签都需要管道拆分。$$bold(hello | world)$$ 标签可能希望 | 就是普通文本。把管道拆分交给处理器,每个标签可以自己决定参数规则。
import {
createParser,
parsePipeArgs,
} from "yume-dsl-rich-text";
const dsl = createParser({
handlers: {
link: {
inline: (tokens, ctx) => {
const args = parsePipeArgs(tokens, ctx);
return {
type: "link",
url: args.text(0),
value: args.materializedTailTokens(1),
};
},
},
},
});
const tokens = dsl.parse("Visit $$link(https://example.com | click here)$$!");
console.log(JSON.stringify(tokens, null, 2));parsePipeArgs(tokens, ctx)
接收原始 token 数组和 DSL 上下文。它扫描文本 token 中未转义的 | 字符,把 token 拆分成多个段(称为 "parts")。返回一个带有便捷访问器的 PipeArgs 对象。
为什么要传 ctx? 因为管道分隔符字符是可以通过语法选项配置的。默认是 |,但如果有人自定义了语法,ctx.syntax.tagDivider 可能不同。传入 ctx 确保使用正确的分隔符。
args.text(0)
获取段 0 的纯文本字符串:已反转义、已去除首尾空白。对于我们的输入,结果是 "https://example.com"。
- "已反转义"意味着 DSL 转义序列已解析:
\|变成|,\\变成\,等等。 - "已去除首尾空白"意味着前后的空格被去掉了。原始段内容是
"https://example.com "(|前面有一个尾部空格),但.text()会 trim 掉。
args.materializedTailTokens(1)
获取从段 1 开始往后的所有内容,作为已物化(已反转义)的 token 数组。"Tail" 意味着它收集段 1、段 2、段 3 等等,并将它们扁平化为一个 token 数组。
为什么是 "tail" 而不是只取段 1? 在更丰富的设计中,显示文本本身可能包含管道。例如,想象一个 tooltip 标签:$$tooltip(hover text | line one | line two)$$,其中段 1 及之后的所有段都是 tooltip 内容。materializedTailTokens(1) 能干净地处理这种模式。
对于我们的 link 标签,显示文本是单个段,所以 materializedTailTokens(1) 和 materializedTokens(1) 产出相同的结果。但使用 tail 版本是个好习惯。
[
{ "type": "text", "value": "Visit ", "id": "rt-0" },
{
"type": "link",
"url": "https://example.com",
"value": [
{ "type": "text", "value": "click here", "id": "rt-3" }
],
"id": "rt-1"
},
{ "type": "text", "value": "!", "id": "rt-4" }
]url 是干净的字符串 "https://example.com"。value 数组包含显示文本的 token。这正是我们想要的。
如果有人写了 $$link(https://example.com)$$,没有管道怎么办?
const tokens = dsl.parse("$$link(https://example.com)$$");在这种情况下,parsePipeArgs 找不到 |,所以只有一个段(索引 0)。当你调用:
-
args.text(0)—— 返回"https://example.com"(URL,正常工作) -
args.text(1)—— 返回""(没有段 1,回退为空字符串) -
args.materializedTailTokens(1)—— 返回[](从索引 1 开始没有段,回退为空数组)
所以 value 会是空数组 []。这可能不是你想要的——如果没有显示文本,URL 本身应该作为显示文本。
link: {
inline: (tokens, ctx) => {
const args = parsePipeArgs(tokens, ctx);
const url = args.text(0);
const displayTokens = args.materializedTailTokens(1);
return {
type: "link",
url,
value: displayTokens.length > 0
? displayTokens
: args.materializedTokens(0),
};
},
},现在没有管道时,value 回退为段 0 的物化 token(即 URL 本身)。$$link(https://example.com)$$ 的输出变为:
{
"type": "link",
"url": "https://example.com",
"value": [
{ "type": "text", "value": "https://example.com", "id": "rt-1" }
],
"id": "rt-0"
}如果 URL 本身包含 | 字符怎么办?(有些 API 在查询字符串中使用管道。)
$$link(https://example.com/api?a=1\|b=2 | API docs)$$
\| 是转义管道。解析器把它保留在 token 流中,形式是 \|(转义字符后跟 |)。当 parsePipeArgs 扫描分隔符时,它识别 \| 为转义序列,不会在此处拆分。只有 URL 和 "API docs" 之间那个未转义的 | 才会触发拆分。
然后当你调用 args.text(0) 时,反转义步骤把 \| 转换为 |,得到干净的 URL:"https://example.com/api?a=1|b=2"。
你不需要做任何特殊处理。 转义处理内置于解析器和管道工具函数中。用户写 \| 来表示字面管道,它就能正常工作。
显示文本可以包含其他标签:
$$link(https://example.com | click $$bold(here)$$)$$
因为解析器会递归解析 $$link(...)$$ 括号内的内容,所以 $$bold(here)$$ 会变成 link 内容 token 中的一个 bold token。当 parsePipeArgs 按管道拆分时,bold token 落入段 1(它不是文本 token,所以永远不会在里面搜索 |)。最终的 value 数组是:
[
{ "type": "text", "value": "click ", "id": "rt-3" },
{
"type": "bold",
"value": [
{ "type": "text", "value": "here", "id": "rt-5" }
],
"id": "rt-4"
}
]嵌套标签自然就能工作——不需要额外的处理。
第 4-6 步的手写工作是很好的学习练习,但在生产代码中你可以使用 createPipeHandlers 辅助函数。它帮你包装了 parsePipeArgs 的样板代码。
import {createParser, createPipeHandlers,} from "yume-dsl-rich-text";
const dsl = createParser({
handlers: {
...createPipeHandlers({
link: {
inline: (args, ctx) => ({
type: "link",
url: args.text(0),
value: args.materializedTailTokens(1),
}),
},
}),
},
});发生了什么变化:
- 原来写
inline: (tokens, ctx) => { const args = parsePipeArgs(tokens, ctx); ... },现在只需写inline: (args, ctx) => { ... }。 -
createPipeHandlers替你调用parsePipeArgs,并把结果作为第一个参数传入。 - 处理器定义使用
PipeHandlerDefinition接口,接受inline、raw和block方法,它们直接接收PipeArgs。
如果你想要第 6.2 步中 URL 作为显示文本的回退逻辑:
...createPipeHandlers({
link: {
inline: (args, ctx) => {
const url = args.text(0);
const display = args.materializedTailTokens(1);
return {
type: "link",
url,
value: display.length > 0 ? display : args.materializedTokens(0),
};
},
},
}),逻辑和手写版本一样,但提取 tokens 和 ctx、调用 parsePipeArgs 的样板代码没有了。
| 场景 | 使用 |
|---|---|
| 标签使用管道分隔的参数 | createPipeHandlers |
| 标签把所有内容当作单一值(无管道) | createSimpleInlineHandlers |
| 标签需要条件逻辑、校验或副作用 | 手写 TagHandler
|
| 标签需要在管道拆分前访问原始 token 数组 | 手写 TagHandler
|
对于大多数基于管道的标签,createPipeHandlers 是正确的选择。
解析器产出 token。渲染是一个独立的关注点。下面是一个最小的渲染器,把 token 树转换为 HTML 字符串——只是为了展示完整的流程。
import type { TextToken } from "yume-dsl-rich-text";
function renderTokens(tokens: TextToken[]): string {
return tokens.map(renderToken).join("");
}
function renderToken(token: TextToken): string {
switch (token.type) {
case "text":
// text token 的 value 是字符串
return escapeHtml(token.value as string);
case "bold":
return `<strong>${renderTokens(token.value as TextToken[])}</strong>`;
case "link":
return `<a href="${escapeAttr(token.url as string)}">${renderTokens(token.value as TextToken[])}</a>`;
default:
// 未知 token 类型——如果有子节点就渲染子节点,否则渲染 value
if (Array.isArray(token.value)) {
return renderTokens(token.value);
}
return escapeHtml(String(token.value));
}
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function escapeAttr(s: string): string {
return escapeHtml(s).replace(/"/g, """);
}const tokens = dsl.parse("Visit $$link(https://example.com | click $$bold(here)$$)$$!");
const html = renderTokens(tokens);
console.log(html);输出:
Visit <a href="https://example.com">click <strong>here</strong></a>!
解析器对 HTML、Vue、React 或任何渲染目标一无所知。这是刻意的设计:
-
框架无关。 同一棵 token 树可以驱动 Vue 递归组件、React
createElement树、HTML 字符串、或终端 ANSI 序列。 -
安全边界。 你的渲染器决定什么是安全的。解析器不会注入 HTML。如果你想清理 URL(例如阻止
javascript:协议),那个逻辑在你的渲染器中,而不是解析器中。 - 可测试性。 你可以独立地对解析器输出(纯 JSON)进行单元测试,与渲染逻辑分开。
生产环境的 Vue 3 渲染器请参见 Vue 3 渲染。
以下是包含回退逻辑的完整、自包含的教程文件:
import {
createParser,
createPipeHandlers,
createSimpleInlineHandlers,
} from "yume-dsl-rich-text";
import type { TextToken } from "yume-dsl-rich-text";
// ── 1. 创建解析器 ──
const dsl = createParser({
handlers: {
...createSimpleInlineHandlers(["bold", "italic"]),
...createPipeHandlers({
link: {
inline: (args, ctx) => {
const url = args.text(0);
const display = args.materializedTailTokens(1);
return {
type: "link",
url,
value: display.length > 0 ? display : args.materializedTokens(0),
};
},
},
}),
},
});
// ── 2. 解析 ──
const input = "Visit $$link(https://example.com | click $$bold(here)$$)$$ for details.";
const tokens = dsl.parse(input);
// ── 3. 渲染(最小 HTML 字符串) ──
function renderTokens(t: TextToken[]): string {
return t.map(renderToken).join("");
}
function renderToken(token: TextToken): string {
switch (token.type) {
case "text":
return token.value as string;
case "bold":
return `<strong>${renderTokens(token.value as TextToken[])}</strong>`;
case "italic":
return `<em>${renderTokens(token.value as TextToken[])}</em>`;
case "link":
return `<a href="${token.url}">${renderTokens(token.value as TextToken[])}</a>`;
default:
return Array.isArray(token.value) ? renderTokens(token.value) : String(token.value);
}
}
console.log(renderTokens(tokens));
// → Visit <a href="https://example.com">click <strong>here</strong></a> for details.-
没有处理器,一切都是文本。 解析器只识别名称出现在
handlers映射中的标签。 -
解析器的职责是识别和递归。 它看到
$$tagName(,匹配标签名,找到匹配的闭合标记,递归解析内容,然后调用你的处理器。 -
处理器赋予语义。 你的处理器决定 token 的
type、它携带哪些字段、以及子内容如何处理。 -
管道是处理器层面的事。 解析器交付原始 token;
parsePipeArgs按|拆分它们;你的处理器决定哪个段是哪个参数。 -
转义序列正确传播。 源文本中的
\|在反转义后变成字面|。解析器和工具函数透明地处理这些。 - 渲染是独立的。 token 树是一个数据结构。它如何变成 HTML、Vue 组件或其他任何东西,完全由你决定。
下一步: