feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
140
frontend/lib/timelineBlocks.ts
Normal file
140
frontend/lib/timelineBlocks.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/** 正文 [timeline]…[/timeline] 块解析与序列化 */
|
||||
|
||||
export type TimelineItem = {
|
||||
date: string;
|
||||
title: string;
|
||||
body: string;
|
||||
sourceUrl?: string;
|
||||
/** 导入勾选;手填项默认 true */
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export type TimelineDayGroup = {
|
||||
date: string;
|
||||
items: TimelineItem[];
|
||||
};
|
||||
|
||||
const GIT_COMMENT_RE = /^<!--\s*git:\s*(https?:\/\/\S+)\s*-->\s*$/i;
|
||||
const HEADING_RE = /^##\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/;
|
||||
|
||||
/** 去掉控制字符与 RTL 覆盖,截断长度 */
|
||||
export function sanitizeTimelineText(raw: string, max: number): string {
|
||||
let s = raw
|
||||
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
|
||||
.replace(/[\u202A-\u202E\u2066-\u2069]/g, "");
|
||||
if ([...s].length > max) {
|
||||
s = [...s].slice(0, max).join("");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function isDirectiveLine(t: string): boolean {
|
||||
const lower = t.toLowerCase();
|
||||
return (
|
||||
lower === "[/hide]" ||
|
||||
lower === "[/timeline]" ||
|
||||
lower === "[timeline]" ||
|
||||
lower.startsWith("[hide") ||
|
||||
t.startsWith("<!-- git:")
|
||||
);
|
||||
}
|
||||
|
||||
/** 避免导入文本拆坏指令块:危险独立行加前缀空格 */
|
||||
export function neutralizeDirectiveLines(text: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const t = line.trim();
|
||||
if (isDirectiveLine(t)) {
|
||||
return " " + line;
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** 标题行:去掉换行,防止拆项 */
|
||||
export function sanitizeTimelineTitle(title: string): string {
|
||||
return sanitizeTimelineText(title.replace(/[\r\n]+/g, " ").trim(), 200);
|
||||
}
|
||||
|
||||
export function parseTimelineBody(body: string): TimelineItem[] {
|
||||
const lines = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const items: TimelineItem[] = [];
|
||||
let cur: TimelineItem | null = null;
|
||||
let bodyBuf: string[] = [];
|
||||
|
||||
const flush = () => {
|
||||
if (!cur) return;
|
||||
let sourceUrl: string | undefined;
|
||||
const kept: string[] = [];
|
||||
for (const line of bodyBuf) {
|
||||
const m = line.trim().match(GIT_COMMENT_RE);
|
||||
if (m) {
|
||||
sourceUrl = m[1];
|
||||
continue;
|
||||
}
|
||||
kept.push(line);
|
||||
}
|
||||
cur.body = kept.join("\n").replace(/^\n+/, "").replace(/\n+$/, "");
|
||||
if (sourceUrl) cur.sourceUrl = sourceUrl;
|
||||
items.push(cur);
|
||||
cur = null;
|
||||
bodyBuf = [];
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const m = line.match(HEADING_RE);
|
||||
if (m) {
|
||||
flush();
|
||||
cur = {
|
||||
date: m[1],
|
||||
title: sanitizeTimelineTitle(m[2]),
|
||||
body: "",
|
||||
selected: true,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (cur) bodyBuf.push(line);
|
||||
}
|
||||
flush();
|
||||
return items;
|
||||
}
|
||||
|
||||
export function serializeTimelineBlock(items: TimelineItem[]): string {
|
||||
const parts = items
|
||||
.filter((it) => it.selected !== false)
|
||||
.map((it) => {
|
||||
const date = /^\d{4}-\d{2}-\d{2}$/.test(it.date)
|
||||
? it.date
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const title = sanitizeTimelineTitle(it.title) || "未命名";
|
||||
const body = neutralizeDirectiveLines(sanitizeTimelineText(it.body || "", 2000));
|
||||
const lines = [`## ${date} ${title}`];
|
||||
if (body.trim()) lines.push(body.trimEnd());
|
||||
if (it.sourceUrl && /^https:\/\//i.test(it.sourceUrl)) {
|
||||
lines.push(`<!-- git: ${it.sourceUrl} -->`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
});
|
||||
return `[timeline]\n${parts.join("\n\n")}\n[/timeline]`;
|
||||
}
|
||||
|
||||
export function groupTimelineByDate(items: TimelineItem[]): TimelineDayGroup[] {
|
||||
const map = new Map<string, TimelineItem[]>();
|
||||
const order: string[] = [];
|
||||
for (const it of items) {
|
||||
const d = it.date || "未知日期";
|
||||
if (!map.has(d)) {
|
||||
map.set(d, []);
|
||||
order.push(d);
|
||||
}
|
||||
map.get(d)!.push(it);
|
||||
}
|
||||
order.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
|
||||
return order.map((date) => ({ date, items: map.get(date)! }));
|
||||
}
|
||||
|
||||
export function isLikelyMergeCommit(title: string): boolean {
|
||||
return /^merge\b/i.test(title.trim()) || /^合并\b/.test(title.trim());
|
||||
}
|
||||
Reference in New Issue
Block a user