/** 正文 [entries]…[/entries] 条目卡块解析: * 通用「结构化小节」渲染约定——字段名不限定,任何书籍可定义自己的字段。 * 块内语法: * ## 条目标题 * @tags 标签一 | 主:次 | 标签三 → 标签 chips(| 或 | 分隔,"主:次" 渲染主次两段) * @summary 一句话结论 → 高亮摘要块(可多行;标题后的裸文本也计入摘要) * 字段名 :: 内容 → 灰色标签行(label 任意,可多行) * @source 来源文本 | https://… → 折叠来源列表(url 可省) */ export interface EntrySource { text: string; url?: string; } export interface EntryField { label: string; value: string; } export interface EntryItem { title: string; tags: string[]; summary?: string; fields: EntryField[]; sources: EntrySource[]; /** 无标题头(注释标签卡等场景):不渲染编号与标题行 */ plain?: boolean; } const HEADING_RE = /^#{1,4}\s+(.+)$/; /** 条目标题自带序号前缀("1."、"12、"、"(3)"、"(4)"):卡片不再叠加自动编号 */ export const ENTRY_TITLE_NUM_RE = /^(?:[((]\d+[))]|\d+\s*[.、))])/; const TAGS_RE = /^@tags\s+(.+)$/i; const SUMMARY_RE = /^@summary(?:\s+(.*))?$/i; const SOURCE_RE = /^@source\s+(.+)$/i; const PLAIN_RE = /^@plain$/i; const FIELD_RE = /^([^:@\n]{1,40}?)\s*::\s*(.*)$/; /** 来源行:"文本 | url",取最后一个 | 且后段为 http(s) 才拆分;文本/链接兼容全角竖线 */ export function parseEntrySource(raw: string): EntrySource { const idx = Math.max(raw.lastIndexOf("|"), raw.lastIndexOf("|")); if (idx > 0) { const url = raw.slice(idx + 1).trim(); if (/^https?:\/\//i.test(url)) { return { text: raw.slice(0, idx).trim(), url }; } } return { text: raw.trim() }; } export function parseEntriesBody(body: string): EntryItem[] { const lines = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); const items: EntryItem[] = []; let cur: EntryItem | null = null; /** 普通文本行的归宿:摘要 / 最后一个字段(缺省时并入摘要) */ let target: { kind: "summary" } | { kind: "field"; index: number } | null = null; const flush = () => { if (cur) items.push(cur); cur = null; target = null; }; const appendSummary = (text: string) => { if (!cur) return; cur.summary = cur.summary ? `${cur.summary}\n${text}` : text; }; for (const line of lines) { const t = line.trim(); if (!t) continue; const hm = t.match(HEADING_RE); if (hm) { flush(); cur = { title: hm[1].trim(), tags: [], fields: [], sources: [] }; target = null; continue; } if (!cur) continue; const tm = t.match(TAGS_RE); if (tm) { cur.tags.push(...tm[1].split(/[||]/).map((s) => s.trim()).filter(Boolean)); target = null; continue; } const sm = t.match(SUMMARY_RE); if (sm) { const rest = (sm[1] ?? "").trim(); if (rest) appendSummary(rest); target = { kind: "summary" }; continue; } const scm = t.match(SOURCE_RE); if (scm) { cur.sources.push(parseEntrySource(scm[1])); target = null; continue; } if (PLAIN_RE.test(t)) { cur.plain = true; target = null; continue; } const fm = t.match(FIELD_RE); if (fm) { cur.fields.push({ label: fm[1].trim(), value: fm[2] }); target = { kind: "field", index: cur.fields.length - 1 }; continue; } // 普通行:追加到开放容器;无容器时视为摘要(标题后直接跟正文段落) if (target?.kind === "field") { const f = cur.fields[target.index]; f.value = `${f.value}\n${t}`; } else { appendSummary(t); } } flush(); return items; } /** 序列化回块文本(编辑器面板复用);selected=false 的条目丢弃,与时间线一致 */ export function serializeEntriesBlock(items: (EntryItem & { selected?: boolean })[]): string { const parts = items .filter((it) => it.selected !== false) .map((it) => { const lines = [`## ${it.title.trim() || "未命名"}`]; if (it.plain) lines.push("@plain"); if (it.tags.length > 0) lines.push(`@tags ${it.tags.join(" | ")}`); if (it.summary?.trim()) lines.push(`@summary ${it.summary.trim()}`); for (const f of it.fields) { if (!f.label.trim() && !f.value.trim()) continue; lines.push(`${f.label.trim() || "备注"} :: ${f.value.trim()}`); } for (const s of it.sources) { if (!s.text.trim()) continue; lines.push(`@source ${s.text.trim()}${s.url?.trim() ? ` | ${s.url.trim()}` : ""}`); } return lines.join("\n"); }); return `[entries]\n${parts.join("\n\n")}\n[/entries]`; } export const ENTRIES_OPEN = "[entries]"; export const ENTRIES_CLOSE = "[/entries]"; /** [entries:off] 例外块:书级条目卡模式开启时,块内恢复普通 Markdown 渲染 */ export const ENTRIES_OFF_OPEN = "[entries:off]"; export function isEntriesOpen(line: string): boolean { return line.trim().toLowerCase() === ENTRIES_OPEN; } export function isEntriesClose(line: string): boolean { return line.trim().toLowerCase() === ENTRIES_CLOSE; } export function isEntriesOffOpen(line: string): boolean { return line.trim().toLowerCase() === ENTRIES_OFF_OPEN; } /** 条目指令行:@tags / @summary / @source / @plain / 字段名 :: 内容(隐式模式识别条目特征用) */ export function isEntrySignalLine(t: string): boolean { return ( TAGS_RE.test(t) || SUMMARY_RE.test(t) || SOURCE_RE.test(t) || PLAIN_RE.test(t) || FIELD_RE.test(t) ); } /** 已知指令块标记行(隐式模式扫描时原样跳过,不当作条目内容) */ const DIRECTIVE_LINE_RE = /^\[(\/)?(entries(?::off)?|timeline|hide[^\]]*)\]$/i; export function isDirectiveLine(t: string): boolean { return DIRECTIVE_LINE_RE.test(t); } /** * 隐式条目模式:把「## 标题 + 条目指令行」的区间自动包上 [entries] 壳。 * 规则: * - 标题行(1-4 级)与其后到空行/结构标记前的段落里出现任一条目指令行 → 确认为条目; * 否则视为普通小节标题,原样输出 * - 条目体持续收集:指令行后的普通文本行归入条目(与显式块语义一致); * 空行后若仍是指令行则继续,否则条目在空行处收尾 * - 代码围栏与已知指令块([entries]/[entries:off]/[timeline]/[hide…])原样跳过 */ export function wrapImplicitEntries(src: string): string { const lines = src.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); const out: string[] = []; let inCode = false; let i = 0; /** 从 i 行的开标记找匹配闭标记,返回闭标记行下标;找不到返回 -1 */ const findClose = (start: number, isClose: (t: string) => boolean): number => { let innerCode = false; for (let j = start; j < lines.length; j++) { const t = lines[j].trim(); if (t.startsWith("```")) innerCode = !innerCode; if (!innerCode && isClose(t)) return j; } return -1; }; while (i < lines.length) { const t = lines[i].trim(); if (t.startsWith("```")) { inCode = !inCode; out.push(lines[i]); i++; continue; } if (inCode) { out.push(lines[i]); i++; continue; } // 显式指令块整段原样([entries] 本身无需再包;off/timeline/hide 内容不参与隐式识别) if (isEntriesOpen(t) || isEntriesOffOpen(t) || isDirectiveLine(t)) { const close = findClose( i + 1, isEntriesOffOpen(t) ? isEntriesClose : isEntriesOpen(t) ? isEntriesClose : t.toLowerCase() === "[timeline]" ? (x) => x.toLowerCase() === "[/timeline]" : (x) => x.toLowerCase().startsWith("[/hide") ); if (close >= 0) { for (; i <= close; i++) out.push(lines[i]); } else { out.push(lines[i]); i++; } continue; } if (HEADING_RE.test(t)) { // 前导段:标题行后到空行/结构标记前的所有行 const seg: string[] = []; let k = i + 1; while (k < lines.length) { const tk = lines[k].trim(); if (!tk || tk.startsWith("```") || isDirectiveLine(tk) || HEADING_RE.test(tk)) break; seg.push(lines[k]); k++; } if (!seg.some(isEntrySignalLine)) { out.push(lines[i]); i++; continue; } // 条目确认:包壳输出标题 + 前导段,再继续吃条目体 out.push(ENTRIES_OPEN); out.push(lines[i]); out.push(...seg); i = k; while (i < lines.length) { const t2 = lines[i].trim(); if (!t2) { // 空行后仍是指令行 → 保留空行继续收集;否则条目到此收尾 let p = i; while (p < lines.length && !lines[p].trim()) p++; if (p < lines.length && isEntrySignalLine(lines[p].trim())) { out.push(lines[i]); i++; continue; } break; } if (t2.startsWith("```") || isDirectiveLine(t2) || HEADING_RE.test(t2)) break; out.push(lines[i]); i++; } out.push(ENTRIES_CLOSE); continue; } out.push(lines[i]); i++; } return out.join("\n"); } /** 编辑器「条目卡」插入模板:字段名(字段名::内容)按书籍内容自定义 */ export const ENTRIES_TEMPLATE = `[entries] ## 条目标题 @tags 标签 | 主:次 @summary 一句话结论。 字段名 :: 内容。 @source 来源标题 | https://example.com [/entries]`; /** 编辑器「条目卡」插入模板(隐式模式):书籍开启全章节条目卡后免写外壳 */ export const ENTRIES_TEMPLATE_AUTO = `## 条目标题 @tags 标签 | 主:次 @summary 一句话结论。 字段名 :: 内容。 @source 来源标题 | https://example.com`;