Files

178 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>();
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));
}
/** 阅读时长展示:≥60 分钟换算成「X 小时 Y 分钟」,避免大分钟数需口算 */
export function formatReadingDuration(minutes: number): string {
if (minutes < 60) return `${minutes} 分钟`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h} 小时 ${m} 分钟` : `${h} 小时`;
}