305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
/**
|
||
* 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: "RXThinkCMF",
|
||
url: "https://www.rxthink.cn",
|
||
logo: `${SITE_URL}/logo.png`,
|
||
sameAs: [
|
||
"https://gitee.com/rxthinkcmf/RXThinkCMF_Laravel13_EleVue",
|
||
"https://www.rxthink.cn",
|
||
],
|
||
};
|
||
|
||
/** 相对路径 → 站内路由(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(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, '"')
|
||
.replace(/'/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: "RXThinkCMF" }],
|
||
["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: "RXThinkCMF 开发文档",
|
||
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 };
|
||
}
|