Skip to content

Commit 3845146

Browse files
authored
feat(seo): 全站 SEO 优化 — sitemap / JSON-LD / canonical / robots (#289)
**新增结构化数据(JSON-LD):** - 全局 WebSite + SearchAction(让 Google 搜索结果下方可能显示站内搜索框) - docs 页 TechArticle + BreadcrumbList(技术文章 rich result + 面包屑层级) - /u/[username] 页 Person(个人档案 knowledge panel 候选) **sitemap 扩容(从仅首页+docs → 312 条):** - 新增 /rank 条目(changeFreq=daily) - 新增 /u/{githubId} 条目(枚举 leaderboard JSON 全部贡献者,非贡献者 profile 不入 sitemap 节省 crawl budget) **canonical + hreflang:** - docs [...slug] 页:canonical 指向 slug 原路径;alternates.languages 声明 zh-CN / en-US / x-default - /u/[username]:canonical 用 githubId 数字路径,避免 github_<id> 和数字两种 URL 竞争 PageRank - /rank、/login、/settings 各加 canonical **robots 调整:** - 删 nocache: true(反而抑制 rich snippet) - googleBot 上放开 max-image-preview=large / max-snippet=-1 让 Google 自行决定摘要长度 - /login、/settings 设 index=false(登录/偏好页不需搜索引擎收录) **per-page metadata:** - /rank 加 title / description / OG - /u/[username] OG 从全局 og/cover.png 覆盖为用户 avatarUrl - docs 页 OG 加 type=article + locale 跟随
1 parent 4c650d8 commit 3845146

7 files changed

Lines changed: 223 additions & 13 deletions

File tree

app/docs/[...slug]/page.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,59 @@ export default async function DocPage({ params }: Param) {
9393
getDocContributorsByDocId(docIdFromPage);
9494
const Mdx = page.data.body;
9595

96+
// SEO 结构化数据
97+
const siteUrl =
98+
process.env.NEXT_PUBLIC_SITE_URL || "https://involutionhell.com";
99+
const slugPath = (slug ?? []).join("/");
100+
const docUrl = slugPath ? `${siteUrl}/docs/${slugPath}` : `${siteUrl}/docs`;
101+
102+
// TechArticle: 让 docs 在 Google 搜索结果上更可能展示为技术文章卡片
103+
const articleJsonLd = {
104+
"@context": "https://schema.org",
105+
"@type": "TechArticle",
106+
headline: page.data.title,
107+
description: page.data.description,
108+
url: docUrl,
109+
inLanguage: locale === "en" ? "en-US" : "zh-CN",
110+
publisher: {
111+
"@type": "Organization",
112+
name: "Involution Hell",
113+
url: siteUrl,
114+
},
115+
};
116+
117+
// BreadcrumbList: 按 slug 层级生成面包屑(Google 搜索结果里的那种层级链接)
118+
const breadcrumbItems = [
119+
{ name: "Involution Hell", url: siteUrl },
120+
{ name: "Docs", url: `${siteUrl}/docs` },
121+
...(slug ?? []).map((seg, idx) => ({
122+
name: decodeURIComponent(seg),
123+
url: `${siteUrl}/docs/${slug!.slice(0, idx + 1).join("/")}`,
124+
})),
125+
];
126+
const breadcrumbJsonLd = {
127+
"@context": "https://schema.org",
128+
"@type": "BreadcrumbList",
129+
itemListElement: breadcrumbItems.map((item, idx) => ({
130+
"@type": "ListItem",
131+
position: idx + 1,
132+
name: item.name,
133+
item: item.url,
134+
})),
135+
};
136+
96137
return (
97138
<>
139+
<script
140+
type="application/ld+json"
141+
// eslint-disable-next-line react/no-danger
142+
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
143+
/>
144+
<script
145+
type="application/ld+json"
146+
// eslint-disable-next-line react/no-danger
147+
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
148+
/>
98149
<DocsPage toc={page.data.toc}>
99150
<DocsBody>
100151
<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> {
144195
notFound();
145196
}
146197

198+
// 规范化 slug → canonical 路径。用户访问 /docs/ai/rl(原文)或 /docs/ai/rl.en(翻译版)
199+
// 都统一指向原始 slug,避免两个 URL 竞争同一份内容的 PageRank。
200+
const slugPath = (slug ?? []).join("/");
201+
const canonical = slugPath ? `/docs/${slugPath}` : "/docs";
202+
203+
// hreflang:告诉搜索引擎该文档有哪些语言版本。
204+
// 翻译版文件命名是 `<slug>.en.mdx` / `<slug>.zh.mdx`,URL 靠 cookie 切换,
205+
// 两种语言走同一 canonical URL,因此 hreflang 都指向自己。
206+
const languages: Record<string, string> = {
207+
"zh-CN": canonical,
208+
"en-US": canonical,
209+
"x-default": canonical,
210+
};
211+
147212
return {
148213
title: page.data.title,
149214
description: page.data.description,
215+
alternates: { canonical, languages },
216+
openGraph: {
217+
type: "article",
218+
title: page.data.title,
219+
description: page.data.description,
220+
url: canonical,
221+
locale: locale === "en" ? "en_US" : "zh_CN",
222+
},
223+
twitter: {
224+
card: "summary_large_image",
225+
title: page.data.title,
226+
description: page.data.description,
227+
},
150228
};
151229
}

app/layout.tsx

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,16 @@ export const metadata: Metadata = {
6767
canonical: "/",
6868
},
6969
robots: {
70+
// nocache 会抑制 rich snippet / cached page,对 SEO 反而不利;移除
7071
index: true,
7172
follow: true,
72-
nocache: true,
7373
googleBot: {
7474
index: true,
7575
follow: true,
76-
"max-image-preview": "standard",
77-
"max-snippet": 160,
78-
"max-video-preview": 0,
76+
// 允许摘要长度,不要限制过短(160 char → -1 让 Google 自行判断)
77+
"max-image-preview": "large",
78+
"max-snippet": -1,
79+
"max-video-preview": -1,
7980
},
8081
},
8182
formatDetection: {
@@ -187,6 +188,33 @@ export default async function RootLayout({
187188
type="image/png"
188189
fetchPriority="high"
189190
/>
191+
{/*
192+
WebSite + SearchAction 结构化数据:Google 搜索结果下方可能直接显示站内搜索框
193+
(Sitelinks Search Box)。target 指向我们的搜索页带 query 参数;
194+
search-input 占位符必须叫 "search_term_string"(Google 硬约定)。
195+
*/}
196+
<script
197+
type="application/ld+json"
198+
// eslint-disable-next-line react/no-danger
199+
dangerouslySetInnerHTML={{
200+
__html: JSON.stringify({
201+
"@context": "https://schema.org",
202+
"@type": "WebSite",
203+
name: "Involution Hell",
204+
alternateName: ["内卷地狱"],
205+
url: SITE_URL,
206+
inLanguage: ["zh-CN", "en-US"],
207+
potentialAction: {
208+
"@type": "SearchAction",
209+
target: {
210+
"@type": "EntryPoint",
211+
urlTemplate: `${SITE_URL}/docs?q={search_term_string}`,
212+
},
213+
"query-input": "required name=search_term_string",
214+
},
215+
}),
216+
}}
217+
/>
190218
{/* 结构化数据:英文主名 + 中文 alternateName */}
191219
<script
192220
type="application/ld+json"

app/login/page.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1+
import type { Metadata } from "next";
12
import { getTranslations } from "next-intl/server";
23
import { SignInButton } from "@/app/components/SignInButton";
34

5+
// SEO: 登录页不参与 index(搜索引擎不需要收录登录入口)
6+
export const metadata: Metadata = {
7+
title: "Sign In",
8+
description: "Sign in to Involution Hell with GitHub.",
9+
alternates: { canonical: "/login" },
10+
robots: { index: false, follow: true },
11+
};
12+
413
export default async function LoginPage() {
514
const t = await getTranslations("login");
615
return (

app/rank/page.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Metadata } from "next";
12
import { Header } from "@/app/components/Header";
23
import { Footer } from "@/app/components/Footer";
34
import { ContributorRow } from "@/app/components/rank/ContributorRow";
@@ -6,6 +7,21 @@ import { Suspense } from "react";
67

78
import leaderboardData from "@/generated/site-leaderboard.json";
89

10+
// SEO: rank 页用 canonical + 稳定 title/description,避免 tab/window 参数造成重复索引
11+
export const metadata: Metadata = {
12+
title: "贡献者排行榜 / Contributors Rank",
13+
description:
14+
"Involution Hell 社区贡献者排行榜 — 按文档 commits 实时统计。谁在写、谁在维护、本周最热文档。Realtime contributor leaderboard of the Involution Hell community.",
15+
alternates: { canonical: "/rank" },
16+
openGraph: {
17+
title: "Contributors Rank · Involution Hell",
18+
description:
19+
"Realtime contributor leaderboard & hottest docs in the Involution Hell community.",
20+
url: "/rank",
21+
type: "website",
22+
},
23+
};
24+
925
import { MAINTAINERS } from "@/lib/admins";
1026

1127
const rawRanks = leaderboardData as {

app/settings/page.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
// 用户偏好设置页(Server Component)
22
// 登录态由客户端 SettingsForm 内部的 useAuth 处理:token 存在 localStorage,服务端无法读取,
33
// 所以这里不做服务端鉴权,仅负责渲染页面壳。未登录 → 客户端 router.replace 到 /login?redirect=/settings。
4+
import type { Metadata } from "next";
45
import { Header } from "@/app/components/Header";
56
import { Footer } from "@/app/components/Footer";
67
import { SettingsForm } from "./SettingsForm";
78

9+
// SEO: 设置页仅登录用户相关,不参与搜索索引
10+
export const metadata: Metadata = {
11+
title: "Settings",
12+
description: "Customize theme, language, and AI assistant preferences.",
13+
alternates: { canonical: "/settings" },
14+
robots: { index: false, follow: true },
15+
};
16+
817
export default function SettingsPage() {
918
return (
1019
<>

app/sitemap.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import type { MetadataRoute } from "next";
2222
import { source } from "@/lib/source";
23+
import leaderboard from "@/generated/site-leaderboard.json";
2324

2425
/**
2526
* 从环境变量中读取的站点根 URL。
@@ -102,12 +103,34 @@ export default function sitemap(): MetadataRoute.Sitemap {
102103
priority: 1, // 首页是最高优先级
103104
};
104105

105-
// 4. 合并与处理
106+
// 4. /rank 排行榜页(静态路由)
107+
const rankEntry: MetadataRoute.Sitemap[number] = {
108+
url: `${SITE_URL}/rank`,
109+
changeFrequency: "daily", // 贡献排行榜每天都可能变
110+
priority: 0.7,
111+
};
112+
113+
// 5. 个人主页 /u/[githubId] — 从 build-time leaderboard JSON 枚举所有贡献者。
114+
// 非贡献者 / 新注册用户的 profile 不入 sitemap(search crawler 进去也是空白,浪费 crawl budget)。
115+
type LeaderboardRow = { id?: string };
116+
const profileEntries: MetadataRoute.Sitemap = (
117+
leaderboard as LeaderboardRow[]
118+
)
119+
.filter((r) => typeof r.id === "string" && /^\d+$/.test(r.id))
120+
.map((r) => ({
121+
url: `${SITE_URL}/u/${r.id}`,
122+
changeFrequency: "weekly" as const,
123+
priority: 0.5,
124+
}));
125+
126+
// 6. 合并与处理
106127
const unique = new Map(docsEntries.map((e) => [e.url, e]));
107128

108-
// 返回合并后的数组:首页 + (去重后的文档页)
129+
// 返回合并后的数组:首页 + /rank + 贡献者 profiles + (去重后的文档页)
109130
return [
110131
homeEntry,
132+
rankEntry,
133+
...profileEntries,
111134
...[...unique.values()].sort((a, b) => a.url.localeCompare(b.url)),
112135
];
113136
}

app/u/[username]/page.tsx

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,10 @@ interface ProfileResponse {
114114
*/
115115
function warnFetchProfile(message: string, details?: Record<string, unknown>) {
116116
const isProduction = process.env.NODE_ENV === "production";
117-
const status = typeof details?.status === "number" ? details.status : undefined;
118-
const success = typeof details?.success === "boolean" ? details.success : undefined;
117+
const status =
118+
typeof details?.status === "number" ? details.status : undefined;
119+
const success =
120+
typeof details?.success === "boolean" ? details.success : undefined;
119121
const isExpectedNotFound = status === 404 || success === false;
120122

121123
// 生产环境仅记录需要诊断的异常场景;404 / success=false 属于预期控制流,
@@ -294,13 +296,32 @@ interface Param {
294296
export async function generateMetadata({ params }: Param): Promise<Metadata> {
295297
const { username } = await params;
296298
const data = await fetchProfile(username);
297-
if (!data) return { title: `@${username}` };
299+
if (!data) return { title: `@${username}`, robots: { index: false } };
298300
const displayName = data.user.displayName || data.user.username;
301+
const description =
302+
data.preferences?.bio ||
303+
`${displayName} on Involution Hell — projects, papers, and docs contributions.`;
304+
// 用 githubId 作为 canonical URL,避免 /u/github_114939201 和 /u/114939201 两个入口重复索引
305+
const canonicalId = data.user.githubId ?? data.user.username;
306+
const canonical = `/u/${canonicalId}`;
307+
const title = `${displayName} (@${data.user.username})`;
299308
return {
300-
title: `${displayName} (@${data.user.username})`,
301-
description:
302-
data.preferences?.bio ||
303-
`${displayName} 在 Involution Hell 的个人主页 — 项目、论文与文档贡献。`,
309+
title,
310+
description,
311+
alternates: { canonical },
312+
openGraph: {
313+
type: "profile",
314+
title,
315+
description,
316+
url: canonical,
317+
images: data.user.avatarUrl ? [{ url: data.user.avatarUrl }] : undefined,
318+
},
319+
twitter: {
320+
card: "summary",
321+
title,
322+
description,
323+
images: data.user.avatarUrl ? [data.user.avatarUrl] : undefined,
324+
},
304325
};
305326
}
306327

@@ -339,8 +360,34 @@ export default async function UserProfilePage({ params }: Param) {
339360
};
340361
});
341362

363+
// Person JSON-LD:让搜索引擎识别这是一个"个人档案"而不是普通页面,有机会走 knowledge panel
364+
const siteUrl =
365+
process.env.NEXT_PUBLIC_SITE_URL || "https://involutionhell.com";
366+
const personJsonLd = {
367+
"@context": "https://schema.org",
368+
"@type": "Person",
369+
name: user.displayName || user.username,
370+
alternateName: user.username,
371+
url: `${siteUrl}/u/${user.githubId ?? user.username}`,
372+
...(user.avatarUrl ? { image: user.avatarUrl } : {}),
373+
...(preferences.bio ? { description: preferences.bio } : {}),
374+
...(user.githubId
375+
? { sameAs: [`https://github.com/${user.githubId}`] }
376+
: {}),
377+
memberOf: {
378+
"@type": "Organization",
379+
name: "Involution Hell",
380+
url: siteUrl,
381+
},
382+
};
383+
342384
return (
343385
<>
386+
<script
387+
type="application/ld+json"
388+
// eslint-disable-next-line react/no-danger
389+
dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }}
390+
/>
344391
<Header />
345392
<main className="pt-32 pb-16 bg-[var(--background)] min-h-screen">
346393
<div className="max-w-7xl mx-auto px-6 lg:px-8">

0 commit comments

Comments
 (0)