,避免聚合转换时丢失首行缩进 */
function splitParagraphBreaks(html: string): string {
if (!/
/i.test(html)) return html;
const doc = new DOMParser().parseFromString(`
…
${content}
\n\n`, }); service.addRule('underline', { filter: ['u'], replacement: (content) => `${content}`, }); service.addRule('strikethrough', { filter: ['del', 's', 'strike'], replacement: (content) => `~~${content}~~`, }); service.addRule('horizontalRule', { filter: 'hr', replacement: () => '\n\n---\n\n', }); // HTML 表格 → GFM 管道表,便于富文本与源码来回切换 service.addRule('table', { filter: 'table', replacement: (_content, node) => { const table = node as HTMLTableElement; const rows = [...table.querySelectorAll('tr')]; if (!rows.length) return ''; const cellText = (cell: Element) => (cell.textContent || '') .replace(/\u00A0/g, ' ') .trim() .replace(/\|/g, '\\|') .replace(/\n+/g, ' '); const matrix = rows .map(tr => [...tr.querySelectorAll('th, td')].map(cellText)) .filter(row => row.length > 0); if (!matrix.length) return ''; const colCount = Math.max(...matrix.map(r => r.length)); if (!colCount) return ''; const pad = (row: string[]) => { const next = [...row]; while (next.length < colCount) next.push(''); return next.slice(0, colCount); }; const lines = matrix.map(row => `| ${pad(row).join(' | ')} |`); const sep = `| ${Array.from({ length: colCount }, () => '---').join(' | ')} |`; lines.splice(1, 0, sep); return `\n\n${lines.join('\n')}\n\n`; }, }); service.addRule('anchor', { filter: (node) => { if (node.nodeName !== 'A') return false; const href = (node as HTMLAnchorElement).getAttribute('href'); return Boolean(href); }, replacement: (content, node) => { const el = node as HTMLAnchorElement; const href = el.getAttribute('href') ?? ''; const title = el.getAttribute('title'); return title ? `[${content}](${href} "${title}")` : `[${content}](${href})`; }, }); } /** 子节点转 Markdown 专用,避免 members-only 规则递归 */ const contentTurndown = new TurndownService(TURNDOWN_OPTIONS); addTurndownContentRules(contentTurndown); patchTurndownEscape(contentTurndown); const turndown = new TurndownService(TURNDOWN_OPTIONS); addTurndownContentRules(turndown); patchTurndownEscape(turndown); /** * Turndown 默认会把「行首 1. 」转义成 1\. ,避免被当成列表。 * 富文本有序列表 / 用户手写序号在源码里都应保持 1. ,故去掉该转义。 */ function patchTurndownEscape(service: TurndownService): void { const original = service.escape.bind(service); service.escape = (str: string) => original(str).replace(/(\d+)\\(\.)/g, '$1$2'); } /** 登录可见区块转为 Markdown:逐子节点转换,保留首行缩进 */ turndown.addRule('membersOnly', { filter: 'members-only', replacement: (_content, node) => { const el = node as HTMLElement; const parts: string[] = []; el.childNodes.forEach(child => { if (child.nodeType === Node.TEXT_NODE) { const text = (child.textContent ?? '').trim(); if (text) parts.push(nbspToSpaces(text)); return; } if (child instanceof HTMLElement) { parts.push(nbspToSpaces(contentTurndown.turndown(child.outerHTML).trim())); } }); const body = trimBlockBoundaryLines(parts.join('\n\n')); return `\n\n` : '';
const classAttr = opts.language ? ` class="language-${opts.language}"` : '';
const body = escaped ? text : escapeCodeHtml(text);
return `${preOpen}${body}\n`;
},
},
});
/** 净化并保留 members-only 标签 */
function sanitizeContentHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
}
/** 将行首空格转为不换行空格,避免 HTML 折叠缩进;跳过围栏代码块 */
function preserveLeadingIndent(markdown: string): string {
return markdown.split(/(```[\s\S]*?```)/g).map((part, index) => {
if (index % 2 === 1) return part;
return part.replace(/^( +)(?=\S)/gm, (_match, spaces: string) => '\u00A0'.repeat(spaces.length));
}).join('');
}
/** 将普通 Markdown 片段转为 HTML */
function parseMarkdownFragment(markdown: string): string {
if (!markdown.trim()) return '';
return marked.parse(preserveLeadingIndent(markdown), { async: false }) as string;
}
/** 转换前清理编辑态装饰结构,避免污染 Markdown */
function prepareHtmlForMarkdown(html: string): string {
const doc = new DOMParser().parseFromString(sanitizeContentHtml(html), 'text/html');
doc.querySelectorAll('.post-members-only__badge, .post-members-only__exit-btn, .post-members-only__unwrap-btn').forEach(el => {
el.remove();
});
doc.querySelectorAll('members-only').forEach(el => {
const body = el.querySelector('.post-members-only__body');
const raw = body ? body.innerHTML : el.innerHTML;
el.innerHTML = splitParagraphBreaks(raw);
});
return doc.body.innerHTML;
}
/** 规范化 members-only 标签边界,避免闭合标签与正文粘连 */
function normalizeMembersOnlyMarkdown(markdown: string): string {
return markdown
.replace(/<\/members-only>(?=[^\s\n])/g, '