en Tutorial Blog Renderer - chiba233/yume-dsl-token-walker GitHub Wiki
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:
- Set up a parser with
yume-dsl-rich-text - Build a ruleset with
createRuleset+fromHandlerMap - Use
envfor runtime context (theme, locale) - Handle pipe parameters (link URL + display text)
- Use
flattenTextfor plain-text extraction (search index, preview) - Use
wrapHandlersfor shared container wrapping
Prerequisites: npm install yume-dsl-rich-text yume-dsl-token-walker
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" },
// ]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>`,
};
},
};// 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" }.
// 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",
);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.
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.
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:
-
envis 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
flattenTextstandalone -
fromHandlerMap+wrapHandlerskeep handler code clean and composable
-
Async Interpret — if your code handler needs to call Shiki
codeToHtmlasync - Tutorial: Game Dialogue Engine — yield typed commands instead of strings
- Tutorial: Editor Lint + Auto-fix — validate DSL source with custom rules