131 lines
4.2 KiB
JavaScript
131 lines
4.2 KiB
JavaScript
/**
|
||
* 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.laravel13.elevue.rxthink.cn";
|
||
|
||
export const SITE_DESCRIPTION =
|
||
"RXThinkCMF 企业级前后端分离后台管理系统 - Laravel 13 + Vue3 + ElementPlus";
|
||
|
||
/**
|
||
* 剥离 H1/标题中的章节编号前缀,用于 <title> 与 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) {
|
||
const src = String(md);
|
||
const fm = src.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||
const body = fm ? src.slice(fm[0].length) : src;
|
||
|
||
let text = "";
|
||
// frontmatter description 显式声明优先(无 H1 页面的补齐手段)
|
||
const fmDesc = fm && fm[1].match(/^description:\s*(.+)$/m);
|
||
if (fmDesc) {
|
||
text = fmDesc[1].trim().replace(/^["']|["']$/g, "");
|
||
}
|
||
const tip = !text && body.match(/^:::\s*tip[^\n]*\n([\s\S]*?)\n:::/m);
|
||
if (tip) {
|
||
text = tip[1];
|
||
}
|
||
if (!text) {
|
||
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);
|
||
}
|