@yaebal/fmt

html and md tagged templates that parse Telegram's markup subset into real entities — with interpolations auto-escaped so user input can never break your formatting.

install

terminal
pnpm add @yaebal/fmt

why

core ships format (entity builders: bold(), link()). @yaebal/fmt adds the parser angle — write familiar markdown or HTML, get the same { text, entities } back. Both avoid parse_mode entirely, so there is nothing to escape.

basic.ts
import { html, md } from "@yaebal/fmt";

// parses into MessageEntity[] — no parse_mode, nothing to escape
ctx.send(html`<b>hello</b> <a href="https://yaebal.mom">docs</a>`);
ctx.send(md`**hello** and *italic* and \`code\` and ||spoiler||`);
formatting.ts
import { createBot, html, md } from "yaebal";

const bot = createBot(process.env.BOT_TOKEN!);

bot.command("start", (ctx) =>
  ctx.reply(html`<b>hello</b>, ${ctx.from?.first_name ?? "friend"}
<code>no parse_mode needed</code>`),
);

bot.command("md", (ctx) =>
  ctx.reply(md`**bold**, *italic*, __underline__ and ~~strike~~
> a quoted line`),
);

bot.start();
scope, honestly: these are template dialects for authoring bot messages, not document converters. they cover telegram's own entity vocabulary and nothing beyond it — no headings, lists or tables. to render an arbitrary markdown/html document (LLM output), use a real markdown parser and map its AST onto entities; for telegram's block-tree rich messages see @yaebal/rich.

auto-escaped interpolation

this is the headline. a $${string} interpolation is inserted as literal text — its *, <, ` are never re-parsed as markup. user input cannot inject entities or break the message. null/undefined/booleans render as empty text, so ${cond && bold("on")} just works.

safe.ts
const name = "<script>**hax**";

// the interpolation is inserted as LITERAL text — never re-parsed
ctx.send(html`hi <b>${name}</b>`);
// → text: "hi <script>**hax**", one bold entity. no injection possible.
composes with core. if an interpolation is itself a FormatResult (e.g. from bold() / link()), it's merged in with its offsets shifted. interpolating into an attribute value (href="${url}") or a markdown link url substitutes the value textually.
compose.ts
import { html, md } from "@yaebal/fmt";
import { bold, link } from "@yaebal/core";

// a FormatResult sub (from core's builders) is MERGED, offsets shifted
ctx.send(html`welcome ${bold(user.name)} — ${link("open", url)}`);

// interpolation inside an attribute or a link url is substituted textually
ctx.send(html`<a href="${url}">open</a>`);
ctx.send(md`[open](${url})`);

html dialect

telegram's full html vocabulary. tags left unclosed at the end of input are auto-closed, unmatched closing tags are dropped, and anything unrecognized (<div>, unquoted attributes) stays literal text.

supported tags
b / strong                    → bold
i / em                        → italic
u / ins                       → underline
s / strike / del              → strikethrough
tg-spoiler, span.tg-spoiler   → spoiler
code                          → code
pre                           → pre
pre > code class="language-x" → one pre entity with language "x"
blockquote                    → blockquote
blockquote expandable         → expandable_blockquote
a href="…"                    → text_link
tg-emoji emoji-id="…"         → custom_emoji
br / br/                      → newline

md dialect

a backslash escapes the next character (2 \* 3), including inside a run (**a \** b**). single */_ don't trigger mid-word — snake_case and 2 * 3 are safe — and their content can't start or end with whitespace. consecutive >-prefixed lines merge into one blockquote entity. for expandable_blockquote and custom_emoji, use the html dialect or core's helpers.

supported syntax
**bold**            *italic* / _italic_      __underline__
~~strike~~          ||spoiler||              `code`
[text](url)         > blockquote lines
\`\`\`lang
multi-line pre
\`\`\`

api

exportsignaturereturns
htmltagged templateFormatResult
mdtagged templateFormatResult
htmlToEntities(s: string)FormatResult
mdToEntities(s: string)FormatResult

the result is accepted anywhere core sends text: ctx.send/reply, captions, and — via the schema-generated format map — every bot.api.* method with an *_entities sibling, including nested spots like reply_parameters.quote, poll options and media groups.