zh CN Tutorial Blog Renderer - chiba233/yume-dsl-token-walker GitHub Wiki

教程:从零实现博客渲染器

首页 | 解释 API

在这个教程里,你将从零构建一个完整的 DSL → HTML 博客渲染管线——支持 bold、italic、link、code block,还有主题感知样式。

你将学到:

  1. yume-dsl-rich-text 搭建 parser
  2. createRuleset + fromHandlerMap 构建 ruleset
  3. env 注入运行时上下文(主题、语言)
  4. 处理管道参数(link 的 URL + 显示文本)
  5. flattenText 提取纯文本(搜索索引、预览)
  6. wrapHandlers 统一容器包装

前置条件: npm install yume-dsl-rich-text yume-dsl-token-walker


第 1 步:搭建 parser

先定义博客支持哪些标签:

// parser.ts
import {
    createParser,
    createSimpleInlineHandlers,
    createSimpleBlockHandlers,
    createSimpleRawHandlers,
    createPipeHandlers,
    declareMultilineTags,
} from "yume-dsl-rich-text";

export const parser = createParser({
    handlers: {
        // 简单 inline:$$bold(text)$$, $$italic(text)$$
        ...createSimpleInlineHandlers(["bold", "italic", "underline", "strike"]),

        // 带 arg 的 block:$$info(title)* content *end$$
        ...createSimpleBlockHandlers(["info", "warning"]),

        // 带 arg 的 raw:$$code(ts)% content %end$$
        ...createSimpleRawHandlers(["code"]),

        // 管道参数:$$link(url | display text)$$
        ...createPipeHandlers({
            link: {
                inline: (args, ctx) => ({
                    type: "link",
                    url: args.text(0, "#"),
                    value: args.materializedTailTokens(1),
                }),
            },
        }),
    },
    blockTags: declareMultilineTags(["info", "warning", "code"]),
});

测试一下:

const tokens = parser.parse("Hello $$bold($$italic(world)$$)$$!");
console.log(tokens);
// [
//   { type: "text", value: "Hello ", id: "rt-0" },
//   { type: "bold", value: [{ type: "italic", value: [...], ... }], ... },
//   { type: "text", value: "!", id: "rt-3" },
// ]

第 2 步:写 handler

把每种 token 类型映射到 HTML 输出:

// handlers.ts
import type { TextToken } from "yume-dsl-rich-text";
import type { InterpretHelpers, ResolvedResult } from "yume-dsl-token-walker";

interface Env {
    theme: "light" | "dark";
}

type H = InterpretHelpers<string, Env>;

// 工具函数:用标签包裹子节点
const tag = (name: string, token: TextToken, h: H, attrs = ""): ResolvedResult<string> => ({
    type: "nodes",
    nodes: [`<${name}${attrs}>`, ...h.interpretChildren(token.value), `</${name}>`],
});

export const handlers: Record<string, (token: TextToken, h: H) => ResolvedResult<string>> = {
    bold: (token, h) => tag("strong", token, h),
    italic: (token, h) => tag("em", token, h),
    underline: (token, h) => tag("u", token, h),
    strike: (token, h) => tag("del", token, h),

    link: (token, h) => {
        const url = typeof token.url === "string" ? token.url : "#";
        return tag("a", token, h, ` href="${url}"`);
    },

    info: (token, h) => {
        const title = typeof token.arg === "string" ? token.arg : "Info";
        const bg = h.env.theme === "dark" ? "#1a3a4a" : "#e8f4fd";
        return {
            type: "nodes",
            nodes: [
                `<div class="callout info" style="background:${bg}">`,
                `<div class="callout-title">${title}</div>`,
                ...h.interpretChildren(token.value),
                "</div>",
            ],
        };
    },

    warning: (token, h) => {
        const title = typeof token.arg === "string" ? token.arg : "Warning";
        return {
            type: "nodes",
            nodes: [
                `<div class="callout warning">`,
                `<div class="callout-title">${title}</div>`,
                ...h.interpretChildren(token.value),
                "</div>",
            ],
        };
    },

    code: (token, h) => {
        const lang = typeof token.arg === "string" ? token.arg : "text";
        return {
            type: "text",
            text: `<pre><code class="language-${lang}">${
                typeof token.value === "string" ? token.value : h.flattenText(token.value)
            }</code></pre>`,
        };
    },
};

