Compare commits

..

22 Commits

Author SHA1 Message Date
Tim
afa0c7fb8f test: update PostServiceTest for redis template 2025-09-09 15:32:03 +08:00
Tim
1852f87341 Merge pull request #951 from nagisa77/codex/update-openapi-servers-configuration
feat: allow configuring multiple OpenAPI servers
2025-09-09 15:03:43 +08:00
Tim
7010e8a058 feat: allow configuring multiple openapi servers 2025-09-09 15:03:25 +08:00
Tim
38ee37d5be Merge pull request #946 from smallclover/main 2025-09-09 14:29:06 +08:00
Tim
e398d8e989 Merge pull request #949 from nagisa77/codex/remove-/docs/-prefix-from-url-uh7skh
feat(docs): remove /docs URL prefix
2025-09-09 14:03:20 +08:00
Tim
85e77c265e feat(docs): remove /docs prefix 2025-09-09 14:03:04 +08:00
tim
8abdc73497 Revert "feat(docs): remove path prefix"
This reverts commit 09cefbedbf.
2025-09-09 14:02:23 +08:00
Tim
747d9c07d1 Merge pull request #948 from nagisa77/codex/remove-/docs/-prefix-from-url-3n0gdr
feat(docs): serve documentation from root
2025-09-09 13:48:51 +08:00
Tim
09cefbedbf feat(docs): remove path prefix 2025-09-09 13:48:26 +08:00
tim
d772bc182f fix: 允许自建OpenAPI地址 2025-09-09 13:46:25 +08:00
tim
358c53338d Revert "fix: 新增检查"
This reverts commit 1cd89eaa54.
2025-09-09 13:23:30 +08:00
wangshun
2110980797 控制用户发帖频率 2025-09-09 13:23:14 +08:00
tim
1cd89eaa54 fix: 新增检查 2025-09-09 13:16:52 +08:00
tim
1d2e7eb96e Revert "Update deploy-docs.yml"
This reverts commit 4428e06f1d.
2025-09-09 13:10:46 +08:00
Tim
4428e06f1d Update deploy-docs.yml 2025-09-09 13:03:08 +08:00
Tim
dddff54556 Update README.md 2025-09-09 12:18:10 +08:00
Tim
e7f7bbac22 Update README.md 2025-09-09 12:17:49 +08:00
Tim
37aae4ba5c Update README.md 2025-09-09 12:17:24 +08:00
Tim
54cfc98336 Merge pull request #945 from nagisa77/codex/fix-server-url-in-api-docs
Add configurable OpenAPI server URL
2025-09-09 12:12:41 +08:00
Tim
d42d38ff7a Add configurable OpenAPI server URL 2025-09-09 12:12:10 +08:00
Tim
2b4601bd4b Update CONTRIBUTING.md 2025-09-09 11:56:15 +08:00
Tim
5071d9c6d5 Merge pull request #944 from nagisa77/codex/fix-api-docs-base-url-to-use-https
docs: use https for OpenAPI base URL
2025-09-09 11:48:53 +08:00
17 changed files with 114 additions and 35 deletions

View File

@@ -246,3 +246,9 @@ https://resend.com/emails 创建账号并登录
`RESEND_FROM_EMAIL` **noreply@域名** `RESEND_FROM_EMAIL` **noreply@域名**
`RESEND_API_KEY`**刚刚复制的 Key** `RESEND_API_KEY`**刚刚复制的 Key**
![image-20250906151218330](assets/contributing/image-20250906151218330.png) ![image-20250906151218330](assets/contributing/image-20250906151218330.png)
## 开源共建和API文档
- API文档: https://openisle-docs.netlify.app/docs/openapi

View File

@@ -4,6 +4,8 @@
高效的开源社区前后端平台 高效的开源社区前后端平台
<br><br><br> <br><br><br>
<img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200"> <img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
<br><br><br>
<a href="https://hellogithub.com/repository/nagisa77/OpenIsle" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8605546658d94cbab45182af2a02e4c8&claim_uid=p5GNFTtZl6HBAYQ" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</p> </p>
## 💡 简介 ## 💡 简介

View File

