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

Tutorial: Building a Blog Renderer from Scratch

Home | Interpret

In this tutorial you'll build a complete DSL-to-HTML renderer for a blog engine — from zero to a working pipeline that handles bold, italic, links, code blocks, and theme-aware styling.

What you'll learn:

  1. Set up a parser with yume-dsl-rich-text
  2. Build a ruleset with createRuleset + fromHandlerMap
  3. Use env for runtime context (theme, locale)
  4. Handle pipe parameters (link URL + display text)
  5. Use flattenText for plain-text extraction (search index, preview)
  6. Use wrapHandlers for shared container wrapping

Prerequisites: npm install yume-dsl-rich-text yume-dsl-token-walker


Step 1: Set up the parser

First, define which tags your blog supports:

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

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

        // Block with arg: $$info(title)* content *end$$
        ...createSimpleBlockHandlers(["info", "warning"]),

        // Raw with arg: $$code(ts)% content %end$$
        ...createSimpleRawHandlers(["code"]),

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

Test it:

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" },
// ]

Step 2: Build handlers

Now map each token type to HTML output:

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

// Helper: wrap children in a tag
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>`,
        };
    },
};

Step 3: Assemble the 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]" in dev
});

Why createRuleset? It's just an identity function — but it gives you full TypeScript inference for TNode and TEnv without manual generic annotations.

Why fromHandlerMap? It turns your Record<type, handler> into the interpret function expected by InterpretRuleset. Unmatched token types automatically return { type: "unhandled" }.


Step 4: Render

// 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("");
}

// Usage
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",
);

Step 5: Plain text for search

You don't need a ruleset for plain text — flattenText is a standalone utility:

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"

Use this for search indexes, RSS feeds, Open Graph descriptions, and notification previews.


Step 6: Shared wrapping with wrapHandlers

Suppose all your block tags (info, warning) need the same outer <section> container. Instead of duplicating the wrapping in every handler, use 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 preprocesses the handler map — every handler's result is passed through wrap before being returned. Then fromHandlerMap turns the whole map into the final interpret function.


Final file structure

src/
  dsl/
    parser.ts       ← createParser + handlers
    handlers.ts     ← handler map (Record<type, handler>)
    ruleset.ts      ← createRuleset + fromHandlerMap
    render.ts       ← interpretText wrapper
    search.ts       ← flattenText for plain-text extraction

Principles learned:

  • env is for runtime context only (theme, locale) — not business state
  • Handlers are pure: token in, result out — side effects belong in the render layer
  • One ruleset per output format — need HTML + plain text? Two rulesets, or use flattenText standalone
  • fromHandlerMap + wrapHandlers keep handler code clean and composable

Next steps

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