import type { TocItem } from "@/components/PostToc"; import type { ContentSegment } from "@/lib/directiveBlocks"; const TIMELINE_DAY_RE = /^##\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/; const HEADING_RE = /^(#{2,3})\s+(.+?)\s*#*\s*$/; const HIDE_OPEN_RE = /^\[hide(?:\s+.+)?\]$/i; /** 时间线日分组锚点 id(与 MdTimeline 一致) */ export function timelineDayId(date: string): string { if (/^\d{4}-\d{2}-\d{2}$/.test(date)) return `tl-${date}`; return `tl-${date.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown"}`; } /** * 从正文提取目录: * - 块外 ## / ### → h-{n}(供 headingRenderer 按序挂 id) * - [timeline] 内按日期去重 → tl-YYYY-MM-DD(日分组锚点) * - 代码围栏 / [hide] / [entries] 块内标题不计入 */ export function extractToc(content: string): TocItem[] { const items: TocItem[] = []; let inCode = false; let inTimeline = false; let inHide = false; let inEntries = false; let headingCount = 0; /** 当前 timeline 块内出现过的日期(保持文件顺序,收块时再按日倒序发出) */ let timelineDates: string[] = []; const timelineSeen = new Set(); const flushTimelineDates = () => { if (timelineDates.length === 0) return; const sorted = [...timelineDates].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)); for (const date of sorted) { items.push({ level: 2, text: date, id: timelineDayId(date) }); } timelineDates = []; timelineSeen.clear(); }; for (const raw of content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) { const line = raw.trim(); if (line.startsWith("```")) { inCode = !inCode; continue; } if (inCode) continue; const lower = line.toLowerCase(); if (!inHide && !inTimeline && lower === "[timeline]") { inTimeline = true; continue; } if (inTimeline && lower === "[/timeline]") { inTimeline = false; flushTimelineDates(); continue; } if (inTimeline) { const tm = TIMELINE_DAY_RE.exec(line); if (tm && !timelineSeen.has(tm[1])) { timelineSeen.add(tm[1]); timelineDates.push(tm[1]); } continue; } if (!inHide && !inTimeline && !inEntries && lower === "[entries]") { inEntries = true; continue; } if (inEntries && lower === "[/entries]") { inEntries = false; continue; } // 条目卡块内标题不进目录(渲染时也不挂 h-{n} 锚点,避免错位) if (inEntries) continue; if (!inHide && HIDE_OPEN_RE.test(line)) { inHide = true; continue; } if (inHide && lower === "[/hide]") { inHide = false; continue; } if (inHide) continue; const m = HEADING_RE.exec(line); if (m) { items.push({ level: m[1].length as 2 | 3, text: m[2], id: `h-${headingCount++}`, }); } } // 未闭合的 timeline 也尽量发出已收集日期 flushTimelineDates(); return items; } /** 仅正文 Markdown 标题(排除时间线日锚点),供 headingRenderer 按序挂 id */ export function markdownHeadingToc(toc: TocItem[]): TocItem[] { return toc.filter((t) => /^h-\d+$/.test(t.id)); } /** buildTocFromSegments 的产物:目录项 + 供正文挂锚点的三组 id */ export interface SegmentsToc { /** 按渲染顺序的目录项(md 标题 / 条目标题 / 时间线日),id 已由 nextAnchor 分配 */ items: TocItem[]; /** md 段标题 id 序列(供 headingComponentsFor 按序挂到 h2/h3) */ mdHeadIds: string[]; /** 按段索引组织的条目标题 id(传给 MdEntries 挂到卡内标题) */ entriesTitleIds: (string[] | undefined)[]; } /** * 基于「渲染分段」构建章内目录(条目卡模式下的目录来源): * - md 段标题 → h2/h3 级目录项(顺序与 headingRenderer 一致) * - entries 段条目标题 → 二级目录项(与卡内标题共享锚点,目录可跳转) * - timeline 段 → 日分组锚点(与 extractToc 同为日倒序);hide 段内容不进目录 * nextAnchor 与阅读页章内锚点计数器共享,保证目录、正文、卡内标题同一份 id */ export function buildTocFromSegments( segs: ContentSegment[], nextAnchor: () => string ): SegmentsToc { const items: TocItem[] = []; const mdHeadIds: string[] = []; const entriesTitleIds: (string[] | undefined)[] = []; for (let si = 0; si < segs.length; si++) { const seg = segs[si]; if (seg.type === "md") { for (const raw of seg.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) { const m = HEADING_RE.exec(raw.trim()); if (!m) continue; const id = nextAnchor(); items.push({ level: m[1].length as 2 | 3, text: m[2], id }); mdHeadIds.push(id); } continue; } if (seg.type === "entries") { const ids = seg.items.map(() => nextAnchor()); seg.items.forEach((it, i) => items.push({ level: 2, text: it.title, id: ids[i] })); entriesTitleIds[si] = ids; continue; } if (seg.type === "timeline") { const dates = [...new Set(seg.items.map((t) => t.date))].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)); for (const date of dates) { items.push({ level: 2, text: date, id: timelineDayId(date) }); } } // hide 段内容(未解锁/未登录不可见)不进目录 } return { items, mdHeadIds, entriesTitleIds }; } /** 粗略阅读时长:约 400 字/分钟,至少 1 分钟 */ export function estimateReadingMinutes(text: string, fallbackLen = 0): number { const len = text.trim().length > 0 ? text.trim().length : fallbackLen; return Math.max(1, Math.ceil(len / 400)); }