Skip to content

Commit 63b93e3

Browse files
committed
refactor(sitemap): 重构 sitemap 生成逻辑,修复 404 错误并添加注释
本次提交对 `sitemap.ts` 进行了重大重构,主要包含以下三方面: 1. **修复 (Fix):** * 彻底移除了运行时的 `node:fs` 访问(即 `getFileLastModified` 函数)。 * 这从根本上解决了本地开发时因 I/O 缓慢导致的间歇性 404,以及生产环境(Vercel)因 Serverless 包不包含源文件而导致的永久 404 错误。 2. **重构与优化 (Refactor/Perf):** * Sitemap 的 `lastModified` 日期现在完全依赖 `source` (Contentlayer) 在构建时解析的 frontmatter 数据 (`extractDateFromPage`),不再有任何运行时磁盘 I/O,执行速度极快。 * 优化了首页 (`/`) 的 `lastModified` 逻辑,使其使用所有文档中的最新更新日期,而不是 `new Date()`,这对 SEO 更加友好。 3. **文档 (Docs):** * 为 `sitemap.ts` 添加了详细的文件头注释,解释其在 Next.js 中的作用。 * 为所有函数和类型添加了完整的 JSDoc 注释,明确了每个函数的职责、参数和返回值,以提高代码的可读性和教学价值。
1 parent 68f6cdf commit 63b93e3

File tree

1 file changed

+155
-61
lines changed

1 file changed

+155
-61
lines changed

app/sitemap.ts

Lines changed: 155 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,146 @@
11
// app/sitemap.ts
2+
3+
/**
4+
* @file app/sitemap.ts
5+
* @description
6+
* 动态站点地图 (Sitemap) 生成器。
7+
* * Next.js 会在构建时或运行时(如果设为动态)访问这个文件来生成 sitemap.xml。
8+
* 这个文件负责:
9+
* 1. 从 `source` (如 Contentlayer) 获取所有文档页面。
10+
* 2. 为首页("/")创建一个入口。
11+
* 3. 为所有非草稿 (draft) 或非隐藏 (hidden) 的文档页面创建入口。
12+
* 4. 从每个页面的 frontmatter 中提取最合适的“最后修改日期”。
13+
* 5. 合并所有入口,去重并排序,然后返回符合 Next.js 要求的格式。
14+
*
15+
* @see https://nextjs.org/docs/app/api-reference/file-conventions/sitemap
16+
*/
17+
218
import type { MetadataRoute } from "next";
3-
import fs from "node:fs/promises";
4-
import path from "node:path";
19+
// 移除 'node:fs/promises' 和 'node:path',因为在 Serverless 环境中访问文件系统不可靠且性能低下。
20+
// 我们完全依赖 source (Contentlayer) 在构建时已经解析好的数据。
521
import { source } from "@/lib/source";
622

23+
/**
24+
* 从环境变量中读取的站点根 URL。
25+
* 默认为一个回退地址。
26+
*/
727
const RAW_SITE_URL =
828
process.env.NEXT_PUBLIC_SITE_URL ?? "https://involutionhell.vercel.app";
29+
30+
/**
31+
* 经过规范化处理的站点 URL(确保有协议头,且不带尾部斜杠)。
32+
* 例如: "https://example.com"
33+
*/
934
const SITE_URL = normalizeSiteUrl(RAW_SITE_URL);
10-
const DOCS_DIR = path.join(process.cwd(), "app/docs");
1135

36+
/** * 定义 `source.getPages()` 返回的单个页面对象的类型别名
37+
*/
1238
type SourcePage = ReturnType<typeof source.getPages>[number];
39+
40+
/** * 定义可以被解析为日期的宽松类型
41+
*/
1342
type DateLike = string | number | Date | undefined | null;
1443

