diff --git a/apps/wiki/app/[language]/(documents)/[...slug]/page.tsx b/apps/wiki/app/[language]/(documents)/[...slug]/page.tsx index d57d269..5db7a55 100644 --- a/apps/wiki/app/[language]/(documents)/[...slug]/page.tsx +++ b/apps/wiki/app/[language]/(documents)/[...slug]/page.tsx @@ -45,6 +45,13 @@ interface DocParams { slug: string[]; } +type NavigationLinkRelation = 'sibling' | 'sequence'; + +interface NavigationLinkInfo { + item: DocItem; + relation: NavigationLinkRelation; +} + export async function generateStaticParams() { // 获取语言配置 const languageConfigs = getLanguageConfigs(); @@ -251,6 +258,7 @@ export default async function DocPage({ root: navigationItemRoot, map: navigationItemMap, redirectMap, + orderMap: navigationOrderMap, } = await getDocsNavigationMap(language, slugArray[0]); const navItem = getDocItemByNavigationMap(navigationItemMap, slugPath); @@ -399,6 +407,37 @@ export default async function DocPage({ const nextPage = currentIndex < siblings.length - 1 ? siblings[currentIndex + 1] : null; + const navigationOrderEntry = + navigationOrderMap.get(navItem.displayPath) ?? null; + const sequentialPreviousPath = navigationOrderEntry?.previous ?? null; + const sequentialNextPath = navigationOrderEntry?.next ?? null; + const sequentialPreviousItem = sequentialPreviousPath + ? getDocItemByNavigationMap(navigationItemMap, sequentialPreviousPath) + : null; + const sequentialNextItem = sequentialNextPath + ? getDocItemByNavigationMap(navigationItemMap, sequentialNextPath) + : null; + const previousNav: NavigationLinkInfo | null = previousPage + ? { item: previousPage, relation: 'sibling' } + : sequentialPreviousItem + ? { item: sequentialPreviousItem, relation: 'sequence' } + : null; + const nextNav: NavigationLinkInfo | null = nextPage + ? { item: nextPage, relation: 'sibling' } + : sequentialNextItem + ? { item: sequentialNextItem, relation: 'sequence' } + : null; + const previousLabelKey = previousNav + ? previousNav.relation === 'sequence' + ? 'previousPageSequence' + : 'previousPageSibling' + : null; + const nextLabelKey = nextNav + ? nextNav.relation === 'sequence' + ? 'nextPageSequence' + : 'nextPageSibling' + : null; + const showEditAndLastModifiedTime = strippedSource.trim().length > 0; // 获取文件的最近修改时间 @@ -520,39 +559,43 @@ export default async function DocPage({ )} {/* 上一页/下一页导航 */} - {(previousPage || nextPage) && ( + {(previousNav || nextNav) && ( )} diff --git a/apps/wiki/app/sitemap.ts b/apps/wiki/app/sitemap.ts index aef12c3..05c973c 100644 --- a/apps/wiki/app/sitemap.ts +++ b/apps/wiki/app/sitemap.ts @@ -13,7 +13,7 @@ export default async function sitemap(): Promise { const seoConfig = getSEOConfig(); const languageConfigs = getLanguageConfigs(); - const urls: MetadataRoute.Sitemap = []; + const entries: Array<{ url: string; realPath?: string | null }> = []; // 添加首页 (该页会跳转,所以不添加) // urls.push({ @@ -25,7 +25,7 @@ export default async function sitemap(): Promise { // 为每种语言添加语言首页 for (const langConfig of languageConfigs) { - urls.push({ + entries.push({ url: `${seoConfig.siteUrl}${langConfig.code}`, }); @@ -37,8 +37,10 @@ export default async function sitemap(): Promise { subfolder, ); - const { root: navigationItemRoot, map: navigationItemMap } = - await getDocsNavigationMap(langConfig.code, subfolder); + const { map: navigationItemMap } = await getDocsNavigationMap( + langConfig.code, + subfolder, + ); for (const param of params) { if (param.slug && param.slug.length > 0) { @@ -46,12 +48,9 @@ export default async function sitemap(): Promise { const url = `${seoConfig.siteUrl}${param.language}/${path}`; const docItem = getDocItemByNavigationMap(navigationItemMap, path); - urls.push({ + entries.push({ url, - lastModified: - (docItem && - (await getFileLastModifiedTime(docItem.realPath))) || - undefined, + realPath: docItem?.realPath || null, }); } } @@ -64,5 +63,60 @@ export default async function sitemap(): Promise { } } - return urls; + const parsedConcurrency = Number(process.env.SITEMAP_LASTMOD_CONCURRENCY); + const concurrency = Math.max( + 1, + Number.isFinite(parsedConcurrency) && parsedConcurrency > 0 + ? Math.floor(parsedConcurrency) + : 8, + ); + + return mapWithConcurrency(entries, concurrency, async (entry) => { + if (!entry.realPath) { + return { url: entry.url }; + } + + const lastModified = await getFileLastModifiedTime(entry.realPath); + + if (!lastModified) { + return { url: entry.url }; + } + + return { url: entry.url, lastModified }; + }); +} + +async function mapWithConcurrency( + items: T[], + limit: number, + mapper: (item: T, index: number) => Promise, +): Promise { + const results: R[] = new Array(items.length); + const effectiveLimit = + Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 1; + const workerCount = Math.min(effectiveLimit, items.length); + let currentIndex = 0; + + async function worker(): Promise { + while (true) { + const index = currentIndex; + currentIndex += 1; + + if (index >= items.length) { + break; + } + + results[index] = await mapper(items[index], index); + } + } + + if (workerCount === 0) { + return results; + } + + const workers = Array.from({ length: workerCount }, () => worker()); + + await Promise.all(workers); + + return results; } diff --git a/apps/wiki/lib/i18n/translations/translations.json b/apps/wiki/lib/i18n/translations/translations.json index 511ec61..b0fc78d 100644 --- a/apps/wiki/lib/i18n/translations/translations.json +++ b/apps/wiki/lib/i18n/translations/translations.json @@ -69,6 +69,20 @@ "es": "Página anterior", "en": "Previous Page" }, + "previousPageSibling": { + "zh-cn": "上一页(同级)", + "zh-hant": "上一頁(同級)", + "ja": "前のページ(同階層)", + "es": "Página anterior (mismo nivel)", + "en": "Previous (Same Level)" + }, + "previousPageSequence": { + "zh-cn": "上一页(顺序)", + "zh-hant": "上一頁(順序)", + "ja": "前のページ(連続)", + "es": "Página anterior (secuencia)", + "en": "Previous (Sequential)" + }, "nextPage": { "zh-cn": "下一页", "zh-hant": "下一頁", @@ -76,6 +90,20 @@ "es": "Página siguiente", "en": "Next Page" }, + "nextPageSibling": { + "zh-cn": "下一页(同级)", + "zh-hant": "下一頁(同級)", + "ja": "次のページ(同階層)", + "es": "Página siguiente (mismo nivel)", + "en": "Next (Same Level)" + }, + "nextPageSequence": { + "zh-cn": "下一页(顺序)", + "zh-hant": "下一頁(順序)", + "ja": "次のページ(連続)", + "es": "Página siguiente (secuencia)", + "en": "Next (Sequential)" + }, "childPages": { "zh-cn": "子页面", "zh-hant": "子頁面", diff --git a/apps/wiki/service/directory-service-client.ts b/apps/wiki/service/directory-service-client.ts index 201c9a2..5be14d8 100644 --- a/apps/wiki/service/directory-service-client.ts +++ b/apps/wiki/service/directory-service-client.ts @@ -30,3 +30,10 @@ export interface DocItemRedirectItem { displayPath: string; redirectTo: string; } + +export interface DocNavigationOrderEntry { + previous: string | null; + next: string | null; +} + +export type DocNavigationOrderMap = Map; diff --git a/apps/wiki/service/directory-service.ts b/apps/wiki/service/directory-service.ts index 92313b0..13478e0 100644 --- a/apps/wiki/service/directory-service.ts +++ b/apps/wiki/service/directory-service.ts @@ -13,6 +13,8 @@ import type { DocItemForClient, DocItemRedirectItem, DocMetadata, + DocNavigationOrderEntry, + DocNavigationOrderMap, } from './directory-service-client'; import { getLocalImagePath } from './path-utils'; @@ -269,6 +271,50 @@ export async function getDocsNavigationRoot( ): Promise { return await getDocsNavigationRootInner(language, subfolder); } +function shouldIncludeInNavigationOrder(item: DocItem): boolean { + if (!item.displayPath) { + return false; + } + if (item.metadata.redirectToSingleChild) { + return false; + } + if (item.slug) { + return true; + } + return Boolean(item.isIndex); +} + +function collectNavigationOrder(item: DocItem, result: string[]): void { + if (shouldIncludeInNavigationOrder(item)) { + result.push(item.displayPath); + } + if (item.children) { + for (const child of item.children) { + collectNavigationOrder(child, result); + } + } +} + +function buildNavigationOrder(root: DocItem): { + order: string[]; + map: DocNavigationOrderMap; +} { + const displayPaths: string[] = []; + collectNavigationOrder(root, displayPaths); + const orderMap: DocNavigationOrderMap = new Map< + string, + DocNavigationOrderEntry + >(); + for (let index = 0; index < displayPaths.length; index++) { + const current = displayPaths[index]; + const previous = index > 0 ? displayPaths[index - 1] : null; + const next = + index < displayPaths.length - 1 ? displayPaths[index + 1] : null; + orderMap.set(current, { previous, next }); + } + return { order: displayPaths, map: orderMap }; +} + const getDocsNavigationRootWithMapInner = cache( async ( language: string, @@ -277,6 +323,8 @@ const getDocsNavigationRootWithMapInner = cache( root: DocItem; map: Map; redirectMap: Map; + order: string[]; + orderMap: DocNavigationOrderMap; }> => { function addRedirectItem( item: DocItem, @@ -344,7 +392,14 @@ const getDocsNavigationRootWithMapInner = cache( } } collectPaths(rootItem); - return { root: rootItem, map: map, redirectMap: redirectMap }; + const { order, map: navigationOrderMap } = buildNavigationOrder(rootItem); + return { + root: rootItem, + map: map, + redirectMap: redirectMap, + order, + orderMap: navigationOrderMap, + }; }, ); @@ -355,12 +410,12 @@ export async function getDocsNavigationMap( root: DocItem; map: Map; redirectMap: Map; + order: string[]; + orderMap: DocNavigationOrderMap; }> { - const { root, map, redirectMap } = await getDocsNavigationRootWithMapInner( - language, - subfolder, - ); - return { root, map, redirectMap }; + const { root, map, redirectMap, order, orderMap } = + await getDocsNavigationRootWithMapInner(language, subfolder); + return { root, map, redirectMap, order, orderMap }; } const getDocsNavigationForClientInner = cache(