@yaebal/preview

render a telegram-style chat from plain objects to an svg string

experimental / wip. the api and rendering may change without notice. not ready for production — pin a version if you depend on the output.

install

terminal
pnpm add @yaebal/preview

usage

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.

preview.ts
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 string

chat(options) is a chainable alternative to building the array by hand:

builder.ts
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:

bot.ts
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).

fieldtypedescription
fromSide ("user" | "bot" | "system")required. outgoing vs incoming vs a centered notice
namestringsender label (incoming); also drives the avatar initial + colour
avatarstringoverride just this message's avatar glyph (else RenderOptions.avatar/the name's initial)
timestringtimestamp shown in the message meta
statusTickStatus ("sent" | "delivered" | "read")outgoing read receipt (ticks). ignored for incoming
editedbooleanshows "edited" next to the time
buttonsstring[][]keyboard rows rendered as buttons under the message
replyReplyQuotereply-to quote block (name, quoted text/entities, accent colour) rendered above the content
forwardForwardHeader"Forwarded from …" header rendered above the content
reactionsReaction[]reaction pills (emoji, count, chosen) rendered under the bubble
webpageWebpagePreviewlink-preview card (site, title, description, src)
debugstring | string[]compact diagnostic text rendered above the bubble
messageIdstring | numbershown in the time/meta slot when time isn't set
textstringmessage text. wrapped automatically (unicode-aware — cjk/emoji measure at their real display width)
entitiesMessageEntity[]entities for text (bold/italic/underline/strike/code/spoiler/link/…). spread @yaebal/fmt's md/html to get these for free
captionstringcaption for a media message
captionEntitiesMessageEntity[]entities for caption
srcstringreal image/thumb url or data-uri for the picture-like media (a file_id can't render)
spoilerbooleancover the media with a spoiler
photo / sticker / animation / video / voice / audio / document / venue / location / contact / pollthe real @yaebal/types shape, or a hand-written partial fixturesee 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.

media.ts
// 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 },
      ],
    },
  },
]);
fieldrenders as
photoimage (or a deterministic placeholder) + optional caption
stickerstandalone image or its emoji big — falls back to a boxed bubble if paired with text/buttons/reply/forward instead of dropping them
animationimage + GIF badge
videoimage + play button + duration
voicewaveform + duration
audioplay disc + title / performer
documentfile icon + name + size
venue / locationmap tile + pin (+ title/address)
contactavatar + name + phone
pollquestion + 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).

flagship.ts
// 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.

grouping.ts
// 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.ts
// 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

exportsignaturedescription
renderChat(messages: ChatMessage[], options?: RenderOptions) => stringrender a telegram-style chat to an svg string
chat(options?: RenderOptions) => ChatBuilderchainable .user()/.bot()/.system()/.push()/.render() alternative to a raw array
ChatMessageinterfaceone message — see the table above
RenderOptionsinterfacerender-wide options — see below
ReplyQuote / ForwardHeader / Reaction / WebpagePreviewinterfacesthe flagship decoration shapes
Paletteinterfaceevery 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

optiontypedefaultdescription
themeTheme"light"the green-wallpaper look, dark, or a fully custom palette
palettePartial<Palette>point overrides layered on top of theme
widthnumber380canvas width in px
scalenumber1scales the rendered width/height (crisp @2x/@3x); viewBox stays 1x
avatarstringname initialdefault avatar glyph for incoming messages — override per-message via ChatMessage.avatar
wallpaperstringtheme gradientsolid override for the chat background
idPrefixstringrandom per callfixed id prefix for deterministic output (tests/snapshots) — otherwise every render gets its own unique ids, so two svgs on one page never collide
a11yTitle / a11yDescstringauto-generatedoverride 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).