新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
170 lines
5.6 KiB
TypeScript
170 lines
5.6 KiB
TypeScript
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));
|
||
}
|