en React Rendering - chiba233/yumeDSL GitHub Wiki
For detailed examples and tutorials, visit the wiki home.
Drop-in recursive React component for rendering TextToken[] trees.
This parser setup is shared with the Vue example — the parser is framework-agnostic.
// src/dsl.ts
import {
createParser,
createSimpleInlineHandlers,
parsePipeArgs,
parsePipeTextArgs,
createToken,
type TagHandler,
type TokenDraft,
} from "yume-dsl-rich-text";
const titledHandler = (type: string, defaultTitle: string): TagHandler => ({
inline: (tokens, ctx): TokenDraft => {
const args = parsePipeArgs(tokens, ctx);
if (args.parts.length <= 1) return {type, title: defaultTitle, value: args.materializedTokens(0)};
return {type, title: args.text(0), value: args.materializedTailTokens(1)};
},
block: (arg, tokens, ctx): TokenDraft => ({type, title: arg || defaultTitle, value: tokens}),
raw: (arg, content, ctx): TokenDraft => ({
type, title: arg || defaultTitle,
value: [createToken({type: "text", value: content}, undefined, ctx)],
}),
});
const collapseHandler: TagHandler = {
block: (arg, tokens, ctx): TokenDraft => ({type: "collapse", title: arg || "Details", value: tokens}),
raw: (arg, content, ctx): TokenDraft => ({
type: "collapse", title: arg || "Details",
value: [createToken({type: "text", value: content}, undefined, ctx)],
}),
};
const rawCodeHandler: TagHandler = {
raw: (arg, content, ctx): TokenDraft => {
const args = parsePipeTextArgs(arg || "", ctx);
return {type: "raw-code", lang: args.text(0) || "text", value: content};
},
};
export const dsl = createParser({
handlers: {
...createSimpleInlineHandlers(["bold", "thin", "underline", "strike", "code", "center"]),
link: {
inline: (tokens, ctx): TokenDraft => {
const args = parsePipeArgs(tokens, ctx);
return {
type: "link",
url: args.text(0),
value: args.materializedTailTokens(1),
};
},
},
info: titledHandler("info", "Info"),
warning: titledHandler("warning", "Warning"),
collapse: collapseHandler,
"raw-code": rawCodeHandler,
},
});// src/components/RichTextRenderer.tsx
import type { TextToken } from "yume-dsl-rich-text";
import type { ReactNode, FC } from "react";
/* ── URL sanitization ── */
function normalizeUrl(raw: string): string {
try {
const url = new URL(raw, "https://example.com");
if (url.protocol === "http:" || url.protocol === "https:") return url.href;
} catch {
// invalid URL
}
return "#";
}
/* ── Per-tag renderers ── */
/**
* Map each tag type to a render function.
* Extend this object when you add new tags.
*
* Each renderer receives the token and a `children` ReactNode
* that is the recursively-rendered token.value (when it's an array).
*/
const tagRenderers: Record<string, (token: TextToken, children: ReactNode) => ReactNode> = {
bold: (_t, children) => <strong>{children}</strong>,
thin: (_t, children) => <span style={{fontWeight: 300}}>{children}</span>,
underline: (_t, children) => <u>{children}</u>,
strike: (_t, children) => <s>{children}</s>,
code: (_t, children) => <code>{children}</code>,
center: (_t, children) => <div style={{textAlign: "center"}}>{children}</div>,
link: (token, children) => (
<a
href={normalizeUrl(String(token.url ?? "#"))}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
info: (token, children) => (
<div className="alert alert-info" role="alert">
{token.title && <strong>{String(token.title)}: </strong>}
{children}
</div>
),
warning: (token, children) => (
<div className="alert alert-warning" role="alert">
{token.title && <strong>{String(token.title)}: </strong>}
{children}
</div>
),
collapse: (token, children) => (
<details>
<summary>{String(token.title ?? "Details")}</summary>
{children}
</details>
),
"raw-code": (token) => (
<pre className="code-block" data-lang={String(token.lang ?? "text")}>
<code>{typeof token.value === "string" ? token.value : ""}</code>
</pre>
),
};
/* ── Recursive renderer ── */
interface RichTextRendererProps {
tokens: TextToken[];
}
/**
* Recursively renders a TextToken[] tree into React elements.
*
* - Plain text tokens → <span>
* - Known tags → looked up in `tagRenderers`
* - Unknown tags → <span> fallback (graceful degradation)
* - Raw string values (e.g. raw-code) → rendered without recursion
*/
export const RichTextRenderer: FC<RichTextRendererProps> = ({tokens}) => (
<>
{tokens.map((token, idx) => {
const key = token.id ?? idx;
// Plain text
if (token.type === "text" && typeof token.value === "string") {
return <span key={key}>{token.value}</span>;
}
// Recursively render children (if value is a token array)
const children: ReactNode =
Array.isArray(token.value) && token.value.length > 0
? <RichTextRenderer tokens={token.value} />
: typeof token.value === "string"
? token.value
: null;
// Look up a tag-specific renderer
const renderer = tagRenderers[token.type];
if (renderer) {
return <span key={key}>{renderer(token, children)}</span>;
}
// Unknown tag — graceful fallback
return <span key={key}>{children}</span>;
})}
</>
);-
tagRenderersis a plain object mappingtoken.typeto a render function. Each function receives the full token (so you can read extra fields likehref,title,lang) and a pre-renderedchildrenReactNode. -
RichTextRendererwalks the token array. For each token:- If it's a text leaf →
<span>{value}</span> - If
token.valueis aTextToken[]→ recurse into<RichTextRenderer>to producechildren - Look up
tagRenderers[token.type]→ call it with(token, children) - No match → render
childrenin a plain<span>(graceful degradation, same as the parser)
- If it's a text leaf →
-
No
dangerouslySetInnerHTML— everything is rendered as React elements. The parser output is a data structure, not HTML.
// src/App.tsx
import { dsl } from "./dsl";
import { RichTextRenderer } from "./components/RichTextRenderer";
function App() {
const source = "Hello $$bold(world)$$! Visit $$link(https://example.com | here)$$.";
const tokens = dsl.parse(source);
return <RichTextRenderer tokens={tokens} />;
}
export default App;import { useState, useMemo } from "react";
import { dsl } from "./dsl";
import { RichTextRenderer } from "./components/RichTextRenderer";
function LivePreview() {
const [source, setSource] = useState("$$bold(Hello)$$ $$italic(world)$$!");
// Memoize: only re-parse when source changes
const tokens = useMemo(() => dsl.parse(source), [source]);
return (
<div>
<textarea
value={source}
onChange={(e) => setSource(e.target.value)}
rows={5}
cols={60}
/>
<div className="preview">
<RichTextRenderer tokens={tokens} />
</div>
</div>
);
}Performance note:
dsl.parse()is synchronous and fast. For typical chat messages / game dialogue, parsing insideuseMemoon every keystroke is fine. For very large documents (10KB+), consider debouncing.
import { Alert, AlertTitle } from "@mui/material";
const tagRenderers = {
// ... base renderers ...
info: (token: TextToken, children: ReactNode) => (
<Alert severity="info">
<AlertTitle>{String(token.title ?? "Info")}</AlertTitle>
{children}
</Alert>
),
warning: (token: TextToken, children: ReactNode) => (
<Alert severity="warning">
<AlertTitle>{String(token.title ?? "Warning")}</AlertTitle>
{children}
</Alert>
),
};import { Alert, Typography } from "antd";
const tagRenderers = {
// ... base renderers ...
info: (token: TextToken, children: ReactNode) => (
<Alert type="info" message={String(token.title ?? "Info")} description={children} />
),
"raw-code": (token: TextToken) => (
<Typography.Text code>
{typeof token.value === "string" ? token.value : ""}
</Typography.Text>
),
};import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
const tagRenderers = {
// ... base renderers ...
"raw-code": (token: TextToken) => (
<SyntaxHighlighter
language={String(token.lang ?? "text")}
style={oneDark}
>
{typeof token.value === "string" ? token.value : ""}
</SyntaxHighlighter>
),
};| Aspect | Vue | React |
|---|---|---|
| Tag mapping |
tagMap object (string or Component) + getComponentProps
|
tagRenderers object (render functions) |
| Recursion |
<RichTextRenderer :tokens="token.value" /> in template |
<RichTextRenderer tokens={token.value} /> in JSX |
| Props per tag |
getComponentProps(token) switch |
Inline in each render function |
| Reactivity | Computed / ref | useMemo |
| URL sanitization | Same normalizeUrl function |
Same normalizeUrl function |
| Parser setup | Identical dsl.ts
|
Identical dsl.ts
|
The parser is framework-agnostic — dsl.ts is shared. Only the renderer differs.