Merge pull request #390 from iflytek/worktree-skill-version-compare-v2

feat(skill): add version compare page with unified diff
This commit is contained in:
dongmucat 2026-05-08 17:22:20 +08:00 committed by GitHub
commit 76db91dcb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1825 additions and 10 deletions

View file

@ -14,6 +14,10 @@ import com.iflytek.skillhub.dto.ResolveVersionResponse;
import com.iflytek.skillhub.dto.SkillDetailResponse;
import com.iflytek.skillhub.dto.SkillFileResponse;
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
import com.iflytek.skillhub.dto.SkillVersionCompareFileResponse;
import com.iflytek.skillhub.dto.SkillVersionCompareHunkResponse;
import com.iflytek.skillhub.dto.SkillVersionCompareLineResponse;
import com.iflytek.skillhub.dto.SkillVersionCompareResponse;
import com.iflytek.skillhub.dto.SkillVersionDetailResponse;
import com.iflytek.skillhub.dto.SkillVersionResponse;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
@ -174,6 +178,27 @@ public class SkillController extends BaseApiController {
return ok("response.success.read", response);
}
@GetMapping("/{namespace}/{slug}/versions/compare")
public ApiResponse<SkillVersionCompareResponse> compareVersions(
@PathVariable String namespace,
@PathVariable String slug,
@RequestParam("from") String from,
@RequestParam("to") String to,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
SkillQueryService.SkillVersionCompareDTO compare = skillQueryService.compareVersions(
namespace,
slug,
from,
to,
userId,
userNsRoles != null ? userNsRoles : Map.of()
);
return ok("response.success.read", toCompareResponse(compare));
}
/**
* Lists packaged files for a concrete version after visibility checks have
* been applied.
@ -421,4 +446,51 @@ public class SkillController extends BaseApiController {
}
return new SkillLifecycleVersionResponse(projection.id(), projection.version(), projection.status());
}
private SkillVersionCompareResponse toCompareResponse(SkillQueryService.SkillVersionCompareDTO compare) {
return new SkillVersionCompareResponse(
compare.from(),
compare.to(),
new SkillVersionCompareResponse.SkillVersionCompareSummaryResponse(
compare.summary().totalFiles(),
compare.summary().addedFiles(),
compare.summary().modifiedFiles(),
compare.summary().removedFiles(),
compare.summary().addedLines(),
compare.summary().removedLines()
),
compare.files().stream().map(this::toCompareFileResponse).toList()
);
}
private SkillVersionCompareFileResponse toCompareFileResponse(SkillQueryService.SkillVersionCompareFileDTO file) {
return new SkillVersionCompareFileResponse(
file.path(),
file.changeType(),
file.oldSize(),
file.newSize(),
file.binary(),
file.truncated(),
file.hunks().stream().map(this::toCompareHunkResponse).toList()
);
}
private SkillVersionCompareHunkResponse toCompareHunkResponse(SkillQueryService.SkillVersionCompareHunkDTO hunk) {
return new SkillVersionCompareHunkResponse(
hunk.oldStart(),
hunk.oldLines(),
hunk.newStart(),
hunk.newLines(),
hunk.lines().stream().map(this::toCompareLineResponse).toList()
);
}
private SkillVersionCompareLineResponse toCompareLineResponse(SkillQueryService.SkillVersionCompareLineDTO line) {
return new SkillVersionCompareLineResponse(
line.type(),
line.content(),
line.oldLineNumber(),
line.newLineNumber()
);
}
}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.dto;
import java.util.List;
public record SkillVersionCompareFileResponse(
String path,
String changeType,
Long oldSize,
Long newSize,
boolean binary,
boolean truncated,
List<SkillVersionCompareHunkResponse> hunks
) {}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto;
import java.util.List;
public record SkillVersionCompareHunkResponse(
int oldStart,
int oldLines,
int newStart,
int newLines,
List<SkillVersionCompareLineResponse> lines
) {}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
public record SkillVersionCompareLineResponse(
String type,
String content,
Integer oldLineNumber,
Integer newLineNumber
) {}

View file

@ -0,0 +1,19 @@
package com.iflytek.skillhub.dto;
import java.util.List;
public record SkillVersionCompareResponse(
String from,
String to,
SkillVersionCompareSummaryResponse summary,
List<SkillVersionCompareFileResponse> files
) {
public record SkillVersionCompareSummaryResponse(
int totalFiles,
int addedFiles,
int modifiedFiles,
int removedFiles,
int addedLines,
int removedLines
) {}
}

View file

@ -106,6 +106,7 @@ error.skill.version.notFound=Version not found: {0}
error.skill.version.notPublished=Version is not published: {0}
error.skill.version.delete.unsupported=Only DRAFT, UPLOADED, REJECTED, or SCAN_FAILED versions can be deleted: {0}
error.skill.version.delete.lastVersion=Cannot delete the last remaining version: {0}
error.skill.version.compare.same=Cannot compare a version with itself
error.skill.report.reason.required=Please provide a report reason
error.skill.report.unavailable=This skill cannot be reported right now: {0}
error.skill.report.self=You cannot report your own skill

View file

@ -106,6 +106,7 @@ error.skill.version.notFound=未找到版本:{0}
error.skill.version.notPublished=版本未发布:{0}
error.skill.version.delete.unsupported=只有 DRAFT、UPLOADED、REJECTED 或 SCAN_FAILED 版本可以删除:{0}
error.skill.version.delete.lastVersion=无法删除最后一个版本:{0}
error.skill.version.compare.same=无法对同一版本进行对比
error.skill.report.reason.required=请填写举报原因
error.skill.report.unavailable=当前无法举报该技能:{0}
error.skill.report.self=不能举报自己发布的技能

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.SkillFile;
import com.iflytek.skillhub.domain.skill.SkillVersion;
@ -239,4 +240,65 @@ class SkillControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].downloadAvailable").value(false));
}
@Test
void compareVersionsShouldReturnStructuredDiffEnvelope() throws Exception {
when(skillQueryService.compareVersions(
eq("team"),
eq("demo"),
eq("1.0.0"),
eq("1.1.0"),
eq((String) null),
eq(Map.<Long, NamespaceRole>of())))
.thenReturn(new SkillQueryService.SkillVersionCompareDTO(
"1.0.0",
"1.1.0",
new SkillQueryService.SkillVersionCompareSummaryDTO(1, 0, 1, 0, 2, 1),
List.of(new SkillQueryService.SkillVersionCompareFileDTO(
"README.md",
"MODIFIED",
10L,
12L,
false,
false,
List.of(new SkillQueryService.SkillVersionCompareHunkDTO(
1,
2,
1,
3,
List.of(
new SkillQueryService.SkillVersionCompareLineDTO("CONTEXT", "# Demo", 1, 1),
new SkillQueryService.SkillVersionCompareLineDTO("DELETE", "old", 2, null),
new SkillQueryService.SkillVersionCompareLineDTO("ADD", "new", null, 2)
)))))));
mockMvc.perform(get("/api/v1/skills/team/demo/versions/compare")
.param("from", "1.0.0")
.param("to", "1.1.0"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.from").value("1.0.0"))
.andExpect(jsonPath("$.data.to").value("1.1.0"))
.andExpect(jsonPath("$.data.summary.totalFiles").value(1))
.andExpect(jsonPath("$.data.files[0].path").value("README.md"))
.andExpect(jsonPath("$.data.files[0].hunks[0].lines[1].type").value("DELETE"));
}
@Test
void compareVersionsShouldRejectSameVersion() throws Exception {
when(skillQueryService.compareVersions(
eq("team"),
eq("demo"),
eq("1.0.0"),
eq("1.0.0"),
eq((String) null),
eq(Map.<Long, NamespaceRole>of())))
.thenThrow(new DomainBadRequestException("error.skill.version.compare.same"));
mockMvc.perform(get("/api/v1/skills/team/demo/versions/compare")
.param("from", "1.0.0")
.param("to", "1.0.0"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
}

View file

@ -40,6 +40,11 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>io.github.java-diff-utils</groupId>
<artifactId>java-diff-utils</artifactId>
<version>4.12</version>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>

View file

@ -14,6 +14,10 @@ import com.iflytek.skillhub.domain.skill.*;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.storage.ObjectStorageService;
import com.github.difflib.DiffUtils;
import com.github.difflib.patch.AbstractDelta;
import com.github.difflib.patch.Chunk;
import com.github.difflib.patch.Patch;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
@ -24,6 +28,7 @@ import java.io.UncheckedIOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
@ -31,6 +36,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
/**
@ -124,6 +130,47 @@ public class SkillQueryService {
String manifestJson
) {}
public record SkillVersionCompareDTO(
String from,
String to,
SkillVersionCompareSummaryDTO summary,
List<SkillVersionCompareFileDTO> files
) {}
public record SkillVersionCompareSummaryDTO(
int totalFiles,
int addedFiles,
int modifiedFiles,
int removedFiles,
int addedLines,
int removedLines
) {}
public record SkillVersionCompareFileDTO(
String path,
String changeType,
Long oldSize,
Long newSize,
boolean binary,
boolean truncated,
List<SkillVersionCompareHunkDTO> hunks
) {}
public record SkillVersionCompareHunkDTO(
int oldStart,
int oldLines,
int newStart,
int newLines,
List<SkillVersionCompareLineDTO> lines
) {}
public record SkillVersionCompareLineDTO(
String type,
String content,
Integer oldLineNumber,
Integer newLineNumber
) {}
public record ResolvedVersionDTO(
Long skillId,
String namespace,
@ -268,6 +315,70 @@ public class SkillQueryService {
);
}
public SkillVersionCompareDTO compareVersions(
String namespaceSlug,
String skillSlug,
String fromVersion,
String toVersion,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
if (Objects.equals(fromVersion, toVersion)) {
throw new DomainBadRequestException("error.skill.version.compare.same");
}
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion from = findVersion(skill, fromVersion);
SkillVersion to = findVersion(skill, toVersion);
assertPreviewAccessible(skill, from, fromVersion, currentUserId, userNsRoles);
assertPreviewAccessible(skill, to, toVersion, currentUserId, userNsRoles);
Map<String, SkillFile> fromFiles = availableFiles(from.getId()).stream()
.collect(Collectors.toMap(SkillFile::getFilePath, file -> file));
Map<String, SkillFile> toFiles = availableFiles(to.getId()).stream()
.collect(Collectors.toMap(SkillFile::getFilePath, file -> file));
List<SkillVersionCompareFileDTO> files = new TreeSet<String>() {{
addAll(fromFiles.keySet());
addAll(toFiles.keySet());
}}.stream()
.map(path -> buildCompareFile(fromFiles.get(path), toFiles.get(path)))
.filter(Objects::nonNull)
.toList();
int addedFiles = 0;
int modifiedFiles = 0;
int removedFiles = 0;
int addedLines = 0;
int removedLines = 0;
for (SkillVersionCompareFileDTO file : files) {
if ("ADDED".equals(file.changeType())) {
addedFiles++;
} else if ("REMOVED".equals(file.changeType())) {
removedFiles++;
} else {
modifiedFiles++;
}
for (SkillVersionCompareHunkDTO hunk : file.hunks()) {
for (SkillVersionCompareLineDTO line : hunk.lines()) {
if ("ADD".equals(line.type())) {
addedLines++;
} else if ("DELETE".equals(line.type())) {
removedLines++;
}
}
}
}
return new SkillVersionCompareDTO(
fromVersion,
toVersion,
new SkillVersionCompareSummaryDTO(files.size(), addedFiles, modifiedFiles, removedFiles, addedLines, removedLines),
files);
}
public List<SkillFile> listFiles(
String namespaceSlug,
String skillSlug,
@ -491,6 +602,81 @@ public class SkillQueryService {
SkillSlugResolutionService.Preference.CURRENT_USER);
}
private static final long COMPARE_MAX_FILE_BYTES = 1024 * 1024;
private static final int COMPARE_MAX_LINES = 5000;
private static final Set<String> BINARY_FILE_EXTENSIONS = Set.of(
".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot",
".zip", ".tar", ".gz", ".jar", ".war", ".class", ".so", ".dll", ".exe", ".pdf"
);
private SkillVersionCompareFileDTO buildCompareFile(SkillFile fromFile, SkillFile toFile) {
if (fromFile != null && toFile != null && Objects.equals(fromFile.getSha256(), toFile.getSha256())) {
return null;
}
String path = fromFile != null ? fromFile.getFilePath() : Objects.requireNonNull(toFile).getFilePath();
String changeType = fromFile == null ? "ADDED" : toFile == null ? "REMOVED" : "MODIFIED";
Long oldSize = fromFile != null ? fromFile.getFileSize() : null;
Long newSize = toFile != null ? toFile.getFileSize() : null;
boolean isBinary = isBinaryFile(path);
if (isBinary) {
return new SkillVersionCompareFileDTO(path, changeType, oldSize, newSize, true, false, List.of());
}
String oldContent = fromFile != null ? readTextContent(fromFile) : "";
String newContent = toFile != null ? readTextContent(toFile) : "";
List<String> oldLines = splitLines(oldContent);
List<String> newLines = splitLines(newContent);
boolean isTruncated = (oldSize != null && oldSize > COMPARE_MAX_FILE_BYTES)
|| (newSize != null && newSize > COMPARE_MAX_FILE_BYTES)
|| oldLines.size() > COMPARE_MAX_LINES
|| newLines.size() > COMPARE_MAX_LINES;
if (isTruncated) {
return new SkillVersionCompareFileDTO(path, changeType, oldSize, newSize, false, true, List.of());
}
Patch<String> patch = DiffUtils.diff(oldLines, newLines);
List<SkillVersionCompareHunkDTO> hunks = patch.getDeltas().stream()
.map(this::toCompareHunk)
.toList();
return new SkillVersionCompareFileDTO(path, changeType, oldSize, newSize, false, false, hunks);
}
private SkillVersionCompareHunkDTO toCompareHunk(AbstractDelta<String> delta) {
Chunk<String> source = delta.getSource();
Chunk<String> target = delta.getTarget();
List<SkillVersionCompareLineDTO> lines = new java.util.ArrayList<>();
int oldLine = source.getPosition() + 1;
for (String line : source.getLines()) {
lines.add(new SkillVersionCompareLineDTO("DELETE", line, oldLine++, null));
}
int newLine = target.getPosition() + 1;
for (String line : target.getLines()) {
lines.add(new SkillVersionCompareLineDTO("ADD", line, null, newLine++));
}
return new SkillVersionCompareHunkDTO(
source.getPosition() + 1,
source.size(),
target.getPosition() + 1,
target.size(),
lines);
}
private List<String> splitLines(String content) {
if (content == null || content.isEmpty()) {
return List.of();
}
return Arrays.asList(content.split("\\R", -1));
}
private boolean isBinaryFile(String path) {
String lowerCasePath = path.toLowerCase(java.util.Locale.ROOT);
return BINARY_FILE_EXTENSIONS.stream().anyMatch(lowerCasePath::endsWith);
}
private SkillVersion findVersion(Skill skill, String version) {
return skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));

View file

@ -466,11 +466,26 @@ export class E2eTestDataBuilder {
}
async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise<void> {
await parseEnvelope<ReviewTaskSummary>(
await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, {
data: { comment },
}),
)
let lastError: unknown
for (let attempt = 0; attempt < 30; attempt += 1) {
try {
await parseEnvelope<ReviewTaskSummary>(
await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, {
data: { comment },
}),
)
return
} catch (error) {
lastError = error
const message = error instanceof Error ? error.message : ''
const isScanInProgress = message.includes('扫描') || message.toLowerCase().includes('scan is still in progress')
if (!isScanInProgress) {
throw error
}
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
throw lastError instanceof Error ? lastError : new Error('approveReview timed out')
}
async searchNamespaceMemberCandidates(slug: string, search: string): Promise<NamespaceCandidate[]> {

View file

@ -0,0 +1,74 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { loginWithCredentials, registerSession } from './helpers/session'
import { E2eTestDataBuilder } from './helpers/test-data-builder'
function getOptionalEnv(name: string): string | undefined {
const value = process.env[name]?.trim()
return value ? value : undefined
}
function adminCredentials() {
return {
username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin',
password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026',
}
}
test.describe('Skill Version Compare (Real API)', () => {
test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
await registerSession(page, testInfo)
})
test('opens compare page for two published versions and renders unified diff data', async ({ page, browser }, testInfo) => {
const builder = new E2eTestDataBuilder(page, testInfo)
await builder.init()
const adminContext = await browser.newContext()
const adminPage = await adminContext.newPage()
const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo)
await loginWithCredentials(adminPage, adminCredentials(), testInfo)
await adminBuilder.init()
try {
const namespace = await builder.ensureWritableNamespace()
const skillName = `compare-ui-${Date.now().toString(36)}`
const v1 = await builder.publishSkill(namespace.slug, {
name: skillName,
version: '1.0.0',
readmeHeading: `${skillName} v1`,
})
const firstReviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, v1.slug, v1.version)
await adminBuilder.approveReview(firstReviewTaskId)
const rereleaseResponse = await page.context().request.post(
`/api/web/skills/${encodeURIComponent(namespace.slug)}/${encodeURIComponent(v1.slug)}/versions/${encodeURIComponent(v1.version)}/rerelease`,
{
data: {
targetVersion: '1.1.0',
confirmWarnings: true,
},
}
)
expect(rereleaseResponse.ok()).toBe(true)
const secondReviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, v1.slug, '1.1.0')
await adminBuilder.approveReview(secondReviewTaskId)
await page.goto(`/space/${encodeURIComponent(namespace.slug)}/${encodeURIComponent(v1.slug)}/compare?from=1.0.0&to=1.1.0`)
await expect(page.getByRole('heading', { name: 'Version Compare' })).toBeVisible()
await expect(page.getByLabel('Base version')).toBeVisible()
await expect(page.getByLabel('Head version')).toBeVisible()
await expect(page.getByLabel('File list')).toContainText('SKILL.md')
await expect(page.getByText('Modified').first()).toBeVisible()
await expect(page.getByText('SKILL.md').first()).toBeVisible()
} finally {
await adminBuilder.cleanup()
await adminContext.close()
await builder.cleanup()
}
})
})

View file

@ -45,6 +45,7 @@
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@testing-library/react": "^16.3.2",
"@types/mdast": "^4.0.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@ -55,6 +56,7 @@
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"jsdom": "^29.1.1",
"openapi-typescript": "^7.6.1",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",

406
web/pnpm-lock.yaml generated
View file

@ -87,6 +87,9 @@ importers:
'@playwright/test':
specifier: ^1.58.2
version: 1.58.2
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/mdast':
specifier: ^4.0.4
version: 4.0.4
@ -117,6 +120,9 @@ importers:
eslint-plugin-react-refresh:
specifier: ^0.4.5
version: 0.4.26(eslint@8.57.1)
jsdom:
specifier: ^29.1.1
version: 29.1.1
openapi-typescript:
specifier: ^7.6.1
version: 7.13.0(typescript@5.9.3)
@ -134,7 +140,7 @@ importers:
version: 6.4.1(jiti@1.21.7)
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(jiti@1.21.7)
version: 3.2.4(@types/debug@4.1.12)(jiti@1.21.7)(jsdom@29.1.1)
packages:
@ -142,6 +148,21 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@asamuzakjp/css-color@5.1.11':
resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/dom-selector@7.1.1':
resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/generational-cache@1.0.1':
resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@ -229,6 +250,46 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@csstools/color-helpers@6.0.2':
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
engines: {node: '>=20.19.0'}
'@csstools/css-calc@3.2.0':
resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-color-parser@4.1.0':
resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-parser-algorithms@4.0.0':
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.3':
resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==}
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
'@csstools/css-tokenizer@4.0.0':
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@emotion/babel-plugin@11.13.5':
resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==}
@ -447,6 +508,15 @@ packages:
resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
'@exodus/bytes@1.15.0':
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
@ -995,6 +1065,28 @@ packages:
'@tanstack/store@0.9.2':
resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
'@testing-library/react@16.3.2':
resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
engines: {node: '>=18'}
peerDependencies:
'@testing-library/dom': ^10.0.0
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@ -1173,6 +1265,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
@ -1190,6 +1286,9 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
aria-query@5.3.0:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
array-union@2.1.0:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'}
@ -1224,6 +1323,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
@ -1339,6 +1441,10 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@ -1347,6 +1453,10 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@ -1356,6 +1466,9 @@ packages:
supports-color:
optional: true
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
@ -1394,9 +1507,16 @@ packages:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
electron-to-chromium@1.5.307:
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@ -1616,6 +1736,10 @@ packages:
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
@ -1706,6 +1830,9 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
isbot@5.1.36:
resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==}
engines: {node: '>=18'}
@ -1731,6 +1858,15 @@ packages:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
jsdom@29.1.1:
resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@ -1790,6 +1926,10 @@ packages:
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
lru-cache@11.3.6:
resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@ -1798,6 +1938,10 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@ -1852,6 +1996,9 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
@ -2032,6 +2179,9 @@ packages:
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
engines: {node: '>=18'}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@ -2142,6 +2292,10 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@ -2192,6 +2346,9 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
@ -2294,6 +2451,10 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@ -2394,6 +2555,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tailwind-merge@2.6.1:
resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
@ -2440,10 +2604,25 @@ packages:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
tldts-core@7.0.30:
resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==}
tldts@7.0.30:
resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==}
hasBin: true
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
tough-cookie@6.0.1:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@ -2479,6 +2658,10 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undici@7.25.0:
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
engines: {node: '>=20.18.1'}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@ -2623,6 +2806,22 @@ packages:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
whatwg-url@16.0.1:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@ -2640,6 +2839,13 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@ -2683,6 +2889,26 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@asamuzakjp/css-color@5.1.11':
dependencies:
'@asamuzakjp/generational-cache': 1.0.1
'@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@asamuzakjp/dom-selector@7.1.1':
dependencies:
'@asamuzakjp/generational-cache': 1.0.1
'@asamuzakjp/nwsapi': 2.3.9
bidi-js: 1.0.3
css-tree: 3.2.1
is-potential-custom-element-name: 1.0.1
'@asamuzakjp/generational-cache@1.0.1': {}
'@asamuzakjp/nwsapi@2.3.9': {}
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@ -2797,6 +3023,34 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
'@csstools/color-helpers@6.0.2': {}
'@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/color-helpers': 6.0.2
'@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)':
optionalDependencies:
css-tree: 3.2.1
'@csstools/css-tokenizer@4.0.0': {}
'@emotion/babel-plugin@11.13.5':
dependencies:
'@babel/helper-module-imports': 7.28.6
@ -2972,6 +3226,8 @@ snapshots:
'@eslint/js@8.57.1': {}
'@exodus/bytes@1.15.0': {}
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
@ -3461,6 +3717,29 @@ snapshots:
'@tanstack/store@0.9.2': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/runtime': 7.28.6
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
lz-string: 1.5.0
picocolors: 1.1.1
pretty-format: 27.5.1
'@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@testing-library/dom': 10.4.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@types/aria-query@5.0.4': {}
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.0
@ -3683,6 +3962,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
ansi-styles@5.2.0: {}
any-promise@1.3.0: {}
anymatch@3.1.3:
@ -3698,6 +3979,10 @@ snapshots:
dependencies:
tslib: 2.8.1
aria-query@5.3.0:
dependencies:
dequal: 2.0.3
array-union@2.1.0: {}
assertion-error@2.0.1: {}
@ -3725,6 +4010,10 @@ snapshots:
baseline-browser-mapping@2.10.0: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
binary-extensions@2.3.0: {}
brace-expansion@1.1.12:
@ -3837,16 +4126,30 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
cssesc@3.0.0: {}
csstype@3.2.3: {}
data-urls@7.0.0:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
transitivePeerDependencies:
- '@noble/hashes'
debug@4.4.3(supports-color@10.2.2):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
decimal.js@10.6.0: {}
decode-named-character-reference@1.3.0:
dependencies:
character-entities: 2.0.2
@ -3877,8 +4180,12 @@ snapshots:
dependencies:
esutils: 2.0.3
dom-accessibility-api@0.5.16: {}
electron-to-chromium@1.5.307: {}
entities@8.0.0: {}
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
@ -4162,6 +4469,12 @@ snapshots:
dependencies:
react-is: 16.13.1
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.15.0
transitivePeerDependencies:
- '@noble/hashes'
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
@ -4238,6 +4551,8 @@ snapshots:
is-plain-obj@4.1.0: {}
is-potential-custom-element-name@1.0.1: {}
isbot@5.1.36: {}
isexe@2.0.0: {}
@ -4254,6 +4569,32 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@29.1.1:
dependencies:
'@asamuzakjp/css-color': 5.1.11
'@asamuzakjp/dom-selector': 7.1.1
'@bramus/specificity': 2.4.2
'@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1)
'@exodus/bytes': 1.15.0
css-tree: 3.2.1
data-urls: 7.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 6.0.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.3.6
parse5: 8.0.1
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.1
undici: 7.25.0
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
@ -4301,6 +4642,8 @@ snapshots:
devlop: 1.1.0
highlight.js: 11.11.1
lru-cache@11.3.6: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@ -4309,6 +4652,8 @@ snapshots:
dependencies:
react: 19.2.4
lz-string@1.5.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -4479,6 +4824,8 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
mdn-data@2.27.1: {}
memoize-one@6.0.0: {}
merge2@1.4.1: {}
@ -4782,6 +5129,10 @@ snapshots:
index-to-position: 1.2.0
type-fest: 4.41.0
parse5@8.0.1:
dependencies:
entities: 8.0.0
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
@ -4855,6 +5206,12 @@ snapshots:
prelude-ls@1.2.1: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
ansi-styles: 5.2.0
react-is: 17.0.2
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@ -4906,6 +5263,8 @@ snapshots:
react-is@16.13.1: {}
react-is@17.0.2: {}
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
dependencies:
'@types/hast': 3.0.4
@ -5070,6 +5429,10 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.27.0: {}
semver@6.3.1: {}
@ -5150,6 +5513,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
symbol-tree@3.2.4: {}
tailwind-merge@2.6.1: {}
tailwindcss@3.4.19:
@ -5209,10 +5574,24 @@ snapshots:
tinyspy@4.0.4: {}
tldts-core@7.0.30: {}
tldts@7.0.30:
dependencies:
tldts-core: 7.0.30
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
tough-cookie@6.0.1:
dependencies:
tldts: 7.0.30
tr46@6.0.0:
dependencies:
punycode: 2.3.1
trim-lines@3.0.1: {}
trough@2.2.0: {}
@ -5235,6 +5614,8 @@ snapshots:
typescript@5.9.3: {}
undici@7.25.0: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@ -5349,7 +5730,7 @@ snapshots:
fsevents: 2.3.3
jiti: 1.21.7
vitest@3.2.4(@types/debug@4.1.12)(jiti@1.21.7):
vitest@3.2.4(@types/debug@4.1.12)(jiti@1.21.7)(jsdom@29.1.1):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
@ -5376,6 +5757,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
jsdom: 29.1.1
transitivePeerDependencies:
- jiti
- less
@ -5392,6 +5774,22 @@ snapshots:
void-elements@3.1.0: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
webidl-conversions@8.0.1: {}
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.1:
dependencies:
'@exodus/bytes': 1.15.0
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
which@2.0.2:
dependencies:
isexe: 2.0.0
@ -5405,6 +5803,10 @@ snapshots:
wrappy@1.0.2: {}
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
yallist@3.1.1: {}
yaml-ast-parser@0.0.43: {}

View file

@ -4,6 +4,38 @@
*/
export interface paths {
"/api/v1/skills/{skillId}/subscription": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["checkSubscribed"];
put: operations["subscribeSkill"];
post?: never;
delete: operations["unsubscribeSkill"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/web/skills/{skillId}/subscription": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["checkSubscribed_1"];
put: operations["subscribeSkill_1"];
post?: never;
delete: operations["unsubscribeSkill_1"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/web/skills/{skillId}/star": {
parameters: {
query?: never;
@ -1764,6 +1796,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/web/skills/{namespace}/{slug}/versions/compare": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["compareVersions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/skills/{namespace}/{slug}/versions/compare": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["compareVersions_1"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/skills/{namespace}/{slug}/versions": {
parameters: {
query?: never;
@ -2436,6 +2500,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/web/me/subscriptions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listMySubscriptions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/subscriptions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listMySubscriptions_1"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/web/me/stars": {
parameters: {
query?: never;
@ -3778,6 +3874,7 @@ export interface components {
slug?: string;
displayName?: string;
summary?: string;
visibility?: string;
status?: string;
/** Format: int64 */
downloadCount?: number;
@ -3860,6 +3957,65 @@ export interface components {
parsedMetadataJson?: string;
manifestJson?: string;
};
ApiResponseSkillVersionCompareResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["SkillVersionCompareResponse"];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
SkillVersionCompareFileResponse: {
path?: string;
changeType?: string;
/** Format: int64 */
oldSize?: number;
/** Format: int64 */
newSize?: number;
binary?: boolean;
truncated?: boolean;
hunks?: components["schemas"]["SkillVersionCompareHunkResponse"][];
};
SkillVersionCompareHunkResponse: {
/** Format: int32 */
oldStart?: number;
/** Format: int32 */
oldLines?: number;
/** Format: int32 */
newStart?: number;
/** Format: int32 */
newLines?: number;
lines?: components["schemas"]["SkillVersionCompareLineResponse"][];
};
SkillVersionCompareLineResponse: {
type?: string;
content?: string;
/** Format: int32 */
oldLineNumber?: number;
/** Format: int32 */
newLineNumber?: number;
};
SkillVersionCompareResponse: {
from?: string;
to?: string;
summary?: components["schemas"]["SkillVersionCompareSummaryResponse"];
files?: components["schemas"]["SkillVersionCompareFileResponse"][];
};
SkillVersionCompareSummaryResponse: {
/** Format: int32 */
totalFiles?: number;
/** Format: int32 */
addedFiles?: number;
/** Format: int32 */
modifiedFiles?: number;
/** Format: int32 */
removedFiles?: number;
/** Format: int32 */
addedLines?: number;
/** Format: int32 */
removedLines?: number;
};
ApiResponsePageResponseSkillVersionResponse: {
/** Format: int32 */
code?: number;
@ -3954,6 +4110,8 @@ export interface components {
downloadCount?: number;
/** Format: int32 */
starCount?: number;
/** Format: int32 */
subscriptionCount?: number;
ratingAvg?: number;
/** Format: int32 */
ratingCount?: number;
@ -4649,6 +4807,138 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
checkSubscribed: {
parameters: {
query?: never;
header?: never;
path: {
skillId: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseBoolean"];
};
};
};
};
subscribeSkill: {
parameters: {
query?: never;
header?: never;
path: {
skillId: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseVoid"];
};
};
};
};
unsubscribeSkill: {
parameters: {
query?: never;
header?: never;
path: {
skillId: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseVoid"];
};
};
};
};
checkSubscribed_1: {
parameters: {
query?: never;
header?: never;
path: {
skillId: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseBoolean"];
};
};
};
};
subscribeSkill_1: {
parameters: {
query?: never;
header?: never;
path: {
skillId: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseVoid"];
};
};
};
};
unsubscribeSkill_1: {
parameters: {
query?: never;
header?: never;
path: {
skillId: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseVoid"];
};
};
};
};
checkStarred: {
parameters: {
query?: never;
@ -8047,6 +8337,58 @@ export interface operations {
};
};
};
compareVersions: {
parameters: {
query: {
from: string;
to: string;
};
header?: never;
path: {
namespace: string;
slug: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillVersionCompareResponse"];
};
};
};
};
compareVersions_1: {
parameters: {
query: {
from: string;
to: string;
};
header?: never;
path: {
namespace: string;
slug: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillVersionCompareResponse"];
};
};
};
};
listVersions: {
parameters: {
query?: {
@ -9077,6 +9419,52 @@ export interface operations {
};
};
};
listMySubscriptions: {
parameters: {
query?: {
page?: number;
size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"];
};
};
};
};
listMySubscriptions_1: {
parameters: {
query?: {
page?: number;
size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"];
};
};
};
};
listMyStars: {
parameters: {
query?: {

View file

@ -274,6 +274,47 @@ export interface SkillFile {
sha256: string
}
export interface SkillVersionCompareLine {
type: 'CONTEXT' | 'ADD' | 'DELETE' | string
content: string
oldLineNumber: number | null
newLineNumber: number | null
}
export interface SkillVersionCompareHunk {
oldStart: number
oldLines: number
newStart: number
newLines: number
lines: SkillVersionCompareLine[]
}
export interface SkillVersionCompareFile {
path: string
changeType: 'ADDED' | 'MODIFIED' | 'REMOVED' | string
oldSize: number | null
newSize: number | null
binary: boolean
truncated: boolean
hunks: SkillVersionCompareHunk[]
}
export interface SkillVersionCompareSummary {
totalFiles: number
addedFiles: number
modifiedFiles: number
removedFiles: number
addedLines: number
removedLines: number
}
export interface SkillVersionCompare {
from: string
to: string
summary: SkillVersionCompareSummary
files: SkillVersionCompareFile[]
}
export interface SkillTag {
id: number
tagName: string

View file

@ -39,4 +39,10 @@ describe('router', () => {
// In test environment, flatRoutes may not be populated until router is used
expect(router.routeTree).toBeDefined()
})
it('registers the skill version compare route', () => {
const children = (router.routeTree.children ?? []) as Array<{ fullPath?: string; path?: string }>
const childPaths = children.map((route) => route.fullPath ?? route.path)
expect(childPaths).toContain('/space/$namespace/$slug/compare')
})
})

View file

@ -71,6 +71,7 @@ const SearchPage = createLazyRouteComponent(() => import('@/pages/search'), 'Sea
const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage')
const NamespacePage = createLazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage')
const SkillDetailPage = createLazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage')
const SkillVersionComparePage = createLazyRouteComponent(() => import('@/pages/skill-version-compare'), 'SkillVersionComparePage')
const DashboardPage = createLazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage')
const MySkillsPage = createLazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage')
const PublishPage = createLazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage')
@ -231,6 +232,16 @@ const skillDetailRoute = createRoute({
component: SkillDetailPage,
})
const skillVersionCompareRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/space/$namespace/$slug/compare',
validateSearch: (search: Record<string, unknown>): { from: string; to: string } => ({
from: typeof search.from === 'string' ? search.from : '',
to: typeof search.to === 'string' ? search.to : '',
}),
component: SkillVersionComparePage,
})
const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard',
@ -424,6 +435,7 @@ const routeTree = rootRoute.addChildren([
termsRoute,
namespaceRoute,
skillDetailRoute,
skillVersionCompareRoute,
dashboardRoute,
dashboardSkillsRoute,
dashboardPublishRoute,

View file

@ -963,6 +963,27 @@
"defaultDescription": "A useful skill"
}
},
"skillCompare": {
"pageTitle": "Version Compare",
"filesChanged": "{{count}} files changed",
"searchFiles": "Search files",
"errorLoadingCompare": "Failed to load version comparison",
"loadDiff": "Load diff",
"loading": "Loading...",
"totalFiles": "Files",
"addedLines": "Added lines",
"removedLines": "Removed lines",
"baseVersion": "Base version",
"headVersion": "Head version",
"fileList": "File list",
"binaryFile": "Binary file — cannot display diff",
"truncatedFile": "Diff output is truncated",
"notEnoughPublishedVersions": "Publish at least two versions before comparing.",
"noFilesFound": "No matching files found.",
"changeTypeAdded": "Added",
"changeTypeModified": "Modified",
"changeTypeRemoved": "Removed"
},
"reports": {
"title": "Skill Reports",
"subtitle": "Handle user-submitted skill reports",

View file

@ -964,6 +964,27 @@
"defaultDescription": "实用技能"
}
},
"skillCompare": {
"pageTitle": "版本对比",
"filesChanged": "{{count}} 个文件变更",
"searchFiles": "搜索文件",
"errorLoadingCompare": "加载版本对比失败",
"loadDiff": "加载差异",
"loading": "加载中...",
"totalFiles": "文件数",
"addedLines": "新增行",
"removedLines": "删除行",
"baseVersion": "基础版本",
"headVersion": "目标版本",
"fileList": "文件列表",
"binaryFile": "二进制文件,无法展示差异",
"truncatedFile": "差异输出已截断",
"notEnoughPublishedVersions": "至少发布两个版本后才能进行对比。",
"noFilesFound": "未找到匹配文件。",
"changeTypeAdded": "新增",
"changeTypeModified": "修改",
"changeTypeRemoved": "删除"
},
"reports": {
"title": "技能举报",
"subtitle": "处理用户提交的技能举报",

View file

@ -618,8 +618,11 @@ export function SkillDetailPage() {
toast.error(t('skillDetail.versionCompareUnavailableTitle'), t('skillDetail.versionCompareUnavailableDescription'))
return
}
setDiffSourceVersion(version)
setDiffCompareVersion(compareVersion)
navigate({
to: '/space/$namespace/$slug/compare',
params: { namespace, slug },
search: { from: version, to: compareVersion },
})
}
const handleSubmitPromotion = async () => {

View file

@ -0,0 +1,145 @@
/** @vitest-environment jsdom */
import { renderToStaticMarkup } from 'react-dom/server'
import { render, screen, fireEvent } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const useSkillVersionCompareMock = vi.fn()
const useSkillVersionsMock = vi.fn()
const navigateMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
useParams: () => ({ namespace: 'global', slug: 'demo-skill' }),
useSearch: () => ({ from: '1.0.0', to: '1.1.0' }),
}))
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
}),
}
})
vi.mock('@/shared/hooks/use-skill-queries', () => ({
useSkillVersionCompare: (...args: unknown[]) => useSkillVersionCompareMock(...args),
useSkillVersions: (...args: unknown[]) => useSkillVersionsMock(...args),
}))
import { SkillVersionComparePage } from './skill-version-compare'
describe('SkillVersionComparePage', () => {
beforeEach(() => {
navigateMock.mockReset()
useSkillVersionsMock.mockReturnValue({
data: [
{ id: 10, version: '1.0.0', status: 'PUBLISHED', changelog: '', fileCount: 1, totalSize: 10, publishedAt: '2026-01-01T00:00:00Z', downloadAvailable: true },
{ id: 11, version: '1.1.0', status: 'PUBLISHED', changelog: '', fileCount: 1, totalSize: 12, publishedAt: '2026-01-02T00:00:00Z', downloadAvailable: true },
{ id: 12, version: '1.2.0-rc.1', status: 'UPLOADED', changelog: '', fileCount: 2, totalSize: 22, publishedAt: '2026-01-03T00:00:00Z', downloadAvailable: false },
],
isLoading: false,
})
useSkillVersionCompareMock.mockReturnValue({
data: {
from: '1.0.0',
to: '1.1.0',
summary: { totalFiles: 2, addedFiles: 1, modifiedFiles: 1, removedFiles: 0, addedLines: 4, removedLines: 2 },
files: [
{
path: 'README.md',
changeType: 'MODIFIED',
oldSize: 10,
newSize: 12,
binary: false,
truncated: false,
hunks: [
{
oldStart: 1,
oldLines: 1,
newStart: 1,
newLines: 1,
lines: [
{ type: 'DELETE', content: 'old', oldLineNumber: 1, newLineNumber: null },
{ type: 'ADD', content: 'new', oldLineNumber: null, newLineNumber: 1 },
],
},
],
},
{
path: 'src/index.ts',
changeType: 'ADDED',
oldSize: null,
newSize: 50,
binary: false,
truncated: false,
hunks: [
{
oldStart: 0,
oldLines: 0,
newStart: 1,
newLines: 1,
lines: [{ type: 'ADD', content: 'console.log(1)', oldLineNumber: null, newLineNumber: 1 }],
},
],
},
],
},
isLoading: false,
error: null,
})
})
it('renders compare summary and files', () => {
const html = renderToStaticMarkup(<SkillVersionComparePage />)
expect(html).toContain('skillCompare.totalFiles')
expect(html).toContain('+4')
expect(html).toContain('-2')
expect(html).toContain('README.md')
expect(html).toContain('src/index.ts')
})
it('renders search input and version selectors from published versions', () => {
const html = renderToStaticMarkup(<SkillVersionComparePage />)
expect(html).toContain('aria-label="skillCompare.searchFiles"')
expect(html).toContain('aria-label="skillCompare.baseVersion"')
expect(html).toContain('aria-label="skillCompare.headVersion"')
expect(html).toContain('v1.0.0')
expect(html).toContain('v1.1.0')
expect(html).not.toContain('v1.2.0-rc.1')
})
it('switches active file marker when a file item is clicked', () => {
render(<SkillVersionComparePage />)
const readmeLink = screen.getByRole('link', { name: 'README.md' })
const sourceLink = screen.getByRole('link', { name: 'src/index.ts' })
expect(readmeLink.getAttribute('aria-current')).toBe('true')
expect(sourceLink.getAttribute('aria-current')).toBeNull()
fireEvent.click(sourceLink)
expect(sourceLink.getAttribute('aria-current')).toBe('true')
expect(readmeLink.getAttribute('aria-current')).toBeNull()
})
it('shows an empty state when there are not enough published versions', () => {
useSkillVersionsMock.mockReturnValueOnce({
data: [
{ id: 10, version: '1.0.0', status: 'PUBLISHED', changelog: '', fileCount: 1, totalSize: 10, publishedAt: '2026-01-01T00:00:00Z', downloadAvailable: true },
],
isLoading: false,
})
const html = renderToStaticMarkup(<SkillVersionComparePage />)
expect(html).toContain('skillCompare.notEnoughPublishedVersions')
})
})