15-
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
44+
/**
45+
* Next.js 会调用的默认导出函数,用于生成整个站点的 Sitemap。
46+
* * @returns {MetadataRoute.Sitemap} 一个包含所有站点地图条目的数组。
47+
*/
48+
export default function sitemap(): MetadataRoute.Sitemap {
1649
const pages = source.getPages();
1750

18-
const docsEntries = await Promise.all(
19-
pages.filter((p) => !isDraftOrHidden(p)).map((p) => buildDocsEntry(p)),
20-
);
21-
51+
// 1. 生成所有文档页面的 sitemap 条目
52+
const docsEntries = pages
53+
.filter((p) => !isDraftOrHidden(p)) // 过滤掉草稿和隐藏页面
54+
.map(buildDocsEntry); // 将页面数据转换为 sitemap 条目
55+
56+
// 2. (优化) 寻找所有文档中最新的修改日期
57+
// 我们用这个日期作为首页的 lastModified 日期,
58+
// 这比总是使用 new Date() 对 SEO 更友好。
59+
const latestDocDate = docsEntries.reduce(
60+
(latest, entry) => {
61+
if (entry.lastModified) {
62+
// 确保 entry.lastModified 是 Date 对象实例
63+
const entryDate = new Date(entry.lastModified);
64+
// 如果 'latest' 还未设置,或者当前条目日期晚于 'latest',则更新 'latest'
65+
if (!latest || entryDate > latest) {
66+
return entryDate;
67+
}
68+
}
69+
return latest; // 否则保持 'latest' 不变
70+
},
71+
null as Date | null,
72+
); // 初始值为 null
73+
74+
// 3. 为首页创建 sitemap 条目
2275
const homeEntry: MetadataRoute.Sitemap[number] = {
23-
url: SITE_URL, // 已去尾斜杠,这里就是 https://xxx
24-
lastModified: new Date(),
25-
changeFrequency: "weekly",
26-
priority: 1,
76+
url: SITE_URL, // 站点的根 URL
77+
// 使用最新文档日期;如果一篇文档都没有,则回退到当前日期
78+
lastModified: latestDocDate ?? new Date(),
79+
changeFrequency: "weekly", // 首页可能每周都有变化
80+
priority: 1, // 首页是最高优先级
2781
};
2882

29-
// 去重并排序(避免重复 slug)
83+
// 4. 合并与处理
84+
// 使用 Map 来确保 URL 的唯一性,防止意外的重复
3085
const unique = new Map(docsEntries.map((e) => [e.url, e]));
86+
87+
// 返回合并后的数组:首页 + (去重后的文档页)
3188
return [
3289
homeEntry,
33-
...[...unique.values()].sort((a, b) => a.url.localeCompare(b.url)),
90+
...[...unique.values()].sort((a, b) => a.url.localeCompare(b.url)), // 按 URL 字母顺序排序
3491
];
3592
}
3693

