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+
218import 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) 在构建时已经解析好的数据。
521import { source } from "@/lib/source" ;
622
23+ /**
24+ * 从环境变量中读取的站点根 URL。
25+ * 默认为一个回退地址。
26+ */
727const RAW_SITE_URL =
828 process . env . NEXT_PUBLIC_SITE_URL ?? "https://involutionhell.vercel.app" ;
29+
30+ /**
31+ * 经过规范化处理的站点 URL(确保有协议头,且不带尾部斜杠)。
32+ * 例如: "https://example.com"
33+ */
934const SITE_URL = normalizeSiteUrl ( RAW_SITE_URL ) ;
10- const DOCS_DIR = path . join ( process . cwd ( ) , "app/docs" ) ;
1135
36+ /** * 定义 `source.getPages()` 返回的单个页面对象的类型别名
37+ */
1238type SourcePage = ReturnType < typeof source . getPages > [ number ] ;
39+
40+ /** * 定义可以被解析为日期的宽松类型
41+ */
1342type 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+ */
62134function 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+ */
98182function 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+ */
116203function 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+ */
133218function 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+ */
143234function normalizeSiteUrl ( url : string ) : string {
144- // 必须有协议,去尾斜杠
235+ // 1. 确保 URL 总是以 http:// 或 https:// 开头
236+ // (这里为了安全,默认补全为 https://)
145237 const withProto = / ^ h t t p s ? : \/ \/ / i. test ( url ) ? url : `https://${ url } ` ;
238+
239+ // 2. 移除 URL 末尾的所有斜杠
146240 return withProto . replace ( / \/ + $ / , "" ) ;
147241}
0 commit comments