= { b: true, i: true, u: true, s: true, code: true, pre: true, a: true, blockquote: true, "tg-spoiler": true };
/** Escape text for Telegram HTML body content (`& < >`). */
export function escapeHtml(value: string): string {
return value.replace(/&/g, "&").replace(//g, ">");
}
/** Escape a value for use inside a double-quoted HTML attribute. */
function escapeAttr(value: string): string {
return escapeHtml(value).replace(/"/g, """);
}
/** Wrap already-escaped text in a Telegram tag. */
function tag(name: string, escaped: string): string {
return `<${name}>${escaped}${name}>`;
}
/** Bold the given raw text (escaped internally). */
export function bold(raw: string): string {
return tag("b", escapeHtml(raw));
}
/** Italicize the given raw text (escaped internally). */
export function italic(raw: string): string {
return tag("i", escapeHtml(raw));
}
/** Render the given raw text as inline code (escaped internally). */
export function code(raw: string): string {
return tag("code", escapeHtml(raw));
}
/** Render the given raw text as a preformatted block (escaped internally). */
export function pre(raw: string): string {
return tag("pre", escapeHtml(raw));
}
const PLACEHOLDER_PREFIX = "\u0000ph";
const PLACEHOLDER_SUFFIX = "\u0000";
/** Only http(s) and mailto links are emitted; anything else stays literal. */
function isSafeUrl(url: string): boolean {
return /^(https?:\/\/|mailto:)/i.test(url);
}
/** Column alignment parsed from a GFM table separator cell. */
type ColumnAlign = "left" | "right" | "center";
/** Split a markdown table row into trimmed cells, honoring escaped `\|`. */
function splitTableRow(line: string): string[] {
let s = line.trim();
if (s.startsWith("|")) s = s.slice(1);
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
const cells: string[] = [];
let cur = "";
for (let i = 0; i < s.length; i++) {
const ch = s[i]!;
if (ch === "\\" && s[i + 1] === "|") {
cur += "|";
i++;
continue;
}
if (ch === "|") {
cells.push(cur);
cur = "";
continue;
}
cur += ch;
}
cells.push(cur);
return cells.map(c => c.trim());
}
/** A line is a candidate table row when it contains an unescaped `|`. */
function looksLikeTableRow(line: string): boolean {
return /(?:^|[^\\])\|/.test(line);
}
/** A separator row has only dashes/colons/spaces per cell, with at least one dash. */
function isTableSeparator(line: string): boolean {
if (!looksLikeTableRow(line)) return false;
const cells = splitTableRow(line);
return cells.length > 0 && cells.every(c => /^:?-+:?$/.test(c));
}
/** Derive a column alignment from a separator cell (`:---`, `---:`, `:---:`). */
function parseAlign(cell: string): ColumnAlign {
const c = cell.trim();
const left = c.startsWith(":");
const right = c.endsWith(":");
if (left && right) return "center";
if (right) return "right";
return "left";
}
/** Render parsed table parts as an aligned, monospace-friendly plain-text grid. */
function renderTableText(header: string[], aligns: ColumnAlign[], body: string[][]): string {
const rows = [header, ...body];
const cols = Math.max(header.length, ...body.map(r => r.length));
const widths: number[] = [];
for (let c = 0; c < cols; c++) {
let w = 1;
for (const row of rows) w = Math.max(w, (row[c] ?? "").length);
widths[c] = w;
}
const padCell = (value: string, c: number): string => {
const width = widths[c]!;
const pad = width - value.length;
if (pad <= 0) return value;
const align = aligns[c] ?? "left";
if (align === "right") return " ".repeat(pad) + value;
if (align === "center") {
const leftPad = Math.floor(pad / 2);
return " ".repeat(leftPad) + value + " ".repeat(pad - leftPad);
}
return value + " ".repeat(pad);
};
const renderRow = (row: string[]): string =>
Array.from({ length: cols }, (_v, c) => padCell(row[c] ?? "", c)).join(" | ");
const divider = widths.map(w => "-".repeat(w)).join("-|-");
return [renderRow(header), divider, ...body.map(renderRow)].join("\n");
}
/**
* Replace GFM tables with stashed monospace `` blocks. Telegram HTML has no
* table primitive, so a header row followed by a `|---|` separator is rendered as
* an aligned plain-text grid (cell content escaped by `pre`).
*/
function convertMarkdownTables(text: string, stash: (html: string) => string): string {
const lines = text.split("\n");
const out: string[] = [];
for (let i = 0; i < lines.length; i++) {
const headerLine = lines[i]!;
const separatorLine = lines[i + 1];
if (looksLikeTableRow(headerLine) && separatorLine !== undefined && isTableSeparator(separatorLine)) {
const header = splitTableRow(headerLine);
const aligns = splitTableRow(separatorLine).map(parseAlign);
const body: string[][] = [];
let j = i + 2;
for (; j < lines.length; j++) {
const row = lines[j]!;
if (!looksLikeTableRow(row) || isTableSeparator(row)) break;
body.push(splitTableRow(row));
}
out.push(stash(pre(renderTableText(header, aligns, body))));
i = j - 1;
continue;
}
out.push(headerLine);
}
return out.join("\n");
}
/**
* Convert a bounded markdown subset into Telegram HTML. Supported: fenced code,
* GFM tables, inline code, links (`http(s)`/`mailto` only), `#`-headers (bolded,
* Telegram HTML has no heading tag), `>` blockquotes (merged across consecutive
* lines), and `**bold**`/`*italic*` emphasis. Everything else passes through
* escaped, so unsupported/malformed markdown degrades to literal text rather
* than breaking the message.
*/
export function markdownToTelegramHtml(markdown: string): string {
const placeholders: string[] = [];
const stash = (html: string): string => {
const token = `${PLACEHOLDER_PREFIX}${placeholders.length}${PLACEHOLDER_SUFFIX}`;
placeholders.push(html);
return token;
};
let text = markdown;
// 1. Fenced code blocks (protect literal content before any other transform).
text = text.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_m, body: string) => stash(pre(body)));
// 1b. GFM tables -> aligned monospace block (no native table primitive).
text = convertMarkdownTables(text, stash);
// 2. Inline code.
text = text.replace(/`([^`\n]+)`/g, (_m, body: string) => stash(code(body)));
// 3. Links (capture raw URL before escaping). Unsafe/malformed links stay literal.
text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (whole, label: string, url: string) => {
if (!isSafeUrl(url)) return whole;
return stash(`${escapeHtml(label)}`);
});
// 4. Escape everything that remains (placeholders contain no escapable chars).
text = escapeHtml(text);
// 5. Line-level transforms on escaped text: headers and merged blockquotes.
const lines = text.split("\n");
const out: string[] = [];
let quoteBuffer: string[] | null = null;
const flushQuote = () => {
if (quoteBuffer) {
out.push(tag("blockquote", quoteBuffer.join("\n")));
quoteBuffer = null;
}
};
for (const line of lines) {
const quote = /^>\s?(.*)$/.exec(line);
if (quote) {
if (!quoteBuffer) {
quoteBuffer = [];
}
quoteBuffer.push(quote[1] ?? "");
continue;
}
flushQuote();
const header = /^(#{1,6})\s+(.*)$/.exec(line);
out.push(header ? tag("b", header[2] ?? "") : line);
}
flushQuote();
text = out.join("\n");
// 6. Inline emphasis (bold before italic; unbalanced markers stay literal).
text = text.replace(/\*\*([^*\n]+)\*\*/g, (_m, body: string) => tag("b", body));
text = text.replace(/\*([^*\n]+)\*/g, (_m, body: string) => tag("i", body));
// 7. Restore protected placeholders.
text = text.replace(
new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, "g"),
(_m, i: string) => placeholders[Number(i)] ?? "",
);
return text;
}
interface Token {
value: string;
/** Tag name if this token opens a tag, else undefined. */
open?: string;
/** Exact opening tag text, including attributes, for safe chunk reopening. */
openTag?: string;
/** Tag name if this token closes a tag, else undefined. */
close?: string;
}
/** Tokenize HTML into tags, entities, and single characters (never splits them). */
function tokenize(html: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < html.length) {
const ch = html[i]!;
if (ch === "<") {
const end = html.indexOf(">", i);
if (end !== -1) {
const raw = html.slice(i, end + 1);
const close = /^<\/([a-z-]+)>$/i.exec(raw);
const openMatch = /^<([a-z-]+)(?:\s[^>]*)?>$/i.exec(raw);
const token: Token = { value: raw };
if (close && ALLOWED_TAGS[close[1]!.toLowerCase()]) token.close = close[1]!.toLowerCase();
else if (openMatch && ALLOWED_TAGS[openMatch[1]!.toLowerCase()]) {
token.open = openMatch[1]!.toLowerCase();
token.openTag = raw;
}
tokens.push(token);
i = end + 1;
continue;
}
}
if (ch === "&") {
const end = html.indexOf(";", i);
if (end !== -1 && end - i <= 10) {
tokens.push({ value: html.slice(i, end + 1) });
i = end + 1;
continue;
}
}
tokens.push({ value: ch });
i++;
}
return tokens;
}
/**
* Truncate a finished Telegram HTML message to at most `max` chars without
* splitting a tag or entity, closing any still-open allowed tags and appending
* `marker`. The final string is guaranteed to be <= `max`.
*/
export function truncateTelegramHtml(message: string, max = TELEGRAM_MESSAGE_LIMIT, marker = "… [truncated]"): string {
if (message.length <= max) return message;
// When `max` is too small to even hold the marker, drop it so the hard
// length guarantee (output.length <= max) still holds.
const effectiveMarker = marker.length <= max ? marker : "";
const tokens = tokenize(message);
const stack: string[] = [];
let out = "";
const closersFor = (s: string[]): string =>
s
.map(t => `${t}>`)
.reverse()
.join("");
for (const token of tokens) {
// Simulate accepting this token, then ensure we can still close + mark.
const nextStack = [...stack];
if (token.open) nextStack.push(token.open);
else if (token.close) {
const idx = nextStack.lastIndexOf(token.close);
if (idx !== -1) nextStack.splice(idx, 1);
}
const projected = out.length + token.value.length + closersFor(nextStack).length + effectiveMarker.length;
if (projected > max) break;
out += token.value;
if (token.open) stack.push(token.open);
else if (token.close) {
const idx = stack.lastIndexOf(token.close);
if (idx !== -1) stack.splice(idx, 1);
}
}
return out + closersFor(stack) + effectiveMarker;
}
interface OpenTag {
name: string;
tag: string;
}
/** Split a finished Telegram HTML message into ordered chunks without splitting tags or entities. */
export function splitTelegramHtml(message: string, max = TELEGRAM_MESSAGE_LIMIT): string[] {
if (message.length <= max) return [message];
const chunks: string[] = [];
const tokens = tokenize(message);
const stack: OpenTag[] = [];
let out = "";
let chunkHasBody = false;
const closersFor = (s: OpenTag[]): string =>
s
.map(t => `${t.name}>`)
.reverse()
.join("");
const openersFor = (s: OpenTag[]): string => s.map(t => t.tag).join("");
const minimumChunkLengthFor = (s: OpenTag[]): number => openersFor(s).length + closersFor(s).length + 1;
const flush = (): void => {
if (!chunkHasBody) return;
chunks.push(out + closersFor(stack));
out = openersFor(stack);
chunkHasBody = false;
};
const updateStackForClose = (name: string): boolean => {
const idx = stack.findLastIndex(t => t.name === name);
if (idx === -1) return false;
stack.splice(idx, 1);
return true;
};
for (const token of tokens) {
if (token.open) {
const nextStack = [...stack, { name: token.open, tag: token.openTag ?? token.value }];
if (minimumChunkLengthFor(nextStack) > max) continue;
if (chunkHasBody && out.length + token.value.length + closersFor(nextStack).length > max) flush();
if (out.length + token.value.length + closersFor(nextStack).length > max) continue;
out += token.value;
chunkHasBody = true;
stack.push({ name: token.open, tag: token.openTag ?? token.value });
continue;
}
if (token.close) {
const idx = stack.findLastIndex(t => t.name === token.close);
if (idx === -1) continue;
const nextStack = stack.toSpliced(idx, 1);
if (chunkHasBody && out.length + token.value.length + closersFor(nextStack).length > max) flush();
if (out.length + token.value.length + closersFor(nextStack).length > max) {
updateStackForClose(token.close);
continue;
}
out += token.value;
chunkHasBody = true;
updateStackForClose(token.close);
continue;
}
if (chunkHasBody && out.length + token.value.length + closersFor(stack).length > max) flush();
out += token.value;
chunkHasBody = true;
}
if (chunkHasBody) chunks.push(out + closersFor(stack));
return chunks;
}
/** Finalize an optional message: undefined passthrough, else safe-truncate. */
export function finalizeTelegramHtml(message?: string): string | undefined {
if (message === undefined) return undefined;
return truncateTelegramHtml(message);
}