zh CN 教程 link 标签 - chiba233/yumeDSL GitHub Wiki

教程:从零实现 link 标签

<< Home

本教程将带你从一个空项目开始,一步一步实现 $$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 输出,或者其他任何目标。


第 1 步:安装并创建最小解析器

1.1 安装依赖

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

1.2 创建一个没有处理器的解析器

创建 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

1.3 观察输出

[
  { "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" 不在处理器映射中,所以整个序列被当作普通字符处理。没有错误,没有崩溃——只是文本。


第 2 步:先注册一个简单的 inline 标签

在挑战 link 之前,先从更简单的 bold 标签开始。这样可以在不涉及管道参数的情况下单独观察解析器的工作机制。

2.1 添加 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));

2.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-0rt-1 等。可用于 UI 元素的 key(React 的 key、Vue 的 :key)。

为什么 boldvalue 是数组? 因为 $$bold(...)$$ 括号内的内容会被递归解析。"important" 这个词变成了 bold token 内部的一个文本 token。如果你写 $$bold(very $$italic(important)$$)$$,bold token 的 value 数组里会包含一个文本 token("very ")和一个 italic token,而 italic token 的 value 又包含 "important"。层层嵌套,全是树。


第 3 步:理解 $$bold(hello)$$ 内部发生了什么

这是本教程最重要的部分。让我们逐字符追踪解析器的行为。理解了这些,后面一切都会豁然开朗。

输入是:This is $$bold(hello)$$ text.

解析器的视角

阶段 1 —— 扫描普通文本

解析器维护一个光标位置(i)和一个文本缓冲区。它逐字符遍历输入:

  1. i=0T —— 不是特殊序列,追加到缓冲区。
  2. i=1h —— 同上。
  3. ……一直到 This is 扫描完毕……
  4. i=8$ —— 这是 tagPrefix$$)的第一个字符。解析器检查:当前位置是否出现了 $$?是的。这可能是一个标签吗?

阶段 2 —— 尝试标签识别

  1. 解析器读取 $$ 之后的字符:bold。这些都是有效的标签名字符(默认为小写 ASCII 字母)。
  2. i=14( —— 这是 tagOpen。解析器现在确认这是一个标签开头,标签名为 "bold"
  3. 关键检查: "bold" 是否在 handlers 映射中?是的——我们用 createSimpleInlineHandlers 注册了它。如果没有注册,解析器会放弃标签尝试,把 $$bold( 当作普通字符放入文本缓冲区。

阶段 3 —— 寻找匹配的闭合标记

  1. 内容从位置 15(( 之后)开始。
  2. 解析器向前扫描,寻找闭合标记 )$$endTag),同时追踪嵌套括号。如果你写了 $$bold(a (parenthesized) note)$$,解析器不会被内部的 ) 误导——它会追踪深度。
  3. 在位置 20 找到 )$$()$$ 之间的内容是 "hello"

阶段 4 —— 递归解析内容

  1. 解析器用相同的处理器和配置递归解析内容 "hello"。由于 "hello" 不包含任何标签语法,产出一个文本 token:{ type: "text", value: "hello" }

阶段 5 —— 调用处理器

  1. 解析器查找 "bold" 处理器并调用其 inline 方法,传入递归解析得到的 token 数组和 DslContext
  2. createSimpleInlineHandlers 创建的处理器是这样的:
    inline: (tokens, ctx) => ({
      type: "bold",
      value: materializeTextTokens(tokens, ctx),
    })
  3. materializeTextTokens 会对文本叶子节点中的 DSL 转义序列进行反转义(例如 \| 变成 |)。对于纯文本 "hello",什么都不会变。
  4. 处理器返回一个 TokenDraft{ type: "bold", value: [{ type: "text", value: "hello", id: "rt-2" }] }

阶段 6 —— 包装并继续

  1. 解析器给 draft 分配一个 id,生成完整的 TextToken
  2. 它把文本缓冲区("This is ")刷新为一个文本 token,追加 bold token,然后从 )$$ 之后继续扫描。
  3. 剩余的 text. 变成另一个文本 token。

为什么这很重要: 解析器的标签识别完全依赖于 handlers 映射中有哪些名称。语法符号($$()$$)是固定的,但标签名是你定义的。注册了 "bold" 解析器才能看到 bold 标签。不注册的话,同样的字符就是纯文本。


第 4 步:第一个手写处理器——没有管道的 link