第 3 步:组装 ruleset

// ruleset.ts
import { createRuleset, fromHandlerMap, debugUnhandled } from "yume-dsl-token-walker";
import { handlers } from "./handlers";

export const ruleset = createRuleset<string, { theme: "light" | "dark" }>({
    createText: (text) => text,
    interpret: fromHandlerMap(handlers),
    onUnhandled: process.env.NODE_ENV === "production"
        ? "flatten"
        : debugUnhandled(),  // 开发环境显示 "[unhandled:someTag]"
});

为什么用 createRuleset 它只是恒等函数——但它让 TypeScript 自动推断 TNodeTEnv,不用手动标泛型。

为什么用 fromHandlerMap 它把你的 Record<type, handler> 变成 InterpretRuleset 需要的 interpret 函数。未匹配的 token 类型自动返回 { type: "unhandled" }


第 4 步:渲染

// render.ts
import { interpretText, collectNodes } from "yume-dsl-token-walker";
import { parser } from "./parser";
import { ruleset } from "./ruleset";

export function renderBlogPost(dslSource: string, theme: "light" | "dark"): string {
    return collectNodes(
        interpretText(dslSource, parser, ruleset, { theme }),
    ).join("");
}

// 使用
const html = renderBlogPost(
    `$$bold(Welcome)$$ to my blog!

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

$$info(Note)*
This uses $$italic(custom DSL)$$ for rich text.
*end$$

$$code(ts)%
const x: number = 42;
%end$$`,
    "dark",
);

第 5 步:搜索用纯文本

纯文本不需要 ruleset——flattenText 是独立工具函数:

import { flattenText } from "yume-dsl-token-walker";
import { parser } from "./parser";

export function extractSearchText(dslSource: string): string {
    return flattenText(parser.parse(dslSource));
}

const plain = extractSearchText("Hello $$bold($$italic(world)$$)$$ - $$link(https://x.com | click)$$");
// → "Hello world - click"

用于搜索索引、RSS feed、Open Graph 描述、通知预览。


第 6 步:用 wrapHandlers 统一包装

假设所有 block 标签(info、warning)都需要相同的 <section> 外层容器。与其在每个 handler 里重复写,不如用 wrapHandlers

import { fromHandlerMap, wrapHandlers } from "yume-dsl-token-walker";

const inlineHandlers = { bold: ..., italic: ..., link: ... };

const blockHandlers = wrapHandlers(
    { info: ..., warning: ... },
    (result, token) => {
        if (result.type !== "nodes") return result;
        return {
            type: "nodes",
            nodes: [`<section class="block-${token.type}">`, ...result.nodes, "</section>"],
        };
    },
);

const interpret = fromHandlerMap({ ...inlineHandlers, ...blockHandlers });

wrapHandlers 预处理 handler map——每个 handler 的结果在返回前都会经过 wrap。然后 fromHandlerMap 把整个 map 变成最终的 interpret 函数。


最终文件结构

src/
  dsl/
    parser.ts       ← createParser + handlers
    handlers.ts     ← handler map (Record<type, handler>)
    ruleset.ts      ← createRuleset + fromHandlerMap
    render.ts       ← interpretText 包装
    search.ts       ← flattenText 纯文本提取

学到的原则:

  • env 只放运行时上下文(主题、语言)——不放业务状态
  • handler 是纯映射:token 进,result 出——副作用放 render 层
  • 一种输出格式一个 ruleset——需要 HTML + 纯文本?两个 ruleset,或者用独立的 flattenText
  • fromHandlerMap + wrapHandlers 让 handler 代码干净可组合

下一步

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