This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
+103
@@ -0,0 +1,103 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user