Files
easygoadmin-wiki/EasyGoAdmin_GoFrame_EleVue/.vitepress/config/seo.ts
T
2026-10-25 17:24:27 +08:00

305 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SEO/GEO 构建期钩子(仅 Node 构建时运行,不进客户端 bundle):
* - transformPageData:每页独立 title(去编号前缀)与 description(取 :::tip 首句)
* - transformHead:canonical / Open Graph / twitter 卡片 / JSON-LD 结构化数据
* - sitemap.transformItems:注入 lastmod(git 最后提交时间)并过滤 404
*
* 由 config/index.ts 通过 createSeoHooks(zhConfig.themeConfig?.sidebar) 挂载。
* 依赖 extract.mjs(纯 ESM,同被 scripts/generate-llms-txt.mjs 复用)。
*/
import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
import {
SITE_URL,
extractDescription,
extractFaqs,
stripNumberPrefix,
} from "./extract.mjs";
/** 与 vitepress HeadConfig 同构:[tag, attrs, innerHTML?] */
type HeadTuple = [string, Record<string, any>, string?];
interface SideItem {
text?: string;
link?: string;
items?: SideItem[];
}
interface PageDataLike {
relativePath?: string;
title?: string;
description?: string;
frontmatter?: Record<string, any>;
[k: string]: any;
}
interface TransformCtx {
pageData?: PageDataLike;
title?: string;
description?: string;
content?: string;
siteConfig?: { root?: string; srcDir?: string };
}
const ORG = {
"@type": "Organization",
name: "EasyGoAdmin",
url: "https://www.easygoadmin.vip",
logo: `${SITE_URL}/logo.png`,
sameAs: [
"https://gitee.com/easygoadmin/EasyGoAdmin_GoFrame_EleVue",
"https://www.easygoadmin.vip",
],
};
/** 相对路径 → 站内路由(cleanUrls 关闭,正文页带 .html) */
function routeOf(relativePath: string): string {
if (relativePath === "index.md") return "/";
if (relativePath === "404.md") return "/404.html";
return `/${relativePath.replace(/\.md$/, ".html")}`;
}
/** 去掉侧边栏文案前导 emoji/符号,保留 "1.1 项目介绍" 这类编号文字 */
function cleanLabel(s?: string): string {
return (s || "").replace(/^[^\p{L}\p{N}]+/u, "").trim();
}
/** 从侧边栏构建 route → 面包屑文案链(含分组小节) */
function buildCrumbMap(sidebar: unknown): Map<string, string[]> {
const map = new Map<string, string[]>();
const groups: SideItem[] = Array.isArray(sidebar)
? sidebar
: ((sidebar as Record<string, SideItem[]>)?.["/zh/"] ?? []);
const walk = (items: SideItem[], trail: string[]) => {
for (const item of items) {
if (item.link && item.link.startsWith("/")) {
map.set(item.link, [...trail, cleanLabel(item.text)]);
} else if (item.items?.length) {
walk(item.items, [...trail, cleanLabel(item.text)]);
}
}
};
for (const group of groups) {
walk(group.items ?? [], [cleanLabel(group.text)].filter(Boolean));
}
return map;
}
/** 从渲染后的 HTML 提取 H1 纯文本(JSON-LD headline 须与页面 H1 一致) */
function h1OfHtml(html?: string): string {
const m = (html || "").match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
if (!m) return "";
return m[1]
.replace(/<[^>]+>/g, "")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
}
/** 从 Markdown 源提取 H1 纯文本(去除行内 markdown 标记) */
function h1OfMd(md: string): string {
const m = md.match(/^# (?!#)(.+)$/m);
if (!m) return "";
return m[1]
.replace(/`([^`]+)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.trim();
}
export function createSeoHooks(sidebar: unknown) {
const crumbs = buildCrumbMap(sidebar);
const projectDirs: string[] = [];
let gitDates: Map<string, string> | null = null;
const readMd = (relativePath: string, srcDir?: string): string => {
try {
return fs.readFileSync(
path.resolve(srcDir || process.cwd(), relativePath),
"utf8",
);
} catch {
return "";
}
};
/**
* 项目内每个文件最后一次 git 提交的 ISO 时间。
* 单次 git log 调用构建全量映射(key = 相对路径,与 pageData.relativePath 一致);
* git 不可用时静默降级(sitemap 无 lastmod、JSON-LD 无 dateModified)。
*/
const getGitDates = (root: string): Map<string, string> => {
if (gitDates) return gitDates;
gitDates = new Map<string, string>();
try {
const out = execSync(
"git log --format=:::%cI --name-only --relative -n 500 -- .",
{
cwd: root,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "ignore"],
},
);
let current = "";
for (const line of out.split(/\r?\n/)) {
if (line.startsWith(":::")) {
current = line.slice(3).trim();
continue;
}
const file = line.trim();
if (file && current && !gitDates.has(file)) {
gitDates.set(file, current);
}
}
} catch {
// 降级:无 lastmod / dateModified
}
return gitDates;
};
/** 每页独立 title(去 "5.10 " 编号前缀,关键词前置)与 description(:::tip 首句) */
function transformPageData(pageData: PageDataLike, ctx?: any): Record<string, unknown> {
const srcDir: string | undefined = ctx?.siteConfig?.srcDir;
const root: string | undefined = ctx?.siteConfig?.root;
if (root && !projectDirs.includes(root)) projectDirs.push(root);
const out: Record<string, unknown> = {};
const rel = pageData.relativePath;
if (rel && !pageData.frontmatter?.description) {
const desc = extractDescription(readMd(rel, srcDir));
if (desc) out.description = desc;
}
if (pageData.title) {
out.title = stripNumberPrefix(pageData.title);
}
return out;
}
/** 构建期注入 canonical / OG / twitter / JSON-LD */
function transformHead(ctx: TransformCtx): HeadTuple[] {
const rel = ctx?.pageData?.relativePath || "";
if (!rel || rel === "404.md") return [];
const route = routeOf(rel);
const url = `${SITE_URL}${route}`;
const title = ctx.pageData?.title || ctx.title || "";
const desc = ctx.description || "";
const isHome = rel === "index.md";
const image = `${SITE_URL}/logo.png`;
const heads: HeadTuple[] = [
["link", { rel: "canonical", href: url }],
["meta", { property: "og:type", content: isHome ? "website" : "article" }],
["meta", { property: "og:site_name", content: "EasyGoAdmin" }],
["meta", { property: "og:locale", content: "zh_CN" }],
["meta", { property: "og:title", content: title }],
["meta", { property: "og:description", content: desc }],
["meta", { property: "og:url", content: url }],
["meta", { property: "og:image", content: image }],
["meta", { property: "twitter:title", content: title }],
["meta", { property: "twitter:description", content: desc }],
["meta", { property: "twitter:url", content: url }],
["meta", { property: "twitter:image", content: image }],
];
// ---- JSON-LD ----
const root = ctx.siteConfig?.root || projectDirs[0] || process.cwd();
const srcDir = ctx.siteConfig?.srcDir || root;
const lastMod = getGitDates(root).get(rel);
const blocks: Record<string, any>[] = [];
if (isHome) {
blocks.push({
"@context": "https://schema.org",
"@type": "WebSite",
name: "EasyGoAdmin 开发文档",
url: `${SITE_URL}/`,
inLanguage: "zh-CN",
description: desc,
});
blocks.push({ "@context": "https://schema.org", ...ORG });
} else {
const md = readMd(rel, srcDir);
const article: Record<string, any> = {
"@context": "https://schema.org",
"@type": "TechArticle",
headline: h1OfMd(md) || h1OfHtml(ctx.content) || title,
description: desc,
url,
inLanguage: "zh-CN",
mainEntityOfPage: { "@type": "WebPage", "@id": url },
publisher: ORG,
};
if (lastMod) article.dateModified = lastMod;
blocks.push(article);
const trail = crumbs.get(route);
if (trail?.length) {
const items: Record<string, any>[] = [
{ "@type": "ListItem", position: 1, name: "首页", item: `${SITE_URL}/` },
];
trail.forEach((name, i) => {
const node: Record<string, any> = {
"@type": "ListItem",
position: i + 2,
name,
};
if (i === trail.length - 1) node.item = url;
items.push(node);
});
blocks.push({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items,
});
}
const faqs = extractFaqs(md);
if (faqs.length) {
blocks.push({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((f) => ({
"@type": "Question",
name: f.q,
acceptedAnswer: { "@type": "Answer", text: f.a },
})),
});
}
}
// 转义 "<",防止内容中出现 </script> 破坏注入
const json = JSON.stringify(blocks).replace(/</g, "\\u003c");
heads.push(["script", { type: "application/ld+json" }, json]);
return heads;
}
/** sitemap.xml:过滤 404/README,注入 git lastmod */
const sitemap = {
hostname: SITE_URL,
transformItems(items: { url: string; lastmod?: string }[]) {
const root = projectDirs[0] || process.cwd();
const dates = getGitDates(root);
const out: { url: string; lastmod?: string }[] = [];
for (const item of items) {
// ⚠ vitepress 传入的 item.url 是相对路径("404.html"、"zh/x.html"、""),
// 无前导斜杠也无域名——hostname 由 SitemapStream 事后拼接。
if (/(^|\/)404\.html$/.test(item.url)) continue; // 404 页不收录
if (/(^|\/)README\.html$/.test(item.url)) continue; // 仓库 README 非文档页
const p = item.url.replace(/^\//, "");
const rel =
p === "" || p === "index.html" ? "index.md" : p.replace(/\.html$/, ".md");
const lastmod = dates.get(rel);
out.push(lastmod ? { ...item, lastmod } : item);
}
return out;
},
};
return { transformPageData, transformHead, sitemap };
}