Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions app/docs/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) }}
/>
Comment on lines +139 to +148
Copy link

Copilot AI Apr 16, 2026

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 dangerouslySetInnerHTML and a raw JSON.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.

Copilot uses AI. Check for mistakes.
<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">
Expand Down Expand Up @@ -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
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

canonical is currently derived from the raw route params (slugPath). Because the docs tree includes language-suffixed routes like *.zh / *.en (e.g. 01-static-array.zh), this will set canonical to the language-suffixed URL, which contradicts the intent in the comment (and the PR description) to consolidate ranking onto the base slug. Consider normalizing the canonical by stripping a trailing .en/.zh from the last slug segment (and using that normalized slug consistently for alternates.canonical / OG url).

Copilot uses AI. Check for mistakes.
// 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,
},
};
}
36 changes: 32 additions & 4 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,16 @@
canonical: "/",
},
robots: {
// nocache 会抑制 rich snippet / cached page,对 SEO 反而不利;移除
index: true,
follow: true,
nocache: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "standard",
"max-snippet": 160,
"max-video-preview": 0,
// 允许摘要长度,不要限制过短(160 char → -1 让 Google 自行判断)
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
},
},
formatDetection: {
Expand Down Expand Up @@ -145,7 +146,7 @@
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link

Check warning on line 149 in app/layout.tsx

View workflow job for this annotation

GitHub Actions / build

Custom fonts not added in `pages/_document.js` will only load for a single page. This is discouraged. See: https://nextjs.org/docs/messages/no-page-custom-font
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Playfair+Display:ital,wght@0,400;0,600;0,700;0,900;1,400&family=Lora:ital,wght@0,400;0,600;1,400&display=swap"
/>
Expand Down Expand Up @@ -187,6 +188,33 @@
type="image/png"
fetchPriority="high"
/>
{/*
WebSite + SearchAction 结构化数据:Google 搜索结果下方可能直接显示站内搜索框
(Sitelinks Search Box)。target 指向我们的搜索页带 query 参数;
search-input 占位符必须叫 "search_term_string"(Google 硬约定)。
*/}
<script
type="application/ld+json"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
name: "Involution Hell",
alternateName: ["内卷地狱"],
url: SITE_URL,
inLanguage: ["zh-CN", "en-US"],
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: `${SITE_URL}/docs?q={search_term_string}`,
},
"query-input": "required name=search_term_string",
},
}),
}}
/>
{/* 结构化数据:英文主名 + 中文 alternateName */}
<script
type="application/ld+json"
Expand Down
9 changes: 9 additions & 0 deletions app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { SignInButton } from "@/app/components/SignInButton";

// SEO: 登录页不参与 index(搜索引擎不需要收录登录入口)
export const metadata: Metadata = {
title: "Sign In",
description: "Sign in to Involution Hell with GitHub.",
alternates: { canonical: "/login" },
robots: { index: false, follow: true },
};

export default async function LoginPage() {
const t = await getTranslations("login");
return (
Expand Down
16 changes: 16 additions & 0 deletions app/rank/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { Header } from "@/app/components/Header";
import { Footer } from "@/app/components/Footer";
import { ContributorRow } from "@/app/components/rank/ContributorRow";
Expand All @@ -6,6 +7,21 @@ import { Suspense } from "react";

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

// SEO: rank 页用 canonical + 稳定 title/description,避免 tab/window 参数造成重复索引
export const metadata: Metadata = {
title: "贡献者排行榜 / Contributors Rank",
description:
"Involution Hell 社区贡献者排行榜 — 按文档 commits 实时统计。谁在写、谁在维护、本周最热文档。Realtime contributor leaderboard of the Involution Hell community.",
alternates: { canonical: "/rank" },
openGraph: {
title: "Contributors Rank · Involution Hell",
description:
"Realtime contributor leaderboard & hottest docs in the Involution Hell community.",
url: "/rank",
type: "website",
},
};

import { MAINTAINERS } from "@/lib/admins";

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

// SEO: 设置页仅登录用户相关,不参与搜索索引
export const metadata: Metadata = {
title: "Settings",
description: "Customize theme, language, and AI assistant preferences.",
alternates: { canonical: "/settings" },
robots: { index: false, follow: true },
};

export default function SettingsPage() {
return (
<>
Expand Down
27 changes: 25 additions & 2 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import type { MetadataRoute } from "next";
import { source } from "@/lib/source";
import leaderboard from "@/generated/site-leaderboard.json";

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

// 4. 合并与处理
// 4. /rank 排行榜页(静态路由)
const rankEntry: MetadataRoute.Sitemap[number] = {
url: `${SITE_URL}/rank`,
changeFrequency: "daily", // 贡献排行榜每天都可能变
priority: 0.7,
};

// 5. 个人主页 /u/[githubId] — 从 build-time leaderboard JSON 枚举所有贡献者。
// 非贡献者 / 新注册用户的 profile 不入 sitemap(search crawler 进去也是空白,浪费 crawl budget)。
type LeaderboardRow = { id?: string };
const profileEntries: MetadataRoute.Sitemap = (
leaderboard as LeaderboardRow[]
)
.filter((r) => typeof r.id === "string" && /^\d+$/.test(r.id))
.map((r) => ({
url: `${SITE_URL}/u/${r.id}`,
changeFrequency: "weekly" as const,
priority: 0.5,
}));

// 6. 合并与处理
const unique = new Map(docsEntries.map((e) => [e.url, e]));

// 返回合并后的数组:首页 + (去重后的文档页)
// 返回合并后的数组:首页 + /rank + 贡献者 profiles + (去重后的文档页)
return [
homeEntry,
rankEntry,
...profileEntries,
...[...unique.values()].sort((a, b) => a.url.localeCompare(b.url)),
];
}
Expand Down
61 changes: 54 additions & 7 deletions app/u/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 属于预期控制流,
Expand Down Expand Up @@ -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,
},
};
}

Expand Down Expand Up @@ -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
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sameAs is being set to https://github.com/${user.githubId} but githubId is a numeric GitHub user id; GitHub profile URLs use the login/username, so this link will be incorrect. Consider omitting sameAs unless you have the GitHub login (or using a verified GitHub profile URL from user preferences) to avoid emitting invalid structured data.

Suggested change
...(user.githubId
? { sameAs: [`https://github.com/${user.githubId}`] }
: {}),

Copilot uses AI. Check for mistakes.
memberOf: {
"@type": "Organization",
name: "Involution Hell",
url: siteUrl,
},
};

return (
<>
<script
type="application/ld+json"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }}
Comment on lines +383 to +389
Copy link

Copilot AI Apr 16, 2026

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.

Suggested change
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 }}

Copilot uses AI. Check for mistakes.
/>
<Header />
<main className="pt-32 pb-16 bg-[var(--background)] min-h-screen">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
Expand Down
Loading