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

299 lines
9.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 正文 [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`;