104 lines
2.7 KiB
TypeScript
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;
|
|
}
|