/** 正文 :::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(["login", "reply", "points", "password"]); function isClose(line: string): boolean { return line.trim() === ":::"; } /** 与 Go parseOpenMarker 对齐::::hide [points|password] [locked] */ function parseOpen(trimmed: string): { kind: HideKind; points: number; locked: boolean; } | null { if (!trimmed.startsWith(":::hide")) return null; const parts = trimmed .slice(":::hide".length) .trim() .split(/\s+/) .filter(Boolean); if (parts.length === 0) return null; const kind = parts[0] as HideKind; if (!HIDE_KINDS.has(kind)) return null; let idx = 1; let points = 0; let locked = false; if (kind === "points") { if (idx >= parts.length) return null; const n = Number(parts[idx]); if (!Number.isFinite(n) || n < 1 || n > 100000) return null; points = n; idx++; } else if (kind === "password") { // 脱敏::::hide password locked;原文::::hide password [locked] if (idx < parts.length && parts[idx] !== "locked") { idx++; } } for (; idx < parts.length; idx++) { if (parts[idx] === "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 => s.type === "md") .map((s) => s.text) .concat( segs .filter( (s): s is Extract => 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 "密码可见"; } }