mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
⚡️ 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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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`));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user