/** 统一拆分正文中的 [hide] / [timeline] / [entries] 指令块(忽略代码围栏) */ import { splitHideSegments, type HideKind, type HideSegment, } from "./hideBlocks.ts"; import { parseTimelineBody, type TimelineItem } from "./timelineBlocks.ts"; import { isEntriesClose, isEntriesOffOpen, isEntriesOpen, parseEntriesBody, wrapImplicitEntries, type EntryItem, } from "./entryBlocks.ts"; export type ContentSegment = | { type: "md"; text: string } | { type: "hide"; kind: HideKind; points: number; locked: boolean; body: string; } | { type: "timeline"; items: TimelineItem[]; rawBody: string } | { type: "entries"; items: EntryItem[]; rawBody: string } | { type: "details"; title: string; body: string } | { type: "video"; provider: "bilibili" | "youtube"; id: string }; function isTimelineClose(line: string): boolean { return line.trim().toLowerCase() === "[/timeline]"; } function isTimelineOpen(trimmed: string): boolean { return trimmed.toLowerCase() === "[timeline]"; } /* ---------- [details] 折叠面板 ---------- */ const DETAILS_OPEN_RE = /^\[details(?:\s+([^\]]*))?\]$/i; export const DETAILS_DEFAULT_TITLE = "点击展开"; function isDetailsClose(line: string): boolean { return line.trim().toLowerCase() === "[/details]"; } /** 开标记整行匹配:[details 标题文字];标题缺省「点击展开」 */ export function isDetailsOpen(trimmed: string): boolean { return DETAILS_OPEN_RE.test(trimmed); } export function parseDetailsOpen(trimmed: string): string { const m = trimmed.match(DETAILS_OPEN_RE); const title = (m?.[1] ?? "").trim(); return title || DETAILS_DEFAULT_TITLE; } /* ---------- [bilibili] / [youtube] 视频嵌入(整行单标记) ---------- */ const VIDEO_LINE_RE = /^\[(bilibili|youtube)\]([^\[\]]+)\[\/\1\]$/i; const BILI_BV_RE = /^BV[0-9A-Za-z]{8,12}$/; const BILI_URL_BV_RE = /\/video\/(BV[0-9A-Za-z]{8,12})/; const YT_ID_RE = /^[0-9A-Za-z_-]{6,20}$/; /** * 裸输入解析:BV 号 / B站视频链接 / YouTube ID / youtu.be、watch?v= 链接。 * 短代码行与编辑器「插入视频」表单共用;解析失败返回 null。 */ export function parseVideoInput( raw: string ): { provider: "bilibili" | "youtube"; id: string } | null { const s = raw.trim(); if (!s || /[\s<>"']/.test(s)) return null; const bvFromUrl = s.match(BILI_URL_BV_RE); if (bvFromUrl) return { provider: "bilibili", id: bvFromUrl[1] }; if (BILI_BV_RE.test(s)) return { provider: "bilibili", id: s }; if (/youtu\.?be/i.test(s)) { const m = s.match(/(?:youtu\.be\/|[?&]v=)([0-9A-Za-z_-]{6,20})/); return m ? { provider: "youtube", id: m[1] } : null; } if (YT_ID_RE.test(s)) return { provider: "youtube", id: s }; return null; } /** * 整行 `[bilibili]…[/bilibili]` / `[youtube]…[/youtube]`。 * 标签名与内容平台不一致、或内容解析失败时返回 null(该行按普通 Markdown 处理)。 */ export function parseVideoLine( trimmed: string ): { provider: "bilibili" | "youtube"; id: string } | null { const m = trimmed.match(VIDEO_LINE_RE); if (!m) return null; const provider = m[1].toLowerCase() as "bilibili" | "youtube"; const parsed = parseVideoInput(m[2]); if (!parsed || parsed.provider !== provider) return null; return parsed; } /** * 先拆时间线,再对普通 md/hide 段做 hide 拆分。 * 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。 * opts.entriesAuto:书级「全章节条目卡模式」开关——开启时按 wrapImplicitEntries * 把「## 标题 + @tags/@summary/@source/字段::值」写法包上 [entries] 壳再走统一拆分; * 关闭(含帖子/评论等非书场景)时均按普通 Markdown 渲染,条目卡只能用显式 [entries] 块。 * [entries:off] 块内始终保持普通 Markdown。 */ export function splitContentSegments( content: string, opts?: { entriesAuto?: boolean } ): ContentSegment[] { if (!content) return [{ type: "md", text: "" }]; if (opts?.entriesAuto) { content = wrapImplicitEntries(content); } const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); const raw: Array< | { type: "md"; text: string } | { type: "timeline"; body: string } | { type: "entries"; body: string } | { type: "details"; title: string; body: string } | { type: "video"; provider: "bilibili" | "youtube"; id: string } > = []; let buf: string[] = []; let inCode = false; let i = 0; const flushMd = () => { if (buf.length === 0) return; raw.push({ type: "md", text: buf.join("\n") }); buf = []; }; /** 从 i 行的开标签扫到匹配闭标签;忽略代码围栏,嵌套开标签视为失败(返回 null,调用方按 md 处理) */ const scanBlock = ( isOpen: (t: string) => boolean, isClose: (t: string) => boolean ): { body: string; end: number } | null => { const bodyLines: string[] = []; let j = i + 1; let innerCode = false; while (j < lines.length) { const inner = lines[j].trim(); if (inner.startsWith("```")) { innerCode = !innerCode; bodyLines.push(lines[j]); j++; continue; } if (innerCode) { bodyLines.push(lines[j]); j++; continue; } if (isOpen(inner)) return null; if (isClose(inner)) return { body: bodyLines.join("\n"), end: j }; bodyLines.push(lines[j]); j++; } return null; }; /** 开闭标签块(timeline/entries)公共处理:失败按 md,成功推进 i */ const takeBlock = ( line: string, isOpen: (t: string) => boolean, isClose: (t: string) => boolean, type: "timeline" | "entries" ): boolean => { const hit = scanBlock(isOpen, isClose); if (!hit) { buf.push(line); i++; return true; } flushMd(); raw.push({ type, body: hit.body }); i = hit.end + 1; return true; }; while (i < lines.length) { const trimmed = lines[i].trim(); if (trimmed.startsWith("```")) { inCode = !inCode; buf.push(lines[i]); i++; continue; } if (inCode) { buf.push(lines[i]); i++; continue; } if (isTimelineOpen(trimmed)) { takeBlock(lines[i], isTimelineOpen, isTimelineClose, "timeline"); continue; } if (isEntriesOpen(trimmed)) { takeBlock(lines[i], isEntriesOpen, isEntriesClose, "entries"); continue; } // [details 标题]…[/details] 折叠面板:嵌套开标签视为失败按 md 处理 if (isDetailsOpen(trimmed)) { const hit = scanBlock(isDetailsOpen, isDetailsClose); if (!hit) { buf.push(lines[i]); i++; continue; } flushMd(); raw.push({ type: "details", title: parseDetailsOpen(trimmed), body: hit.body }); i = hit.end + 1; continue; } // [bilibili]…[/bilibili] / [youtube]…[/youtube] 整行视频嵌入 const video = parseVideoLine(trimmed); if (video) { flushMd(); raw.push({ type: "video", ...video }); i++; continue; } // [entries:off] 例外块:body 原样进 md 缓冲(标记行丢弃), // 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown if (isEntriesOffOpen(trimmed)) { const hit = scanBlock(isEntriesOffOpen, isEntriesClose); if (!hit) { buf.push(lines[i]); i++; continue; } buf.push(hit.body); i = hit.end + 1; continue; } buf.push(lines[i]); i++; } flushMd(); const out: ContentSegment[] = []; for (const part of raw) { if (part.type === "timeline") { out.push({ type: "timeline", items: parseTimelineBody(part.body), rawBody: part.body, }); continue; } if (part.type === "entries") { out.push({ type: "entries", items: parseEntriesBody(part.body), rawBody: part.body, }); continue; } if (part.type === "details") { out.push({ type: "details", title: part.title, body: part.body }); continue; } if (part.type === "video") { out.push({ type: "video", provider: part.provider, id: part.id }); continue; } const hideSegs = splitHideSegments(part.text) as HideSegment[]; for (const s of hideSegs) { if (s.type === "md") { out.push({ type: "md", text: s.text }); } else { out.push({ type: "hide", kind: s.kind, points: s.points, locked: s.locked, body: s.body, }); } } } return out.length > 0 ? out : [{ type: "md", text: content }]; } /** 光标是否落在某个 [timeline]…[/timeline] 块内;返回块起止与正文 */ export function findTimelineBlockAt( content: string, cursor: number ): { start: number; end: number; body: string } | null { const blocks = listTimelineBlocks(content); for (const b of blocks) { if (cursor >= b.start && cursor <= b.end) return b; } return null; } /** * 编辑器打开时间线面板时的目标块: * 1) 光标落在某块内 → 该块 * 2) 正文恰有一块 → 该块(changelog 页常见:光标在标题区仍应更新已有时间线) * 3) 否则 null(新建插入) */ export function resolveTimelineBlockForEdit( content: string, cursor: number ): { start: number; end: number; body: string } | null { const at = findTimelineBlockAt(content, cursor); if (at) return at; const blocks = listTimelineBlocks(content); if (blocks.length === 1) return blocks[0]; return null; } function listTimelineBlocks( content: string ): Array<{ start: number; end: number; body: string }> { const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); const out: Array<{ start: number; end: number; body: string }> = []; let offset = 0; let inCode = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const lineStart = offset; const lineEnd = offset + line.length; const trimmed = line.trim(); if (trimmed.startsWith("```")) inCode = !inCode; if (!inCode && isTimelineOpen(trimmed)) { const openStart = lineStart; let j = i + 1; let innerCode = false; let closeEnd = -1; const bodyLines: string[] = []; let pos = lineEnd + 1; while (j < lines.length) { const l = lines[j]; const t = l.trim(); if (t.startsWith("```")) innerCode = !innerCode; if (!innerCode && isTimelineClose(t)) { closeEnd = pos + l.length; break; } bodyLines.push(l); pos += l.length + 1; j++; } if (closeEnd >= 0) { out.push({ start: openStart, end: closeEnd, body: bodyLines.join("\n") }); } } offset = lineEnd + 1; } return out; }