@@ -42,6 +42,8 @@ public class CachingConfig {
public static final String ONLINE_CACHE_NAME="openisle_online"; public static final String ONLINE_CACHE_NAME="openisle_online";
// 注册验证码 // 注册验证码
public static final String VERIFY_CACHE_NAME="openisle_verify"; public static final String VERIFY_CACHE_NAME="openisle_verify";
// 发帖频率限制
public static final String LIMIT_CACHE_NAME="openisle_limit";
/** /**
* 自定义Redis的序列化器 * 自定义Redis的序列化器

View File

@@ -5,13 +5,21 @@ import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@Configuration @Configuration
@RequiredArgsConstructor
public class OpenApiConfig { public class OpenApiConfig {
private final SpringDocProperties springDocProperties;
@Value("${springdoc.info.title}") @Value("${springdoc.info.title}")
private String title; private String title;
@@ -30,19 +38,23 @@ public class OpenApiConfig {
@Bean @Bean
public OpenAPI openAPI() { public OpenAPI openAPI() {
SecurityScheme securityScheme = new SecurityScheme() SecurityScheme securityScheme = new SecurityScheme()
.type(SecurityScheme.Type.HTTP) .type(SecurityScheme.Type.HTTP)
.scheme(scheme.toLowerCase()) .scheme(scheme.toLowerCase())
.bearerFormat("JWT") .bearerFormat("JWT")
.in(SecurityScheme.In.HEADER) .in(SecurityScheme.In.HEADER)
.name(header); .name(header);
List<Server> servers = springDocProperties.getServers().stream()
.map(s -> new Server().url(s.getUrl()).description(s.getDescription()))
.collect(Collectors.toList());
return new OpenAPI() return new OpenAPI()
.servers(servers)
.info(new Info() .info(new Info()
.title(title) .title(title)
.description(description) .description(description)
.version(version)) .version(version))
.components(new Components() .components(new Components().addSecuritySchemes("JWT", securityScheme))
.addSecuritySchemes("JWT", securityScheme))
.addSecurityItem(new SecurityRequirement().addList("JWT")); .addSecurityItem(new SecurityRequirement().addList("JWT"));
} }
} }

View File

@@ -90,6 +90,9 @@ public class SecurityConfig {
"http://192.168.7.98", "http://192.168.7.98",
"http://192.168.7.98:3000", "http://192.168.7.98:3000",
"https://petstore.swagger.io", "https://petstore.swagger.io",
// 允许自建OpenAPI地址
"https://docs.open-isle.com",
"https://www.docs.open-isle.com",
websiteUrl, websiteUrl,
websiteUrl.replace("://www.", "://") websiteUrl.replace("://www.", "://")
)); ));

View File

@@ -0,0 +1,20 @@
package com.openisle.config;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "springdoc.api-docs")
public class SpringDocProperties {
private List<ServerConfig> servers = new ArrayList<>();
@Data
public static class ServerConfig {
private String url;
private String description;
}
}

View File

@@ -1,5 +1,6 @@
package com.openisle.service; package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.Post; import com.openisle.model.Post;
import com.openisle.model.PostStatus; import com.openisle.model.PostStatus;
import com.openisle.model.PostType; import com.openisle.model.PostType;
@@ -28,12 +29,15 @@ import com.openisle.repository.PollVoteRepository;
import com.openisle.model.Role; import com.openisle.model.Role;
import com.openisle.exception.RateLimitException; import com.openisle.exception.RateLimitException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.TaskScheduler;
import com.openisle.service.EmailSender; import com.openisle.service.EmailSender;
import java.time.Duration;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.*; import java.util.*;
@@ -80,6 +84,8 @@ public class PostService {
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl; private String websiteUrl;
private final RedisTemplate redisTemplate;
@org.springframework.beans.factory.annotation.Autowired @org.springframework.beans.factory.annotation.Autowired
public PostService(PostRepository postRepository, public PostService(PostRepository postRepository,
UserRepository userRepository, UserRepository userRepository,
@@ -102,7 +108,8 @@ public class PostService {
ApplicationContext applicationContext, ApplicationContext applicationContext,
PointService pointService, PointService pointService,
PostChangeLogService postChangeLogService, PostChangeLogService postChangeLogService,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
RedisTemplate redisTemplate) {
this.postRepository = postRepository; this.postRepository = postRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
@@ -125,6 +132,8 @@ public class PostService {
this.pointService = pointService; this.pointService = pointService;
this.postChangeLogService = postChangeLogService; this.postChangeLogService = postChangeLogService;
this.publishMode = publishMode; this.publishMode = publishMode;
this.redisTemplate = redisTemplate;
} }
@EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
@@ -201,9 +210,9 @@ public class PostService {
LocalDateTime endTime, LocalDateTime endTime,
java.util.List<String> options, java.util.List<String> options,
Boolean multiple) { Boolean multiple) {
long recent = postRepository.countByAuthorAfter(username, // 限制访问次数
java.time.LocalDateTime.now().minusMinutes(5)); boolean limitResult = postRateLimit(username);
if (recent >= 1) { if (!limitResult) {
throw new RateLimitException("Too many posts"); throw new RateLimitException("Too many posts");
} }
if (tagIds == null || tagIds.isEmpty()) { if (tagIds == null || tagIds.isEmpty()) {
@@ -300,6 +309,23 @@ public class PostService {
return post; return post;
} }
/**
* 限制发帖频率
* @param username
* @return
*/
private boolean postRateLimit(String username){
String key = CachingConfig.LIMIT_CACHE_NAME +":posts:"+username;
String result = (String)redisTemplate.opsForValue().get(key);
//最近没有创建过文章
if(StringUtils.isEmpty(result)){
// 限制频率为5分钟
redisTemplate.opsForValue().set(key,"1", Duration.ofMinutes(5));
return true;
}
return false;
}
public void joinLottery(Long postId, String username) { public void joinLottery(Long postId, String username) {
LotteryPost post = lotteryPostRepository.findById(postId) LotteryPost post = lotteryPostRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));

View File

@@ -108,7 +108,10 @@ rabbitmq.sharding.enabled=true
# see https://springdoc.org/#springdoc-openapi-core-properties # see https://springdoc.org/#springdoc-openapi-core-properties
springdoc.api-docs.path=/api/v3/api-docs springdoc.api-docs.path=/api/v3/api-docs
springdoc.api-docs.enabled=true springdoc.api-docs.enabled=true
springdoc.api-docs.server-url=${WEBSITE_URL:https://www.open-isle.com} springdoc.api-docs.servers[0].url=${WEBSITE_URL:https://www.open-isle.com}
springdoc.api-docs.servers[0].description=正式环境
springdoc.api-docs.servers[1].url=https://www.staging.open-isle.com
springdoc.api-docs.servers[1].description=预发环境
springdoc.info.title=OpenIsle springdoc.info.title=OpenIsle
springdoc.info.description=OpenIsle Open API Documentation springdoc.info.description=OpenIsle Open API Documentation
springdoc.info.version=0.0.1 springdoc.info.version=0.0.1

View File

@@ -6,6 +6,7 @@ import com.openisle.exception.RateLimitException;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.TaskScheduler;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.springframework.data.redis.core.RedisTemplate;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -38,11 +39,12 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post(); Post post = new Post();
@@ -88,11 +90,12 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post(); Post post = new Post();
@@ -144,11 +147,12 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L); when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L);
@@ -181,11 +185,12 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
User author = new User(); User author = new User();