37-
async function buildDocsEntry(
38-
page: SourcePage,
39-
): Promise<MetadataRoute.Sitemap[number]> {
94+
/**
95+
* 将单个文档页面对象 (SourcePage) 转换为 Sitemap 条目。
96+
* 这是一个同步函数,因为它只依赖已加载的 `page` 数据。
97+
* * @param {SourcePage} page - 从 `source.getPages()` 获取的单个页面对象。
98+
* @returns {MetadataRoute.Sitemap[number]} 一个 Sitemap 条目对象。
99+
*/
100+
function buildDocsEntry(page: SourcePage): MetadataRoute.Sitemap[number] {
101+
// 将页面的 slugs 数组转换为 URL 路径,例如 ['getting-started', 'intro'] -> 'getting-started/intro'
40102
const slugPath = sanitizeSlugPath(page.slugs);
41-
// 根文档:/docs 或 /docs/<segments>
103+
104+
// 构建完整的 URL。
105+
// 如果 slugPath 为空 (例如 /docs/index.mdx),则 URL 为 .../docs
106+
// 否则为 .../docs/slug/path
42107
const url = slugPath ? `${SITE_URL}/docs/${slugPath}` : `${SITE_URL}/docs`;
43108

109+
// 仅从页面的 frontmatter (data) 中提取日期
44110
const fmDate = extractDateFromPage(page);
45-
const filePath = path.resolve(DOCS_DIR, page.file.path);
46-
const fileDate = await getFileLastModified(filePath);
47111

112+
// 构建条目
48113
const entry: MetadataRoute.Sitemap[number] = {
49114
url,
50-
changeFrequency: "monthly",
51-
priority: 0.6,
52-
...(fmDate
53-
? { lastModified: fmDate }
54-
: fileDate
55-
? { lastModified: fileDate }
56-
: {}),
115+
changeFrequency: "monthly", // 假设文档内容每月或更少频率更新
116+
priority: 0.6, // 文档页的优先级低于首页
117+
118+
// (优化) 仅当 fmDate (frontmatter 日期) 存在时,才添加 lastModified 字段。
119+
// 如果为 undefined,则省略该字段。
120+
...(fmDate ? { lastModified: fmDate } : {}),
57121
};
58122

59123
return entry;
60124
}
61125

126+
/**
127+
* 从页面的 data/frontmatter 中按优先级提取最合适的日期。
128+
* * 优先级顺序(从高到低):
129+
* 1. updated / updatedAt / lastUpdated (反映最后修改时间)
130+
* 2. date (反映创建时间)
131+
* * @param {SourcePage} page - 页面对象。
132+
* @returns {Date | undefined} 解析后的 Date 对象,如果找不到或无效则返回 undefined。
133+
*/
62134
function extractDateFromPage(page: SourcePage): Date | undefined {
135+
// page.data 包含了 frontmatter 以及其他由 source 注入的数据
63136
const data = page.data as {
64137
date?: DateLike;
65138
updated?: DateLike;
66139
updatedAt?: DateLike;
67140
lastUpdated?: DateLike;
68141
draft?: boolean;
69142
hidden?: boolean;
143+
// 有时 frontmatter 会被嵌套在 'frontmatter' 键下
70144
frontmatter?: {
71145
date?: DateLike;
72146
updated?: DateLike;
@@ -77,71 +151,91 @@ function extractDateFromPage(page: SourcePage): Date | undefined {
77151
};
78152
};
79153

154+
// 按期望的优先级列出所有可能的日期字段
80155
const candidates: DateLike[] = [
81156
data?.updatedAt,
82157
data?.updated,
83158
data?.lastUpdated,
84-
data?.date,
85159
data?.frontmatter?.updatedAt,
86160
data?.frontmatter?.updated,
87161
data?.frontmatter?.lastUpdated,
162+
// 最后才检查 'date'
163+
data?.date,
88164
data?.frontmatter?.date,
89165
];
90166

167+
// 遍历候选项,返回第一个有效的日期
91168
for (const c of candidates) {
92169
const parsed = normalizeDate(c);
93-
if (parsed) return parsed;
170+
if (parsed) return parsed; // 找到即返回
94171
}
172+
173+
// 遍历结束仍未找到
95174
return undefined;
96175
}
97176

177+
/**
178+
* 将一个不确定类型的值(DateLike)转换为标准的 Date 对象。
179+
* * @param {DateLike} value - 可能是 Date, string, number, null 或 undefined。
180+
* @returns {Date | undefined} 如果值为有效日期,则返回 Date 对象;否则返回 undefined。
181+
*/
98182
function normalizeDate(value: DateLike): Date | undefined {
99-
if (!value) return undefined;
100-
if (value instanceof Date) return isNaN(value.getTime()) ? undefined : value;
101-
const d = new Date(value);
102-
return isNaN(d.getTime()) ? undefined : d;
103-
}
183+
if (!value) return undefined; // 处理 null, undefined, "", 0
104184

105-
async function getFileLastModified(
106-
filePath: string,
107-
): Promise<Date | undefined> {
108-
try {
109-
const stats = await fs.stat(filePath);
110-
return stats.mtime;
111-
} catch {
112-
return undefined;
185+
// 如果已经是 Date 对象
186+
if (value instanceof Date) {
187+
// 检查是否为无效日期 (例如 new Date('invalid-string'))
188+
return isNaN(value.getTime()) ? undefined : value;
113189
}
190+
191+
// 尝试将 string 或 number 转换为 Date
192+
const d = new Date(value);
193+
194+
// 再次检查转换结果是否有效
195+
return isNaN(d.getTime()) ? undefined : d;
114196
}
115197

198+
/**
199+
* 将 slugs 数组清理并转换为 URL 路径字符串。
200+
* * @param {string[]} slugs - 来源于 page.slugs 的数组。
201+
* @returns {string} 组合后的路径,例如 "segment1/segment2"。
202+
*/
116203
function sanitizeSlugPath(slugs: string[]): string {
117-
// 过滤空段并对每段进行 URL 编码
204+
// 1. 过滤掉数组中可能存在的空字符串 (例如 /docs/index.mdx 对应的 slugs 可能是 [])
205+
// 2. 对每个 slug 段进行 URL 编码,以防包含特殊字符
206+
// 3. 用 '/' 将它们连接起来
118207
return slugs
119-
.filter(Boolean)
120-
.map((s) => encodeURIComponent(s))
208+
.filter(Boolean) // 过滤掉 "" 或 undefined
209+
.map((s) => encodeURIComponent(s)) // 编码特殊字符
121210
.join("/");
122211
}
123212

124-
type VisibilityFlags = {
125-
draft?: boolean;
126-
hidden?: boolean;
127-
frontmatter?: {
128-
draft?: boolean;
129-
hidden?: boolean;
130-
};
131-
};
132-
213+
/**
214+
* 检查页面是否被标记为草稿 (draft) 或隐藏 (hidden)。
215+
* * @param {SourcePage} page - 页面对象。
216+
* @returns {boolean} 如果是草稿或隐藏,返回 true。
217+
*/
133218
function isDraftOrHidden(page: SourcePage): boolean {
134-
const data = (page.data ?? {}) as VisibilityFlags;
135-
return Boolean(
136-
data.draft ||
137-
data.hidden ||
138-
data.frontmatter?.draft ||
139-
data.frontmatter?.hidden,
219+
// 使用 'any' 类型来简化对嵌套属性的访问
220+
const d: any = page.data ?? {};
221+
222+
// 检查顶层或 'frontmatter' 嵌套下的 'draft' 或 'hidden' 标志
223+
return !!(
224+
// !! 确保最终结果是 boolean
225+
(d.draft || d.hidden || d.frontmatter?.draft || d.frontmatter?.hidden)
140226
);
141227
}
142228

229+
/**
230+
* 规范化站点的 URL。
231+
* * @param {string} url - 原始 URL 字符串。
232+
* @returns {string} 规范化后的 URL。
233+
*/
143234
function normalizeSiteUrl(url: string): string {
144-
// 必须有协议,去尾斜杠
235+
// 1. 确保 URL 总是以 http:// 或 https:// 开头
236+
// (这里为了安全,默认补全为 https://)
145237
const withProto = /^https?:\/\//i.test(url) ? url : `https://${url}`;
238+
239+
// 2. 移除 URL 末尾的所有斜杠
146240
return withProto.replace(/\/+$/, "");
147241
}

0 commit comments

Comments
 (0)