fix(telegram): preserve fenced code languages (#85209)

Co-authored-by: wuyangfan <yangfan.wu@succaiss.com>
This commit is contained in:
吴杨帆
2026-05-22 16:59:06 +08:00
committed by GitHub
parent 6f933656e5
commit a80476fbe9
5 changed files with 58 additions and 16 deletions

View File

@@ -96,9 +96,9 @@ describe("markdownToTelegramHtml", () => {
expect(res.match(/<blockquote>/g)).toHaveLength(2);
});
it("renders fenced code blocks", () => {
const res = markdownToTelegramHtml("```js\nconst x = 1;\n```");
expect(res).toBe("<pre><code>const x = 1;\n</code></pre>");
it("renders fenced code block languages for Telegram native copy buttons", () => {
const res = markdownToTelegramHtml('```bash\necho "hello"\n```');
expect(res).toBe('<pre><code class="language-bash">echo "hello"\n</code></pre>');
});
it("properly nests overlapping bold and autolink (#4071)", () => {

View File

@@ -60,6 +60,13 @@ function buildTelegramLink(link: MarkdownLinkSpan, text: string) {
};
}
function buildTelegramCodeBlockOpen(span: { language?: string }): string {
if (!span.language) {
return "<pre><code>";
}
return `<pre><code class="language-${escapeHtmlAttr(span.language)}">`;
}
function renderTelegramHtml(ir: MarkdownIR): string {
return renderMarkdownWithMarkers(ir, {
styleMarkers: {
@@ -67,7 +74,7 @@ function renderTelegramHtml(ir: MarkdownIR): string {
italic: { open: "<i>", close: "</i>" },
strikethrough: { open: "<s>", close: "</s>" },
code: { open: "<code>", close: "</code>" },
code_block: { open: "<pre><code>", close: "</code></pre>" },
code_block: { open: buildTelegramCodeBlockOpen, close: "</code></pre>" },
spoiler: { open: "<tg-spoiler>", close: "</tg-spoiler>" },
blockquote: { open: "<blockquote>", close: "</blockquote>" },
},

View File

@@ -20,6 +20,7 @@ type RenderEnv = {
type MarkdownToken = {
type: string;
content?: string;
info?: string;
children?: MarkdownToken[];
attrs?: [string, string][];
attrGet?: (name: string) => string | null;
@@ -40,6 +41,7 @@ export type MarkdownStyleSpan = {
start: number;
end: number;
style: MarkdownStyle;
language?: string;
};
export type MarkdownLinkSpan = {
@@ -54,6 +56,18 @@ export type MarkdownIR = {
links: MarkdownLinkSpan[];
};
function createStyleSpan(params: MarkdownStyleSpan): MarkdownStyleSpan {
const span: MarkdownStyleSpan = {
start: params.start,
end: params.end,
style: params.style,
};
if (params.language) {
span.language = params.language;
}
return span;
}
export type MarkdownTableData = {
headers: string[];
rows: string[][];
@@ -325,7 +339,12 @@ function renderInlineCode(state: RenderState, content: string) {
target.styles.push({ start, end: start + content.length, style: "code" });
}
function renderCodeBlock(state: RenderState, content: string) {
function resolveFenceLanguage(info: string | undefined): string | undefined {
const language = info?.trim().split(/\s+/, 1)[0]?.trim();
return language || undefined;
}
function renderCodeBlock(state: RenderState, content: string, info?: string) {
let code = content ?? "";
if (!code.endsWith("\n")) {
code = `${code}\n`;
@@ -333,7 +352,14 @@ function renderCodeBlock(state: RenderState, content: string) {
const target = resolveRenderTarget(state);
const start = target.text.length;
target.text += code;
target.styles.push({ start, end: start + code.length, style: "code_block" });
target.styles.push(
createStyleSpan({
start,
end: start + code.length,
style: "code_block",
language: resolveFenceLanguage(info),
}),
);
if (state.env.listStack.length === 0) {
target.text += "\n";
}
@@ -732,7 +758,7 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
break;
case "code_block":
case "fence":
renderCodeBlock(state, token.content ?? "");
renderCodeBlock(state, token.content ?? "", token.info);
break;
case "html_block":
case "html_inline":
@@ -834,7 +860,7 @@ function clampStyleSpans(spans: MarkdownStyleSpan[], maxLength: number): Markdow
const start = Math.max(0, Math.min(span.start, maxLength));
const end = Math.max(start, Math.min(span.end, maxLength));
if (end > start) {
clamped.push({ start, end, style: span.style });
clamped.push(createStyleSpan({ start, end, style: span.style, language: span.language }));
}
}
return clamped;
@@ -869,6 +895,7 @@ function mergeStyleSpans(spans: MarkdownStyleSpan[]): MarkdownStyleSpan[] {
if (
prev &&
prev.style === span.style &&
prev.language === span.language &&
// Blockquotes are container blocks. Adjacent blockquote spans should not merge or
// consecutive blockquotes can "style bleed" across the paragraph boundary.
(span.start < prev.end || (span.start === prev.end && span.style !== "blockquote"))
@@ -908,11 +935,14 @@ function sliceStyleSpans(
if (!bounds) {
continue;
}
sliced.push({
start: bounds.start - start,
end: bounds.end - start,
style: span.style,
});
sliced.push(
createStyleSpan({
start: bounds.start - start,
end: bounds.end - start,
style: span.style,
language: span.language,
}),
);
}
return mergeStyleSpans(sliced);
}

View File

@@ -215,7 +215,12 @@ function mergeAdjacentStyleSpans(styles: MarkdownStyleSpan[]): MarkdownStyleSpan
const merged: MarkdownStyleSpan[] = [];
for (const span of styles) {
const last = merged.at(-1);
if (last && last.style === span.style && span.start <= last.end) {
if (
last &&
last.style === span.style &&
last.language === span.language &&
span.start <= last.end
) {
last.end = Math.max(last.end, span.end);
continue;
}

View File

@@ -1,7 +1,7 @@
import type { MarkdownIR, MarkdownLinkSpan, MarkdownStyle, MarkdownStyleSpan } from "./ir.js";
export type RenderStyleMarker = {
open: string;
open: string | ((span: MarkdownStyleSpan) => string);
close: string;
};
@@ -153,7 +153,7 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions
}
openingItems.push({
end: span.end,
open: marker.open,
open: typeof marker.open === "function" ? marker.open(span) : marker.open,
close: marker.close,
kind: "style",
style: span.style,