新增三层 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>
830 lines
20 KiB
TypeScript
830 lines
20 KiB
TypeScript
import MarkdownIt from 'markdown-it';
|
|
|
|
type ListState = {
|
|
type: 'bullet' | 'ordered';
|
|
index: number;
|
|
};
|
|
|
|
type LinkState = {
|
|
href: string;
|
|
labelStart: number;
|
|
};
|
|
|
|
type RenderEnv = {
|
|
listStack: ListState[];
|
|
};
|
|
|
|
type MarkdownToken = {
|
|
type: string;
|
|
content?: string;
|
|
children?: MarkdownToken[];
|
|
attrs?: [string, string][];
|
|
attrGet?: (name: string) => string | null;
|
|
};
|
|
|
|
export type MarkdownStyle =
|
|
| 'bold'
|
|
| 'italic'
|
|
| 'strikethrough'
|
|
| 'code'
|
|
| 'code_block'
|
|
| 'blockquote';
|
|
|
|
export type MarkdownStyleSpan = {
|
|
start: number;
|
|
end: number;
|
|
style: MarkdownStyle;
|
|
};
|
|
|
|
export type MarkdownLinkSpan = {
|
|
start: number;
|
|
end: number;
|
|
href: string;
|
|
};
|
|
|
|
export type MarkdownIR = {
|
|
text: string;
|
|
styles: MarkdownStyleSpan[];
|
|
links: MarkdownLinkSpan[];
|
|
};
|
|
|
|
type OpenStyle = {
|
|
style: MarkdownStyle;
|
|
start: number;
|
|
};
|
|
|
|
type RenderTarget = {
|
|
text: string;
|
|
styles: MarkdownStyleSpan[];
|
|
openStyles: OpenStyle[];
|
|
links: MarkdownLinkSpan[];
|
|
linkStack: LinkState[];
|
|
};
|
|
|
|
type TableCell = {
|
|
text: string;
|
|
styles: MarkdownStyleSpan[];
|
|
links: MarkdownLinkSpan[];
|
|
};
|
|
|
|
type TableState = {
|
|
headers: TableCell[];
|
|
rows: TableCell[][];
|
|
currentRow: TableCell[];
|
|
currentCell: RenderTarget | null;
|
|
inHeader: boolean;
|
|
};
|
|
|
|
type RenderState = RenderTarget & {
|
|
env: RenderEnv;
|
|
headingStyle: 'none' | 'bold';
|
|
blockquotePrefix: string;
|
|
table: TableState | null;
|
|
};
|
|
|
|
export type MarkdownParseOptions = {
|
|
linkify?: boolean;
|
|
headingStyle?: 'none' | 'bold';
|
|
blockquotePrefix?: string;
|
|
autolink?: boolean;
|
|
enableTables?: boolean;
|
|
};
|
|
|
|
function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
|
|
const md = new MarkdownIt({
|
|
html: false,
|
|
linkify: options.linkify ?? true,
|
|
breaks: false,
|
|
typographer: false,
|
|
});
|
|
md.enable('strikethrough');
|
|
if (options.enableTables) {
|
|
md.enable('table');
|
|
} else {
|
|
md.disable('table');
|
|
}
|
|
if (options.autolink === false) {
|
|
md.disable('autolink');
|
|
}
|
|
return md;
|
|
}
|
|
|
|
function getAttr(token: MarkdownToken, name: string): string | null {
|
|
if (token.attrGet) {
|
|
return token.attrGet(name);
|
|
}
|
|
if (token.attrs) {
|
|
for (const [key, value] of token.attrs) {
|
|
if (key === name) {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function initRenderTarget(): RenderTarget {
|
|
return {
|
|
text: '',
|
|
styles: [],
|
|
openStyles: [],
|
|
links: [],
|
|
linkStack: [],
|
|
};
|
|
}
|
|
|
|
function resolveRenderTarget(state: RenderState): RenderTarget {
|
|
return state.table?.currentCell ?? state;
|
|
}
|
|
|
|
function appendText(state: RenderState, value: string) {
|
|
if (!value) {
|
|
return;
|
|
}
|
|
const target = resolveRenderTarget(state);
|
|
target.text += value;
|
|
}
|
|
|
|
function openStyle(state: RenderState, style: MarkdownStyle) {
|
|
const target = resolveRenderTarget(state);
|
|
target.openStyles.push({ style, start: target.text.length });
|
|
}
|
|
|
|
function closeStyle(state: RenderState, style: MarkdownStyle) {
|
|
const target = resolveRenderTarget(state);
|
|
for (let i = target.openStyles.length - 1; i >= 0; i -= 1) {
|
|
if (target.openStyles[i]?.style === style) {
|
|
const start = target.openStyles[i].start;
|
|
target.openStyles.splice(i, 1);
|
|
const end = target.text.length;
|
|
if (end > start) {
|
|
target.styles.push({ start, end, style });
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function appendParagraphSeparator(state: RenderState) {
|
|
if (state.env.listStack.length > 0) {
|
|
return;
|
|
}
|
|
if (state.table) {
|
|
return;
|
|
}
|
|
state.text += '\n\n';
|
|
}
|
|
|
|
function appendListPrefix(state: RenderState) {
|
|
const stack = state.env.listStack;
|
|
const top = stack[stack.length - 1];
|
|
if (!top) {
|
|
return;
|
|
}
|
|
top.index += 1;
|
|
const indent = ' '.repeat(Math.max(0, stack.length - 1));
|
|
const prefix = top.type === 'ordered' ? `${top.index}. ` : '• ';
|
|
state.text += `${indent}${prefix}`;
|
|
}
|
|
|
|
function renderInlineCode(state: RenderState, content: string) {
|
|
if (!content) {
|
|
return;
|
|
}
|
|
const target = resolveRenderTarget(state);
|
|
const start = target.text.length;
|
|
target.text += content;
|
|
target.styles.push({ start, end: start + content.length, style: 'code' });
|
|
}
|
|
|
|
function renderCodeBlock(state: RenderState, content: string) {
|
|
let code = content ?? '';
|
|
if (!code.endsWith('\n')) {
|
|
code = `${code}\n`;
|
|
}
|
|
const target = resolveRenderTarget(state);
|
|
const start = target.text.length;
|
|
target.text += code;
|
|
target.styles.push({ start, end: start + code.length, style: 'code_block' });
|
|
if (state.env.listStack.length === 0) {
|
|
target.text += '\n';
|
|
}
|
|
}
|
|
|
|
function handleLinkClose(state: RenderState) {
|
|
const target = resolveRenderTarget(state);
|
|
const link = target.linkStack.pop();
|
|
if (!link?.href) {
|
|
return;
|
|
}
|
|
const href = link.href.trim();
|
|
if (!href) {
|
|
return;
|
|
}
|
|
const start = link.labelStart;
|
|
const end = target.text.length;
|
|
target.links.push({ start, end, href });
|
|
}
|
|
|
|
function initTableState(): TableState {
|
|
return {
|
|
headers: [],
|
|
rows: [],
|
|
currentRow: [],
|
|
currentCell: null,
|
|
inHeader: false,
|
|
};
|
|
}
|
|
|
|
function finishTableCell(cell: RenderTarget): TableCell {
|
|
closeRemainingStyles(cell);
|
|
return {
|
|
text: cell.text,
|
|
styles: cell.styles,
|
|
links: cell.links,
|
|
};
|
|
}
|
|
|
|
function trimCell(cell: TableCell): TableCell {
|
|
const text = cell.text;
|
|
let start = 0;
|
|
let end = text.length;
|
|
while (start < end && /\s/.test(text[start] ?? '')) {
|
|
start += 1;
|
|
}
|
|
while (end > start && /\s/.test(text[end - 1] ?? '')) {
|
|
end -= 1;
|
|
}
|
|
if (start === 0 && end === text.length) {
|
|
return cell;
|
|
}
|
|
const trimmedText = text.slice(start, end);
|
|
const trimmedLength = trimmedText.length;
|
|
const trimmedStyles: MarkdownStyleSpan[] = [];
|
|
for (const span of cell.styles) {
|
|
const sliceStart = Math.max(0, span.start - start);
|
|
const sliceEnd = Math.min(trimmedLength, span.end - start);
|
|
if (sliceEnd > sliceStart) {
|
|
trimmedStyles.push({ start: sliceStart, end: sliceEnd, style: span.style });
|
|
}
|
|
}
|
|
const trimmedLinks: MarkdownLinkSpan[] = [];
|
|
for (const span of cell.links) {
|
|
const sliceStart = Math.max(0, span.start - start);
|
|
const sliceEnd = Math.min(trimmedLength, span.end - start);
|
|
if (sliceEnd > sliceStart) {
|
|
trimmedLinks.push({ start: sliceStart, end: sliceEnd, href: span.href });
|
|
}
|
|
}
|
|
return { text: trimmedText, styles: trimmedStyles, links: trimmedLinks };
|
|
}
|
|
|
|
function appendCell(state: RenderState, cell: TableCell) {
|
|
if (!cell.text) {
|
|
return;
|
|
}
|
|
const start = state.text.length;
|
|
state.text += cell.text;
|
|
for (const span of cell.styles) {
|
|
state.styles.push({
|
|
start: start + span.start,
|
|
end: start + span.end,
|
|
style: span.style,
|
|
});
|
|
}
|
|
for (const link of cell.links) {
|
|
state.links.push({
|
|
start: start + link.start,
|
|
end: start + link.end,
|
|
href: link.href,
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderTableAsBullets(state: RenderState) {
|
|
if (!state.table) {
|
|
return;
|
|
}
|
|
const headers = state.table.headers.map(trimCell);
|
|
const rows = state.table.rows.map((row) => row.map(trimCell));
|
|
|
|
if (headers.length === 0 && rows.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const useFirstColAsLabel = headers.length > 1 && rows.length > 0;
|
|
|
|
if (useFirstColAsLabel) {
|
|
for (const row of rows) {
|
|
if (row.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
const rowLabel = row[0];
|
|
if (rowLabel?.text) {
|
|
const labelStart = state.text.length;
|
|
appendCell(state, rowLabel);
|
|
const labelEnd = state.text.length;
|
|
if (labelEnd > labelStart) {
|
|
state.styles.push({ start: labelStart, end: labelEnd, style: 'bold' });
|
|
}
|
|
state.text += '\n';
|
|
}
|
|
|
|
for (let i = 1; i < row.length; i++) {
|
|
const header = headers[i];
|
|
const value = row[i];
|
|
if (!value?.text) {
|
|
continue;
|
|
}
|
|
state.text += '• ';
|
|
if (header?.text) {
|
|
appendCell(state, header);
|
|
state.text += ': ';
|
|
} else {
|
|
state.text += `Column ${i}: `;
|
|
}
|
|
appendCell(state, value);
|
|
state.text += '\n';
|
|
}
|
|
state.text += '\n';
|
|
}
|
|
} else {
|
|
for (const row of rows) {
|
|
for (let i = 0; i < row.length; i++) {
|
|
const header = headers[i];
|
|
const value = row[i];
|
|
if (!value?.text) {
|
|
continue;
|
|
}
|
|
state.text += '• ';
|
|
if (header?.text) {
|
|
appendCell(state, header);
|
|
state.text += ': ';
|
|
}
|
|
appendCell(state, value);
|
|
state.text += '\n';
|
|
}
|
|
state.text += '\n';
|
|
}
|
|
}
|
|
}
|
|
|
|
function appendCellTextOnly(state: RenderState, cell: TableCell) {
|
|
if (!cell.text) {
|
|
return;
|
|
}
|
|
state.text += cell.text;
|
|
// Do not append styles — used for code blocks where inner styles would overlap
|
|
}
|
|
|
|
function renderTableAsCode(state: RenderState) {
|
|
if (!state.table) {
|
|
return;
|
|
}
|
|
const headers = state.table.headers.map(trimCell);
|
|
const rows = state.table.rows.map((row) => row.map(trimCell));
|
|
|
|
const columnCount = Math.max(headers.length, ...rows.map((row) => row.length));
|
|
if (columnCount === 0) {
|
|
return;
|
|
}
|
|
|
|
const widths = Array.from({ length: columnCount }, () => 0);
|
|
const updateWidths = (cells: TableCell[]) => {
|
|
for (let i = 0; i < columnCount; i += 1) {
|
|
const cell = cells[i];
|
|
const width = cell?.text.length ?? 0;
|
|
if (widths[i]! < width) {
|
|
widths[i] = width;
|
|
}
|
|
}
|
|
};
|
|
updateWidths(headers);
|
|
for (const row of rows) {
|
|
updateWidths(row);
|
|
}
|
|
|
|
const codeStart = state.text.length;
|
|
|
|
const appendRow = (cells: TableCell[]) => {
|
|
state.text += '|';
|
|
for (let i = 0; i < columnCount; i += 1) {
|
|
state.text += ' ';
|
|
const cell = cells[i];
|
|
if (cell) {
|
|
appendCellTextOnly(state, cell);
|
|
}
|
|
const pad = widths[i]! - (cell?.text.length ?? 0);
|
|
if (pad > 0) {
|
|
state.text += ' '.repeat(pad);
|
|
}
|
|
state.text += ' |';
|
|
}
|
|
state.text += '\n';
|
|
};
|
|
|
|
const appendDivider = () => {
|
|
state.text += '|';
|
|
for (let i = 0; i < columnCount; i += 1) {
|
|
const dashCount = Math.max(3, widths[i]!);
|
|
state.text += ` ${'-'.repeat(dashCount)} |`;
|
|
}
|
|
state.text += '\n';
|
|
};
|
|
|
|
appendRow(headers);
|
|
appendDivider();
|
|
for (const row of rows) {
|
|
appendRow(row);
|
|
}
|
|
|
|
const codeEnd = state.text.length;
|
|
if (codeEnd > codeStart) {
|
|
state.styles.push({ start: codeStart, end: codeEnd, style: 'code_block' });
|
|
}
|
|
if (state.env.listStack.length === 0) {
|
|
state.text += '\n';
|
|
}
|
|
}
|
|
|
|
function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
|
for (const token of tokens) {
|
|
switch (token.type) {
|
|
case 'inline':
|
|
if (token.children) {
|
|
renderTokens(token.children, state);
|
|
}
|
|
break;
|
|
case 'text':
|
|
appendText(state, token.content ?? '');
|
|
break;
|
|
case 'em_open':
|
|
openStyle(state, 'italic');
|
|
break;
|
|
case 'em_close':
|
|
closeStyle(state, 'italic');
|
|
break;
|
|
case 'strong_open':
|
|
openStyle(state, 'bold');
|
|
break;
|
|
case 'strong_close':
|
|
closeStyle(state, 'bold');
|
|
break;
|
|
case 's_open':
|
|
openStyle(state, 'strikethrough');
|
|
break;
|
|
case 's_close':
|
|
closeStyle(state, 'strikethrough');
|
|
break;
|
|
case 'code_inline':
|
|
renderInlineCode(state, token.content ?? '');
|
|
break;
|
|
case 'link_open': {
|
|
const href = getAttr(token, 'href') ?? '';
|
|
const target = resolveRenderTarget(state);
|
|
target.linkStack.push({ href, labelStart: target.text.length });
|
|
break;
|
|
}
|
|
case 'link_close':
|
|
handleLinkClose(state);
|
|
break;
|
|
case 'image':
|
|
appendText(state, token.content ?? '');
|
|
break;
|
|
case 'softbreak':
|
|
case 'hardbreak':
|
|
appendText(state, '\n');
|
|
break;
|
|
case 'paragraph_close':
|
|
appendParagraphSeparator(state);
|
|
break;
|
|
case 'heading_open':
|
|
if (state.headingStyle === 'bold') {
|
|
openStyle(state, 'bold');
|
|
}
|
|
break;
|
|
case 'heading_close':
|
|
if (state.headingStyle === 'bold') {
|
|
closeStyle(state, 'bold');
|
|
}
|
|
appendParagraphSeparator(state);
|
|
break;
|
|
case 'blockquote_open':
|
|
if (state.blockquotePrefix) {
|
|
state.text += state.blockquotePrefix;
|
|
}
|
|
openStyle(state, 'blockquote');
|
|
break;
|
|
case 'blockquote_close':
|
|
closeStyle(state, 'blockquote');
|
|
break;
|
|
case 'bullet_list_open':
|
|
if (state.env.listStack.length > 0) {
|
|
state.text += '\n';
|
|
}
|
|
state.env.listStack.push({ type: 'bullet', index: 0 });
|
|
break;
|
|
case 'bullet_list_close':
|
|
state.env.listStack.pop();
|
|
if (state.env.listStack.length === 0) {
|
|
state.text += '\n';
|
|
}
|
|
break;
|
|
case 'ordered_list_open': {
|
|
if (state.env.listStack.length > 0) {
|
|
state.text += '\n';
|
|
}
|
|
const start = Number(getAttr(token, 'start') ?? '1');
|
|
state.env.listStack.push({ type: 'ordered', index: start - 1 });
|
|
break;
|
|
}
|
|
case 'ordered_list_close':
|
|
state.env.listStack.pop();
|
|
if (state.env.listStack.length === 0) {
|
|
state.text += '\n';
|
|
}
|
|
break;
|
|
case 'list_item_open':
|
|
appendListPrefix(state);
|
|
break;
|
|
case 'list_item_close':
|
|
if (!state.text.endsWith('\n')) {
|
|
state.text += '\n';
|
|
}
|
|
break;
|
|
case 'code_block':
|
|
case 'fence':
|
|
renderCodeBlock(state, token.content ?? '');
|
|
break;
|
|
case 'html_block':
|
|
case 'html_inline': {
|
|
const htmlContent = token.content ?? '';
|
|
// Convert <br> variants to newlines instead of appending raw HTML
|
|
const converted = htmlContent.replace(/<br\s*\/?>/gi, '\n');
|
|
appendText(state, converted);
|
|
break;
|
|
}
|
|
|
|
// Table handling
|
|
case 'table_open':
|
|
state.table = initTableState();
|
|
break;
|
|
case 'table_close':
|
|
if (state.table) {
|
|
renderTableAsCode(state);
|
|
}
|
|
state.table = null;
|
|
break;
|
|
case 'thead_open':
|
|
if (state.table) {
|
|
state.table.inHeader = true;
|
|
}
|
|
break;
|
|
case 'thead_close':
|
|
if (state.table) {
|
|
state.table.inHeader = false;
|
|
}
|
|
break;
|
|
case 'tbody_open':
|
|
case 'tbody_close':
|
|
break;
|
|
case 'tr_open':
|
|
if (state.table) {
|
|
state.table.currentRow = [];
|
|
}
|
|
break;
|
|
case 'tr_close':
|
|
if (state.table) {
|
|
if (state.table.inHeader) {
|
|
state.table.headers = state.table.currentRow;
|
|
} else {
|
|
state.table.rows.push(state.table.currentRow);
|
|
}
|
|
state.table.currentRow = [];
|
|
}
|
|
break;
|
|
case 'th_open':
|
|
case 'td_open':
|
|
if (state.table) {
|
|
state.table.currentCell = initRenderTarget();
|
|
}
|
|
break;
|
|
case 'th_close':
|
|
case 'td_close':
|
|
if (state.table?.currentCell) {
|
|
state.table.currentRow.push(finishTableCell(state.table.currentCell));
|
|
state.table.currentCell = null;
|
|
}
|
|
break;
|
|
|
|
case 'hr':
|
|
state.text += '───\n\n';
|
|
break;
|
|
default:
|
|
if (token.children) {
|
|
renderTokens(token.children, state);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function closeRemainingStyles(target: RenderTarget) {
|
|
for (let i = target.openStyles.length - 1; i >= 0; i -= 1) {
|
|
const open = target.openStyles[i];
|
|
const end = target.text.length;
|
|
if (end > open.start) {
|
|
target.styles.push({
|
|
start: open.start,
|
|
end,
|
|
style: open.style,
|
|
});
|
|
}
|
|
}
|
|
target.openStyles = [];
|
|
}
|
|
|
|
function clampStyleSpans(spans: MarkdownStyleSpan[], maxLength: number): MarkdownStyleSpan[] {
|
|
const clamped: MarkdownStyleSpan[] = [];
|
|
for (const span of spans) {
|
|
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 });
|
|
}
|
|
}
|
|
return clamped;
|
|
}
|
|
|
|
function clampLinkSpans(spans: MarkdownLinkSpan[], maxLength: number): MarkdownLinkSpan[] {
|
|
const clamped: MarkdownLinkSpan[] = [];
|
|
for (const span of spans) {
|
|
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, href: span.href });
|
|
}
|
|
}
|
|
return clamped;
|
|
}
|
|
|
|
function mergeStyleSpans(spans: MarkdownStyleSpan[]): MarkdownStyleSpan[] {
|
|
const sorted = [...spans].toSorted((a, b) => {
|
|
if (a.start !== b.start) {
|
|
return a.start - b.start;
|
|
}
|
|
if (a.end !== b.end) {
|
|
return a.end - b.end;
|
|
}
|
|
return a.style.localeCompare(b.style);
|
|
});
|
|
|
|
const merged: MarkdownStyleSpan[] = [];
|
|
for (const span of sorted) {
|
|
const prev = merged[merged.length - 1];
|
|
if (
|
|
prev &&
|
|
prev.style === span.style &&
|
|
(span.start < prev.end || (span.start === prev.end && span.style !== 'blockquote'))
|
|
) {
|
|
prev.end = Math.max(prev.end, span.end);
|
|
continue;
|
|
}
|
|
merged.push({ ...span });
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function sliceStyleSpans(
|
|
spans: MarkdownStyleSpan[],
|
|
start: number,
|
|
end: number,
|
|
): MarkdownStyleSpan[] {
|
|
if (spans.length === 0) {
|
|
return [];
|
|
}
|
|
const sliced: MarkdownStyleSpan[] = [];
|
|
for (const span of spans) {
|
|
const sliceStart = Math.max(span.start, start);
|
|
const sliceEnd = Math.min(span.end, end);
|
|
if (sliceEnd > sliceStart) {
|
|
sliced.push({
|
|
start: sliceStart - start,
|
|
end: sliceEnd - start,
|
|
style: span.style,
|
|
});
|
|
}
|
|
}
|
|
return mergeStyleSpans(sliced);
|
|
}
|
|
|
|
function sliceLinkSpans(spans: MarkdownLinkSpan[], start: number, end: number): MarkdownLinkSpan[] {
|
|
if (spans.length === 0) {
|
|
return [];
|
|
}
|
|
const sliced: MarkdownLinkSpan[] = [];
|
|
for (const span of spans) {
|
|
const sliceStart = Math.max(span.start, start);
|
|
const sliceEnd = Math.min(span.end, end);
|
|
if (sliceEnd > sliceStart) {
|
|
sliced.push({
|
|
start: sliceStart - start,
|
|
end: sliceEnd - start,
|
|
href: span.href,
|
|
});
|
|
}
|
|
}
|
|
return sliced;
|
|
}
|
|
|
|
function chunkText(text: string, limit: number): string[] {
|
|
if (text.length <= limit) return [text];
|
|
const chunks: string[] = [];
|
|
let remaining = text;
|
|
while (remaining.length > 0) {
|
|
if (remaining.length <= limit) { chunks.push(remaining); break; }
|
|
let splitIdx = remaining.lastIndexOf('\n', limit);
|
|
if (splitIdx <= 0 || splitIdx < limit * 0.5) splitIdx = limit;
|
|
chunks.push(remaining.slice(0, splitIdx));
|
|
remaining = remaining.slice(splitIdx).replace(/^\n/, '');
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
export function markdownToIR(markdown: string, options: MarkdownParseOptions = {}): MarkdownIR {
|
|
const env: RenderEnv = { listStack: [] };
|
|
const md = createMarkdownIt(options);
|
|
const tokens = md.parse(markdown ?? '', env as unknown as object);
|
|
|
|
const state: RenderState = {
|
|
text: '',
|
|
styles: [],
|
|
openStyles: [],
|
|
links: [],
|
|
linkStack: [],
|
|
env,
|
|
headingStyle: options.headingStyle ?? 'none',
|
|
blockquotePrefix: options.blockquotePrefix ?? '',
|
|
table: null,
|
|
};
|
|
|
|
renderTokens(tokens as MarkdownToken[], state);
|
|
closeRemainingStyles(state);
|
|
|
|
const trimmedText = state.text.trimEnd();
|
|
const trimmedLength = trimmedText.length;
|
|
let codeBlockEnd = 0;
|
|
for (const span of state.styles) {
|
|
if (span.style !== 'code_block') {
|
|
continue;
|
|
}
|
|
if (span.end > codeBlockEnd) {
|
|
codeBlockEnd = span.end;
|
|
}
|
|
}
|
|
const finalLength = Math.max(trimmedLength, codeBlockEnd);
|
|
const finalText =
|
|
finalLength === state.text.length ? state.text : state.text.slice(0, finalLength);
|
|
|
|
return {
|
|
text: finalText,
|
|
styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)),
|
|
links: clampLinkSpans(state.links, finalLength),
|
|
};
|
|
}
|
|
|
|
export function chunkMarkdownIR(ir: MarkdownIR, limit: number): MarkdownIR[] {
|
|
if (!ir.text) {
|
|
return [];
|
|
}
|
|
if (limit <= 0 || ir.text.length <= limit) {
|
|
return [ir];
|
|
}
|
|
|
|
const chunks = chunkText(ir.text, limit);
|
|
const results: MarkdownIR[] = [];
|
|
let cursor = 0;
|
|
|
|
chunks.forEach((chunk, index) => {
|
|
if (!chunk) {
|
|
return;
|
|
}
|
|
if (index > 0) {
|
|
while (cursor < ir.text.length && /\s/.test(ir.text[cursor] ?? '')) {
|
|
cursor += 1;
|
|
}
|
|
}
|
|
const start = cursor;
|
|
const end = Math.min(ir.text.length, start + chunk.length);
|
|
results.push({
|
|
text: chunk,
|
|
styles: sliceStyleSpans(ir.styles, start, end),
|
|
links: sliceLinkSpans(ir.links, start, end),
|
|
});
|
|
cursor = end;
|
|
});
|
|
|
|
return results;
|
|
}
|