/**
* 段落逐行拆分(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)
}