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

264 lines
7.3 KiB
TypeScript
Raw 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.
/** 统一拆分正文中的 [hide] / [timeline] / [entries] 指令块(忽略代码围栏) */
import {
splitHideSegments,
type HideKind,
type HideSegment,
} from "./hideBlocks.ts";
import { parseTimelineBody, type TimelineItem } from "./timelineBlocks.ts";
import {
isEntriesClose,
isEntriesOffOpen,
isEntriesOpen,
parseEntriesBody,
wrapImplicitEntries,
type EntryItem,
} from "./entryBlocks.ts";
export type ContentSegment =
| { type: "md"; text: string }
| {
type: "hide";
kind: HideKind;
points: number;
locked: boolean;
body: string;
}
| { type: "timeline"; items: TimelineItem[]; rawBody: string }
| { type: "entries"; items: EntryItem[]; rawBody: string };
function isTimelineClose(line: string): boolean {
return line.trim().toLowerCase() === "[/timeline]";
}
function isTimelineOpen(trimmed: string): boolean {
return trimmed.toLowerCase() === "[timeline]";
}
/**
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
* opts.entriesAuto:书级「全章节条目卡模式」开关——开启时按 wrapImplicitEntries
* 把「## 标题 + @tags/@summary/@source/字段::值」写法包上 [entries] 壳再走统一拆分;
* 关闭(含帖子/评论等非书场景)时均按普通 Markdown 渲染,条目卡只能用显式 [entries] 块。
* [entries:off] 块内始终保持普通 Markdown。
*/
export function splitContentSegments(
content: string,
opts?: { entriesAuto?: boolean }
): ContentSegment[] {
if (!content) return [{ type: "md", text: "" }];
if (opts?.entriesAuto) {
content = wrapImplicitEntries(content);
}
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const raw: Array<
{ type: "md"; text: string } | { type: "timeline"; body: string } | { type: "entries"; body: string }
> = [];
let buf: string[] = [];
let inCode = false;
let i = 0;
const flushMd = () => {
if (buf.length === 0) return;
raw.push({ type: "md", text: buf.join("\n") });
buf = [];
};
/** 从 i 行的开标签扫到匹配闭标签;忽略代码围栏,嵌套开标签视为失败(返回 null,调用方按 md 处理) */
const scanBlock = (
isOpen: (t: string) => boolean,
isClose: (t: string) => boolean
): { body: string; end: number } | null => {
const bodyLines: string[] = [];
let j = i + 1;
let innerCode = false;
while (j < lines.length) {
const inner = lines[j].trim();
if (inner.startsWith("```")) {
innerCode = !innerCode;
bodyLines.push(lines[j]);
j++;
continue;
}
if (innerCode) {
bodyLines.push(lines[j]);
j++;
continue;
}
if (isOpen(inner)) return null;
if (isClose(inner)) return { body: bodyLines.join("\n"), end: j };
bodyLines.push(lines[j]);
j++;
}
return null;
};
/** 开闭标签块(timeline/entries)公共处理:失败按 md,成功推进 i */
const takeBlock = (
line: string,
isOpen: (t: string) => boolean,
isClose: (t: string) => boolean,
type: "timeline" | "entries"
): boolean => {
const hit = scanBlock(isOpen, isClose);
if (!hit) {
buf.push(line);
i++;
return true;
}
flushMd();
raw.push({ type, body: hit.body });
i = hit.end + 1;
return true;
};
while (i < lines.length) {
const trimmed = lines[i].trim();
if (trimmed.startsWith("```")) {
inCode = !inCode;
buf.push(lines[i]);
i++;
continue;
}
if (inCode) {
buf.push(lines[i]);
i++;
continue;
}
if (isTimelineOpen(trimmed)) {
takeBlock(lines[i], isTimelineOpen, isTimelineClose, "timeline");
continue;
}
if (isEntriesOpen(trimmed)) {
takeBlock(lines[i], isEntriesOpen, isEntriesClose, "entries");
continue;
}
// [entries:off] 例外块:body 原样进 md 缓冲(标记行丢弃),
// 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown
if (isEntriesOffOpen(trimmed)) {
const hit = scanBlock(isEntriesOffOpen, isEntriesClose);
if (!hit) {
buf.push(lines[i]);
i++;
continue;
}
buf.push(hit.body);
i = hit.end + 1;
continue;
}
buf.push(lines[i]);
i++;
}
flushMd();
const out: ContentSegment[] = [];
for (const part of raw) {
if (part.type === "timeline") {
out.push({
type: "timeline",
items: parseTimelineBody(part.body),
rawBody: part.body,
});
continue;
}
if (part.type === "entries") {
out.push({
type: "entries",
items: parseEntriesBody(part.body),
rawBody: part.body,
});
continue;
}
const hideSegs = splitHideSegments(part.text) as HideSegment[];
for (const s of hideSegs) {
if (s.type === "md") {
out.push({ type: "md", text: s.text });
} else {
out.push({
type: "hide",
kind: s.kind,
points: s.points,
locked: s.locked,
body: s.body,
});
}
}
}
return out.length > 0 ? out : [{ type: "md", text: content }];
}
/** 光标是否落在某个 [timeline]…[/timeline] 块内;返回块起止与正文 */
export function findTimelineBlockAt(
content: string,
cursor: number
): { start: number; end: number; body: string } | null {
const blocks = listTimelineBlocks(content);
for (const b of blocks) {
if (cursor >= b.start && cursor <= b.end) return b;
}
return null;
}
/**
* 编辑器打开时间线面板时的目标块:
* 1) 光标落在某块内 → 该块
* 2) 正文恰有一块 → 该块(changelog 页常见:光标在标题区仍应更新已有时间线)
* 3) 否则 null(新建插入)
*/
export function resolveTimelineBlockForEdit(
content: string,
cursor: number
): { start: number; end: number; body: string } | null {
const at = findTimelineBlockAt(content, cursor);
if (at) return at;
const blocks = listTimelineBlocks(content);
if (blocks.length === 1) return blocks[0];
return null;
}
function listTimelineBlocks(
content: string
): Array<{ start: number; end: number; body: string }> {
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const out: Array<{ start: number; end: number; body: string }> = [];
let offset = 0;
let inCode = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineStart = offset;
const lineEnd = offset + line.length;
const trimmed = line.trim();
if (trimmed.startsWith("```")) inCode = !inCode;
if (!inCode && isTimelineOpen(trimmed)) {
const openStart = lineStart;
let j = i + 1;
let innerCode = false;
let closeEnd = -1;
const bodyLines: string[] = [];
let pos = lineEnd + 1;
while (j < lines.length) {
const l = lines[j];
const t = l.trim();
if (t.startsWith("```")) innerCode = !innerCode;
if (!innerCode && isTimelineClose(t)) {
closeEnd = pos + l.length;
break;
}
bodyLines.push(l);
pos += l.length + 1;
j++;
}
if (closeEnd >= 0) {
out.push({ start: openStart, end: closeEnd, body: bodyLines.join("\n") });
}
}
offset = lineEnd + 1;
}
return out;
}