去掉整帖可见性表单,改为 :::hide 按块脱敏;工具栏补齐代码块、链接/图片对话框与粘贴拖拽上传。 Co-authored-by: Cursor <cursoragent@cursor.com>
155 lines
3.8 KiB
TypeScript
155 lines
3.8 KiB
TypeScript
/** 正文 :::hide 块解析(与 backend/markdown/hide.go 契约一致) */
|
||
|
||
export type HideKind = "login" | "reply" | "points";
|
||
|
||
export type HideSegment =
|
||
| { type: "md"; text: string }
|
||
| {
|
||
type: "hide";
|
||
kind: HideKind;
|
||
points: number;
|
||
locked: boolean;
|
||
body: string;
|
||
};
|
||
|
||
const OPEN_RE = /^:::hide\s+(login|reply|points)(?:\s+(\d+))?(?:\s+(locked))?\s*$/;
|
||
|
||
function isClose(line: string): boolean {
|
||
return line.trim() === ":::";
|
||
}
|
||
|
||
function parseOpen(trimmed: string): {
|
||
kind: HideKind;
|
||
points: number;
|
||
locked: boolean;
|
||
} | null {
|
||
const m = OPEN_RE.exec(trimmed);
|
||
if (!m) return null;
|
||
const kind = m[1] as HideKind;
|
||
const points = m[2] ? Number(m[2]) : 0;
|
||
const locked = m[3] === "locked";
|
||
if (kind === "points" && (!Number.isFinite(points) || points < 1)) {
|
||
return null;
|
||
}
|
||
return { kind, points: kind === "points" ? points : 0, 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 "积分可见";
|
||
}
|
||
}
|