Compare commits

...

3 Commits

Author SHA1 Message Date
Ayaan Zaidi
8c4e676442 fix: correct telegram html nesting (#4578) (thanks @ThanhNguyxn) 2026-01-30 16:48:17 +05:30
ThanhNguyxn
01e291016d style: format test file 2026-01-30 16:48:17 +05:30
ThanhNguyxn
80089f5d22 fix(telegram): properly nest overlapping HTML tags (#4071)
Unify style and link closing in render.ts to use LIFO order across
both element types, fixing cases where bold/italic spans containing
autolinks produced invalid HTML like <b><a></b></a>.
2026-01-30 16:48:17 +05:30
3 changed files with 73 additions and 20 deletions

View File

@ -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.

View File

@ -69,7 +69,6 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions
}
const linkStarts = new Map<number, RenderLink[]>();
const linkEnds = new Map<number, RenderLink[]>();
if (options.buildLink) {
for (const link of ir.links) {
if (link.start === link.end) continue;
@ -80,47 +79,78 @@ 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);
const stack: MarkdownStyleSpan[] = [];
// 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) {
const pos = points[i];
// Close ALL elements (styles and links) in LIFO order at this position
while (stack.length && stack[stack.length - 1]?.end === pos) {
const span = stack.pop();
if (!span) break;
const marker = styleMarkers[span.style];
if (marker) out += marker.close;
const item = stack.pop();
if (item) out += item.close;
}
const closingLinks = linkEnds.get(pos);
if (closingLinks && closingLinks.length > 0) {
for (const link of closingLinks) {
out += link.close;
}
}
const openingItems: OpeningItem[] = [];
const openingLinks = linkStarts.get(pos);
if (openingLinks && openingLinks.length > 0) {
for (const link of openingLinks) {
out += link.open;
for (const [index, link] of openingLinks.entries()) {
openingItems.push({
end: link.end,
open: link.open,
close: link.close,
kind: "link",
index,
});
}
}
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;
stack.push(span);
out += marker.open;
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 });
}
}

View File

@ -47,4 +47,26 @@ describe("markdownToTelegramHtml", () => {
const res = markdownToTelegramHtml("```js\nconst x = 1;\n```");
expect(res).toBe("<pre><code>const x = 1;\n</code></pre>");
});
it("properly nests overlapping bold and autolink (#4071)", () => {
const res = markdownToTelegramHtml("**start https://example.com** end");
expect(res).toMatch(
/<b>start <a href="https:\/\/example\.com">https:\/\/example\.com<\/a><\/b> end/,
);
});
it("properly nests link inside bold", () => {
const res = markdownToTelegramHtml("**bold [link](https://example.com) text**");
expect(res).toBe('<b>bold <a href="https://example.com">link</a> text</b>');
});
it("properly nests bold wrapping a link with trailing text", () => {
const res = markdownToTelegramHtml("**[link](https://example.com) rest**");
expect(res).toBe('<b><a href="https://example.com">link</a> rest</b>');
});
it("properly nests bold inside a link", () => {
const res = markdownToTelegramHtml("[**bold**](https://example.com)");
expect(res).toBe('<a href="https://example.com"><b>bold</b></a>');
});
});