新增三层 Markdown → Telegram HTML 渲染管线: markdown/ir.ts — markdown-it 解析 Markdown 为中间表示 (MarkdownIR) - 支持 bold/italic/strikethrough/code/code_block/blockquote/links/lists/headings/tables/hr - 表格使用 code-block 模式渲染为 ASCII 表格(<pre> 保留对齐) - HTML 内联 <br> 标签转换为换行符 markdown/render.ts — 通用标记渲染器 - boundary tracking + LIFO stack 处理嵌套样式 markdown/telegram.ts — Telegram 专用渲染器 - 样式映射到 Telegram HTML 标签 (b/i/s/code/pre+code/blockquote/a) - 文件引用保护:README.md/main.go 等不被误识别为 URL - render-first 分片:按渲染后 HTML 长度分片,超限时按比例重分割 delivery-layer.ts — 新增 deliverRendered() 接收预渲染 chunks - HTML + plain text 双通道,解析失败自动降级纯文本 - sendWithRetry 支持 plainFallback 参数 bridge-manager.ts — Claude 回复改用 markdownToTelegramChunks + deliverRendered - 命令响应和错误消息保持 escapeHtml + deliver Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
193 lines
4.7 KiB
TypeScript
193 lines
4.7 KiB
TypeScript
import type { MarkdownIR, MarkdownLinkSpan, MarkdownStyle, MarkdownStyleSpan } from './ir';
|
|
|
|
export type RenderStyleMarker = {
|
|
open: string;
|
|
close: string;
|
|
};
|
|
|
|
export type RenderStyleMap = Partial<Record<MarkdownStyle, RenderStyleMarker>>;
|
|
|
|
export type RenderLink = {
|
|
start: number;
|
|
end: number;
|
|
open: string;
|
|
close: string;
|
|
};
|
|
|
|
export type RenderOptions = {
|
|
styleMarkers: RenderStyleMap;
|
|
escapeText: (text: string) => string;
|
|
buildLink?: (link: MarkdownLinkSpan, text: string) => RenderLink | null;
|
|
};
|
|
|
|
const STYLE_ORDER: MarkdownStyle[] = [
|
|
'blockquote',
|
|
'code_block',
|
|
'code',
|
|
'bold',
|
|
'italic',
|
|
'strikethrough',
|
|
];
|
|
|
|
const STYLE_RANK = new Map<MarkdownStyle, number>(
|
|
STYLE_ORDER.map((style, index) => [style, index]),
|
|
);
|
|
|
|
function sortStyleSpans(spans: MarkdownStyleSpan[]): MarkdownStyleSpan[] {
|
|
return [...spans].toSorted((a, b) => {
|
|
if (a.start !== b.start) {
|
|
return a.start - b.start;
|
|
}
|
|
if (a.end !== b.end) {
|
|
return b.end - a.end;
|
|
}
|
|
return (STYLE_RANK.get(a.style) ?? 0) - (STYLE_RANK.get(b.style) ?? 0);
|
|
});
|
|
}
|
|
|
|
export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions): string {
|
|
const text = ir.text ?? '';
|
|
if (!text) {
|
|
return '';
|
|
}
|
|
|
|
const styleMarkers = options.styleMarkers;
|
|
const styled = sortStyleSpans(ir.styles.filter((span) => Boolean(styleMarkers[span.style])));
|
|
|
|
const boundaries = new Set<number>();
|
|
boundaries.add(0);
|
|
boundaries.add(text.length);
|
|
|
|
const startsAt = new Map<number, MarkdownStyleSpan[]>();
|
|
for (const span of styled) {
|
|
if (span.start === span.end) {
|
|
continue;
|
|
}
|
|
boundaries.add(span.start);
|
|
boundaries.add(span.end);
|
|
const bucket = startsAt.get(span.start);
|
|
if (bucket) {
|
|
bucket.push(span);
|
|
} else {
|
|
startsAt.set(span.start, [span]);
|
|
}
|
|
}
|
|
for (const spans of startsAt.values()) {
|
|
spans.sort((a, b) => {
|
|
if (a.end !== b.end) {
|
|
return b.end - a.end;
|
|
}
|
|
return (STYLE_RANK.get(a.style) ?? 0) - (STYLE_RANK.get(b.style) ?? 0);
|
|
});
|
|
}
|
|
|
|
const linkStarts = new Map<number, RenderLink[]>();
|
|
if (options.buildLink) {
|
|
for (const link of ir.links) {
|
|
if (link.start === link.end) {
|
|
continue;
|
|
}
|
|
const rendered = options.buildLink(link, text);
|
|
if (!rendered) {
|
|
continue;
|
|
}
|
|
boundaries.add(rendered.start);
|
|
boundaries.add(rendered.end);
|
|
const openBucket = linkStarts.get(rendered.start);
|
|
if (openBucket) {
|
|
openBucket.push(rendered);
|
|
} else {
|
|
linkStarts.set(rendered.start, [rendered]);
|
|
}
|
|
}
|
|
}
|
|
|
|
const points = [...boundaries].toSorted((a, b) => a - b);
|
|
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];
|
|
|
|
while (stack.length && stack[stack.length - 1]?.end === pos) {
|
|
const item = stack.pop();
|
|
if (item) {
|
|
out += item.close;
|
|
}
|
|
}
|
|
|
|
const openingItems: OpeningItem[] = [];
|
|
|
|
const openingLinks = linkStarts.get(pos);
|
|
if (openingLinks && openingLinks.length > 0) {
|
|
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 [index, span] of openingStyles.entries()) {
|
|
const marker = styleMarkers[span.style];
|
|
if (!marker) {
|
|
continue;
|
|
}
|
|
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;
|
|
});
|
|
|
|
for (const item of openingItems) {
|
|
out += item.open;
|
|
stack.push({ close: item.close, end: item.end });
|
|
}
|
|
}
|
|
|
|
const next = points[i + 1];
|
|
if (next === undefined) {
|
|
break;
|
|
}
|
|
if (next > pos) {
|
|
out += options.escapeText(text.slice(pos, next));
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|