Files
2026-10-08 11:09:37 +08:00

104 lines
2.7 KiB
TypeScript

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<string, number>();
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;
}