@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
pnpm add @yaebal/fmtwhy
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.
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||`);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();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.
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.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.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.
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/ → newlinemd 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.
**bold** *italic* / _italic_ __underline__
~~strike~~ ||spoiler|| `code`
[text](url) > blockquote lines
\`\`\`lang
multi-line pre
\`\`\`api
| export | signature | returns |
|---|---|---|
html | tagged template | FormatResult |
md | tagged template | FormatResult |
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.