From a80476fbe9f19c818633bee7c73bf03f619ffaa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=A8=E5=B8=86?= <39647285+leno23@users.noreply.github.com> Date: Fri, 22 May 2026 16:59:06 +0800 Subject: [PATCH] fix(telegram): preserve fenced code languages (#85209) Co-authored-by: wuyangfan --- extensions/telegram/src/format.test.ts | 6 ++-- extensions/telegram/src/format.ts | 9 ++++- src/markdown/ir.ts | 48 +++++++++++++++++++++----- src/markdown/render-aware-chunking.ts | 7 +++- src/markdown/render.ts | 4 +-- 5 files changed, 58 insertions(+), 16 deletions(-) diff --git a/extensions/telegram/src/format.test.ts b/extensions/telegram/src/format.test.ts index 0816f6461ba..fed6d39e6b4 100644 --- a/extensions/telegram/src/format.test.ts +++ b/extensions/telegram/src/format.test.ts @@ -96,9 +96,9 @@ describe("markdownToTelegramHtml", () => { expect(res.match(/
/g)).toHaveLength(2); }); - it("renders fenced code blocks", () => { - const res = markdownToTelegramHtml("```js\nconst x = 1;\n```"); - expect(res).toBe("
const x = 1;\n
"); + it("renders fenced code block languages for Telegram native copy buttons", () => { + const res = markdownToTelegramHtml('```bash\necho "hello"\n```'); + expect(res).toBe('
echo "hello"\n
'); }); it("properly nests overlapping bold and autolink (#4071)", () => { diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts index 04586ab5554..17079d341cf 100644 --- a/extensions/telegram/src/format.ts +++ b/extensions/telegram/src/format.ts @@ -60,6 +60,13 @@ function buildTelegramLink(link: MarkdownLinkSpan, text: string) { }; } +function buildTelegramCodeBlockOpen(span: { language?: string }): string { + if (!span.language) { + return "
";
+  }
+  return `
`;
+}
+
 function renderTelegramHtml(ir: MarkdownIR): string {
   return renderMarkdownWithMarkers(ir, {
     styleMarkers: {
@@ -67,7 +74,7 @@ function renderTelegramHtml(ir: MarkdownIR): string {
       italic: { open: "", close: "" },
       strikethrough: { open: "", close: "" },
       code: { open: "", close: "" },
-      code_block: { open: "
", close: "
" }, + code_block: { open: buildTelegramCodeBlockOpen, close: "
" }, spoiler: { open: "", close: "" }, blockquote: { open: "
", close: "
" }, }, diff --git a/src/markdown/ir.ts b/src/markdown/ir.ts index 9f3991fafb2..b2266dcb6f9 100644 --- a/src/markdown/ir.ts +++ b/src/markdown/ir.ts @@ -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); } diff --git a/src/markdown/render-aware-chunking.ts b/src/markdown/render-aware-chunking.ts index b840cb4854f..3f0441dc296 100644 --- a/src/markdown/render-aware-chunking.ts +++ b/src/markdown/render-aware-chunking.ts @@ -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; } diff --git a/src/markdown/render.ts b/src/markdown/render.ts index ee44ac97407..2db4385bbb7 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -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,