@yaebal/preview
render a telegram-style chat from plain objects to an svg string
install
pnpm add @yaebal/previewusage
renderChat(messages, options) returns an svg string — rich text, every common
media type, reply quotes, forwarded headers, reactions, link previews, custom themes, and
telegram-style message grouping. zero runtime, no <foreignObject> (so it
rasterizes and survives github's svg sanitizer). drop the result into docs, a readme, or a
landing page.
import { renderChat } from "@yaebal/preview";
import { md } from "@yaebal/fmt"; // optional — produces { text, entities }
import { writeFile } from "node:fs/promises";
const svg = renderChat(
[
{ from: "user", text: "/start", time: "23:33", status: "read" },
{ from: "bot", name: "yaebal", ...md`Hello, **unknown** person`, time: "23:33" },
{ from: "bot", name: "yaebal", photo: [], src: "cat.jpg", caption: "a cat" },
{ from: "bot", name: "yaebal", voice: { duration: 7 } },
{ from: "bot", name: "yaebal", buttons: [["Useless button"]] },
],
{ theme: "light", width: 400 },
);
await writeFile("chat.svg", svg); // it's just a stringchat(options) is a chainable alternative to building the array by hand:
import { chat } from "@yaebal/preview";
// a chainable alternative to a raw array literal — handy for quick docs/examples
const svg = chat({ theme: "dark" })
.user("/start", { time: "23:33", status: "read" })
.bot("hello, unknown person", { name: "yaebal", time: "23:33" })
.system("today")
.render();try it
a live bot that quotes, reacts to, and forwards whatever you send it — each renders as the real thing, not a debug annotation:
import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.command("start", (ctx) =>
ctx.reply("send me anything — I'll quote it, react to it, and forward it back."),
);
bot.on("message:text", async (ctx) => {
// reply_parameters.quote — renders as a real reply-quote block, not just a debug annotation
await ctx.quote(ctx.text, "got it — quoting your message back.");
// setMessageReaction — renders as a reaction pill under the message
await ctx.react("🔥");
// forwardMessage — renders as a "Forwarded from …" header
await ctx.api.call("forwardMessage", {
chat_id: ctx.chat.id,
from_chat_id: ctx.chat.id,
message_id: ctx.message_id,
});
});
bot.start();messages
each entry is a ChatMessage. from is the only required field: "user" renders outgoing (right-aligned, with ticks), "bot" renders incoming
(left-aligned, with an avatar), "system" renders a centered pill (e.g. a date divider).
| field | type | description |
|---|---|---|
from | Side ("user" | "bot" | "system") | required. outgoing vs incoming vs a centered notice |
name | string | sender label (incoming); also drives the avatar initial + colour |
avatar | string | override just this message's avatar glyph (else RenderOptions.avatar/the name's initial) |
time | string | timestamp shown in the message meta |
status | TickStatus ("sent" | "delivered" | "read") | outgoing read receipt (ticks). ignored for incoming |
edited | boolean | shows "edited" next to the time |
buttons | string[][] | keyboard rows rendered as buttons under the message |
reply | ReplyQuote | reply-to quote block (name, quoted text/entities, accent colour) rendered above the content |
forward | ForwardHeader | "Forwarded from …" header rendered above the content |
reactions | Reaction[] | reaction pills (emoji, count, chosen) rendered under the bubble |
webpage | WebpagePreview | link-preview card (site, title, description, src) |
debug | string | string[] | compact diagnostic text rendered above the bubble |
messageId | string | number | shown in the time/meta slot when time isn't set |
text | string | message text. wrapped automatically (unicode-aware — cjk/emoji measure at their real display width) |
entities | MessageEntity[] | entities for text (bold/italic/underline/strike/code/spoiler/link/…). spread @yaebal/fmt's md/html to get these for free |
caption | string | caption for a media message |
captionEntities | MessageEntity[] | entities for caption |
src | string | real image/thumb url or data-uri for the picture-like media (a file_id can't render) |
spoiler | boolean | cover the media with a spoiler |
photo / sticker / animation / video / voice / audio / document / venue / location / contact / poll | the real @yaebal/types shape, or a hand-written partial fixture | see media below |
media
every media field accepts the real @yaebal/types shape (the array/objects you'd get
off an Update) or a hand-written partial fixture with just the fields the
renderer draws — voice: { duration: 7 } is valid, not just a full Voice. add spoiler: true to cover picture media. long names/titles
truncate with an ellipsis instead of overflowing their card.
// real @yaebal/types shapes — hand it a ctx.message almost verbatim, or write a
// minimal fixture by hand: media fields accept a Partial<T>, so { duration: 7 }
// is just as valid as a full Voice object.
// for picture-like media add `src` (URL/data-URI) to show real pixels;
// a file_id has none, so without `src` you get a clean, deterministically-coloured
// placeholder (seeded by the media's own file_unique_id, not by message order).
renderChat([
{ from: "bot", name: "yaebal", photo: [], src: "cat.jpg", spoiler: true },
{ from: "bot", name: "yaebal", video: { width: 640, height: 360, duration: 42 } },
{ from: "bot", name: "yaebal", sticker: { emoji: "🎈" } },
{ from: "bot", name: "yaebal", document: { file_name: "report.pdf", file_size: 81920 } },
{ from: "bot", name: "yaebal", contact: { first_name: "Ann", phone_number: "+1 555" } },
{
from: "bot",
name: "yaebal",
poll: {
question: "tabs?",
options: [
{ text: "yes", voter_count: 7 },
{ text: "no", voter_count: 1 },
],
},
},
]);| field | renders as |
|---|---|
photo | image (or a deterministic placeholder) + optional caption |
sticker | standalone image or its emoji big — falls back to a boxed bubble if paired with text/buttons/reply/forward instead of dropping them |
animation | image + GIF badge |
video | image + play button + duration |
voice | waveform + duration |
audio | play disc + title / performer |
document | file icon + name + size |
venue / location | map tile + pin (+ title/address) |
contact | avatar + name + phone |
poll | question + options with percentage bars (sums to a clean 100%); shows the winning option on closed/quiz polls |
reply, forward, reactions, link previews
the four flagship decorations are just fields on ChatMessage — no bot required to
produce them, though the live example above shows them driven by a real bot
(ctx.quote(), ctx.react(), forwardMessage).
// reply quotes, forwarded headers, reactions, and a link-preview card — all plain
// fields on ChatMessage, no bot required to produce them
renderChat([
{
from: "bot",
name: "yaebal",
forward: { from: "release notes" },
text: "@yaebal/preview now speaks reply quotes, reactions, and link previews.",
},
{
from: "bot",
name: "yaebal",
reply: { name: "yaebal", text: "@yaebal/preview now speaks reply quotes..." },
text: "here's the whole tour in one message.",
reactions: [
{ emoji: "🔥", count: 3, chosen: true },
{ emoji: "👍", count: 1 },
],
},
{
from: "bot",
name: "yaebal",
webpage: {
site: "yaebal.mom",
title: "@yaebal/preview",
description: "render a telegram-style chat to an svg string — zero deps, zero runtime.",
},
text: "docs are here:",
},
]);message grouping
consecutive messages from the same sender group like telegram does: one avatar and one name
label for the whole run, a smaller gap between them, and only the last bubble gets the pointed
tail corner. override just one message's avatar with avatar without breaking the
group.
// consecutive messages from the same sender (same "from" + "name") group like
// telegram does: one avatar and one name label for the whole run, and only the
// last bubble in the run gets the pointed tail corner
renderChat([
{ from: "bot", name: "yaebal", text: "first" },
{ from: "bot", name: "yaebal", text: "second" },
{ from: "bot", name: "yaebal", avatar: "🐸", text: "override just this message's avatar" },
]);theming
theme accepts "light"/"dark", a fully custom Palette, or a preset with point palette overrides. wallpaper swaps the two-tone gradient background for a solid fill, and scale renders at a
higher pixel density while keeping the same layout (viewBox stays 1x).
// theme accepts a preset name, a full custom Palette, or a preset + point overrides
renderChat(messages, { theme: "dark" });
renderChat(messages, { theme: { in: "#202830", inText: "#e8e8e8" } }); // fully custom palette
renderChat(messages, { theme: "dark", palette: { out: "#204020" } }); // preset + overrides
renderChat(messages, { wallpaper: "#0b0b0b" }); // solid background instead of the two-tone gradient
renderChat(messages, { scale: 2 }); // crisp @2x rasterization (viewBox stays 1x)accessibility
the root <svg> always carries role="img" and an auto-generated <title>/<desc> (spoiler text is masked before it can reach
the description) — pass a11yTitle/a11yDesc to override them.
api
| export | signature | description |
|---|---|---|
renderChat | (messages: ChatMessage[], options?: RenderOptions) => string | render a telegram-style chat to an svg string |
chat | (options?: RenderOptions) => ChatBuilder | chainable .user()/.bot()/.system()/.push()/.render() alternative to a raw array |
ChatMessage | interface | one message — see the table above |
RenderOptions | interface | render-wide options — see below |
ReplyQuote / ForwardHeader / Reaction / WebpagePreview | interfaces | the flagship decoration shapes |
Palette | interface | every themeable colour — pass a full or partial one via theme/palette |
Theme | "light" | "dark" | Partial<Palette> | what RenderOptions.theme accepts |
Side | "user" | "bot" | "system" | message direction |
TickStatus | "sent" | "delivered" | "read" | outgoing read receipt |
RenderOptions
| option | type | default | description |
|---|---|---|---|
theme | Theme | "light" | the green-wallpaper look, dark, or a fully custom palette |
palette | Partial<Palette> | — | point overrides layered on top of theme |
width | number | 380 | canvas width in px |
scale | number | 1 | scales the rendered width/height (crisp @2x/@3x); viewBox stays 1x |
avatar | string | name initial | default avatar glyph for incoming messages — override per-message via ChatMessage.avatar |
wallpaper | string | theme gradient | solid override for the chat background |
idPrefix | string | random per call | fixed id prefix for deterministic output (tests/snapshots) — otherwise every render gets its own unique ids, so two svgs on one page never collide |
a11yTitle / a11yDesc | string | auto-generated | override the <title>/<desc> |
part of the same suite as @yaebal/link-preview (link_preview_options for real outgoing messages) and @yaebal/fmt (md/html produce the { text, entities } shape renderChat expects).