Add Markdown to Telegram HTML converter

Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT
This commit is contained in:
EugeneTes
2026-09-06 14:24:58 +02:00
parent 7373c51995
commit 9affcc1c03
2 changed files with 138 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { escapeHtml, markdownToTelegramHtml } from "./markdown.js";
describe("escapeHtml", () => {
it("escapes the three HTML specials", () => {
expect(escapeHtml("a < b && c > d")).toBe("a &lt; b &amp;&amp; c &gt; d");
});
});
describe("markdownToTelegramHtml", () => {
it("escapes plain text", () => {
expect(markdownToTelegramHtml("1 < 2 & 3 > 2")).toBe("1 &lt; 2 &amp; 3 &gt; 2");
});
it("renders inline formatting", () => {
expect(markdownToTelegramHtml("**bold** __bold2__ *it* _it2_ ~~gone~~ `co<de>`")).toBe(
"<b>bold</b> <b>bold2</b> <i>it</i> <i>it2</i> <s>gone</s> <code>co&lt;de&gt;</code>",
);
});
it("leaves snake_case words and lone asterisks alone", () => {
expect(markdownToTelegramHtml("use snake_case_names and 2 * 3")).toBe("use snake_case_names and 2 * 3");
});
it("does not format inside inline code", () => {
expect(markdownToTelegramHtml("run `**not bold**`")).toBe("run <code>**not bold**</code>");
});
it("renders links", () => {
expect(markdownToTelegramHtml("see [docs](https://x.y/a?b=1&c=2)")).toBe(
'see <a href="https://x.y/a?b=1&amp;c=2">docs</a>',
);
});
it("renders headings as bold lines", () => {
expect(markdownToTelegramHtml("# Title\n\ntext")).toBe("<b>Title</b>\n\ntext");
});
it("renders bullet and ordered lists", () => {
expect(markdownToTelegramHtml("- one\n* two\n1. three\n - nested")).toBe("• one\n• two\n1. three\n • nested");
});
it("renders fenced code with and without language, escaped and unformatted", () => {
const md = "```ts\nconst a = 1 < 2; // **x**\n```\n\n```\nplain\n```";
expect(markdownToTelegramHtml(md)).toBe(
'<pre><code class="language-ts">const a = 1 &lt; 2; // **x**</code></pre>\n\n<pre>plain</pre>',
);
});
it("merges consecutive blockquote lines and renders rules", () => {
expect(markdownToTelegramHtml("> a\n> b\n\n---\n\nc")).toBe("<blockquote>a\nb</blockquote>\n\n───\n\nc");
});
it("collapses runs of blank lines and trims", () => {
expect(markdownToTelegramHtml("\n\na\n\n\n\nb\n\n")).toBe("a\n\nb");
});
});

81
src/telegram/markdown.ts Normal file
View File

@@ -0,0 +1,81 @@
export function escapeHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
const FENCE = /```([\w+-]*)[ \t]*\n([\s\S]*?)\n?```/g;
/** Convert a Markdown subset to Telegram HTML. Unknown constructs are passed through escaped. */
export function markdownToTelegramHtml(markdown: string): string {
const out: string[] = [];
let last = 0;
for (const m of markdown.matchAll(FENCE)) {
out.push(renderBlocks(markdown.slice(last, m.index)));
const lang = m[1];
const code = escapeHtml(m[2]);
out.push(lang ? `<pre><code class="language-${lang}">${code}</code></pre>` : `<pre>${code}</pre>`);
last = m.index + m[0].length;
}
out.push(renderBlocks(markdown.slice(last)));
return out.join("").replace(/\n{3,}/g, "\n\n").trim();
}
function renderBlocks(text: string): string {
const lines = text.split("\n");
const out: string[] = [];
let quote: string[] | null = null;
const flushQuote = () => {
if (quote) out.push(`<blockquote>${quote.join("\n")}</blockquote>`);
quote = null;
};
for (const line of lines) {
const q = /^\s{0,3}>\s?(.*)$/.exec(line);
if (q) {
(quote ??= []).push(renderInline(q[1]));
continue;
}
flushQuote();
const heading = /^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/.exec(line);
if (heading) {
out.push(`<b>${renderInline(heading[1])}</b>`);
continue;
}
if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(line)) {
out.push("───");
continue;
}
const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
if (bullet) {
out.push(`${bullet[1]}• ${renderInline(bullet[2])}`);
continue;
}
const ordered = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(line);
if (ordered) {
out.push(`${ordered[1]}${ordered[2]}. ${renderInline(ordered[3])}`);
continue;
}
out.push(renderInline(line));
}
flushQuote();
return out.join("\n");
}
function renderInline(text: string): string {
const codes: string[] = [];
let s = text.replace(/`([^`\n]+)`/g, (_m, code: string) => {
codes.push(`<code>${escapeHtml(code)}</code>`);
return `\u0000${codes.length - 1}\u0000`;
});
s = escapeHtml(s);
s = s.replace(
/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g,
(_m, label: string, url: string) => `<a href="${url.replace(/"/g, "&quot;")}">${label}</a>`,
);
s = s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
s = s.replace(/__(.+?)__/g, "<b>$1</b>");
s = s.replace(/~~(.+?)~~/g, "<s>$1</s>");
s = s.replace(/(^|[^*\w])\*(?!\s)([^*\n]+?)(?<!\s)\*(?!\w)/g, "$1<i>$2</i>");
s = s.replace(/(^|[^_\w])_(?!\s)([^_\n]+?)(?<!\s)_(?!\w)/g, "$1<i>$2</i>");
return s.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => codes[Number(i)]);
}