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