️ perf(build): remove sitemap generation to cut static export time (#15702)

* ️ perf(build): remove sitemap generation to cut static export time

The sitemap accounted for 772 of 827 prerendered pages, each fetching
marketplace data at build time. Static generation drops from 28.2s to
0.3s and total next build from ~59s to ~32s.

* Redirect legacy sitemap URLs to the landing site

* Redirect sitemap index to landing sitemap
This commit is contained in:
Innei
2026-06-12 15:17:52 +08:00
committed by GitHub
parent 7633c0e83f
commit 365dd1ff64
9 changed files with 10 additions and 845 deletions
-3
View File
@@ -93,9 +93,6 @@ public/swe-worker*
# Generated files
src/app/spa/[variants]/[[...path]]/spaHtmlTemplates.ts
public/*.js
public/sitemap.xml
public/sitemap-index.xml
sitemap*.xml
robots.txt
# Git hooks
+1 -2
View File
@@ -36,7 +36,7 @@
"scripts": {
"build": "bun run build:spa && bun run build:spa:copy && bun run build:next",
"build:analyze": "cross-env NODE_OPTIONS=--max-old-space-size=81920 next experimental-analyze",
"build:docker": "pnpm run build:spa && pnpm run build:spa:mobile && pnpm run build:spa:copy && cross-env NODE_OPTIONS=--max-old-space-size=8192 DOCKER=true next build && pnpm run build-sitemap",
"build:docker": "pnpm run build:spa && pnpm run build:spa:mobile && pnpm run build:spa:copy && cross-env NODE_OPTIONS=--max-old-space-size=8192 DOCKER=true next build",
"build:next": "cross-env NODE_OPTIONS=--max-old-space-size=7168 bun run build:next:raw",
"build:next:raw": "next build",
"build:raw": "bun run build:spa:raw && bun run build:spa:copy && bun run build:next:raw",
@@ -46,7 +46,6 @@
"build:spa:raw": "rm -rf public/_spa && vite build",
"build:vercel": "cross-env-shell NODE_OPTIONS=--max-old-space-size=8192 \"bun run build:raw && bun run db:migrate\"",
"build-migrate-db": "bun run db:migrate",
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
"clean:node_modules": "bash -lc 'set -e; echo \"Removing all node_modules...\"; rm -rf node_modules; pnpm -r exec rm -rf node_modules; rm -rf apps/desktop/node_modules; echo \"All node_modules removed.\"'",
"codemod:workspace-nav": "tsx ./scripts/codemodWorkspaceNav.ts",
"codemod:workspace-nav:check": "tsx ./scripts/codemodWorkspaceNav.ts --check",
-3
View File
@@ -1,7 +1,5 @@
import urlJoin from 'url-join';
const isDev = process.env.NODE_ENV === 'development';
export const OFFICIAL_URL = 'https://app.lobehub.com';
export const OFFICIAL_SITE = 'https://lobehub.com';
export const OFFICIAL_DOMAIN = 'lobehub.com';
@@ -73,7 +71,6 @@ export const mailTo = (email: string) => `mailto:${email}`;
export const AES_GCM_URL = 'https://datatracker.ietf.org/doc/html/draft-ietf-avt-srtp-aes-gcm-01';
export const BASE_PROVIDER_DOC_URL = 'https://lobehub.com/docs/usage/providers';
export const SITEMAP_BASE_URL = isDev ? '/sitemap.xml/' : 'sitemap';
export const CHANGELOG_URL = urlJoin(OFFICIAL_SITE, 'changelog');
export const DOWNLOAD_URL = {
-31
View File
@@ -1,31 +0,0 @@
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import dotenv from 'dotenv';
import { RedisKeyNamespace, resetPrefixedRedisClient } from '@/libs/redis';
import { Sitemap } from '@/server/sitemap';
dotenv.config();
const genSitemap = async () => {
const sitemapModule = new Sitemap();
const sitemapIndexXML = await sitemapModule.getIndex();
const filename = path.resolve(__dirname, '../../', 'public', 'sitemap-index.xml');
writeFileSync(filename, sitemapIndexXML);
};
const main = async () => {
try {
await genSitemap();
} finally {
console.log('[build-sitemap] Closing LobeHub Redis client');
await resetPrefixedRedisClient(RedisKeyNamespace.LOBEHUB);
console.log('[build-sitemap] Closed LobeHub Redis client');
}
};
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
-3
View File
@@ -1,6 +1,5 @@
import { type MetadataRoute } from 'next';
import { Sitemap } from '@/server/sitemap';
import { getCanonicalUrl } from '@/server/utils/url';
// Robots file cache configuration - revalidate every 24 hours
@@ -8,7 +7,6 @@ export const revalidate = 86_400; // 24 hours - content page cache
export const dynamic = 'force-static';
const robots = (): MetadataRoute.Robots => {
const sitemapModule = new Sitemap();
return {
host: getCanonicalUrl(),
rules: [
@@ -30,7 +28,6 @@ const robots = (): MetadataRoute.Robots => {
userAgent: '*',
},
],
sitemap: sitemapModule.getRobots(),
};
};
-95
View File
@@ -1,95 +0,0 @@
import { type MetadataRoute } from 'next';
import { LAST_MODIFIED, Sitemap, SitemapType } from '@/server/sitemap';
// Sitemap cache configuration - revalidate every 24 hours
export const revalidate = 86_400; // 24 hours - content page cache
export const dynamic = 'force-static';
export const generateSitemapLink = (url: string) =>
['<sitemap>', `<loc>${url}</loc>`, `<lastmod>${LAST_MODIFIED}</lastmod>`, '</sitemap>'].join(
'\n',
);
export async function generateSitemaps() {
const sitemapModule = new Sitemap();
// Generate dynamic sitemap list, including paginated sitemaps
const staticSitemaps = sitemapModule.sitemapIndexs;
// Get page counts for types that need pagination
const [pluginPages, assistantPages, modelPages] = await Promise.all([
sitemapModule.getPluginPageCount(),
sitemapModule.getAssistantPageCount(),
sitemapModule.getModelPageCount(),
]);
// Generate paginated sitemap ID list
const paginatedSitemaps = [
...Array.from({ length: pluginPages }, (_, i) => ({ id: `plugins-${i + 1}` as SitemapType })),
...Array.from({ length: assistantPages }, (_, i) => ({
id: `assistants-${i + 1}` as SitemapType,
})),
...Array.from({ length: modelPages }, (_, i) => ({ id: `models-${i + 1}` as SitemapType })),
];
return [...staticSitemaps, ...paginatedSitemaps];
}
// Parse paginated ID
export function parsePaginatedId(id: string): { page?: number; type: SitemapType } {
if (id.includes('-')) {
const [type, pageStr] = id.split('-');
const page = parseInt(pageStr, 10);
if (!isNaN(page)) {
return { page, type: type as SitemapType };
}
}
return { type: id as SitemapType };
}
export default async function sitemap({
id: idPromise,
}: {
id: string;
}): Promise<MetadataRoute.Sitemap> {
const id = await idPromise;
const { type, page } = parsePaginatedId(id);
const sitemapModule = new Sitemap();
switch (type) {
case SitemapType.Pages: {
return sitemapModule.getPage();
}
case SitemapType.Assistants: {
return sitemapModule.getAssistants(page);
}
case SitemapType.Plugins: {
return sitemapModule.getPlugins(page);
}
case SitemapType.Models: {
return sitemapModule.getModels(page);
}
case SitemapType.Providers: {
return sitemapModule.getProviders();
}
default: {
// Handle paginated sitemaps (plugins-1, assistants-2, mcp-3, etc.)
if (id.startsWith('plugins-')) {
const pageNum = parseInt(id.split('-')[1], 10);
return sitemapModule.getPlugins(pageNum);
}
if (id.startsWith('assistants-')) {
const pageNum = parseInt(id.split('-')[1], 10);
return sitemapModule.getAssistants(pageNum);
}
if (id.startsWith('models-')) {
const pageNum = parseInt(id.split('-')[1], 10);
return sitemapModule.getModels(pageNum);
}
// Default to empty array
return [];
}
}
}
+9 -6
View File
@@ -2,6 +2,8 @@ import { codeInspectorPlugin } from 'code-inspector-plugin';
import { type NextConfig } from 'next';
import { type Header, type Redirect } from 'next/dist/lib/load-custom-routes';
const LANDING_SITEMAP_URL = 'https://lobehub.com/sitemap.xml';
interface CustomNextConfig {
experimental?: NextConfig['experimental'];
headers?: Header[];
@@ -269,25 +271,26 @@ export function defineConfig(config: CustomNextConfig) {
}),
reactStrictMode: true,
redirects: async () => [
// Sitemap generation lives on the landing site; keep legacy app sitemap URLs crawlable.
{
destination: '/sitemap-index.xml',
destination: LANDING_SITEMAP_URL,
permanent: true,
source: '/sitemap.xml',
},
{
destination: '/sitemap-index.xml',
destination: LANDING_SITEMAP_URL,
permanent: true,
source: '/sitemap-0.xml',
},
{
destination: '/sitemap/plugins-1.xml',
destination: LANDING_SITEMAP_URL,
permanent: true,
source: '/sitemap/plugins.xml',
source: '/sitemap-index.xml',
},
{
destination: '/sitemap/assistants-1.xml',
destination: LANDING_SITEMAP_URL,
permanent: true,
source: '/sitemap/assistants.xml',
source: '/sitemap/:path*',
},
{
destination: '/manifest.webmanifest',
-336
View File
@@ -1,336 +0,0 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { DiscoverService } from '@/server/services/discover';
import { getCanonicalUrl } from '@/server/utils/url';
import { LAST_MODIFIED, Sitemap, SitemapType } from './sitemap';
const LOCALE_COUNT = 18;
interface SitemapWithDiscoverService {
discoverService: Pick<DiscoverService, 'getModelIdentifiers'>;
}
describe('Sitemap', () => {
const sitemap = new Sitemap();
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('getIndex', () => {
it('should return a valid sitemap index with pagination', async () => {
// Mock the page count methods to return specific values for testing
vi.spyOn(sitemap, 'getPluginPageCount').mockResolvedValue(2);
vi.spyOn(sitemap, 'getAssistantPageCount').mockResolvedValue(3);
vi.spyOn(sitemap, 'getModelPageCount').mockResolvedValue(2);
const index = await sitemap.getIndex();
expect(index).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(index).toContain('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">');
// Check static sitemaps
[SitemapType.Pages, SitemapType.Providers].forEach((type) => {
expect(index).toContain(`<loc>${getCanonicalUrl(`/sitemap/${type}.xml`)}</loc>`);
});
// Check paginated sitemaps
expect(index).toContain(`<loc>${getCanonicalUrl('/sitemap/plugins-1.xml')}</loc>`);
expect(index).toContain(`<loc>${getCanonicalUrl('/sitemap/plugins-2.xml')}</loc>`);
expect(index).toContain(`<loc>${getCanonicalUrl('/sitemap/assistants-1.xml')}</loc>`);
expect(index).toContain(`<loc>${getCanonicalUrl('/sitemap/assistants-2.xml')}</loc>`);
expect(index).toContain(`<loc>${getCanonicalUrl('/sitemap/assistants-3.xml')}</loc>`);
expect(index).toContain(`<loc>${getCanonicalUrl('/sitemap/models-1.xml')}</loc>`);
expect(index).toContain(`<loc>${getCanonicalUrl('/sitemap/models-2.xml')}</loc>`);
expect(index).toContain(`<lastmod>${LAST_MODIFIED}</lastmod>`);
});
});
describe('getModelPageCount', () => {
it('should clear the timeout after model identifiers resolve', async () => {
vi.useFakeTimers();
const isolatedSitemap = new Sitemap({ modelPageCountTimeoutMs: 15 * 60 * 1000 });
const isolatedSitemapWithService = isolatedSitemap as unknown as SitemapWithDiscoverService;
vi.spyOn(isolatedSitemapWithService.discoverService, 'getModelIdentifiers').mockResolvedValue(
[{ identifier: 'test-model', lastModified: LAST_MODIFIED }],
);
await expect(isolatedSitemap.getModelPageCount()).resolves.toBe(1);
expect(vi.getTimerCount()).toBe(0);
});
it('should not block sitemap generation when model identifiers never resolve', async () => {
vi.useFakeTimers();
vi.spyOn(console, 'error').mockImplementation(() => {});
const isolatedSitemap = new Sitemap({ modelPageCountTimeoutMs: 100 });
const isolatedSitemapWithService = isolatedSitemap as unknown as SitemapWithDiscoverService;
vi.spyOn(isolatedSitemapWithService.discoverService, 'getModelIdentifiers').mockReturnValue(
new Promise(() => {}),
);
const pageCountPromise = isolatedSitemap.getModelPageCount();
await vi.advanceTimersByTimeAsync(100);
await expect(pageCountPromise).resolves.toBe(0);
});
});
describe('getPage', () => {
it('should return a valid page sitemap', async () => {
const pageSitemap = await sitemap.getPage();
expect(pageSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/'),
changeFrequency: 'monthly',
priority: 0.4,
}),
);
// /discover has been replaced with /community routes
expect(pageSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/community'),
changeFrequency: 'daily',
priority: 0.7,
}),
);
expect(pageSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/community/agent'),
changeFrequency: 'daily',
priority: 0.7,
}),
);
expect(pageSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/community/plugin'),
changeFrequency: 'daily',
priority: 0.7,
}),
);
});
});
describe('getAssistants', () => {
it('should return a valid assistants sitemap without pagination', async () => {
vi.spyOn(sitemap['discoverService'], 'getAssistantIdentifiers').mockResolvedValue([
// @ts-ignore
{ identifier: 'test-assistant', lastModified: '2023-01-01' },
]);
const assistantsSitemap = await sitemap.getAssistants();
expect(assistantsSitemap.length).toBe(LOCALE_COUNT);
expect(assistantsSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/community/agent/test-assistant'),
lastModified: '2023-01-01T00:00:00.000Z',
}),
);
expect(assistantsSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/community/agent/test-assistant?hl=zh-CN'),
lastModified: '2023-01-01T00:00:00.000Z',
}),
);
});
it('should return a valid assistants sitemap with pagination', async () => {
const mockAssistants = Array.from({ length: 150 }, (_, i) => ({
identifier: `test-assistant-${i}`,
lastModified: '2023-01-01',
}));
vi.spyOn(sitemap['discoverService'], 'getAssistantIdentifiers').mockResolvedValue(
// @ts-ignore
mockAssistants,
);
// Test first page (should have 100 items)
const firstPageSitemap = await sitemap.getAssistants(1);
expect(firstPageSitemap.length).toBe(100 * LOCALE_COUNT); // 100 items * LOCALE_COUNT locales
expect(firstPageSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/community/agent/test-assistant-0'),
lastModified: '2023-01-01T00:00:00.000Z',
}),
);
// Test second page (should have 50 items)
const secondPageSitemap = await sitemap.getAssistants(2);
expect(secondPageSitemap.length).toBe(50 * LOCALE_COUNT); // 50 items * LOCALE_COUNT locales
expect(secondPageSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/community/agent/test-assistant-100'),
lastModified: '2023-01-01T00:00:00.000Z',
}),
);
});
});
describe('getPlugins', () => {
it('should return a valid plugins sitemap without pagination', async () => {
vi.spyOn(sitemap['discoverService'], 'getPluginIdentifiers').mockResolvedValue([
// @ts-ignore
{ identifier: 'test-plugin', lastModified: '2023-01-01' },
]);
const pluginsSitemap = await sitemap.getPlugins();
expect(pluginsSitemap.length).toBe(LOCALE_COUNT);
expect(pluginsSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/community/plugin/test-plugin'),
lastModified: '2023-01-01T00:00:00.000Z',
}),
);
expect(pluginsSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/community/plugin/test-plugin?hl=ja-JP'),
lastModified: '2023-01-01T00:00:00.000Z',
}),
);
});
it('should return a valid plugins sitemap with pagination', async () => {
const mockPlugins = Array.from({ length: 250 }, (_, i) => ({
identifier: `test-plugin-${i}`,
lastModified: '2023-01-01',
}));
vi.spyOn(sitemap['discoverService'], 'getPluginIdentifiers').mockResolvedValue(
// @ts-ignore
mockPlugins,
);
// Test first page (should have 100 items)
const firstPageSitemap = await sitemap.getPlugins(1);
expect(firstPageSitemap.length).toBe(100 * LOCALE_COUNT); // 100 items * 15 locales
// Test third page (should have 50 items)
const thirdPageSitemap = await sitemap.getPlugins(3);
expect(thirdPageSitemap.length).toBe(50 * LOCALE_COUNT); // 50 items * 15 locales
});
});
describe('getModels', () => {
it('should return a valid models sitemap without pagination', async () => {
vi.spyOn(sitemap['discoverService'], 'getModelIdentifiers').mockResolvedValue([
// @ts-ignore
{ identifier: 'test:model', lastModified: '2023-01-01' },
]);
const modelsSitemap = await sitemap.getModels();
expect(modelsSitemap.length).toBe(LOCALE_COUNT);
expect(modelsSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/community/model/test:model'),
lastModified: '2023-01-01T00:00:00.000Z',
}),
);
expect(modelsSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/community/model/test:model?hl=ko-KR'),
lastModified: '2023-01-01T00:00:00.000Z',
}),
);
});
it('should return a valid models sitemap with pagination', async () => {
const mockModels = Array.from({ length: 120 }, (_, i) => ({
identifier: `test:model-${i}`,
lastModified: '2023-01-01',
}));
vi.spyOn(sitemap['discoverService'], 'getModelIdentifiers').mockResolvedValue(
// @ts-ignore
mockModels,
);
// Test first page (should have 100 items)
const firstPageSitemap = await sitemap.getModels(1);
expect(firstPageSitemap.length).toBe(100 * LOCALE_COUNT); // 100 items * LOCALE_COUNT locales
// Test second page (should have 20 items)
const secondPageSitemap = await sitemap.getModels(2);
expect(secondPageSitemap.length).toBe(20 * LOCALE_COUNT); // 20 items * LOCALE_COUNT locales
});
});
describe('getProviders', () => {
it('should return a valid providers sitemap', async () => {
vi.spyOn(sitemap['discoverService'], 'getProviderIdentifiers').mockResolvedValue([
// @ts-ignore
{ identifier: 'test-provider', lastModified: '2023-01-01' },
]);
const providersSitemap = await sitemap.getProviders();
expect(providersSitemap.length).toBe(LOCALE_COUNT);
expect(providersSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/community/provider/test-provider'),
lastModified: '2023-01-01T00:00:00.000Z',
}),
);
expect(providersSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/community/provider/test-provider?hl=ar'),
lastModified: '2023-01-01T00:00:00.000Z',
}),
);
});
});
describe('page count methods', () => {
it('should return correct plugin page count', async () => {
vi.spyOn(sitemap['discoverService'], 'getPluginIdentifiers').mockResolvedValue(
// @ts-ignore
Array.from({ length: 150 }, (_, i) => ({ identifier: `plugin-${i}` })),
);
const pageCount = await sitemap.getPluginPageCount();
expect(pageCount).toBe(2); // 150 items / 100 per page = ceil(1.5) = 2 pages
});
it('should return correct assistant page count', async () => {
vi.spyOn(sitemap['discoverService'], 'getAssistantIdentifiers').mockResolvedValue(
// @ts-ignore
Array.from({ length: 250 }, (_, i) => ({ identifier: `assistant-${i}` })),
);
const pageCount = await sitemap.getAssistantPageCount();
expect(pageCount).toBe(3); // 250 items / 100 per page = ceil(2.5) = 3 pages
});
it('should return correct model page count', async () => {
vi.spyOn(sitemap['discoverService'], 'getModelIdentifiers').mockResolvedValue(
// @ts-ignore
Array.from({ length: 120 }, (_, i) => ({ identifier: `model-${i}` })),
);
const pageCount = await sitemap.getModelPageCount();
expect(pageCount).toBe(2); // 120 items / 100 per page = ceil(1.2) = 2 pages
});
it('should skip model sitemap pagination when model identifiers are unavailable', async () => {
const isolatedSitemap = new Sitemap();
vi.spyOn(isolatedSitemap['discoverService'], 'getModelIdentifiers').mockRejectedValue(
new Error('model config unavailable'),
);
await expect(isolatedSitemap.getModelPageCount()).resolves.toBe(0);
});
});
describe('getRobots', () => {
it('should return correct robots.txt entries', () => {
const robots = sitemap.getRobots();
expect(robots).toContain(getCanonicalUrl('/sitemap-index.xml'));
[SitemapType.Pages, SitemapType.Providers].forEach((type) => {
expect(robots).toContain(getCanonicalUrl(`/sitemap/${type}.xml`));
});
});
});
});
-366
View File
@@ -1,366 +0,0 @@
import { setTimeout as sleep } from 'node:timers/promises';
import { flatten } from 'es-toolkit/compat';
import { type MetadataRoute } from 'next';
import qs from 'query-string';
import urlJoin from 'url-join';
import { serverFeatureFlags } from '@/config/featureFlags';
import { DEFAULT_LANG } from '@/const/locale';
import { SITEMAP_BASE_URL } from '@/const/url';
import { type Locales } from '@/locales/resources';
import { locales as allLocales } from '@/locales/resources';
import { DiscoverService } from '@/server/services/discover';
import { getCanonicalUrl } from '@/server/utils/url';
import { isDev } from '@/utils/env';
export interface SitemapItem {
alternates?: {
languages?: string;
};
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
lastModified?: string | Date;
priority?: number;
url: string;
}
export enum SitemapType {
Assistants = 'assistants',
Mcp = 'mcp',
Models = 'models',
Pages = 'pages',
Plugins = 'plugins',
Providers = 'providers',
}
export const LAST_MODIFIED = new Date().toISOString();
// Number of items per page
const ITEMS_PER_PAGE = 100;
const DEFAULT_MODEL_PAGE_COUNT_TIMEOUT_MS = 15 * 60 * 1000;
interface SitemapOptions {
modelPageCountTimeoutMs?: number;
}
export class Sitemap {
private modelPageCountTimeoutMs: number;
sitemapIndexs = [{ id: SitemapType.Pages }, { id: SitemapType.Providers }];
private discoverService = new DiscoverService();
constructor(options: SitemapOptions = {}) {
this.modelPageCountTimeoutMs =
options.modelPageCountTimeoutMs ?? DEFAULT_MODEL_PAGE_COUNT_TIMEOUT_MS;
}
private _withModelPageCountTimeout = async <T>(promise: Promise<T>) => {
const timeoutController = new AbortController();
try {
return await Promise.race([
promise,
sleep(this.modelPageCountTimeoutMs, undefined, { signal: timeoutController.signal }).then(
() => {
throw new Error('Timed out while getting model identifiers for sitemap');
},
),
]);
} finally {
timeoutController.abort();
}
};
// Get total number of plugin pages
async getPluginPageCount(): Promise<number> {
const list = await this.discoverService.getPluginIdentifiers();
return Math.ceil(list.length / ITEMS_PER_PAGE);
}
// Get total number of assistant pages
async getAssistantPageCount(): Promise<number> {
const list = await this.discoverService.getAssistantIdentifiers();
return Math.ceil(list.length / ITEMS_PER_PAGE);
}
// Get total number of model pages
async getModelPageCount(): Promise<number> {
let list: Awaited<ReturnType<DiscoverService['getModelIdentifiers']>>;
try {
list = await this._withModelPageCountTimeout(this.discoverService.getModelIdentifiers());
} catch (error) {
// Keep sitemap generation from blocking deployment when model identifiers are unavailable.
console.error('[Sitemap] Failed to get model identifiers for sitemap', error);
return 0;
}
return Math.ceil(list.length / ITEMS_PER_PAGE);
}
private _generateSitemapLink(url: string) {
return [
'<sitemap>',
`<loc>${url}</loc>`,
`<lastmod>${LAST_MODIFIED}</lastmod>`,
'</sitemap>',
].join('\n');
}
private _formatTime(time?: string) {
try {
if (!time) return LAST_MODIFIED;
return new Date(time).toISOString() || LAST_MODIFIED;
} catch {
return LAST_MODIFIED;
}
}
private _genSitemapItem = (
lang: Locales,
url: string,
{
lastModified,
changeFrequency = 'monthly',
priority = 0.4,
noLocales,
locales = allLocales,
}: {
changeFrequency?: SitemapItem['changeFrequency'];
lastModified?: string;
locales?: typeof allLocales;
noLocales?: boolean;
priority?: number;
} = {},
) => {
const sitemap = {
changeFrequency,
lastModified: this._formatTime(lastModified),
priority,
url:
lang === DEFAULT_LANG
? getCanonicalUrl(url)
: qs.stringifyUrl({ query: { hl: lang }, url: getCanonicalUrl(url) }),
};
if (noLocales) return sitemap;
const languages: any = {};
for (const locale of locales) {
if (locale === lang) continue;
languages[locale] = qs.stringifyUrl({
query: { hl: locale },
url: getCanonicalUrl(url),
});
}
return {
alternates: {
languages,
},
...sitemap,
};
};
private _genSitemap(
url: string,
{
lastModified,
changeFrequency = 'monthly',
priority = 0.4,
noLocales,
locales = allLocales,
}: {
changeFrequency?: SitemapItem['changeFrequency'];
lastModified?: string;
locales?: typeof allLocales;
noLocales?: boolean;
priority?: number;
} = {},
) {
if (noLocales)
return [
this._genSitemapItem(DEFAULT_LANG, url, {
changeFrequency,
lastModified,
locales,
noLocales,
priority,
}),
];
return locales.map((lang) =>
this._genSitemapItem(lang, url, {
changeFrequency,
lastModified,
locales,
noLocales,
priority,
}),
);
}
async getIndex(): Promise<string> {
const staticSitemaps = this.sitemapIndexs.map((item) =>
this._generateSitemapLink(
getCanonicalUrl(SITEMAP_BASE_URL, isDev ? item.id : `${item.id}.xml`),
),
);
// Get page counts for types that need pagination
const [pluginPages, assistantPages, modelPages] = await Promise.all([
this.getPluginPageCount(),
this.getAssistantPageCount(),
this.getModelPageCount(),
]);
// Generate paginated sitemap links
const paginatedSitemaps = [
...Array.from({ length: pluginPages }, (_, i) =>
this._generateSitemapLink(
getCanonicalUrl(SITEMAP_BASE_URL, isDev ? `plugins-${i + 1}` : `plugins-${i + 1}.xml`),
),
),
...Array.from({ length: assistantPages }, (_, i) =>
this._generateSitemapLink(
getCanonicalUrl(
SITEMAP_BASE_URL,
isDev ? `assistants-${i + 1}` : `assistants-${i + 1}.xml`,
),
),
),
...Array.from({ length: modelPages }, (_, i) =>
this._generateSitemapLink(
getCanonicalUrl(SITEMAP_BASE_URL, isDev ? `models-${i + 1}` : `models-${i + 1}.xml`),
),
),
];
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...staticSitemaps,
...paginatedSitemaps,
'</sitemapindex>',
].join('\n');
}
async getAssistants(page?: number): Promise<MetadataRoute.Sitemap> {
const list = await this.discoverService.getAssistantIdentifiers();
if (page !== undefined) {
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const pageAssistants = list.slice(startIndex, endIndex);
const sitmap = pageAssistants
.filter((item) => item.identifier) // Filter out items with empty identifiers
.map((item) =>
this._genSitemap(urlJoin('/community/agent', item.identifier), {
lastModified: item?.lastModified || LAST_MODIFIED,
}),
);
return flatten(sitmap);
}
// If page number is not specified, return all (backward compatibility)
const sitmap = list
.filter((item) => item.identifier) // Filter out items with empty identifiers
.map((item) =>
this._genSitemap(urlJoin('/community/agent', item.identifier), {
lastModified: item?.lastModified || LAST_MODIFIED,
}),
);
return flatten(sitmap);
}
async getPlugins(page?: number): Promise<MetadataRoute.Sitemap> {
const list = await this.discoverService.getPluginIdentifiers();
if (page !== undefined) {
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const pagePlugins = list.slice(startIndex, endIndex);
const sitmap = pagePlugins
.filter((item) => item.identifier) // Filter out items with empty identifiers
.map((item) =>
this._genSitemap(urlJoin('/community/plugin', item.identifier), {
lastModified: item?.lastModified || LAST_MODIFIED,
}),
);
return flatten(sitmap);
}
// If page number is not specified, return all (backward compatibility)
const sitmap = list
.filter((item) => item.identifier) // Filter out items with empty identifiers
.map((item) =>
this._genSitemap(urlJoin('/community/plugin', item.identifier), {
lastModified: item?.lastModified || LAST_MODIFIED,
}),
);
return flatten(sitmap);
}
async getModels(page?: number): Promise<MetadataRoute.Sitemap> {
const list = await this.discoverService.getModelIdentifiers();
if (page !== undefined) {
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const pageModels = list.slice(startIndex, endIndex);
const sitmap = pageModels
.filter((item) => item.identifier) // Filter out items with empty identifiers
.map((item) =>
this._genSitemap(urlJoin('/community/model', item.identifier), {
lastModified: item?.lastModified || LAST_MODIFIED,
}),
);
return flatten(sitmap);
}
// If page number is not specified, return all (backward compatibility)
const sitmap = list
.filter((item) => item.identifier) // Filter out items with empty identifiers
.map((item) =>
this._genSitemap(urlJoin('/community/model', item.identifier), {
lastModified: item?.lastModified || LAST_MODIFIED,
}),
);
return flatten(sitmap);
}
async getProviders(): Promise<MetadataRoute.Sitemap> {
const list = await this.discoverService.getProviderIdentifiers();
const sitmap = list
.filter((item) => item.identifier) // Filter out items with empty identifiers
.map((item) =>
this._genSitemap(urlJoin('/community/provider', item.identifier), {
lastModified: item?.lastModified || LAST_MODIFIED,
}),
);
return flatten(sitmap);
}
async getPage(): Promise<MetadataRoute.Sitemap> {
const hideDocs = serverFeatureFlags().hideDocs;
return [
...this._genSitemap('/', { noLocales: true }),
...this._genSitemap('/agent', { noLocales: true }),
...(!hideDocs ? this._genSitemap('/changelog', { noLocales: true }) : []),
...this._genSitemap('/community', { changeFrequency: 'daily', priority: 0.7 }),
...this._genSitemap('/community/agent', { changeFrequency: 'daily', priority: 0.7 }),
...this._genSitemap('/community/mcp', { changeFrequency: 'daily', priority: 0.7 }),
...this._genSitemap('/community/plugin', { changeFrequency: 'daily', priority: 0.7 }),
...this._genSitemap('/community/model', { changeFrequency: 'daily', priority: 0.7 }),
...this._genSitemap('/community/provider', { changeFrequency: 'daily', priority: 0.7 }),
].filter(Boolean);
}
getRobots() {
return [
getCanonicalUrl('/sitemap-index.xml'),
...this.sitemapIndexs.map((index) =>
getCanonicalUrl(SITEMAP_BASE_URL, isDev ? index.id : `${index.id}.xml`),
),
];
}
}