zh CN Token 结构 - chiba233/yumeDSL GitHub Wiki

Token 结构

ParseOptions 选项 | 稳定 Token ID

解析器把 DSL 文本变成一棵 token 树。树上每个节点就是一个 TextToken。 你的 handler 返回 TokenDraft(半成品),解析器自动补上 idposition 变成最终的 TextToken


一张图看懂

你的 DSL 文本
    │
    ▼
解析器扫描
    │
    ├─ 纯文本 → TextToken { type: "text", value: "Hello ", id: "rt-0" }
    │
    └─ $$bold(world)$$ → 调用你的 handler
                              │
                              ▼
                         handler 返回 TokenDraft
                         { type: "bold", value: [...子token] }
                              │
                              ▼
                         解析器补上 id + position
                              │
                              ▼
                         TextToken { type: "bold", value: [...], id: "rt-1" }

TextToken

interface TextToken {
    type: string;                // "text" 或 handler 返回的类型
    value: string | TextToken[]; // 文本内容 或 子 token 数组
    id: string;                  // 解析内唯一标识
    position?: SourceSpan;       // 源码坐标(开了 trackPositions 才有)
    [key: string]: unknown;      // handler 附加的额外字段
}

各字段说明

字段 什么意思
type 纯文本是 "text",标签节点是 handler 返回的 type(通常是标签名如 "bold",但也可以是自定义类型如 "version-note"
value 文本节点 → string;inline/block 标签 → TextToken[](子节点);raw 标签 → string(原始内容)
id 默认顺序编号 rt-0, rt-1, ...。想要稳定 ID → 稳定 Token ID
position trackPositions: true 时才有。详见 源码位置追踪
[key] handler 返回什么额外字段就保留什么。比如 link 的 url、code 的 lang

判断 value 类型: typeof token.value === "string" → 文本/raw;否则 → 子 token 数组。


TokenDraft

interface TokenDraft {
    type: string;
    value: string | TextToken[];

    [key: string]: unknown;
}

handler 返回的东西。和 TextToken 一样,但没有 idposition——解析器会自动补。

handler 必须设 typevalue,想加什么额外字段随便加:

return {
    type: "link",
    value: childTokens,               // 必须
    url: "https://example.com",       // 额外字段,会原样保留在 TextToken 上
};

强类型

基础 TextToken 用索引签名保持灵活,但你可以——也应该——给自己的标签定义精确类型。

先明确边界

  • 库公共边界parseRichText() 返回的是通用 TextToken[](允许未知标签扩展字段)
  • 业务内部边界:你自己的渲染器 / handler 用 TokenMap 收窄成严格类型

这两层分开后,既不会牺牲扩展性,也能拿到完整的 TypeScript 校验。

推荐方式:NarrowToken + createTokenGuard 1.1.0+

定义一份 token map,然后用 createTokenGuard 零样板收窄:

import {
    type NarrowToken,
    type NarrowDraft,
    type NarrowTokenUnion,
    createTokenGuard,
    type TextToken,
} from "yume-dsl-rich-text";

// 1. 定义 token map —— key 是 type,value 是该类型的额外字段
interface MyTokenMap {
    text: Record<string, never>;
    bold: Record<string, never>;
    link: { url: string };
    code: { lang: string };
}

type MyToken = NarrowTokenUnion<MyTokenMap>;

// 2. 创建类型守卫
const is = createTokenGuard<MyTokenMap>();

const renderChildren = (value: TextToken["value"]) =>
    Array.isArray(value) ? value.map(render).join("") : value;

// 3. 在 if 分支里自动收窄 —— TypeScript 自动推导额外字段
function render(token: TextToken): string {
    if (is(token, "text")) return typeof token.value === "string" ? token.value : renderChildren(token.value);
    if (is(token, "bold")) return `<b>${renderChildren(token.value)}</b>`;
    if (is(token, "link")) return `<a href="${token.url}">${renderChildren(token.value)}</a>`;
    if (is(token, "code")) return `<pre data-lang="${token.lang}">${renderChildren(token.value)}</pre>`;
    return "";
}

// 4. 如果你希望在某些模块里使用完整判别联合:
const tokens = parseRichText(input, { handlers }) as MyToken[];

工具类型一览:

类型 作用
NarrowToken<TType, TExtra?> TextToken 收窄为特定 type 字面量 + 已知额外字段
NarrowDraft<TType, TExtra?> TokenDraft 收窄,用于 handler 返回类型标注
NarrowTokenUnion<TMap> 从 token map 批量生成 NarrowToken 联合类型,适合 switch 穷举
createTokenGuard<TMap>() 创建运行时类型守卫,按 type 键收窄 TextToken

小提示:TokenMap 里“无额外字段”的类型建议用 Record<string, never>,避免 {} 在严格 ESLint 配置下触发 no-empty-object-type

handler 侧类型安全 —— NarrowDraft

import {type NarrowDraft, type TagHandler, parsePipeArgs} from "yume-dsl-rich-text";

type LinkDraft = NarrowDraft<"link", { url: string }>;

const linkHandler: TagHandler = {
    inline: (tokens, ctx): LinkDraft => {
        const args = parsePipeArgs(tokens, ctx);
        return {
            type: "link",
            url: args.text(0),              // ← 漏写 url 会报编译错误
            value: args.materializedTailTokens(1),
        };
    },
};

替代方式:手写判别联合

如果你更喜欢显式接口:

// 1. 给每个标签定义接口
interface PlainText extends TextToken {
    type: "text";
    value: string;
}

interface BoldToken extends TextToken {
    type: "bold";
    value: TextToken[];
}

interface LinkToken extends TextToken {
    type: "link";
    url: string;
    value: TextToken[];
}

interface CodeBlockToken extends TextToken {
    type: "code";
    lang: string;
    value: string;
}

// 2. 联合类型
type MyToken = PlainText | BoldToken | LinkToken | CodeBlockToken;

// 3. 解析时 cast 一次
const tokens = parseRichText(input, {handlers}) as MyToken[];

// 4. switch 穷举
function render(token: MyToken): string {
    switch (token.type) {
        case "text":
            return token.value;
        case "bold":
            return `<b>${token.value.map(t => render(t as MyToken)).join("")}</b>`;
        case "link":
            return `<a href="${token.url}">${token.value.map(t => render(t as MyToken)).join("")}</a>`;
        case "code":
            return `<pre data-lang="${token.lang}">${token.value}</pre>`;
        default: {
            const _: never = token;
            return String(_);
        }
    }
}

简单场景:typeof 缩窄

不想定义接口的话,运行时 typeof 也行:

if (token.type === "link" && typeof token.url === "string") {
    console.log("Link to:", token.url);
}

安全性不如判别联合(没有穷举检查),但临时用用够了。

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