feat: 公文三栏单页布局,并修复时间线目录跳转与样式
公告/单页对齐帖子详情三栏壳;时间线按日锚点可跳转,条目改为日志行并用暖金日期区分 feat 色。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
102
frontend/lib/toc.ts
Normal file
102
frontend/lib/toc.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { TocItem } from "@/components/PostToc";
|
||||
|
||||
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] 块内标题不计入
|
||||
*/
|
||||
export function extractToc(content: string): TocItem[] {
|
||||
const items: TocItem[] = [];
|
||||
let inCode = false;
|
||||
let inTimeline = false;
|
||||
let inHide = 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 && 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));
|
||||
}
|
||||
|
||||
/** 粗略阅读时长:约 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));
|
||||
}
|
||||
Reference in New Issue
Block a user