Files
jiang13-bbs/frontend/lib/timelineBlocks.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

143 lines
3.9 KiB
TypeScript

/** 正文 [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 === "[/entries]" ||
lower === "[timeline]" ||
lower === "[entries]" ||
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());
}