View File

@@ -16,6 +16,6 @@ bun dev
使用以下路由: 使用以下路由:
- `docs/frontend/` 前端技术文档 - `frontend/` 前端技术文档
- `docs/backend/` 后端技术文档 - `backend/` 后端技术文档
- `docs/openapi/` 后端 API 文档 - `openapi/` 后端 API 文档

View File

@@ -19,7 +19,7 @@ function DocsCategory({ url }: { url: string }) {
); );
} }
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) { export default async function Page(props: PageProps<'/[[...slug]]'>) {
const params = await props.params; const params = await props.params;
const page = source.getPage(params.slug); const page = source.getPage(params.slug);
if (!page) notFound(); if (!page) notFound();
@@ -48,7 +48,7 @@ export async function generateStaticParams() {
} }
export async function generateMetadata( export async function generateMetadata(
props: PageProps<'/docs/[[...slug]]'> props: PageProps<'/[[...slug]]'>
): Promise<Metadata> { ): Promise<Metadata> {
const params = await props.params; const params = await props.params;
const page = source.getPage(params.slug); const page = source.getPage(params.slug);

View File

@@ -28,7 +28,7 @@ function TabTitle({ children }: { children: React.ReactNode }) {
return <span className="text-[11px]">{children}</span>; return <span className="text-[11px]">{children}</span>;
} }
export default function Layout({ children }: LayoutProps<'/docs'>) { export default function Layout({ children }: LayoutProps<'/'>) {
return ( return (
// @ts-ignore // @ts-ignore
<DocsLayout <DocsLayout
@@ -40,7 +40,7 @@ export default function Layout({ children }: LayoutProps<'/docs'>) {
{ {
title: 'OpenIsle 前端', title: 'OpenIsle 前端',
description: <TabTitle></TabTitle>, description: <TabTitle></TabTitle>,
url: '/docs/frontend', url: '/frontend',
icon: ( icon: (
<TabIcon color="#4ca154"> <TabIcon color="#4ca154">
<CompassIcon /> <CompassIcon />
@@ -50,7 +50,7 @@ export default function Layout({ children }: LayoutProps<'/docs'>) {
{ {
title: 'OpenIsle 后端', title: 'OpenIsle 后端',
description: <TabTitle></TabTitle>, description: <TabTitle></TabTitle>,
url: '/docs/backend', url: '/backend',
icon: ( icon: (
<TabIcon color="#1f66f4"> <TabIcon color="#1f66f4">
<ServerIcon /> <ServerIcon />
@@ -60,7 +60,7 @@ export default function Layout({ children }: LayoutProps<'/docs'>) {
{ {
title: 'OpenIsle API', title: 'OpenIsle API',
description: <TabTitle> API </TabTitle>, description: <TabTitle> API </TabTitle>,
url: '/docs/openapi', url: '/openapi',
icon: ( icon: (
<TabIcon color="#677489"> <TabIcon color="#677489">
<CodeXmlIcon /> <CodeXmlIcon />

View File

@@ -6,7 +6,7 @@ const inter = Inter({
subsets: ['latin'], subsets: ['latin'],
}); });
export default function Layout({ children }: LayoutProps<'/docs'>) { export default function Layout({ children }: LayoutProps<'/'>) {
return ( return (
<html lang="zh" className={inter.className} suppressHydrationWarning> <html lang="zh" className={inter.className} suppressHydrationWarning>
<body className="flex flex-col min-h-screen"> <body className="flex flex-col min-h-screen">

View File

@@ -40,4 +40,4 @@ backend/
## API 接口 ## API 接口
详细的 API 接口文档请查看 [API 文档](/docs/openapi)。 详细的 API 接口文档请查看 [API 文档](/openapi)。

View File

@@ -9,6 +9,6 @@ OpenIsle 是一个现代化的社区平台,提供完整的社交功能。
## 快速开始 ## 快速开始
- [后端开发指南](/docs/backend) - 了解后端架构和开发 - [后端开发指南](/backend) - 了解后端架构和开发
- [前端开发指南](/docs/frontend) - 了解前端技术栈和组件 - [前端开发指南](/frontend) - 了解前端技术栈和组件
- [API 文档](/docs/openapi) - 查看完整的 API 接口文档 - [API 文档](/openapi) - 查看完整的 API 接口文档

View File

@@ -8,7 +8,7 @@ export function baseOptions(): BaseLayoutProps {
githubUrl: 'https://github.com/nagisa77/OpenIsle', githubUrl: 'https://github.com/nagisa77/OpenIsle',
nav: { nav: {
title: 'OpenIsle Docs', title: 'OpenIsle Docs',
url: '/docs', url: '/',
}, },
searchToggle: { searchToggle: {
enabled: false, enabled: false,

View File

@@ -10,7 +10,7 @@ import * as ClientAdapters from './media-adapter.client';
// See https://fumadocs.vercel.app/docs/headless/source-api for more info // See https://fumadocs.vercel.app/docs/headless/source-api for more info
export const source = loader({ export const source = loader({
// it assigns a URL to your pages // it assigns a URL to your pages
baseUrl: '/docs', baseUrl: '/',
source: docs.toFumadocsSource(), source: docs.toFumadocsSource(),
pageTree: { pageTree: {
transformers: [transformerOpenAPI()], transformers: [transformerOpenAPI()],