import "server-only"; /** * 从 MDX 原文中提取标题,生成目录(TOC)。 * * 说明:这里用正则扫描 Markdown 标题而不是解析 AST——因为标题锚点 id * 由 rehype-slug 生成,其算法是 GitHub 风格的 slugify。为了与渲染结果 * 完全一致,本模块实现了同一套规则(转小写、去标点、空格转连字符)。 * * 只提取 h2 / h3:文档式阅读下超过三级的目录反而降低可扫读性。 */ export interface TocItem { /** 标题文本(已去除行内 Markdown 标记) */ title: string; /** 与 rehype-slug 生成结果一致的锚点 id */ id: string; /** 标题层级:2 或 3 */ level: 2 | 3; } /** * GitHub 风格 slugify,与 rehype-slug 的默认行为保持一致。 * * - 转小写 * - 移除除字母、数字、空格、连字符、下划线、CJK 之外的字符 * - 空格转连字符 */ export function slugifyHeading(text: string): string { return text .trim() .toLowerCase() .replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, "") .replace(/\s+/g, "-"); } /** 去掉标题中的行内标记:**粗体**、`代码`、[链接](url) */ function stripInlineMarkdown(text: string): string { return text .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // [文本](链接) → 文本 .replace(/[*_`~]/g, "") // 强调与行内代码标记 .trim(); } /** * 提取目录项。 * * 跳过代码块内的 `#` 注释,避免把代码里的注释误当作标题。 */ export function extractToc(source: string): TocItem[] { const items: TocItem[] = []; const slugCounter = new Map(); let insideCodeFence = false; let fenceMarker = ""; for (const rawLine of source.split("\n")) { const line = rawLine.trimEnd(); // 跟踪代码块边界(``` 或 ~~~) const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(line); if (fenceMatch) { const marker = fenceMatch[1][0]; if (!insideCodeFence) { insideCodeFence = true; fenceMarker = marker; } else if (marker === fenceMarker) { insideCodeFence = false; fenceMarker = ""; } continue; } if (insideCodeFence) { continue; } // ATX 标题:## 标题 / ### 标题 const headingMatch = /^(#{2,3})\s+(.+?)\s*#*\s*$/.exec(line); if (!headingMatch) { continue; } const level = headingMatch[1].length as 2 | 3; const title = stripInlineMarkdown(headingMatch[2]); if (!title) { continue; } // 处理重复标题:rehype-slug 会为同名标题追加 -1、-2 … const baseId = slugifyHeading(title); const seen = slugCounter.get(baseId) ?? 0; slugCounter.set(baseId, seen + 1); const id = seen === 0 ? baseId : `${baseId}-${seen}`; items.push({ title, id, level }); } return items; }