Add Markdown to Telegram HTML converter
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT
This commit is contained in:
57
src/telegram/markdown.test.ts
Normal file
57
src/telegram/markdown.test.ts
Normal 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 < b && c > d");
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdownToTelegramHtml", () => {
|
||||
it("escapes plain text", () => {
|
||||
expect(markdownToTelegramHtml("1 < 2 & 3 > 2")).toBe("1 < 2 & 3 > 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<de></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&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 < 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
81
src/telegram/markdown.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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, """)}">${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)]);
|
||||
}
|
||||
Reference in New Issue
Block a user