View file

@ -0,0 +1,292 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useSkillVersionCompare, useSkillVersions } from '@/shared/hooks/use-skill-queries'
import { Input } from '@/shared/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
function toDiffSectionId(path: string) {
return `diff-${encodeURIComponent(path)}`
}
function getChangeTypeLabel(t: (key: string) => string, changeType: string) {
if (changeType === 'ADDED') {
return t('skillCompare.changeTypeAdded')
}
if (changeType === 'REMOVED') {
return t('skillCompare.changeTypeRemoved')
}
return t('skillCompare.changeTypeModified')
}
export function SkillVersionComparePage() {
const { t } = useTranslation()
const navigate = useNavigate()
const { namespace, slug } = useParams({ from: '/space/$namespace/$slug/compare' })
const { from, to } = useSearch({ from: '/space/$namespace/$slug/compare' }) as { from: string; to: string }
const [keyword, setKeyword] = useState('')
const [activePath, setActivePath] = useState<string | null>(null)
const { data: versions, isLoading: isVersionsLoading } = useSkillVersions(namespace, slug)
const publishedVersions = useMemo(
() => (versions ?? []).filter((version) => version.status === 'PUBLISHED'),
[versions]
)
const hasEnoughVersions = publishedVersions.length >= 2
const defaultFrom = publishedVersions[1]?.version ?? publishedVersions[0]?.version ?? ''
const defaultTo = publishedVersions[0]?.version ?? ''
const normalizedVersions = useMemo(() => {
if (!hasEnoughVersions) {
return { from: '', to: '' }
}
const isPublishedVersion = (value: string) => publishedVersions.some((version) => version.version === value)
let nextFrom = from && isPublishedVersion(from) ? from : defaultFrom
let nextTo = to && isPublishedVersion(to) ? to : defaultTo
if (nextFrom === nextTo) {
const alternative = publishedVersions.find((version) => version.version !== nextFrom)?.version ?? ''
if (!from && !to) {
nextFrom = defaultFrom
nextTo = defaultTo
} else if (from && !to) {
nextTo = alternative
} else if (!from && to) {
nextFrom = alternative
} else {
nextTo = alternative
}
}
return { from: nextFrom, to: nextTo }
}, [defaultFrom, defaultTo, from, hasEnoughVersions, publishedVersions, to])
useEffect(() => {
if (!hasEnoughVersions || !normalizedVersions.from || !normalizedVersions.to) {
return
}
if (from === normalizedVersions.from && to === normalizedVersions.to) {
return
}
navigate({
to: '/space/$namespace/$slug/compare',
params: { namespace, slug },
search: { from: normalizedVersions.from, to: normalizedVersions.to },
replace: true,
})
}, [from, hasEnoughVersions, namespace, navigate, normalizedVersions.from, normalizedVersions.to, slug, to])
const { data, isLoading: isCompareLoading, error } = useSkillVersionCompare(
namespace,
slug,
normalizedVersions.from,
normalizedVersions.to,
hasEnoughVersions && !!normalizedVersions.from && !!normalizedVersions.to
)
const filteredFiles = useMemo(() => {
const files = data?.files ?? []
if (!keyword.trim()) {
return files
}
return files.filter((file) => file.path.toLowerCase().includes(keyword.trim().toLowerCase()))
}, [data?.files, keyword])
useEffect(() => {
if (!activePath && filteredFiles.length > 0) {
setActivePath(filteredFiles[0].path)
return
}
if (activePath && !filteredFiles.some((file) => file.path === activePath)) {
setActivePath(filteredFiles[0]?.path ?? null)
}
}, [activePath, filteredFiles])
if (isVersionsLoading) {
return <div className="py-10 text-sm text-muted-foreground">{t('skillCompare.loading')}</div>
}
if (!hasEnoughVersions) {
return <div className="py-10 text-sm text-muted-foreground">{t('skillCompare.notEnoughPublishedVersions')}</div>
}
if (isCompareLoading) {
return <div className="py-10 text-sm text-muted-foreground">{t('skillCompare.loading')}</div>
}
if (error || !data) {
return <div className="py-10 text-sm text-muted-foreground">{t('skillCompare.errorLoadingCompare')}</div>
}
return (
<div className="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-6 lg:flex-row">
<aside className="w-full shrink-0 space-y-4 rounded-xl border border-border/60 p-4 lg:w-80">
<div>
<h1 className="text-lg font-semibold text-foreground">{t('skillCompare.pageTitle')}</h1>
<p className="mt-1 text-sm text-muted-foreground">v{data.from} v{data.to}</p>
</div>
<div className="grid grid-cols-3 gap-2 rounded-lg border border-border/50 p-3 text-sm">
<div>
<div className="text-xs text-muted-foreground">{t('skillCompare.totalFiles')}</div>
<div className="font-medium text-foreground">{data.summary.totalFiles}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">{t('skillCompare.addedLines')}</div>
<div className="font-medium text-emerald-600">+{data.summary.addedLines}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">{t('skillCompare.removedLines')}</div>
<div className="font-medium text-rose-600">-{data.summary.removedLines}</div>
</div>
</div>
<div className="space-y-2">
<label className="text-xs text-muted-foreground" htmlFor="compare-from-select">
{t('skillCompare.baseVersion')}
</label>
<Select
value={normalizedVersions.from}
onValueChange={(value) => {
navigate({
to: '/space/$namespace/$slug/compare',
params: { namespace, slug },
search: { from: value, to: normalizedVersions.to },
})
}}
>
<SelectTrigger id="compare-from-select" aria-label={t('skillCompare.baseVersion')}>
<SelectValue placeholder={t('skillCompare.baseVersion')} />
</SelectTrigger>
<SelectContent>
{publishedVersions.map((version) => (
<SelectItem key={version.id} value={version.version} disabled={version.version === normalizedVersions.to}>
v{version.version}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs text-muted-foreground" htmlFor="compare-to-select">
{t('skillCompare.headVersion')}
</label>
<Select
value={normalizedVersions.to}
onValueChange={(value) => {
navigate({
to: '/space/$namespace/$slug/compare',
params: { namespace, slug },
search: { from: normalizedVersions.from, to: value },
})
}}
>
<SelectTrigger id="compare-to-select" aria-label={t('skillCompare.headVersion')}>
<SelectValue placeholder={t('skillCompare.headVersion')} />
</SelectTrigger>
<SelectContent>
{publishedVersions.map((version) => (
<SelectItem key={version.id} value={version.version} disabled={version.version === normalizedVersions.from}>
v{version.version}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder={t('skillCompare.searchFiles')}
aria-label={t('skillCompare.searchFiles')}
/>
<nav className="space-y-1 text-sm" aria-label={t('skillCompare.fileList')}>
{filteredFiles.length > 0 ? (
filteredFiles.map((file, index) => {
const isActive = activePath ? activePath === file.path : index === 0
return (
<a
key={file.path}
href={`#${toDiffSectionId(file.path)}`}
onClick={() => setActivePath(file.path)}
aria-current={isActive ? 'true' : undefined}
data-active={isActive ? 'true' : 'false'}
className={[
'block rounded-md border px-3 py-2 font-mono text-xs',
isActive
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-border/50 text-muted-foreground hover:text-foreground',
].join(' ')}
>
{file.path}
</a>
)
})
) : (
<div className="rounded-md border border-dashed border-border/50 px-3 py-2 text-xs text-muted-foreground">
{keyword.trim() ? t('skillCompare.noFilesFound') : t('skillCompare.noFilesFound')}
</div>
)}
</nav>
</aside>
<main className="min-w-0 flex-1 space-y-6">
{filteredFiles.length > 0 ? (
filteredFiles.map((file) => (
<section key={file.path} id={toDiffSectionId(file.path)} className="rounded-xl border border-border/60 p-4">
<header className="mb-3 flex items-center justify-between gap-4">
<div className="font-mono text-sm text-foreground">{file.path}</div>
<div className="text-xs text-muted-foreground">{getChangeTypeLabel(t, file.changeType)}</div>
</header>
{file.binary ? (
<div className="text-sm text-muted-foreground">{t('skillCompare.binaryFile')}</div>
) : file.truncated ? (
<div className="text-sm text-muted-foreground">{t('skillCompare.truncatedFile')}</div>
) : (
<div className="overflow-hidden rounded-md border border-border/40 font-mono text-sm">
{file.hunks.flatMap((hunk) => hunk.lines).map((line, index) => (
<div
key={`${file.path}-${index}`}
className={[
'grid grid-cols-[56px_56px_1fr] gap-0',
line.type === 'ADD' ? 'bg-emerald-50 dark:bg-emerald-950/30' : '',
line.type === 'DELETE' ? 'bg-rose-50 dark:bg-rose-950/30' : '',
].join(' ')}
>
<span className="select-none border-r border-border/30 px-2 py-0.5 text-right text-xs text-muted-foreground">
{line.oldLineNumber ?? ''}
</span>
<span className="select-none border-r border-border/30 px-2 py-0.5 text-right text-xs text-muted-foreground">
{line.newLineNumber ?? ''}
</span>
<span
className={[
'whitespace-pre overflow-x-auto px-3 py-0.5',
line.type === 'ADD' ? 'text-emerald-700 dark:text-emerald-400' : '',
line.type === 'DELETE' ? 'text-rose-700 dark:text-rose-400' : '',
line.type === 'CONTEXT' ? 'text-foreground' : '',
].join(' ')}
>
{line.type === 'ADD' ? '+' : line.type === 'DELETE' ? '-' : ' '}
{line.content}
</span>
</div>
))}
</div>
)}
</section>
))
) : (
<div className="rounded-xl border border-border/60 p-8 text-sm text-muted-foreground">
{t('skillCompare.noFilesFound')}
</div>
)}
</main>
</div>
)
}

View file

@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult } from '@/api/types'
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillVersionCompare, SkillFile, SearchParams, PagedResponse, PublishResult } from '@/api/types'
import { fetchJson, fetchText, getCsrfHeaders, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client'
import { clearDeletedSkillQueries } from '@/features/skill/skill-delete-flow'
import { getSkillDetailQueryKey } from './query-keys'
@ -32,6 +32,13 @@ async function getSkillVersionDetail(namespace: string, slug: string, version: s
return fetchJson<SkillVersionDetail>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`)
}
async function getSkillVersionCompare(namespace: string, slug: string, from: string, to: string): Promise<SkillVersionCompare> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
return fetchJson<SkillVersionCompare>(
`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/compare?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`
)
}
async function getSkillDocumentation(namespace: string, slug: string, version: string, path: string): Promise<string> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
return fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/file?path=${encodeURIComponent(path)}`)
@ -116,6 +123,14 @@ export function useSkillVersionDetail(namespace: string, slug: string, version?:
})
}
export function useSkillVersionCompare(namespace: string, slug: string, from?: string, to?: string, enabled = true) {
return useQuery({
queryKey: ['skills', namespace, slug, 'versions', 'compare', from, to],
queryFn: () => getSkillVersionCompare(namespace, slug, from!, to!),
enabled: enabled && !!namespace && !!slug && !!from && !!to,
})
}
export function usePublishSkill() {
const queryClient = useQueryClient()