/**
* 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);
}