-
Notifications
You must be signed in to change notification settings - Fork 45
feat(seo): 全站 SEO 优化 — sitemap / JSON-LD / canonical / robots #289
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -93,8 +93,59 @@ export default async function DocPage({ params }: Param) { | |
| getDocContributorsByDocId(docIdFromPage); | ||
| const Mdx = page.data.body; | ||
|
|
||
| // SEO 结构化数据 | ||
| const siteUrl = | ||
| process.env.NEXT_PUBLIC_SITE_URL || "https://involutionhell.com"; | ||
| const slugPath = (slug ?? []).join("/"); | ||
| const docUrl = slugPath ? `${siteUrl}/docs/${slugPath}` : `${siteUrl}/docs`; | ||
|
|
||
| // TechArticle: 让 docs 在 Google 搜索结果上更可能展示为技术文章卡片 | ||
| const articleJsonLd = { | ||
| "@context": "https://schema.org", | ||
| "@type": "TechArticle", | ||
| headline: page.data.title, | ||
| description: page.data.description, | ||
| url: docUrl, | ||
| inLanguage: locale === "en" ? "en-US" : "zh-CN", | ||
| publisher: { | ||
| "@type": "Organization", | ||
| name: "Involution Hell", | ||
| url: siteUrl, | ||
| }, | ||
| }; | ||
|
|
||
| // BreadcrumbList: 按 slug 层级生成面包屑(Google 搜索结果里的那种层级链接) | ||
| const breadcrumbItems = [ | ||
| { name: "Involution Hell", url: siteUrl }, | ||
| { name: "Docs", url: `${siteUrl}/docs` }, | ||
| ...(slug ?? []).map((seg, idx) => ({ | ||
| name: decodeURIComponent(seg), | ||
| url: `${siteUrl}/docs/${slug!.slice(0, idx + 1).join("/")}`, | ||
| })), | ||
| ]; | ||
| const breadcrumbJsonLd = { | ||
| "@context": "https://schema.org", | ||
| "@type": "BreadcrumbList", | ||
| itemListElement: breadcrumbItems.map((item, idx) => ({ | ||
| "@type": "ListItem", | ||
| position: idx + 1, | ||
| name: item.name, | ||
| item: item.url, | ||
| })), | ||
| }; | ||
|
|
||
| return ( | ||
| <> | ||
| <script | ||
| type="application/ld+json" | ||
| // eslint-disable-next-line react/no-danger | ||
| dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }} | ||
| /> | ||
| <script | ||
| type="application/ld+json" | ||
| // eslint-disable-next-line react/no-danger | ||
| dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} | ||
| /> | ||
| <DocsPage toc={page.data.toc}> | ||
| <DocsBody> | ||
| <div className="mb-6 flex flex-col gap-3 border-b border-border pb-6 md:mb-8 md:flex-row md:items-start md:justify-between"> | ||
|
|
@@ -144,8 +195,35 @@ export async function generateMetadata({ params }: Param): Promise<Metadata> { | |
| notFound(); | ||
| } | ||
|
|
||
| // 规范化 slug → canonical 路径。用户访问 /docs/ai/rl(原文)或 /docs/ai/rl.en(翻译版) | ||
| // 都统一指向原始 slug,避免两个 URL 竞争同一份内容的 PageRank。 | ||
| const slugPath = (slug ?? []).join("/"); | ||
| const canonical = slugPath ? `/docs/${slugPath}` : "/docs"; | ||
|
|
||
|
Comment on lines
+198
to
+202
|
||
| // hreflang:告诉搜索引擎该文档有哪些语言版本。 | ||
| // 翻译版文件命名是 `<slug>.en.mdx` / `<slug>.zh.mdx`,URL 靠 cookie 切换, | ||
| // 两种语言走同一 canonical URL,因此 hreflang 都指向自己。 | ||
| const languages: Record<string, string> = { | ||
| "zh-CN": canonical, | ||
| "en-US": canonical, | ||
| "x-default": canonical, | ||
| }; | ||
|
|
||
| return { | ||
| title: page.data.title, | ||
| description: page.data.description, | ||
| alternates: { canonical, languages }, | ||
| openGraph: { | ||
| type: "article", | ||
| title: page.data.title, | ||
| description: page.data.description, | ||
| url: canonical, | ||
| locale: locale === "en" ? "en_US" : "zh_CN", | ||
| }, | ||
| twitter: { | ||
| card: "summary_large_image", | ||
| title: page.data.title, | ||
| description: page.data.description, | ||
| }, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -114,8 +114,10 @@ interface ProfileResponse { | |||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||
| function warnFetchProfile(message: string, details?: Record<string, unknown>) { | ||||||||||||||||||||||||||||||||||||||||||
| const isProduction = process.env.NODE_ENV === "production"; | ||||||||||||||||||||||||||||||||||||||||||
| const status = typeof details?.status === "number" ? details.status : undefined; | ||||||||||||||||||||||||||||||||||||||||||
| const success = typeof details?.success === "boolean" ? details.success : undefined; | ||||||||||||||||||||||||||||||||||||||||||
| const status = | ||||||||||||||||||||||||||||||||||||||||||
| typeof details?.status === "number" ? details.status : undefined; | ||||||||||||||||||||||||||||||||||||||||||
| const success = | ||||||||||||||||||||||||||||||||||||||||||
| typeof details?.success === "boolean" ? details.success : undefined; | ||||||||||||||||||||||||||||||||||||||||||
| const isExpectedNotFound = status === 404 || success === false; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // 生产环境仅记录需要诊断的异常场景;404 / success=false 属于预期控制流, | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -294,13 +296,32 @@ interface Param { | |||||||||||||||||||||||||||||||||||||||||
| export async function generateMetadata({ params }: Param): Promise<Metadata> { | ||||||||||||||||||||||||||||||||||||||||||
| const { username } = await params; | ||||||||||||||||||||||||||||||||||||||||||
| const data = await fetchProfile(username); | ||||||||||||||||||||||||||||||||||||||||||
| if (!data) return { title: `@${username}` }; | ||||||||||||||||||||||||||||||||||||||||||
| if (!data) return { title: `@${username}`, robots: { index: false } }; | ||||||||||||||||||||||||||||||||||||||||||
| const displayName = data.user.displayName || data.user.username; | ||||||||||||||||||||||||||||||||||||||||||
| const description = | ||||||||||||||||||||||||||||||||||||||||||
| data.preferences?.bio || | ||||||||||||||||||||||||||||||||||||||||||
| `${displayName} on Involution Hell — projects, papers, and docs contributions.`; | ||||||||||||||||||||||||||||||||||||||||||
| // 用 githubId 作为 canonical URL,避免 /u/github_114939201 和 /u/114939201 两个入口重复索引 | ||||||||||||||||||||||||||||||||||||||||||
| const canonicalId = data.user.githubId ?? data.user.username; | ||||||||||||||||||||||||||||||||||||||||||
| const canonical = `/u/${canonicalId}`; | ||||||||||||||||||||||||||||||||||||||||||
| const title = `${displayName} (@${data.user.username})`; | ||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||
| title: `${displayName} (@${data.user.username})`, | ||||||||||||||||||||||||||||||||||||||||||
| description: | ||||||||||||||||||||||||||||||||||||||||||
| data.preferences?.bio || | ||||||||||||||||||||||||||||||||||||||||||
| `${displayName} 在 Involution Hell 的个人主页 — 项目、论文与文档贡献。`, | ||||||||||||||||||||||||||||||||||||||||||
| title, | ||||||||||||||||||||||||||||||||||||||||||
| description, | ||||||||||||||||||||||||||||||||||||||||||
| alternates: { canonical }, | ||||||||||||||||||||||||||||||||||||||||||
| openGraph: { | ||||||||||||||||||||||||||||||||||||||||||
| type: "profile", | ||||||||||||||||||||||||||||||||||||||||||
| title, | ||||||||||||||||||||||||||||||||||||||||||
| description, | ||||||||||||||||||||||||||||||||||||||||||
| url: canonical, | ||||||||||||||||||||||||||||||||||||||||||
| images: data.user.avatarUrl ? [{ url: data.user.avatarUrl }] : undefined, | ||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||
| twitter: { | ||||||||||||||||||||||||||||||||||||||||||
| card: "summary", | ||||||||||||||||||||||||||||||||||||||||||
| title, | ||||||||||||||||||||||||||||||||||||||||||
| description, | ||||||||||||||||||||||||||||||||||||||||||
| images: data.user.avatarUrl ? [data.user.avatarUrl] : undefined, | ||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -339,8 +360,34 @@ export default async function UserProfilePage({ params }: Param) { | |||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Person JSON-LD:让搜索引擎识别这是一个"个人档案"而不是普通页面,有机会走 knowledge panel | ||||||||||||||||||||||||||||||||||||||||||
| const siteUrl = | ||||||||||||||||||||||||||||||||||||||||||
| process.env.NEXT_PUBLIC_SITE_URL || "https://involutionhell.com"; | ||||||||||||||||||||||||||||||||||||||||||
| const personJsonLd = { | ||||||||||||||||||||||||||||||||||||||||||
| "@context": "https://schema.org", | ||||||||||||||||||||||||||||||||||||||||||
| "@type": "Person", | ||||||||||||||||||||||||||||||||||||||||||
| name: user.displayName || user.username, | ||||||||||||||||||||||||||||||||||||||||||
| alternateName: user.username, | ||||||||||||||||||||||||||||||||||||||||||
| url: `${siteUrl}/u/${user.githubId ?? user.username}`, | ||||||||||||||||||||||||||||||||||||||||||
| ...(user.avatarUrl ? { image: user.avatarUrl } : {}), | ||||||||||||||||||||||||||||||||||||||||||
| ...(preferences.bio ? { description: preferences.bio } : {}), | ||||||||||||||||||||||||||||||||||||||||||
| ...(user.githubId | ||||||||||||||||||||||||||||||||||||||||||
| ? { sameAs: [`https://github.com/${user.githubId}`] } | ||||||||||||||||||||||||||||||||||||||||||
| : {}), | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+374
to
+376
|
||||||||||||||||||||||||||||||||||||||||||
| ...(user.githubId | |
| ? { sameAs: [`https://github.com/${user.githubId}`] } | |
| : {}), |
Copilot
AI
Apr 16, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The JSON-LD script is built from user-controlled fields (e.g. preferences.bio, user.username) and injected via dangerouslySetInnerHTML with a raw JSON.stringify(...). If any field contains </script> (or <), it can break out of the script tag and enable XSS. Escape unsafe characters in the serialized JSON-LD (commonly replacing < with \u003c, and also >/& as needed) before injecting.
| return ( | |
| <> | |
| <script | |
| type="application/ld+json" | |
| // eslint-disable-next-line react/no-danger | |
| dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }} | |
| const personJsonLdString = JSON.stringify(personJsonLd) | |
| .replace(/</g, "\\u003c") | |
| .replace(/>/g, "\\u003e") | |
| .replace(/&/g, "\\u0026") | |
| .replace(/\u2028/g, "\\u2028") | |
| .replace(/\u2029/g, "\\u2029"); | |
| return ( | |
| <> | |
| <script | |
| type="application/ld+json" | |
| // eslint-disable-next-line react/no-danger | |
| dangerouslySetInnerHTML={{ __html: personJsonLdString }} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The JSON-LD blobs are injected with
dangerouslySetInnerHTMLand a rawJSON.stringify(...). If any doc frontmatter field ever contains</script>/<(titles/descriptions are user-editable content in this repo), it can break out of the script tag. Escape unsafe characters in the serialized JSON-LD (at least replace<with\u003c) before injecting.