/** * 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?]; interface SideItem { text?: string; link?: string; items?: SideItem[]; } interface PageDataLike { relativePath?: string; title?: string; description?: string; frontmatter?: Record; [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_AntdVue", "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 { const map = new Map(); const groups: SideItem[] = Array.isArray(sidebar) ? sidebar : ((sidebar as Record)?.["/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(/]*>([\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 | 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 => { if (gitDates) return gitDates; gitDates = new Map(); 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 { 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 = {}; 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[] = []; 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 = { "@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[] = [ { "@type": "ListItem", position: 1, name: "首页", item: `${SITE_URL}/` }, ]; trail.forEach((name, i) => { const node: Record = { "@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 }, })), }); } } // 转义 "<",防止内容中出现 破坏注入 const json = JSON.stringify(blocks).replace(/