/** * SEO/GEO 共享工具(纯 ESM,零依赖)。 * * 同时被两类调用方 import: * 1. VitePress 配置(.ts,esbuild 打包时内联本文件) * 2. scripts/generate-llms-txt.mjs(Node 直接运行) * * 注意:SITE_URL 是全站唯一 URL 真相源。 * docs 子域当前 TLS 证书为腾讯 CDN 默认证书(*.cdn.myqcloud.com,校验失败), * 故暂用 http;证书修复后只需改这里一处。 */ export const SITE_URL = "http://docs.goframe.elevue.easygoadmin.vip"; export const SITE_DESCRIPTION = "EasyGoAdmin 企业级多租户后台管理基座 - GoFrame v2 + Vue3 + ElementPlus"; /** * 剥离 H1/标题中的章节编号前缀,用于 与 JSON-LD headline。 * 例:"5.10 字典模块" → "字典模块";"K. 贡献指南" → "贡献指南"。 * 页内 H1 与侧边栏编号保持不变,仅影响 SEO 输出。 */ export function stripNumberPrefix(title) { return String(title) .replace(/^(?:\d+(?:\.\d+)+|[A-Z]\.)\s+/, "") .trim(); } /** * 从页面 Markdown 提取 meta description: * 1. 首选 `:::tip xxx` 容器内的首句(141/143 页的标准 opener) * 2. 回退到 H1 后的第一个普通段落 * 输出压缩为单行、去除行内 Markdown,按中文句末标点截断(≤100 字符)。 */ export function extractDescription(md) { let body = String(md).replace(/^---[\s\S]*?\n---\s*/, ""); let text = ""; const tip = body.match(/^:::\s*tip[^\n]*\n([\s\S]*?)\n:::/m); if (tip) { text = tip[1]; } else { const afterH1 = body.match(/^# (?!#)(.+)$/m); if (afterH1) { const rest = body.slice((afterH1.index ?? 0) + afterH1[0].length); const para = rest .split(/\n{2,}/) .find( (p) => p.trim() && !/^\s*#/.test(p) && !/^\s*:::/.test(p) && !/^\s*```/.test(p) && !/^\s*\|/.test(p), ); text = para || ""; } } text = text .replace(/\s+/g, " ") .replace(/`([^`]+)`/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") .trim(); if (!text) return ""; // 按中文句末标点截取首句,上限 100 字符;截断时避免切碎词 const m = text.match(/^.{1,100}?(?:[。!?!?]|$)/); let out = m ? m[0] : text.slice(0, 100); if (out.length < text.length && !/[。!?!?]$/.test(out)) { out = out.replace(/[^\s,、;:,;:]+$/, ""); } return out.trim() || text.slice(0, 100); } function cleanText(s) { return s .replace(/^:::[^\n]*$/gm, "") .replace(/^```[^\n]*$/gm, "") .replace(/`([^`]+)`/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") .replace(/\s+/g, " ") .trim(); } /** * 提取页面中的 Q/A 对(用于 FAQPage JSON-LD),支持两种站内格式: * A. `### Q:问题` 标题式(如 zh/quickstart/faq.md) * B. `**Q:问题**` 加粗式(如 zh/appendix/faq.md) * 返回 [{ q, a }];无匹配返回 []。 */ export function extractFaqs(md) { const body = String(md).replace(/^---[\s\S]*?\n---\s*/, ""); let faqs = []; // 格式 A:按 `### Q:` 切块,答案截断到下一个标题 for (const part of body.split(/(?=^###\s*Q[::])/m)) { const m = part.match(/^###\s*Q[::]\s*([^\n]+)\n+([\s\S]*)/); if (!m) continue; let a = m[2]; const cut = a.search(/^#{1,4}\s/m); if (cut >= 0) a = a.slice(0, cut); faqs.push({ q: cleanText(m[1]), a: cleanText(a) }); } // 格式 B:按 `**Q:` 切块 if (!faqs.length) { for (const part of body.split(/(?=^\*\*Q[::])/m)) { const m = part.match(/^\*\*Q[::]\s*([^*]+)\*\*\s*\n+([\s\S]*)/); if (!m) continue; let a = m[2]; const cut = a.search(/^(?:#{1,4}\s|\*\*Q)/m); if (cut >= 0) a = a.slice(0, cut); faqs.push({ q: cleanText(m[1]), a: cleanText(a) }); } } return faqs.filter((f) => f.q && f.a); }