/** * 段落逐行拆分(paragraphPerLine) * * v1 行为:每个非空行都是一个独立的

。 * 标准 markdown 会把相邻行合并为一个段落(软换行), * 这会改变存量简历的渲染结构。 * * 本插件在 block 解析之后、inline 解析之前, * 把顶层段落中含软换行的段落拆分为多个段落,保持 v1 的渲染结构: * 第一行 * 第二行 →

第一行

第二行

* * 只处理顶层段落:列表项、引用块内部的软换行不受影响 * (由 breaks: true 渲染为
,与 tiptap 富文本回写 dom2md 的行为对齐)。 */ // 需要计入嵌套深度的块容器(这些容器内部的段落不拆分) const DEPTH_TOKENS = new Set([ "blockquote_open", "blockquote_close", "bullet_list_open", "bullet_list_close", "ordered_list_open", "ordered_list_close", "list_item_open", "list_item_close", ]) interface Insertion { index: number // paragraph_open 的下标 parts: string[] source: any } function paragraphPerLineRule(state: any) { const tokens = state.tokens const insertions: Insertion[] = [] let depth = 0 for (let i = 0; i < tokens.length; i++) { const t = tokens[i] if (t.nesting === 1 && DEPTH_TOKENS.has(t.type)) { depth++ continue } if (t.nesting === -1 && DEPTH_TOKENS.has(t.type)) { depth-- continue } if (depth === 0 && t.type === "inline" && t.content.includes("\n")) { const prev = tokens[i - 1] const next = tokens[i + 1] if (prev?.type === "paragraph_open" && next?.type === "paragraph_close") { insertions.push({ index: i - 1, parts: t.content.split("\n"), source: t }) } } } // 从后往前替换,避免下标失效 for (let k = insertions.length - 1; k >= 0; k--) { const { index, parts, source } = insertions[k] const replacement: any[] = [] for (const part of parts) { const open = new state.Token("paragraph_open", "p", 1) open.block = true open.hidden = source.hidden if (source.map) open.map = source.map const inline = new state.Token("inline", "", 0) inline.content = part inline.children = [] // 核心链路的 inline 规则要求 children 为数组 inline.hidden = false const close = new state.Token("paragraph_close", "p", -1) close.block = true replacement.push(open, inline, close) } tokens.splice(index, 3, ...replacement) } } export function paragraphPerLinePlugin(md: any) { md.core.ruler.after("block", "resume_paragraph_per_line", paragraphPerLineRule) }