diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/GitHubApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/GitHubApi.kt new file mode 100644 index 0000000..f241588 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/GitHubApi.kt @@ -0,0 +1,86 @@ +package com.wrbug.polymarketbot.api + +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path + +/** + * GitHub API 接口 + */ +interface GitHubApi { + /** + * 获取 Issue 信息 + * @param owner 仓库所有者 + * @param repo 仓库名 + * @param issueNumber Issue 编号 + * @return Issue 信息响应 + */ + @GET("repos/{owner}/{repo}/issues/{issue_number}") + suspend fun getIssue( + @Path("owner") owner: String, + @Path("repo") repo: String, + @Path("issue_number") issueNumber: Int + ): Response + + /** + * 获取 Issue 评论列表 + * @param owner 仓库所有者 + * @param repo 仓库名 + * @param issueNumber Issue 编号 + * @return 评论列表响应 + */ + @GET("repos/{owner}/{repo}/issues/{issue_number}/comments") + suspend fun getIssueComments( + @Path("owner") owner: String, + @Path("repo") repo: String, + @Path("issue_number") issueNumber: Int + ): Response> +} + +/** + * GitHub Issue 响应 + */ +data class GitHubIssueResponse( + val id: Long, + val number: Int, + val assignees: List +) + +/** + * GitHub 评论响应 + */ +data class GitHubCommentResponse( + val id: Long, + val body: String, + val user: GitHubUser, + val created_at: String, + val updated_at: String, + val issue_url: String? = null, // Issue URL,格式:https://api.github.com/repos/owner/repo/issues/3703128976 + val reactions: GitHubReactions? = null // Reactions 数据 +) + +/** + * GitHub Reactions 数据 + */ +data class GitHubReactions( + val url: String, + val total_count: Int, + val `+1`: Int = 0, // +1 数量 + val `-1`: Int = 0, // -1 数量 + val laugh: Int = 0, // 😄 数量 + val confused: Int = 0, // 😕 数量 + val heart: Int = 0, // ❤️ 数量 + val hooray: Int = 0, // 🎉 数量 + val eyes: Int = 0, // 👀 数量 + val rocket: Int = 0 // 🚀 数量 +) + +/** + * GitHub 用户信息 + */ +data class GitHubUser( + val login: String, + val id: Long, + val avatar_url: String? = null +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AnnouncementController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AnnouncementController.kt new file mode 100644 index 0000000..f8b5ef3 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AnnouncementController.kt @@ -0,0 +1,73 @@ +package com.wrbug.polymarketbot.controller + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.service.AnnouncementService +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import org.springframework.context.MessageSource +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * 公告控制器 + */ +@RestController +@RequestMapping("/api/announcements") +class AnnouncementController( + private val announcementService: AnnouncementService, + private val messageSource: MessageSource +) { + + private val logger = LoggerFactory.getLogger(AnnouncementController::class.java) + + /** + * 获取公告列表(最近10条) + */ + @PostMapping("/list") + fun getAnnouncementList(@RequestBody request: AnnouncementListRequest): ResponseEntity> { + return try { + val result = runBlocking { announcementService.getAnnouncementList(request.forceRefresh) } + result.fold( + onSuccess = { response -> + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e -> + logger.error("获取公告列表失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("获取公告列表异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } + + /** + * 获取公告详情 + */ + @PostMapping("/detail") + fun getAnnouncementDetail(@RequestBody request: AnnouncementDetailRequest): ResponseEntity> { + return try { + val result = runBlocking { announcementService.getAnnouncementDetail(request.id, request.forceRefresh) } + result.fold( + onSuccess = { announcement -> + ResponseEntity.ok(ApiResponse.success(announcement)) + }, + onFailure = { e -> + logger.error("获取公告详情失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok( + ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource) + ) + else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } + ) + } catch (e: Exception) { + logger.error("获取公告详情异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AnnouncementDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AnnouncementDto.kt new file mode 100644 index 0000000..cd81d15 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AnnouncementDto.kt @@ -0,0 +1,55 @@ +package com.wrbug.polymarketbot.dto + +/** + * 公告列表请求 + */ +data class AnnouncementListRequest( + val forceRefresh: Boolean = false // 是否强制刷新缓存 +) + +/** + * 公告详情请求 + */ +data class AnnouncementDetailRequest( + val id: Long? = null, // 评论ID,如果为空则返回最新一条 + val forceRefresh: Boolean = false // 是否强制刷新缓存 +) + +/** + * Reactions 信息 + */ +data class ReactionsDto( + val plusOne: Int = 0, // 👍 +1 数量 + val minusOne: Int = 0, // 👎 -1 数量 + val laugh: Int = 0, // 😄 数量 + val confused: Int = 0, // 😕 数量 + val heart: Int = 0, // ❤️ 数量 + val hooray: Int = 0, // 🎉 数量 + val eyes: Int = 0, // 👀 数量 + val rocket: Int = 0, // 🚀 数量 + val total: Int = 0 // 总数量 +) + +/** + * 公告信息响应 + */ +data class AnnouncementDto( + val id: Long, // GitHub 评论 ID + val title: String, // 标题(从评论第一行提取,已移除 Markdown 格式) + val body: String, // Markdown 内容(完整内容) + val author: String, // 作者用户名 + val authorAvatarUrl: String?, // 作者头像 URL + val createdAt: Long, // 创建时间(时间戳,毫秒) + val updatedAt: Long, // 更新时间(时间戳,毫秒) + val reactions: ReactionsDto? = null // Reactions 数据 +) + +/** + * 公告列表响应 + */ +data class AnnouncementListResponse( + val list: List, + val hasMore: Boolean, // 是否还有更多(总数 > 10) + val total: Int // 总数 +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AnnouncementService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AnnouncementService.kt new file mode 100644 index 0000000..1e2305a --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AnnouncementService.kt @@ -0,0 +1,401 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.api.GitHubApi +import com.wrbug.polymarketbot.dto.AnnouncementDto +import com.wrbug.polymarketbot.dto.AnnouncementListResponse +import com.wrbug.polymarketbot.util.RetrofitFactory +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.time.Instant +import java.time.format.DateTimeFormatter + +/** + * 公告服务 + * 从 GitHub Issues API 获取公告信息 + */ +@Service +class AnnouncementService( + private val retrofitFactory: RetrofitFactory, + @Value("\${github.repo.owner:WrBug}") + private val repoOwner: String, + @Value("\${github.repo.name:PolyHermes}") + private val repoName: String, + @Value("\${github.announcement.issue.number:1}") + private val issueNumber: Int +) { + + private val logger = LoggerFactory.getLogger(AnnouncementService::class.java) + + // GitHub API 客户端(懒加载) + private val githubApi: GitHubApi by lazy { + retrofitFactory.createGitHubApi() + } + + // 需要排除的 Issue ID(从 issue_url 中提取) + private val excludedIssueId = "3703128976" + + // 缓存数据(1分钟有效期) + private data class CachedData( + val data: T, + val timestamp: Long + ) + + private var cachedList: CachedData? = null + private var cachedAssignees: CachedData>? = null + private var cachedComments: CachedData>? = null + + // 缓存有效期:10分钟(毫秒) + private val cacheExpiryTime = 10 * 60 * 1000L + + /** + * 检查缓存是否有效 + */ + private fun isCacheValid(cached: CachedData?): Boolean { + if (cached == null) return false + val now = System.currentTimeMillis() + return (now - cached.timestamp) < cacheExpiryTime + } + + /** + * 检查是否被限流 + */ + private fun isRateLimited(response: retrofit2.Response<*>): Boolean { + // HTTP 403 通常表示限流 + if (response.code() == 403) { + return true + } + // 检查响应头中的限流信息 + val remaining = response.headers()["X-RateLimit-Remaining"] + return remaining == "0" + } + + /** + * 获取 Issue 的 assignees 列表(通过 API 获取,带缓存) + * @return Pair + */ + private suspend fun getAssignees(forceRefresh: Boolean = false): Pair, Boolean> { + // 检查缓存 + if (!forceRefresh && isCacheValid(cachedAssignees)) { + logger.debug("使用缓存的 assignees") + return Pair(cachedAssignees!!.data, true) + } + + return try { + val response = githubApi.getIssue( + owner = repoOwner, + repo = repoName, + issueNumber = issueNumber + ) + + // 如果被限流,使用缓存数据,不更新缓存 + if (isRateLimited(response)) { + logger.warn("GitHub API 被限流,使用缓存的 assignees(不更新缓存)") + if (cachedAssignees != null) { + return Pair(cachedAssignees!!.data, true) // 返回缓存数据,标记为使用了缓存 + } + // 如果没有缓存,使用默认值 + return Pair(listOf("WrBug"), false) + } + + val assignees = if (response.isSuccessful && response.body() != null) { + response.body()!!.assignees.map { it.login } + } else { + logger.warn("获取 Issue assignees 失败,使用默认值: code=${response.code()}") + listOf("WrBug") // 默认值 + } + + // 更新缓存 + cachedAssignees = CachedData(assignees, System.currentTimeMillis()) + Pair(assignees, false) // 返回新数据,标记为未使用缓存 + } catch (e: Exception) { + logger.error("获取 Issue assignees 异常: ${e.message}", e) + // 如果缓存存在,使用缓存 + if (cachedAssignees != null) { + logger.warn("使用缓存的 assignees(API 调用失败)") + return Pair(cachedAssignees!!.data, true) // 返回缓存数据,标记为使用了缓存 + } + Pair(listOf("WrBug"), false) // 默认值 + } + } + + /** + * 获取 Issue 评论列表(带缓存) + * @return Pair<评论列表, 是否使用了缓存> + */ + private suspend fun getIssueComments(forceRefresh: Boolean = false): Pair, Boolean> { + // 检查缓存 + if (!forceRefresh && isCacheValid(cachedComments)) { + logger.debug("使用缓存的评论列表") + return Pair(cachedComments!!.data, true) + } + + val response = githubApi.getIssueComments( + owner = repoOwner, + repo = repoName, + issueNumber = issueNumber + ) + + // 如果被限流,使用缓存数据,不更新缓存 + if (isRateLimited(response)) { + logger.warn("GitHub API 被限流,使用缓存的评论列表(不更新缓存)") + if (cachedComments != null) { + return Pair(cachedComments!!.data, true) // 返回缓存数据,标记为使用了缓存 + } + // 如果没有缓存,抛出异常 + throw Exception("获取公告列表失败: GitHub API 被限流,且无缓存数据") + } + + if (!response.isSuccessful || response.body() == null) { + logger.error("获取 GitHub Issue 评论失败: code=${response.code()}, message=${response.message()}") + // 如果缓存存在,使用缓存 + if (cachedComments != null) { + logger.warn("使用缓存的评论列表(API 调用失败)") + return Pair(cachedComments!!.data, true) // 返回缓存数据,标记为使用了缓存 + } + throw Exception("获取公告列表失败: HTTP ${response.code()}") + } + + val comments = response.body()!! + + // 更新缓存 + cachedComments = CachedData(comments, System.currentTimeMillis()) + return Pair(comments, false) // 返回新数据,标记为未使用缓存 + } + + /** + * 获取公告列表(最近10条) + * @param forceRefresh 是否强制刷新缓存 + */ + suspend fun getAnnouncementList(forceRefresh: Boolean = false): Result { + // 检查缓存 + if (!forceRefresh && isCacheValid(cachedList)) { + logger.debug("使用缓存的公告列表") + return Result.success(cachedList!!.data) + } + + return try { + // 强制刷新时,先尝试获取新数据 + val (assigneeList, assigneesFromCache) = getAssignees(forceRefresh) + val (comments, commentsFromCache) = getIssueComments(forceRefresh) + + // 如果强制刷新时使用了缓存(被限流),直接返回缓存数据,不更新缓存 + if (forceRefresh && (assigneesFromCache || commentsFromCache)) { + logger.warn("强制刷新时被限流,返回缓存的公告列表(不更新缓存)") + if (cachedList != null) { + return Result.success(cachedList!!.data) + } + } + + // 筛选条件: + // 1. assignees 发布的评论 + // 2. 排除 issueNumber 为 3703128976 的评论(从 issue_url 中提取) + val filteredComments = comments + .filter { comment -> + // 检查是否为 assignee + val isAssignee = assigneeList.contains(comment.user.login) + + // 检查是否应该排除(从 issue_url 中提取 issue ID) + val shouldExclude = comment.issue_url?.let { issueUrl -> + // issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976 + // 提取最后的数字 + val issueId = issueUrl.split("/").lastOrNull() + issueId == excludedIssueId + } ?: false + + isAssignee && !shouldExclude + } + .sortedByDescending { comment -> + parseGitHubTime(comment.created_at) + } + + val total = filteredComments.size + val hasMore = total > 10 + + // 取前10条 + val latest10 = filteredComments.take(10).map { comment -> + toAnnouncementDto(comment) + } + + val result = AnnouncementListResponse( + list = latest10, + hasMore = hasMore, + total = total + ) + + // 只有在数据正常返回时才更新缓存(不是从缓存获取的) + if (!assigneesFromCache && !commentsFromCache) { + cachedList = CachedData(result, System.currentTimeMillis()) + } + + Result.success(result) + } catch (e: Exception) { + logger.error("获取公告列表异常: ${e.message}", e) + // 如果缓存存在,返回缓存 + if (cachedList != null) { + logger.warn("使用缓存的公告列表(API 调用失败)") + return Result.success(cachedList!!.data) + } + Result.failure(e) + } + } + + /** + * 获取公告详情 + * @param id 评论ID,如果为 null 则返回最新一条 + * @param forceRefresh 是否强制刷新缓存 + */ + suspend fun getAnnouncementDetail(id: Long?, forceRefresh: Boolean = false): Result { + return try { + // 获取 assignees + val (assigneeList, assigneesFromCache) = getAssignees(forceRefresh) + + // 获取评论列表 + val (comments, commentsFromCache) = getIssueComments(forceRefresh) + + // 筛选条件: + // 1. assignees 发布的评论 + // 2. 排除 issueNumber 为 3703128976 的评论(从 issue_url 中提取) + val filteredComments = comments + .filter { comment -> + // 检查是否为 assignee + val isAssignee = assigneeList.contains(comment.user.login) + + // 检查是否应该排除(从 issue_url 中提取 issue ID) + val shouldExclude = comment.issue_url?.let { issueUrl -> + // issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976 + // 提取最后的数字 + val issueId = issueUrl.split("/").lastOrNull() + issueId == excludedIssueId + } ?: false + + isAssignee && !shouldExclude + } + .sortedByDescending { comment -> + parseGitHubTime(comment.created_at) + } + + val targetComment = if (id != null) { + filteredComments.find { it.id == id } + } else { + filteredComments.firstOrNull() + } + + if (targetComment == null) { + return Result.failure(IllegalArgumentException("公告不存在")) + } + + Result.success(toAnnouncementDto(targetComment)) + } catch (e: Exception) { + logger.error("获取公告详情异常: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 将 GitHub 评论转换为 AnnouncementDto + */ + private fun toAnnouncementDto(comment: com.wrbug.polymarketbot.api.GitHubCommentResponse): AnnouncementDto { + // 提取标题(第一行,移除 Markdown 格式) + val title = extractTitle(comment.body) + + // 转换 reactions 数据 + val reactions = comment.reactions?.let { r -> + com.wrbug.polymarketbot.dto.ReactionsDto( + plusOne = r.`+1`, + minusOne = r.`-1`, + laugh = r.laugh, + confused = r.confused, + heart = r.heart, + hooray = r.hooray, + eyes = r.eyes, + rocket = r.rocket, + total = r.total_count + ) + } + + return AnnouncementDto( + id = comment.id, + title = title, + body = comment.body, + author = comment.user.login, + authorAvatarUrl = comment.user.avatar_url, + createdAt = parseGitHubTime(comment.created_at), + updatedAt = parseGitHubTime(comment.updated_at), + reactions = reactions + ) + } + + /** + * 从评论内容中提取标题(第一行,移除 Markdown 格式) + * 支持的 Markdown 格式: + * - # 标题 + * - ## 标题 + * - ### 标题 + * - **粗体** + * - *斜体* + * - `代码` + * - [链接](url) + */ + private fun extractTitle(body: String): String { + if (body.isBlank()) { + return "" + } + + // 获取第一行 + val firstLine = body.lines().firstOrNull()?.trim() ?: "" + if (firstLine.isBlank()) { + return "" + } + + // 移除 Markdown 格式 + var title = firstLine + + // 移除标题标记(# ## ### 等) + title = title.replace(Regex("^#{1,6}\\s+"), "") + + // 移除粗体标记(**text** 或 __text__) + title = title.replace(Regex("\\*\\*([^*]+)\\*\\*"), "$1") + title = title.replace(Regex("__([^_]+)__"), "$1") + + // 移除斜体标记(*text* 或 _text_) + title = title.replace(Regex("(? text) + title = title.replace(Regex("^>\\s+"), "") + + // 移除列表标记(- * + 1. 等) + title = title.replace(Regex("^[-*+]\\s+"), "") + title = title.replace(Regex("^\\d+\\.\\s+"), "") + + return title.trim() + } + + /** + * 解析 GitHub 时间格式(ISO 8601)为时间戳(毫秒) + * GitHub API 返回的时间格式:2025-12-07T14:30:00Z + */ + private fun parseGitHubTime(timeStr: String): Long { + return try { + Instant.parse(timeStr).toEpochMilli() + } catch (e: Exception) { + logger.warn("解析 GitHub 时间失败: $timeStr", e) + System.currentTimeMillis() + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/ApiHealthCheckService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/ApiHealthCheckService.kt index b4b119a..26bf7a0 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/ApiHealthCheckService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/ApiHealthCheckService.kt @@ -89,7 +89,8 @@ class ApiHealthCheckService( async { checkGammaApi() }, async { checkPolygonRpc() }, async { checkPolymarketWebSocket() }, - async { checkBuilderRelayerApi() } + async { checkBuilderRelayerApi() }, + async { checkGitHubApi() } ) jobs.awaitAll().forEach { result -> @@ -453,5 +454,14 @@ class ApiHealthCheckService( ) } } + + /** + * 检查 GitHub API + */ + private suspend fun checkGitHubApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) { + val url = "https://api.github.com/" + // 直接使用 GitHub API 根端点检查可用性 + checkApi("GitHub API", url) + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt index 97423b7..7a8542f 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt @@ -4,6 +4,7 @@ import com.google.gson.Gson import com.google.gson.GsonBuilder import com.wrbug.polymarketbot.api.BuilderRelayerApi import com.wrbug.polymarketbot.api.EthereumRpcApi +import com.wrbug.polymarketbot.api.GitHubApi import com.wrbug.polymarketbot.api.PolymarketClobApi import com.wrbug.polymarketbot.api.PolymarketDataApi import com.wrbug.polymarketbot.api.PolymarketGammaApi @@ -201,6 +202,41 @@ class RetrofitFactory( .build() .create(BuilderRelayerApi::class.java) } + + /** + * 创建 GitHub API 客户端 + * GitHub API 是公开 API,不需要认证(但建议使用 token 提高速率限制) + * 添加 Accept 头以获取 reactions 数据 + * @return GitHubApi 客户端 + */ + fun createGitHubApi(): GitHubApi { + val baseUrl = "https://api.github.com" + + // 添加拦截器,设置 Accept 头以获取 reactions 数据 + val githubInterceptor = object : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().newBuilder() + .header("Accept", "application/vnd.github+json") + .build() + return chain.proceed(request) + } + } + + val okHttpClient = createClient() + .addInterceptor(githubInterceptor) + .build() + + val gson = GsonBuilder() + .setLenient() + .create() + + return Retrofit.Builder() + .baseUrl("$baseUrl/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(GitHubApi::class.java) + } } /** diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 6f74aa3..dc75ad0 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -86,3 +86,8 @@ rate-limit.reset-password.max-attempts=3 # 时间窗口(秒) rate-limit.reset-password.window-seconds=60 +# GitHub 配置(用于公告功能) +github.repo.owner=WrBug +github.repo.name=PolyHermes +github.announcement.issue.number=1 + diff --git a/docs/github-api-rate-limit-alternatives.md b/docs/github-api-rate-limit-alternatives.md new file mode 100644 index 0000000..4335bc1 --- /dev/null +++ b/docs/github-api-rate-limit-alternatives.md @@ -0,0 +1,489 @@ +# GitHub API 限流问题及替代方案 + +## 📊 GitHub API 限流情况 + +### REST API 限流规则 +- **未认证请求**:每小时 60 次 +- **认证请求(使用 Token)**:每小时 5,000 次 +- **限流检测**:响应头 `X-RateLimit-Remaining` 显示剩余次数 +- **限流重置**:响应头 `X-RateLimit-Reset` 显示重置时间(Unix 时间戳) + +### GraphQL API 限流规则(基于点数) + +#### 主要限流规则 +- **未认证请求**:每小时 60 次(与 REST API 相同) +- **认证请求(使用 Token)**: + - 个人用户/应用:5,000 点/小时 + - 组织拥有的应用:10,000 点/小时 +- **每分钟点数限制**:2,000 点/分钟(仅限认证请求) + +#### 点数计算规则 +- **查询请求(Query)**:每个查询消耗 **1 点** +- **变更请求(Mutation)**:每个变更消耗 **5 点** +- **复杂度计算**:查询的复杂度会影响点数消耗(但基础查询通常为 1 点) + +#### 次要限流规则 +- **并发请求限制**:同时进行的请求不得超过 100 个 +- **CPU 时间限制**:每 60 秒实际时间内,最大 CPU 时间为 90 秒(GraphQL API 为 60 秒) +- **内容创建限制**: + - 每分钟不超过 80 个内容生成请求 + - 每小时不超过 500 个内容生成请求 + +#### 查询限制 +- 必须在连接上提供 `first` 或 `last` 参数 +- `first` 和 `last` 的值必须在 1 到 100 之间 +- 单个调用请求的节点总数不能超过 500,000 + +#### 限流检测 +GraphQL API 的限流信息在响应中返回: +```json +{ + "data": { ... }, + "extensions": { + "rateLimit": { + "limit": 5000, + "remaining": 4998, + "resetAt": "2024-12-07T15:00:00Z", + "used": 2 + } + } +} +``` + +### 当前使用场景(REST API) +- 获取 Issue 信息(获取 assignees):每次请求 1 次 +- 获取 Issue 评论列表:每次请求 1 次 +- **总计**:每次获取公告列表需要 2 次 API 调用 +- **缓存时间**:1 分钟(已实现) + +### 限流风险分析 + +#### REST API +- **未认证**:60 次/小时 ÷ 2 次/请求 = 最多 30 次请求/小时 +- **认证后**:5,000 次/小时 ÷ 2 次/请求 = 最多 2,500 次请求/小时 +- **实际使用**:用户刷新 + 自动加载,可能触发限流 + +#### GraphQL API(如果迁移) +- **未认证**:60 次/小时(与 REST API 相同) +- **认证后**: + - 查询消耗:1 个 GraphQL 查询 = 1 点(获取 Issue + Comments + Reactions) + - 限流容量:5,000 点/小时 = 最多 5,000 次请求/小时 +- **优势**:单次请求获取所有数据,请求次数减少 50% +- **实际使用**:认证后 5,000 次/小时足够使用 + +--- + +## 🔧 替代方案对比 + +### 方案 1:使用 GitHub Token 认证(推荐 ⭐⭐⭐⭐⭐) + +**优点:** +- ✅ 实现简单,只需添加 Token +- ✅ 限流提升:60 → 5,000 次/小时(提升 83 倍) +- ✅ 无需额外服务 +- ✅ 成本低(免费) + +**缺点:** +- ❌ 需要用户提供 GitHub Token +- ❌ Token 需要存储(建议加密) + +**实现方式:** +```kotlin +// 在拦截器中添加 Authorization 头 +.header("Authorization", "token $githubToken") +``` + +**适用场景:** 推荐作为首选方案 + +--- + +### 方案 2:使用缓存机制(已实现 ⭐⭐⭐⭐) + +**优点:** +- ✅ 已实现 1 分钟缓存 +- ✅ 减少 API 调用次数 +- ✅ 提升响应速度 + +**缺点:** +- ❌ 数据可能不是最新的 +- ❌ 缓存时间需要平衡 + +**优化建议:** +- 可以延长缓存时间到 5-10 分钟(公告更新频率低) +- 实现多级缓存(内存 + Redis) + +**适用场景:** 配合其他方案使用 + +--- + +### 方案 3:使用 GraphQL API(推荐 ⭐⭐⭐⭐) + +**优点:** +- ✅ 单次请求获取所有数据(Issue + Comments + Reactions) +- ✅ 减少请求次数:2 次 → 1 次 +- ✅ 可以精确控制返回字段 +- ✅ 限流更宽松:5,000 点/小时(查询消耗 1 点/次) +- ✅ 认证后限流充足(5,000 次/小时) + +**缺点:** +- ❌ 需要学习 GraphQL 语法 +- ❌ 需要修改现有代码 +- ❌ 需要处理 GraphQL 响应格式 + +**限流对比:** +- REST API(认证):5,000 次/小时 ÷ 2 次/请求 = 2,500 次完整请求/小时 +- GraphQL API(认证):5,000 点/小时 ÷ 1 点/请求 = 5,000 次完整请求/小时 +- **提升**:GraphQL 比 REST 多 100% 的请求容量 + +**实现方式:** +```graphql +query { + repository(owner: "WrBug", name: "PolyHermes") { + issue(number: 1) { + assignees(first: 10) { + nodes { + login + } + } + comments(first: 100) { + nodes { + id + body + createdAt + updatedAt + issue { + id + } + author { + login + avatarUrl + } + reactions(first: 100) { + totalCount + nodes { + content + } + } + } + } + } + } +} +``` + +**响应格式:** +```json +{ + "data": { + "repository": { + "issue": { + "assignees": { + "nodes": [ + { "login": "WrBug" } + ] + }, + "comments": { + "nodes": [ + { + "id": "123", + "body": "...", + "reactions": { + "totalCount": 10, + "nodes": [ + { "content": "THUMBS_UP" }, + { "content": "HEART" } + ] + } + } + ] + } + } + } + }, + "extensions": { + "rateLimit": { + "limit": 5000, + "remaining": 4999, + "resetAt": "2024-12-07T15:00:00Z" + } + } +} +``` + +**适用场景:** 适合需要优化请求次数和限流容量的场景 + +--- + +### 方案 4:自建代理服务 ⭐⭐⭐ + +**优点:** +- ✅ 可以添加额外缓存层(5-30 分钟) +- ✅ 可以聚合多个请求 +- ✅ 可以添加限流保护 +- ✅ 可以 Token 轮换(多个 Token 共享限流) +- ✅ 免费额度充足(Cloudflare Workers 100,000 次/天) + +**缺点:** +- ❌ 需要额外部署服务 +- ❌ 增加系统复杂度 +- ❌ 需要维护 + +**实现方式:** +``` +用户请求 → 自建代理(Cloudflare Workers/Vercel) → GitHub API + ← 缓存响应(5-30分钟) ← +``` + +**可选平台:** +- **Cloudflare Workers**:免费 100,000 次/天 +- **Vercel Edge Functions**:免费额度充足 +- **Netlify Functions**:免费额度充足 +- **自建 Node.js 服务**:完全控制 + +**适用场景:** 需要更高可用性和更长缓存时间的场景 + +--- + +### 方案 4.1:第三方 GitHub API 代理服务 ❌ + +**结论:没有可用的第三方服务** + +**原因:** +1. ❌ **数据源限制**:公告数据在 GitHub Issue,无法迁移到其他平台 +2. ❌ **认证问题**:GitHub API 需要 Token,第三方服务无法安全共享用户 Token +3. ❌ **服务缺失**:没有公开的、稳定的第三方 GitHub API 代理服务 +4. ❌ **商业限制**:GitHub 不允许第三方服务代理其 API(违反 ToS) + +**为什么不可行:** +- 数据在 GitHub,必须调用 GitHub API +- Token 是个人凭证,不能共享给第三方 +- 没有公开的代理服务(违反 GitHub ToS) + +**可行的替代思路:** +- ✅ **自建代理服务**(方案 4):使用 Cloudflare Workers 等平台 +- ✅ **使用 GitHub Token**(方案 1):直接认证,限流提升 83 倍 +- ✅ **使用 GraphQL API**(方案 3):减少请求次数,提升限流容量 + +--- + +### 方案 5:使用 GitHub Webhook(不适用)❌ + +**说明:** +- Webhook 是事件驱动的,不适合主动获取数据 +- 公告功能需要主动查询,不适合 Webhook + +**适用场景:** 不适用于当前需求 + +--- + +### 方案 6:使用其他代码托管平台 API(不适用)❌ + +**说明:** +- GitLab、Bitbucket、Gitee 等不包含 GitHub 的 Issue 数据 +- 公告数据在 GitHub,无法迁移到其他平台 +- 这些平台的 API 无法访问 GitHub 的数据 + +**适用场景:** 不适用于当前需求(数据在 GitHub) + +--- + +## 🎯 推荐方案组合 + +### 方案 A:Token + 缓存(推荐)⭐⭐⭐⭐⭐ + +**组合:** +1. 使用 GitHub Token 认证(提升限流到 5,000/小时) +2. 保持 1-5 分钟缓存 +3. 添加限流检测和错误处理 + +**优点:** +- 实现简单 +- 限流充足(5,000/小时足够使用) +- 响应快速(缓存) + +**实现成本:** 低 + +--- + +### 方案 B:GraphQL + Token + 缓存 ⭐⭐⭐⭐⭐ + +**组合:** +1. 使用 GraphQL API(减少请求次数,提升限流容量) +2. 使用 GitHub Token 认证 +3. 保持缓存机制 + +**优点:** +- 请求次数最少(1 次/请求) +- 限流容量最大(5,000 次/小时,比 REST 多 100%) +- 数据获取更高效(单次请求获取所有数据) +- 可以精确控制返回字段 + +**实现成本:** 中等(需要学习 GraphQL) + +**限流对比:** +- REST API:2,500 次完整请求/小时 +- GraphQL API:5,000 次完整请求/小时 +- **提升**:100% 的请求容量提升 + +--- + +### 方案 C:自建代理服务 + 缓存 ⭐⭐⭐ + +**组合:** +1. 使用 Cloudflare Workers / Vercel Edge Functions 自建代理 +2. 在代理层添加缓存(5-30 分钟) +3. 聚合请求(可选) +4. Token 轮换(可选,多个 Token 共享限流) + +**优点:** +- 可以添加更长的缓存时间(5-30 分钟) +- 可以聚合多个请求 +- 可以添加限流保护 +- 可以 Token 轮换(多个 Token 共享限流容量) + +**实现成本:** 中等(需要部署,但平台提供免费额度) + +**实现示例(Cloudflare Workers):** +```javascript +// cloudflare-worker.js +export default { + async fetch(request) { + const cacheKey = request.url; + const cache = caches.default; + + // 检查缓存(5 分钟) + let response = await cache.match(cacheKey); + if (response) { + return response; + } + + // 转发到 GitHub API + const githubResponse = await fetch(request, { + headers: { + 'Authorization': `Bearer ${GITHUB_TOKEN}`, + 'Accept': 'application/vnd.github+json' + } + }); + + // 缓存响应(5 分钟) + response = new Response(githubResponse.body, githubResponse); + response.headers.set('Cache-Control', 'public, max-age=300'); + await cache.put(cacheKey, response.clone()); + + return response; + } +} +``` + +--- + +## 📝 实现建议 + +### 短期方案(立即实施) +1. **添加 GitHub Token 支持** + - 在配置文件中添加 `github.token` 配置项 + - 在拦截器中添加 Authorization 头 + - 限流从 60 → 5,000/小时 + +2. **优化缓存时间** + - 将缓存时间从 1 分钟延长到 5-10 分钟 + - 公告更新频率低,5-10 分钟足够 + +3. **添加限流检测** + - 检查响应头 `X-RateLimit-Remaining` + - 当剩余次数 < 10 时,延长缓存时间 + - 当触发限流时,返回缓存数据 + +### 中期方案(可选) +1. **迁移到 GraphQL API** + - 学习 GraphQL 语法 + - 重写 API 调用 + - 减少请求次数 + +2. **实现多级缓存** + - 内存缓存(快速) + - Redis 缓存(持久化) + +### 长期方案(如需要) +1. **自建代理服务** + - 使用 Cloudflare Workers / Vercel Edge Functions + - 添加更长的缓存时间(5-30 分钟) + - 聚合多个请求 + - Token 轮换(多个 Token 共享限流) + +--- + +## 🔍 限流检测实现 + +### 响应头说明 +- `X-RateLimit-Limit`: 总限制次数 +- `X-RateLimit-Remaining`: 剩余次数 +- `X-RateLimit-Used`: 已使用次数 +- `X-RateLimit-Reset`: 重置时间(Unix 时间戳) + +### 错误处理 +当触发限流时,GitHub API 返回: +- HTTP 403 Forbidden +- 响应头 `X-RateLimit-Remaining: 0` +- 响应体包含限流信息 + +--- + +## 💡 总结 + +### 第三方 API 服务情况 + +**结论:没有可用的第三方 GitHub API 代理服务** + +**原因:** +1. ❌ 数据源限制:公告数据在 GitHub,无法迁移 +2. ❌ 认证问题:GitHub API 需要 Token,第三方无法安全共享 +3. ❌ 服务缺失:没有公开的、稳定的第三方代理服务 + +**可行的替代方案:** +- ✅ **自建代理服务**:使用 Cloudflare Workers / Vercel 等平台 +- ✅ **使用 GitHub Token**:直接认证,限流提升 83 倍 +- ✅ **使用 GraphQL API**:减少请求次数,提升限流容量 + +--- + +### 限流对比表 + +| 方案 | API 类型 | 认证 | 请求次数 | 限流容量 | 完整请求数/小时 | 实现难度 | +|------|---------|------|---------|---------|----------------|---------| +| 当前 | REST | 否 | 2 次/请求 | 60 次/小时 | 30 次 | - | +| REST + Token | REST | 是 | 2 次/请求 | 5,000 次/小时 | 2,500 次 | ⭐ 简单 | +| GraphQL | GraphQL | 否 | 1 次/请求 | 60 次/小时 | 60 次 | ⭐⭐ 中等 | +| GraphQL + Token | GraphQL | 是 | 1 次/请求 | 5,000 点/小时 | 5,000 次 | ⭐⭐ 中等 | +| 自建代理 + Token | REST/GraphQL | 是 | 1-2 次/请求 | 5,000+ 次/小时 | 5,000+ 次 | ⭐⭐⭐ 中等 | + +### 推荐方案 + +**最佳方案:** 方案 A(Token + 缓存)⭐⭐⭐⭐⭐ +- 实现简单(只需添加 Token) +- 效果显著(限流提升 83 倍:60 → 5,000/小时) +- 成本低 +- 适合当前需求 +- **限流容量**:2,500 次完整请求/小时 + +**优化方案:** 方案 B(GraphQL + Token + 缓存)⭐⭐⭐⭐⭐ +- 请求次数最少(1 次/请求) +- 限流容量最大(5,000 次/小时,比 REST 多 100%) +- 数据获取更高效(单次请求获取所有数据) +- 适合长期优化 +- **限流容量**:5,000 次完整请求/小时 + +**备选方案:** 方案 C(代理服务)⭐⭐⭐ +- 适合需要更高可用性的场景 +- 需要额外部署和维护 + +### 建议实施顺序 + +1. **立即实施**:方案 A(Token + 缓存) + - 快速解决限流问题 + - 实现成本低 + +2. **中期优化**:方案 B(GraphQL + Token + 缓存) + - 进一步提升限流容量 + - 优化请求效率 + diff --git a/docs/github-token-setup.md b/docs/github-token-setup.md new file mode 100644 index 0000000..e028b45 --- /dev/null +++ b/docs/github-token-setup.md @@ -0,0 +1,298 @@ +# GitHub Token 获取和配置指南 + +## 📋 概述 + +GitHub Personal Access Token (PAT) 用于提高 API 限流容量: +- **未认证**:60 次/小时 +- **使用 Token**:5,000 次/小时(REST API)或 5,000 点/小时(GraphQL API) + +--- + +## 🔑 获取 GitHub Token + +### 方法 1:通过 GitHub 网站创建(推荐) + +#### 步骤 1:登录 GitHub +1. 访问 [GitHub](https://github.com) +2. 登录您的账户 + +#### 步骤 2:进入开发者设置 +1. 点击右上角头像 +2. 选择 **Settings**(设置) +3. 在左侧菜单中,滚动到底部 +4. 点击 **Developer settings**(开发者设置) + +#### 步骤 3:创建 Personal Access Token +1. 在左侧菜单中,点击 **Personal access tokens** +2. 选择 **Tokens (classic)** 或 **Fine-grained tokens** + +**推荐使用 Fine-grained tokens(更安全):** +- 点击 **Generate new token** → **Generate new token (fine-grained)** +- 填写 Token 名称(如:`PolyHermes Announcements API`) +- 设置过期时间(建议:90 天或自定义) +- 选择资源所有者(Repository access): + - 如果公告在您的仓库:选择 **Only select repositories**,然后选择 `WrBug/PolyHermes` + - 如果公告在公共仓库:选择 **Public repositories (read-only)** +- 设置权限(Repository permissions): + - **Metadata**: Read(必需) + - **Contents**: Read(如果需要读取 Issue 内容) + - **Issues**: Read(必需,用于读取 Issue 和评论) +- 点击 **Generate token** + +**或使用 Classic tokens(更简单):** +- 点击 **Generate new token (classic)** +- 填写 Token 名称(如:`PolyHermes Announcements API`) +- 设置过期时间 +- 选择权限(Scopes): + - ✅ **public_repo**(读取公共仓库的 Issue 和评论) + - 如果仓库是私有的,需要选择 **repo** +- 点击 **Generate token** + +#### 步骤 4:复制并保存 Token +⚠️ **重要**:Token 只会显示一次,请立即复制并保存到安全的地方! + +``` +ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +--- + +### 方法 2:通过 GitHub CLI 创建 + +如果您安装了 GitHub CLI (`gh`),可以使用命令行创建: + +```bash +# 登录 GitHub CLI +gh auth login + +# 创建 Token +gh auth token +``` + +--- + +## 🔐 所需权限说明 + +### Fine-grained Token 权限 +- **Metadata**: Read(必需,读取仓库基本信息) +- **Contents**: Read(可选,读取仓库内容) +- **Issues**: Read(必需,读取 Issue 和评论) + +### Classic Token 权限 +- **public_repo**(公共仓库) +- **repo**(私有仓库,如果需要) + +--- + +## ⚙️ 在项目中使用 Token + +### 方式 1:环境变量(推荐) + +#### 1. 在配置文件中添加 Token 配置 + +编辑 `backend/src/main/resources/application.properties`: + +```properties +# GitHub 配置(用于公告功能) +github.repo.owner=WrBug +github.repo.name=PolyHermes +github.announcement.issue.number=1 +github.token=${GITHUB_TOKEN:} # 从环境变量读取,如果未设置则为空 +``` + +#### 2. 设置环境变量 + +**Linux/macOS:** +```bash +export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +**Windows (PowerShell):** +```powershell +$env:GITHUB_TOKEN="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +``` + +**Windows (CMD):** +```cmd +set GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +#### 3. 在 Docker 中使用 + +在 `docker-compose.yml` 或启动命令中添加: +```yaml +environment: + - GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +或在启动命令中: +```bash +docker run -e GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ... +``` + +--- + +### 方式 2:直接配置(不推荐,仅用于测试) + +⚠️ **不推荐**:Token 会暴露在配置文件中,存在安全风险。 + +编辑 `backend/src/main/resources/application.properties`: + +```properties +github.token=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +--- + +## 💻 代码实现 + +### 更新 RetrofitFactory + +在 `RetrofitFactory.kt` 中添加 Token 支持: + +```kotlin +fun createGitHubApi(): GitHubApi { + val baseUrl = "https://api.github.com" + + // 从配置读取 Token + val githubToken = githubToken // 从 @Value 注入 + + // 添加拦截器 + val githubInterceptor = object : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val requestBuilder = chain.request().newBuilder() + .header("Accept", "application/vnd.github+json") + + // 如果配置了 Token,添加认证头 + if (githubToken.isNotBlank()) { + requestBuilder.header("Authorization", "Bearer $githubToken") + } + + return chain.proceed(requestBuilder.build()) + } + } + + val okHttpClient = createClient() + .addInterceptor(githubInterceptor) + .build() + + // ... 其余代码 +} +``` + +--- + +## 🔒 安全注意事项 + +### 1. Token 存储 +- ✅ **推荐**:使用环境变量存储 Token +- ✅ **推荐**:使用密钥管理服务(如 AWS Secrets Manager、HashiCorp Vault) +- ❌ **禁止**:将 Token 提交到 Git 仓库 +- ❌ **禁止**:在日志中输出 Token + +### 2. Token 权限 +- ✅ **最小权限原则**:只授予必要的权限 +- ✅ **定期轮换**:建议每 90 天更新一次 Token +- ✅ **监控使用**:定期检查 Token 的使用情况 + +### 3. 配置文件 +- ✅ 将 `application.properties` 添加到 `.gitignore`(如果包含 Token) +- ✅ 使用 `application-local.properties` 存储本地配置 +- ✅ 使用环境变量覆盖配置 + +--- + +## 🧪 测试 Token + +### 使用 curl 测试 + +```bash +# 测试 REST API +curl -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/WrBug/PolyHermes/issues/1 + +# 测试 GraphQL API +curl -X POST \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"query": "query { viewer { login } }"}' \ + https://api.github.com/graphql +``` + +### 检查限流 + +响应头中包含限流信息: +``` +X-RateLimit-Limit: 5000 +X-RateLimit-Remaining: 4999 +X-RateLimit-Used: 1 +X-RateLimit-Reset: 1701964800 +``` + +--- + +## 📝 完整配置示例 + +### application.properties +```properties +# GitHub 配置(用于公告功能) +github.repo.owner=WrBug +github.repo.name=PolyHermes +github.announcement.issue.number=1 +github.token=${GITHUB_TOKEN:} # 从环境变量读取 +``` + +### .env 文件(用于本地开发) +```env +GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +### docker-compose.yml +```yaml +services: + backend: + environment: + - GITHUB_TOKEN=${GITHUB_TOKEN} +``` + +--- + +## 🚨 常见问题 + +### Q1: Token 过期了怎么办? +**A:** 重新生成新的 Token,更新环境变量或配置文件。 + +### Q2: Token 泄露了怎么办? +**A:** 立即在 GitHub 设置中删除该 Token,然后生成新 Token。 + +### Q3: 如何查看 Token 的使用情况? +**A:** 在 GitHub Settings → Developer settings → Personal access tokens 中查看 Token 的最后使用时间。 + +### Q4: 可以使用 GitHub App 吗? +**A:** 可以,GitHub App 的限流更高(组织应用 10,000 点/小时),但实现更复杂。 + +### Q5: Token 需要哪些权限? +**A:** 对于公共仓库,只需要 `public_repo` 权限;对于私有仓库,需要 `repo` 权限。 + +--- + +## 📚 参考链接 + +- [GitHub Personal Access Tokens 文档](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) +- [GitHub API 认证文档](https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api) +- [GitHub API 限流文档](https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting) + +--- + +## ✅ 检查清单 + +- [ ] 已创建 GitHub Personal Access Token +- [ ] Token 已保存到安全的地方 +- [ ] 已在环境变量中配置 Token +- [ ] 已更新 `application.properties` 配置 +- [ ] 已更新代码支持 Token 认证 +- [ ] 已测试 Token 是否生效 +- [ ] 已检查限流是否提升(从 60 → 5,000) +- [ ] 已将 Token 相关配置添加到 `.gitignore` + diff --git a/frontend/index.html b/frontend/index.html index e2ccd9b..f229cc3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + PolyHermes diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5be82e7..a775c71 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,8 +16,10 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^16.3.5", + "react-markdown": "^10.1.0", "react-responsive": "^9.0.2", "react-router-dom": "^6.20.0", + "remark-gfm": "^4.0.1", "zustand": "^4.4.7" }, "devDependencies": { @@ -1604,11 +1606,34 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } }, "node_modules/@types/js-cookie": { "version": "3.0.6", @@ -1621,6 +1646,19 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, "node_modules/@types/node": { "version": "22.7.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", @@ -1632,14 +1670,12 @@ "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==" }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1660,6 +1696,11 @@ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -1853,8 +1894,7 @@ "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" }, "node_modules/@use-gesture/core": { "version": "10.3.0", @@ -2151,6 +2191,15 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2261,6 +2310,15 @@ } ] }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2277,6 +2335,42 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -2311,6 +2405,15 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/compute-scroll-into-view": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", @@ -2369,7 +2472,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "dependencies": { "ms": "^2.1.3" }, @@ -2382,6 +2484,18 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2404,6 +2518,26 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2724,6 +2858,15 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2765,6 +2908,11 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3138,6 +3286,44 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -3146,6 +3332,15 @@ "void-elements": "3.1.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hyphenate-style-name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", @@ -3232,12 +3427,48 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==" + }, "node_modules/intersection-observer": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.12.2.tgz", "integrity": "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==", "deprecated": "The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019." }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3259,6 +3490,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3277,6 +3517,17 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3406,6 +3657,15 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3426,6 +3686,15 @@ "yallist": "^3.0.2" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/matchmediaquery": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.3.1.tgz", @@ -3442,6 +3711,272 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3451,6 +3986,541 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -3501,8 +4571,7 @@ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/nano-memoize": { "version": "3.0.16", @@ -3615,6 +4684,29 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3721,6 +4813,15 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4386,6 +5487,32 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4442,6 +5569,68 @@ "react-dom": ">=16.8" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -4634,6 +5823,15 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/staged-components": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/staged-components/-/staged-components-1.1.3.tgz", @@ -4647,6 +5845,19 @@ "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==" }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4671,6 +5882,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", @@ -4719,6 +5946,24 @@ "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -4778,6 +6023,87 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -4825,6 +6151,32 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -4986,6 +6338,15 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 0b4b737..088749d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,8 +17,10 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^16.3.5", + "react-markdown": "^10.1.0", "react-responsive": "^9.0.2", "react-router-dom": "^6.20.0", + "remark-gfm": "^4.0.1", "zustand": "^4.4.7" }, "devDependencies": { diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..018e9e0 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 46af972..cfa5819 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,7 @@ import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders' import FilteredOrdersList from './pages/FilteredOrdersList' import SystemSettings from './pages/SystemSettings' import ApiHealthStatus from './pages/ApiHealthStatus' +import Announcements from './pages/Announcements' import { wsManager } from './services/websocket' import type { OrderPushMessage } from './types' import { apiService } from './services/api' @@ -236,7 +237,7 @@ function App() { } /> {/* 受保护的路由 */} - } /> + } /> } /> } /> } /> @@ -259,6 +260,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c3af30f..aedb3ee 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -18,7 +18,8 @@ import { GithubOutlined, TwitterOutlined, CheckCircleOutlined, - SendOutlined + SendOutlined, + NotificationOutlined } from '@ant-design/icons' import type { MenuProps } from 'antd' import type { ReactNode } from 'react' @@ -73,6 +74,11 @@ const Layout: React.FC = ({ children }) => { }, [location.pathname]) const menuItems: MenuProps['items'] = [ + { + key: '/announcements', + icon: , + label: t('menu.announcements') || '公告' + }, { key: '/accounts', icon: , diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 4494d65..24ccede 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -231,6 +231,7 @@ "copyTradingConfig": "Copy Trading Config", "positions": "Position Management", "statistics": "Statistics", + "announcements": "Announcements", "users": "User Management", "systemSettings": "System", "systemOverview": "Overview", @@ -952,5 +953,18 @@ "webhookUrl": "Webhook URL", "webhookUrlPlaceholder": "Webhook URL (from Slack App settings)", "webhookUrlRequired": "Please enter Webhook URL" + }, + "announcements": { + "title": "Announcements", + "noAnnouncements": "No announcements", + "noDetail": "Please select an announcement to view details", + "viewMore": "View more announcements", + "author": "Author", + "createdAt": "Published at", + "noTitle": "No title", + "expand": "Expand all", + "collapse": "Collapse", + "refresh": "Refresh", + "list": "List" } } diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 5133870..57b0236 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -148,6 +148,7 @@ "copyTradingConfig": "跟单配置", "positions": "仓位管理", "statistics": "统计信息", + "announcements": "公告", "users": "用户管理", "systemSettings": "系统管理", "systemOverview": "概览", @@ -869,5 +870,18 @@ "webhookUrl": "Webhook URL", "webhookUrlPlaceholder": "Webhook URL(从 Slack App 设置中获取)", "webhookUrlRequired": "请输入 Webhook URL" + }, + "announcements": { + "title": "公告", + "noAnnouncements": "暂无公告", + "noDetail": "请选择一条公告查看详情", + "viewMore": "查看更多公告", + "author": "作者", + "createdAt": "发布时间", + "noTitle": "无标题", + "expand": "展开全部", + "collapse": "收起", + "refresh": "刷新", + "list": "列表" } } diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index fb157e7..1a28fd2 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -231,6 +231,7 @@ "copyTradingConfig": "跟單配置", "positions": "倉位管理", "statistics": "統計信息", + "announcements": "公告", "users": "用戶管理", "systemSettings": "系統管理", "systemOverview": "概覽", @@ -952,5 +953,18 @@ "webhookUrl": "Webhook URL", "webhookUrlPlaceholder": "Webhook URL(從 Slack App 設置中獲取)", "webhookUrlRequired": "請輸入 Webhook URL" + }, + "announcements": { + "title": "公告", + "noAnnouncements": "暫無公告", + "noDetail": "請選擇一條公告查看詳情", + "viewMore": "查看更多公告", + "author": "作者", + "createdAt": "發布時間", + "noTitle": "無標題", + "expand": "展開全部", + "collapse": "收起", + "refresh": "刷新", + "list": "列表" } } diff --git a/frontend/src/pages/Announcements.tsx b/frontend/src/pages/Announcements.tsx new file mode 100644 index 0000000..6c3ff3d --- /dev/null +++ b/frontend/src/pages/Announcements.tsx @@ -0,0 +1,534 @@ +import { useEffect, useState, useRef } from 'react' +import { Card, List, Spin, Empty, Typography, Button, Avatar, Drawer } from 'antd' +import { MessageOutlined, LinkOutlined, UpOutlined, DownOutlined, ReloadOutlined, UnorderedListOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { apiService } from '../services/api' +import { useMediaQuery } from 'react-responsive' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' + +const { Title, Text } = Typography + +interface Reactions { + plusOne?: number + minusOne?: number + laugh?: number + confused?: number + heart?: number + hooray?: number + eyes?: number + rocket?: number + total?: number +} + +interface Announcement { + id: number + title: string + body: string + author: string + authorAvatarUrl?: string + createdAt: number + updatedAt: number + reactions?: Reactions +} + +const Announcements: React.FC = () => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [announcements, setAnnouncements] = useState([]) + const [selectedAnnouncement, setSelectedAnnouncement] = useState(null) + const [loading, setLoading] = useState(false) + const [loadingDetail, setLoadingDetail] = useState(false) + const [hasMore, setHasMore] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) + const [drawerVisible, setDrawerVisible] = useState(false) + const contentRef = useRef(null) + + useEffect(() => { + fetchAnnouncements() + fetchLatestDetail() + }, []) + + const fetchAnnouncements = async (forceRefresh: boolean = false) => { + setLoading(true) + try { + const response = await apiService.announcements.list({ forceRefresh }) + if (response.data.code === 0 && response.data.data) { + setAnnouncements(response.data.data.list || []) + setHasMore(response.data.data.hasMore || false) + } else { + console.error('获取公告列表失败:', response.data.msg) + } + } catch (error: any) { + console.error('获取公告列表异常:', error) + } finally { + setLoading(false) + } + } + + const fetchLatestDetail = async (forceRefresh: boolean = false) => { + setLoadingDetail(true) + try { + const response = await apiService.announcements.detail({ forceRefresh }) + if (response.data.code === 0 && response.data.data) { + setSelectedAnnouncement(response.data.data) + } else { + console.error('获取公告详情失败:', response.data.msg) + } + } catch (error: any) { + console.error('获取公告详情异常:', error) + } finally { + setLoadingDetail(false) + } + } + + const handleSelectAnnouncement = async (id: number, forceRefresh: boolean = false) => { + setLoadingDetail(true) + try { + const response = await apiService.announcements.detail({ id, forceRefresh }) + if (response.data.code === 0 && response.data.data) { + setSelectedAnnouncement(response.data.data) + // 移动端选择公告后关闭抽屉 + if (isMobile) { + setDrawerVisible(false) + } + } else { + console.error('获取公告详情失败:', response.data.msg) + } + } catch (error: any) { + console.error('获取公告详情异常:', error) + } finally { + setLoadingDetail(false) + } + } + + const handleRefresh = async () => { + await Promise.all([ + fetchAnnouncements(true), + fetchLatestDetail(true) + ]) + } + + const formatDate = (timestamp: number): string => { + const date = new Date(timestamp) + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + } + + // 计算内容行数(通过换行符计算) + const getLineCount = (text: string): number => { + if (!text) return 0 + return text.split('\n').length + } + + // 检查是否需要折叠(超过30行) + const shouldCollapse = (body: string): boolean => { + return getLineCount(body) > 30 + } + + // 当选中公告改变时,重置展开状态 + useEffect(() => { + if (selectedAnnouncement) { + const shouldCollapseContent = shouldCollapse(selectedAnnouncement.body) + setIsExpanded(!shouldCollapseContent) // 如果超过30行,默认折叠(isExpanded = false) + } + }, [selectedAnnouncement]) + + // 渲染公告详情内容(带折叠功能) + const renderAnnouncementContent = (announcement: Announcement, isMobileView: boolean) => { + const lineCount = getLineCount(announcement.body) + const needsCollapse = shouldCollapse(announcement.body) + const showCollapseButton = needsCollapse + + return ( +
+
+ } + size={isMobileView ? 'default' : 'large'} + /> +
+ + {announcement.author} + +
+ + {formatDate(announcement.createdAt)} + +
+
+
+
+ + {announcement.body} + + {needsCollapse && !isExpanded && ( +
+ )} +
+
+ {showCollapseButton && ( +
+ +
+ )} +
+ ) + } + + // 渲染 reactions(使用 emoji) + const renderReactions = (reactions?: Reactions) => { + if (!reactions || reactions.total === 0) { + return null + } + + const reactionItems: Array<{ emoji: string; count: number; key: string }> = [] + + if (reactions.plusOne && reactions.plusOne > 0) { + reactionItems.push({ emoji: '👍', count: reactions.plusOne, key: 'plusOne' }) + } + if (reactions.minusOne && reactions.minusOne > 0) { + reactionItems.push({ emoji: '👎', count: reactions.minusOne, key: 'minusOne' }) + } + if (reactions.laugh && reactions.laugh > 0) { + reactionItems.push({ emoji: '😄', count: reactions.laugh, key: 'laugh' }) + } + if (reactions.confused && reactions.confused > 0) { + reactionItems.push({ emoji: '😕', count: reactions.confused, key: 'confused' }) + } + if (reactions.heart && reactions.heart > 0) { + reactionItems.push({ emoji: '❤️', count: reactions.heart, key: 'heart' }) + } + if (reactions.hooray && reactions.hooray > 0) { + reactionItems.push({ emoji: '🎉', count: reactions.hooray, key: 'hooray' }) + } + if (reactions.eyes && reactions.eyes > 0) { + reactionItems.push({ emoji: '👀', count: reactions.eyes, key: 'eyes' }) + } + if (reactions.rocket && reactions.rocket > 0) { + reactionItems.push({ emoji: '🚀', count: reactions.rocket, key: 'rocket' }) + } + + if (reactionItems.length === 0) { + return null + } + + return ( +
+ {reactionItems.map((item) => ( + + {item.emoji} + {item.count} + + ))} +
+ ) + } + + // 渲染公告列表(用于抽屉) + const renderAnnouncementList = () => { + return ( +
+ {loading ? ( +
+ +
+ ) : announcements.length === 0 ? ( + + ) : ( +
+ {announcements.map((item) => { + const isSelected = selectedAnnouncement?.id === item.id + + return ( + handleSelectAnnouncement(item.id)} + style={{ + cursor: 'pointer', + borderRadius: '12px', + boxShadow: isSelected + ? '0 4px 12px rgba(24, 144, 255, 0.2)' + : '0 2px 8px rgba(0,0,0,0.08)', + border: isSelected + ? '2px solid #1890ff' + : '1px solid #e8e8e8', + backgroundColor: isSelected ? '#f0f8ff' : '#ffffff', + transition: 'all 0.3s ease', + transform: isSelected ? 'scale(1.02)' : 'scale(1)' + }} + bodyStyle={{ padding: '16px' }} + hoverable + > +
+ {/* 标题 */} +
+ {item.title || t('announcements.noTitle') || '无标题'} +
+ + {/* 时间和作者 */} +
+ } + size="small" + style={{ flexShrink: 0 }} + /> + {item.author} + + {formatDate(item.createdAt)} +
+ + {/* Reactions */} + {renderReactions(item.reactions)} +
+
+ ) + })} +
+ )} + + {hasMore && ( +
+ +
+ )} +
+ ) + } + + if (isMobile) { + // 移动端布局:详情在主要内容区,列表在侧边抽屉 + return ( +
+ +
+
+ + + {t('announcements.title') || '公告'} + +
+ +
+ + {/* 公告详情 */} +
+ {loadingDetail ? ( +
+ +
+ ) : selectedAnnouncement ? ( + renderAnnouncementContent(selectedAnnouncement, true) + ) : ( + + )} +
+
+ + {/* 侧边抽屉:公告列表 */} + setDrawerVisible(false)} + open={drawerVisible} + width="85%" + bodyStyle={{ padding: '16px' }} + > + {renderAnnouncementList()} + +
+ ) + } + + // 桌面端布局:左右结构 + return ( +
+ +
+ + {t('announcements.title') || '公告'} + + +
+ +
+ {/* 左侧:公告列表 */} +
+ }} + renderItem={(item) => ( + handleSelectAnnouncement(item.id)} + > + + {item.title || t('announcements.noTitle') || '无标题'} + + } + description={ +
+ + {formatDate(item.createdAt)} + + {renderReactions(item.reactions)} +
+ } + /> +
+ )} + /> + + {hasMore && ( +
+ +
+ )} +
+ + {/* 右侧:公告详情 */} +
+ {loadingDetail ? ( +
+ +
+ ) : selectedAnnouncement ? ( + renderAnnouncementContent(selectedAnnouncement, false) + ) : ( + + )} +
+
+
+
+ ) +} + +export default Announcements + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e14eebc..8a6d04c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -613,6 +613,65 @@ export const apiService = { */ getAutoRedeemStatus: () => apiClient.post>('/system/config/auto-redeem/status', {}) + }, + + /** + * 公告 API + */ + announcements: { + /** + * 获取公告列表(最近10条) + */ + list: (data?: { forceRefresh?: boolean }) => + apiClient.post + hasMore: boolean + total: number + }>>('/announcements/list', data || {}), + + /** + * 获取公告详情 + */ + detail: (data: { id?: number; forceRefresh?: boolean }) => + apiClient.post>('/announcements/detail', data) } }