@lobehub/chat 1.19.3 → 1.19.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of @lobehub/chat might be problematic. Click here for more details.
- package/CHANGELOG.md +25 -0
- package/next.config.mjs +10 -0
- package/package.json +6 -7
- package/scripts/buildSitemapIndex/index.ts +12 -0
- package/src/app/(main)/discover/(detail)/assistant/[slug]/page.tsx +1 -0
- package/src/app/(main)/discover/(detail)/model/[...slugs]/features/ProviderList/ProviderItem.tsx +1 -0
- package/src/app/(main)/discover/(detail)/model/[...slugs]/page.tsx +1 -0
- package/src/app/(main)/discover/(detail)/plugin/[slug]/page.tsx +1 -0
- package/src/app/(main)/discover/(detail)/provider/[slug]/features/ModelList/ModelItem.tsx +1 -0
- package/src/app/(main)/discover/(detail)/provider/[slug]/page.tsx +1 -0
- package/src/app/(main)/discover/(list)/_layout/Desktop/Nav.tsx +1 -0
- package/src/app/(main)/discover/_layout/Desktop/index.tsx +1 -0
- package/src/app/page.tsx +1 -1
- package/src/app/robots.tsx +16 -0
- package/src/app/sitemap.tsx +30 -0
- package/src/const/url.ts +2 -2
- package/src/server/ld.test.ts +102 -0
- package/src/server/ld.ts +3 -9
- package/src/server/metadata.test.ts +138 -0
- package/src/server/metadata.ts +3 -3
- package/src/server/modules/AssistantStore/index.test.ts +1 -1
- package/src/server/modules/AssistantStore/index.ts +2 -6
- package/src/server/sitemap.test.ts +179 -0
- package/src/server/sitemap.ts +243 -0
- package/src/server/translation.test.ts +137 -0
- package/src/server/utils/url.test.ts +61 -0
- package/src/server/utils/url.ts +9 -0
- package/next-sitemap.config.mjs +0 -53
package/CHANGELOG.md
CHANGED
@@ -2,6 +2,31 @@
|
|
2
2
|
|
3
3
|
# Changelog
|
4
4
|
|
5
|
+
### [Version 1.19.4](https://github.com/lobehub/lobe-chat/compare/v1.19.3...v1.19.4)
|
6
|
+
|
7
|
+
<sup>Released on **2024-09-19**</sup>
|
8
|
+
|
9
|
+
#### ♻ Code Refactoring
|
10
|
+
|
11
|
+
- **misc**: Refactor the sitemap implement.
|
12
|
+
|
13
|
+
<br/>
|
14
|
+
|
15
|
+
<details>
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
17
|
+
|
18
|
+
#### Code refactoring
|
19
|
+
|
20
|
+
- **misc**: Refactor the sitemap implement, closes [#4012](https://github.com/lobehub/lobe-chat/issues/4012) ([d93a161](https://github.com/lobehub/lobe-chat/commit/d93a161))
|
21
|
+
|
22
|
+
</details>
|
23
|
+
|
24
|
+
<div align="right">
|
25
|
+
|
26
|
+
[](#readme-top)
|
27
|
+
|
28
|
+
</div>
|
29
|
+
|
5
30
|
### [Version 1.19.3](https://github.com/lobehub/lobe-chat/compare/v1.19.2...v1.19.3)
|
6
31
|
|
7
32
|
<sup>Released on **2024-09-19**</sup>
|
package/next.config.mjs
CHANGED
@@ -107,6 +107,16 @@ const nextConfig = {
|
|
107
107
|
output: buildWithDocker ? 'standalone' : undefined,
|
108
108
|
reactStrictMode: true,
|
109
109
|
redirects: async () => [
|
110
|
+
{
|
111
|
+
destination: '/sitemap-index.xml',
|
112
|
+
permanent: true,
|
113
|
+
source: '/sitemap.xml',
|
114
|
+
},
|
115
|
+
{
|
116
|
+
destination: '/discover',
|
117
|
+
permanent: true,
|
118
|
+
source: '/market',
|
119
|
+
},
|
110
120
|
{
|
111
121
|
destination: '/settings/common',
|
112
122
|
permanent: true,
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@lobehub/chat",
|
3
|
-
"version": "1.19.
|
3
|
+
"version": "1.19.4",
|
4
4
|
"description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
5
5
|
"keywords": [
|
6
6
|
"framework",
|
@@ -29,11 +29,11 @@
|
|
29
29
|
"build": "next build",
|
30
30
|
"postbuild": "npm run build-sitemap && npm run build-migrate-db",
|
31
31
|
"build-migrate-db": "bun run db:migrate",
|
32
|
-
"build-sitemap": "
|
32
|
+
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
|
33
33
|
"build:analyze": "ANALYZE=true next build",
|
34
34
|
"build:docker": "DOCKER=true next build && npm run build-sitemap",
|
35
35
|
"db:generate": "drizzle-kit generate",
|
36
|
-
"db:migrate": "MIGRATION_DB=1 tsx scripts/migrateServerDB/index.ts",
|
36
|
+
"db:migrate": "MIGRATION_DB=1 tsx ./scripts/migrateServerDB/index.ts",
|
37
37
|
"db:push": "drizzle-kit push",
|
38
38
|
"db:push-test": "NODE_ENV=test drizzle-kit push",
|
39
39
|
"db:studio": "drizzle-kit studio",
|
@@ -65,11 +65,11 @@
|
|
65
65
|
"test:update": "vitest -u",
|
66
66
|
"type-check": "tsc --noEmit",
|
67
67
|
"webhook:ngrok": "ngrok http http://localhost:3011",
|
68
|
-
"workflow:docs": "tsx scripts/docsWorkflow/index.ts",
|
69
|
-
"workflow:i18n": "tsx scripts/i18nWorkflow/index.ts",
|
68
|
+
"workflow:docs": "tsx ./scripts/docsWorkflow/index.ts",
|
69
|
+
"workflow:i18n": "tsx ./scripts/i18nWorkflow/index.ts",
|
70
70
|
"workflow:mdx": "tsx ./scripts/mdxWorkflow/index.ts",
|
71
71
|
"workflow:mdx-with-lint": "tsx ./scripts/mdxWorkflow/index.ts && eslint \"docs/**/*.mdx\" --quiet --fix",
|
72
|
-
"workflow:readme": "tsx scripts/readmeWorkflow/index.ts"
|
72
|
+
"workflow:readme": "tsx ./scripts/readmeWorkflow/index.ts"
|
73
73
|
},
|
74
74
|
"lint-staged": {
|
75
75
|
"*.md": [
|
@@ -172,7 +172,6 @@
|
|
172
172
|
"next": "14.2.8",
|
173
173
|
"next-auth": "beta",
|
174
174
|
"next-mdx-remote": "^4.4.1",
|
175
|
-
"next-sitemap": "^4.2.3",
|
176
175
|
"nextjs-toploader": "^3.6.15",
|
177
176
|
"numeral": "^2.0.6",
|
178
177
|
"nuqs": "^1.17.8",
|
@@ -0,0 +1,12 @@
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
2
|
+
import { resolve } from 'node:path';
|
3
|
+
|
4
|
+
import { sitemapModule } from '@/server/sitemap';
|
5
|
+
|
6
|
+
const genSitemap = () => {
|
7
|
+
const sitemapIndexXML = sitemapModule.getIndex();
|
8
|
+
const filename = resolve(__dirname, '../../', 'public', 'sitemap-index.xml');
|
9
|
+
writeFileSync(filename, sitemapIndexXML);
|
10
|
+
};
|
11
|
+
|
12
|
+
genSitemap();
|
@@ -100,6 +100,7 @@ const Page = async ({ params, searchParams }: Props) => {
|
|
100
100
|
mobile={mobile}
|
101
101
|
sidebar={<InfoSidebar data={data} identifier={identifier} mobile={mobile} />}
|
102
102
|
/* ↓ cloud slot ↓ */
|
103
|
+
|
103
104
|
/* ↑ cloud slot ↑ */
|
104
105
|
>
|
105
106
|
<ProviderList data={providerData} identifier={identifier} mobile={mobile} />
|
@@ -102,6 +102,7 @@ const Page = async ({ params, searchParams }: Props) => {
|
|
102
102
|
mobile={mobile}
|
103
103
|
sidebar={<InfoSidebar data={data} identifier={identifier} />}
|
104
104
|
/* ↓ cloud slot ↓ */
|
105
|
+
|
105
106
|
/* ↑ cloud slot ↑ */
|
106
107
|
>
|
107
108
|
<ModelList identifier={identifier} mobile={mobile} modelData={modelData} />
|
package/src/app/page.tsx
CHANGED
@@ -0,0 +1,16 @@
|
|
1
|
+
import { MetadataRoute } from 'next';
|
2
|
+
|
3
|
+
import { sitemapModule } from '@/server/sitemap';
|
4
|
+
import { getCanonicalUrl } from '@/server/utils/url';
|
5
|
+
|
6
|
+
export default function robots(): MetadataRoute.Robots {
|
7
|
+
return {
|
8
|
+
host: getCanonicalUrl(),
|
9
|
+
rules: {
|
10
|
+
allow: ['/'],
|
11
|
+
disallow: ['/api/*'],
|
12
|
+
userAgent: '*',
|
13
|
+
},
|
14
|
+
sitemap: sitemapModule.getRobots(),
|
15
|
+
};
|
16
|
+
}
|
@@ -0,0 +1,30 @@
|
|
1
|
+
import { MetadataRoute } from 'next';
|
2
|
+
|
3
|
+
import { SitemapType, sitemapModule } from '@/server/sitemap';
|
4
|
+
|
5
|
+
export const generateSitemaps = async () => {
|
6
|
+
// Fetch the total number of products and calculate the number of sitemaps needed
|
7
|
+
return sitemapModule.sitemapIndexs;
|
8
|
+
};
|
9
|
+
|
10
|
+
const Sitemap = async ({ id }: { id: SitemapType }): Promise<MetadataRoute.Sitemap> => {
|
11
|
+
switch (id) {
|
12
|
+
case SitemapType.Pages: {
|
13
|
+
return sitemapModule.getPage();
|
14
|
+
}
|
15
|
+
case SitemapType.Assistants: {
|
16
|
+
return sitemapModule.getAssistants();
|
17
|
+
}
|
18
|
+
case SitemapType.Plugins: {
|
19
|
+
return sitemapModule.getPlugins();
|
20
|
+
}
|
21
|
+
case SitemapType.Models: {
|
22
|
+
return sitemapModule.getModels();
|
23
|
+
}
|
24
|
+
case SitemapType.Providers: {
|
25
|
+
return sitemapModule.getProviders();
|
26
|
+
}
|
27
|
+
}
|
28
|
+
};
|
29
|
+
|
30
|
+
export default Sitemap;
|
package/src/const/url.ts
CHANGED
@@ -2,6 +2,7 @@ import qs from 'query-string';
|
|
2
2
|
import urlJoin from 'url-join';
|
3
3
|
|
4
4
|
import { withBasePath } from '@/utils/basePath';
|
5
|
+
import { isDev } from '@/utils/env';
|
5
6
|
|
6
7
|
import pkg from '../../package.json';
|
7
8
|
import { INBOX_SESSION_ID } from './session';
|
@@ -12,8 +13,6 @@ export const OFFICIAL_URL = 'https://lobechat.com/';
|
|
12
13
|
export const OFFICIAL_PREVIEW_URL = 'https://chat-preview.lobehub.com/';
|
13
14
|
export const OFFICIAL_SITE = 'https://lobehub.com/';
|
14
15
|
|
15
|
-
export const getCanonicalUrl = (path: string) => urlJoin(OFFICIAL_URL, path);
|
16
|
-
|
17
16
|
export const OG_URL = '/og/cover.png?v=1';
|
18
17
|
|
19
18
|
export const GITHUB = pkg.homepage;
|
@@ -73,3 +72,4 @@ export const mailTo = (email: string) => `mailto:${email}`;
|
|
73
72
|
|
74
73
|
export const AES_GCM_URL = 'https://datatracker.ietf.org/doc/html/draft-ietf-avt-srtp-aes-gcm-01';
|
75
74
|
export const BASE_PROVIDER_DOC_URL = 'https://lobehub.com/docs/usage/providers';
|
75
|
+
export const SITEMAP_BASE_URL = isDev ? '/sitemap.xml/' : 'sitemap';
|
@@ -0,0 +1,102 @@
|
|
1
|
+
// @vitest-environment node
|
2
|
+
import { describe, expect, it } from 'vitest';
|
3
|
+
|
4
|
+
import { DEFAULT_LANG } from '@/const/locale';
|
5
|
+
|
6
|
+
import { AUTHOR_LIST, Ld } from './ld';
|
7
|
+
|
8
|
+
describe('Ld', () => {
|
9
|
+
const ld = new Ld();
|
10
|
+
|
11
|
+
describe('generate', () => {
|
12
|
+
it('should generate correct LD+JSON structure', () => {
|
13
|
+
const result = ld.generate({
|
14
|
+
title: 'Test Title',
|
15
|
+
description: 'Test Description',
|
16
|
+
url: 'https://example.com/test',
|
17
|
+
locale: DEFAULT_LANG,
|
18
|
+
});
|
19
|
+
|
20
|
+
expect(result['@context']).toBe('https://schema.org');
|
21
|
+
expect(Array.isArray(result['@graph'])).toBe(true);
|
22
|
+
expect(result['@graph'].length).toBeGreaterThan(0);
|
23
|
+
});
|
24
|
+
});
|
25
|
+
|
26
|
+
describe('genOrganization', () => {
|
27
|
+
it('should generate correct organization structure', () => {
|
28
|
+
const org = ld.genOrganization();
|
29
|
+
|
30
|
+
expect(org['@type']).toBe('Organization');
|
31
|
+
expect(org.name).toBe('LobeHub');
|
32
|
+
expect(org.url).toBe('https://lobehub.com/');
|
33
|
+
});
|
34
|
+
});
|
35
|
+
|
36
|
+
describe('getAuthors', () => {
|
37
|
+
it('should return default author when no ids provided', () => {
|
38
|
+
const author = ld.getAuthors();
|
39
|
+
expect(author['@type']).toBe('Organization');
|
40
|
+
});
|
41
|
+
|
42
|
+
it('should return person when valid id provided', () => {
|
43
|
+
const author = ld.getAuthors(['arvinxx']);
|
44
|
+
expect(author['@type']).toBe('Person');
|
45
|
+
// @ts-ignore
|
46
|
+
expect(author.name).toBe(AUTHOR_LIST.arvinxx.name);
|
47
|
+
});
|
48
|
+
});
|
49
|
+
|
50
|
+
describe('genWebPage', () => {
|
51
|
+
it('should generate correct webpage structure', () => {
|
52
|
+
const webpage = ld.genWebPage({
|
53
|
+
title: 'Test Page',
|
54
|
+
description: 'Test Description',
|
55
|
+
url: 'https://example.com/test',
|
56
|
+
locale: DEFAULT_LANG,
|
57
|
+
});
|
58
|
+
|
59
|
+
expect(webpage['@type']).toBe('WebPage');
|
60
|
+
expect(webpage.name).toBe('Test Page · LobeChat');
|
61
|
+
expect(webpage.description).toBe('Test Description');
|
62
|
+
});
|
63
|
+
});
|
64
|
+
|
65
|
+
describe('genImageObject', () => {
|
66
|
+
it('should generate correct image object', () => {
|
67
|
+
const image = ld.genImageObject({
|
68
|
+
image: 'https://example.com/image.jpg',
|
69
|
+
url: 'https://example.com/test',
|
70
|
+
});
|
71
|
+
|
72
|
+
expect(image['@type']).toBe('ImageObject');
|
73
|
+
expect(image.url).toBe('https://example.com/image.jpg');
|
74
|
+
});
|
75
|
+
});
|
76
|
+
|
77
|
+
describe('genWebSite', () => {
|
78
|
+
it('should generate correct website structure', () => {
|
79
|
+
const website = ld.genWebSite();
|
80
|
+
|
81
|
+
expect(website['@type']).toBe('WebSite');
|
82
|
+
expect(website.name).toBe('LobeChat');
|
83
|
+
});
|
84
|
+
});
|
85
|
+
|
86
|
+
describe('genArticle', () => {
|
87
|
+
it('should generate correct article structure', () => {
|
88
|
+
const article = ld.genArticle({
|
89
|
+
title: 'Test Article',
|
90
|
+
description: 'Test Description',
|
91
|
+
url: 'https://example.com/test',
|
92
|
+
author: ['arvinxx'],
|
93
|
+
identifier: 'test-id',
|
94
|
+
locale: DEFAULT_LANG,
|
95
|
+
});
|
96
|
+
|
97
|
+
expect(article['@type']).toBe('Article');
|
98
|
+
expect(article.headline).toBe('Test Article · LobeChat');
|
99
|
+
expect(article.author['@type']).toBe('Person');
|
100
|
+
});
|
101
|
+
});
|
102
|
+
});
|
package/src/server/ld.ts
CHANGED
@@ -3,15 +3,9 @@ import urlJoin from 'url-join';
|
|
3
3
|
|
4
4
|
import { BRANDING_NAME } from '@/const/branding';
|
5
5
|
import { DEFAULT_LANG } from '@/const/locale';
|
6
|
-
import {
|
7
|
-
EMAIL_BUSINESS,
|
8
|
-
EMAIL_SUPPORT,
|
9
|
-
OFFICIAL_SITE,
|
10
|
-
OFFICIAL_URL,
|
11
|
-
X,
|
12
|
-
getCanonicalUrl,
|
13
|
-
} from '@/const/url';
|
6
|
+
import { EMAIL_BUSINESS, EMAIL_SUPPORT, OFFICIAL_SITE, OFFICIAL_URL, X } from '@/const/url';
|
14
7
|
import { Locales } from '@/locales/resources';
|
8
|
+
import { getCanonicalUrl } from '@/server/utils/url';
|
15
9
|
|
16
10
|
import pkg from '../../package.json';
|
17
11
|
|
@@ -37,7 +31,7 @@ export const AUTHOR_LIST = {
|
|
37
31
|
},
|
38
32
|
};
|
39
33
|
|
40
|
-
class Ld {
|
34
|
+
export class Ld {
|
41
35
|
generate({
|
42
36
|
image = '/og/cover.png',
|
43
37
|
article,
|
@@ -0,0 +1,138 @@
|
|
1
|
+
// @vitest-environment node
|
2
|
+
import { describe, expect, it } from 'vitest';
|
3
|
+
|
4
|
+
import { BRANDING_NAME } from '@/const/branding';
|
5
|
+
import { OG_URL } from '@/const/url';
|
6
|
+
|
7
|
+
import { Meta } from './metadata';
|
8
|
+
|
9
|
+
describe('Metadata', () => {
|
10
|
+
const meta = new Meta();
|
11
|
+
|
12
|
+
describe('generate', () => {
|
13
|
+
it('should generate metadata with default values', () => {
|
14
|
+
const result = meta.generate({
|
15
|
+
title: 'Test Title',
|
16
|
+
url: 'https://example.com',
|
17
|
+
});
|
18
|
+
|
19
|
+
expect(result).toMatchObject({
|
20
|
+
title: 'Test Title',
|
21
|
+
description: expect.any(String),
|
22
|
+
openGraph: expect.objectContaining({
|
23
|
+
title: `Test Title · ${BRANDING_NAME}`,
|
24
|
+
description: expect.any(String),
|
25
|
+
images: [{ url: OG_URL, alt: `Test Title · ${BRANDING_NAME}` }],
|
26
|
+
}),
|
27
|
+
twitter: expect.objectContaining({
|
28
|
+
title: `Test Title · ${BRANDING_NAME}`,
|
29
|
+
description: expect.any(String),
|
30
|
+
images: [OG_URL],
|
31
|
+
}),
|
32
|
+
});
|
33
|
+
});
|
34
|
+
|
35
|
+
it('should generate metadata with custom values', () => {
|
36
|
+
const result = meta.generate({
|
37
|
+
title: 'Custom Title',
|
38
|
+
description: 'Custom description',
|
39
|
+
image: 'https://custom-image.com',
|
40
|
+
url: 'https://example.com/custom',
|
41
|
+
type: 'article',
|
42
|
+
tags: ['tag1', 'tag2'],
|
43
|
+
locale: 'fr-FR',
|
44
|
+
alternate: true,
|
45
|
+
});
|
46
|
+
|
47
|
+
expect(result).toMatchObject({
|
48
|
+
title: 'Custom Title',
|
49
|
+
description: expect.stringContaining('Custom description'),
|
50
|
+
openGraph: expect.objectContaining({
|
51
|
+
title: `Custom Title · ${BRANDING_NAME}`,
|
52
|
+
description: 'Custom description',
|
53
|
+
images: [{ url: 'https://custom-image.com', alt: `Custom Title · ${BRANDING_NAME}` }],
|
54
|
+
type: 'article',
|
55
|
+
locale: 'fr-FR',
|
56
|
+
}),
|
57
|
+
twitter: expect.objectContaining({
|
58
|
+
title: `Custom Title · ${BRANDING_NAME}`,
|
59
|
+
description: 'Custom description',
|
60
|
+
images: ['https://custom-image.com'],
|
61
|
+
}),
|
62
|
+
alternates: expect.objectContaining({
|
63
|
+
languages: expect.any(Object),
|
64
|
+
}),
|
65
|
+
});
|
66
|
+
});
|
67
|
+
});
|
68
|
+
|
69
|
+
describe('genAlternateLocales', () => {
|
70
|
+
it('should generate alternate locales correctly', () => {
|
71
|
+
const result = (meta as any).genAlternateLocales('en', '/test');
|
72
|
+
|
73
|
+
expect(result).toHaveProperty('x-default', expect.stringContaining('/test'));
|
74
|
+
expect(result).toHaveProperty('zh-CN', expect.stringContaining('hl=zh-CN'));
|
75
|
+
expect(result).not.toHaveProperty('en');
|
76
|
+
});
|
77
|
+
});
|
78
|
+
|
79
|
+
describe('genTwitter', () => {
|
80
|
+
it('should generate Twitter metadata correctly', () => {
|
81
|
+
const result = (meta as any).genTwitter({
|
82
|
+
title: 'Twitter Title',
|
83
|
+
description: 'Twitter description',
|
84
|
+
image: 'https://twitter-image.com',
|
85
|
+
url: 'https://example.com/twitter',
|
86
|
+
});
|
87
|
+
|
88
|
+
expect(result).toEqual({
|
89
|
+
card: 'summary_large_image',
|
90
|
+
title: 'Twitter Title',
|
91
|
+
description: 'Twitter description',
|
92
|
+
images: ['https://twitter-image.com'],
|
93
|
+
site: '@lobehub',
|
94
|
+
url: 'https://example.com/twitter',
|
95
|
+
});
|
96
|
+
});
|
97
|
+
});
|
98
|
+
|
99
|
+
describe('genOpenGraph', () => {
|
100
|
+
it('should generate OpenGraph metadata correctly', () => {
|
101
|
+
const result = (meta as any).genOpenGraph({
|
102
|
+
title: 'OG Title',
|
103
|
+
description: 'OG description',
|
104
|
+
image: 'https://og-image.com',
|
105
|
+
url: 'https://example.com/og',
|
106
|
+
locale: 'es-ES',
|
107
|
+
type: 'article',
|
108
|
+
alternate: true,
|
109
|
+
});
|
110
|
+
|
111
|
+
expect(result).toMatchObject({
|
112
|
+
title: 'OG Title',
|
113
|
+
description: 'OG description',
|
114
|
+
images: [{ url: 'https://og-image.com', alt: 'OG Title' }],
|
115
|
+
locale: 'es-ES',
|
116
|
+
type: 'article',
|
117
|
+
url: 'https://example.com/og',
|
118
|
+
siteName: 'LobeChat',
|
119
|
+
alternateLocale: expect.arrayContaining([
|
120
|
+
'ar',
|
121
|
+
'bg-BG',
|
122
|
+
'de-DE',
|
123
|
+
'en-US',
|
124
|
+
'es-ES',
|
125
|
+
'fr-FR',
|
126
|
+
'ja-JP',
|
127
|
+
'ko-KR',
|
128
|
+
'pt-BR',
|
129
|
+
'ru-RU',
|
130
|
+
'tr-TR',
|
131
|
+
'zh-CN',
|
132
|
+
'zh-TW',
|
133
|
+
'vi-VN',
|
134
|
+
]),
|
135
|
+
});
|
136
|
+
});
|
137
|
+
});
|
138
|
+
});
|
package/src/server/metadata.ts
CHANGED
@@ -3,8 +3,9 @@ import qs from 'query-string';
|
|
3
3
|
|
4
4
|
import { BRANDING_NAME } from '@/const/branding';
|
5
5
|
import { DEFAULT_LANG } from '@/const/locale';
|
6
|
-
import { OG_URL
|
6
|
+
import { OG_URL } from '@/const/url';
|
7
7
|
import { Locales, locales } from '@/locales/resources';
|
8
|
+
import { getCanonicalUrl } from '@/server/utils/url';
|
8
9
|
import { formatDescLength, formatTitleLength } from '@/utils/genOG';
|
9
10
|
|
10
11
|
export class Meta {
|
@@ -59,7 +60,6 @@ export class Meta {
|
|
59
60
|
let links: any = {};
|
60
61
|
const defaultLink = getCanonicalUrl(path);
|
61
62
|
for (const alterLocales of locales) {
|
62
|
-
if (locale === alterLocales) continue;
|
63
63
|
links[alterLocales] = qs.stringifyUrl({
|
64
64
|
query: { hl: alterLocales },
|
65
65
|
url: defaultLink,
|
@@ -125,7 +125,7 @@ export class Meta {
|
|
125
125
|
};
|
126
126
|
|
127
127
|
if (alternate) {
|
128
|
-
data['alternateLocale'] = locales
|
128
|
+
data['alternateLocale'] = locales;
|
129
129
|
}
|
130
130
|
|
131
131
|
return data;
|
@@ -13,7 +13,7 @@ describe('AssistantStore', () => {
|
|
13
13
|
|
14
14
|
it('should return the index URL for a not supported language', () => {
|
15
15
|
const agentMarket = new AssistantStore();
|
16
|
-
const url = agentMarket.getAgentIndexUrl('
|
16
|
+
const url = agentMarket.getAgentIndexUrl('xxx' as any);
|
17
17
|
expect(url).toBe('https://chat-agents.lobehub.com');
|
18
18
|
});
|
19
19
|
|
@@ -4,10 +4,6 @@ import { appEnv } from '@/config/app';
|
|
4
4
|
import { DEFAULT_LANG, isLocaleNotSupport } from '@/const/locale';
|
5
5
|
import { Locales, normalizeLocale } from '@/locales/resources';
|
6
6
|
|
7
|
-
const checkSupportLocale = (lang: Locales) => {
|
8
|
-
return isLocaleNotSupport(lang) || normalizeLocale(lang) !== 'zh-CN';
|
9
|
-
};
|
10
|
-
|
11
7
|
export class AssistantStore {
|
12
8
|
private readonly baseUrl: string;
|
13
9
|
|
@@ -16,13 +12,13 @@ export class AssistantStore {
|
|
16
12
|
}
|
17
13
|
|
18
14
|
getAgentIndexUrl = (lang: Locales = DEFAULT_LANG) => {
|
19
|
-
if (
|
15
|
+
if (isLocaleNotSupport(lang)) return this.baseUrl;
|
20
16
|
|
21
17
|
return urlJoin(this.baseUrl, `index.${normalizeLocale(lang)}.json`);
|
22
18
|
};
|
23
19
|
|
24
20
|
getAgentUrl = (identifier: string, lang: Locales = DEFAULT_LANG) => {
|
25
|
-
if (
|
21
|
+
if (isLocaleNotSupport(lang)) return urlJoin(this.baseUrl, `${identifier}.json`);
|
26
22
|
|
27
23
|
return urlJoin(this.baseUrl, `${identifier}.${normalizeLocale(lang)}.json`);
|
28
24
|
};
|
@@ -0,0 +1,179 @@
|
|
1
|
+
// @vitest-environment node
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
3
|
+
|
4
|
+
import { getCanonicalUrl } from '@/server/utils/url';
|
5
|
+
import { AssistantCategory, PluginCategory } from '@/types/discover';
|
6
|
+
|
7
|
+
import { LAST_MODIFIED, Sitemap, SitemapType } from './sitemap';
|
8
|
+
|
9
|
+
describe('Sitemap', () => {
|
10
|
+
const sitemap = new Sitemap();
|
11
|
+
|
12
|
+
describe('getIndex', () => {
|
13
|
+
it('should return a valid sitemap index', () => {
|
14
|
+
const index = sitemap.getIndex();
|
15
|
+
expect(index).toContain('<?xml version="1.0" encoding="UTF-8"?>');
|
16
|
+
expect(index).toContain('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">');
|
17
|
+
[
|
18
|
+
SitemapType.Pages,
|
19
|
+
SitemapType.Assistants,
|
20
|
+
SitemapType.Plugins,
|
21
|
+
SitemapType.Models,
|
22
|
+
SitemapType.Providers,
|
23
|
+
].forEach((type) => {
|
24
|
+
expect(index).toContain(`<loc>${getCanonicalUrl(`/sitemap/${type}.xml`)}</loc>`);
|
25
|
+
});
|
26
|
+
expect(index).toContain(`<lastmod>${LAST_MODIFIED}</lastmod>`);
|
27
|
+
});
|
28
|
+
});
|
29
|
+
|
30
|
+
describe('getPage', () => {
|
31
|
+
it('should return a valid page sitemap', async () => {
|
32
|
+
const pageSitemap = await sitemap.getPage();
|
33
|
+
expect(pageSitemap).toContainEqual(
|
34
|
+
expect.objectContaining({
|
35
|
+
url: getCanonicalUrl('/'),
|
36
|
+
changeFrequency: 'monthly',
|
37
|
+
priority: 0.4,
|
38
|
+
}),
|
39
|
+
);
|
40
|
+
expect(pageSitemap).toContainEqual(
|
41
|
+
expect.objectContaining({
|
42
|
+
url: getCanonicalUrl('/discover'),
|
43
|
+
changeFrequency: 'daily',
|
44
|
+
priority: 0.7,
|
45
|
+
}),
|
46
|
+
);
|
47
|
+
Object.values(AssistantCategory).forEach((category) => {
|
48
|
+
expect(pageSitemap).toContainEqual(
|
49
|
+
expect.objectContaining({
|
50
|
+
url: getCanonicalUrl(`/discover/assistants/${category}`),
|
51
|
+
changeFrequency: 'daily',
|
52
|
+
priority: 0.7,
|
53
|
+
}),
|
54
|
+
);
|
55
|
+
});
|
56
|
+
Object.values(PluginCategory).forEach((category) => {
|
57
|
+
expect(pageSitemap).toContainEqual(
|
58
|
+
expect.objectContaining({
|
59
|
+
url: getCanonicalUrl(`/discover/plugins/${category}`),
|
60
|
+
changeFrequency: 'daily',
|
61
|
+
priority: 0.7,
|
62
|
+
}),
|
63
|
+
);
|
64
|
+
});
|
65
|
+
});
|
66
|
+
});
|
67
|
+
|
68
|
+
describe('getAssistants', () => {
|
69
|
+
it('should return a valid assistants sitemap', async () => {
|
70
|
+
vi.spyOn(sitemap['discoverService'], 'getAssistantList').mockResolvedValue([
|
71
|
+
// @ts-ignore
|
72
|
+
{ identifier: 'test-assistant', createdAt: '2023-01-01' },
|
73
|
+
]);
|
74
|
+
|
75
|
+
const assistantsSitemap = await sitemap.getAssistants();
|
76
|
+
expect(assistantsSitemap.length).toBe(14);
|
77
|
+
expect(assistantsSitemap).toContainEqual(
|
78
|
+
expect.objectContaining({
|
79
|
+
url: getCanonicalUrl('/discover/assistant/test-assistant'),
|
80
|
+
lastModified: '2023-01-01T00:00:00.000Z',
|
81
|
+
}),
|
82
|
+
);
|
83
|
+
expect(assistantsSitemap).toContainEqual(
|
84
|
+
expect.objectContaining({
|
85
|
+
url: getCanonicalUrl('/discover/assistant/test-assistant?hl=zh-CN'),
|
86
|
+
lastModified: '2023-01-01T00:00:00.000Z',
|
87
|
+
}),
|
88
|
+
);
|
89
|
+
});
|
90
|
+
});
|
91
|
+
|
92
|
+
describe('getPlugins', () => {
|
93
|
+
it('should return a valid plugins sitemap', async () => {
|
94
|
+
vi.spyOn(sitemap['discoverService'], 'getPluginList').mockResolvedValue([
|
95
|
+
// @ts-ignore
|
96
|
+
{ identifier: 'test-plugin', createdAt: '2023-01-01' },
|
97
|
+
]);
|
98
|
+
|
99
|
+
const pluginsSitemap = await sitemap.getPlugins();
|
100
|
+
expect(pluginsSitemap.length).toBe(14);
|
101
|
+
expect(pluginsSitemap).toContainEqual(
|
102
|
+
expect.objectContaining({
|
103
|
+
url: getCanonicalUrl('/discover/plugin/test-plugin'),
|
104
|
+
lastModified: '2023-01-01T00:00:00.000Z',
|
105
|
+
}),
|
106
|
+
);
|
107
|
+
expect(pluginsSitemap).toContainEqual(
|
108
|
+
expect.objectContaining({
|
109
|
+
url: getCanonicalUrl('/discover/plugin/test-plugin?hl=ja-JP'),
|
110
|
+
lastModified: '2023-01-01T00:00:00.000Z',
|
111
|
+
}),
|
112
|
+
);
|
113
|
+
});
|
114
|
+
});
|
115
|
+
|
116
|
+
describe('getModels', () => {
|
117
|
+
it('should return a valid models sitemap', async () => {
|
118
|
+
vi.spyOn(sitemap['discoverService'], 'getModelList').mockResolvedValue([
|
119
|
+
// @ts-ignore
|
120
|
+
{ identifier: 'test:model', createdAt: '2023-01-01' },
|
121
|
+
]);
|
122
|
+
|
123
|
+
const modelsSitemap = await sitemap.getModels();
|
124
|
+
expect(modelsSitemap.length).toBe(14);
|
125
|
+
expect(modelsSitemap).toContainEqual(
|
126
|
+
expect.objectContaining({
|
127
|
+
url: getCanonicalUrl('/discover/model/test:model'),
|
128
|
+
lastModified: '2023-01-01T00:00:00.000Z',
|
129
|
+
}),
|
130
|
+
);
|
131
|
+
expect(modelsSitemap).toContainEqual(
|
132
|
+
expect.objectContaining({
|
133
|
+
url: getCanonicalUrl('/discover/model/test:model?hl=ko-KR'),
|
134
|
+
lastModified: '2023-01-01T00:00:00.000Z',
|
135
|
+
}),
|
136
|
+
);
|
137
|
+
});
|
138
|
+
});
|
139
|
+
|
140
|
+
describe('getProviders', () => {
|
141
|
+
it('should return a valid providers sitemap', async () => {
|
142
|
+
vi.spyOn(sitemap['discoverService'], 'getProviderList').mockResolvedValue([
|
143
|
+
// @ts-ignore
|
144
|
+
{ identifier: 'test-provider', createdAt: '2023-01-01' },
|
145
|
+
]);
|
146
|
+
|
147
|
+
const providersSitemap = await sitemap.getProviders();
|
148
|
+
expect(providersSitemap.length).toBe(14);
|
149
|
+
expect(providersSitemap).toContainEqual(
|
150
|
+
expect.objectContaining({
|
151
|
+
url: getCanonicalUrl('/discover/provider/test-provider'),
|
152
|
+
lastModified: '2023-01-01T00:00:00.000Z',
|
153
|
+
}),
|
154
|
+
);
|
155
|
+
expect(providersSitemap).toContainEqual(
|
156
|
+
expect.objectContaining({
|
157
|
+
url: getCanonicalUrl('/discover/provider/test-provider?hl=ar'),
|
158
|
+
lastModified: '2023-01-01T00:00:00.000Z',
|
159
|
+
}),
|
160
|
+
);
|
161
|
+
});
|
162
|
+
});
|
163
|
+
|
164
|
+
describe('getRobots', () => {
|
165
|
+
it('should return correct robots.txt entries', () => {
|
166
|
+
const robots = sitemap.getRobots();
|
167
|
+
expect(robots).toContain(getCanonicalUrl('/sitemap-index.xml'));
|
168
|
+
[
|
169
|
+
SitemapType.Pages,
|
170
|
+
SitemapType.Assistants,
|
171
|
+
SitemapType.Plugins,
|
172
|
+
SitemapType.Models,
|
173
|
+
SitemapType.Providers,
|
174
|
+
].forEach((type) => {
|
175
|
+
expect(robots).toContain(getCanonicalUrl(`/sitemap/${type}.xml`));
|
176
|
+
});
|
177
|
+
});
|
178
|
+
});
|
179
|
+
});
|
@@ -0,0 +1,243 @@
|
|
1
|
+
import { flatten } from 'lodash-es';
|
2
|
+
import { MetadataRoute } from 'next';
|
3
|
+
import qs from 'query-string';
|
4
|
+
import urlJoin from 'url-join';
|
5
|
+
|
6
|
+
import { DEFAULT_LANG } from '@/const/locale';
|
7
|
+
import { SITEMAP_BASE_URL } from '@/const/url';
|
8
|
+
import { Locales, locales as allLocales } from '@/locales/resources';
|
9
|
+
import { DiscoverService } from '@/server/services/discover';
|
10
|
+
import { getCanonicalUrl } from '@/server/utils/url';
|
11
|
+
import { AssistantCategory, PluginCategory } from '@/types/discover';
|
12
|
+
import { isDev } from '@/utils/env';
|
13
|
+
|
14
|
+
export interface SitemapItem {
|
15
|
+
alternates?: {
|
16
|
+
languages?: string;
|
17
|
+
};
|
18
|
+
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
19
|
+
lastModified?: string | Date;
|
20
|
+
priority?: number;
|
21
|
+
url: string;
|
22
|
+
}
|
23
|
+
|
24
|
+
export enum SitemapType {
|
25
|
+
Assistants = 'assistants',
|
26
|
+
Models = 'models',
|
27
|
+
Pages = 'pages',
|
28
|
+
Plugins = 'plugins',
|
29
|
+
Providers = 'providers',
|
30
|
+
}
|
31
|
+
|
32
|
+
export const LAST_MODIFIED = new Date().toISOString();
|
33
|
+
|
34
|
+
export class Sitemap {
|
35
|
+
sitemapIndexs = [
|
36
|
+
{ id: SitemapType.Pages },
|
37
|
+
{ id: SitemapType.Assistants },
|
38
|
+
{ id: SitemapType.Plugins },
|
39
|
+
{ id: SitemapType.Models },
|
40
|
+
{ id: SitemapType.Providers },
|
41
|
+
];
|
42
|
+
|
43
|
+
private discoverService = new DiscoverService();
|
44
|
+
|
45
|
+
private _generateSitemapLink(url: string) {
|
46
|
+
return [
|
47
|
+
'<sitemap>',
|
48
|
+
`<loc>${url}</loc>`,
|
49
|
+
`<lastmod>${LAST_MODIFIED}</lastmod>`,
|
50
|
+
'</sitemap>',
|
51
|
+
].join('\n');
|
52
|
+
}
|
53
|
+
|
54
|
+
private _formatTime(time?: string) {
|
55
|
+
try {
|
56
|
+
if (!time) return LAST_MODIFIED;
|
57
|
+
return new Date(time).toISOString() || LAST_MODIFIED;
|
58
|
+
} catch {
|
59
|
+
return LAST_MODIFIED;
|
60
|
+
}
|
61
|
+
}
|
62
|
+
|
63
|
+
private _genSitemapItem = (
|
64
|
+
lang: Locales,
|
65
|
+
url: string,
|
66
|
+
{
|
67
|
+
lastModified,
|
68
|
+
changeFrequency = 'monthly',
|
69
|
+
priority = 0.4,
|
70
|
+
noLocales,
|
71
|
+
locales = allLocales,
|
72
|
+
}: {
|
73
|
+
changeFrequency?: SitemapItem['changeFrequency'];
|
74
|
+
lastModified?: string;
|
75
|
+
locales?: typeof allLocales;
|
76
|
+
noLocales?: boolean;
|
77
|
+
priority?: number;
|
78
|
+
} = {},
|
79
|
+
) => {
|
80
|
+
const sitemap = {
|
81
|
+
changeFrequency,
|
82
|
+
lastModified: this._formatTime(lastModified),
|
83
|
+
priority,
|
84
|
+
url:
|
85
|
+
lang === DEFAULT_LANG
|
86
|
+
? getCanonicalUrl(url)
|
87
|
+
: qs.stringifyUrl({ query: { hl: lang }, url: getCanonicalUrl(url) }),
|
88
|
+
};
|
89
|
+
if (noLocales) return sitemap;
|
90
|
+
|
91
|
+
const languages: any = {};
|
92
|
+
for (const locale of locales) {
|
93
|
+
if (locale === lang) continue;
|
94
|
+
languages[locale] = qs.stringifyUrl({
|
95
|
+
query: { hl: locale },
|
96
|
+
url: getCanonicalUrl(url),
|
97
|
+
});
|
98
|
+
}
|
99
|
+
return {
|
100
|
+
alternates: {
|
101
|
+
languages,
|
102
|
+
},
|
103
|
+
...sitemap,
|
104
|
+
};
|
105
|
+
};
|
106
|
+
|
107
|
+
private _genSitemap(
|
108
|
+
url: string,
|
109
|
+
{
|
110
|
+
lastModified,
|
111
|
+
changeFrequency = 'monthly',
|
112
|
+
priority = 0.4,
|
113
|
+
noLocales,
|
114
|
+
locales = allLocales,
|
115
|
+
}: {
|
116
|
+
changeFrequency?: SitemapItem['changeFrequency'];
|
117
|
+
lastModified?: string;
|
118
|
+
locales?: typeof allLocales;
|
119
|
+
noLocales?: boolean;
|
120
|
+
priority?: number;
|
121
|
+
} = {},
|
122
|
+
) {
|
123
|
+
if (noLocales)
|
124
|
+
return [
|
125
|
+
this._genSitemapItem(DEFAULT_LANG, url, {
|
126
|
+
changeFrequency,
|
127
|
+
lastModified,
|
128
|
+
locales,
|
129
|
+
noLocales,
|
130
|
+
priority,
|
131
|
+
}),
|
132
|
+
];
|
133
|
+
return locales.map((lang) =>
|
134
|
+
this._genSitemapItem(lang, url, {
|
135
|
+
changeFrequency,
|
136
|
+
lastModified,
|
137
|
+
locales,
|
138
|
+
noLocales,
|
139
|
+
priority,
|
140
|
+
}),
|
141
|
+
);
|
142
|
+
}
|
143
|
+
|
144
|
+
getIndex(): string {
|
145
|
+
return [
|
146
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
147
|
+
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
148
|
+
...this.sitemapIndexs.map((item) =>
|
149
|
+
this._generateSitemapLink(
|
150
|
+
getCanonicalUrl(SITEMAP_BASE_URL, isDev ? item.id : `${item.id}.xml`),
|
151
|
+
),
|
152
|
+
),
|
153
|
+
'</sitemapindex>',
|
154
|
+
].join('\n');
|
155
|
+
}
|
156
|
+
|
157
|
+
async getAssistants(): Promise<MetadataRoute.Sitemap> {
|
158
|
+
const list = await this.discoverService.getAssistantList(DEFAULT_LANG);
|
159
|
+
const sitmap = list.map((item) =>
|
160
|
+
this._genSitemap(urlJoin('/discover/assistant', item.identifier), {
|
161
|
+
lastModified: item?.createdAt || LAST_MODIFIED,
|
162
|
+
}),
|
163
|
+
);
|
164
|
+
return flatten(sitmap);
|
165
|
+
}
|
166
|
+
|
167
|
+
async getPlugins(): Promise<MetadataRoute.Sitemap> {
|
168
|
+
const list = await this.discoverService.getPluginList(DEFAULT_LANG);
|
169
|
+
const sitmap = list.map((item) =>
|
170
|
+
this._genSitemap(urlJoin('/discover/plugin', item.identifier), {
|
171
|
+
lastModified: item?.createdAt || LAST_MODIFIED,
|
172
|
+
}),
|
173
|
+
);
|
174
|
+
return flatten(sitmap);
|
175
|
+
}
|
176
|
+
|
177
|
+
async getModels(): Promise<MetadataRoute.Sitemap> {
|
178
|
+
const list = await this.discoverService.getModelList(DEFAULT_LANG);
|
179
|
+
const sitmap = list.map((item) =>
|
180
|
+
this._genSitemap(urlJoin('/discover/model', item.identifier), {
|
181
|
+
lastModified: item?.createdAt || LAST_MODIFIED,
|
182
|
+
}),
|
183
|
+
);
|
184
|
+
return flatten(sitmap);
|
185
|
+
}
|
186
|
+
|
187
|
+
async getProviders(): Promise<MetadataRoute.Sitemap> {
|
188
|
+
const list = await this.discoverService.getProviderList(DEFAULT_LANG);
|
189
|
+
const sitmap = list.map((item) =>
|
190
|
+
this._genSitemap(urlJoin('/discover/provider', item.identifier), {
|
191
|
+
lastModified: item?.createdAt || LAST_MODIFIED,
|
192
|
+
}),
|
193
|
+
);
|
194
|
+
return flatten(sitmap);
|
195
|
+
}
|
196
|
+
|
197
|
+
async getPage(): Promise<MetadataRoute.Sitemap> {
|
198
|
+
const assistantsCategory = Object.values(AssistantCategory);
|
199
|
+
const pluginCategory = Object.values(PluginCategory);
|
200
|
+
const modelCategory = await this.discoverService.getProviderList(DEFAULT_LANG);
|
201
|
+
return [
|
202
|
+
...this._genSitemap('/', { noLocales: true }),
|
203
|
+
...this._genSitemap('/chat', { noLocales: true }),
|
204
|
+
...this._genSitemap('/welcome', { noLocales: true }),
|
205
|
+
/* ↓ cloud slot ↓ */
|
206
|
+
|
207
|
+
/* ↑ cloud slot ↑ */
|
208
|
+
...this._genSitemap('/discover', { changeFrequency: 'daily', priority: 0.7 }),
|
209
|
+
...this._genSitemap('/discover/assistants', { changeFrequency: 'daily', priority: 0.7 }),
|
210
|
+
...assistantsCategory.flatMap((slug) =>
|
211
|
+
this._genSitemap(`/discover/assistants/${slug}`, {
|
212
|
+
changeFrequency: 'daily',
|
213
|
+
priority: 0.7,
|
214
|
+
}),
|
215
|
+
),
|
216
|
+
...this._genSitemap('/discover/plugins', { changeFrequency: 'daily', priority: 0.7 }),
|
217
|
+
...pluginCategory.flatMap((slug) =>
|
218
|
+
this._genSitemap(`/discover/plugins/${slug}`, {
|
219
|
+
changeFrequency: 'daily',
|
220
|
+
priority: 0.7,
|
221
|
+
}),
|
222
|
+
),
|
223
|
+
...this._genSitemap('/discover/models', { changeFrequency: 'daily', priority: 0.7 }),
|
224
|
+
...modelCategory.flatMap((slug) =>
|
225
|
+
this._genSitemap(`/discover/models/${slug}`, {
|
226
|
+
changeFrequency: 'daily',
|
227
|
+
priority: 0.7,
|
228
|
+
}),
|
229
|
+
),
|
230
|
+
...this._genSitemap('/discover/providers', { changeFrequency: 'daily', priority: 0.7 }),
|
231
|
+
];
|
232
|
+
}
|
233
|
+
getRobots() {
|
234
|
+
return [
|
235
|
+
getCanonicalUrl('/sitemap-index.xml'),
|
236
|
+
...this.sitemapIndexs.map((index) =>
|
237
|
+
getCanonicalUrl(SITEMAP_BASE_URL, isDev ? index.id : `${index.id}.xml`),
|
238
|
+
),
|
239
|
+
];
|
240
|
+
}
|
241
|
+
}
|
242
|
+
|
243
|
+
export const sitemapModule = new Sitemap();
|
@@ -0,0 +1,137 @@
|
|
1
|
+
// @vitest-environment node
|
2
|
+
import { cookies } from 'next/headers';
|
3
|
+
import * as fs from 'node:fs';
|
4
|
+
import * as path from 'node:path';
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
6
|
+
|
7
|
+
import { DEFAULT_LANG, LOBE_LOCALE_COOKIE } from '@/const/locale';
|
8
|
+
import { normalizeLocale } from '@/locales/resources';
|
9
|
+
import * as env from '@/utils/env';
|
10
|
+
|
11
|
+
import { getLocale, translation } from './translation';
|
12
|
+
|
13
|
+
// Mock external dependencies
|
14
|
+
vi.mock('next/headers', () => ({
|
15
|
+
cookies: vi.fn(),
|
16
|
+
}));
|
17
|
+
|
18
|
+
vi.mock('node:fs', () => ({
|
19
|
+
existsSync: vi.fn(),
|
20
|
+
readFileSync: vi.fn(),
|
21
|
+
}));
|
22
|
+
|
23
|
+
vi.mock('node:path', () => ({
|
24
|
+
join: vi.fn(),
|
25
|
+
}));
|
26
|
+
|
27
|
+
vi.mock('@/const/locale', () => ({
|
28
|
+
DEFAULT_LANG: 'en-US',
|
29
|
+
LOBE_LOCALE_COOKIE: 'LOBE_LOCALE',
|
30
|
+
}));
|
31
|
+
|
32
|
+
vi.mock('@/locales/resources', () => ({
|
33
|
+
normalizeLocale: vi.fn((locale) => locale),
|
34
|
+
}));
|
35
|
+
|
36
|
+
vi.mock('@/utils/env', () => ({
|
37
|
+
isDev: false,
|
38
|
+
}));
|
39
|
+
|
40
|
+
describe('getLocale', () => {
|
41
|
+
const mockCookieStore = {
|
42
|
+
get: vi.fn(),
|
43
|
+
};
|
44
|
+
|
45
|
+
beforeEach(() => {
|
46
|
+
vi.clearAllMocks();
|
47
|
+
(cookies as any).mockReturnValue(mockCookieStore);
|
48
|
+
});
|
49
|
+
|
50
|
+
it('should return the provided locale if hl is specified', async () => {
|
51
|
+
const result = await getLocale('fr-FR');
|
52
|
+
expect(result).toBe('fr-FR');
|
53
|
+
expect(normalizeLocale).toHaveBeenCalledWith('fr-FR');
|
54
|
+
});
|
55
|
+
|
56
|
+
it('should return the locale from cookie if available', async () => {
|
57
|
+
mockCookieStore.get.mockReturnValue({ value: 'de-DE' });
|
58
|
+
const result = await getLocale();
|
59
|
+
expect(result).toBe('de-DE');
|
60
|
+
expect(mockCookieStore.get).toHaveBeenCalledWith(LOBE_LOCALE_COOKIE);
|
61
|
+
});
|
62
|
+
|
63
|
+
it('should return DEFAULT_LANG if no cookie is set', async () => {
|
64
|
+
mockCookieStore.get.mockReturnValue(undefined);
|
65
|
+
const result = await getLocale();
|
66
|
+
expect(result).toBe(DEFAULT_LANG);
|
67
|
+
});
|
68
|
+
});
|
69
|
+
|
70
|
+
describe('translation', () => {
|
71
|
+
const mockTranslations = {
|
72
|
+
key1: 'Value 1',
|
73
|
+
key2: 'Value 2 with {{param}}',
|
74
|
+
nested: { key: 'Nested value' },
|
75
|
+
};
|
76
|
+
|
77
|
+
beforeEach(() => {
|
78
|
+
vi.clearAllMocks();
|
79
|
+
(fs.existsSync as any).mockReturnValue(true);
|
80
|
+
(fs.readFileSync as any).mockReturnValue(JSON.stringify(mockTranslations));
|
81
|
+
(path.join as any).mockImplementation((...args: any) => args.join('/'));
|
82
|
+
});
|
83
|
+
|
84
|
+
it('should return correct translation object', async () => {
|
85
|
+
const result = await translation('common', 'en-US');
|
86
|
+
expect(result).toHaveProperty('locale', 'en-US');
|
87
|
+
expect(result).toHaveProperty('t');
|
88
|
+
expect(typeof result.t).toBe('function');
|
89
|
+
});
|
90
|
+
|
91
|
+
it('should translate keys correctly', async () => {
|
92
|
+
const { t } = await translation('common', 'en-US');
|
93
|
+
expect(t('key1')).toBe('Value 1');
|
94
|
+
expect(t('key2', { param: 'test' })).toBe('Value 2 with test');
|
95
|
+
expect(t('nested.key')).toBe('Nested value');
|
96
|
+
});
|
97
|
+
|
98
|
+
it('should return key if translation is not found', async () => {
|
99
|
+
const { t } = await translation('common', 'en-US');
|
100
|
+
expect(t('nonexistent.key')).toBe('nonexistent.key');
|
101
|
+
});
|
102
|
+
|
103
|
+
it('should use fallback language if specified locale file does not exist', async () => {
|
104
|
+
(fs.existsSync as any).mockReturnValueOnce(false);
|
105
|
+
await translation('common', 'nonexistent-LANG');
|
106
|
+
expect(fs.readFileSync).toHaveBeenCalledWith(
|
107
|
+
expect.stringContaining(`/${DEFAULT_LANG}/common.json`),
|
108
|
+
'utf8',
|
109
|
+
);
|
110
|
+
});
|
111
|
+
|
112
|
+
it('should use zh-CN in dev mode when fallback is needed', async () => {
|
113
|
+
(fs.existsSync as any).mockReturnValueOnce(false);
|
114
|
+
(env.isDev as unknown as boolean) = true;
|
115
|
+
await translation('common', 'nonexistent-LANG');
|
116
|
+
expect(fs.readFileSync).toHaveBeenCalledWith(
|
117
|
+
expect.stringContaining('/zh-CN/common.json'),
|
118
|
+
'utf8',
|
119
|
+
);
|
120
|
+
});
|
121
|
+
|
122
|
+
it('should handle file reading errors', async () => {
|
123
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
124
|
+
(fs.readFileSync as any).mockImplementation(() => {
|
125
|
+
throw new Error('File read error');
|
126
|
+
});
|
127
|
+
|
128
|
+
const result = await translation('common', 'en-US');
|
129
|
+
expect(result.t('any.key')).toBe('any.key');
|
130
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
131
|
+
'Error while reading translation file',
|
132
|
+
expect.any(Error),
|
133
|
+
);
|
134
|
+
|
135
|
+
consoleErrorSpy.mockRestore();
|
136
|
+
});
|
137
|
+
});
|
@@ -0,0 +1,61 @@
|
|
1
|
+
// @vitest-environment node
|
2
|
+
import urlJoin from 'url-join';
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
4
|
+
|
5
|
+
// 模拟 urlJoin 函数
|
6
|
+
vi.mock('url-join', () => ({
|
7
|
+
default: vi.fn((...args) => args.join('/')),
|
8
|
+
}));
|
9
|
+
|
10
|
+
describe('getCanonicalUrl', () => {
|
11
|
+
const originalEnv = process.env;
|
12
|
+
|
13
|
+
beforeEach(() => {
|
14
|
+
// 在每个测试前重置 process.env
|
15
|
+
vi.resetModules();
|
16
|
+
process.env = { ...originalEnv };
|
17
|
+
});
|
18
|
+
|
19
|
+
afterEach(() => {
|
20
|
+
// 在每个测试后恢复原始的 process.env
|
21
|
+
process.env = originalEnv;
|
22
|
+
});
|
23
|
+
|
24
|
+
it('should return correct URL for production environment', async () => {
|
25
|
+
process.env.VERCEL = undefined;
|
26
|
+
process.env.VERCEL_ENV = undefined;
|
27
|
+
|
28
|
+
const { getCanonicalUrl } = await import('./url'); // 动态导入以获取最新的环境变量状态
|
29
|
+
const result = getCanonicalUrl('path', 'to', 'page');
|
30
|
+
expect(result).toBe('https://lobechat.com/path/to/page');
|
31
|
+
expect(urlJoin).toHaveBeenCalledWith('https://lobechat.com', 'path', 'to', 'page');
|
32
|
+
});
|
33
|
+
|
34
|
+
it('should return correct URL for Vercel preview environment', async () => {
|
35
|
+
process.env.VERCEL = '1';
|
36
|
+
process.env.VERCEL_ENV = 'preview';
|
37
|
+
process.env.VERCEL_URL = 'preview-url.vercel.app';
|
38
|
+
|
39
|
+
const { getCanonicalUrl } = await import('./url'); // 动态导入
|
40
|
+
const result = getCanonicalUrl('path', 'to', 'page');
|
41
|
+
expect(result).toBe('https://preview-url.vercel.app/path/to/page');
|
42
|
+
expect(urlJoin).toHaveBeenCalledWith('https://preview-url.vercel.app', 'path', 'to', 'page');
|
43
|
+
});
|
44
|
+
|
45
|
+
it('should return production URL when VERCEL is set but VERCEL_ENV is production', async () => {
|
46
|
+
process.env.VERCEL = '1';
|
47
|
+
process.env.VERCEL_ENV = 'production';
|
48
|
+
|
49
|
+
const { getCanonicalUrl } = await import('./url'); // 动态导入
|
50
|
+
const result = getCanonicalUrl('path', 'to', 'page');
|
51
|
+
expect(result).toBe('https://lobechat.com/path/to/page');
|
52
|
+
expect(urlJoin).toHaveBeenCalledWith('https://lobechat.com', 'path', 'to', 'page');
|
53
|
+
});
|
54
|
+
|
55
|
+
it('should work correctly without additional path arguments', async () => {
|
56
|
+
const { getCanonicalUrl } = await import('./url'); // 动态导入
|
57
|
+
const result = getCanonicalUrl();
|
58
|
+
expect(result).toBe('https://lobechat.com');
|
59
|
+
expect(urlJoin).toHaveBeenCalledWith('https://lobechat.com');
|
60
|
+
});
|
61
|
+
});
|
@@ -0,0 +1,9 @@
|
|
1
|
+
import urlJoin from 'url-join';
|
2
|
+
|
3
|
+
const isVercelPreview = process.env.VERCEL === '1' && process.env.VERCEL_ENV !== 'production';
|
4
|
+
|
5
|
+
const vercelPreviewUrl = `https://${process.env.VERCEL_URL}`;
|
6
|
+
|
7
|
+
const siteUrl = isVercelPreview ? vercelPreviewUrl : 'https://lobechat.com';
|
8
|
+
|
9
|
+
export const getCanonicalUrl = (...paths: string[]) => urlJoin(siteUrl, ...paths);
|
package/next-sitemap.config.mjs
DELETED
@@ -1,53 +0,0 @@
|
|
1
|
-
import { glob } from 'glob';
|
2
|
-
|
3
|
-
const isVercelPreview = process.env.VERCEL === '1' && process.env.VERCEL_ENV !== 'production';
|
4
|
-
|
5
|
-
const vercelPreviewUrl = `https://${process.env.VERCEL_URL}`;
|
6
|
-
|
7
|
-
const siteUrl = isVercelPreview ? vercelPreviewUrl : 'https://lobechat.com';
|
8
|
-
|
9
|
-
/** @type {import('next-sitemap').IConfig} */
|
10
|
-
const config = {
|
11
|
-
// next-sitemap does not work with app dir inside the /src dir (and have other problems e.g. with route groups)
|
12
|
-
// https://github.com/iamvishnusankar/next-sitemap/issues/700#issuecomment-1759458127
|
13
|
-
// https://github.com/iamvishnusankar/next-sitemap/issues/701
|
14
|
-
// additionalPaths is a workaround for this (once the issues are fixed, we can remove it)
|
15
|
-
additionalPaths: async () => {
|
16
|
-
const routes = await glob('src/app/**/page.{md,mdx,ts,tsx}', {
|
17
|
-
cwd: new URL('.', import.meta.url).pathname,
|
18
|
-
});
|
19
|
-
|
20
|
-
// https://nextjs.org/docs/app/building-your-application/routing/colocation#private-folders
|
21
|
-
const publicRoutes = routes.filter(
|
22
|
-
(page) => !page.split('/').some((folder) => folder.startsWith('_')),
|
23
|
-
);
|
24
|
-
|
25
|
-
// https://nextjs.org/docs/app/building-your-application/routing/colocation#route-groups
|
26
|
-
const publicRoutesWithoutRouteGroups = publicRoutes.map((page) =>
|
27
|
-
page
|
28
|
-
.split('/')
|
29
|
-
.filter((folder) => !folder.startsWith('(') && !folder.endsWith(')'))
|
30
|
-
.join('/'),
|
31
|
-
);
|
32
|
-
|
33
|
-
const locs = publicRoutesWithoutRouteGroups.map((route) => {
|
34
|
-
const path = route.replace(/^src\/app/, '').replace(/\/[^/]+$/, '');
|
35
|
-
const loc = path === '' ? siteUrl : `${siteUrl}/${path}`;
|
36
|
-
|
37
|
-
return loc;
|
38
|
-
});
|
39
|
-
|
40
|
-
const paths = locs.map((loc) => ({
|
41
|
-
changefreq: 'daily',
|
42
|
-
lastmod: new Date().toISOString(),
|
43
|
-
loc,
|
44
|
-
priority: 0.7,
|
45
|
-
}));
|
46
|
-
|
47
|
-
return paths;
|
48
|
-
},
|
49
|
-
generateRobotsTxt: true,
|
50
|
-
siteUrl,
|
51
|
-
};
|
52
|
-
|
53
|
-
export default config;
|