diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8b0e115a..191c2172d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ Status: stable. ### Fixes - Telegram: use undici fetch for per-account proxy dispatcher. (#4456) Thanks @spiceoogway. +- Telegram: fix HTML nesting for overlapping styles and links. (#4578) Thanks @ThanhNguyxn. - Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796) - Telegram: accept numeric messageId/chatId in react action and honor channelId fallback. (#4533) Thanks @Ayush10. - Telegram: scope native skill commands to bound agent per bot. (#4360) Thanks @robhparker. diff --git a/src/markdown/render.ts b/src/markdown/render.ts index dad833a051..9793ab1675 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -69,7 +69,6 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions } const linkStarts = new Map(); - const linkEnds = new Map(); if (options.buildLink) { for (const link of ir.links) { if (link.start === link.end) continue; @@ -80,15 +79,22 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions const openBucket = linkStarts.get(rendered.start); if (openBucket) openBucket.push(rendered); else linkStarts.set(rendered.start, [rendered]); - const closeBucket = linkEnds.get(rendered.end); - if (closeBucket) closeBucket.push(rendered); - else linkEnds.set(rendered.end, [rendered]); } } const points = [...boundaries].sort((a, b) => a - b); // Unified stack for both styles and links, tracking close string and end position const stack: { close: string; end: number }[] = []; + type OpeningItem = + | { end: number; open: string; close: string; kind: "link"; index: number } + | { + end: number; + open: string; + close: string; + kind: "style"; + style: MarkdownStyle; + index: number; + }; let out = ""; for (let i = 0; i < points.length; i += 1) { @@ -100,23 +106,51 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions if (item) out += item.close; } - // Open links first (so they close after styles that start at the same position) + const openingItems: OpeningItem[] = []; + const openingLinks = linkStarts.get(pos); if (openingLinks && openingLinks.length > 0) { - for (const link of openingLinks) { - out += link.open; - stack.push({ close: link.close, end: link.end }); + for (const [index, link] of openingLinks.entries()) { + openingItems.push({ + end: link.end, + open: link.open, + close: link.close, + kind: "link", + index, + }); } } - // Open styles second (so they close before links that start at the same position) const openingStyles = startsAt.get(pos); if (openingStyles) { - for (const span of openingStyles) { + for (const [index, span] of openingStyles.entries()) { const marker = styleMarkers[span.style]; if (!marker) continue; - out += marker.open; - stack.push({ close: marker.close, end: span.end }); + openingItems.push({ + end: span.end, + open: marker.open, + close: marker.close, + kind: "style", + style: span.style, + index, + }); + } + } + + if (openingItems.length > 0) { + openingItems.sort((a, b) => { + if (a.end !== b.end) return b.end - a.end; + if (a.kind !== b.kind) return a.kind === "link" ? -1 : 1; + if (a.kind === "style" && b.kind === "style") { + return (STYLE_RANK.get(a.style) ?? 0) - (STYLE_RANK.get(b.style) ?? 0); + } + return a.index - b.index; + }); + + // Open outer spans first (larger end) so LIFO closes stay valid for same-start overlaps. + for (const item of openingItems) { + out += item.open; + stack.push({ close: item.close, end: item.end }); } } diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index fbc811b728..e267719a83 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -59,4 +59,14 @@ describe("markdownToTelegramHtml", () => { const res = markdownToTelegramHtml("**bold [link](https://example.com) text**"); expect(res).toBe('bold link text'); }); + + it("properly nests bold wrapping a link with trailing text", () => { + const res = markdownToTelegramHtml("**[link](https://example.com) rest**"); + expect(res).toBe('link rest'); + }); + + it("properly nests bold inside a link", () => { + const res = markdownToTelegramHtml("[**bold**](https://example.com)"); + expect(res).toBe('bold'); + }); });