/** * 简历布局容器插件(markdown-it block rule) * * 同时兼容两套语法: * * 1. v1 遗留语法(老数据永久兼容): * ::: start / ::: / ::: end 多列 flex 布局,裸 ::: 为列分隔符 * ::: headStart / ::: headEnd 头部布局 * ::: mainStart / ::: mainEnd 主体布局 * * 2. 新语法(嵌套井号约定,与 markdown-it-container / Pandoc fenced div 同风格): * :::: flex * ::: col * 第一列 * ::: * ::: col * 第二列 * ::: * :::: * 井号数量表示嵌套层级,内层必须少于外层。 * flex 容器内仍然支持裸 ::: 作为列分隔符(少于开标记的井号数)。 * 未知名称(如 ::: warning)会渲染为
,便于后续扩展。 * * 输出 HTML 契约与 v1 保持一致: * .flex-layout > .flex-layout-item / .head-layout / .main-layout */ type ContainerKind = "flex" | "col" | "head" | "main" | "generic" interface MarkerInfo { colons: number rest: string } type Marker = | { t: "open"; style: "legacy" | "named"; kind: ContainerKind; name: string; colons: number } | { t: "close"; kind: ContainerKind; colons: number } | { t: "bare"; colons: number } const LEGACY_KINDS: Record = { start: "flex", headStart: "head", mainStart: "main", } const LEGACY_ENDS: Record = { end: "flex", headEnd: "head", mainEnd: "main", } const NAMED_KINDS: Record = { flex: "flex", col: "col", head: "head", main: "main", } function kindFromName(name: string): ContainerKind { return NAMED_KINDS[name] ?? "generic" } function parseMarker(line: string): MarkerInfo | null { const m = /^[ \t]{0,3}(:::+)[ \t]*(.*)$/.exec(line) if (!m) return null return { colons: m[1].length, rest: m[2].trim() } } function classify(info: MarkerInfo): Marker { const { colons, rest } = info if (!rest) return { t: "bare", colons } if (colons === 3 && LEGACY_KINDS[rest]) { return { t: "open", style: "legacy", kind: LEGACY_KINDS[rest], name: rest, colons } } if (LEGACY_ENDS[rest]) return { t: "close", kind: LEGACY_ENDS[rest], colons } const kind = kindFromName(rest) return { t: "open", style: "named", kind, name: rest, colons } } const TOKEN_PREFIX: Record = { flex: "flex_layout", col: "flex_column", head: "head_layout", main: "main_layout", generic: "container", } /** 从行文本取整行内容(跳过缩进),供标记分类使用 */ function lineAt(state: any, i: number): string { return state.src.slice(state.bMarks[i] + state.tShift[i], state.eMarks[i]) } /** 取 [from, to) 区间原始文本 */ function rawRange(state: any, from: number, to: number): string { if (to <= from) return "" return state.src.slice(state.bMarks[from], state.eMarks[to - 1]) + "\n" } interface Frame { kind: ContainerKind style: "legacy" | "named" colons: number } /** * 扫描容器闭合位置,并收集顶层列分隔符。 * 嵌套规则:任意子容器开启时压栈,与之匹配的关闭出栈(允许隐式跨层闭合, * 即 :::: 可以同时关闭未闭合的内层 ::: col);裸 ::: 只有在栈空且当前 * 容器为 flex 时才是列分隔符。 */ function scanRange(state: any, startLine: number, endLine: number, open: Marker) { const stack: Frame[] = [] const separators: number[] = [] // 从栈顶向下找匹配的 frame,弹出它及其之上的所有 frame(隐式闭合) const popThrough = (match: (f: Frame) => boolean) => { for (let d = stack.length - 1; d >= 0; d--) { if (match(stack[d])) { stack.length = d return true } } return false } for (let i = startLine + 1; i < endLine; i++) { const info = parseMarker(lineAt(state, i)) if (!info) continue const c = classify(info) if (c.t === "open") { stack.push({ kind: c.kind, style: c.style, colons: c.colons }) } else if (c.t === "close") { if (popThrough(f => f.kind === c.kind)) continue if ( !stack.length && open.t === "open" && open.kind === c.kind && open.style === "legacy" ) { return { closeLine: i, separators } } } else { // 裸 ::: 标记 const top = stack[stack.length - 1] if (top) { // 匹配栈内任一 named frame 的同数量井号(隐式闭合其上的层) popThrough(f => f.style === "named" && f.colons === c.colons) } else if (open.t === "open" && open.kind === "flex") { if (open.style === "legacy" && c.colons === 3) separators.push(i) else if (open.style === "named") { if (c.colons === open.colons) return { closeLine: i, separators } if (c.colons < open.colons) separators.push(i) } } else if ( open.t === "open" && open.style === "named" && c.colons === open.colons ) { // 非.flex 的具名容器(warning/col/head/main 等):同数量裸井号即闭合 return { closeLine: i, separators } } } } // 未闭合:与 v1 行为一致,消费到文档结尾 return { closeLine: endLine, separators } } function containerRule(state: any, startLine: number, endLine: number, silent: boolean): boolean { const startInfo = parseMarker(lineAt(state, startLine)) if (!startInfo) return false const open = classify(startInfo) if (open.t !== "open") return false if (silent) return true const { closeLine, separators } = scanRange(state, startLine, endLine, open) const prefix = TOKEN_PREFIX[open.kind] const openToken = state.push(`${prefix}_open`, "div", 1) openToken.block = true openToken.map = [startLine, closeLine] if (open.style === "named") openToken.meta = { name: open.name } if (open.kind === "flex" && !(open.style === "named" && separators.length === 0)) { // 按 separators 切分为多个列,每列内容独立走一遍 block 解析。 // 新语法下若内容里已用 ::: col 显式分列且没有裸分隔符,则不再包列。 let segStart = startLine + 1 for (const sep of [...separators, closeLine]) { const col = state.push("flex_column_open", "div", 1) col.block = true col.map = [segStart, sep] state.md.block.parse(rawRange(state, segStart, sep), state.md, state.env, state.tokens) const colClose = state.push("flex_column_close", "div", -1) colClose.block = true segStart = sep + 1 } } else { state.md.block.parse(rawRange(state, startLine + 1, closeLine), state.md, state.env, state.tokens) } const closeToken = state.push(`${prefix}_close`, "div", -1) closeToken.block = true state.line = closeLine + 1 return true } const escapeAttr = (s: string) => s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">") export function containersPlugin(md: any) { md.block.ruler.before("fence", "resume_container", containerRule, { alt: ["paragraph", "reference", "blockquote", "list"], }) md.renderer.rules.flex_layout_open = () => '
\n' md.renderer.rules.flex_layout_close = () => "
\n" md.renderer.rules.flex_column_open = () => '
' md.renderer.rules.flex_column_close = () => "
\n" md.renderer.rules.head_layout_open = () => '
' md.renderer.rules.head_layout_close = () => "
\n" md.renderer.rules.main_layout_open = () => '
' md.renderer.rules.main_layout_close = () => "
\n" // 未知名称的通用容器:
,为后续扩展(callout 等)留口 md.renderer.rules.container_open = (tokens: any[], idx: number) => { const name = tokens[idx].meta?.name || "" const cls = /^[\w-]+$/.test(name) ? ` class="${escapeAttr(name)}"` : "" return `\n` } md.renderer.rules.container_close = () => "
\n" }