首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
189 lines
4.5 KiB
TypeScript
189 lines
4.5 KiB
TypeScript
/** 正文 [hide]…[/hide] 块解析(与 backend/markdown/hide.go 契约一致) */
|
||
|
||
export type HideKind = "login" | "reply" | "points" | "password";
|
||
|
||
export type HideSegment =
|
||
| { type: "md"; text: string }
|
||
| {
|
||
type: "hide";
|
||
kind: HideKind;
|
||
points: number;
|
||
locked: boolean;
|
||
body: string;
|
||
};
|
||
|
||
const HIDE_KINDS = new Set<HideKind>(["login", "reply", "points", "password"]);
|
||
const HIDE_OPEN_RE = /^\[hide(?:\s+(.+))?\]$/i;
|
||
|
||
function isClose(line: string): boolean {
|
||
return line.trim().toLowerCase() === "[/hide]";
|
||
}
|
||
|
||
/** 与 Go parseOpenMarker 对齐:[hide <kind>[=arg] [locked]] */
|
||
function parseOpen(trimmed: string): {
|
||
kind: HideKind;
|
||
points: number;
|
||
locked: boolean;
|
||
} | null {
|
||
const m = trimmed.match(HIDE_OPEN_RE);
|
||
if (!m) return null;
|
||
const rest = (m[1] || "").trim();
|
||
if (!rest) return null;
|
||
const parts = rest.split(/\s+/).filter(Boolean);
|
||
if (parts.length === 0) return null;
|
||
|
||
let head = parts[0];
|
||
let kind = head;
|
||
let arg = "";
|
||
const eq = head.indexOf("=");
|
||
if (eq >= 0) {
|
||
kind = head.slice(0, eq);
|
||
arg = head.slice(eq + 1);
|
||
}
|
||
kind = kind.toLowerCase() as HideKind;
|
||
if (!HIDE_KINDS.has(kind)) return null;
|
||
|
||
let idx = 1;
|
||
let points = 0;
|
||
let locked = false;
|
||
|
||
if (kind === "points") {
|
||
if (!arg) return null;
|
||
const n = Number(arg);
|
||
if (!Number.isFinite(n) || n < 1 || n > 100000) return null;
|
||
points = n;
|
||
} else if (kind === "password") {
|
||
// 脱敏:[hide password locked];原文密码不在前端解析保留
|
||
if (arg) {
|
||
/* skip plaintext */
|
||
}
|
||
} else if (arg) {
|
||
return null;
|
||
}
|
||
|
||
for (; idx < parts.length; idx++) {
|
||
if (parts[idx].toLowerCase() === "locked") locked = true;
|
||
else return null;
|
||
}
|
||
return { kind, points, locked };
|
||
}
|
||
|
||
/**
|
||
* 将正文拆成公开 Markdown 段与隐藏块(忽略代码围栏内伪语法)。
|
||
* 解析失败时整篇当作普通 Markdown,避免渲染崩溃。
|
||
*/
|
||
export function splitHideSegments(content: string): HideSegment[] {
|
||
if (!content) return [{ type: "md", text: "" }];
|
||
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||
const out: HideSegment[] = [];
|
||
let buf: string[] = [];
|
||
let inCode = false;
|
||
let i = 0;
|
||
|
||
const flushMd = () => {
|
||
if (buf.length === 0) return;
|
||
out.push({ type: "md", text: buf.join("\n") });
|
||
buf = [];
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
const open = parseOpen(trimmed);
|
||
if (open) {
|
||
flushMd();
|
||
const bodyLines: string[] = [];
|
||
let j = i + 1;
|
||
let innerCode = false;
|
||
let found = 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 (parseOpen(inner)) {
|
||
buf.push(lines[i]);
|
||
i++;
|
||
found = false;
|
||
break;
|
||
}
|
||
if (isClose(inner)) {
|
||
found = true;
|
||
break;
|
||
}
|
||
bodyLines.push(lines[j]);
|
||
j++;
|
||
}
|
||
if (!found) {
|
||
buf.push(lines[i]);
|
||
i++;
|
||
continue;
|
||
}
|
||
out.push({
|
||
type: "hide",
|
||
kind: open.kind,
|
||
points: open.points,
|
||
locked: open.locked,
|
||
body: bodyLines.join("\n"),
|
||
});
|
||
i = j + 1;
|
||
continue;
|
||
}
|
||
|
||
buf.push(lines[i]);
|
||
i++;
|
||
}
|
||
flushMd();
|
||
return out.length > 0 ? out : [{ type: "md", text: content }];
|
||
}
|
||
|
||
/** 去掉锁块与标记后,供 SEO / TOC / description 使用的公开纯文本片段 */
|
||
export function publicMarkdownForSeo(content: string): string {
|
||
const segs = splitHideSegments(content);
|
||
return segs
|
||
.filter((s): s is Extract<HideSegment, { type: "md" }> => s.type === "md")
|
||
.map((s) => s.text)
|
||
.concat(
|
||
segs
|
||
.filter(
|
||
(s): s is Extract<HideSegment, { type: "hide" }> =>
|
||
s.type === "hide" && !s.locked
|
||
)
|
||
.map((s) => s.body)
|
||
)
|
||
.join("\n\n")
|
||
.trim();
|
||
}
|
||
|
||
export function hideKindLabel(kind: HideKind): string {
|
||
switch (kind) {
|
||
case "login":
|
||
return "登录可见";
|
||
case "reply":
|
||
return "回复可见";
|
||
case "points":
|
||
return "积分可见";
|
||
case "password":
|
||
return "密码可见";
|
||
}
|
||
}
|