fix(publish): preserve package preview and version presentation compatibility

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-15 16:10:42 +08:00
parent 1c164c8702
commit 793a519bdb
9 changed files with 82 additions and 7 deletions

View file

@ -49,7 +49,8 @@ public class SkillPublishController extends BaseApiController {
throw new DomainForbiddenException("error.auth.required");
}
try {
return ok("response.success.read", packagePreview.preview(skillPackageArchiveExtractor.extract(file)));
return ok("response.success.read", packagePreview.preview(
skillPackageArchiveExtractor.extractWithWarnings(file).entries()));
} catch (IllegalArgumentException exception) {
throw new DomainBadRequestException("error.skill.publish.package.invalid", exception.getMessage());
}

View file

@ -261,6 +261,33 @@ class SkillPublishControllerTest {
org.mockito.Mockito.verifyNoInteractions(skillPublishService);
}
@Test
void packagePreviewNormalizesNestedSkillAlongsideRootReadmeLikePublish() throws Exception {
byte[] archive;
try (var output = new ByteArrayOutputStream(); var zip = new ZipOutputStream(output)) {
zip.putNextEntry(new ZipEntry("README.md"));
zip.write("Repository overview".getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
zip.putNextEntry(new ZipEntry("foo/SKILL.md"));
zip.write("---\nname: foo\ndescription: Nested skill\nversion: 1.0.0\nmetadata:\n display-name: 嵌套技能\n---\nBody".getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
zip.finish();
archive = output.toByteArray();
}
var principal = new PlatformPrincipal("preview_nested", "publisher", "publisher@example.com", "", "local", Set.of());
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
for (String base : List.of("/api/v1", "/api/web")) {
mockMvc.perform(multipart(base + "/skills/package-preview")
.file(new MockMultipartFile("file", "skill.zip", "application/zip", archive))
.with(authentication(auth)).with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.name").value("foo"))
.andExpect(jsonPath("$.data.displayName").value("嵌套技能"))
.andExpect(jsonPath("$.data.description").value("Nested skill"));
}
org.mockito.Mockito.verifyNoInteractions(skillPublishService);
}
@Test
void packagePreviewRejectsAnonymousWithoutPublishing() throws Exception {
mockMvc.perform(multipart("/api/v1/skills/package-preview")

View file

@ -1831,6 +1831,7 @@
"copyright": "© 2026 SkillHub. Apache-2.0."
},
"publish": {
"packageName": "Package name",
"displayName": "Display name",
"displayNamePlaceholder": "For example: Meeting notes assistant",
"displayNameHelp": "Up to 200 characters. Defaults to the package title. Changes follow this version’s review and do not alter the identifier or package files. Leave blank to use the package title.",

View file

@ -1822,6 +1822,7 @@
"goToPage": "Перейти на страницу {{page}}"
},
"publish": {
"packageName": "Имя в пакете",
"displayName": "Отображаемое имя",
"displayNamePlaceholder": "Например: Помощник для протоколов встреч",
"displayNameHelp": "До 200 символов. По умолчанию — имя из пакета. Изменения проходят проверку версии и не меняют идентификатор или файлы пакета. Пустое поле означает имя из пакета.",

View file

@ -1830,6 +1830,7 @@
"copyright": "© 2026 SkillHub. Apache-2.0."
},
"publish": {
"packageName": "包内名称",
"displayName": "显示名称",
"displayNamePlaceholder": "例如:会议纪要助手",
"displayNameHelp": "支持中文,最多 200 个字符。默认读取包内名称;修改后随本次版本审核,不改变技能标识或包内文件。留空则使用包内名称。",

View file

@ -330,7 +330,7 @@ export function PublishPage() {
<p>{t('publish.packagePreviewFailed')}</p>
<Button type="button" size="sm" variant="outline" onClick={() => packagePreview.refetch()}>{t('common.retry')}</Button>
</div>}
{selectedFile && packagePreview.data && <p className="break-all font-mono text-xs text-muted-foreground">@{namespaceSlug || '…'}/{packagePreview.data.name}</p>}
{selectedFile && packagePreview.data && <p className="break-all text-xs text-muted-foreground">{t('publish.packageName')}: <code>{packagePreview.data.name}</code></p>}
</div>
<SkillAvatarEditor key={`${namespaceSlug}/${updateSlug}`} name={search.updateName ?? updateSlug} currentUrl={avatarTarget.data?.avatarUrl} canRemove={!!updateSlug} disabled={isBusy || avatarUnavailable} onChange={setAvatarEdit} />

View file

@ -11,6 +11,7 @@ import { FileTree } from '@/features/skill/file-tree'
import { FilePreviewDialog } from '@/features/skill/file-preview-dialog'
import type { FileTreeNode } from '@/features/skill/file-tree-builder'
import type { SkillFile } from '@/api/types'
import { getVersionPresentation } from '@/shared/lib/version-presentation'
import { CopyButton } from '@/shared/components/copy-button'
import { InstallCommand } from '@/features/skill/install-command'
import { ShareButton } from '@/features/skill/share-button'
@ -200,10 +201,13 @@ export function SkillDetailPage() {
: headlineVersion?.version ?? versions?.[0]?.version
const selectedVersionEntry = versions?.find((version) => version.version === selectedVersion) ?? versions?.[0]
const selectedMetadata = selectedVersion === ownerPreviewVersion?.version ? ownerPreviewVersion
: selectedVersion === publishedVersion?.version ? publishedVersion : headlineVersion
const selectedDisplayName = selectedMetadata?.displayName ?? skill?.displayName
const selectedSummary = selectedMetadata?.summary ?? skill?.summary
const showingUnpublishedPreview = selectedMetadata != null && selectedMetadata.status !== 'PUBLISHED'
: selectedVersion === publishedVersion?.version ? publishedVersion
: selectedVersion === headlineVersion?.version ? headlineVersion : null
const { data: selectedVersionDetail } = useSkillVersionDetail(qns, qslug, selectedVersion, skillReady && !selectedMetadata)
const historicalMetadata = getVersionPresentation(selectedVersionDetail?.parsedMetadataJson)
const selectedDisplayName = selectedMetadata?.displayName ?? historicalMetadata.displayName ?? skill?.displayName
const selectedSummary = selectedMetadata?.summary ?? historicalMetadata.summary ?? skill?.summary
const showingUnpublishedPreview = selectedVersionEntry != null && selectedVersionEntry.status !== 'PUBLISHED'
const { data: files } = useSkillFiles(qns, qslug, selectedVersion, skillReady)
const documentationPath = resolveDocumentationFilePath(files)
@ -1197,7 +1201,7 @@ export function SkillDetailPage() {
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">{t('skillDetail.version')}</div>
<div className="max-w-[11rem] break-all text-right font-mono font-semibold leading-snug text-foreground">
{headlineVersion ? `v${headlineVersion.version}` : '—'}
{selectedVersion ? `v${selectedVersion}` : '—'}
</div>
</div>

View file

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { getVersionPresentation } from './version-presentation'
describe('historical version presentation', () => {
it('uses the explicit version override before original package metadata', () => {
expect(getVersionPresentation(JSON.stringify({ name: 'slug', description: '旧版摘要', displayNameOverride: ' 网页名 ', frontmatter: { metadata: { 'display-name': '包内名' } } })))
.toEqual({ displayName: '网页名', summary: '旧版摘要' })
})
it('keeps old package-only snapshots readable and falls back for unsupported metadata', () => {
expect(getVersionPresentation(JSON.stringify({ name: 'slug', frontmatter: { metadata: { 'display-name': '包内名' } } })).displayName).toBe('包内名')
for (const value of [123, '长'.repeat(201), null, ' ']) {
expect(getVersionPresentation(JSON.stringify({ name: 'slug', frontmatter: { metadata: { 'display-name': value } } })).displayName).toBe('slug')
}
})
it('tolerates absent, malformed and non-object snapshots', () => {
for (const value of [undefined, '', '{', 'null', '[]', 'true']) expect(getVersionPresentation(value)).toEqual({})
})
})

View file

@ -0,0 +1,22 @@
/** Read legacy version snapshots without rewriting the package or trusting arbitrary JSON. */
export function getVersionPresentation(snapshot?: string): { displayName?: string; summary?: string } {
if (!snapshot) return {}
try {
const value: unknown = JSON.parse(snapshot)
if (!isObject(value)) return {}
const frontmatter = isObject(value.frontmatter) ? value.frontmatter : {}
const metadata = isObject(frontmatter.metadata) ? frontmatter.metadata : {}
const displayName = validName(value.displayNameOverride) ?? validName(metadata['display-name'])
?? (typeof value.name === 'string' ? value.name : undefined)
return { displayName, summary: typeof value.description === 'string' ? value.description : undefined }
} catch {
return {}
}
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function validName(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 && value.trim().length <= 200 ? value.trim() : undefined
}