feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
298
frontend/lib/entryBlocks.ts
Normal file
298
frontend/lib/entryBlocks.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/** 正文 [entries]…[/entries] 条目卡块解析:
|
||||
* 通用「结构化小节」渲染约定——字段名不限定,任何书籍可定义自己的字段。
|
||||
* 块内语法:
|
||||
* ## 条目标题
|
||||
* @tags 标签一 | 主:次 | 标签三 → 标签 chips(| 或 | 分隔,"主:次" 渲染主次两段)
|
||||
* @summary 一句话结论 → 高亮摘要块(可多行;标题后的裸文本也计入摘要)
|
||||
* 字段名 :: 内容 → 灰色标签行(label 任意,可多行)
|
||||
* @source 来源文本 | https://… → 折叠来源列表(url 可省)
|
||||
*/
|
||||
|
||||
export interface EntrySource {
|
||||
text: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface EntryField {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface EntryItem {
|
||||
title: string;
|
||||
tags: string[];
|
||||
summary?: string;
|
||||
fields: EntryField[];
|
||||
sources: EntrySource[];
|
||||
/** 无标题头(注释标签卡等场景):不渲染编号与标题行 */
|
||||
plain?: boolean;
|
||||
}
|
||||
|
||||
const HEADING_RE = /^#{1,4}\s+(.+)$/;
|
||||
/** 条目标题自带序号前缀("1."、"12、"、"(3)"、"(4)"):卡片不再叠加自动编号 */
|
||||
export const ENTRY_TITLE_NUM_RE = /^(?:[((]\d+[))]|\d+\s*[.、))])/;
|
||||
const TAGS_RE = /^@tags\s+(.+)$/i;
|
||||
const SUMMARY_RE = /^@summary(?:\s+(.*))?$/i;
|
||||
const SOURCE_RE = /^@source\s+(.+)$/i;
|
||||
const PLAIN_RE = /^@plain$/i;
|
||||
const FIELD_RE = /^([^:@\n]{1,40}?)\s*::\s*(.*)$/;
|
||||
|
||||
/** 来源行:"文本 | url",取最后一个 | 且后段为 http(s) 才拆分;文本/链接兼容全角竖线 */
|
||||
export function parseEntrySource(raw: string): EntrySource {
|
||||
const idx = Math.max(raw.lastIndexOf("|"), raw.lastIndexOf("|"));
|
||||
if (idx > 0) {
|
||||
const url = raw.slice(idx + 1).trim();
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
return { text: raw.slice(0, idx).trim(), url };
|
||||
}
|
||||
}
|
||||
return { text: raw.trim() };
|
||||
}
|
||||
|
||||
export function parseEntriesBody(body: string): EntryItem[] {
|
||||
const lines = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const items: EntryItem[] = [];
|
||||
let cur: EntryItem | null = null;
|
||||
/** 普通文本行的归宿:摘要 / 最后一个字段(缺省时并入摘要) */
|
||||
let target: { kind: "summary" } | { kind: "field"; index: number } | null = null;
|
||||
|
||||
const flush = () => {
|
||||
if (cur) items.push(cur);
|
||||
cur = null;
|
||||
target = null;
|
||||
};
|
||||
|
||||
const appendSummary = (text: string) => {
|
||||
if (!cur) return;
|
||||
cur.summary = cur.summary ? `${cur.summary}\n${text}` : text;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
|
||||
const hm = t.match(HEADING_RE);
|
||||
if (hm) {
|
||||
flush();
|
||||
cur = { title: hm[1].trim(), tags: [], fields: [], sources: [] };
|
||||
target = null;
|
||||
continue;
|
||||
}
|
||||
if (!cur) continue;
|
||||
|
||||
const tm = t.match(TAGS_RE);
|
||||
if (tm) {
|
||||
cur.tags.push(...tm[1].split(/[||]/).map((s) => s.trim()).filter(Boolean));
|
||||
target = null;
|
||||
continue;
|
||||
}
|
||||
const sm = t.match(SUMMARY_RE);
|
||||
if (sm) {
|
||||
const rest = (sm[1] ?? "").trim();
|
||||
if (rest) appendSummary(rest);
|
||||
target = { kind: "summary" };
|
||||
continue;
|
||||
}
|
||||
const scm = t.match(SOURCE_RE);
|
||||
if (scm) {
|
||||
cur.sources.push(parseEntrySource(scm[1]));
|
||||
target = null;
|
||||
continue;
|
||||
}
|
||||
if (PLAIN_RE.test(t)) {
|
||||
cur.plain = true;
|
||||
target = null;
|
||||
continue;
|
||||
}
|
||||
const fm = t.match(FIELD_RE);
|
||||
if (fm) {
|
||||
cur.fields.push({ label: fm[1].trim(), value: fm[2] });
|
||||
target = { kind: "field", index: cur.fields.length - 1 };
|
||||
continue;
|
||||
}
|
||||
// 普通行:追加到开放容器;无容器时视为摘要(标题后直接跟正文段落)
|
||||
if (target?.kind === "field") {
|
||||
const f = cur.fields[target.index];
|
||||
f.value = `${f.value}\n${t}`;
|
||||
} else {
|
||||
appendSummary(t);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 序列化回块文本(编辑器面板复用);selected=false 的条目丢弃,与时间线一致 */
|
||||
export function serializeEntriesBlock(items: (EntryItem & { selected?: boolean })[]): string {
|
||||
const parts = items
|
||||
.filter((it) => it.selected !== false)
|
||||
.map((it) => {
|
||||
const lines = [`## ${it.title.trim() || "未命名"}`];
|
||||
if (it.plain) lines.push("@plain");
|
||||
if (it.tags.length > 0) lines.push(`@tags ${it.tags.join(" | ")}`);
|
||||
if (it.summary?.trim()) lines.push(`@summary ${it.summary.trim()}`);
|
||||
for (const f of it.fields) {
|
||||
if (!f.label.trim() && !f.value.trim()) continue;
|
||||
lines.push(`${f.label.trim() || "备注"} :: ${f.value.trim()}`);
|
||||
}
|
||||
for (const s of it.sources) {
|
||||
if (!s.text.trim()) continue;
|
||||
lines.push(`@source ${s.text.trim()}${s.url?.trim() ? ` | ${s.url.trim()}` : ""}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
});
|
||||
return `[entries]\n${parts.join("\n\n")}\n[/entries]`;
|
||||
}
|
||||
|
||||
export const ENTRIES_OPEN = "[entries]";
|
||||
export const ENTRIES_CLOSE = "[/entries]";
|
||||
|
||||
/** [entries:off] 例外块:书级条目卡模式开启时,块内恢复普通 Markdown 渲染 */
|
||||
export const ENTRIES_OFF_OPEN = "[entries:off]";
|
||||
|
||||
export function isEntriesOpen(line: string): boolean {
|
||||
return line.trim().toLowerCase() === ENTRIES_OPEN;
|
||||
}
|
||||
|
||||
export function isEntriesClose(line: string): boolean {
|
||||
return line.trim().toLowerCase() === ENTRIES_CLOSE;
|
||||
}
|
||||
|
||||
export function isEntriesOffOpen(line: string): boolean {
|
||||
return line.trim().toLowerCase() === ENTRIES_OFF_OPEN;
|
||||
}
|
||||
|
||||
/** 条目指令行:@tags / @summary / @source / @plain / 字段名 :: 内容(隐式模式识别条目特征用) */
|
||||
export function isEntrySignalLine(t: string): boolean {
|
||||
return (
|
||||
TAGS_RE.test(t) || SUMMARY_RE.test(t) || SOURCE_RE.test(t) || PLAIN_RE.test(t) || FIELD_RE.test(t)
|
||||
);
|
||||
}
|
||||
|
||||
/** 已知指令块标记行(隐式模式扫描时原样跳过,不当作条目内容) */
|
||||
const DIRECTIVE_LINE_RE = /^\[(\/)?(entries(?::off)?|timeline|hide[^\]]*)\]$/i;
|
||||
|
||||
export function isDirectiveLine(t: string): boolean {
|
||||
return DIRECTIVE_LINE_RE.test(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐式条目模式:把「## 标题 + 条目指令行」的区间自动包上 [entries] 壳。
|
||||
* 规则:
|
||||
* - 标题行(1-4 级)与其后到空行/结构标记前的段落里出现任一条目指令行 → 确认为条目;
|
||||
* 否则视为普通小节标题,原样输出
|
||||
* - 条目体持续收集:指令行后的普通文本行归入条目(与显式块语义一致);
|
||||
* 空行后若仍是指令行则继续,否则条目在空行处收尾
|
||||
* - 代码围栏与已知指令块([entries]/[entries:off]/[timeline]/[hide…])原样跳过
|
||||
*/
|
||||
export function wrapImplicitEntries(src: string): string {
|
||||
const lines = src.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const out: string[] = [];
|
||||
let inCode = false;
|
||||
let i = 0;
|
||||
|
||||
/** 从 i 行的开标记找匹配闭标记,返回闭标记行下标;找不到返回 -1 */
|
||||
const findClose = (start: number, isClose: (t: string) => boolean): number => {
|
||||
let innerCode = false;
|
||||
for (let j = start; j < lines.length; j++) {
|
||||
const t = lines[j].trim();
|
||||
if (t.startsWith("```")) innerCode = !innerCode;
|
||||
if (!innerCode && isClose(t)) return j;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const t = lines[i].trim();
|
||||
if (t.startsWith("```")) {
|
||||
inCode = !inCode;
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// 显式指令块整段原样([entries] 本身无需再包;off/timeline/hide 内容不参与隐式识别)
|
||||
if (isEntriesOpen(t) || isEntriesOffOpen(t) || isDirectiveLine(t)) {
|
||||
const close = findClose(
|
||||
i + 1,
|
||||
isEntriesOffOpen(t)
|
||||
? isEntriesClose
|
||||
: isEntriesOpen(t)
|
||||
? isEntriesClose
|
||||
: t.toLowerCase() === "[timeline]"
|
||||
? (x) => x.toLowerCase() === "[/timeline]"
|
||||
: (x) => x.toLowerCase().startsWith("[/hide")
|
||||
);
|
||||
if (close >= 0) {
|
||||
for (; i <= close; i++) out.push(lines[i]);
|
||||
} else {
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (HEADING_RE.test(t)) {
|
||||
// 前导段:标题行后到空行/结构标记前的所有行
|
||||
const seg: string[] = [];
|
||||
let k = i + 1;
|
||||
while (k < lines.length) {
|
||||
const tk = lines[k].trim();
|
||||
if (!tk || tk.startsWith("```") || isDirectiveLine(tk) || HEADING_RE.test(tk)) break;
|
||||
seg.push(lines[k]);
|
||||
k++;
|
||||
}
|
||||
if (!seg.some(isEntrySignalLine)) {
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// 条目确认:包壳输出标题 + 前导段,再继续吃条目体
|
||||
out.push(ENTRIES_OPEN);
|
||||
out.push(lines[i]);
|
||||
out.push(...seg);
|
||||
i = k;
|
||||
while (i < lines.length) {
|
||||
const t2 = lines[i].trim();
|
||||
if (!t2) {
|
||||
// 空行后仍是指令行 → 保留空行继续收集;否则条目到此收尾
|
||||
let p = i;
|
||||
while (p < lines.length && !lines[p].trim()) p++;
|
||||
if (p < lines.length && isEntrySignalLine(lines[p].trim())) {
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (t2.startsWith("```") || isDirectiveLine(t2) || HEADING_RE.test(t2)) break;
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
out.push(ENTRIES_CLOSE);
|
||||
continue;
|
||||
}
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** 编辑器「条目卡」插入模板:字段名(字段名::内容)按书籍内容自定义 */
|
||||
export const ENTRIES_TEMPLATE = `[entries]
|
||||
## 条目标题
|
||||
@tags 标签 | 主:次
|
||||
@summary 一句话结论。
|
||||
字段名 :: 内容。
|
||||
@source 来源标题 | https://example.com
|
||||
[/entries]`;
|
||||
|
||||
/** 编辑器「条目卡」插入模板(隐式模式):书籍开启全章节条目卡后免写外壳 */
|
||||
export const ENTRIES_TEMPLATE_AUTO = `## 条目标题
|
||||
@tags 标签 | 主:次
|
||||
@summary 一句话结论。
|
||||
字段名 :: 内容。
|
||||
@source 来源标题 | https://example.com`;
|
||||
Reference in New Issue
Block a user