Files
jiang13-bbs/frontend/lib/toc.ts
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

170 lines
5.6 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));
}