现在开始构建 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,因为显示文本将来可能包含嵌套标签。

问题在于:urlvalue 是一样的。显示文本就是 URL。这对真正的链接没什么用——你需要的是 $$link(https://example.com | click here)$$,让 URL 和显示文本分开。


第 5 步:添加管道参数

5.1 管道的工作原理

$$tag(...)$$ 内部,| 字符是管道分隔符。它用来分隔参数。例如:

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

这里有两个段:

  • 段 0:https://example.com
  • 段 1:click here

重要: 解析器本身不会拆分管道。它把 ()$$ 之间的全部内容作为一个 token 流来解析。| 字符以字面文本的形式出现在你的处理器收到的 token 中。管道拆分发生在你的处理器中,借助工具函数完成。

为什么这样设计? 因为不是每个标签都需要管道拆分。$$bold(hello | world)$$ 标签可能希望 | 就是普通文本。把管道拆分交给处理器,每个标签可以自己决定参数规则。

5.2 用 parsePipeArgs 重写处理器

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

5.3 逐行理解

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 版本是个好习惯。

5.4 观察输出

[
  { "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。这正是我们想要的。


第 6 步:处理边界情况

6.1 没有管道——只有 URL

如果有人写了 $$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 本身应该作为显示文本。

6.2 添加回退逻辑

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"
}

6.3 URL 中的转义管道

如果 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"

你不需要做任何特殊处理。 转义处理内置于解析器和管道工具函数中。用户写 \| 来表示字面管道,它就能正常工作。

6.4 显示文本中的嵌套标签

显示文本可以包含其他标签:

$$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"
  }
]

嵌套标签自然就能工作——不需要额外的处理。


第 7 步:改用 createPipeHandlers

第 4-6 步的手写工作是很好的学习练习,但在生产代码中你可以使用 createPipeHandlers 辅助函数。它帮你包装了 parsePipeArgs 的样板代码。

7.1 简洁版本

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 接口,接受 inlinerawblock 方法,它们直接接收 PipeArgs

7.2 添加回退逻辑

如果你想要第 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),
      };
    },
  },
}),

逻辑和手写版本一样,但提取 tokensctx、调用 parsePipeArgs 的样板代码没有了。

7.3 何时使用手写处理器 vs. createPipeHandlers

场景 使用
标签使用管道分隔的参数 createPipeHandlers
标签把所有内容当作单一值(无管道) createSimpleInlineHandlers
标签需要条件逻辑、校验或副作用 手写 TagHandler
标签需要在管道拆分前访问原始 token 数组 手写 TagHandler

对于大多数基于管道的标签,createPipeHandlers 是正确的选择。


第 8 步:渲染它

解析器产出 token。渲染是一个独立的关注点。下面是一个最小的渲染器,把 token 树转换为 HTML 字符串——只是为了展示完整的流程。

8.1 简单的 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

function escapeAttr(s: string): string {
  return escapeHtml(s).replace(/"/g, "&quot;");
}

8.2 使用渲染器

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>!

8.3 为什么渲染是独立的

解析器对 HTML、Vue、React 或任何渲染目标一无所知。这是刻意的设计:

  1. 框架无关。 同一棵 token 树可以驱动 Vue 递归组件、React createElement 树、HTML 字符串、或终端 ANSI 序列。
  2. 安全边界。 你的渲染器决定什么是安全的。解析器不会注入 HTML。如果你想清理 URL(例如阻止 javascript: 协议),那个逻辑在你的渲染器中,而不是解析器中。
  3. 可测试性。 你可以独立地对解析器输出(纯 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.

你学到了什么

  1. 没有处理器,一切都是文本。 解析器只识别名称出现在 handlers 映射中的标签。
  2. 解析器的职责是识别和递归。 它看到 $$tagName(,匹配标签名,找到匹配的闭合标记,递归解析内容,然后调用你的处理器。
  3. 处理器赋予语义。 你的处理器决定 token 的 type、它携带哪些字段、以及子内容如何处理。
  4. 管道是处理器层面的事。 解析器交付原始 token;parsePipeArgs| 拆分它们;你的处理器决定哪个段是哪个参数。
  5. 转义序列正确传播。 源文本中的 \| 在反转义后变成字面 |。解析器和工具函数透明地处理这些。
  6. 渲染是独立的。 token 树是一个数据结构。它如何变成 HTML、Vue 组件或其他任何东西,完全由你决定。

下一步:

⚠️ **GitHub.com Fallback** ⚠️