en Vue 3 Rendering - chiba233/yumeDSL GitHub Wiki

Vue 3 Rendering

Error Handling | Deprecated API

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

1. Set up the parser (dsl.ts)

Full example with:

  • createSimpleInlineHandlers for bold, thin, underline, strike, code, center
  • link handler with parsePipeArgs
  • titledHandler factory for info/warning (inline+block+raw)
  • collapse (block+raw only)
  • raw-code with parsePipeTextArgs
  • date and fromNow inline handlers
import {
    createParser, createSimpleInlineHandlers,
    parsePipeArgs, parsePipeTextArgs, createToken, materializeTextTokens,
    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};
    },
};

const dateHandler: TagHandler = {
    inline: (tokens, ctx): TokenDraft => {
        const args = parsePipeArgs(tokens, ctx);
        return {type: "date", value: args.text(0)};
    },
};

const fromNowHandler: TagHandler = {
    inline: (tokens, ctx): TokenDraft => {
        const args = parsePipeArgs(tokens, ctx);
        return {type: "fromNow", value: args.text(0)};
    },
};

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,
        date: dateHandler,
        fromNow: fromNowHandler,
    },
});

2. RichTextRenderer.vue

<!-- src/components/RichTextRenderer.vue -->
<script setup lang="ts">
import type { TextToken } from "yume-dsl-rich-text";
import type { Component } from "vue";

defineProps<{
    tokens: TextToken[];
}>();

/**
 * Map DSL tag names to Vue components or HTML elements.
 * Extend this object when you add new tags.
 */
const tagMap: Record<string, string | Component> = {
    bold: "strong",
    thin: "span",
    underline: "u",
    strike: "s",
    code: "code",
    center: "div",
    link: "a",
    info: "div",
    warning: "div",
    collapse: "details",
    "raw-code": "pre",
    date: "time",
    fromNow: "time",
};

/** Return extra props for specific tags. */
function getComponentProps(token: TextToken): Record<string, unknown> {
    if (token.type === "link") {
        const href = normalizeUrl(String(token.url ?? "#"));
        return {href, target: "_blank", rel: "noopener noreferrer"};
    }
    if (token.type === "info" || token.type === "warning") {
        return {title: token.title};
    }
    if (token.type === "collapse") {
        return {};
    }
    if (token.type === "date" || token.type === "fromNow") {
        return {datetime: typeof token.value === "string" ? token.value : ""};
    }
    return {};
}

/** Return CSS class(es) for specific tags. */
function getComponentClass(token: TextToken): string | undefined {
    if (token.type === "info") return "alert alert-info";
    if (token.type === "warning") return "alert alert-warning";
    if (token.type === "raw-code") return "code-block";
    if (token.type === "center") return "text-center";
    return undefined;
}

/** Basic URL sanitization -- only allow http/https. */
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 "#";
}
</script>

<template>
    <template v-for="(token, idx) in tokens" :key="token.id ?? idx">
        <!-- Plain text node -->
        <span v-if="token.type === 'text' && typeof token.value === 'string'">{{ token.value }}</span>

        <!-- Raw code: string value, no recursion -->
        <component
            v-else-if="token.type === 'raw-code' && typeof token.value === 'string'"
            :is="tagMap['raw-code']"
            :class="getComponentClass(token)"
            v-bind="getComponentProps(token)"
        >{{ token.value }}</component>

        <!-- Everything else: recursive children -->
        <component
            v-else-if="tagMap[token.type]"
            :is="tagMap[token.type]"
            :class="getComponentClass(token)"
            v-bind="getComponentProps(token)"
        >
            <RichTextRenderer
                v-if="Array.isArray(token.value) && token.value.length"
                :tokens="token.value"
            />
        </component>
    </template>
</template>

3. Usage

<script setup lang="ts">
import { dsl } from "@/dsl";
import RichTextRenderer from "@/components/RichTextRenderer.vue";

const source = "Hello $$bold(world)$$! Visit $$link(https://example.com | here)$$.";
const tokens = dsl.parse(source);
</script>

<template>
    <RichTextRenderer :tokens="tokens" />
</template>

Extending with UI Libraries

Map tags to Vue components from libraries like Naive UI:

import { NAlert } from "naive-ui";
import CodeBlock from "@/components/CodeBlock.vue";
import CollapseWrapper from "@/components/CollapseWrapper.vue";

const tagMap: Record<string, string | Component> = {
    // ... base mappings ...
    info: NAlert,              // renders <NAlert type="info">
    warning: NAlert,           // renders <NAlert type="warning">
    "raw-code": CodeBlock,     // syntax-highlighted code block
    collapse: CollapseWrapper, // animated collapse component
};

For tags that need dynamic rendering logic, use functional components:

import { h, type FunctionalComponent } from "vue";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";

dayjs.extend(relativeTime);

const DateTag: FunctionalComponent<{datetime: string}> = (props) => {
    const d = dayjs(props.datetime);
    return h("time", {datetime: props.datetime}, d.format("YYYY-MM-DD"));
};

const FromNowTag: FunctionalComponent<{datetime: string}> = (props) => {
    return h("time", {datetime: props.datetime}, dayjs(props.datetime).fromNow());
};

const tagMap = {
    date: DateTag,
    fromNow: FromNowTag,
};
⚠️ **GitHub.com Fallback** ⚠️