en React Rendering - chiba233/yumeDSL GitHub Wiki

React Rendering

Vue 3 Rendering | Exports

For detailed examples and tutorials, visit the wiki home.

Drop-in recursive React component for rendering TextToken[] trees.


1. Set up the parser (dsl.ts)

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,
    },
});

2. RichTextRenderer.tsx

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

How it works

  1. tagRenderers is a plain object mapping token.type to a render function. Each function receives the full token (so you can read extra fields like href, title, lang) and a pre-rendered children ReactNode.

  2. RichTextRenderer walks the token array. For each token:

    • If it's a text leaf → <span>{value}</span>
    • If token.value is a TextToken[] → recurse into <RichTextRenderer> to produce children
    • Look up tagRenderers[token.type] → call it with (token, children)
    • No match → render children in a plain <span> (graceful degradation, same as the parser)
  3. No dangerouslySetInnerHTML — everything is rendered as React elements. The parser output is a data structure, not HTML.


3. Usage

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

With reactive input

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 inside useMemo on every keystroke is fine. For very large documents (10KB+), consider debouncing.


4. Extending with component libraries

Material UI

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>
    ),
};

Ant Design

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>
    ),
};

Syntax-highlighted code blocks

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>
    ),
};

5. Comparison with Vue approach

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.

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