mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-15 09:11:18 +00:00
Merge d6a76e5fe8 into 81a48b4dde
This commit is contained in:
commit
4d81e47176
66 changed files with 4027 additions and 531 deletions
|
|
@ -37,6 +37,20 @@ skillhub publish ./my-skill --namespace myspace
|
|||
skillhub sync pull --namespace myspace
|
||||
```
|
||||
|
||||
## Presentation metadata for published packages
|
||||
|
||||
Add optional `metadata.short-description` (up to 200 characters) and `metadata.overview` (Markdown, up to 20,000 characters) to `SKILL.md` frontmatter. The former is the card/search summary; the latter is the detail-page overview. Neither changes the original required `description`, slug, or archive contents. If absent, summary uses `description` and the detail view uses README.md then SKILL.md. CLI publication reads these package keys directly; it does not need web-only multipart fields. Custom web values belong to version snapshots, not the package.
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: my-skill
|
||||
description: When to use this skill
|
||||
metadata:
|
||||
short-description: Brief card text
|
||||
overview: "# Detailed overview"
|
||||
---
|
||||
```
|
||||
|
||||
## 🌐 Registry Configuration
|
||||
|
||||
The active registry is resolved in the following priority order:
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ WHERE (visibility = 'PUBLIC')
|
|||
| title | varchar(256) | |
|
||||
| summary | varchar(512) | |
|
||||
| keywords | varchar(512) | |
|
||||
| search_text | text | `displayName`、`slug`、`summary`,以及 frontmatter 中除 `name` / `description` / `version` 外的字段展开结果 |
|
||||
| search_text | text | `displayName`、`slug`、有效简短描述、原始包 `description`,以及 frontmatter 中除 `name` / `description` / `version` 与仅用于详情的 `metadata.overview` 外的字段展开结果 |
|
||||
| visibility | enum | 冗余,避免搜索时 join |
|
||||
| status | enum | |
|
||||
| updated_at | datetime | |
|
||||
|
|
|
|||
|
|
@ -44,8 +44,9 @@ description: When to use # 必需,1-2 句话
|
|||
- 网页发布 multipart 可选字段 `displayName` 在 trim 后非空时优先于包内展示名,最长 200 字符;空白回退包内字段。该覆盖值保存在版本元数据快照,原始 frontmatter 和文件内容不改写;下载 ZIP 沿用现有重新打包机制,压缩包二进制可能不同。CLI 不填写此字段时继续使用包内规则。
|
||||
- 网页上传后通过鉴权只读 `POST /api/web/skills/package-preview`(也提供 `/api/v1/skills/package-preview`)读取 `name/displayName/description/version`;此步骤不保存包、不创建技能或启动扫描,正式提交仍执行完整校验。
|
||||
- 上述类型和长度限制在新上传解析时检查。读取历史版本快照时,非字符串或超长的旧 `display-name` 回退到该版本的 `name`,原始 frontmatter 保留,避免阻塞审核、提升或版本撤回。
|
||||
- `description` 映射为 `skill.summary`
|
||||
- frontmatter 完整解析结果存入 `skill_version.parsed_metadata_json`
|
||||
- `metadata.short-description` 可选,作为卡片、列表和搜索摘要,去除首尾空白后最多 200 个字符;缺失时回退到原始 `description`。`metadata.overview` 可选,为详情页 Markdown 概览,最多 20,000 个字符;缺失时详情页按 README.md → SKILL.md 回退。
|
||||
- 网页发布 multipart 可选字段 `shortDescription`、`overview` 和 `resetPresentation`;网页显式值优先于包内元数据,`resetPresentation=true` 清除自定义覆盖并禁止本次继承。两边均未提供展示值时,仅继承提交时当前已发布版本的自定义覆盖,并在快照记录 `presentationInheritedFrom`;不继承旧版包内默认值。值随版本快照保存,原始包文件不变。待审核版本不更新公开摘要,审核通过后才生效。
|
||||
- 有效 short description 映射为 `skill.summary`;原始 `description` 仍加入搜索文本。frontmatter 完整解析结果存入 `skill_version.parsed_metadata_json`。
|
||||
|
||||
平台扩展字段(可选,`x-astron-` 前缀):
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ Status: contract reference with documented implementation exception
|
|||
- `PENDING_REVIEW -> PUBLISHED`
|
||||
- review task 标记为 `APPROVED`
|
||||
- `Skill.latestVersionId` 指向该版本
|
||||
- skill 展示元数据从发布版本刷新
|
||||
- skill 展示元数据从发布版本刷新,摘要采用该版本有效 `short-description`(自定义覆盖优先,其次包内值,再退回 `description`);`overview` 仅在选中版本详情展示。提交时的自定义继承冻结在版本快照,审核期间不随其他版本发布而变化。
|
||||
|
||||
### 4.3 审核拒绝
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"schema": 1,
|
||||
"description": "Source declarations, not runtime DOM counts, pageviews, successful outcomes or a closed runtime enum. Forwarding sites require explicit callers. See frontend-analytics.md for gaps and event policy.",
|
||||
"counts": {"routes":54,"uniqueIds":804,"literalJsxSites":823,"literalJsxValues":977,"navigationDefinitions":29,"forwardingSites":36},
|
||||
"counts": {"routes":54,"uniqueIds":810,"literalJsxSites":830,"literalJsxValues":984,"navigationDefinitions":29,"forwardingSites":36},
|
||||
"routes": [
|
||||
{"name":"landingRoute","path":"/","declaration":{"file":"web/src/app/router.tsx","line":258},"implementation":{"file":"web/src/pages/landing.tsx","line":258,"export":"LandingPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"skillsRoute","path":"skills","declaration":{"file":"web/src/app/router.tsx","line":264},"implementation":{"file":"web/src/pages/home.tsx","line":12,"export":"HomePage"},"guarded":false,"roleProtected":false},
|
||||
|
|
@ -16,11 +16,11 @@
|
|||
{"name":"termsRoute","path":"terms","declaration":{"file":"web/src/app/router.tsx","line":344},"implementation":{"file":"web/src/pages/terms.tsx","line":171,"export":"TermsOfServicePage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"namespaceRoute","path":"/space/$namespace","declaration":{"file":"web/src/app/router.tsx","line":350},"implementation":{"file":"web/src/pages/namespace.tsx","line":5,"export":"NamespacePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"joinRoute","path":"join","declaration":{"file":"web/src/app/router.tsx","line":357},"implementation":{"file":"web/src/pages/join.tsx","line":12,"export":"JoinPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"skillDetailRoute","path":"/space/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":364},"implementation":{"file":"web/src/pages/skill-detail.tsx","line":133,"export":"SkillDetailPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"skillDetailRoute","path":"/space/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":364},"implementation":{"file":"web/src/pages/skill-detail.tsx","line":134,"export":"SkillDetailPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"skillVersionCompareRoute","path":"/space/$namespace/$slug/compare","declaration":{"file":"web/src/app/router.tsx","line":374},"implementation":{"file":"web/src/pages/skill-version-compare.tsx","line":5,"export":"SkillVersionComparePage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"dashboardRoute","path":"dashboard","declaration":{"file":"web/src/app/router.tsx","line":384},"implementation":{"file":"web/src/pages/dashboard.tsx","line":97,"export":"DashboardPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSkillsRoute","path":"dashboard/skills","declaration":{"file":"web/src/app/router.tsx","line":391},"implementation":{"file":"web/src/pages/dashboard/my-skills.tsx","line":59,"export":"MySkillsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardPublishRoute","path":"dashboard/publish","declaration":{"file":"web/src/app/router.tsx","line":404},"implementation":{"file":"web/src/pages/dashboard/publish.tsx","line":41,"export":"PublishPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardPublishRoute","path":"dashboard/publish","declaration":{"file":"web/src/app/router.tsx","line":404},"implementation":{"file":"web/src/pages/dashboard/publish.tsx","line":50,"export":"PublishPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteCreateRoute","path":"dashboard/suites/new","declaration":{"file":"web/src/app/router.tsx","line":430},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":716,"export":"SuiteCreatePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuitesRoute","path":"dashboard/suites","declaration":{"file":"web/src/app/router.tsx","line":437},"implementation":{"file":"web/src/pages/dashboard/my-suites.tsx","line":21,"export":"MySuitesPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteManagementRoute","path":"dashboard/suites/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":447},"implementation":{"file":"web/src/pages/dashboard/suite-management.tsx","line":31,"export":"SuiteManagementPage"},"guarded":true,"roleProtected":false},
|
||||
|
|
@ -251,10 +251,11 @@
|
|||
{"file":"web/src/features/notification/notification-dropdown.tsx","line":75,"host":"Link","prop":"data-analytics-id","event":"click","ids":["notification.fallback.open","notification.promotion.open","notification.report.open","notification.review.detail.open","notification.review.list.open","notification.target.provided.open"],"conditional":true},
|
||||
{"file":"web/src/features/notification/notification-dropdown.tsx","line":105,"host":"Link","prop":"data-analytics-id","event":"click","ids":["notification.list.open"]},
|
||||
{"file":"web/src/features/notification/notification-preference-form.tsx","line":75,"host":"button","prop":"data-analytics-id","event":"click","ids":["notification.preference.promotion.toggle","notification.preference.publish.toggle","notification.preference.report.toggle","notification.preference.review.toggle"],"conditional":true},
|
||||
{"file":"web/src/features/publish/upload-zone.tsx","line":71,"host":"div","prop":"data-analytics-id","event":"click+keydown+drop","ids":[],"conditional":true,"forward":"analyticsId"},
|
||||
{"file":"web/src/features/publish/upload-zone.tsx","line":81,"host":"input","prop":"data-analytics-id","event":"change","ids":[],"conditional":true,"forward":"analyticsId"},
|
||||
{"file":"web/src/features/publish/upload-zone.tsx","line":113,"host":"input","prop":"data-analytics-id","event":"change","forward":"folderAnalyticsId"},
|
||||
{"file":"web/src/features/publish/upload-zone.tsx","line":123,"host":"button","prop":"data-analytics-id","event":"click","forward":"pickerAnalyticsId"},
|
||||
{"file":"web/src/features/publish/upload-zone.tsx","line":131,"host":"Button","prop":"data-analytics-id","event":"click","ids":[],"conditional":true,"forward":"pickerAnalyticsId"},
|
||||
{"file":"web/src/features/publish/upload-zone.tsx","line":143,"host":"div","prop":"data-analytics-id","event":"click+keydown+drop","ids":[],"conditional":true,"forward":"analyticsId"},
|
||||
{"file":"web/src/features/publish/upload-zone.tsx","line":155,"host":"input","prop":"data-analytics-id","event":"change","ids":[],"conditional":true,"forward":"analyticsId"},
|
||||
{"file":"web/src/features/publish/upload-zone.tsx","line":178,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.package.remove"]},
|
||||
{"file":"web/src/features/publish/upload-zone.tsx","line":198,"host":"input","prop":"data-analytics-id","event":"change","forward":"folderAnalyticsId"},
|
||||
{"file":"web/src/features/review/github-sync-review-queue.tsx","line":49,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.sync.status"]},
|
||||
{"file":"web/src/features/review/github-sync-review-queue.tsx","line":62,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.sync.sort.interact"]},
|
||||
{"file":"web/src/features/review/github-sync-review-queue.tsx","line":64,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.sync.sort"]},
|
||||
|
|
@ -318,6 +319,8 @@
|
|||
{"file":"web/src/features/skill/skill-avatar-manager.tsx","line":52,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.retry"]},
|
||||
{"file":"web/src/features/skill/skill-avatar-takedown.tsx","line":21,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.takedown"]},
|
||||
{"file":"web/src/features/skill/skill-card.tsx","line":37,"host":"Card","prop":"data-analytics-id","event":"click+keydown-enter-space","ids":[],"conditional":true,"forward":"analyticsId"},
|
||||
{"file":"web/src/features/skill/skill-instructions.tsx","line":27,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.instructions.retry"]},
|
||||
{"file":"web/src/features/skill/skill-instructions.tsx","line":44,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.instructions.source.toggle"]},
|
||||
{"file":"web/src/features/skill/skill-label-panel.tsx","line":181,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.label.privileged.detach","skill.label.recommended.detach","suite.label.privileged.detach","suite.label.recommended.detach"],"conditional":true},
|
||||
{"file":"web/src/features/skill/skill-label-panel.tsx","line":212,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.label.privileged.attach","skill.label.recommended.attach","suite.label.privileged.attach","suite.label.recommended.attach"],"conditional":true},
|
||||
{"file":"web/src/features/skill/skill-version-compare-content.tsx","line":164,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.comparison.base.interact"]},
|
||||
|
|
@ -653,21 +656,20 @@
|
|||
{"file":"web/src/pages/dashboard/promotions.tsx","line":439,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["promotion.revocation.history"]},
|
||||
{"file":"web/src/pages/dashboard/promotions.tsx","line":440,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["promotion.approved.tab"]},
|
||||
{"file":"web/src/pages/dashboard/promotions.tsx","line":441,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["promotion.rejected.tab"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":265,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.publish.namespace.interact"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":269,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.namespace.clear"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":271,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.namespace.select"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":283,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.publish.visibility.interact"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":287,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.visibility.public"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":288,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.visibility.namespace"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":289,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.visibility.private"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":297,"host":"UploadZone","prop":"analyticsId","event":"click+keydown+change+drop","ids":["skill.package.select"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":297,"host":"UploadZone","prop":"folderAnalyticsId","event":"change","ids":["skill.package.folder.select"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":297,"host":"UploadZone","prop":"pickerAnalyticsId","event":"click","ids":["skill.package.folder.open"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":312,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.package.remove"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":343,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.package.preview.retry"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":355,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.save","skill.publish.request"],"conditional":true},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":365,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.publish.confirm"],"conditional":true},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":365,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.publish.cancel"],"conditional":true},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":394,"host":"UploadZone","prop":"analyticsId","event":"click+keydown+change+drop","ids":["skill.package.select"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":394,"host":"UploadZone","prop":"folderAnalyticsId","event":"change","ids":["skill.package.folder.select"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":394,"host":"UploadZone","prop":"pickerAnalyticsId","event":"click","ids":["skill.package.folder.open"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":412,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.package.preview.retry"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":453,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.publish.namespace.interact"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":455,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.namespace.clear"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":456,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.namespace.select"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":463,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.publish.visibility.interact"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":465,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.visibility.public"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":466,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.visibility.namespace"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":467,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.visibility.private"]},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":534,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.save","skill.publish.request"],"conditional":true},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":581,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.publish.confirm"],"conditional":true},
|
||||
{"file":"web/src/pages/dashboard/publish.tsx","line":581,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.publish.cancel"],"conditional":true},
|
||||
{"file":"web/src/pages/dashboard/reports.tsx","line":131,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]},
|
||||
{"file":"web/src/pages/dashboard/reports.tsx","line":151,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.dismiss.request"]},
|
||||
{"file":"web/src/pages/dashboard/reports.tsx","line":159,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.resolve.request"]},
|
||||
|
|
@ -829,79 +831,84 @@
|
|||
{"file":"web/src/pages/settings/profile.tsx","line":215,"host":"form","prop":"data-analytics-id","event":"submit","ids":["account.profile.save"]},
|
||||
{"file":"web/src/pages/settings/profile.tsx","line":261,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.profile.cancel"]},
|
||||
{"file":"web/src/pages/settings/security.tsx","line":96,"host":"form","prop":"data-analytics-id","event":"submit","ids":["account.password.save"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":831,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.login.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":867,"host":"div","prop":"data-analytics-id","event":"structural","ids":["skill.detail.render"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":872,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.detail.back"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":923,"host":"CopyButton","prop":"analyticsId","event":"click","ids":["skill.coordinate.copy"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":945,"host":"Link","prop":"data-analytics-id","event":"click","ids":["search.label.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":985,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["skill.overview.tab"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":986,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["skill.files.tab"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":987,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["skill.versions.tab"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1015,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.overview.toggle"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1058,"host":"FileTree","prop":"analyticsId","event":"click","ids":["skill.file.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1097,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comparison.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1104,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.rerelease.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1114,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.delete"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1123,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.review.withdraw"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1132,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.publish"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1141,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.review.submit"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1178,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.browser.toggle"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1200,"host":"FileTree","prop":"analyticsId","event":"click","ids":["skill.file.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1253,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.create.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1291,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.detail.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1318,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.related.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1373,"host":"InstallCommand","prop":"analyticsId","event":"click","ids":["skill.command.copy"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1373,"host":"InstallCommand","prop":"skillhubAnalyticsId","event":"click","ids":["skill.command.skillhub.select"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1373,"host":"InstallCommand","prop":"clawhubAnalyticsId","event":"click","ids":["skill.command.clawhub.select"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1413,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.review.withdraw"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1423,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.download.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1442,"host":"InstallForAgentButton","prop":"analyticsId","event":"click","ids":["skill.prompt.copy"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1514,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.unarchive.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1518,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.archive.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1523,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.delete.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1555,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.promotion.version.interact"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1560,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.promotion.version"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1569,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.promotion.create.request","skill.promotion.update.request"],"conditional":true},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1582,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1586,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.direct"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1621,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.hide.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1625,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.unhide.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1631,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.yank"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1641,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["report.create.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1661,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.create.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1664,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.create.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1671,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.promotion.create.confirm","skill.promotion.update.confirm"],"conditional":true},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1671,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.promotion.create.cancel","skill.promotion.update.cancel"],"conditional":true},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1690,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.revocation.direct.cancel","skill.revocation.request.cancel"],"conditional":true},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1703,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.direct.cancel","skill.revocation.request.cancel"],"conditional":true},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1704,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.direct.submit","skill.revocation.request.submit"],"conditional":true},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1711,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.archive.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1711,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.archive.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1720,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.unarchive.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1720,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.unarchive.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1729,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.delete.continue"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1729,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.delete.continue.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1748,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.delete.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1764,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.delete.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1767,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.delete.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1778,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.version.delete.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1778,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.version.delete.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1792,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.review.withdraw.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1792,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.review.withdraw.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1816,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.rerelease.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1836,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.rerelease.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1839,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.rerelease.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1846,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.rerelease.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1846,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.rerelease.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1867,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.version.publish.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1867,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.version.publish.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1880,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.review.submit.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1880,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.review.submit.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1902,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.comparison.close"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1967,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comparison.close"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1981,"host":"FilePreviewDialog","prop":"closeAnalyticsId","event":"click","ids":["skill.file.close"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1981,"host":"FilePreviewDialog","prop":"downloadAnalyticsId","event":"click","ids":["skill.file.download"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1981,"host":"FilePreviewDialog","prop":"copyAnalyticsId","event":"click","ids":["skill.file.copy"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":854,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.login.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":889,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.retry"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":903,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.latest"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":916,"host":"div","prop":"data-analytics-id","event":"structural","ids":["skill.detail.render"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":921,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.detail.back"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":972,"host":"CopyButton","prop":"analyticsId","event":"click","ids":["skill.coordinate.copy"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":994,"host":"Link","prop":"data-analytics-id","event":"click","ids":["search.label.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1034,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.latest"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1038,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["skill.overview.tab"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1039,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["skill.instructions.tab"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1040,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["skill.files.tab"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1041,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["skill.versions.tab"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1069,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.overview.toggle"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1115,"host":"FileTree","prop":"analyticsId","event":"click","ids":["skill.file.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1149,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.view"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1155,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comparison.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1162,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.rerelease.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1172,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.delete"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1181,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.review.withdraw"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1190,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.publish"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1199,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.review.submit"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1236,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.browser.toggle"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1258,"host":"FileTree","prop":"analyticsId","event":"click","ids":["skill.file.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1311,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.create.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1349,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.detail.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1376,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.related.open"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1431,"host":"InstallCommand","prop":"analyticsId","event":"click","ids":["skill.command.copy"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1431,"host":"InstallCommand","prop":"skillhubAnalyticsId","event":"click","ids":["skill.command.skillhub.select"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1431,"host":"InstallCommand","prop":"clawhubAnalyticsId","event":"click","ids":["skill.command.clawhub.select"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1471,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.review.withdraw"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1481,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.download.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1500,"host":"InstallForAgentButton","prop":"analyticsId","event":"click","ids":["skill.prompt.copy"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1572,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.unarchive.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1576,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.archive.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1581,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.delete.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1613,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.promotion.version.interact"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1618,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.promotion.version"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1627,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.promotion.create.request","skill.promotion.update.request"],"conditional":true},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1640,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1644,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.direct"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1679,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.hide.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1683,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.unhide.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1689,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.yank"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1699,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["report.create.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1719,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.create.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1722,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.create.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1729,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.promotion.create.confirm","skill.promotion.update.confirm"],"conditional":true},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1729,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.promotion.create.cancel","skill.promotion.update.cancel"],"conditional":true},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1748,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.revocation.direct.cancel","skill.revocation.request.cancel"],"conditional":true},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1761,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.direct.cancel","skill.revocation.request.cancel"],"conditional":true},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1762,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.direct.submit","skill.revocation.request.submit"],"conditional":true},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1769,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.archive.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1769,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.archive.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1778,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.unarchive.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1778,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.unarchive.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1787,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.delete.continue"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1787,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.delete.continue.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1806,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.delete.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1822,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.delete.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1825,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.delete.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1836,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.version.delete.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1836,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.version.delete.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1850,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.review.withdraw.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1850,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.review.withdraw.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1874,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.rerelease.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1894,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.rerelease.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1897,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.rerelease.request"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1904,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.rerelease.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1904,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.rerelease.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1925,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.version.publish.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1925,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.version.publish.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1938,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.review.submit.confirm"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1938,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.review.submit.cancel"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":1960,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.comparison.close"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":2025,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comparison.close"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":2039,"host":"FilePreviewDialog","prop":"closeAnalyticsId","event":"click","ids":["skill.file.close"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":2039,"host":"FilePreviewDialog","prop":"downloadAnalyticsId","event":"click","ids":["skill.file.download"]},
|
||||
{"file":"web/src/pages/skill-detail.tsx","line":2039,"host":"FilePreviewDialog","prop":"copyAnalyticsId","event":"click","ids":["skill.file.copy"]},
|
||||
{"file":"web/src/pages/suite-detail.tsx","line":116,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.command.copy"]},
|
||||
{"file":"web/src/pages/suite-detail.tsx","line":122,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.command.copy"]},
|
||||
{"file":"web/src/pages/suite-detail.tsx","line":128,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.edit"]},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
# Publish and Update Skills
|
||||
|
||||
## Write the public introduction
|
||||
|
||||
After selecting a package, inspect its parsed identifier and version. Structural parsing does not complete scanning, review, or publication. The empty dropzone accepts a ZIP by dropping, clicking, or pressing Enter or Space while focused. A distinct folder picker is available in supporting browsers. A selected package replaces the empty dropzone with a file row showing reading, parsed, or failed state. Cancelling or rejecting a replacement retains the preceding file. Recheck the identifier, version, and retained edits after replacement.
|
||||
|
||||
The main form places the package, avatar, and display name before the introduction. Desktop publishing settings appear on the right; narrow layouts place them before the introduction. Edit the summary directly. The overview remains a read-only preview with Edit overview consistently at the field header’s upper right. Its separate editor provides visual, Markdown source, and publication-preview modes, including single-pane and split source layouts.
|
||||
|
||||
Valid package content needs no duplicate entry or source-mode switch. Unmodified fields follow the current package defaults; actual changes become custom introduction values and survive package replacement. Restoring package content requires confirmation and affects only that field. Cancelling retains its custom content; confirming restores default behavior so later packages supply their current defaults. Merely viewing or completing an unchanged editor does not create an override.
|
||||
|
||||
Web submission of a new version requires both effective introduction values to be nonblank, within 200 and 20,000 characters respectively; document frontmatter alone is not an overview. Retry a failed read or enter the content manually rather than treating read failure as missing content. Package metadata fields remain optional for CLI/API uploads. Missing required content produces field errors and focus targets without sending a version.
|
||||
|
||||
Complete editing returns the content to the current form without submitting a review or saving a server draft. Cancel changes discards edits made since entering the editor, with confirmation when necessary. If no custom overview exists, available package documentation supplies the preview; an empty state means no usable content could be obtained. A failed preview does not itself reject the package: registry validation and authorization still govern submission.
|
||||
|
||||
Inspect the namespace, identifier, version, visibility, and summary in the final confirmation before submitting. This confirmation does not replace package prechecks, scanning, or review. Precheck warnings retain their own confirmation flow. Unsubmitted form content is not guaranteed to survive a page refresh.
|
||||
|
||||
Do not send a duplicate request during submission. The result page is a receipt that distinguishes scanning, pending review, uploaded-only and published states. View this version for current progress. If the version already exists, replace the package with a new version number; introductions and their source state remain available, and the application does not rewrite the version. A timeout or interrupted network may leave the write outcome unknown. Inspect My Skills before retrying rather than immediately sending the package again.
|
||||
|
||||
## From Local Files to an Installable Version
|
||||
|
||||
A single-skill upload has one root `SKILL.md`. For multiple skills, use [Suite import](./suite-bundle.html); do not place unrelated skills in one single-skill ZIP.
|
||||
|
|
@ -27,6 +43,12 @@ Resolve versions by an exact version, tag, or fingerprint; `^` / `~` ranges are
|
|||
|
||||
Use Update in My Skills or publish again to the same namespace and skill name. Verify the coordinate: changing `name` may create a different skill. Published versions cannot be overwritten; use a new version for normal updates. While it awaits review, public users retain the previously published version. After approval, check the displayed version, title and summary.
|
||||
|
||||
### Browse historical versions and original instructions
|
||||
|
||||
The detail page separates Overview, Skill instructions, Files, and Versions. Overview displays the selected version's introduction; Skill instructions reads that version's original `SKILL.md` with rendered and source modes; Files retains the complete package tree. To browse a historical version, open Versions and choose View this version. The page returns to Overview and identifies the historical version, with an action to return to the latest published version. The summary, documentation, file inventory, and install/download targets follow the selected version. Refresh and browser back/forward retain the version represented by the URL.
|
||||
|
||||
Browsing changes only the reading target: it does not publish, rerelease, submit a review, or update an installed skill. Existing access restrictions still apply, including unpublished versions.
|
||||
|
||||
Existing local installations do not update automatically. Consumers explicitly upgrade through the CLI. Suite members remain pinned until a new Suite version is published.
|
||||
|
||||
### Tracking and Managing My Skills
|
||||
|
|
@ -51,7 +73,6 @@ Version comparison opens in a centered dialog and only compares accessible publi
|
|||
| Review rejected | Read the decision, revise the package and use the supported resubmission flow |
|
||||
| Frozen namespace or removed membership | Restore legitimate write access first; a previously installed copy grants no publishing rights |
|
||||
|
||||
|
||||
## Prepare metadata and choose a publishing entry
|
||||
|
||||
### Prepare metadata
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
# 发布与更新技能
|
||||
|
||||
## 填写展示介绍
|
||||
|
||||
选择技能包后,页面解析包结构并显示技能标识与版本;解析完成不表示扫描或审核完成。未选包时,可拖入 ZIP,或通过上传区、ZIP 按钮选择;聚焦上传区后可使用 Enter 或空格选择 ZIP。文件夹通过独立入口选择,仅在浏览器支持时提供。选包后上传区收起为文件信息条,读取中、解析完成和失败分别显示;替换选择被取消或拒绝时保留原文件。替换包后须核对技能标识、版本和已填内容。
|
||||
|
||||
主表单先放技能包、头像和展示名称,后放简介与详细概述。宽屏右侧显示命名空间与可见范围,窄屏将设置放在介绍之前。简介可直接修改;概述保持只读预览,通过字段右上角固定的“编辑概述”进入独立编辑界面。编辑界面提供可视化、Markdown源码和发布预览,源码支持单栏与分栏。
|
||||
|
||||
包内已有有效介绍时无需重复填写,也无需先选择编辑模式。未修改的字段跟随本次包内内容;实际修改后作为本次版本自定义介绍,更换包不覆盖手动修改。自定义字段的“恢复包内内容”需确认;取消保留原稿,确认仅恢复该字段,后续使用新包的默认内容。单纯查看或未改值完成编辑不会把包内介绍保存为自定义。
|
||||
|
||||
网页提交新版本前,最终采用的两项介绍均须非空,简介最多200字符、概述最多20,000字符;只有文档头部信息不算有效概述。读取失败须重试或手动补充,不能直接判作空内容。CLI/API 的包内 metadata 字段仍为可选。未完成必填项时,确认操作显示字段错误和定位入口,不会发送版本。
|
||||
|
||||
“完成编辑”只把内容带回当前表单,不提交审核,也不保存服务端草稿。“取消修改”丢弃本次进入编辑界面后的修改;存在未完成修改时需确认。没有自定义概述时,预览使用可用的包内说明;只有无法取得可用内容时才显示空态。预览读取失败不等于技能包已被拒绝,提交仍以注册中心的包校验与权限判断为准。
|
||||
|
||||
核对发布确认中的命名空间、技能标识、版本、可见范围及简介,再提交。普通信息确认不会替代包预检、扫描和审核;如有预检警告,仍按其单独的确认流程处理。未提交的表单内容不承诺在页面刷新后保留。
|
||||
|
||||
提交期间不得重复发送。结果页展示本次提交收据,按返回状态区分扫描中、待审核、仅上传和已发布;最新进度通过“查看本次版本”核对。版本已存在时,返回更换具有新版本号的包,已填介绍及其来源状态保留,不自动修改版本号。超时或网络中断可能无法确认是否已创建版本,必须先到“我的技能”核对,不要直接重复提交。
|
||||
|
||||
## 从文件到可安装版本
|
||||
|
||||
单技能根目录只放一个 `SKILL.md`;多技能创作与导入使用[套装导入](./suite-bundle.html),不要混入单技能 ZIP。
|
||||
|
|
@ -27,6 +43,12 @@ my-skill/
|
|||
|
||||
在“我的技能”选择更新,或对原坐标重新发布。修改 `name` 可能创建另一技能;已发布版本不可覆盖,迭代使用新版本号。待审期间公众仍见旧版本;批准后核对详情版本、名称和摘要。
|
||||
|
||||
### 查看历史版本与原始指令
|
||||
|
||||
技能详情的“概述”展示当前所选版本的介绍;“技能指令”读取该版本原始 `SKILL.md`,可切换渲染与源码;“文件”保留完整包目录。无需重新发布即可查看历史版本:进入“版本”,在目标记录选择“查看此版本”,页面返回概述。页面会标注正在查看的历史版本,可选择返回最新已发布版本;简介、正文、文件和安装/下载目标应与所选版本一致。刷新及浏览器前进、后退保留 URL 对应版本。
|
||||
|
||||
查看版本只改变阅读对象,不会创建或发布版本,也不会升级本地安装。没有权限的版本不会因切换入口变得可读;待审内容仍受原访问权限限制。
|
||||
|
||||
本地已有安装不会因服务端发布新版而自动更新。使用者需要明确执行 CLI 升级;套装成员还受套装固定版本约束,不能以成员发布新版本代替发布新的套装版本。
|
||||
|
||||
### 在“我的技能”跟踪与管理
|
||||
|
|
|
|||
39
openspec/changes/add-skill-presentation-fields/design.md
Normal file
39
openspec/changes/add-skill-presentation-fields/design.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Design
|
||||
|
||||
- Parse `metadata.short-description` and `metadata.overview` from SKILL.md, with limits of 200 and 20,000 characters.
|
||||
- Accept multipart `shortDescription`, `overview`, and `resetPresentation`; web values override package metadata and are stored in `parsedMetadataJson` as `shortDescriptionOverride`, `overviewOverride`, and `presentationInheritedFrom`.
|
||||
- Keep the existing display-name/version snapshot model and publish/review/rollback lifecycle. Pending versions never update the Skill container summary.
|
||||
- The effective short description feeds `skill.summary`; search also retains the original package description in `search_text`. Overview is detail-only and falls back to README then SKILL.md in the existing documentation reader.
|
||||
- Web package preview supplies defaults without editing the uploaded archive. Generated API types remain authoritative.
|
||||
|
||||
## 发布表单与概述编辑
|
||||
|
||||
发布页采用后续确认的 A1 分栏方案:最大1200px,左侧为主表单,右侧仅放命名空间、可见范围及必要的策略说明。主表单依次为技能包、基本信息、展示介绍;头像放在展示名称左侧。手机布局将发布设置移到基本信息之后、介绍之前。移除顶部步骤条、来源胶囊、常驻使用者卡片预览及独立大块审核说明。提交操作保持在所有内容之后。
|
||||
|
||||
上传组件复用既有 react-dropzone、按钮与图标,不新增上传组件运行时依赖。未选包时整区支持点击与键盘选择 ZIP,文件夹选择为独立入口;选包后收起为文件信息条。选择、目录打包、读取结构、读取失败与解析完成分别表达;没有真实上传进度时不得显示虚构百分比。格式不支持和多个 ZIP 要明确拒绝,更换选择被取消或拒绝时保留有效包。解析完成不表示扫描、审核或正式发布完成。
|
||||
|
||||
简要描述直接编辑,概述主表单只读且在字段标题右上固定提供“编辑概述”,无需选择包内/自定义模式。未修改的内容保持包默认来源;实际修改后作为本次自定义值,替换技能包不得覆盖自定义草稿。更新时沿用已发布的自定义覆盖值,并保留原有迟到快照和跨技能切换保护。单纯打开、查看或不改值完成编辑不得把包内值转为自定义。
|
||||
|
||||
两个字段独立显示来源的次要说明,仅自定义字段提供“恢复包内内容”。恢复需要确认,取消保留原稿;确认后只将对应字段恢复为默认状态,采用当前包内容,下次更换包继续跟随新默认,不把旧默认文本冻结成自定义。有效简介与概述在网页版本提交前必须非空,并遵循200与20,000字符上限;不把包内可选 metadata 字段变为 CLI/API 强制参数。缺失、读取中和读取失败分别显示,错误与操作位置关联,失败不清空已有介绍。
|
||||
|
||||
专注编辑界面复用成熟开源编辑内核,自定义现有组件体系的工具栏与胶囊模式切换,提供可视化、Markdown源码和发布预览;源码支持单栏与分栏。预览复用详情页 Markdown 渲染组件。内容仍按 Markdown 提交,不改写包内文件。无法安全转换的语法保留原始内容,并提供源码继续编辑,不能静默丢弃。
|
||||
|
||||
实现选择 Vditor 的所见即所得内核,工具栏使用既有 Lucide 图标并局部适配主题;Lute 与所需语言资源通过 Vite 输出为同源静态资源,不依赖默认外部 CDN,关闭默认本地内容缓存。包预览没有概述时,以 fflate 有界读取包内文档,仅用于可选预览,不将回退正文自动提交为自定义值;本地预览预算不是上传限额,读取失败和无内容分别显示。
|
||||
|
||||
包预览响应的概述只表示显式 `metadata.overview`,缺失时不提前填入 SKILL.md 正文,否则会掩盖详情阅读路径的 README 优先规则。此修正不改变域对象既有正文回退方法、版本发布或审核流程,也不新增响应字段。
|
||||
|
||||
进入编辑时创建本地编辑副本;“完成编辑”校验长度后更新表单预览并返回,不提交审核、不声称服务端草稿已保存。“取消修改”保留进入前的表单内容,存在改动时确认丢弃。替换包、恢复默认、权限限制和现有头像更新仍沿用原有业务边界,不因视觉改造新增发布权限。
|
||||
|
||||
## 提交状态与恢复
|
||||
|
||||
原型中的提交中、已提交和版本冲突表示互斥状态,不在正式页面同时堆叠。开始提交后锁定重复动作;成功收据按实际版本状态区分扫描中、待审核、仅上传、已发布与扫描失败,不能将非已发布状态统一称为已发布或待审核。结果页提供只读查看本次版本和返回我的技能的入口,不立即跳转吞掉结果,也不替换公众仍可见的旧发布内容。
|
||||
|
||||
版本冲突保留文件选择、字段来源状态与自定义内容,引导更换具有新版本号的包并聚焦替换入口,不自动改包或版本号。普通失败保留表单;预检警告继续沿用现有确认流程,权限错误刷新真实权限。超时或传输中断可能无法确认结果,不自动重试写入,应先核对我的技能。错误摘要与字段内错误用于定位未完成项,避免重复 toast;普通校验失败不只通过禁用按钮表达原因。
|
||||
|
||||
## 详情阅读与版本导航
|
||||
|
||||
详情页采用“概述/技能指令/文件/版本”四个 Tab。概述面向使用者展示版本介绍;技能指令展示该版本根目录的原始 `SKILL.md`,提供渲染与源码两种阅读方式,不通过弹窗或概述上方的额外按钮承载入口。指令读取复用版本文件接口,加载、读取失败与缺少文件分别表达,失败不得回退为其他版本的正文。
|
||||
|
||||
版本选择放在“版本”Tab 的每条记录中。选择“查看此版本”只更新现有 URL 的 `version` 条件,保留其他导航上下文,并返回概述;浏览器前进、后退与刷新继续按照 URL 读取版本。简介、概述、技能指令、文件和安装/下载目标使用同一选中版本。历史版本显示明确提示,返回最新版使用当前已发布版本,不把待审版本误称为最新版。选择版本不创建版本、不提审、不重新发布,访问边界沿用服务端授权。
|
||||
|
||||
折叠状态与技能指令阅读模式按版本重置,避免旧正文或阅读状态在版本之间串用。本次交互调整不替代此前字段继承、审核展示等诊断缺陷的修复及验收。
|
||||
10
openspec/changes/add-skill-presentation-fields/proposal.md
Normal file
10
openspec/changes/add-skill-presentation-fields/proposal.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Add skill presentation fields
|
||||
|
||||
## Why
|
||||
Skill packages currently expose only one description and cannot provide a concise card summary plus a richer detail overview without modifying package files.
|
||||
|
||||
## Scope
|
||||
Add optional short description and Markdown overview to web and CLI-compatible publish metadata. Values are version snapshots, preserve package files, and use package metadata/default documentation when omitted. Public content changes only when the version publishes.
|
||||
|
||||
## Non-goals
|
||||
No upload-free editing, database migration, i18n variants, or dedicated presentation file.
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Presentation metadata
|
||||
The system MUST accept optional `metadata.short-description` and `metadata.overview` in SKILL.md. The short description MUST be at most 200 characters and the overview at most 20,000 characters.
|
||||
|
||||
#### Scenario: Validate optional package presentation fields
|
||||
- **GIVEN** a package with optional short-description and overview metadata
|
||||
- **WHEN** the package is validated
|
||||
- **THEN** values within the respective limits are accepted and values beyond them are rejected
|
||||
|
||||
### Requirement: Web publish precedence
|
||||
A web-provided value MUST override the package value for that submitted version. The archive MUST remain unchanged. Reset MUST remove the custom override so the package/default resolution applies.
|
||||
|
||||
#### Scenario: Override package defaults without rewriting files
|
||||
- **GIVEN** package metadata and explicit web values
|
||||
- **WHEN** the version is submitted
|
||||
- **THEN** the explicit web edits win and the original package files remain unchanged
|
||||
|
||||
#### Scenario: Restore defaults for a later version
|
||||
- **GIVEN** a reset request
|
||||
- **WHEN** the version publishes
|
||||
- **THEN** it uses package defaults and later default versions read their own package defaults
|
||||
|
||||
### Requirement: Lifecycle isolation
|
||||
A pending version MUST NOT replace the currently published title, summary, or overview. Published metadata MUST be restored by rollback/yank using the selected published version snapshot.
|
||||
|
||||
#### Scenario: Keep a pending update separate
|
||||
- **GIVEN** an existing published version
|
||||
- **WHEN** an update is submitted for review
|
||||
- **THEN** the public title, summary, and overview remain unchanged until publication
|
||||
|
||||
#### Scenario: Restore a published snapshot after rollback
|
||||
- **GIVEN** published versions with different presentation fields
|
||||
- **WHEN** yanking changes the published pointer
|
||||
- **THEN** the public presentation follows the selected published version snapshot
|
||||
|
||||
### Requirement: Search and detail
|
||||
The effective short description MUST be used as the public summary. The original package description MUST remain searchable. The detailed overview MUST NOT be indexed and MUST fall back to README.md then SKILL.md when no explicit/package overview exists.
|
||||
|
||||
#### Scenario: Resolve default and custom presentation
|
||||
- **GIVEN** an accessible skill version
|
||||
- **WHEN** a reader views its details or searches for it
|
||||
- **THEN** the summary uses the effective short description, the original description remains searchable, and the overview is detail-only with the documented fallback order
|
||||
|
||||
### Requirement: Version-scoped detail navigation
|
||||
The detail page MUST present Overview, Skill instructions, Files, and Versions tabs. Skill instructions MUST read the selected version's original root `SKILL.md`, support rendered and source modes, and distinguish loading, missing-file, and read-failure states. The overview MUST NOT contain an extra View SKILL.md button.
|
||||
|
||||
#### Scenario: Browse an accessible historical version
|
||||
- **GIVEN** multiple accessible versions
|
||||
- **WHEN** a reader chooses View this version in the Versions tab
|
||||
- **THEN** the page returns to Overview and updates the URL version without losing other search context
|
||||
- **AND** the summary, overview, instructions, file inventory, and install/download targets follow that same version
|
||||
- **AND** a historical-version notice and return-to-latest-published action are available where applicable
|
||||
- **AND** browsing does not publish, rerelease, or submit a review
|
||||
|
||||
#### Scenario: Restore a version from browser history
|
||||
- **GIVEN** a URL selecting an accessible version
|
||||
- **WHEN** the reader refreshes or uses browser back/forward
|
||||
- **THEN** the detail content follows the version represented by the URL
|
||||
|
||||
#### Scenario: Read original instructions
|
||||
- **GIVEN** an accessible version with a root SKILL.md
|
||||
- **WHEN** the reader opens Skill instructions
|
||||
- **THEN** rendered and source views show that version's original file content without rewriting it
|
||||
|
||||
#### Scenario: Handle missing or unreadable instructions
|
||||
- **GIVEN** a missing or failed file response
|
||||
- **WHEN** the reader opens Skill instructions
|
||||
- **THEN** an explicit empty or retryable error state is shown rather than another version's instructions
|
||||
|
||||
#### Scenario: Reset transient reading state
|
||||
- **GIVEN** an expanded overview or source instruction view
|
||||
- **WHEN** the selected version changes
|
||||
- **THEN** transient overview and instruction reading state resets for the new version
|
||||
|
||||
### Requirement: Single overview editing surface
|
||||
The publish form MUST display the overview as a read-only preview with an Edit overview action at the field header's upper right, regardless of package or custom origin. The same placement MUST apply to populated, empty, and mobile states. A package fallback MUST be shown when available rather than treated as empty. Merely opening or completing an unchanged editor MUST NOT convert the default into a custom override.
|
||||
|
||||
#### Scenario: Edit without submitting
|
||||
- **GIVEN** an overview preview in the publish form
|
||||
- **WHEN** the author opens the editor, changes its local draft, and completes editing
|
||||
- **THEN** the form preview updates without publishing or submitting a review
|
||||
- **AND** cancelling an edited draft requires confirmation and preserves the preceding form value
|
||||
|
||||
#### Scenario: Preserve content between editor modes
|
||||
- **GIVEN** Markdown content in the overview editor
|
||||
- **WHEN** the author switches visual, source, or publication-preview modes
|
||||
- **THEN** the same content is retained and the preview uses the detail rendering rules
|
||||
- **AND** unsupported visual syntax remains recoverable in source mode rather than silently discarded
|
||||
|
||||
#### Scenario: Reject an oversized overview
|
||||
- **GIVEN** an overview draft exceeding 20,000 characters
|
||||
- **WHEN** the author tries to complete editing
|
||||
- **THEN** completion is blocked with a visible limit error and the draft remains available for correction
|
||||
|
||||
#### Scenario: Complete immediately after an edit
|
||||
- **GIVEN** a native editor input or touch-formatting action whose change callback has not yet run
|
||||
- **WHEN** the author completes editing, changes mode, or requests cancellation immediately
|
||||
- **THEN** the latest native content determines completion, mode content, length validation, and unsaved-change confirmation
|
||||
- **AND** leaving without modifications does not convert package documentation into a custom override
|
||||
|
||||
#### Scenario: Return focus after cancellation
|
||||
- **GIVEN** the author entered through Edit overview and changed the draft
|
||||
- **WHEN** the author cancels and confirms discarding the changes
|
||||
- **THEN** the form retains its preceding content and keyboard focus returns to Edit overview
|
||||
|
||||
#### Scenario: Preview a package before submission
|
||||
- **GIVEN** an author selects a ZIP or a supported folder selection
|
||||
- **WHEN** the package structure preview is pending, fails, or succeeds
|
||||
- **THEN** each state has a distinct recoverable presentation
|
||||
- **AND** structural parsing is not presented as completed security scanning, review, or publication
|
||||
|
||||
### Requirement: Independent web presentation sources
|
||||
The web publish form MUST independently retain package-default or custom state for the summary and overview, without requiring a visible source-mode switch. Editing a value MUST deliberately create its custom state; unchanged default content MUST remain package-derived. The chosen effective values MUST be nonblank and within their existing respective limits before a version is submitted. This web validation MUST NOT make the optional package metadata fields mandatory for CLI or API uploads.
|
||||
|
||||
#### Scenario: Replace a package without overwriting edits
|
||||
- **GIVEN** custom presentation drafts in the current form
|
||||
- **WHEN** the author chooses another package or its preview completes asynchronously
|
||||
- **THEN** modified values remain available and default fields follow the current package
|
||||
- **AND** the other field's custom/default state does not change
|
||||
|
||||
#### Scenario: Restore one field to its package default
|
||||
- **GIVEN** a custom field with a Restore package content action
|
||||
- **WHEN** the author requests restoration and cancels the confirmation
|
||||
- **THEN** its current content is preserved
|
||||
- **WHEN** the author confirms restoration
|
||||
- **THEN** only that field returns to the current package default
|
||||
- **AND** a later package change follows its new default rather than inheriting the old default text
|
||||
|
||||
#### Scenario: Submit one package field and one custom field
|
||||
- **GIVEN** any of the four independent source combinations
|
||||
- **WHEN** the author submits a valid version
|
||||
- **THEN** only the deliberately chosen custom values are frozen as custom overrides
|
||||
- **AND** package values retain default-source behavior instead of being silently copied into custom overrides
|
||||
|
||||
#### Scenario: Validate missing effective content
|
||||
- **GIVEN** a selected source with no usable content
|
||||
- **WHEN** the author requests confirmation
|
||||
- **THEN** a field error and a navigable error summary identify the missing content
|
||||
- **AND** the file, source choices and drafts remain available for correction
|
||||
|
||||
### Requirement: Observable submission and recovery
|
||||
Submission progress, accepted receipts and failures MUST be mutually exclusive. The form MUST prevent duplicate writes while submitting and preserve drafts on failure. Receipt text MUST follow the returned version state and MUST NOT describe an unpublished version as published.
|
||||
|
||||
#### Scenario: View the submitted version
|
||||
- **GIVEN** an accepted submission receipt
|
||||
- **WHEN** the author chooses to view this version
|
||||
- **THEN** read-only detail navigation selects the receipt's namespace, skill and version rather than another published version
|
||||
|
||||
#### Scenario: Recover from a duplicate version
|
||||
- **GIVEN** a version-already-exists response
|
||||
- **WHEN** the author returns to replace the package
|
||||
- **THEN** the form retains its presentation drafts and source choices and focuses the package replacement control
|
||||
- **AND** the system does not silently change the version or automatically resubmit
|
||||
|
||||
#### Scenario: Handle an unknown submission outcome
|
||||
- **GIVEN** a timeout or interrupted transport whose write outcome is not confirmed
|
||||
- **WHEN** the result is displayed
|
||||
- **THEN** the author is directed to inspect My Skills before retrying
|
||||
- **AND** the client does not automatically replay the mutation or claim that no version was created
|
||||
|
||||
### Requirement: Contextual package upload control
|
||||
The publish form MUST use the A1 primary-form and publishing-settings layout, with presentation fields after package and basic information. On narrow screens settings MUST precede presentation content. A selected package MUST replace the large empty dropzone with an actionable file row.
|
||||
|
||||
#### Scenario: Select a package accessibly
|
||||
- **GIVEN** the empty upload control
|
||||
- **WHEN** an enabled user clicks it or activates it with Enter or Space
|
||||
- **THEN** the ZIP picker is opened once
|
||||
- **AND** folder selection remains a distinct supported browser control
|
||||
|
||||
#### Scenario: Reject a replacement without losing the current package
|
||||
- **GIVEN** a valid selected package and edited presentation content
|
||||
- **WHEN** a replacement selection is cancelled, has an unsupported type or contains multiple ZIPs
|
||||
- **THEN** an actionable rejection is shown where applicable, with the preceding package and custom content retained
|
||||
|
||||
#### Scenario: Report package-reading progress honestly
|
||||
- **GIVEN** a selected package awaiting structure preview
|
||||
- **WHEN** reading is pending, succeeds or fails
|
||||
- **THEN** the file control shows distinct state and recovery actions
|
||||
- **AND** it shows no invented progress percentage or publication-success claim
|
||||
36
openspec/changes/add-skill-presentation-fields/tasks.md
Normal file
36
openspec/changes/add-skill-presentation-fields/tasks.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Tasks
|
||||
|
||||
- [x] Add SKILL.md metadata parsing and validation.
|
||||
- [x] Add multipart publish fields and package-preview defaults.
|
||||
- [x] Snapshot presentation fields in parsed metadata and update effective published summary.
|
||||
- [x] Preserve original description in search text and keep pending updates isolated.
|
||||
- [x] Add automated inheritance, reset/default, rollback, approval, promotion and rerelease coverage.
|
||||
- [x] Regenerate OpenAPI types and update CLI usage documentation.
|
||||
- [x] Add detail-page overview projection.
|
||||
- [x] Use Overview, Skill instructions, Files and Versions tabs, with rendered/source original instructions.
|
||||
- [x] Add read-only historical-version selection, URL history, notices and return to latest published.
|
||||
- [x] Add regression coverage for unavailable versions, asynchronous loading and keyboard focus.
|
||||
|
||||
## Publish form and editor
|
||||
|
||||
- [x] Implement A1 desktop/sidebar and mobile settings-before-introduction layout.
|
||||
- [x] Place package selection before basic information and presentation, with avatar preceding the display name.
|
||||
- [x] Keep a single overview editor entry at the field header and a read-only form preview.
|
||||
- [x] Integrate visual/Markdown/preview editing with same-origin assets, recoverable syntax and cancel/apply behavior.
|
||||
- [x] Use direct summary editing and actual-value changes to create independent custom fields.
|
||||
- [x] Confirm per-field restore-to-package actions and preserve default-following behavior after package replacement.
|
||||
- [x] Require effective web introduction values without making optional CLI/API metadata mandatory.
|
||||
- [x] Implement accessible empty/selected/reading/rejected upload states and recoverable replacements.
|
||||
- [x] Preserve shared upload hints for Suite Bundle and isolate skill-specific package requirements.
|
||||
- [x] Prevent cross-resource cached snapshots and late package responses from populating a different form.
|
||||
- [x] Implement mutually exclusive submission progress, actual-status receipts and actionable recovery without automatic replay.
|
||||
- [x] Preserve readonly, avatar-only updates, staged-avatar state and existing publication permissions.
|
||||
- [x] Verify frontend types/lint/tests/build, documentation build and responsive browser workflows.
|
||||
|
||||
## Full-PR acceptance
|
||||
|
||||
- [ ] Revalidate single-field CLI/API inheritance independently of the web form's complete payload.
|
||||
- [ ] Revalidate reviewer visibility of the effective custom summary and overview before approving a version.
|
||||
- [ ] Complete the final-head lifecycle, rollback, promotion, search and access-control acceptance matrix.
|
||||
|
||||
The completed items record implementation and automated or scoped browser coverage. They do not imply that the full PR is accepted or merge-ready. Native OS IME, physical drag-and-drop, system file-dialog cancellation and real Safari remain separate manual checks. Browser-generated drag events and fault-injected responses must be reported separately from real OS and backend behavior. Local preview provenance, test commands and remaining findings belong to the validation report rather than this formal checklist.
|
||||
|
|
@ -83,6 +83,9 @@ public class SkillPublishController extends BaseApiController {
|
|||
@RequestParam(value = "avatarUploadId", required = false) String avatarUploadId,
|
||||
@RequestParam(value = "expectedSlug", required = false) String expectedSlug,
|
||||
@RequestParam(value = "displayName", required = false) String displayName,
|
||||
@RequestParam(value = "shortDescription", required = false) String shortDescription,
|
||||
@RequestParam(value = "overview", required = false) String overview,
|
||||
@RequestParam(value = "resetPresentation", defaultValue = "false") boolean resetPresentation,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request) throws IOException {
|
||||
|
||||
SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase());
|
||||
|
|
@ -110,7 +113,8 @@ public class SkillPublishController extends BaseApiController {
|
|||
principal.userId(),
|
||||
skillVisibility,
|
||||
principal.platformRoles(),
|
||||
confirmWarnings, avatarAction, avatarUploadId, expectedSlug, displayName, AuditRequestContext.from(request)
|
||||
confirmWarnings, avatarAction, avatarUploadId, expectedSlug, displayName,
|
||||
shortDescription, overview, resetPresentation, AuditRequestContext.from(request)
|
||||
);
|
||||
|
||||
PublishResponse response = new PublishResponse(
|
||||
|
|
|
|||
|
|
@ -4,10 +4,16 @@ import com.fasterxml.jackson.annotation.JsonInclude;
|
|||
import com.iflytek.skillhub.domain.skill.validation.SkillExamplesSummary.Status;
|
||||
|
||||
/** Package defaults only: preview never creates a skill or changes its files. */
|
||||
public record SkillPackagePreviewResponse(String name, String displayName, String description, String version,
|
||||
public record SkillPackagePreviewResponse(String name, String displayName, String description, String shortDescription,
|
||||
String overview, String version,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Status examplesStatus,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Integer examplesCount) {
|
||||
public SkillPackagePreviewResponse(String name, String displayName, String description, String version,
|
||||
Status examplesStatus, Integer examplesCount) {
|
||||
this(name, displayName, description, description, null, version, examplesStatus, examplesCount);
|
||||
}
|
||||
|
||||
public SkillPackagePreviewResponse(String name, String displayName, String description, String version) {
|
||||
this(name, displayName, description, version, null, null);
|
||||
this(name, displayName, description, description, null, version, null, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ public class SkillPackagePreviewService {
|
|||
.orElseThrow(() -> new DomainBadRequestException("error.skill.publish.skillMd.notFound"));
|
||||
SkillMetadata metadata = parser.parse(new String(skillMd.content(), StandardCharsets.UTF_8));
|
||||
SkillExamplesSummary examples = SkillExamplesSummary.recognize(entries);
|
||||
return new SkillPackagePreviewResponse(metadata.name(), metadata.displayName(), metadata.description(), metadata.version(),
|
||||
return new SkillPackagePreviewResponse(metadata.name(), metadata.displayName(), metadata.description(),
|
||||
metadata.shortDescription(), metadata.packageOverview(), metadata.version(),
|
||||
examples.status(), examples.count());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,13 +42,14 @@ public class SkillPublishAppService {
|
|||
SkillVisibility visibility, Set<String> roles, boolean confirmWarnings, AvatarAction action,
|
||||
String uploadId, String expectedSlug, AuditRequestContext context) {
|
||||
return publish(namespace, entries, actor, visibility, roles, confirmWarnings, action,
|
||||
uploadId, expectedSlug, null, context);
|
||||
uploadId, expectedSlug, null, null, null, false, context);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillPublishService.PublishResult publish(String namespace, List<PackageEntry> entries, String actor,
|
||||
SkillVisibility visibility, Set<String> roles, boolean confirmWarnings, AvatarAction action,
|
||||
String uploadId, String expectedSlug, String displayName, AuditRequestContext context) {
|
||||
String uploadId, String expectedSlug, String displayName, String shortDescription,
|
||||
String overview, boolean resetPresentation, AuditRequestContext context) {
|
||||
if (action == AvatarAction.REPLACE && (uploadId == null || uploadId.isBlank())
|
||||
|| action != AvatarAction.REPLACE && uploadId != null && !uploadId.isBlank()) {
|
||||
throw new DomainBadRequestException("error.skill.avatar.invalidUpload");
|
||||
|
|
@ -64,9 +65,13 @@ public class SkillPublishAppService {
|
|||
uploads.validateReference(namespace, targetSlug, actor, uploadId);
|
||||
}
|
||||
}
|
||||
SkillPublishService.PublishResult result = displayName == null || displayName.isBlank()
|
||||
? publications.publishFromEntries(namespace, entries, actor, visibility, roles, confirmWarnings)
|
||||
: publications.publishFromEntries(namespace, entries, actor, visibility, roles, confirmWarnings, displayName);
|
||||
SkillPublishService.PublishResult result = (shortDescription == null || shortDescription.isBlank())
|
||||
&& (overview == null || overview.isBlank()) && !resetPresentation
|
||||
? (displayName == null || displayName.isBlank()
|
||||
? publications.publishFromEntries(namespace, entries, actor, visibility, roles, confirmWarnings)
|
||||
: publications.publishFromEntries(namespace, entries, actor, visibility, roles, confirmWarnings, displayName))
|
||||
: publications.publishFromEntries(namespace, entries, actor, visibility, roles, confirmWarnings, displayName,
|
||||
shortDescription, overview, resetPresentation);
|
||||
var review = reviews.findBySkillVersionIdAndStatus(
|
||||
result.version().getId(), ReviewTaskStatus.PENDING).orElse(null);
|
||||
if (action == AvatarAction.REPLACE) {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,39 @@ class SkillPackagePreviewServiceTest {
|
|||
assertThat(serialized).contains("\"version\":null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOverviewStaysNullSoTheClientCanResolvePackageDocumentation() throws Exception {
|
||||
var files = new java.util.ArrayList<>(entries(""));
|
||||
byte[] readme = "# User-facing README".getBytes(StandardCharsets.UTF_8);
|
||||
files.add(new PackageEntry("README.md", readme, readme.length, "text/markdown"));
|
||||
|
||||
var response = service.preview(files);
|
||||
|
||||
assertThat(response.overview()).isNull();
|
||||
assertThat(response.description()).isEqualTo("Summary");
|
||||
assertThat(response.shortDescription()).isEqualTo("Summary");
|
||||
assertThat(response.examplesCount()).isZero();
|
||||
assertThat(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(response))
|
||||
.contains("\"overview\":null");
|
||||
assertThat(files.get(0).content()).containsExactly(entries("").get(0).content());
|
||||
assertThat(files.get(1).content()).containsExactly(readme);
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitPackageOverviewIsReturnedWithoutUsingTheSkillBody() {
|
||||
var response = service.preview(entries("metadata:\n overview: ' Package overview '\n"));
|
||||
|
||||
assertThat(response.overview()).isEqualTo("Package overview");
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankPackageOverviewDoesNotBecomeTheSkillBody() {
|
||||
for (String metadata : List.of("metadata: {}\n", "metadata:\n overview: null\n",
|
||||
"metadata:\n overview: ' '\n")) {
|
||||
assertThat(service.preview(entries(metadata)).overview()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void examplesRecognitionIsNonBlockingAndOmitsInvalidCount() throws Exception {
|
||||
assertThat(service.preview(entries("")).examplesCount()).isZero();
|
||||
|
|
|
|||
|
|
@ -320,7 +320,7 @@ public class PromotionService {
|
|||
try {
|
||||
SkillMetadata metadata = objectMapper.readValue(metadataJson, SkillMetadata.class);
|
||||
target.setDisplayName(metadata.displayName());
|
||||
target.setSummary(metadata.description());
|
||||
target.setSummary(metadata.shortDescription());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to deserialize skill metadata", e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -467,7 +467,7 @@ public class ReviewService {
|
|||
try {
|
||||
SkillMetadata metadata = objectMapper.readValue(metadataJson, SkillMetadata.class);
|
||||
skill.setDisplayName(metadata.displayName());
|
||||
skill.setSummary(metadata.description());
|
||||
skill.setSummary(metadata.shortDescription());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to deserialize skill metadata", e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,35 @@ public record SkillMetadata(
|
|||
String version,
|
||||
String body,
|
||||
Map<String, Object> frontmatter,
|
||||
String displayNameOverride
|
||||
String displayNameOverride,
|
||||
String shortDescriptionOverride,
|
||||
String overviewOverride,
|
||||
String presentationInheritedFrom
|
||||
) {
|
||||
public SkillMetadata(String name, String description, String version, String body, Map<String, Object> frontmatter) {
|
||||
this(name, description, version, body, frontmatter, null);
|
||||
this(name, description, version, body, frontmatter, null, null, null, null);
|
||||
}
|
||||
|
||||
public SkillMetadata(String name, String description, String version, String body, Map<String, Object> frontmatter,
|
||||
String displayNameOverride) {
|
||||
this(name, description, version, body, frontmatter, displayNameOverride, null, null, null);
|
||||
}
|
||||
|
||||
public SkillMetadata withPresentation(String shortDescription, String overview, String inheritedFrom) {
|
||||
String shortValue = normalize(shortDescription);
|
||||
String overviewValue = normalize(overview);
|
||||
if (shortValue != null && shortValue.length() > 200) {
|
||||
throw new DomainBadRequestException("error.skill.shortDescription.tooLong", 200);
|
||||
}
|
||||
if (overviewValue != null && overviewValue.length() > 20000) {
|
||||
throw new DomainBadRequestException("error.skill.overview.tooLong", 20000);
|
||||
}
|
||||
return new SkillMetadata(name, description, version, body, frontmatter, displayNameOverride,
|
||||
shortValue, overviewValue, inheritedFrom);
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null || value.strip().isEmpty() ? null : value.strip();
|
||||
}
|
||||
|
||||
/** Explicit portal input is validated before it becomes part of the version snapshot. */
|
||||
|
|
@ -26,6 +51,29 @@ public record SkillMetadata(
|
|||
override == null || override.isEmpty() ? null : override);
|
||||
}
|
||||
|
||||
public String shortDescription() {
|
||||
return shortDescriptionOverride != null ? shortDescriptionOverride
|
||||
: nestedText(frontmatter, "short-description", description);
|
||||
}
|
||||
|
||||
public String overview() {
|
||||
String packageOverview = packageOverview();
|
||||
return overviewOverride != null ? overviewOverride : (packageOverview != null ? packageOverview : body);
|
||||
}
|
||||
|
||||
/** Explicit package presentation only; documentation fallback is resolved by the reader. */
|
||||
public String packageOverview() {
|
||||
return nestedText(frontmatter, "overview", null);
|
||||
}
|
||||
|
||||
private static String nestedText(Map<String, Object> values, String key, String fallback) {
|
||||
if (values != null && values.get("metadata") instanceof Map<?, ?> metadata
|
||||
&& metadata.get(key) instanceof String text && !text.strip().isEmpty()) {
|
||||
return text.strip();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Human-readable label; unsupported legacy snapshot values fall back to the package name. */
|
||||
public String displayName() {
|
||||
if (displayNameOverride != null && !displayNameOverride.isBlank()
|
||||
|
|
|
|||
|
|
@ -60,6 +60,14 @@ public class SkillMetadataParser {
|
|||
}
|
||||
|
||||
validateDisplayName(frontmatter);
|
||||
String shortDescription = extractNestedOptionalField(frontmatter, "metadata", "short-description");
|
||||
String overview = extractNestedOptionalField(frontmatter, "metadata", "overview");
|
||||
if (shortDescription != null && shortDescription.length() > 200) {
|
||||
throw new DomainBadRequestException("error.skill.metadata.shortDescription.tooLong", 200);
|
||||
}
|
||||
if (overview != null && overview.length() > 20000) {
|
||||
throw new DomainBadRequestException("error.skill.metadata.overview.tooLong", 20000);
|
||||
}
|
||||
return new SkillMetadata(name, description, version, body, frontmatter);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -388,7 +388,7 @@ public class SkillGovernanceService {
|
|||
try {
|
||||
SkillMetadata metadata = objectMapper.readValue(metadataJson, SkillMetadata.class);
|
||||
skill.setDisplayName(metadata.displayName());
|
||||
skill.setSummary(metadata.description());
|
||||
skill.setSummary(metadata.shortDescription());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to deserialize skill metadata", e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ public class SkillLifecycleProjectionService {
|
|||
version.getStatus().name(),
|
||||
version.getParsedMetadataJson(),
|
||||
metadata == null ? null : metadata.displayName(),
|
||||
metadata == null ? null : metadata.description(),
|
||||
metadata == null ? null : metadata.shortDescription(),
|
||||
version.getGithubSyncRevision()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -336,8 +336,17 @@ public class SkillPublishService {
|
|||
String namespaceSlug, List<PackageEntry> entries, String publisherId,
|
||||
SkillVisibility visibility, Set<String> platformRoles, boolean confirmWarnings,
|
||||
String displayName) {
|
||||
return publishFromEntries(namespaceSlug, entries, publisherId, visibility, platformRoles, confirmWarnings,
|
||||
displayName, null, null, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PublishResult publishFromEntries(
|
||||
String namespaceSlug, List<PackageEntry> entries, String publisherId,
|
||||
SkillVisibility visibility, Set<String> platformRoles, boolean confirmWarnings,
|
||||
String displayName, String shortDescription, String overview, boolean resetPresentation) {
|
||||
return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles,
|
||||
confirmWarnings, false, false, null, null, displayName);
|
||||
confirmWarnings, false, false, null, null, displayName, shortDescription, overview, resetPresentation);
|
||||
}
|
||||
|
||||
/** Publishes a validated GitHub package into the dedicated sync review queue. */
|
||||
|
|
@ -450,10 +459,31 @@ public class SkillPublishService {
|
|||
true,
|
||||
null,
|
||||
null,
|
||||
displayNameOverrideForRerelease(publishedVersion)
|
||||
displayNameOverrideForRerelease(publishedVersion),
|
||||
presentationOverrideForRerelease(publishedVersion, "shortDescriptionOverride"),
|
||||
presentationOverrideForRerelease(publishedVersion, "overviewOverride"),
|
||||
presentationOverrideForRerelease(publishedVersion, "shortDescriptionOverride") == null
|
||||
&& presentationOverrideForRerelease(publishedVersion, "overviewOverride") == null
|
||||
);
|
||||
}
|
||||
|
||||
private boolean hasPackagePresentation(SkillMetadata metadata) {
|
||||
if (metadata.frontmatter() == null || !(metadata.frontmatter().get("metadata") instanceof Map<?, ?> values)) {
|
||||
return false;
|
||||
}
|
||||
return values.get("short-description") instanceof String shortText && !shortText.isBlank()
|
||||
|| values.get("overview") instanceof String overviewText && !overviewText.isBlank();
|
||||
}
|
||||
|
||||
private String presentationOverrideForRerelease(SkillVersion source, String field) {
|
||||
if (source.getParsedMetadataJson() == null) return null;
|
||||
try {
|
||||
return objectMapper.readTree(source.getParsedMetadataJson()).path(field).textValue();
|
||||
} catch (java.io.IOException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String displayNameOverrideForRerelease(SkillVersion source) {
|
||||
if (source.getParsedMetadataJson() == null) {
|
||||
return null;
|
||||
|
|
@ -499,6 +529,17 @@ public class SkillPublishService {
|
|||
SkillVisibility visibility, Set<String> platformRoles, boolean confirmWarnings,
|
||||
boolean forceAutoPublish, boolean bypassMembershipCheck,
|
||||
BundlePublicationTarget bundleTarget, GithubSyncPublicationTarget syncTarget, String displayName) {
|
||||
return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles,
|
||||
confirmWarnings, forceAutoPublish, bypassMembershipCheck, bundleTarget, syncTarget, displayName,
|
||||
null, null, false);
|
||||
}
|
||||
|
||||
private PublishResult publishFromEntriesInternal(
|
||||
String namespaceSlug, List<PackageEntry> entries, String publisherId,
|
||||
SkillVisibility visibility, Set<String> platformRoles, boolean confirmWarnings,
|
||||
boolean forceAutoPublish, boolean bypassMembershipCheck,
|
||||
BundlePublicationTarget bundleTarget, GithubSyncPublicationTarget syncTarget, String displayName,
|
||||
String shortDescription, String overview, boolean resetPresentation) {
|
||||
|
||||
// 1. Find namespace by slug
|
||||
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
|
||||
|
|
@ -538,6 +579,8 @@ public class SkillPublishService {
|
|||
|
||||
String skillMdContent = new String(skillMd.content());
|
||||
SkillMetadata metadata = skillMetadataParser.parse(skillMdContent).withDisplayNameOverride(displayName);
|
||||
metadata = metadata.withPresentation(resetPresentation ? null : shortDescription,
|
||||
resetPresentation ? null : overview, null);
|
||||
String upstreamVersion = syncTarget == null ? null : metadata.version();
|
||||
if (syncTarget != null) {
|
||||
// A reverted upstream package is a new review target after an intervening update.
|
||||
|
|
@ -545,10 +588,13 @@ public class SkillPublishService {
|
|||
String syncVersion = "0.0.0-g" + syncTarget.contentSha256().substring(0, 40)
|
||||
+ "-" + syncTarget.ingestionId().toString().replace("-", "").substring(0, 16);
|
||||
metadata = new SkillMetadata(metadata.name(), metadata.description(), syncVersion,
|
||||
metadata.body(), metadata.frontmatter());
|
||||
metadata.body(), metadata.frontmatter(), metadata.displayNameOverride(),
|
||||
metadata.shortDescriptionOverride(), metadata.overviewOverride(), null);
|
||||
} else if (metadata.version() == null || metadata.version().isBlank()) {
|
||||
String autoVersion = AUTO_VERSION_FORMATTER.format(currentTime());
|
||||
metadata = new SkillMetadata(metadata.name(), metadata.description(), autoVersion, metadata.body(), metadata.frontmatter(), metadata.displayNameOverride());
|
||||
metadata = new SkillMetadata(metadata.name(), metadata.description(), autoVersion,
|
||||
metadata.body(), metadata.frontmatter(), metadata.displayNameOverride(),
|
||||
metadata.shortDescriptionOverride(), metadata.overviewOverride(), null);
|
||||
}
|
||||
String skillSlug = SlugValidator.slugify(metadata.name());
|
||||
if (bundleTarget != null
|
||||
|
|
@ -646,6 +692,32 @@ public class SkillPublishService {
|
|||
newSkill = bundleTarget.expectedSkillId() == null;
|
||||
}
|
||||
|
||||
if (!newSkill && skill.getLatestVersionId() != null && !resetPresentation
|
||||
&& shortDescription == null && overview == null && !hasPackagePresentation(metadata)) {
|
||||
SkillVersion published = skillVersionRepository.findBySkillIdAndStatus(
|
||||
skill.getId(), SkillVersionStatus.PUBLISHED).stream()
|
||||
.max(java.util.Comparator.comparing(SkillVersion::getPublishedAt,
|
||||
java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
|
||||
.thenComparing(SkillVersion::getCreatedAt,
|
||||
java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
|
||||
.thenComparing(SkillVersion::getId,
|
||||
java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())))
|
||||
.orElse(null);
|
||||
if (published != null && published.getParsedMetadataJson() != null) {
|
||||
try {
|
||||
SkillMetadata previous = objectMapper.readerFor(SkillMetadata.class)
|
||||
.without(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.readValue(published.getParsedMetadataJson());
|
||||
if (previous.shortDescriptionOverride() != null || previous.overviewOverride() != null) {
|
||||
metadata = metadata.withPresentation(previous.shortDescriptionOverride(),
|
||||
previous.overviewOverride(), published.getVersion());
|
||||
}
|
||||
} catch (java.io.IOException e) {
|
||||
throw new IllegalStateException("Failed to deserialize published skill metadata", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visibilityService != null) {
|
||||
visibilityService.lockedSkill(skill.getId());
|
||||
visibilityService.assertNoPendingVisibility(skill.getId());
|
||||
|
|
@ -886,7 +958,7 @@ public class SkillPublishService {
|
|||
// A pending update must not replace the currently published title or summary.
|
||||
if (autoPublish || visibility == SkillVisibility.PRIVATE || skill.getLatestVersionId() == null) {
|
||||
skill.setDisplayName(metadata.displayName());
|
||||
skill.setSummary(metadata.description());
|
||||
skill.setSummary(metadata.shortDescription());
|
||||
}
|
||||
if (syncTarget != null) {
|
||||
skill.assignGithubSyncOwnership(publisherId);
|
||||
|
|
@ -1150,6 +1222,9 @@ public class SkillPublishService {
|
|||
parsedMetadata.put("body", metadata.body());
|
||||
parsedMetadata.put("frontmatter", metadata.frontmatter());
|
||||
parsedMetadata.put("displayNameOverride", metadata.displayNameOverride());
|
||||
parsedMetadata.put("shortDescriptionOverride", metadata.shortDescriptionOverride());
|
||||
parsedMetadata.put("overviewOverride", metadata.overviewOverride());
|
||||
parsedMetadata.put("presentationInheritedFrom", metadata.presentationInheritedFrom());
|
||||
if (syncTarget != null) {
|
||||
parsedMetadata.put("upstreamVersion", upstreamVersion);
|
||||
parsedMetadata.put("githubContentSha256", syncTarget.contentSha256());
|
||||
|
|
|
|||
|
|
@ -672,7 +672,9 @@ class PromotionServiceTest {
|
|||
Skill sourceSkill = createSourceSkill();
|
||||
SkillVersion sourceVersion = createPublishedVersion();
|
||||
sourceSkill.setLatestVersionId(999L);
|
||||
sourceVersion.setParsedMetadataJson(selectedVersionMetadata(hasDisplayName));
|
||||
sourceVersion.setParsedMetadataJson(selectedVersionMetadata(hasDisplayName)
|
||||
.replace("\"description\":\"Old version summary\"",
|
||||
"\"description\":\"Old version summary\",\"shortDescriptionOverride\":\"Selected custom summary\",\"overviewOverride\":\"# Selected overview\""));
|
||||
|
||||
when(promotionRequestRepository.findById(PROMOTION_ID))
|
||||
.thenReturn(Optional.of(pr), Optional.of(approvedRequest));
|
||||
|
|
@ -699,7 +701,10 @@ class PromotionServiceTest {
|
|||
verify(skillRepository, times(2)).save(skillCaptor.capture());
|
||||
Skill newSkill = skillCaptor.getAllValues().get(0);
|
||||
assertEquals(hasDisplayName ? "旧版标题" : "包内标题", newSkill.getDisplayName());
|
||||
assertEquals("Old version summary", newSkill.getSummary());
|
||||
assertEquals("Selected custom summary", newSkill.getSummary());
|
||||
ArgumentCaptor<SkillVersion> versionCaptor = ArgumentCaptor.forClass(SkillVersion.class);
|
||||
verify(skillVersionRepository).save(versionCaptor.capture());
|
||||
assertEquals(sourceVersion.getParsedMetadataJson(), versionCaptor.getValue().getParsedMetadataJson());
|
||||
assertEquals(NEW_VERSION_ID, newSkill.getLatestVersionId());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -304,7 +304,8 @@ class ReviewServiceTest {
|
|||
assertDoesNotThrow(() -> sv.setParsedMetadataJson(objectMapper.writeValueAsString(
|
||||
new SkillMetadata("Approved Name", "Approved Summary", "1.0.0", "Body",
|
||||
hasDisplayName ? Map.of("metadata", Map.of("display-name", "包内标题")) : Map.of(),
|
||||
hasDisplayName ? "已审核的中文标题" : null)
|
||||
hasDisplayName ? "已审核的中文标题" : null,
|
||||
"Approved custom summary", "# Approved overview", null)
|
||||
)));
|
||||
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
|
@ -333,7 +334,9 @@ class ReviewServiceTest {
|
|||
assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId());
|
||||
assertEquals(hasDisplayName ? "已审核的中文标题" : "Approved Name", skill.getDisplayName());
|
||||
assertEquals("my-skill", skill.getSlug());
|
||||
assertEquals("Approved Summary", skill.getSummary());
|
||||
assertEquals("Approved custom summary", skill.getSummary());
|
||||
assertEquals("# Approved overview", assertDoesNotThrow(() ->
|
||||
objectMapper.readTree(sv.getParsedMetadataJson())).path("overviewOverride").asText());
|
||||
assertEquals(REVIEWER_ID, skill.getUpdatedBy());
|
||||
verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));
|
||||
verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("REVIEW"), eq("REVIEW_TASK"), eq(REVIEW_TASK_ID), eq("Review approved"), any());
|
||||
|
|
|
|||
|
|
@ -11,6 +11,47 @@ class SkillMetadataParserTest {
|
|||
|
||||
private final SkillMetadataParser parser = new SkillMetadataParser();
|
||||
|
||||
@Test
|
||||
void packageOverviewDoesNotIncludeOverridesOrBodyFallback() {
|
||||
SkillMetadata declared = parser.parse("---\nname: my-skill\ndescription: Original\nmetadata:\n overview: ' Package overview '\n---\nBody");
|
||||
assertEquals("Package overview", declared.packageOverview());
|
||||
assertEquals("Package overview", declared.withPresentation(null, "Web overview", null).packageOverview());
|
||||
for (Map<String, Object> frontmatter : java.util.List.of(Map.<String, Object>of(),
|
||||
Map.<String, Object>of("metadata", Map.of("overview", " ")),
|
||||
Map.<String, Object>of("metadata", Map.of("overview", 123)),
|
||||
Map.<String, Object>of("metadata", "legacy"))) {
|
||||
SkillMetadata metadata = new SkillMetadata("my-skill", "Summary", "1.0.0", "Body", frontmatter);
|
||||
assertNull(metadata.packageOverview());
|
||||
assertEquals("Body", metadata.overview());
|
||||
}
|
||||
assertNull(new SkillMetadata("my-skill", "Summary", "1.0.0", "Body", null).packageOverview());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presentationOverridesAndPackageDefaultsRemainDistinct() {
|
||||
SkillMetadata source = parser.parse("---\nname: my-skill\ndescription: Original\nmetadata:\n short-description: Package short\n overview: Package overview\n---\nBody");
|
||||
assertEquals("Package short", source.shortDescription());
|
||||
assertEquals("Package overview", source.overview());
|
||||
assertNull(source.shortDescriptionOverride());
|
||||
assertNull(source.overviewOverride());
|
||||
assertEquals("Original", source.withPresentation(null, null, null).description());
|
||||
SkillMetadata reset = source.withPresentation("Web short", "Web overview", null)
|
||||
.withPresentation(null, null, null);
|
||||
assertNull(reset.shortDescriptionOverride());
|
||||
assertNull(reset.overviewOverride());
|
||||
assertEquals("Package short", reset.shortDescription());
|
||||
assertEquals("Package overview", reset.overview());
|
||||
SkillMetadata custom = source.withPresentation(" Web short ", " # Web overview ", null);
|
||||
assertEquals("Web short", custom.shortDescription());
|
||||
assertEquals("# Web overview", custom.overview());
|
||||
assertEquals(source.frontmatter(), custom.frontmatter());
|
||||
assertThrows(DomainBadRequestException.class,
|
||||
() -> source.withPresentation("x".repeat(201), null, null));
|
||||
assertThrows(DomainBadRequestException.class,
|
||||
() -> source.withPresentation(null, "x".repeat(20001), null));
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void platformOverrideIsVersionedWithoutChangingFrontmatter() throws Exception {
|
||||
SkillMetadata source = parser.parse("---\nname: my-skill\ndescription: Test\nmetadata:\n display-name: 包内标题\n---\nBody");
|
||||
|
|
|
|||
|
|
@ -297,6 +297,8 @@ class SkillGovernanceServiceTest {
|
|||
fallback.setParsedMetadataJson(new ObjectMapper().writeValueAsString(Map.of(
|
||||
"name", "example-skill",
|
||||
"description", "Published summary from v1",
|
||||
"shortDescriptionOverride", "Restored custom summary",
|
||||
"overviewOverride", "# Restored overview",
|
||||
"displayNameOverride", displayNameCase.equals("override") ? "网页旧版标题" : "",
|
||||
"frontmatter", switch (displayNameCase) {
|
||||
case "custom" -> Map.of("metadata", Map.of("display-name", "示例技能一"));
|
||||
|
|
@ -321,7 +323,9 @@ class SkillGovernanceServiceTest {
|
|||
|
||||
assertThat(skill.getLatestVersionId()).isEqualTo(11L);
|
||||
assertThat(skill.getDisplayName()).isEqualTo(displayNameCase.equals("override") ? "网页旧版标题" : displayNameCase.equals("custom") ? "示例技能一" : "example-skill");
|
||||
assertThat(skill.getSummary()).isEqualTo("Published summary from v1");
|
||||
assertThat(skill.getSummary()).isEqualTo("Restored custom summary");
|
||||
assertThat(new ObjectMapper().readTree(fallback.getParsedMetadataJson()).path("overviewOverride").asText())
|
||||
.isEqualTo("# Restored overview");
|
||||
verify(skillRepository).save(skill);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,80 @@ class SkillPublishServiceTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void inheritsOnlyPublishedCustomPresentationAndFreezesSnapshot() throws Exception {
|
||||
String content = "---\nname: test-skill\ndescription: New default\nversion: 2.0.0\n---\nBody";
|
||||
PackageEntry entry = new PackageEntry("SKILL.md", content.getBytes(StandardCharsets.UTF_8),
|
||||
content.length(), "text/markdown");
|
||||
List<PackageEntry> entries = List.of(entry);
|
||||
Namespace namespace = new Namespace("test-ns", "Test NS", "owner");
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, "test-skill", "owner", SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setSummary("Published custom");
|
||||
skill.setLatestVersionId(11L);
|
||||
SkillVersion previous = new SkillVersion(1L, "1.0.0", "owner");
|
||||
setId(previous, 11L);
|
||||
previous.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
previous.setParsedMetadataJson(objectMapper.writeValueAsString(Map.of(
|
||||
"name", "test-skill", "description", "Old default", "version", "1.0.0",
|
||||
"body", "Body", "frontmatter", Map.of(),
|
||||
"shortDescriptionOverride", "Published custom", "overviewOverride", "# Custom overview",
|
||||
"complianceSnapshot", Map.of("status", "PASS"))));
|
||||
when(namespaceRepository.findBySlug("test-ns")).thenReturn(Optional.of(namespace));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner"))
|
||||
.thenReturn(Optional.of(mock(NamespaceMember.class)));
|
||||
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(content)).thenReturn(new SkillMetadata("test-skill", "New default", "2.0.0", "Body", Map.of()));
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "test-skill")).thenReturn(List.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "test-skill", "owner")).thenReturn(Optional.of(skill));
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(previous));
|
||||
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(call -> {
|
||||
SkillVersion version = call.getArgument(0);
|
||||
if (version.getId() == null) setId(version, 12L);
|
||||
return version;
|
||||
});
|
||||
SkillVersion pending = service.publishFromEntries("test-ns", entries, "owner", SkillVisibility.PUBLIC,
|
||||
Set.of(), true, null, null, null, false).version();
|
||||
assertEquals("Published custom", skill.getSummary());
|
||||
assertEquals(SkillVersionStatus.PENDING_REVIEW, pending.getStatus());
|
||||
JsonNode snapshot = objectMapper.readTree(pending.getParsedMetadataJson());
|
||||
assertEquals("1.0.0", snapshot.path("presentationInheritedFrom").asText());
|
||||
assertEquals("Published custom", snapshot.path("shortDescriptionOverride").asText());
|
||||
assertEquals("# Custom overview", snapshot.path("overviewOverride").asText());
|
||||
assertEquals("New default", snapshot.path("description").asText());
|
||||
|
||||
SkillVersion reset = service.publishFromEntries("test-ns", entries, "owner", SkillVisibility.PUBLIC,
|
||||
Set.of(), true, null, null, null, true).version();
|
||||
JsonNode resetSnapshot = objectMapper.readTree(reset.getParsedMetadataJson());
|
||||
assertTrue(resetSnapshot.path("shortDescriptionOverride").isNull());
|
||||
assertTrue(resetSnapshot.path("overviewOverride").isNull());
|
||||
assertTrue(resetSnapshot.path("presentationInheritedFrom").isNull());
|
||||
SkillMetadata restored = objectMapper.readerFor(SkillMetadata.class)
|
||||
.without(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.readValue(reset.getParsedMetadataJson());
|
||||
assertEquals("New default", restored.shortDescription());
|
||||
assertEquals("Body", restored.overview());
|
||||
assertEquals("Published custom", skill.getSummary());
|
||||
|
||||
previous.setParsedMetadataJson(objectMapper.writeValueAsString(
|
||||
new SkillMetadata("test-skill", "Old default", "1.0.0", "Body", Map.of())));
|
||||
SkillVersion defaults = service.publishFromEntries("test-ns", entries, "owner", SkillVisibility.PUBLIC,
|
||||
Set.of(), true, null, null, null, false).version();
|
||||
JsonNode defaultSnapshot = objectMapper.readTree(defaults.getParsedMetadataJson());
|
||||
assertTrue(defaultSnapshot.path("shortDescriptionOverride").isNull());
|
||||
assertTrue(defaultSnapshot.path("presentationInheritedFrom").isNull());
|
||||
|
||||
SkillVersion publishedReset = service.publishFromEntries("test-ns", entries, "owner", SkillVisibility.PUBLIC,
|
||||
Set.of("SUPER_ADMIN"), true, null, null, null, true).version();
|
||||
assertEquals(SkillVersionStatus.PUBLISHED, publishedReset.getStatus());
|
||||
assertEquals("New default", skill.getSummary());
|
||||
assertEquals("Body", objectMapper.readerFor(SkillMetadata.class)
|
||||
.without(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.<SkillMetadata>readValue(publishedReset.getParsedMetadataJson()).overview());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPublishFromEntries_Success() throws Exception {
|
||||
// Arrange
|
||||
|
|
@ -1025,13 +1099,14 @@ class SkillPublishServiceTest {
|
|||
setId(skill, 11L);
|
||||
skill.setDisplayName("Demo Skill");
|
||||
skill.setSummary("Original summary");
|
||||
skill.setLatestVersionId(21L);
|
||||
Namespace namespace = new Namespace("global", "Global", "owner");
|
||||
setId(namespace, 1L);
|
||||
|
||||
SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId);
|
||||
setId(sourceVersion, 21L);
|
||||
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
sourceVersion.setParsedMetadataJson("{\"displayNameOverride\":\"原网页标题\"}");
|
||||
sourceVersion.setParsedMetadataJson("{\"displayNameOverride\":\"原网页标题\",\"shortDescriptionOverride\":\"Custom summary\",\"overviewOverride\":\"# Custom overview\"}");
|
||||
sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z"));
|
||||
|
||||
String sourceSkillMd = """
|
||||
|
|
@ -1062,6 +1137,8 @@ class SkillPublishServiceTest {
|
|||
when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile, readmeFile));
|
||||
when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8)));
|
||||
when(objectStorageService.getObject(readmeFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(readmeBytes));
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "demo-skill", publisherId))
|
||||
.thenReturn(Optional.of(skill));
|
||||
when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
|
|
@ -1085,6 +1162,9 @@ class SkillPublishServiceTest {
|
|||
|
||||
assertEquals("1.2.4", result.version().getVersion());
|
||||
assertEquals("原网页标题", objectMapper.readTree(result.version().getParsedMetadataJson()).path("displayNameOverride").asText());
|
||||
assertEquals("Custom summary", objectMapper.readTree(result.version().getParsedMetadataJson()).path("shortDescriptionOverride").asText());
|
||||
assertEquals("# Custom overview", objectMapper.readTree(result.version().getParsedMetadataJson()).path("overviewOverride").asText());
|
||||
assertEquals("Original summary", skill.getSummary(), "Pending rerelease must not change the public summary");
|
||||
// Rerelease for PUBLIC skill should go to PENDING_REVIEW (respecting visibility rules)
|
||||
assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
|
||||
// Review task should be created for PUBLIC skill
|
||||
|
|
|
|||
|
|
@ -123,13 +123,19 @@ public class PostgresSearchRebuildService implements SearchRebuildService {
|
|||
private SearchIndexPayload buildSearchPayload(Skill skill) {
|
||||
List<String> searchParts = new ArrayList<>();
|
||||
addPart(searchParts, skill.getSlug());
|
||||
addPart(searchParts, skill.getDisplayName());
|
||||
addPart(searchParts, skill.getSummary());
|
||||
|
||||
Set<String> keywords = new TreeSet<>();
|
||||
resolveLatestVersion(skill)
|
||||
.map(this::extractParsedMetadata)
|
||||
.ifPresent(metadata -> {
|
||||
appendFrontmatter(asMap(metadata.get("frontmatter")), keywords, searchParts);
|
||||
addPart(searchParts, String.valueOf(metadata.get("description")));
|
||||
Map<String, Object> frontmatter = new LinkedHashMap<>(asMap(metadata.get("frontmatter")));
|
||||
Map<String, Object> packageMetadata = new LinkedHashMap<>(asMap(frontmatter.get("metadata")));
|
||||
packageMetadata.remove("overview");
|
||||
frontmatter.put("metadata", packageMetadata);
|
||||
appendFrontmatter(frontmatter, keywords, searchParts);
|
||||
appendComplianceSnapshot(
|
||||
asMap(metadata.get(ComplianceMetadataService.SNAPSHOT_FIELD_NAME)),
|
||||
keywords,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,31 @@ import static org.mockito.Mockito.when;
|
|||
|
||||
class PostgresSearchRebuildServiceTest {
|
||||
|
||||
@Test
|
||||
void rebuildBySkill_indexesEffectiveSummaryAndOriginalDescriptionButNotOverview() {
|
||||
SkillRepository skills = mock(SkillRepository.class);
|
||||
NamespaceRepository namespaces = mock(NamespaceRepository.class);
|
||||
SkillVersionRepository versions = mock(SkillVersionRepository.class);
|
||||
SearchIndexService index = mock(SearchIndexService.class);
|
||||
Skill skill = new Skill(7L, "slug", "owner", SkillVisibility.PUBLIC);
|
||||
skill.setDisplayName("Custom title");
|
||||
skill.setSummary("Custom short");
|
||||
skill.setLatestVersionId(99L);
|
||||
SkillVersion version = new SkillVersion(1L, "1.0.0", "owner");
|
||||
version.setParsedMetadataJson("""
|
||||
{"description":"Original package description","frontmatter":
|
||||
{"metadata":{"overview":"Private detail-only text"}}}
|
||||
""");
|
||||
when(skills.findById(7L)).thenReturn(Optional.of(skill));
|
||||
when(namespaces.findById(7L)).thenReturn(Optional.of(new Namespace("team", "Team", "owner")));
|
||||
when(versions.findById(99L)).thenReturn(Optional.of(version));
|
||||
newService(skills, namespaces, versions, index).rebuildBySkill(7L);
|
||||
ArgumentCaptor<SkillSearchDocument> captor = ArgumentCaptor.forClass(SkillSearchDocument.class);
|
||||
verify(index).index(captor.capture());
|
||||
assertThat(captor.getValue().searchText()).contains("Custom title", "Custom short", "Original package description")
|
||||
.doesNotContain("Private detail-only text");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rebuildBySkill_shouldIndexFrontmatterFieldsAndKeywordsWithoutBody() {
|
||||
SkillRepository skillRepository = mock(SkillRepository.class);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@
|
|||
"@tanstack/react-router": "^1.95.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"fflate": "0.8.3",
|
||||
"i18next": "^25.8.18",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lowlight": "^3.3.0",
|
||||
|
|
@ -76,6 +77,7 @@
|
|||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vditor": "4.0.1",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
23
web/pnpm-lock.yaml
generated
23
web/pnpm-lock.yaml
generated
|
|
@ -59,6 +59,9 @@ importers:
|
|||
clsx:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.1
|
||||
fflate:
|
||||
specifier: 0.8.3
|
||||
version: 0.8.3
|
||||
i18next:
|
||||
specifier: ^25.8.18
|
||||
version: 25.8.18(typescript@5.9.3)
|
||||
|
|
@ -119,6 +122,9 @@ importers:
|
|||
unist-util-visit:
|
||||
specifier: ^5.0.0
|
||||
version: 5.1.0
|
||||
vditor:
|
||||
specifier: 4.0.1
|
||||
version: 4.0.1
|
||||
zustand:
|
||||
specifier: ^5.0.11
|
||||
version: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
||||
|
|
@ -2023,6 +2029,9 @@ packages:
|
|||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
diff-match-patch@1.0.5:
|
||||
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
|
||||
|
||||
diff@8.0.4:
|
||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
|
@ -2166,6 +2175,9 @@ packages:
|
|||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.8.3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
file-entry-cache@6.0.1:
|
||||
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
|
||||
engines: {node: ^10.12.0 || >=12.0.0}
|
||||
|
|
@ -3298,6 +3310,9 @@ packages:
|
|||
resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==}
|
||||
hasBin: true
|
||||
|
||||
vditor@4.0.1:
|
||||
resolution: {integrity: sha512-CyajvPNE8fjCgEAzAaSxVEEWwQMJ5FTIVkNn3k+5IRaldqGwPRr8Dx2QLALHGCby8L/AJ6pQXjXiSPUGhHGmDw==}
|
||||
|
||||
vfile-message@4.0.3:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
|
|
@ -5275,6 +5290,8 @@ snapshots:
|
|||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
diff-match-patch@1.0.5: {}
|
||||
|
||||
diff@8.0.4: {}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
|
|
@ -5456,6 +5473,8 @@ snapshots:
|
|||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
|
||||
fflate@0.8.3: {}
|
||||
|
||||
file-entry-cache@6.0.1:
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
|
|
@ -6843,6 +6862,10 @@ snapshots:
|
|||
|
||||
uuid@14.0.2: {}
|
||||
|
||||
vditor@4.0.1:
|
||||
dependencies:
|
||||
diff-match-patch: 1.0.5
|
||||
|
||||
vfile-message@4.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
|
|
|||
8
web/src/api/generated/schema.d.ts
vendored
8
web/src/api/generated/schema.d.ts
vendored
|
|
@ -8187,6 +8187,8 @@ export interface components {
|
|||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
shortDescription?: string;
|
||||
overview?: string;
|
||||
version?: string;
|
||||
/** @enum {string} */
|
||||
examplesStatus?: "ready" | "none" | "invalid";
|
||||
|
|
@ -15503,6 +15505,9 @@ export interface operations {
|
|||
avatarUploadId?: string;
|
||||
expectedSlug?: string;
|
||||
displayName?: string;
|
||||
shortDescription?: string;
|
||||
overview?: string;
|
||||
resetPresentation?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
|
|
@ -15539,6 +15544,9 @@ export interface operations {
|
|||
avatarUploadId?: string;
|
||||
expectedSlug?: string;
|
||||
displayName?: string;
|
||||
shortDescription?: string;
|
||||
overview?: string;
|
||||
resetPresentation?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
|
|
|
|||
|
|
@ -24,11 +24,11 @@ describe('passive, caller-scoped analytics attributes', () => {
|
|||
it('omits disabled dropzone intents without changing dropzone or native folder guards', () => {
|
||||
const select = vi.fn()
|
||||
const view = render(<UploadZone analyticsId="skill.package.select" folderAnalyticsId="skill.package.folder.select" onFileSelect={select} onFolderSelect={vi.fn()} />)
|
||||
const drop = view.container.querySelector('.upload-zone')!
|
||||
const drop = view.container.querySelector('[data-analytics-event="click+keydown+drop"]')!
|
||||
expect(attr(drop)).toBe('skill.package.select')
|
||||
view.rerender(<UploadZone analyticsId="skill.package.select" folderAnalyticsId="skill.package.folder.select" onFileSelect={select} onFolderSelect={vi.fn()} disabled />)
|
||||
expect(attr(drop)).toBeNull()
|
||||
const file = view.container.querySelector('.upload-zone input')!
|
||||
const file = drop.querySelector('input')!
|
||||
expect(attr(file)).toBeNull()
|
||||
expect(view.container.querySelector('[data-analytics-id="skill.package.folder.select"]')?.hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.change(file, { target: { files: [new File(['synthetic'], 'synthetic.zip', { type: 'application/zip' })] } })
|
||||
|
|
|
|||
106
web/src/features/publish/package-overview.test.ts
Normal file
106
web/src/features/publish/package-overview.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { zipSync, strToU8 } from 'fflate'
|
||||
import {
|
||||
PACKAGE_OVERVIEW_ARCHIVE_LIMIT,
|
||||
PACKAGE_OVERVIEW_TEXT_LIMIT,
|
||||
readPackageOverview,
|
||||
} from './package-overview'
|
||||
|
||||
function archive(files: Record<string, string | Uint8Array>, level: 0 | 6 = 6): File {
|
||||
const data = Object.fromEntries(Object.entries(files).map(([name, value]) => [name, typeof value === 'string' ? strToU8(value) : value]))
|
||||
return new File([zipSync(data, { level })], 'skill.zip', { type: 'application/zip' })
|
||||
}
|
||||
|
||||
describe('readPackageOverview', () => {
|
||||
it.each([0, 6] as const)('reads real STORE/deflate archives (level %i) and prefers root README', async level => {
|
||||
expect(await readPackageOverview(archive({ 'SKILL.md': 'original instructions', 'README.md': '# 中文介绍\n\n正文。' }, level)))
|
||||
.toEqual({ content: '# 中文介绍\n\n正文。', source: 'readme' })
|
||||
})
|
||||
|
||||
it('retains original SKILL frontmatter without changing package bytes', async () => {
|
||||
const content = '---\nname: example\n---\n\n# 原始指令'
|
||||
const file = archive({ 'skill.md': content })
|
||||
const before = await file.arrayBuffer()
|
||||
expect(await readPackageOverview(file)).toEqual({ content, source: 'skill' })
|
||||
expect(await file.arrayBuffer()).toEqual(before)
|
||||
})
|
||||
|
||||
it('strips a shared wrapper while ignoring OS metadata', async () => {
|
||||
expect(await readPackageOverview(archive({
|
||||
'wrapped/SKILL.md': 'skill', 'wrapped/README.md': 'readme',
|
||||
'__MACOSX/._README.md': 'resource fork', '.DS_Store': 'junk',
|
||||
}))).toEqual({ content: 'readme', source: 'readme' })
|
||||
})
|
||||
|
||||
it('promotes only the immediate skill directory like the backend', async () => {
|
||||
expect(await readPackageOverview(archive({
|
||||
'outside.txt': 'ignored by backend promotion', 'skill/SKILL.md': 'skill', 'skill/README.md': 'promoted',
|
||||
}))).toEqual({ content: 'promoted', source: 'readme' })
|
||||
})
|
||||
|
||||
it('does not select nested or hidden documentation when the root skill exists', async () => {
|
||||
expect(await readPackageOverview(archive({
|
||||
'SKILL.md': 'root', 'docs/README.md': 'nested', '.hidden/README.md': 'hidden',
|
||||
}))).toEqual({ content: 'root', source: 'skill' })
|
||||
})
|
||||
|
||||
it('does not read a hidden skill directory', async () => {
|
||||
expect(await readPackageOverview(archive({ '.hidden/SKILL.md': 'secret' }))).toEqual({ content: '', source: null })
|
||||
})
|
||||
|
||||
it('does not choose arbitrarily among ambiguous skill directories', async () => {
|
||||
expect(await readPackageOverview(archive({ 'one/SKILL.md': 'one', 'two/SKILL.md': 'two' })))
|
||||
.toEqual({ content: '', source: null, error: 'unavailable' })
|
||||
})
|
||||
|
||||
it('returns an empty result when no root documentation can be resolved', async () => {
|
||||
expect(await readPackageOverview(archive({ 'icon.txt': 'no document' }))).toEqual({ content: '', source: null })
|
||||
})
|
||||
|
||||
it('reports malformed archives and invalid UTF-8 instead of replacement text', async () => {
|
||||
expect(await readPackageOverview(new File(['not zip'], 'invalid.zip'))).toEqual({ content: '', source: null, error: 'unavailable' })
|
||||
expect(await readPackageOverview(archive({ 'README.md': new Uint8Array([0xc3, 0x28]) })))
|
||||
.toEqual({ content: '', source: null, error: 'unavailable' })
|
||||
})
|
||||
|
||||
it('rejects non-normalized paths rather than picking a misleading README', async () => {
|
||||
expect(await readPackageOverview(archive({ '../README.md': 'outside', 'SKILL.md': 'root' })))
|
||||
.toEqual({ content: '', source: null, error: 'unavailable' })
|
||||
})
|
||||
|
||||
it('bounds compressed input as an optional-preview limit', async () => {
|
||||
const file = new File([new Uint8Array(PACKAGE_OVERVIEW_ARCHIVE_LIMIT + 1)], 'large.zip')
|
||||
expect(await readPackageOverview(file)).toEqual({ content: '', source: null, error: 'tooLarge' })
|
||||
})
|
||||
|
||||
it('bounds declared and actual decompressed text bytes', async () => {
|
||||
const file = archive({ 'README.md': 'x'.repeat(PACKAGE_OVERVIEW_TEXT_LIMIT + 1) })
|
||||
expect(await readPackageOverview(file)).toEqual({ content: '', source: null, error: 'tooLarge' })
|
||||
// Lie about the local header size: the output budget must still catch it.
|
||||
const bytes = new Uint8Array(await file.arrayBuffer())
|
||||
new DataView(bytes.buffer).setUint32(22, 1, true)
|
||||
expect(await readPackageOverview(new File([bytes], 'misleading-size.zip')))
|
||||
.toEqual({ content: '', source: null, error: 'tooLarge' })
|
||||
})
|
||||
|
||||
it('does not inflate oversized unrelated assets', async () => {
|
||||
expect(await readPackageOverview(archive({
|
||||
'README.md': 'small document', 'asset.txt': 'x'.repeat(PACKAGE_OVERVIEW_TEXT_LIMIT * 2),
|
||||
}))).toEqual({ content: 'small document', source: 'readme' })
|
||||
})
|
||||
|
||||
it('supports an exact text budget without truncation', async () => {
|
||||
const content = 'x'.repeat(PACKAGE_OVERVIEW_TEXT_LIMIT)
|
||||
expect(await readPackageOverview(archive({ 'README.md': content }))).toEqual({ content, source: 'readme' })
|
||||
})
|
||||
|
||||
it('aborts before reading and while indexing without returning stale content', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(readPackageOverview(archive({ 'SKILL.md': 'old' }), controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
|
||||
const pending = new AbortController()
|
||||
const result = readPackageOverview(archive({ 'asset.bin': new Uint8Array(200_000), 'SKILL.md': 'old' }, 0), pending.signal)
|
||||
setTimeout(() => pending.abort(), 0)
|
||||
await expect(result).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
})
|
||||
125
web/src/features/publish/package-overview.ts
Normal file
125
web/src/features/publish/package-overview.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { Unzip, UnzipInflate, type UnzipFile } from 'fflate'
|
||||
|
||||
// Optional browser-preview budgets, not upload or server package limits.
|
||||
export const PACKAGE_OVERVIEW_ARCHIVE_LIMIT = 20 * 1024 * 1024
|
||||
export const PACKAGE_OVERVIEW_TEXT_LIMIT = 1024 * 1024
|
||||
const ENTRY_LIMIT = 256
|
||||
const INPUT_CHUNK_BYTES = 1024
|
||||
|
||||
export interface PackageOverview {
|
||||
content: string
|
||||
source: 'readme' | 'skill' | null
|
||||
error?: 'unavailable' | 'tooLarge'
|
||||
}
|
||||
|
||||
class PreviewLimitError extends Error {}
|
||||
|
||||
function checkAbort(signal?: AbortSignal) {
|
||||
if (signal?.aborted) throw new DOMException('Package preview cancelled', 'AbortError')
|
||||
}
|
||||
|
||||
function archivePath(name: string): string | null {
|
||||
const path = name.replace(/\\/g, '/').trim()
|
||||
if (path.endsWith('/')) return null
|
||||
const parts = path.split('/')
|
||||
const filename = parts[parts.length - 1]
|
||||
if (parts[0] === '__MACOSX' || filename === '.DS_Store' || filename.startsWith('._')) return null
|
||||
if (!path || path.startsWith('/') || path.includes(':') || parts.some(part => !part || part === '.' || part === '..')) {
|
||||
throw new Error('Invalid archive path')
|
||||
}
|
||||
if (filename.toLowerCase() === 'skill.md') parts[parts.length - 1] = 'SKILL.md'
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
function documentationEntry(entries: Map<string, UnzipFile>): { file: UnzipFile; source: 'readme' | 'skill' } | null {
|
||||
// Match SkillPackageArchiveExtractor: strip one shared outer directory,
|
||||
// then promote a single immediate skill directory when SKILL.md is absent.
|
||||
let paths = [...entries.keys()]
|
||||
let prefix = ''
|
||||
if (paths.length && paths.every(path => path.includes('/'))) {
|
||||
const roots = new Set(paths.map(path => path.split('/')[0]))
|
||||
if (roots.size === 1) {
|
||||
prefix = paths[0].slice(0, paths[0].indexOf('/') + 1)
|
||||
paths = paths.map(path => path.slice(prefix.length))
|
||||
}
|
||||
}
|
||||
if (!paths.includes('SKILL.md')) {
|
||||
const skillRoots = paths.filter(path => /^[^/]+\/SKILL\.md$/.test(path))
|
||||
if (skillRoots.length > 1) throw new Error('Ambiguous skill directories')
|
||||
if (skillRoots.length === 1) {
|
||||
prefix += skillRoots[0].slice(0, skillRoots[0].indexOf('/') + 1)
|
||||
}
|
||||
}
|
||||
// Hidden directories and installer/OS metadata must not become the overview.
|
||||
if (prefix.split('/').some(part => part.startsWith('.'))) return null
|
||||
for (const [name, source] of [['README.md', 'readme'], ['SKILL.md', 'skill']] as const) {
|
||||
const file = entries.get(prefix + name)
|
||||
if (file) return { file, source }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Read only the selected root document; never inflate unrelated package files. */
|
||||
export async function readPackageOverview(file: File, signal?: AbortSignal): Promise<PackageOverview> {
|
||||
checkAbort(signal)
|
||||
if (file.size > PACKAGE_OVERVIEW_ARCHIVE_LIMIT) return { content: '', source: null, error: 'tooLarge' }
|
||||
|
||||
try {
|
||||
const data = new Uint8Array(await file.arrayBuffer())
|
||||
checkAbort(signal)
|
||||
const entries = new Map<string, UnzipFile>()
|
||||
const unzip = new Unzip(entry => {
|
||||
const path = archivePath(entry.name)
|
||||
if (!path) return
|
||||
if (entries.size >= ENTRY_LIMIT) throw new PreviewLimitError()
|
||||
if (entries.has(path)) throw new Error('Duplicate archive path')
|
||||
entries.set(path, entry)
|
||||
})
|
||||
unzip.register(UnzipInflate)
|
||||
// Small compressed chunks also bound each inflater output allocation. Yield
|
||||
// while indexing so cancellation can interrupt a large optional preview.
|
||||
for (let offset = 0; offset < data.length; offset += INPUT_CHUNK_BYTES) {
|
||||
checkAbort(signal)
|
||||
const end = Math.min(offset + INPUT_CHUNK_BYTES, data.length)
|
||||
unzip.push(data.subarray(offset, end), end === data.length)
|
||||
if (end < data.length && end % (64 * INPUT_CHUNK_BYTES) === 0) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
if (!entries.size) return { content: '', source: null, error: 'unavailable' }
|
||||
const document = documentationEntry(entries)
|
||||
if (!document) return { content: '', source: null }
|
||||
const entry = document.file
|
||||
if (entry.originalSize !== undefined && entry.originalSize > PACKAGE_OVERVIEW_TEXT_LIMIT) {
|
||||
throw new PreviewLimitError()
|
||||
}
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
let finished = false
|
||||
try {
|
||||
entry.ondata = (error, chunk, final) => {
|
||||
checkAbort(signal)
|
||||
if (error) throw error
|
||||
size += chunk.length
|
||||
if (size > PACKAGE_OVERVIEW_TEXT_LIMIT) throw new PreviewLimitError()
|
||||
chunks.push(chunk)
|
||||
finished = final
|
||||
}
|
||||
entry.start()
|
||||
} finally {
|
||||
entry.terminate()
|
||||
}
|
||||
if (!finished) throw new Error('Incomplete document')
|
||||
const content = new Uint8Array(size)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
content.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
checkAbort(signal)
|
||||
return { content: new TextDecoder('utf-8', { fatal: true }).decode(content), source: document.source }
|
||||
} catch (error) {
|
||||
checkAbort(signal)
|
||||
return { content: '', source: null, error: error instanceof PreviewLimitError ? 'tooLarge' : 'unavailable' }
|
||||
}
|
||||
}
|
||||
104
web/src/features/publish/presentation-editor-rich.test.tsx
Normal file
104
web/src/features/publish/presentation-editor-rich.test.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import PresentationEditorRich from './presentation-editor-rich'
|
||||
|
||||
const engine = vi.hoisted(() => ({ options: undefined as IOptions | undefined, destroy: vi.fn(), construct: vi.fn(), value: '' }))
|
||||
vi.mock('react-i18next', () => {
|
||||
const t = (key: string) => key
|
||||
return { useTranslation: () => ({ t, i18n: { resolvedLanguage: 'zh' } }) }
|
||||
})
|
||||
vi.mock('vditor', () => ({ default: class {
|
||||
constructor(element: HTMLElement, options: IOptions) {
|
||||
engine.construct()
|
||||
engine.options = options
|
||||
element.innerHTML = '<div class="vditor-toolbar"><button type="button">format</button></div><div contenteditable="true">content</div>'
|
||||
queueMicrotask(() => options.after?.())
|
||||
}
|
||||
destroy() { engine.destroy() }
|
||||
focus() {}
|
||||
insertMD() {}
|
||||
getValue() { return engine.value }
|
||||
} }))
|
||||
|
||||
afterEach(() => { cleanup(); vi.clearAllMocks() })
|
||||
|
||||
async function loadAssets() {
|
||||
for (const script of document.querySelectorAll('script[src]')) fireEvent.load(script)
|
||||
await waitFor(() => expect(engine.construct).toHaveBeenCalled())
|
||||
}
|
||||
|
||||
const props = { value: '# original\n\n spaces\n', onChange: vi.fn(), onError: vi.fn(), onReady: vi.fn(), onTouched: vi.fn(), onRegister: vi.fn(), onImage: vi.fn() }
|
||||
|
||||
describe('PresentationEditorRich adapter', () => {
|
||||
it('loads only same-origin emitted assets with cache and remote renderers disabled', async () => {
|
||||
render(<PresentationEditorRich {...props} />)
|
||||
for (const script of document.querySelectorAll<HTMLScriptElement>('script[src]')) {
|
||||
expect(new URL(script.src).origin).toBe(window.location.origin)
|
||||
expect(script.src).not.toContain('unpkg')
|
||||
expect(script.src).not.toMatch(/^data:/)
|
||||
}
|
||||
await loadAssets()
|
||||
expect(engine.options?.cache?.enable).toBe(false)
|
||||
expect(engine.options?.icon).toBeUndefined()
|
||||
expect(engine.options?.preview?.theme?.path).toBe('')
|
||||
expect(engine.options?.preview?.hljs?.enable).toBe(false)
|
||||
})
|
||||
|
||||
it('does not publish normalization changes after initialization or a content-only click', async () => {
|
||||
const onChange = vi.fn()
|
||||
render(<PresentationEditorRich {...props} onChange={onChange} />)
|
||||
await loadAssets()
|
||||
engine.options?.input?.('# normalized')
|
||||
fireEvent.click(screen.getByText('content'))
|
||||
engine.options?.input?.('# normalized')
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'format' }))
|
||||
engine.options?.input?.('**formatted**')
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith('**formatted**')
|
||||
})
|
||||
|
||||
it('flushes a touch-only format action before the delayed native input callback', async () => {
|
||||
const reader: { current: (() => string | undefined) | null } = { current: null }
|
||||
const onRegister = vi.fn((getter: (() => string | undefined) | null) => { reader.current = getter })
|
||||
const onChange = vi.fn()
|
||||
render(<PresentationEditorRich {...props} onChange={onChange} onRegister={onRegister} />)
|
||||
await loadAssets()
|
||||
await waitFor(() => expect(onRegister).toHaveBeenCalled())
|
||||
expect(reader.current?.()).toBeUndefined()
|
||||
const button = screen.getByRole('button', { name: 'format' })
|
||||
button.addEventListener('touchstart', (event) => { event.preventDefault(); event.stopPropagation(); engine.value = '**formatted on iPhone**' })
|
||||
fireEvent.touchStart(button)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(reader.current?.()).toBe('**formatted on iPhone**')
|
||||
})
|
||||
|
||||
it('does not mark disabled toolbar actions as edited on pointer down', async () => {
|
||||
const reader: { current: (() => string | undefined) | null } = { current: null }
|
||||
const onRegister = vi.fn((getter: (() => string | undefined) | null) => { reader.current = getter })
|
||||
render(<PresentationEditorRich {...props} onRegister={onRegister} />)
|
||||
await loadAssets()
|
||||
await waitFor(() => expect(onRegister).toHaveBeenCalled())
|
||||
const button = screen.getByRole('button', { name: 'format' }) as HTMLButtonElement
|
||||
button.disabled = true
|
||||
fireEvent.pointerDown(button)
|
||||
expect(reader.current?.()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('provides the custom insertion group callback required by Vditor', async () => {
|
||||
render(<PresentationEditorRich {...props} />)
|
||||
await loadAssets()
|
||||
const menu = engine.options?.toolbar?.find((item): item is IMenuItem => typeof item !== 'string' && item.name === 'skillhub-insert')
|
||||
expect(menu?.click).toBeTypeOf('function')
|
||||
// Vditor invokes custom callbacks before its own submenu toggle; this group has no action.
|
||||
expect(() => Reflect.apply(menu!.click!, undefined, [new Event('click'), {}])).not.toThrow()
|
||||
expect(menu?.toolbar?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('destroys its engine when unmounted', async () => {
|
||||
const { unmount } = render(<PresentationEditorRich {...props} />)
|
||||
await loadAssets()
|
||||
unmount()
|
||||
expect(engine.destroy).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
151
web/src/features/publish/presentation-editor-rich.tsx
Normal file
151
web/src/features/publish/presentation-editor-rich.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { createElement, useEffect, useRef } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Bold, Italic, List, ListOrdered, Quote, Link, Code2, Image, Undo2, Redo2, Heading, ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Vditor from 'vditor'
|
||||
import luteUrl from 'vditor/dist/js/lute/lute.min.js?url&no-inline'
|
||||
import zhUrl from 'vditor/dist/js/i18n/zh_CN.js?url&no-inline'
|
||||
import enUrl from 'vditor/dist/js/i18n/en_US.js?url&no-inline'
|
||||
import koUrl from 'vditor/dist/js/i18n/ko_KR.js?url&no-inline'
|
||||
import ruUrl from 'vditor/dist/js/i18n/ru_RU.js?url&no-inline'
|
||||
import 'vditor/dist/index.css'
|
||||
import './presentation-editor.css'
|
||||
|
||||
const scripts = new Map<string, Promise<void>>()
|
||||
const languages = new Map<string, ITips>()
|
||||
function loadScript(url: string, id: string): Promise<void> {
|
||||
const pending = scripts.get(url)
|
||||
if (pending) return pending
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = url
|
||||
script.async = true
|
||||
script.onload = () => { script.id = id; resolve() }
|
||||
script.onerror = () => { script.remove(); scripts.delete(url); reject(new Error('Editor asset failed')) }
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
scripts.set(url, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onError: () => void
|
||||
onReady: () => void
|
||||
onTouched: () => void
|
||||
onRegister: (read: (() => string | undefined) | null) => void
|
||||
onImage: (insert: (markdown: string) => void) => void
|
||||
}
|
||||
|
||||
// The native editor owns only its host. All mode switching and publishing state stay in React.
|
||||
export default function PresentationEditorRich({ value, onChange, onError, onReady, onTouched, onRegister, onImage }: Props) {
|
||||
const host = useRef<HTMLDivElement>(null)
|
||||
const callbacks = useRef({ onChange, onError, onReady, onTouched, onRegister, onImage })
|
||||
callbacks.current = { onChange, onError, onReady, onTouched, onRegister, onImage }
|
||||
const initialValue = useRef(value)
|
||||
initialValue.current = value
|
||||
const { i18n, t } = useTranslation()
|
||||
const language = i18n.resolvedLanguage?.split('-')[0] ?? 'en'
|
||||
const imageLabel = t('publish.editor.insertImage')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let instance: Vditor | undefined
|
||||
let changedByUser = false
|
||||
let ready = false
|
||||
const element = host.current
|
||||
if (!element) return
|
||||
const markChanged = () => { changedByUser = true; callbacks.current.onTouched() }
|
||||
const markToolbarChange = (event: Event) => {
|
||||
const button = event.target instanceof Element ? event.target.closest('.vditor-toolbar button') : null
|
||||
if (button instanceof HTMLButtonElement && !button.disabled && button.getAttribute('aria-disabled') !== 'true') markChanged()
|
||||
}
|
||||
element.addEventListener('beforeinput', markChanged)
|
||||
element.addEventListener('paste', markChanged)
|
||||
element.addEventListener('drop', markChanged)
|
||||
element.addEventListener('click', markToolbarChange)
|
||||
// iPhone Vditor actions use touchstart.preventDefault(), so a compatible click may never follow.
|
||||
element.addEventListener('touchstart', markToolbarChange, true)
|
||||
element.addEventListener('pointerdown', markToolbarChange, true)
|
||||
const icon = (Component: typeof Bold) => renderToStaticMarkup(createElement(Component, { width: 17, height: 17, 'aria-hidden': true }))
|
||||
const localeUrl = ({ zh: zhUrl, ko: koUrl, ru: ruUrl } as Record<string, string>)[language] ?? enUrl
|
||||
// Preload both assets before construction: Vditor's asynchronous initialization otherwise
|
||||
// races React StrictMode cleanup. URLs are Vite-emitted local assets, including subpath bases.
|
||||
Promise.all([loadScript(luteUrl, 'vditorLuteScript'), loadScript(localeUrl, `skillhub-editor-language-${language}`).then(() => {
|
||||
if (!languages.has(localeUrl)) languages.set(localeUrl, { ...window.VditorI18n })
|
||||
})])
|
||||
.then(() => {
|
||||
if (cancelled) return
|
||||
instance = new Vditor(element, {
|
||||
value: initialValue.current,
|
||||
mode: 'wysiwyg',
|
||||
i18n: languages.get(localeUrl),
|
||||
icon: undefined,
|
||||
cdn: new URL(import.meta.env.BASE_URL, window.location.origin).href.replace(/\/$/, ''),
|
||||
_lutePath: luteUrl,
|
||||
cache: { enable: false },
|
||||
minHeight: 480,
|
||||
height: 'auto',
|
||||
width: '100%',
|
||||
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'classic',
|
||||
counter: { enable: false },
|
||||
resize: { enable: false },
|
||||
hint: { emoji: {}, emojiPath: '', parse: false },
|
||||
preview: {
|
||||
mode: 'editor',
|
||||
hljs: { enable: false },
|
||||
theme: { current: '', path: '' },
|
||||
markdown: { sanitize: true, codeBlockPreview: false, mathBlockPreview: false, toc: false },
|
||||
},
|
||||
toolbar: [
|
||||
{ name: 'headings', icon: icon(Heading) }, '|',
|
||||
{ name: 'bold', icon: icon(Bold) }, { name: 'italic', icon: icon(Italic) }, '|',
|
||||
{ name: 'list', icon: icon(List) }, { name: 'quote', icon: icon(Quote) }, { name: 'link', icon: icon(Link) },
|
||||
{ name: 'skillhub-insert', icon: icon(ChevronDown), tip: t('publish.editor.insertMore'), click: () => { /* Vditor toggles the submenu after the required custom callback. */ }, toolbar: [
|
||||
{ name: 'ordered-list', icon: icon(ListOrdered) },
|
||||
{ name: 'code', icon: icon(Code2) },
|
||||
{ name: 'skillhub-image', icon: icon(Image), tip: imageLabel, click: () => {
|
||||
callbacks.current.onImage((markdown) => {
|
||||
if (cancelled || !instance) return
|
||||
markChanged()
|
||||
instance.focus()
|
||||
instance.insertMD(markdown)
|
||||
callbacks.current.onChange(instance.getValue())
|
||||
})
|
||||
} },
|
||||
] }, '|',
|
||||
{ name: 'undo', icon: icon(Undo2) }, { name: 'redo', icon: icon(Redo2) },
|
||||
],
|
||||
customWysiwygToolbar: (type, toolbar) => {
|
||||
// Image insertion goes through the HTTPS-only product dialog.
|
||||
if (type === 'image') toolbar.replaceChildren()
|
||||
},
|
||||
input: (markdown) => {
|
||||
if (!cancelled && ready && changedByUser) callbacks.current.onChange(markdown)
|
||||
},
|
||||
after: () => {
|
||||
if (cancelled) return
|
||||
ready = true
|
||||
callbacks.current.onRegister(() => !cancelled && ready && changedByUser ? instance?.getValue() : undefined)
|
||||
element.querySelector('[contenteditable="true"]')?.setAttribute('aria-label', t('publish.editor.visual'))
|
||||
callbacks.current.onReady()
|
||||
},
|
||||
})
|
||||
})
|
||||
.catch(() => { if (!cancelled) callbacks.current.onError() })
|
||||
return () => {
|
||||
cancelled = true
|
||||
callbacks.current.onRegister(null)
|
||||
element.removeEventListener('beforeinput', markChanged)
|
||||
element.removeEventListener('paste', markChanged)
|
||||
element.removeEventListener('drop', markChanged)
|
||||
element.removeEventListener('click', markToolbarChange)
|
||||
element.removeEventListener('touchstart', markToolbarChange, true)
|
||||
element.removeEventListener('pointerdown', markToolbarChange, true)
|
||||
if (instance) instance.destroy()
|
||||
}
|
||||
}, [language, imageLabel, t])
|
||||
|
||||
return <div ref={host} className="presentation-rich" data-testid="presentation-rich-editor" />
|
||||
}
|
||||
38
web/src/features/publish/presentation-editor.css
Normal file
38
web/src/features/publish/presentation-editor.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
.presentation-rich.vditor {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--background));
|
||||
--panel-background-color: hsl(var(--background));
|
||||
--toolbar-background-color: hsl(var(--background));
|
||||
--toolbar-icon-color: hsl(var(--foreground));
|
||||
--textarea-background-color: hsl(var(--background));
|
||||
--textarea-text-color: hsl(var(--foreground));
|
||||
--border-color: hsl(var(--border));
|
||||
--second-color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.presentation-rich .vditor-toolbar { padding: 10px 16px !important; border-bottom: 1px solid hsl(var(--border) / .6); background: transparent; }
|
||||
.presentation-rich .vditor-toolbar__item { padding: 0 3px; }
|
||||
.presentation-rich .vditor-toolbar__item button { border-radius: 6px; height: 32px; min-width: 32px; }
|
||||
.presentation-rich .vditor-toolbar__item button:hover { background: hsl(var(--muted)); }
|
||||
.presentation-rich .vditor-toolbar__item button:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; }
|
||||
.presentation-rich .vditor-reset { padding: 32px 40px !important; min-height: 480px; font-family: inherit; font-size: 16px; line-height: 1.8; color: hsl(var(--foreground)); }
|
||||
.presentation-rich .vditor-reset h1, .presentation-rich .vditor-reset h2, .presentation-rich .vditor-reset h3 { border: 0; margin: 1.5em 0 .6em; font-weight: 600; }
|
||||
.presentation-rich .vditor-reset h1 { font-size: 1.75em; }
|
||||
.presentation-rich .vditor-reset h2 { font-size: 1.3em; }
|
||||
.presentation-rich .vditor-reset h3 { font-size: 1.1em; }
|
||||
.presentation-rich .vditor-reset p { margin: .5em 0 1em; }
|
||||
.presentation-rich .vditor-reset ul { list-style: disc; }
|
||||
.presentation-rich .vditor-reset ol { list-style: decimal; }
|
||||
.presentation-rich .vditor-reset img { max-width: 100%; height: auto; }
|
||||
.presentation-rich .vditor-reset pre { overflow-x: auto; padding: 16px; background: hsl(var(--muted)); border-radius: 8px; }
|
||||
.presentation-rich .vditor-wysiwyg__pre code { color: hsl(var(--foreground)); background: hsl(var(--muted)); }
|
||||
.presentation-rich .vditor-panel, .presentation-rich .vditor-hint { color: hsl(var(--foreground)); background: hsl(var(--popover)); }
|
||||
.presentation-rich .vditor-counter, .presentation-rich .vditor-toolbar__divider { color: hsl(var(--muted-foreground)); }
|
||||
@media (max-width: 640px) {
|
||||
.presentation-rich .vditor-toolbar { padding: 8px !important; }
|
||||
.presentation-rich .vditor-reset { padding: 24px 20px !important; font-size: 15px; }
|
||||
}
|
||||
|
||||
/* Vditor ships filled-icon defaults; our toolbar uses Lucide stroke icons. */
|
||||
.presentation-rich .vditor-toolbar__item svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
152
web/src/features/publish/presentation-editor.test.tsx
Normal file
152
web/src/features/publish/presentation-editor.test.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// @vitest-environment jsdom
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { PresentationEditor, requiresSourceEditing } from './presentation-editor'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({ useBlocker: () => ({ status: 'idle' }) }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
vi.mock('./presentation-editor-rich', () => ({ default: function FakeRichEditor({ value, onChange, onError, onImage, onRegister, onTouched }: { value: string; onChange: (value: string) => void; onError: () => void; onImage: (insert: (value: string) => void) => void; onRegister: (read: (() => string | undefined) | null) => void; onTouched: () => void }) {
|
||||
const current = useRef(value)
|
||||
const edited = useRef(false)
|
||||
useEffect(() => {
|
||||
onRegister(() => edited.current ? current.current : undefined)
|
||||
return () => onRegister(null)
|
||||
}, [onRegister])
|
||||
return <div>
|
||||
<textarea aria-label="rich-editor" defaultValue={value} onChange={(event) => { edited.current = true; current.current = event.target.value; onChange(event.target.value) }} />
|
||||
<button onClick={onError}>engine-error</button>
|
||||
<button onClick={() => { edited.current = true; current.current = 'last pending input'; onTouched() }}>delayed-engine-input</button>
|
||||
<button onClick={() => onImage((markdown) => onChange(value + markdown))}>image-action</button>
|
||||
</div>
|
||||
} }))
|
||||
|
||||
afterEach(cleanup)
|
||||
const defaults = { open: true, value: '# 原文\n\n保持 空格\n', onApply: vi.fn(), onCancel: vi.fn() }
|
||||
|
||||
function source() { fireEvent.click(screen.getByRole('tab', { name: 'publish.editor.source' })); return screen.getByRole('textbox', { name: 'publish.editor.markdownLabel' }) }
|
||||
|
||||
describe('PresentationEditor', () => {
|
||||
it('does not mount an editor when closed', () => {
|
||||
render(<PresentationEditor {...defaults} open={false} />)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves exact source during a no-edit source/preview round trip', () => {
|
||||
const apply = vi.fn()
|
||||
render(<PresentationEditor {...defaults} onApply={apply} />)
|
||||
expect((source() as HTMLTextAreaElement).value).toBe(defaults.value)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'publish.editor.preview' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'publish.editor.source' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.editor.apply' }))
|
||||
expect(apply).toHaveBeenCalledExactlyOnceWith(defaults.value)
|
||||
})
|
||||
|
||||
it('only applies source changes when explicitly completed', () => {
|
||||
const apply = vi.fn()
|
||||
render(<PresentationEditor {...defaults} onApply={apply} />)
|
||||
fireEvent.change(source(), { target: { value: '新的概述\n\n```custom-lang\n<raw>\n```\n' } })
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.editor.apply' }))
|
||||
expect(apply).toHaveBeenCalledWith('新的概述\n\n```custom-lang\n<raw>\n```\n')
|
||||
})
|
||||
|
||||
it('requires confirmation before abandoning changes and can continue editing', () => {
|
||||
const cancel = vi.fn()
|
||||
render(<PresentationEditor {...defaults} onCancel={cancel} />)
|
||||
fireEvent.change(source(), { target: { value: '修改后' } })
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'publish.editor.cancel' })[0])
|
||||
expect(cancel).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.editor.keepEditing' }))
|
||||
expect((screen.getByRole('textbox', { name: 'publish.editor.markdownLabel' }) as HTMLTextAreaElement).value).toBe('修改后')
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'publish.editor.cancel' })[0])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.editor.discard' }))
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('blocks over-limit completion without truncating the draft', () => {
|
||||
const apply = vi.fn()
|
||||
render(<PresentationEditor {...defaults} maxLength={5} onApply={apply} />)
|
||||
fireEvent.change(source(), { target: { value: '123456' } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('publish.editor.tooLong')
|
||||
expect((screen.getByRole('button', { name: 'publish.editor.apply' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((screen.getByRole('textbox', { name: 'publish.editor.markdownLabel' }) as HTMLTextAreaElement).value).toBe('123456')
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains unsupported HTML verbatim and starts in source mode', () => {
|
||||
const value = '<details><summary>内容</summary>\n\n未修改\n</details>'
|
||||
render(<PresentationEditor {...defaults} value={value} />)
|
||||
expect((screen.getByRole('tab', { name: 'publish.editor.visual' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((screen.getByRole('textbox', { name: 'publish.editor.markdownLabel' }) as HTMLTextAreaElement).value).toBe(value)
|
||||
})
|
||||
|
||||
it('recovers to the source if the visual engine fails', async () => {
|
||||
render(<PresentationEditor {...defaults} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'engine-error' }))
|
||||
expect((screen.getByRole('textbox', { name: 'publish.editor.markdownLabel' }) as HTMLTextAreaElement).value).toBe(defaults.value)
|
||||
expect(screen.getByText('publish.editor.loadFailed')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects non-HTTPS image insertion without altering source', async () => {
|
||||
const apply = vi.fn()
|
||||
render(<PresentationEditor {...defaults} onApply={apply} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'image-action' }))
|
||||
fireEvent.change(screen.getByLabelText('publish.editor.imageUrl'), { target: { value: 'javascript:alert(1)' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.editor.insert' }))
|
||||
expect(screen.getByRole('alert').textContent).toBe('publish.editor.invalidImageUrl')
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flushes the latest native value before completion even before the debounced input callback', async () => {
|
||||
const apply = vi.fn()
|
||||
render(<PresentationEditor {...defaults} onApply={apply} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'delayed-engine-input' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.editor.apply' }))
|
||||
expect(apply).toHaveBeenCalledExactlyOnceWith('last pending input')
|
||||
})
|
||||
|
||||
it('flushes pending native input before changing modes', async () => {
|
||||
render(<PresentationEditor {...defaults} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'delayed-engine-input' }))
|
||||
expect((source() as HTMLTextAreaElement).value).toBe('last pending input')
|
||||
})
|
||||
|
||||
it('confirms cancellation of native edits whose callback is still pending', async () => {
|
||||
const cancel = vi.fn()
|
||||
render(<PresentationEditor {...defaults} onCancel={cancel} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'delayed-engine-input' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'publish.editor.cancel' })[0])
|
||||
expect(screen.getByRole('button', { name: 'publish.editor.discard' })).toBeTruthy()
|
||||
expect(cancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revalidates the actual native input length before applying', async () => {
|
||||
const apply = vi.fn()
|
||||
render(<PresentationEditor {...defaults} value="" maxLength={5} onApply={apply} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'delayed-engine-input' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.editor.apply' }))
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('alert').textContent).toBe('publish.editor.tooLong')
|
||||
})
|
||||
|
||||
it('restores focus to its trigger after confirming discard through a nested dialog', async () => {
|
||||
function Harness() {
|
||||
const [open, setOpen] = useState(false)
|
||||
return <><button onClick={() => setOpen(true)}>edit-overview-trigger</button><PresentationEditor {...defaults} open={open} onCancel={() => setOpen(false)} /></>
|
||||
}
|
||||
render(<Harness />)
|
||||
const trigger = screen.getByRole('button', { name: 'edit-overview-trigger' })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.change(source(), { target: { value: 'changed' } })
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'publish.editor.cancel' })[0])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.editor.discard' }))
|
||||
await waitFor(() => expect(document.activeElement).toBe(trigger))
|
||||
})
|
||||
|
||||
it('distinguishes supported fences and plain Unicode text from raw HTML', () => {
|
||||
expect(requiresSourceEditing('中文说明\n\n```unknown-language\nhello\n```')).toBe(false)
|
||||
expect(requiresSourceEditing('<!-- preserve -->')).toBe(true)
|
||||
})
|
||||
})
|
||||
186
web/src/features/publish/presentation-editor.tsx
Normal file
186
web/src/features/publish/presentation-editor.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { lazy, Suspense, useEffect, useRef, useState } from 'react'
|
||||
import { useBlocker } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
function LoadFailure({ onError }: { onError: () => void }) {
|
||||
useEffect(() => { onError() }, [onError])
|
||||
return <></>
|
||||
}
|
||||
const RichEditor = lazy(() => import('./presentation-editor-rich').catch(() => ({ default: LoadFailure })))
|
||||
|
||||
export interface PresentationEditorProps {
|
||||
open: boolean
|
||||
value: string
|
||||
onApply: (value: string) => void
|
||||
onCancel: () => void
|
||||
maxLength?: number
|
||||
}
|
||||
|
||||
// Raw HTML and MDX are not converted through a rich-text document: preserving the source
|
||||
// takes precedence over visual editing of syntax outside the supported Markdown subset.
|
||||
export function requiresSourceEditing(markdown: string): boolean {
|
||||
return /<\/?[a-zA-Z][^>]*>|<!--|^import\s.+from\s|^export\s/m.test(markdown)
|
||||
}
|
||||
|
||||
function EditorSession({ value, onApply, onCancel, maxLength = 20000 }: Omit<PresentationEditorProps, 'open'>) {
|
||||
const { t } = useTranslation()
|
||||
const [draft, setDraft] = useState(value)
|
||||
const [mode, setMode] = useState(requiresSourceEditing(value) ? 'source' : 'visual')
|
||||
const [split, setSplit] = useState(false)
|
||||
const [discardOpen, setDiscardOpen] = useState(false)
|
||||
const [visualFailed, setVisualFailed] = useState(false)
|
||||
const [richReady, setRichReady] = useState(false)
|
||||
const [richTouched, setRichTouched] = useState(false)
|
||||
const readRichDraft = useRef<(() => string | undefined) | null>(null)
|
||||
const [imageOpen, setImageOpen] = useState(false)
|
||||
const [imageUrl, setImageUrl] = useState('')
|
||||
const [imageAlt, setImageAlt] = useState('')
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const insertImageRef = useRef<((markdown: string) => void) | null>(null)
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const returnFocusRef = useRef(document.activeElement instanceof HTMLElement ? document.activeElement : null)
|
||||
const closingRef = useRef(false)
|
||||
const restoreTriggerFocus = (event: Event) => {
|
||||
event.preventDefault()
|
||||
const restore = () => {
|
||||
// The nested confirmation's FocusScope must finish teardown before focus is restored.
|
||||
if (returnFocusRef.current?.isConnected && !document.querySelector('[data-testid="presentation-editor"]')) {
|
||||
returnFocusRef.current.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
const raf = globalThis.requestAnimationFrame
|
||||
if (typeof raf === 'function') raf(() => raf(restore))
|
||||
else setTimeout(restore, 0)
|
||||
}
|
||||
const closeEditor = (close: () => void) => { closingRef.current = true; close() }
|
||||
const dirty = draft !== value || richTouched
|
||||
const tooLong = draft.length > maxLength
|
||||
const visualUnavailable = requiresSourceEditing(draft)
|
||||
const blocker = useBlocker({ shouldBlockFn: () => dirty, enableBeforeUnload: dirty, withResolver: true })
|
||||
const flushDraft = () => {
|
||||
const current = readRichDraft.current?.() ?? draft
|
||||
setDraft(current)
|
||||
setRichTouched(false)
|
||||
return current
|
||||
}
|
||||
const requestCancel = () => { if (flushDraft() !== value) setDiscardOpen(true); else closeEditor(onCancel) }
|
||||
const applyDraft = () => {
|
||||
const current = flushDraft()
|
||||
if (current.length <= maxLength) closeEditor(() => onApply(current))
|
||||
}
|
||||
const handleFailure = () => { setVisualFailed(true); setMode('source') }
|
||||
const openImage = (insert: (markdown: string) => void) => {
|
||||
insertImageRef.current = insert
|
||||
setImageUrl('')
|
||||
setImageAlt('')
|
||||
setImageError(false)
|
||||
setImageOpen(true)
|
||||
}
|
||||
const insertImage = () => {
|
||||
try {
|
||||
const url = new URL(imageUrl)
|
||||
if (url.protocol !== 'https:') throw new Error('Invalid scheme')
|
||||
const escapedAlt = imageAlt.replace(/[\\[\]]/g, '\\$&').replace(/[\r\n]/g, ' ')
|
||||
// Percent-encode Markdown delimiters rather than interpolating executable markup.
|
||||
const safeUrl = url.href.replace(/\(/g, '%28').replace(/\)/g, '%29')
|
||||
insertImageRef.current?.(``)
|
||||
setImageOpen(false)
|
||||
} catch { setImageError(true) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open onOpenChange={(next) => { if (!next) requestCancel() }}>
|
||||
<DialogContent
|
||||
ref={overlayRef}
|
||||
hideClose
|
||||
className="!fixed !inset-0 !left-0 !top-0 !flex !h-dvh !max-h-none !w-full !max-w-none !translate-x-0 !translate-y-0 !flex-col !gap-0 !rounded-none !border-0 !bg-background !p-0 !shadow-none"
|
||||
onCloseAutoFocus={restoreTriggerFocus}
|
||||
onEscapeKeyDown={(event) => { event.preventDefault(); requestCancel() }}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
data-testid="presentation-editor"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="mx-auto w-full max-w-[1200px] px-4 py-6 sm:px-8 sm:py-10 xl:px-0">
|
||||
<Button type="button" variant="ghost" size="sm" className="mb-5 -ml-3" onClick={requestCancel}>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />{t('publish.editor.cancel')}
|
||||
</Button>
|
||||
<div className="mb-7 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-semibold sm:text-[30px] sm:leading-9">{t('publish.editor.title')}</DialogTitle>
|
||||
<DialogDescription className="mt-2 text-sm text-muted-foreground">{t('publish.editor.description')}</DialogDescription>
|
||||
</div>
|
||||
<span className="pt-2 text-xs text-muted-foreground" role="status">{t(dirty ? 'publish.editor.unsaved' : 'publish.editor.unchanged')}</span>
|
||||
</div>
|
||||
<Tabs value={mode} onValueChange={(next) => { flushDraft(); setMode(next); setRichReady(false) }} className="overflow-hidden rounded-xl border border-border/70 bg-background">
|
||||
<div className="px-3 pt-3 sm:px-4">
|
||||
<TabsList className="!inline-flex !gap-1 !rounded-full !border-0 !bg-muted/65 !p-1" aria-label={t('publish.editor.title')}>
|
||||
{(['visual', 'source', 'preview'] as const).map((tab) => (
|
||||
<TabsTrigger key={tab} value={tab} disabled={tab === 'visual' && (visualUnavailable || visualFailed)} className="!rounded-full !border-0 !px-3 !py-2 text-xs sm:!px-4 sm:text-sm data-[state=active]:!bg-background data-[state=active]:!shadow-sm after:!hidden">
|
||||
{t(`publish.editor.${tab}`)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
{(visualUnavailable || visualFailed) && <p className="mx-4 my-3 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground" role="status">{t(visualFailed ? 'publish.editor.loadFailed' : 'publish.editor.visualUnavailable')}</p>}
|
||||
<TabsContent value="visual" className="!mt-3 outline-none">
|
||||
{!richReady && <div className="flex items-center gap-2 px-6 py-4 text-sm text-muted-foreground" role="status"><Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />{t('publish.editor.loading')}</div>}
|
||||
<Suspense fallback={null}>
|
||||
<RichEditor value={draft} onChange={setDraft} onReady={() => setRichReady(true)} onTouched={() => setRichTouched(true)} onRegister={(read) => { readRichDraft.current = read }} onError={handleFailure} onImage={openImage} />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
<TabsContent value="source" className="!mt-3 outline-none">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/60 px-4 py-3 text-xs text-muted-foreground">
|
||||
<span>{t('publish.editor.markdownLabel')}</span>
|
||||
<div className="flex shrink-0 gap-1 rounded-md bg-muted/60 p-0.5" role="group" aria-label={t('publish.editor.source')}>
|
||||
{[false, true].map((isSplit) => <Button key={String(isSplit)} type="button" size="sm" variant="ghost" aria-pressed={split === isSplit} onClick={() => setSplit(isSplit)} className={cn('h-7 px-3 text-xs', split === isSplit && 'bg-background shadow-sm')}>{t(isSplit ? 'publish.editor.split' : 'publish.editor.sourceOnly')}</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('grid', split && 'md:grid-cols-2')}>
|
||||
<Textarea aria-label={t('publish.editor.markdownLabel')} value={draft} onChange={(event) => setDraft(event.target.value)} spellCheck={false} aria-invalid={tooLong} aria-describedby={tooLong ? 'presentation-limit-error' : undefined} className="min-h-[480px] resize-y rounded-none border-0 bg-transparent p-6 font-mono text-sm leading-7 shadow-none focus-visible:ring-inset sm:p-8" />
|
||||
{split && <div className="min-w-0 border-t border-border/60 p-6 md:border-l md:border-t-0 sm:p-8"><MarkdownRenderer content={draft} /></div>}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="preview" className="!mt-3 min-h-[480px] border-t border-border/60 p-6 outline-none sm:px-12 sm:py-8"><MarkdownRenderer content={draft} /></TabsContent>
|
||||
</Tabs>
|
||||
<div className="mt-4 flex flex-wrap items-start justify-between gap-2 text-xs text-muted-foreground">
|
||||
<p>{t('publish.editor.returnHint')}</p>
|
||||
<span className={cn('tabular-nums', tooLong && 'text-destructive')}>{draft.length} / {maxLength}</span>
|
||||
</div>
|
||||
{tooLong && <p id="presentation-limit-error" role="alert" className="mt-2 text-sm text-destructive">{t('publish.editor.tooLong', { max: maxLength })}</p>}
|
||||
<div className="mt-7 flex justify-end gap-3 pb-3">
|
||||
<Button type="button" variant="outline" onClick={requestCancel}>{t('publish.editor.cancel')}</Button>
|
||||
<Button type="button" disabled={tooLong} onClick={applyDraft}>{t('publish.editor.apply')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ConfirmDialog onCloseAutoFocus={(event) => { if (closingRef.current) event.preventDefault() }} open={discardOpen || blocker.status === 'blocked'} onOpenChange={(next) => { if (!next) { setDiscardOpen(false); if (blocker.status === 'blocked') blocker.reset() } }} title={t('publish.editor.discardTitle')} description={t('publish.editor.discardDescription')} confirmText={t('publish.editor.discard')} cancelText={t('publish.editor.keepEditing')} onConfirm={() => { const proceed = blocker.status === 'blocked' ? blocker.proceed : undefined; setDiscardOpen(false); closeEditor(onCancel); proceed?.() }} />
|
||||
<Dialog open={imageOpen} onOpenChange={setImageOpen}>
|
||||
<DialogContent>
|
||||
<DialogTitle>{t('publish.editor.insertImage')}</DialogTitle>
|
||||
<DialogDescription>{t('publish.editor.imageUrl')}</DialogDescription>
|
||||
<div className="space-y-2"><Label htmlFor="presentation-image-url">{t('publish.editor.imageUrl')}</Label><Input id="presentation-image-url" type="url" value={imageUrl} onChange={(event) => { setImageUrl(event.target.value); setImageError(false) }} aria-invalid={imageError} /></div>
|
||||
<div className="space-y-2"><Label htmlFor="presentation-image-alt">{t('publish.editor.imageAlt')}</Label><Input id="presentation-image-alt" value={imageAlt} onChange={(event) => setImageAlt(event.target.value)} /></div>
|
||||
{imageError && <p className="text-sm text-destructive" role="alert">{t('publish.editor.invalidImageUrl')}</p>}
|
||||
<div className="flex justify-end gap-2"><Button type="button" variant="outline" onClick={() => setImageOpen(false)}>{t('dialog.cancel')}</Button><Button type="button" onClick={insertImage}>{t('publish.editor.insert')}</Button></div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PresentationEditor({ open, ...props }: PresentationEditorProps) {
|
||||
return open ? <EditorSession {...props} /> : null
|
||||
}
|
||||
16
web/src/features/publish/presentation-source.test.ts
Normal file
16
web/src/features/publish/presentation-source.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { presentationPayload, publishedOverrides } from './presentation-source'
|
||||
describe('independent presentation choices', () => {
|
||||
it.each(['package','custom'] as const)('encodes summary %s independently', short => {
|
||||
for (const overview of ['package','custom'] as const) {
|
||||
const result = presentationPayload(short, overview, 'S', 'O')
|
||||
expect(result.shortDescription).toBe(short === 'custom' ? 'S' : undefined)
|
||||
expect(result.overview).toBe(overview === 'custom' ? 'O' : undefined)
|
||||
expect(result.resetPresentation).toBe(short === 'package' && overview === 'package')
|
||||
}
|
||||
})
|
||||
it('inherits explicit overrides, never freezes defaults', () => {
|
||||
expect(publishedOverrides(JSON.stringify({shortDescriptionOverride:'Custom',frontmatter:{metadata:{overview:'Default'}}}))).toEqual({short:'Custom',overview:undefined})
|
||||
expect(publishedOverrides('bad')).toEqual({})
|
||||
})
|
||||
})
|
||||
12
web/src/features/publish/presentation-source.ts
Normal file
12
web/src/features/publish/presentation-source.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export type PresentationSource = 'package' | 'custom'
|
||||
export function presentationPayload(shortSource: PresentationSource, overviewSource: PresentationSource, short: string, overview: string) {
|
||||
return { shortDescription: shortSource === 'custom' ? short : undefined, overview: overviewSource === 'custom' ? overview : undefined, resetPresentation: shortSource === 'package' && overviewSource === 'package' }
|
||||
}
|
||||
export function publishedOverrides(snapshot?: string): { short?: string; overview?: string } {
|
||||
try {
|
||||
const value: unknown = JSON.parse(snapshot || '{}')
|
||||
if (!value || typeof value !== 'object') return {}
|
||||
const data = value as Record<string, unknown>
|
||||
return { short: typeof data.shortDescriptionOverride === 'string' ? data.shortDescriptionOverride : undefined, overview: typeof data.overviewOverride === 'string' ? data.overviewOverride : undefined }
|
||||
} catch { return {} }
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ const VERSION_EXISTS_MARKERS = [
|
|||
'error.skill.version.exists',
|
||||
'Version already exists',
|
||||
'版本已存在',
|
||||
'Версия уже существует',
|
||||
]
|
||||
|
||||
const FRONTMATTER_FAILURE_MARKERS = [
|
||||
|
|
|
|||
67
web/src/features/publish/publish-result-panel.test.tsx
Normal file
67
web/src/features/publish/publish-result-panel.test.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PublishResult } from '@/api/types'
|
||||
import { PublishResultPanel } from './publish-result-panel'
|
||||
import type { PublishSubmissionState } from './publish-result'
|
||||
|
||||
const translation = vi.hoisted(() => ({ t: vi.fn((key: string, parameters?: Record<string, unknown>) => key === 'publish.result.submitting.description' ? `${parameters?.skill} ${parameters?.version}` : key) }))
|
||||
vi.mock('react-i18next', async (importOriginal) => ({ ...await importOriginal<typeof import('react-i18next')>(), useTranslation: () => ({ t: translation.t }) }))
|
||||
afterEach(cleanup)
|
||||
const result: PublishResult = { skillId: 12, namespace: 'private-team', slug: 'fixture', version: '1.0.0', status: 'SCANNING', fileCount: 2, totalSize: 100 }
|
||||
function setup(state: PublishSubmissionState) {
|
||||
const callbacks = { onReturnToForm: vi.fn(), onReplacePackage: vi.fn(), onViewMySkills: vi.fn(), onViewVersion: vi.fn() }
|
||||
const view = render(<PublishResultPanel state={state} {...callbacks} />)
|
||||
return { ...callbacks, ...view }
|
||||
}
|
||||
|
||||
describe('one submission result at a time', () => {
|
||||
it('presents a busy state with no active resubmit control', () => {
|
||||
setup({ kind: 'submitting', label: 'fixture 1.0.0' })
|
||||
expect(screen.getByRole('status').getAttribute('aria-busy')).toBe('true')
|
||||
expect(screen.getByRole('button').hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
it('interpolates both the skill and selected package version while submitting', () => {
|
||||
setup({ kind: 'submitting', label: 'Meeting notes', version: '2.3.4' })
|
||||
expect(screen.getByText('Meeting notes 2.3.4')).toBeDefined()
|
||||
expect(translation.t).toHaveBeenCalledWith('publish.result.submitting.description', { skill: 'Meeting notes', version: '2.3.4' })
|
||||
})
|
||||
it('supplies an empty version fallback instead of leaving an interpolation token', () => {
|
||||
setup({ kind: 'submitting', label: 'Meeting notes' })
|
||||
expect(translation.t).toHaveBeenCalledWith('publish.result.submitting.description', { skill: 'Meeting notes', version: '' })
|
||||
})
|
||||
it.each(['SCANNING', 'PENDING_REVIEW', 'UPLOADED', 'PUBLISHED', 'SCAN_FAILED', 'REJECTED', 'YANKED', 'DRAFT', 'unrecognized'])('presents %s truthfully with a read/navigation callback', status => {
|
||||
const phase = status === 'unrecognized' ? 'FUTURE' : status
|
||||
const receipt = { ...result, status: phase }
|
||||
const test = setup({ kind: 'accepted', result: receipt, hadPublishedVersion: true })
|
||||
expect(screen.getByRole('heading').textContent).toBe(`publish.result.accepted.${status}.title`)
|
||||
expect(screen.queryByText('publish.result.previousPublished') !== null).toBe(status !== 'PUBLISHED')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.result.viewVersion' }))
|
||||
expect(test.onViewVersion).toHaveBeenCalledWith(receipt)
|
||||
expect(test.onReturnToForm).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.result.mySkills' }))
|
||||
expect(test.onViewMySkills).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('returns a version conflict to package replacement without changing the result', () => {
|
||||
const state: PublishSubmissionState = { kind: 'version-conflict', version: '1.0.0' }
|
||||
const test = setup(state)
|
||||
expect(screen.getByRole('alert')).toBeDefined()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.result.replacePackage' }))
|
||||
expect(test.onReplacePackage).toHaveBeenCalledOnce()
|
||||
expect(state).toEqual({ kind: 'version-conflict', version: '1.0.0' })
|
||||
})
|
||||
it('offers verification for an unknown outcome rather than an automatic retry', () => {
|
||||
const test = setup({ kind: 'unknown' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.result.checkMySkills' }))
|
||||
expect(test.onViewMySkills).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('button', { name: /retry/i })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.result.returnToForm' }))
|
||||
expect(test.onReturnToForm).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('replaces a prior pending state instead of rendering multiple result cards', () => {
|
||||
const test = setup({ kind: 'submitting', label: 'fixture' })
|
||||
test.rerender(<PublishResultPanel state={{ kind: 'accepted', result }} {...test} />)
|
||||
expect(screen.getAllByRole('heading')).toHaveLength(1)
|
||||
expect(screen.queryByText('publish.result.submitting.title')).toBeNull()
|
||||
})
|
||||
})
|
||||
54
web/src/features/publish/publish-result-panel.tsx
Normal file
54
web/src/features/publish/publish-result-panel.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CheckCircle2, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import type { PublishResult } from '@/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import { publishResultPhase, type PublishSubmissionState } from './publish-result'
|
||||
|
||||
export interface PublishResultPanelProps {
|
||||
state: PublishSubmissionState
|
||||
onReturnToForm: () => void
|
||||
onReplacePackage: () => void
|
||||
onViewMySkills: () => void
|
||||
onViewVersion: (result: PublishResult) => void
|
||||
}
|
||||
|
||||
/** Present exactly one submission outcome. Callbacks navigate/read; this panel never writes. */
|
||||
export function PublishResultPanel({ state, onReturnToForm, onReplacePackage, onViewMySkills, onViewVersion }: PublishResultPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const heading = useRef<HTMLHeadingElement>(null)
|
||||
useEffect(() => { heading.current?.focus() }, [state.kind])
|
||||
const submitting = state.kind === 'submitting'
|
||||
const phase = state.kind === 'accepted' ? publishResultPhase(state.result.status) : undefined
|
||||
const failedPhase = phase === 'SCAN_FAILED' || phase === 'REJECTED' || phase === 'YANKED'
|
||||
const error = state.kind === 'error' || state.kind === 'version-conflict' || failedPhase
|
||||
const key = phase ? `accepted.${phase}` : state.kind === 'error' ? `error.${state.reason}` : state.kind
|
||||
const parameters = state.kind === 'accepted'
|
||||
? { skill: `@${state.result.namespace}/${state.result.slug}`, version: state.result.version }
|
||||
: state.kind === 'submitting' ? { skill: state.label, version: state.version ?? '' } : state.kind === 'version-conflict' ? { version: state.version ?? '' } : {}
|
||||
|
||||
return (
|
||||
<section aria-busy={submitting} aria-labelledby="publish-result-heading" role={error ? 'alert' : 'status'} className={cn('rounded-xl border p-5 sm:p-7', error ? 'border-destructive/25 bg-destructive/5' : state.kind === 'accepted' && phase === 'PUBLISHED' ? 'border-emerald-600/20 bg-emerald-600/5' : 'border-border bg-muted/30')}>
|
||||
<div className="flex items-start gap-3">
|
||||
{submitting ? <Loader2 aria-hidden="true" className="mt-0.5 h-5 w-5 shrink-0 animate-spin motion-reduce:animate-none" /> : error || state.kind === 'unknown' ? <AlertCircle aria-hidden="true" className="mt-0.5 h-5 w-5 shrink-0" /> : <CheckCircle2 aria-hidden="true" className="mt-0.5 h-5 w-5 shrink-0" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 ref={heading} tabIndex={-1} id="publish-result-heading" className="break-words text-lg font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring">{t(`publish.result.${key}.title`, parameters)}</h2>
|
||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-muted-foreground">{t(`publish.result.${key}.description`, parameters)}</p>
|
||||
{state.kind === 'accepted' && <p className="mt-2 text-xs text-muted-foreground">{t('publish.result.receiptHint')}</p>}
|
||||
{state.kind === 'accepted' && <p className="mt-2 break-all text-xs text-muted-foreground">@{state.result.namespace}/{state.result.slug} · {state.result.version}</p>}
|
||||
{state.kind === 'accepted' && state.hadPublishedVersion && phase !== 'PUBLISHED' && <p className="mt-2 text-sm text-muted-foreground">{t('publish.result.previousPublished')}</p>}
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{submitting ? <Button type="button" disabled>{t('publish.result.submitting.button')}</Button> : state.kind === 'accepted' ? <>
|
||||
<Button type="button" onClick={() => onViewVersion(state.result)}>{t('publish.result.viewVersion')}</Button>
|
||||
<Button type="button" variant="outline" onClick={onViewMySkills}>{t('publish.result.mySkills')}</Button>
|
||||
</> : state.kind === 'version-conflict' ? <Button type="button" onClick={onReplacePackage}>{t('publish.result.replacePackage')}</Button> : <>
|
||||
{state.kind === 'unknown' && <Button type="button" onClick={onViewMySkills}>{t('publish.result.checkMySkills')}</Button>}
|
||||
<Button type="button" variant={state.kind === 'unknown' ? 'outline' : 'default'} onClick={onReturnToForm}>{t('publish.result.returnToForm')}</Button>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
30
web/src/features/publish/publish-result.test.ts
Normal file
30
web/src/features/publish/publish-result.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
import { classifyPublishFailure, publishResultPhase } from './publish-result'
|
||||
|
||||
describe('publish submission outcomes', () => {
|
||||
it.each(['SCANNING', 'PENDING_REVIEW', 'UPLOADED', 'PUBLISHED', 'SCAN_FAILED', 'REJECTED', 'YANKED', 'DRAFT'])('retains the real version state %s', status => {
|
||||
expect(publishResultPhase(status)).toBe(status)
|
||||
})
|
||||
it('does not call an unrecognized response published', () => expect(publishResultPhase('FUTURE')).toBe('unrecognized'))
|
||||
it.each([0, 408, 500, 503, 200])('treats ambiguous transport/response %s as unknown', status => {
|
||||
expect(classifyPublishFailure(new ApiError('transport', status))).toEqual({ kind: 'unknown' })
|
||||
})
|
||||
it('keeps unexpected thrown errors unknown', () => expect(classifyPublishFailure(new TypeError('network'))).toEqual({ kind: 'unknown' }))
|
||||
it('identifies version conflicts by the existing message contract', () => {
|
||||
expect(classifyPublishFailure(new ApiError('Version already exists', 400), '1.0.0')).toEqual({ kind: 'version-conflict', version: '1.0.0' })
|
||||
expect(classifyPublishFailure(new ApiError('Other conflict', 409))).toEqual({ kind: 'error', reason: 'failed' })
|
||||
})
|
||||
it('recognizes the localized version conflict without classifying arbitrary conflicts', () => {
|
||||
expect(classifyPublishFailure(new ApiError('Версия уже существует: 1.0.0', 400), '1.0.0')).toEqual({ kind: 'version-conflict', version: '1.0.0' })
|
||||
})
|
||||
it('leaves warnings in the original confirmation flow', () => {
|
||||
expect(classifyPublishFailure(new ApiError('预发布发现以下风险提醒,确认后仍可继续发布', 400))).toBeNull()
|
||||
})
|
||||
it.each([401, 403])('identifies access rejection %s without retry', status => {
|
||||
expect(classifyPublishFailure(new ApiError('denied', status))).toEqual({ kind: 'error', reason: 'forbidden' })
|
||||
})
|
||||
it.each([400, 413, 422])('identifies an invalid request %s without discarding its draft', status => {
|
||||
expect(classifyPublishFailure(new ApiError('invalid', status))).toEqual({ kind: 'error', reason: 'invalid' })
|
||||
})
|
||||
})
|
||||
29
web/src/features/publish/publish-result.ts
Normal file
29
web/src/features/publish/publish-result.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { PublishResult } from '@/api/types'
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
import { isPrecheckConfirmationMessage, isVersionExistsMessage } from './publish-error-utils'
|
||||
|
||||
export type PublishSubmissionState =
|
||||
| { kind: 'submitting'; label: string; version?: string }
|
||||
| { kind: 'accepted'; result: PublishResult; hadPublishedVersion?: boolean }
|
||||
| { kind: 'version-conflict'; version?: string }
|
||||
| { kind: 'unknown' }
|
||||
| { kind: 'error'; reason: 'forbidden' | 'invalid' | 'failed' }
|
||||
|
||||
/** Warning confirmation stays with the existing precheck flow. Never retry here. */
|
||||
export function classifyPublishFailure(error: unknown, version?: string): PublishSubmissionState | null {
|
||||
if (!(error instanceof ApiError)) return { kind: 'unknown' }
|
||||
const message = error.serverMessage || error.message
|
||||
if (isVersionExistsMessage(message) || isVersionExistsMessage(error.serverMessageKey)) return { kind: 'version-conflict', version }
|
||||
if (isPrecheckConfirmationMessage(message)) return null
|
||||
if (error.status === 0 || error.status === 408 || error.status >= 500 || (error.status >= 200 && error.status < 300)) {
|
||||
return { kind: 'unknown' }
|
||||
}
|
||||
if (error.status === 401 || error.status === 403) return { kind: 'error', reason: 'forbidden' }
|
||||
if (error.status === 400 || error.status === 413 || error.status === 422) return { kind: 'error', reason: 'invalid' }
|
||||
return { kind: 'error', reason: 'failed' }
|
||||
}
|
||||
|
||||
const versionStates = ['SCANNING', 'PENDING_REVIEW', 'UPLOADED', 'PUBLISHED', 'SCAN_FAILED', 'REJECTED', 'YANKED', 'DRAFT'] as const
|
||||
export function publishResultPhase(status: string): typeof versionStates[number] | 'unrecognized' {
|
||||
return versionStates.find(value => value === status) ?? 'unrecognized'
|
||||
}
|
||||
|
|
@ -1,55 +1,208 @@
|
|||
/** @vitest-environment jsdom */
|
||||
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as mod from './upload-zone'
|
||||
import { UploadZone } from './upload-zone'
|
||||
import zh from '@/i18n/locales/zh.json'
|
||||
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
vi.mock('react-dropzone', () => ({
|
||||
useDropzone: () => ({
|
||||
getRootProps: () => ({}),
|
||||
getInputProps: () => ({}),
|
||||
isDragActive: false,
|
||||
}),
|
||||
}))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key === 'upload.formatHint' ? zh.upload.formatHint : key }) }))
|
||||
|
||||
/**
|
||||
* upload-zone.tsx exports the UploadZone component. It is a stateless
|
||||
* dropzone wrapper with no exported constants, validation logic, or
|
||||
* helper functions.
|
||||
*
|
||||
* We verify the export contract so downstream consumers break fast if
|
||||
* the module shape changes.
|
||||
*/
|
||||
describe('upload-zone module exports', () => {
|
||||
afterEach(() => cleanup())
|
||||
const zip = (name = 'meeting-notes.zip') => new File(['package'], name, { type: 'application/zip' })
|
||||
function transfer(files: File[]) {
|
||||
return {
|
||||
files,
|
||||
items: files.map(file => ({ kind: 'file', type: file.type, getAsFile: () => file })),
|
||||
types: ['Files'],
|
||||
}
|
||||
}
|
||||
|
||||
it('exports the UploadZone component', () => {
|
||||
expect(mod.UploadZone).toBeDefined()
|
||||
expect(typeof mod.UploadZone).toBe('function')
|
||||
async function drop(element: HTMLElement, files: File[]) {
|
||||
await act(async () => { fireEvent.drop(element, { dataTransfer: transfer(files) }) })
|
||||
}
|
||||
|
||||
function directorySupport() {
|
||||
const previous = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'webkitdirectory')
|
||||
Object.defineProperty(HTMLInputElement.prototype, 'webkitdirectory', { configurable: true, writable: true, value: false })
|
||||
return () => {
|
||||
if (previous) Object.defineProperty(HTMLInputElement.prototype, 'webkitdirectory', previous)
|
||||
else delete (HTMLInputElement.prototype as { webkitdirectory?: boolean }).webkitdirectory
|
||||
}
|
||||
}
|
||||
|
||||
describe('UploadZone', () => {
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
it('keeps the shared ZIP hint generic for Suite Bundle and allows caller-specific requirements', () => {
|
||||
const view = render(createElement(UploadZone, { onFileSelect: vi.fn() }))
|
||||
expect(screen.getByText(zh.upload.formatHint)).toBeTruthy()
|
||||
expect(zh.upload.formatHint).not.toContain('SKILL.md')
|
||||
expect(zh.upload.formatHint).not.toContain('SUITE.yaml')
|
||||
view.rerender(createElement(UploadZone, { onFileSelect: vi.fn(), formatHint: 'Bundle 根目录需包含 SUITE.yaml' }))
|
||||
expect(screen.getByText('Bundle 根目录需包含 SUITE.yaml')).toBeTruthy()
|
||||
expect(screen.queryByText(zh.upload.formatHint)).toBeNull()
|
||||
})
|
||||
|
||||
it('hides directory selection when the browser does not expose a directory picker', () => {
|
||||
render(createElement(mod.UploadZone, { onFileSelect: vi.fn(), onFolderSelect: vi.fn() }))
|
||||
it('accepts one ZIP using the real dropzone and replaces the large picker with its controlled file row', async () => {
|
||||
const onFileSelect = vi.fn()
|
||||
const file = zip()
|
||||
const view = render(createElement(UploadZone, { onFileSelect }))
|
||||
await drop(screen.getByRole('button', { name: 'upload.dragHint' }), [file])
|
||||
expect(onFileSelect).toHaveBeenCalledExactlyOnceWith(file)
|
||||
view.rerender(createElement(UploadZone, { onFileSelect, selectedFile: file, previewStatus: 'ready' }))
|
||||
expect(screen.queryByText('upload.dragHint')).toBeNull()
|
||||
expect(screen.getByText(file.name)).toBeTruthy()
|
||||
expect(screen.getByText('publish.packageParsed')).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['two ZIPs', [zip('one.zip'), zip('two.zip')], 'upload.errors.singleZip'],
|
||||
['a ZIP and a text file', [zip(), new File(['x'], 'notes.txt', { type: 'text/plain' })], 'upload.errors.singleZip'],
|
||||
['a misleading MIME type', [new File(['x'], 'notes.txt', { type: 'application/zip' })], 'upload.errors.zipRequired'],
|
||||
])('rejects %s without replacing the existing package', async (_name, files, expectedError) => {
|
||||
const onFileSelect = vi.fn()
|
||||
const existing = zip('existing.zip')
|
||||
render(createElement(UploadZone, { onFileSelect, selectedFile: existing, previewStatus: 'ready' }))
|
||||
await drop(screen.getByText(existing.name), files)
|
||||
expect(onFileSelect).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('alert').textContent).toBe(expectedError)
|
||||
expect(screen.getByText(existing.name)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('refuses a dropped directory before recursively reading its entries', async () => {
|
||||
const onFileSelect = vi.fn()
|
||||
const getAsFile = vi.fn()
|
||||
render(createElement(UploadZone, { onFileSelect }))
|
||||
const dataTransfer = { types: ['Files'], files: [], items: [{ kind: 'file', webkitGetAsEntry: () => ({ isDirectory: true }), getAsFile }] }
|
||||
await act(async () => { fireEvent.dragEnter(screen.getByRole('button', { name: 'upload.dragHint' }), { dataTransfer }) })
|
||||
expect(screen.getByText('upload.dropHint')).toBeTruthy()
|
||||
await act(async () => { fireEvent.drop(screen.getByRole('button', { name: 'upload.dragHint' }), { dataTransfer }) })
|
||||
expect(screen.queryByText('upload.dropHint')).toBeNull()
|
||||
expect(screen.getByRole('alert').textContent).toBe('upload.errors.folderDropUnsupported')
|
||||
expect(onFileSelect).not.toHaveBeenCalled()
|
||||
expect(getAsFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a single native picker from either the full-area button or the explicit ZIP button', () => {
|
||||
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
|
||||
render(createElement(UploadZone, { onFileSelect: vi.fn() }))
|
||||
const surface = screen.getByRole('button', { name: 'upload.dragHint' })
|
||||
expect(surface.tagName).toBe('BUTTON')
|
||||
surface.focus()
|
||||
expect(document.activeElement).toBe(surface)
|
||||
// detail=0 is the click emitted by native keyboard button activation.
|
||||
fireEvent.click(surface, { detail: 0 })
|
||||
expect(click).toHaveBeenCalledTimes(1)
|
||||
click.mockClear()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'upload.selectZip' }))
|
||||
expect(click).toHaveBeenCalledTimes(1)
|
||||
expect(surface.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the current file when the picker is cancelled and accepts the same ZIP again', async () => {
|
||||
const onFileSelect = vi.fn()
|
||||
const file = zip()
|
||||
const view = render(createElement(UploadZone, { onFileSelect, selectedFile: file, previewStatus: 'ready' }))
|
||||
const input = view.container.querySelector('input[accept]') as HTMLInputElement
|
||||
vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.replacePackage' }))
|
||||
expect(onFileSelect).not.toHaveBeenCalled()
|
||||
expect(screen.getByText(file.name)).toBeTruthy()
|
||||
await act(async () => { fireEvent.change(input, { target: { files: [file] } }) })
|
||||
await waitFor(() => expect(onFileSelect).toHaveBeenCalledTimes(1))
|
||||
await act(async () => { fireEvent.change(input, { target: { files: [file] } }) })
|
||||
await waitFor(() => expect(onFileSelect).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('shows a busy reading state with no fabricated numeric percentage', () => {
|
||||
render(createElement(UploadZone, { onFileSelect: vi.fn(), selectedFile: zip(), previewStatus: 'reading' }))
|
||||
expect(screen.getByText('publish.readingPackage')).toBeTruthy()
|
||||
expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'publish.replacePackage' }).hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes an actionable preview failure and the public replacement focus target', () => {
|
||||
render(createElement(UploadZone, { onFileSelect: vi.fn(), selectedFile: zip(), previewStatus: 'error', previewError: 'preview failed', replaceActionId: 'replace-package' }))
|
||||
expect(screen.getByRole('alert').textContent).toBe('preview failed')
|
||||
expect(screen.queryByText('publish.packagePreviewFailed')).toBeNull()
|
||||
const replace = document.getElementById('replace-package')!
|
||||
replace.focus()
|
||||
expect(document.activeElement).toBe(replace)
|
||||
})
|
||||
|
||||
it('disables every picker and ignores file input changes and drops while unavailable', async () => {
|
||||
const restore = directorySupport()
|
||||
try {
|
||||
const onFileSelect = vi.fn(), onFolderSelect = vi.fn(), onRemove = vi.fn()
|
||||
const view = render(createElement(UploadZone, { onFileSelect, onFolderSelect, onRemove, selectedFile: zip(), disabled: true }))
|
||||
for (const button of screen.getAllByRole('button')) expect(button.hasAttribute('disabled')).toBe(true)
|
||||
for (const input of view.container.querySelectorAll('input')) expect(input.disabled).toBe(true)
|
||||
for (const input of view.container.querySelectorAll('input')) await act(async () => { fireEvent.change(input, { target: { files: [zip()] } }) })
|
||||
await drop(screen.getByText('meeting-notes.zip'), [zip('new.zip')])
|
||||
expect(onFileSelect).not.toHaveBeenCalled()
|
||||
expect(onFolderSelect).not.toHaveBeenCalled()
|
||||
expect(onRemove).not.toHaveBeenCalled()
|
||||
} finally { restore() }
|
||||
})
|
||||
|
||||
it('shows a packaging state while keeping the original package and actions disabled', () => {
|
||||
render(createElement(UploadZone, { onFileSelect: vi.fn(), selectedFile: zip(), isPackaging: true }))
|
||||
expect(screen.getByText('meeting-notes.zip')).toBeTruthy()
|
||||
expect(screen.getByText('upload.packaging')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'publish.replacePackage' }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('hides directory selection when it is unsupported', () => {
|
||||
render(createElement(UploadZone, { onFileSelect: vi.fn(), onFolderSelect: vi.fn() }))
|
||||
expect(screen.queryByRole('button', { name: 'upload.folderHint' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows directory selection only when the browser supports it', async () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'webkitdirectory')
|
||||
Object.defineProperty(HTMLInputElement.prototype, 'webkitdirectory', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: false,
|
||||
})
|
||||
it('selects folders through a separate native input, resets it, and reports an empty selection', async () => {
|
||||
const restore = directorySupport()
|
||||
try {
|
||||
render(createElement(mod.UploadZone, { onFileSelect: vi.fn(), onFolderSelect: vi.fn() }))
|
||||
await waitFor(() => expect(
|
||||
screen.getByRole('button', { name: 'upload.folderHint' })
|
||||
).not.toBeNull())
|
||||
} finally {
|
||||
if (previous) Object.defineProperty(HTMLInputElement.prototype, 'webkitdirectory', previous)
|
||||
else delete (HTMLInputElement.prototype as { webkitdirectory?: boolean }).webkitdirectory
|
||||
}
|
||||
const onFileSelect = vi.fn(), onFolderSelect = vi.fn()
|
||||
const view = render(createElement(UploadZone, { onFileSelect, onFolderSelect, folderAnalyticsId: 'folder-select', pickerAnalyticsId: 'folder-open' }))
|
||||
const folderButton = await screen.findByRole('button', { name: 'upload.folderHint' })
|
||||
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
|
||||
fireEvent.click(folderButton)
|
||||
expect(click).toHaveBeenCalledTimes(1)
|
||||
expect(folderButton.getAttribute('data-analytics-id')).toBe('folder-open')
|
||||
const input = view.container.querySelector('input[data-analytics-id="folder-select"]') as HTMLInputElement
|
||||
expect(input.hasAttribute('webkitdirectory')).toBe(true)
|
||||
const file = new File(['x'], 'SKILL.md', { type: 'text/markdown' })
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
expect(onFolderSelect).toHaveBeenCalledExactlyOnceWith([file])
|
||||
expect(onFileSelect).not.toHaveBeenCalled()
|
||||
expect(input.value).toBe('')
|
||||
fireEvent.change(input, { target: { files: [] } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('upload.errors.emptySelection')
|
||||
expect(onFolderSelect).toHaveBeenCalledTimes(1)
|
||||
} finally { restore() }
|
||||
})
|
||||
|
||||
it.each(['unmount', 'disable'])('ignores a delayed native file handle after %s', async (change) => {
|
||||
vi.stubGlobal('isSecureContext', true)
|
||||
const onFileSelect = vi.fn()
|
||||
const file = zip()
|
||||
let release!: (handle: { getFile: () => Promise<File> }) => void
|
||||
const handle = new Promise<{ getFile: () => Promise<File> }>(resolve => { release = resolve })
|
||||
const view = render(createElement(UploadZone, { onFileSelect }))
|
||||
fireEvent.drop(screen.getByRole('button', { name: 'upload.dragHint' }), {
|
||||
dataTransfer: { types: ['Files'], files: [file], items: [{ kind: 'file', getAsFileSystemHandle: () => handle, getAsFile: () => file }] },
|
||||
})
|
||||
if (change === 'unmount') view.unmount()
|
||||
else view.rerender(createElement(UploadZone, { onFileSelect, disabled: true }))
|
||||
await act(async () => { release({ getFile: async () => file }); await handle })
|
||||
expect(onFileSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears a rejected replacement error only after a new valid selection', async () => {
|
||||
const onFileSelect = vi.fn()
|
||||
render(createElement(UploadZone, { onFileSelect, selectedFile: zip() }))
|
||||
await drop(screen.getByText('meeting-notes.zip'), [new File(['x'], 'notes.txt')])
|
||||
expect(screen.getByRole('alert')).toBeTruthy()
|
||||
await drop(screen.getByText('meeting-notes.zip'), [zip('new.zip')])
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(onFileSelect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react'
|
||||
import { useCallback, useEffect, useId, useRef, useState, type ChangeEvent, type DragEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useDropzone, type FileRejection } from 'react-dropzone'
|
||||
import { AlertCircle, CheckCircle2, FileArchive, Loader2, Upload } from 'lucide-react'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
interface UploadZoneProps {
|
||||
|
|
@ -8,8 +10,16 @@ interface UploadZoneProps {
|
|||
folderAnalyticsId?: string
|
||||
pickerAnalyticsId?: string
|
||||
onFileSelect: (file: File) => void
|
||||
/** Optional: called with the raw files of a picked folder (webkitdirectory). */
|
||||
/** The parent packages directory files and owns package validation. */
|
||||
onFolderSelect?: (files: File[]) => void
|
||||
selectedFile?: File | null
|
||||
previewStatus?: 'idle' | 'reading' | 'ready' | 'error'
|
||||
previewError?: string
|
||||
/** Caller-specific package requirements; the shared default only describes ZIP. */
|
||||
formatHint?: string
|
||||
isPackaging?: boolean
|
||||
onRemove?: () => void
|
||||
replaceActionId?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -17,121 +27,175 @@ export function supportsDirectorySelection(input: HTMLInputElement | null): bool
|
|||
return Boolean(input && ('webkitdirectory' in input || 'directory' in input))
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the publish page dropzone for uploading one zip package at a time.
|
||||
* The component is intentionally stateless so packaging validation can remain in
|
||||
* the publish flow that knows the surrounding form and backend constraints.
|
||||
*/
|
||||
export function UploadZone({ onFileSelect, onFolderSelect, disabled, analyticsId, folderAnalyticsId, pickerAnalyticsId }: UploadZoneProps) {
|
||||
/** One package picker; selection, packaging and publication remain separate steps. */
|
||||
export function UploadZone({
|
||||
onFileSelect,
|
||||
onFolderSelect,
|
||||
selectedFile,
|
||||
previewStatus = 'idle',
|
||||
previewError,
|
||||
formatHint,
|
||||
isPackaging = false,
|
||||
onRemove,
|
||||
replaceActionId,
|
||||
disabled = false,
|
||||
analyticsId,
|
||||
folderAnalyticsId,
|
||||
pickerAnalyticsId,
|
||||
}: UploadZoneProps) {
|
||||
const { t } = useTranslation()
|
||||
const hintId = useId()
|
||||
const errorId = useId()
|
||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||
const [folderSelectionSupported, setFolderSelectionSupported] = useState(false)
|
||||
const [selectionError, setSelectionError] = useState<string | null>(null)
|
||||
const unavailable = disabled || isPackaging
|
||||
const unavailableRef = useRef(unavailable)
|
||||
const mountedRef = useRef(true)
|
||||
unavailableRef.current = unavailable
|
||||
|
||||
// `webkitdirectory` / `directory` are not in React's input attribute types;
|
||||
// set them imperatively so the folder picker works without an untyped cast.
|
||||
useEffect(() => {
|
||||
const el = folderInputRef.current
|
||||
const supported = supportsDirectorySelection(el)
|
||||
if (el && supported) {
|
||||
el.setAttribute('webkitdirectory', '')
|
||||
el.setAttribute('directory', '')
|
||||
const input = folderInputRef.current
|
||||
const supported = supportsDirectorySelection(input)
|
||||
if (input && supported) {
|
||||
input.setAttribute('webkitdirectory', '')
|
||||
input.setAttribute('directory', '')
|
||||
}
|
||||
setFolderSelectionSupported(supported)
|
||||
}, [])
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
onFileSelect(acceptedFiles[0])
|
||||
}
|
||||
},
|
||||
[onFileSelect]
|
||||
)
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => { mountedRef.current = false }
|
||||
}, [])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
useEffect(() => { setSelectionError(null) }, [selectedFile])
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||
if (!mountedRef.current || unavailableRef.current) return
|
||||
// Reject the whole selection rather than silently publishing its first valid file.
|
||||
if (acceptedFiles.length + rejectedFiles.length > 1) {
|
||||
setSelectionError('upload.errors.singleZip')
|
||||
} else if (rejectedFiles.length) {
|
||||
setSelectionError('upload.errors.zipRequired')
|
||||
} else if (acceptedFiles.length === 1) {
|
||||
setSelectionError(null)
|
||||
onFileSelect(acceptedFiles[0])
|
||||
} else {
|
||||
setSelectionError('upload.errors.emptySelection')
|
||||
}
|
||||
}, [onFileSelect])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'application/zip': ['.zip'],
|
||||
},
|
||||
accept: { 'application/zip': ['.zip'] },
|
||||
validator: file => /\.zip$/i.test(file.name) ? null : { code: 'file-invalid-type', message: 'zip-required' },
|
||||
maxFiles: 1,
|
||||
disabled,
|
||||
multiple: false,
|
||||
disabled: unavailable,
|
||||
// A native full-surface button supplies click and keyboard interaction, without
|
||||
// nesting the explicit ZIP/folder action buttons inside an ARIA button.
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
})
|
||||
|
||||
const handleFolderChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files
|
||||
if (files && files.length > 0) {
|
||||
onFolderSelect?.(Array.from(files))
|
||||
const rejectDirectoryDrop = (event: DragEvent<HTMLElement>) => {
|
||||
if (unavailableRef.current) return
|
||||
const hasDirectory = Array.from(event.dataTransfer.items ?? []).some(item => item.webkitGetAsEntry?.()?.isDirectory)
|
||||
if (hasDirectory) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setSelectionError('upload.errors.folderDropUnsupported')
|
||||
}
|
||||
// Reset so picking the same folder again re-triggers change.
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
const handleFolderChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? [])
|
||||
event.target.value = ''
|
||||
if (unavailableRef.current) return
|
||||
if (!files.length) {
|
||||
setSelectionError('upload.errors.emptySelection')
|
||||
return
|
||||
}
|
||||
setSelectionError(null)
|
||||
onFolderSelect?.(files)
|
||||
}
|
||||
|
||||
const showError = selectionError ? t(selectionError) : !isPackaging && previewStatus === 'error' ? (previewError || t('publish.packagePreviewFailed')) : undefined
|
||||
const inlinePreviewError = !!selectedFile && !!showError && previewStatus === 'error' && !selectionError
|
||||
const dragging = isDragActive && selectionError !== 'upload.errors.folderDropUnsupported'
|
||||
const reading = isPackaging || previewStatus === 'reading'
|
||||
const status = isPackaging ? t('upload.packaging') : t(previewStatus === 'reading' ? 'publish.readingPackage' : previewStatus === 'ready' ? 'publish.packageParsed' : 'publish.packagePreviewFailed')
|
||||
const visibleStatus = reading || previewStatus === 'ready' ? status : inlinePreviewError ? showError : undefined
|
||||
const folderAction = onFolderSelect && folderSelectionSupported ? (
|
||||
<Button
|
||||
data-analytics-id={unavailable ? undefined : pickerAnalyticsId}
|
||||
type="button"
|
||||
variant="link"
|
||||
className="relative z-10 h-10 whitespace-normal px-2"
|
||||
disabled={unavailable}
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
>{t('upload.folderHint')}</Button>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
data-analytics-id={disabled ? undefined : analyticsId}
|
||||
data-analytics-event={!disabled && analyticsId ? 'click+keydown+drop' : undefined}
|
||||
{...getRootProps({ onDrop: rejectDirectoryDrop })}
|
||||
data-analytics-id={unavailable ? undefined : analyticsId}
|
||||
data-analytics-event={!unavailable && analyticsId ? 'click+keydown+drop' : undefined}
|
||||
aria-busy={reading}
|
||||
className={cn(
|
||||
'upload-zone rounded-xl p-10 text-center cursor-pointer transition-all duration-300',
|
||||
isDragActive && 'border-primary bg-primary/5 scale-[1.01]',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
'relative rounded-xl border bg-background transition-colors',
|
||||
dragging ? 'border-primary bg-primary/5' : 'border-border',
|
||||
showError && 'border-destructive/60',
|
||||
unavailable && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} data-analytics-id={disabled ? undefined : analyticsId} />
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-2xl bg-secondary/60 flex items-center justify-center">
|
||||
<svg
|
||||
className={cn(
|
||||
'w-7 h-7 upload-zone-icon transition-colors',
|
||||
isDragActive && 'text-primary'
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<input {...getInputProps()} data-analytics-id={unavailable ? undefined : analyticsId} disabled={unavailable} />
|
||||
{!selectedFile && <button
|
||||
type="button"
|
||||
aria-label={t('upload.dragHint')}
|
||||
aria-describedby={showError ? errorId : selectedFile ? undefined : hintId}
|
||||
disabled={unavailable}
|
||||
onClick={open}
|
||||
className="absolute inset-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
/>}
|
||||
{selectedFile ? (
|
||||
<div className="pointer-events-none flex items-start gap-4 p-5 sm:p-6">
|
||||
<div className="flex h-14 w-12 shrink-0 items-center justify-center rounded-lg border bg-muted/30 text-muted-foreground"><FileArchive className="h-6 w-6" aria-hidden="true" /></div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="break-all text-sm font-medium sm:text-base">{selectedFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{(selectedFile.size / 1024).toFixed(1)} KB</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<p id={inlinePreviewError ? errorId : undefined} role={inlinePreviewError ? 'alert' : 'status'} className={cn('flex min-w-0 items-center gap-2 text-xs text-muted-foreground', previewStatus === 'ready' && !isPackaging && 'text-emerald-700 dark:text-emerald-400', inlinePreviewError && 'text-destructive')}>
|
||||
{reading ? <Loader2 className="h-4 w-4 shrink-0 animate-spin motion-reduce:animate-none" aria-hidden="true" /> : previewStatus === 'ready' ? <CheckCircle2 className="h-4 w-4 shrink-0" aria-hidden="true" /> : previewStatus === 'error' ? <AlertCircle className="h-4 w-4 shrink-0" aria-hidden="true" /> : null}
|
||||
{visibleStatus}
|
||||
</p>
|
||||
<div className="pointer-events-auto relative z-10 flex flex-wrap items-center gap-2">
|
||||
<Button id={replaceActionId} aria-describedby={showError ? errorId : undefined} type="button" variant="ghost" size="sm" disabled={unavailable} onClick={open}>{t('publish.replacePackage')}</Button>
|
||||
{folderAction}
|
||||
{onRemove && <Button data-analytics-id="skill.package.remove" type="button" variant="ghost" size="sm" disabled={unavailable} onClick={() => { setSelectionError(null); onRemove() }}>{t('publish.removeSelectedFile')}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isDragActive ? (
|
||||
<p className="text-sm text-primary font-medium">{t('upload.dropHint')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-foreground">{t('upload.dragHint')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('upload.formatHint')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="pointer-events-none flex min-h-[260px] flex-col items-center justify-center gap-3 px-5 py-6 text-center sm:px-8">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-muted/70 text-muted-foreground"><Upload className="h-6 w-6" aria-hidden="true" /></div>
|
||||
<p className="text-lg font-semibold">{t(isPackaging ? 'upload.packaging' : dragging ? 'upload.dropHint' : 'upload.dragHint')}</p>
|
||||
<p id={hintId} className="text-sm text-muted-foreground">{formatHint ?? t('upload.formatHint')}</p>
|
||||
<div className="pointer-events-auto relative z-10 mt-1 flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
|
||||
<Button type="button" className="h-10" disabled={unavailable} onClick={open}>{t('upload.selectZip')}</Button>
|
||||
{folderAction}
|
||||
</div>
|
||||
{onFolderSelect && folderSelectionSupported && <p className="text-xs leading-5 text-muted-foreground">{t('upload.folderPackagingHint')}</p>}
|
||||
</div>
|
||||
)}
|
||||
{reading && <div role="progressbar" aria-label={status} className="mx-5 mb-3 h-0.5 overflow-hidden rounded-full bg-muted sm:mx-6"><div className="h-full w-1/3 bg-muted-foreground/60" /></div>}
|
||||
</div>
|
||||
{onFolderSelect && (
|
||||
<div className="text-center">
|
||||
<input
|
||||
data-analytics-id={folderAnalyticsId}
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={handleFolderChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{folderSelectionSupported ? (
|
||||
<button
|
||||
data-analytics-id={pickerAnalyticsId}
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('upload.folderHint')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{showError && !inlinePreviewError && <p id={errorId} role="alert" className="text-sm text-destructive">{showError}</p>}
|
||||
{onFolderSelect && <input data-analytics-id={folderAnalyticsId} ref={folderInputRef} type="file" className="hidden" multiple onChange={handleFolderChange} disabled={unavailable} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
40
web/src/features/skill/skill-instructions.test.tsx
Normal file
40
web/src/features/skill/skill-instructions.test.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SkillInstructions } from './skill-instructions'
|
||||
const query = vi.hoisted(() => ({ data: '# Original instructions', error: null as unknown, isLoading: false, refetch: vi.fn() }))
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({ useSkillFile: () => query }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
vi.mock('./markdown-renderer', () => ({ MarkdownRenderer: ({ content }: {content: string}) => <article>{content}</article> }))
|
||||
const props = { namespace: 'team', slug: 'example', version: '1.0.0', available: true, loading: false, error: null, retryFiles: vi.fn(), onLinkClick: vi.fn() }
|
||||
afterEach(() => { cleanup(); query.error = null; query.isLoading = false; vi.clearAllMocks() })
|
||||
describe('skill instruction reading', () => {
|
||||
it('reads original content and toggles source without modifying it', () => {
|
||||
render(<SkillInstructions {...props} />)
|
||||
expect(screen.getByRole('article').textContent).toBe(query.data)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillDetail.instructionsSource' }))
|
||||
expect(screen.getByText(query.data).closest('pre')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillDetail.instructionsRendered' }))
|
||||
expect(screen.getByRole('article')).toBeTruthy()
|
||||
})
|
||||
it('reports load failure and retries the file query', () => {
|
||||
query.error = new Error('failed')
|
||||
render(<SkillInstructions {...props} />)
|
||||
expect(screen.getByRole('alert')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillDetail.instructionsRetry' }))
|
||||
expect(query.refetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('retries directory failure rather than substituting README', () => {
|
||||
render(<SkillInstructions {...props} error={new Error('files failed')} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillDetail.instructionsRetry' }))
|
||||
expect(props.retryFiles).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('article')).toBeNull()
|
||||
})
|
||||
it('distinguishes missing files from loading', () => {
|
||||
const { rerender } = render(<SkillInstructions {...props} available={false} loading />)
|
||||
expect(screen.getByRole('status').textContent).toBe('skillDetail.instructionsLoading')
|
||||
rerender(<SkillInstructions {...props} available={false} />)
|
||||
expect(screen.getByText('skillDetail.instructionsMissing')).toBeTruthy()
|
||||
expect(screen.queryByRole('article')).toBeNull()
|
||||
})
|
||||
})
|
||||
58
web/src/features/skill/skill-instructions.tsx
Normal file
58
web/src/features/skill/skill-instructions.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useState, type MouseEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSkillFile } from '@/shared/hooks/use-skill-queries'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { MarkdownRenderer } from './markdown-renderer'
|
||||
|
||||
interface SkillInstructionsProps {
|
||||
namespace: string
|
||||
slug: string
|
||||
version?: string
|
||||
available: boolean
|
||||
loading: boolean
|
||||
error: unknown
|
||||
retryFiles: () => void
|
||||
onLinkClick: (href: string, event: MouseEvent<HTMLAnchorElement>) => void
|
||||
}
|
||||
|
||||
export function SkillInstructions({ namespace, slug, version, available, loading, error, retryFiles, onLinkClick }: SkillInstructionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [source, setSource] = useState(false)
|
||||
const query = useSkillFile(namespace, slug, version, 'SKILL.md', available && !error && !loading)
|
||||
if (error || query.error) {
|
||||
return (
|
||||
<Card className="p-6 space-y-3" role="alert">
|
||||
<p>{t('skillDetail.instructionsError')}</p>
|
||||
<Button data-analytics-id="skill.instructions.retry" variant="outline"
|
||||
onClick={() => error ? retryFiles() : void query.refetch()}>
|
||||
{t('skillDetail.instructionsRetry')}
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
if (loading || (available && query.isLoading)) {
|
||||
return <Card className="p-6" role="status">{t('skillDetail.instructionsLoading')}</Card>
|
||||
}
|
||||
if (!available) {
|
||||
return <Card className="p-6">{t('skillDetail.instructionsMissing')}</Card>
|
||||
}
|
||||
return (
|
||||
<Card className="min-w-0 p-4 sm:p-6 space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
|
||||
<code className="text-sm text-muted-foreground">SKILL.md · {version}</code>
|
||||
<Button data-analytics-id="skill.instructions.source.toggle" variant="outline" size="sm"
|
||||
aria-pressed={source} onClick={() => setSource(!source)}>
|
||||
{t(source ? 'skillDetail.instructionsRendered' : 'skillDetail.instructionsSource')}
|
||||
</Button>
|
||||
</div>
|
||||
{source ? (
|
||||
<pre className="max-w-full overflow-x-auto whitespace-pre-wrap break-words text-sm">
|
||||
<code>{query.data}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<MarkdownRenderer content={query.data ?? ''} onLinkClick={onLinkClick} />
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -1466,6 +1466,22 @@
|
|||
"accessDeniedDesc": "You don't have permission to view this skill",
|
||||
"tabReadme": "README",
|
||||
"tabOverview": "Overview",
|
||||
"versionLoadError": "Could not load this version. Please retry.",
|
||||
"versionLoading": "Loading version content…",
|
||||
"tabInstructions": "Skill instructions",
|
||||
"instructionsError": "Could not load instructions. Please retry.",
|
||||
"instructionsRetry": "Retry",
|
||||
"instructionsLoading": "Loading instructions…",
|
||||
"instructionsMissing": "This version does not contain SKILL.md.",
|
||||
"instructionsRendered": "Read rendered",
|
||||
"instructionsSource": "View source",
|
||||
"versionUnavailable": "Version unavailable or inaccessible: {{version}}",
|
||||
"viewingUnpublishedVersion": "Previewing unpublished v{{version}}",
|
||||
"viewingHistoricalVersion": "Viewing historical v{{version}}",
|
||||
"viewingVersion": "Viewing v{{version}}",
|
||||
"returnLatestPublished": "Return to latest published",
|
||||
"viewingThisVersion": "Currently viewing",
|
||||
"viewThisVersion": "View this version",
|
||||
"tabFiles": "Files",
|
||||
"tabVersions": "Versions",
|
||||
"noReadme": "No README",
|
||||
|
|
@ -1972,10 +1988,19 @@
|
|||
"saveErrorTitle": "Failed to update namespace"
|
||||
},
|
||||
"upload": {
|
||||
"dropHint": "Drop to upload...",
|
||||
"dragHint": "Drag a ZIP file here, or click to select",
|
||||
"formatHint": "Only .zip format supported",
|
||||
"folderHint": "Or select a folder to package and upload"
|
||||
"dropHint": "Release to read the ZIP file",
|
||||
"dragHint": "Drop a ZIP file here",
|
||||
"formatHint": "Only .zip files are supported",
|
||||
"folderHint": "Choose a folder",
|
||||
"selectZip": "Choose ZIP file",
|
||||
"folderPackagingHint": "The folder will be packaged as a ZIP before processing.",
|
||||
"packaging": "Packaging the selected folder…",
|
||||
"errors": {
|
||||
"singleZip": "Choose one ZIP file at a time.",
|
||||
"zipRequired": "Choose a skill package in .zip format.",
|
||||
"folderDropUnsupported": "Use Choose a folder, or compress the folder as a ZIP first.",
|
||||
"emptySelection": "No files were found. Choose again."
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"openNavigation": "Open navigation menu",
|
||||
|
|
@ -2246,11 +2271,28 @@
|
|||
"copyright": "© 2026 SkillHub. Apache-2.0."
|
||||
},
|
||||
"publish": {
|
||||
"packageFormatHint": "The ZIP root must contain SKILL.md",
|
||||
"form": {
|
||||
"identity": "Basic information",
|
||||
"nameHelp": "Starts with the package title. Leave empty to use the package name.",
|
||||
"presentation": "Presentation",
|
||||
"presentationHelp": "Explain the purpose, inputs, outputs and limitations for users.",
|
||||
"customized": "Customized for this version",
|
||||
"packageContent": "From the current package",
|
||||
"restore": "Restore package content",
|
||||
"restoreTitle": "Restore package {{field}}?",
|
||||
"restoreDescription": "This replaces the manually edited content with content from the current package. The package file itself will not change.",
|
||||
"confirmHelp": "Review the version and presentation before submitting."
|
||||
},
|
||||
"retryPackagePreview": "Retry",
|
||||
"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.",
|
||||
"shortDescription": "Short description",
|
||||
"overview": "Detailed overview (Markdown)",
|
||||
"presentationInheritance": "Without web or package presentation values, custom values from published version {{version}} are inherited when submitted.",
|
||||
"resetPresentation": "Restore package defaults (do not inherit)",
|
||||
"readingPackage": "Reading package metadata…",
|
||||
"examplesReady": "Recognized {{count}} task examples across all languages.",
|
||||
"examplesNone": "No task examples provided. Publishing and use remain available.",
|
||||
|
|
@ -2260,7 +2302,7 @@
|
|||
"subtitle": "Upload skill package to SkillHub",
|
||||
"reviewNotice": {
|
||||
"title": "Review Notice",
|
||||
"description": "Submitted skill packages require admin review before publication."
|
||||
"description": "Processing depends on visibility and the security scanning policy. Check the submission receipt and version status for the next step."
|
||||
},
|
||||
"resubmitNotice": {
|
||||
"title": "Resubmit {{skill}} v{{version}}",
|
||||
|
|
@ -2304,7 +2346,137 @@
|
|||
"updateSubtitle": "Save avatar changes independently, or upload a package to publish a new version.",
|
||||
"optionalPackage": "A package is optional when changing only the avatar.",
|
||||
"saveAvatar": "Save avatar",
|
||||
"avatarSaved": "Avatar changes submitted"
|
||||
"avatarSaved": "Avatar changes submitted",
|
||||
"overviewPreview": "Overview preview",
|
||||
"editOverview": "Edit overview",
|
||||
"emptyOverview": "No overview to preview",
|
||||
"emptyOverviewHelp": "No usable introduction was found. Add the purpose, inputs, outputs and limitations in the overview editor.",
|
||||
"settings": "Publishing settings",
|
||||
"cardPreview": "Skill card preview",
|
||||
"confirmDetails": "Confirm submission",
|
||||
"backToEdit": "Back to editing",
|
||||
"readingOverview": "Reading package introduction…",
|
||||
"overviewPreviewUnavailable": "Package introduction cannot be previewed. Edit the overview or choose the package again; the server still validates submissions.",
|
||||
"stepsLabel": "Publishing steps",
|
||||
"stepUpload": "Upload package",
|
||||
"stepPresentation": "Presentation",
|
||||
"stepConfirm": "Confirm submission",
|
||||
"nextConfirm": "Next: confirm",
|
||||
"packageParsed": "Structure parsed",
|
||||
"replacePackage": "Replace",
|
||||
"editor": {
|
||||
"title": "Edit detailed overview",
|
||||
"description": "Describe the purpose, inputs, outputs and limitations.",
|
||||
"visual": "Visual",
|
||||
"source": "Markdown source",
|
||||
"preview": "Publish preview",
|
||||
"sourceOnly": "Source",
|
||||
"split": "Split",
|
||||
"unsaved": "Unsaved changes",
|
||||
"unchanged": "No unsaved changes",
|
||||
"apply": "Done editing",
|
||||
"cancel": "Cancel changes",
|
||||
"returnHint": "Finishing updates the form preview; it does not submit for review.",
|
||||
"discardTitle": "Discard these changes?",
|
||||
"discardDescription": "Overview changes that have not been applied will be discarded.",
|
||||
"discard": "Discard changes",
|
||||
"keepEditing": "Keep editing",
|
||||
"sourceRecovery": "This content cannot be edited visually. The source is preserved; continue in Markdown source mode.",
|
||||
"tooLong": "Overview cannot exceed {{max}} characters.",
|
||||
"loading": "Loading editor…",
|
||||
"loadFailed": "The editor failed to load. Continue in Markdown source mode.",
|
||||
"insertImage": "Insert image",
|
||||
"imageUrl": "Image HTTPS URL",
|
||||
"imageAlt": "Image description",
|
||||
"insert": "Insert",
|
||||
"invalidImageUrl": "Enter a valid HTTPS image URL.",
|
||||
"insertMore": "Insert",
|
||||
"visualUnavailable": "This content cannot be edited visually. Continue in Markdown source mode; the original content is preserved.",
|
||||
"markdownLabel": "Overview Markdown source"
|
||||
},
|
||||
"source": {
|
||||
"package": "Use package",
|
||||
"custom": "Custom",
|
||||
"view": "Read full overview",
|
||||
"shortRequired": "Provide a non-empty summary of up to 200 characters, from the package or custom content.",
|
||||
"overviewRequired": "Provide a non-empty overview of up to 20,000 characters; select Custom to shorten package documentation.",
|
||||
"requiredSummary": "Required information is incomplete. Check the package, namespace, summary and overview.",
|
||||
"historyUnavailable": "The published introduction could not be read. Retry before submitting to avoid overwriting it."
|
||||
},
|
||||
"result": {
|
||||
"submitting": {
|
||||
"title": "Submitting this version",
|
||||
"description": "Sending {{skill}} {{version}}. Please wait.",
|
||||
"button": "Submitting…"
|
||||
},
|
||||
"version-conflict": {
|
||||
"title": "Version {{version}} already exists",
|
||||
"description": "Your introduction is retained. Replace the package with a new version and review before submitting."
|
||||
},
|
||||
"unknown": {
|
||||
"title": "Submission result is uncertain",
|
||||
"description": "Check My skills for this version before submitting again."
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "Submission not permitted",
|
||||
"description": "Your introduction is retained. Check permissions and package contents before proceeding."
|
||||
},
|
||||
"invalid": {
|
||||
"title": "Submission failed",
|
||||
"description": "Your introduction is retained. Check permissions and package contents before proceeding."
|
||||
},
|
||||
"failed": {
|
||||
"title": "Submission failed",
|
||||
"description": "Your introduction is retained. Check permissions and package contents before proceeding."
|
||||
}
|
||||
},
|
||||
"accepted": {
|
||||
"SCANNING": {
|
||||
"title": "Submitted; awaiting security scanning",
|
||||
"description": "This version is not published. After scanning, the configured review workflow applies."
|
||||
},
|
||||
"PENDING_REVIEW": {
|
||||
"title": "Submitted; awaiting review",
|
||||
"description": "This version is not published. Open this version to follow the review outcome."
|
||||
},
|
||||
"UPLOADED": {
|
||||
"title": "Uploaded; not published",
|
||||
"description": "Open this version and use the available publish or review action for its visibility."
|
||||
},
|
||||
"PUBLISHED": {
|
||||
"title": "Version published",
|
||||
"description": "This version has been published. Open its details to inspect the released content."
|
||||
},
|
||||
"SCAN_FAILED": {
|
||||
"title": "Security scan failed to run",
|
||||
"description": "This is an execution failure, not a harmful-content verdict. Inspect the scan report and ask the deployment operator to investigate."
|
||||
},
|
||||
"REJECTED": {
|
||||
"title": "Version rejected",
|
||||
"description": "Read the review feedback before preparing another version."
|
||||
},
|
||||
"YANKED": {
|
||||
"title": "Version withdrawn",
|
||||
"description": "Open the version details to inspect its current availability."
|
||||
},
|
||||
"DRAFT": {
|
||||
"title": "Draft version",
|
||||
"description": "The version has not been published."
|
||||
},
|
||||
"unrecognized": {
|
||||
"title": "Submission response received",
|
||||
"description": "Open My skills to confirm the current state of this version."
|
||||
}
|
||||
},
|
||||
"previousPublished": "The published version remains visible under its existing permissions.",
|
||||
"viewVersion": "View this version",
|
||||
"mySkills": "My skills",
|
||||
"replacePackage": "Replace package",
|
||||
"checkMySkills": "Check My skills",
|
||||
"returnToForm": "Return to editing",
|
||||
"receiptHint": "This is the submission receipt. Open this version to check its latest progress."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"success": "Success",
|
||||
|
|
|
|||
|
|
@ -1743,6 +1743,22 @@
|
|||
"accessDeniedDesc": "이 스킬을 볼 권한이 없습니다",
|
||||
"tabReadme": "README",
|
||||
"tabOverview": "개요",
|
||||
"versionLoadError": "버전을 불러오지 못했습니다. 다시 시도하세요.",
|
||||
"versionLoading": "버전 내용을 불러오는 중…",
|
||||
"tabInstructions": "스킬 지침",
|
||||
"instructionsError": "지침을 불러오지 못했습니다. 다시 시도하세요.",
|
||||
"instructionsRetry": "다시 시도",
|
||||
"instructionsLoading": "지침을 불러오는 중…",
|
||||
"instructionsMissing": "이 버전에는 SKILL.md가 없습니다.",
|
||||
"instructionsRendered": "읽기 보기",
|
||||
"instructionsSource": "소스 보기",
|
||||
"versionUnavailable": "접근할 수 없는 버전: {{version}}",
|
||||
"viewingUnpublishedVersion": "미게시 버전 v{{version}} 미리보기",
|
||||
"viewingHistoricalVersion": "이전 버전 v{{version}} 보는 중",
|
||||
"viewingVersion": "v{{version}} 보는 중",
|
||||
"returnLatestPublished": "최신 게시 버전으로",
|
||||
"viewingThisVersion": "현재 보는 버전",
|
||||
"viewThisVersion": "이 버전 보기",
|
||||
"tabFiles": "파일",
|
||||
"tabVersions": "버전",
|
||||
"noReadme": "README 없음",
|
||||
|
|
@ -2253,10 +2269,19 @@
|
|||
"saveErrorTitle": "네임스페이스를 업데이트하지 못했습니다"
|
||||
},
|
||||
"upload": {
|
||||
"dropHint": "놓아서 업로드하세요...",
|
||||
"dragHint": "ZIP 파일을 여기로 끌어 놓거나 클릭하여 선택하세요",
|
||||
"dropHint": "놓아서 ZIP 파일 읽기",
|
||||
"dragHint": "ZIP 파일을 여기에 놓으세요",
|
||||
"formatHint": ".zip 형식만 지원합니다",
|
||||
"folderHint": "또는 폴더를 선택하여 패키지로 묶어 업로드하세요"
|
||||
"folderHint": "폴더 선택",
|
||||
"selectZip": "ZIP 파일 선택",
|
||||
"folderPackagingHint": "폴더를 ZIP으로 묶은 다음 처리합니다.",
|
||||
"packaging": "선택한 폴더를 압축하는 중…",
|
||||
"errors": {
|
||||
"singleZip": "ZIP 파일은 한 번에 하나만 선택하세요.",
|
||||
"zipRequired": ".zip 형식의 스킬 패키지를 선택하세요.",
|
||||
"folderDropUnsupported": "폴더 선택을 사용하거나 먼저 폴더를 ZIP으로 압축하세요.",
|
||||
"emptySelection": "파일을 읽지 못했습니다. 다시 선택하세요."
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"openNavigation": "탐색 메뉴 열기",
|
||||
|
|
@ -2526,11 +2551,28 @@
|
|||
"copyright": "© 2026 SkillHub. Apache-2.0."
|
||||
},
|
||||
"publish": {
|
||||
"packageFormatHint": "ZIP 최상위 경로에 SKILL.md가 있어야 합니다",
|
||||
"form": {
|
||||
"identity": "기본 정보",
|
||||
"nameHelp": "패키지 제목을 기본으로 사용합니다. 비워 두면 패키지 이름을 사용합니다.",
|
||||
"presentation": "소개",
|
||||
"presentationHelp": "사용자에게 용도, 입력, 출력 및 사용 제한을 설명하세요.",
|
||||
"customized": "이번 버전에서 수정됨",
|
||||
"packageContent": "현재 패키지에서 가져옴",
|
||||
"restore": "패키지 내용 복원",
|
||||
"restoreTitle": "패키지의 {{field}} 내용을 복원할까요?",
|
||||
"restoreDescription": "직접 편집한 내용을 현재 패키지의 내용으로 바꿉니다. 패키지 파일 자체는 변경되지 않습니다.",
|
||||
"confirmHelp": "제출하기 전에 버전과 소개를 다시 확인합니다."
|
||||
},
|
||||
"retryPackagePreview": "다시 시도",
|
||||
"packageName": "패키지 이름",
|
||||
"displayName": "표시 이름",
|
||||
"displayNamePlaceholder": "예: 회의록 도우미",
|
||||
"displayNameHelp": "최대 200자이며 기본값은 패키지 제목입니다. 변경 사항은 이 버전의 검토를 따르며 식별자나 패키지 파일을 변경하지 않습니다. 비워 두면 패키지 제목을 사용합니다.",
|
||||
"shortDescription": "짧은 설명",
|
||||
"overview": "상세 개요 (Markdown)",
|
||||
"presentationInheritance": "웹과 패키지에 값이 없으면 제출 시 게시된 버전 {{version}}의 사용자 지정 값을 상속합니다.",
|
||||
"resetPresentation": "패키지 기본값으로 복원 (상속 안 함)",
|
||||
"readingPackage": "패키지 메타데이터를 읽는 중…",
|
||||
"examplesReady": "전체 언어에서 작업 예제 {{count}}개를 인식했습니다.",
|
||||
"examplesNone": "제공된 작업 예제가 없습니다. 게시와 사용은 계속 가능합니다.",
|
||||
|
|
@ -2540,7 +2582,7 @@
|
|||
"subtitle": "SkillHub에 스킬 패키지 업로드",
|
||||
"reviewNotice": {
|
||||
"title": "검토 안내",
|
||||
"description": "제출된 스킬 패키지는 게시 전에 관리자 검토가 필요합니다."
|
||||
"description": "제출 후 처리는 공개 범위와 보안 스캔 정책에 따라 달라집니다. 이번 제출 결과와 버전 상태에서 다음 단계를 확인하세요."
|
||||
},
|
||||
"resubmitNotice": {
|
||||
"title": "{{skill}} v{{version}} 재제출",
|
||||
|
|
@ -2584,7 +2626,137 @@
|
|||
"updateSubtitle": "아바타 변경만 저장하거나 패키지를 업로드하여 새 버전을 게시하세요.",
|
||||
"optionalPackage": "아바타만 변경할 때는 패키지가 필요하지 않습니다.",
|
||||
"saveAvatar": "아바타 저장",
|
||||
"avatarSaved": "아바타 변경을 제출했습니다"
|
||||
"avatarSaved": "아바타 변경을 제출했습니다",
|
||||
"overviewPreview": "개요 미리보기",
|
||||
"editOverview": "개요 편집",
|
||||
"emptyOverview": "미리 볼 개요가 없습니다",
|
||||
"emptyOverviewHelp": "사용 가능한 설명이 없습니다. 개요 편집기에서 목적, 입력, 출력 및 제한 사항을 추가하세요.",
|
||||
"settings": "게시 설정",
|
||||
"cardPreview": "스킬 카드 미리보기",
|
||||
"confirmDetails": "제출 정보 확인",
|
||||
"backToEdit": "편집으로 돌아가기",
|
||||
"readingOverview": "패키지 설명 읽는 중…",
|
||||
"overviewPreviewUnavailable": "패키지 설명을 미리 볼 수 없습니다. 개요를 편집하거나 패키지를 다시 선택하세요. 제출 시 서버에서 검증합니다.",
|
||||
"stepsLabel": "게시 단계",
|
||||
"stepUpload": "패키지 업로드",
|
||||
"stepPresentation": "표시 정보",
|
||||
"stepConfirm": "제출 확인",
|
||||
"nextConfirm": "다음: 확인",
|
||||
"packageParsed": "구조 분석 완료",
|
||||
"replacePackage": "교체",
|
||||
"editor": {
|
||||
"title": "상세 개요 편집",
|
||||
"description": "목적, 입력, 출력 및 제한 사항을 작성하세요.",
|
||||
"visual": "시각 편집",
|
||||
"source": "Markdown 소스",
|
||||
"preview": "게시 미리보기",
|
||||
"sourceOnly": "소스",
|
||||
"split": "분할",
|
||||
"unsaved": "저장되지 않은 변경",
|
||||
"unchanged": "저장되지 않은 변경 없음",
|
||||
"apply": "편집 완료",
|
||||
"cancel": "변경 취소",
|
||||
"returnHint": "완료하면 양식 미리보기만 업데이트되며 심사에 제출되지 않습니다.",
|
||||
"discardTitle": "변경 사항을 버릴까요?",
|
||||
"discardDescription": "적용하지 않은 개요 변경 사항이 삭제됩니다.",
|
||||
"discard": "변경 버리기",
|
||||
"keepEditing": "계속 편집",
|
||||
"sourceRecovery": "시각 편집을 지원하지 않는 내용입니다. 원본을 보존했습니다. Markdown 소스에서 계속 편집하세요.",
|
||||
"tooLong": "개요는 {{max}}자를 초과할 수 없습니다.",
|
||||
"loading": "편집기 로딩 중…",
|
||||
"loadFailed": "편집기를 불러오지 못했습니다. Markdown 소스에서 계속하세요.",
|
||||
"insertImage": "이미지 삽입",
|
||||
"imageUrl": "이미지 HTTPS 주소",
|
||||
"imageAlt": "이미지 설명",
|
||||
"insert": "삽입",
|
||||
"invalidImageUrl": "유효한 HTTPS 이미지 주소를 입력하세요.",
|
||||
"insertMore": "삽입",
|
||||
"visualUnavailable": "이 내용은 시각 편집을 지원하지 않습니다. Markdown 소스로 편집하세요. 원본은 보존됩니다.",
|
||||
"markdownLabel": "개요 Markdown 소스"
|
||||
},
|
||||
"source": {
|
||||
"package": "스킬 패키지 사용",
|
||||
"custom": "직접 작성",
|
||||
"view": "개요 전체 보기",
|
||||
"shortRequired": "패키지 내용 또는 직접 작성한 내용으로 200자 이내의 짧은 설명을 입력하세요. 공백만 있는 내용은 사용할 수 없습니다.",
|
||||
"overviewRequired": "20,000자 이내의 비어 있지 않은 상세 개요를 입력하세요. 패키지 문서가 너무 길면 직접 작성으로 전환해 정리하세요.",
|
||||
"requiredSummary": "필수 정보가 누락되었습니다. 패키지, 네임스페이스, 짧은 설명과 상세 개요를 확인하세요.",
|
||||
"historyUnavailable": "게시 버전의 소개를 불러오지 못했습니다. 다시 불러온 후 제출하세요."
|
||||
},
|
||||
"result": {
|
||||
"submitting": {
|
||||
"title": "이 버전을 제출하는 중",
|
||||
"description": "{{skill}} {{version}} 버전을 전송하고 있습니다. 잠시 기다려 주세요.",
|
||||
"button": "제출 중…"
|
||||
},
|
||||
"version-conflict": {
|
||||
"title": "{{version}} 버전이 이미 존재합니다",
|
||||
"description": "작성한 소개와 내용 출처 선택은 유지됩니다. 새 버전 번호가 포함된 패키지로 교체하고 내용을 확인한 후 제출하세요."
|
||||
},
|
||||
"unknown": {
|
||||
"title": "제출 결과를 확인할 수 없습니다",
|
||||
"description": "다시 제출하기 전에 내 스킬에서 이 버전이 생성되었는지 확인하세요."
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "제출 권한이 없습니다",
|
||||
"description": "작성한 소개는 유지됩니다. 현재 네임스페이스 권한을 확인한 후 계속하세요."
|
||||
},
|
||||
"invalid": {
|
||||
"title": "패키지를 제출할 수 없습니다",
|
||||
"description": "작성한 소개는 유지됩니다. 패키지 구조와 입력 내용을 수정한 후 다시 확인하세요."
|
||||
},
|
||||
"failed": {
|
||||
"title": "제출이 완료되지 않았습니다",
|
||||
"description": "작성한 소개는 유지됩니다. 문제를 확인한 후 양식으로 돌아가 수정하세요."
|
||||
}
|
||||
},
|
||||
"accepted": {
|
||||
"SCANNING": {
|
||||
"title": "제출됨 · 보안 검사 단계",
|
||||
"description": "이 버전은 아직 게시되지 않았습니다. 검사 이후에는 플랫폼 설정에 따라 처리됩니다."
|
||||
},
|
||||
"PENDING_REVIEW": {
|
||||
"title": "제출됨 · 검토 대기",
|
||||
"description": "이 버전은 아직 게시되지 않았습니다. 내 스킬에서 검토 진행 상황을 확인하세요."
|
||||
},
|
||||
"UPLOADED": {
|
||||
"title": "업로드됨 · 아직 게시되지 않음",
|
||||
"description": "내 스킬에서 현재 공개 범위와 권한에 따라 게시 확인 또는 검토 제출을 진행하세요."
|
||||
},
|
||||
"PUBLISHED": {
|
||||
"title": "이 버전이 게시되었습니다",
|
||||
"description": "이 버전의 상세 페이지에서 게시된 내용을 확인할 수 있습니다."
|
||||
},
|
||||
"SCAN_FAILED": {
|
||||
"title": "보안 검사 실행 실패",
|
||||
"description": "검사가 완료되지 않았으며 유해한 콘텐츠로 판정되었다는 의미는 아닙니다. 버전 상세 정보에서 실패 원인을 확인하세요."
|
||||
},
|
||||
"REJECTED": {
|
||||
"title": "이 버전이 거부되었습니다",
|
||||
"description": "검토 의견을 확인하고 내용을 수정한 새 버전을 제출하세요."
|
||||
},
|
||||
"YANKED": {
|
||||
"title": "이 버전이 철회되었습니다",
|
||||
"description": "버전 상세 페이지에서 현재 상태와 사용할 수 있는 작업을 확인하세요."
|
||||
},
|
||||
"DRAFT": {
|
||||
"title": "이 버전은 초안 상태입니다",
|
||||
"description": "이 버전은 아직 게시되지 않았습니다."
|
||||
},
|
||||
"unrecognized": {
|
||||
"title": "제출 응답을 받았습니다",
|
||||
"description": "내 스킬에서 이 버전의 실제 상태를 확인하세요."
|
||||
}
|
||||
},
|
||||
"previousPublished": "기존 게시 버전은 원래 접근 권한에 따라 계속 표시됩니다.",
|
||||
"viewVersion": "이 버전 보기",
|
||||
"mySkills": "내 스킬로 돌아가기",
|
||||
"replacePackage": "버전 패키지 교체",
|
||||
"checkMySkills": "내 스킬에서 확인",
|
||||
"returnToForm": "양식으로 돌아가기",
|
||||
"receiptHint": "이 화면은 제출 당시의 응답입니다. 최신 진행 상황은 이 버전의 상세 페이지에서 확인하세요."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"success": "성공",
|
||||
|
|
|
|||
|
|
@ -1533,6 +1533,22 @@
|
|||
"accessDeniedDesc": "У вас нет прав на просмотр этого скилла",
|
||||
"tabReadme": "README",
|
||||
"tabOverview": "Обзор",
|
||||
"versionLoadError": "Не удалось загрузить версию. Повторите попытку.",
|
||||
"versionLoading": "Загрузка содержимого версии…",
|
||||
"tabInstructions": "Инструкции навыка",
|
||||
"instructionsError": "Не удалось загрузить инструкции. Повторите попытку.",
|
||||
"instructionsRetry": "Повторить",
|
||||
"instructionsLoading": "Загрузка инструкций…",
|
||||
"instructionsMissing": "В этой версии нет SKILL.md.",
|
||||
"instructionsRendered": "Режим чтения",
|
||||
"instructionsSource": "Исходный текст",
|
||||
"versionUnavailable": "Версия недоступна: {{version}}",
|
||||
"viewingUnpublishedVersion": "Предпросмотр неопубликованной v{{version}}",
|
||||
"viewingHistoricalVersion": "Просмотр старой версии v{{version}}",
|
||||
"viewingVersion": "Просмотр v{{version}}",
|
||||
"returnLatestPublished": "К последней опубликованной",
|
||||
"viewingThisVersion": "Открытая версия",
|
||||
"viewThisVersion": "Открыть версию",
|
||||
"tabFiles": "Файлы",
|
||||
"tabVersions": "Версии",
|
||||
"noReadme": "Нет README",
|
||||
|
|
@ -2237,11 +2253,28 @@
|
|||
"goToPage": "Перейти на страницу {{page}}"
|
||||
},
|
||||
"publish": {
|
||||
"packageFormatHint": "В корне ZIP должен находиться SKILL.md",
|
||||
"form": {
|
||||
"identity": "Основная информация",
|
||||
"nameHelp": "По умолчанию используется заголовок из пакета. Пустое поле означает имя пакета.",
|
||||
"presentation": "Описание для пользователей",
|
||||
"presentationHelp": "Опишите назначение, входные и выходные данные и ограничения.",
|
||||
"customized": "Изменено для этой версии",
|
||||
"packageContent": "Из текущего пакета",
|
||||
"restore": "Восстановить из пакета",
|
||||
"restoreTitle": "Восстановить поле «{{field}}» из пакета?",
|
||||
"restoreDescription": "Введённый вручную текст будет заменён содержимым текущего пакета. Сам файл пакета не изменится.",
|
||||
"confirmHelp": "Перед отправкой проверьте версию и описание."
|
||||
},
|
||||
"retryPackagePreview": "Повторить",
|
||||
"packageName": "Имя в пакете",
|
||||
"displayName": "Отображаемое имя",
|
||||
"displayNamePlaceholder": "Например: Помощник для протоколов встреч",
|
||||
"displayNameHelp": "До 200 символов. По умолчанию — имя из пакета. Изменения проходят проверку версии и не меняют идентификатор или файлы пакета. Пустое поле означает имя из пакета.",
|
||||
"shortDescription": "Краткое описание",
|
||||
"overview": "Подробный обзор (Markdown)",
|
||||
"presentationInheritance": "Если значения не указаны на сайте и в пакете, при отправке наследуются пользовательские значения опубликованной версии {{version}}.",
|
||||
"resetPresentation": "Вернуть значения пакета (без наследования)",
|
||||
"readingPackage": "Чтение метаданных пакета…",
|
||||
"examplesReady": "Распознано примеров задач на всех языках: {{count}}.",
|
||||
"examplesNone": "Примеры задач не предоставлены. Публикация и использование доступны.",
|
||||
|
|
@ -2251,7 +2284,7 @@
|
|||
"subtitle": "Загрузить пакет скилла в SkillHub",
|
||||
"reviewNotice": {
|
||||
"title": "Уведомление о ревью",
|
||||
"description": "Отправленные пакеты скиллов проходят ревью администратора перед публикацией."
|
||||
"description": "Дальнейшая обработка зависит от видимости и политики сканирования безопасности. Следующий шаг указан в результате отправки и статусе версии."
|
||||
},
|
||||
"resubmitNotice": {
|
||||
"title": "Повторная отправка {{skill}} v{{version}}",
|
||||
|
|
@ -2295,7 +2328,137 @@
|
|||
"updateSubtitle": "Сохраните аватар отдельно или загрузите пакет для публикации новой версии.",
|
||||
"optionalPackage": "Для изменения только аватара пакет не требуется.",
|
||||
"saveAvatar": "Сохранить аватар",
|
||||
"avatarSaved": "Изменения аватара отправлены"
|
||||
"avatarSaved": "Изменения аватара отправлены",
|
||||
"overviewPreview": "Предпросмотр обзора",
|
||||
"editOverview": "Редактировать обзор",
|
||||
"emptyOverview": "Нет обзора для предпросмотра",
|
||||
"emptyOverviewHelp": "Описание не найдено. Добавьте назначение, входные и выходные данные и ограничения.",
|
||||
"settings": "Настройки публикации",
|
||||
"cardPreview": "Предпросмотр карточки",
|
||||
"confirmDetails": "Подтверждение отправки",
|
||||
"backToEdit": "Вернуться к редактированию",
|
||||
"readingOverview": "Чтение описания пакета…",
|
||||
"overviewPreviewUnavailable": "Невозможно показать описание пакета. Отредактируйте обзор или выберите пакет повторно; отправку проверяет сервер.",
|
||||
"stepsLabel": "Этапы публикации",
|
||||
"stepUpload": "Загрузка пакета",
|
||||
"stepPresentation": "Описание",
|
||||
"stepConfirm": "Подтверждение",
|
||||
"nextConfirm": "Далее: подтверждение",
|
||||
"packageParsed": "Структура проверена",
|
||||
"replacePackage": "Заменить",
|
||||
"editor": {
|
||||
"title": "Редактировать подробный обзор",
|
||||
"description": "Укажите назначение, входные и выходные данные и ограничения.",
|
||||
"visual": "Визуально",
|
||||
"source": "Исходный Markdown",
|
||||
"preview": "Предпросмотр публикации",
|
||||
"sourceOnly": "Исходник",
|
||||
"split": "Разделить",
|
||||
"unsaved": "Несохранённые изменения",
|
||||
"unchanged": "Нет несохранённых изменений",
|
||||
"apply": "Завершить редактирование",
|
||||
"cancel": "Отменить изменения",
|
||||
"returnHint": "Завершение обновляет предпросмотр формы без отправки на проверку.",
|
||||
"discardTitle": "Отменить эти изменения?",
|
||||
"discardDescription": "Неприменённые изменения обзора будут потеряны.",
|
||||
"discard": "Отменить изменения",
|
||||
"keepEditing": "Продолжить редактирование",
|
||||
"sourceRecovery": "Визуальное редактирование недоступно. Исходный текст сохранён; продолжите в режиме Markdown.",
|
||||
"tooLong": "Обзор не может превышать {{max}} символов.",
|
||||
"loading": "Загрузка редактора…",
|
||||
"loadFailed": "Не удалось загрузить редактор. Продолжите в режиме Markdown.",
|
||||
"insertImage": "Вставить изображение",
|
||||
"imageUrl": "HTTPS-адрес изображения",
|
||||
"imageAlt": "Описание изображения",
|
||||
"insert": "Вставить",
|
||||
"invalidImageUrl": "Введите допустимый HTTPS-адрес изображения.",
|
||||
"insertMore": "Вставить",
|
||||
"visualUnavailable": "Визуальное редактирование недоступно. Продолжите в режиме Markdown; исходный текст сохранён.",
|
||||
"markdownLabel": "Исходный Markdown обзора"
|
||||
},
|
||||
"source": {
|
||||
"package": "Из пакета",
|
||||
"custom": "Свой текст",
|
||||
"view": "Полный обзор",
|
||||
"shortRequired": "Укажите непустое краткое описание из пакета или собственный текст длиной до 200 символов.",
|
||||
"overviewRequired": "Укажите непустой обзор длиной до 20 000 символов. Чтобы сократить документацию пакета, выберите свой текст.",
|
||||
"requiredSummary": "Заполнены не все обязательные поля. Проверьте пакет, пространство имён, краткое описание и обзор.",
|
||||
"historyUnavailable": "Не удалось загрузить описание опубликованной версии. Повторите загрузку перед отправкой."
|
||||
},
|
||||
"result": {
|
||||
"submitting": {
|
||||
"title": "Отправка этой версии",
|
||||
"description": "Отправляется {{skill}} {{version}}. Подождите.",
|
||||
"button": "Отправка…"
|
||||
},
|
||||
"version-conflict": {
|
||||
"title": "Версия {{version}} уже существует",
|
||||
"description": "Введённое описание и выбранные источники сохранены. Выберите пакет с новым номером версии, проверьте данные и отправьте его."
|
||||
},
|
||||
"unknown": {
|
||||
"title": "Результат отправки неизвестен",
|
||||
"description": "Перед повторной отправкой проверьте в разделе «Мои навыки», была ли создана эта версия."
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "Недостаточно прав для отправки",
|
||||
"description": "Введённое описание сохранено. Проверьте текущие права в пространстве имён."
|
||||
},
|
||||
"invalid": {
|
||||
"title": "Пакет не принят",
|
||||
"description": "Введённое описание сохранено. Исправьте структуру пакета и заполненные поля, затем проверьте их снова."
|
||||
},
|
||||
"failed": {
|
||||
"title": "Отправка не завершена",
|
||||
"description": "Введённое описание сохранено. Устраните причину ошибки и вернитесь к форме."
|
||||
}
|
||||
},
|
||||
"accepted": {
|
||||
"SCANNING": {
|
||||
"title": "Отправлено · проверка безопасности",
|
||||
"description": "Эта версия ещё не опубликована. После проверки дальнейшие действия зависят от настроек платформы."
|
||||
},
|
||||
"PENDING_REVIEW": {
|
||||
"title": "Отправлено · ожидает проверки",
|
||||
"description": "Эта версия ещё не опубликована. Следите за проверкой в разделе «Мои навыки»."
|
||||
},
|
||||
"UPLOADED": {
|
||||
"title": "Загружено · ещё не опубликовано",
|
||||
"description": "В разделе «Мои навыки» подтвердите публикацию или отправьте версию на проверку в соответствии с доступными действиями."
|
||||
},
|
||||
"PUBLISHED": {
|
||||
"title": "Эта версия опубликована",
|
||||
"description": "Откройте страницу этой версии, чтобы проверить опубликованное содержимое."
|
||||
},
|
||||
"SCAN_FAILED": {
|
||||
"title": "Не удалось выполнить проверку безопасности",
|
||||
"description": "Проверка не завершена; это не означает, что содержимое признано вредоносным. Откройте сведения о версии и проверьте причину сбоя."
|
||||
},
|
||||
"REJECTED": {
|
||||
"title": "Эта версия отклонена",
|
||||
"description": "Изучите замечания и отправьте исправленный пакет с новым номером версии."
|
||||
},
|
||||
"YANKED": {
|
||||
"title": "Эта версия отозвана",
|
||||
"description": "Проверьте текущее состояние и доступные действия на странице версии."
|
||||
},
|
||||
"DRAFT": {
|
||||
"title": "Эта версия является черновиком",
|
||||
"description": "Эта версия ещё не опубликована."
|
||||
},
|
||||
"unrecognized": {
|
||||
"title": "Получен ответ на отправку",
|
||||
"description": "Проверьте фактическое состояние этой версии в разделе «Мои навыки»."
|
||||
}
|
||||
},
|
||||
"previousPublished": "Ранее опубликованная версия остаётся видимой в пределах действующих прав доступа.",
|
||||
"viewVersion": "Посмотреть эту версию",
|
||||
"mySkills": "Мои навыки",
|
||||
"replacePackage": "Заменить пакет версии",
|
||||
"checkMySkills": "Проверить мои навыки",
|
||||
"returnToForm": "Вернуться к форме",
|
||||
"receiptHint": "Здесь показан ответ на отправку. Актуальное состояние доступно на странице этой версии."
|
||||
}
|
||||
},
|
||||
"ratingInput": {
|
||||
"yourRating": "Ваша оценка: {{score}} зв."
|
||||
|
|
@ -2565,10 +2728,19 @@
|
|||
"loading": "Загрузка..."
|
||||
},
|
||||
"upload": {
|
||||
"dropHint": "Отпустите для загрузки...",
|
||||
"dragHint": "Перетащите ZIP-файл сюда или нажмите, чтобы выбрать",
|
||||
"formatHint": "Поддерживается только формат .zip",
|
||||
"folderHint": "Или выберите папку для упаковки и загрузки"
|
||||
"dropHint": "Отпустите, чтобы прочитать ZIP-файл",
|
||||
"dragHint": "Перетащите сюда ZIP-файл",
|
||||
"formatHint": "Поддерживаются только файлы .zip",
|
||||
"folderHint": "Выбрать папку",
|
||||
"selectZip": "Выбрать ZIP-файл",
|
||||
"folderPackagingHint": "Папка будет упакована в ZIP перед обработкой.",
|
||||
"packaging": "Упаковка выбранной папки…",
|
||||
"errors": {
|
||||
"singleZip": "Выбирайте только один ZIP-файл за раз.",
|
||||
"zipRequired": "Выберите пакет скилла в формате .zip.",
|
||||
"folderDropUnsupported": "Используйте «Выбрать папку» или сначала упакуйте папку в ZIP.",
|
||||
"emptySelection": "Файлы не найдены. Выберите снова."
|
||||
}
|
||||
},
|
||||
"systemConfig": {
|
||||
"intro": "Настройте способы входа и роли для новых внешних учётных записей.",
|
||||
|
|
|
|||
|
|
@ -1464,7 +1464,23 @@
|
|||
"accessDenied": "访问被拒绝",
|
||||
"accessDeniedDesc": "您没有权限查看该技能",
|
||||
"tabReadme": "README",
|
||||
"tabOverview": "概览",
|
||||
"tabOverview": "概述",
|
||||
"versionLoadError": "版本内容加载失败,请重试。",
|
||||
"versionLoading": "正在加载版本内容…",
|
||||
"tabInstructions": "技能指令",
|
||||
"instructionsError": "指令加载失败,请重试。",
|
||||
"instructionsRetry": "重试",
|
||||
"instructionsLoading": "正在加载技能指令…",
|
||||
"instructionsMissing": "此版本未包含 SKILL.md。",
|
||||
"instructionsRendered": "阅读视图",
|
||||
"instructionsSource": "查看源码",
|
||||
"versionUnavailable": "版本不可用或无权访问:{{version}}",
|
||||
"viewingUnpublishedVersion": "正在预览未发布版本 v{{version}}",
|
||||
"viewingHistoricalVersion": "正在查看历史版本 v{{version}}",
|
||||
"viewingVersion": "正在查看 v{{version}}",
|
||||
"returnLatestPublished": "返回最新已发布版",
|
||||
"viewingThisVersion": "当前查看",
|
||||
"viewThisVersion": "查看此版本",
|
||||
"tabFiles": "文件",
|
||||
"tabVersions": "版本",
|
||||
"noReadme": "暂无 README",
|
||||
|
|
@ -1971,10 +1987,19 @@
|
|||
"saveErrorTitle": "更新命名空间失败"
|
||||
},
|
||||
"upload": {
|
||||
"dropHint": "放开以上传文件...",
|
||||
"dragHint": "拖拽 ZIP 文件到此处,或点击选择",
|
||||
"dropHint": "松开以读取 ZIP 文件",
|
||||
"dragHint": "将 ZIP 文件拖到这里",
|
||||
"formatHint": "仅支持 .zip 格式",
|
||||
"folderHint": "或选择文件夹,自动打包上传"
|
||||
"folderHint": "选择文件夹",
|
||||
"selectZip": "选择 ZIP 文件",
|
||||
"folderPackagingHint": "文件夹将打包为 ZIP,再继续处理。",
|
||||
"packaging": "正在打包所选文件夹…",
|
||||
"errors": {
|
||||
"singleZip": "每次只能选择一个 ZIP 文件。",
|
||||
"zipRequired": "请选择 .zip 格式的技能包。",
|
||||
"folderDropUnsupported": "请使用“选择文件夹”入口,或先将文件夹压缩为 ZIP。",
|
||||
"emptySelection": "未读取到文件,请重新选择。"
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"openNavigation": "打开导航菜单",
|
||||
|
|
@ -2244,11 +2269,28 @@
|
|||
"copyright": "© 2026 SkillHub. Apache-2.0."
|
||||
},
|
||||
"publish": {
|
||||
"packageFormatHint": "ZIP 根目录需包含 SKILL.md",
|
||||
"form": {
|
||||
"identity": "基本信息",
|
||||
"nameHelp": "默认读取包内名称;留空时使用技能包名称。",
|
||||
"presentation": "展示介绍",
|
||||
"presentationHelp": "面向使用者说明用途、输入、输出和适用限制。",
|
||||
"customized": "本次版本已修改",
|
||||
"packageContent": "来自当前技能包",
|
||||
"restore": "恢复包内内容",
|
||||
"restoreTitle": "恢复包内{{field}}?",
|
||||
"restoreDescription": "将使用当前技能包的内容,替换本次手动编辑的内容。技能包文件本身不会改变。",
|
||||
"confirmHelp": "提交前将再次核对版本与展示信息。"
|
||||
},
|
||||
"retryPackagePreview": "重试",
|
||||
"packageName": "包内名称",
|
||||
"displayName": "显示名称",
|
||||
"displayNamePlaceholder": "例如:会议纪要助手",
|
||||
"displayNameHelp": "支持中文,最多 200 个字符。默认读取包内名称;修改后随本次版本审核,不改变技能标识或包内文件。留空则使用包内名称。",
|
||||
"shortDescription": "简短描述",
|
||||
"overview": "详细概览(Markdown)",
|
||||
"presentationInheritance": "本次未填写网页或包内展示值时,将在提交时继承已发布版本 {{version}} 的自定义值。",
|
||||
"resetPresentation": "恢复包内默认值(不继承)",
|
||||
"readingPackage": "正在读取技能包信息…",
|
||||
"examplesReady": "已识别 {{count}} 条使用案例(包含全部语种)。",
|
||||
"examplesNone": "未提供使用案例,可正常发布和使用。",
|
||||
|
|
@ -2258,7 +2300,7 @@
|
|||
"subtitle": "上传技能包到 SkillHub",
|
||||
"reviewNotice": {
|
||||
"title": "发布审核说明",
|
||||
"description": "技能包提交后需要经过管理员审核才能正式发布。"
|
||||
"description": "提交后的处理取决于可见性和安全扫描策略。请以本次提交回执及版本状态为准。"
|
||||
},
|
||||
"resubmitNotice": {
|
||||
"title": "重新提交 {{skill}} v{{version}}",
|
||||
|
|
@ -2302,7 +2344,137 @@
|
|||
"updateSubtitle": "头像可单独保存;上传技能包将发布新版本。",
|
||||
"optionalPackage": "仅更新头像时无需上传技能包。",
|
||||
"saveAvatar": "保存头像",
|
||||
"avatarSaved": "头像修改已提交"
|
||||
"avatarSaved": "头像修改已提交",
|
||||
"overviewPreview": "概述预览",
|
||||
"editOverview": "编辑概述",
|
||||
"emptyOverview": "暂无可预览内容",
|
||||
"emptyOverviewHelp": "尚未读取到可用说明。可以编辑概述,补充用途、输入、输出与适用限制。",
|
||||
"settings": "发布设置",
|
||||
"cardPreview": "使用者卡片预览",
|
||||
"confirmDetails": "确认提交信息",
|
||||
"backToEdit": "返回编辑",
|
||||
"readingOverview": "正在读取包内说明…",
|
||||
"overviewPreviewUnavailable": "无法预览包内说明。可以编辑概述或重新选择技能包;提交仍由服务端校验。",
|
||||
"stepsLabel": "发布步骤",
|
||||
"stepUpload": "上传技能包",
|
||||
"stepPresentation": "完善展示信息",
|
||||
"stepConfirm": "确认提交",
|
||||
"nextConfirm": "下一步:确认",
|
||||
"packageParsed": "结构已解析",
|
||||
"replacePackage": "替换",
|
||||
"editor": {
|
||||
"title": "编辑详细概述",
|
||||
"description": "编写用途、输入、输出与适用限制。",
|
||||
"visual": "可视化",
|
||||
"source": "Markdown 源码",
|
||||
"preview": "发布预览",
|
||||
"sourceOnly": "源码",
|
||||
"split": "分栏",
|
||||
"unsaved": "未保存修改",
|
||||
"unchanged": "无未保存修改",
|
||||
"apply": "完成编辑",
|
||||
"cancel": "取消修改",
|
||||
"returnHint": "完成编辑后更新表单预览;不会提交审核。",
|
||||
"discardTitle": "放弃本次修改?",
|
||||
"discardDescription": "尚未应用的概述修改将被丢弃。",
|
||||
"discard": "放弃修改",
|
||||
"keepEditing": "继续编辑",
|
||||
"sourceRecovery": "此内容暂不支持可视化编辑,原文已保留。请切换至 Markdown 源码继续编辑。",
|
||||
"tooLong": "概述不能超过 {{max}} 字符。",
|
||||
"loading": "正在加载编辑器…",
|
||||
"loadFailed": "编辑器加载失败,请使用 Markdown 源码继续编辑。",
|
||||
"insertImage": "插入图片",
|
||||
"imageUrl": "图片 HTTPS 地址",
|
||||
"imageAlt": "图片说明",
|
||||
"insert": "插入",
|
||||
"invalidImageUrl": "请输入有效的 HTTPS 图片地址。",
|
||||
"insertMore": "插入",
|
||||
"visualUnavailable": "此内容包含暂不支持的富文本语法,请使用 Markdown 源码编辑,原文会完整保留。",
|
||||
"markdownLabel": "概述 Markdown 源码"
|
||||
},
|
||||
"source": {
|
||||
"package": "使用技能包",
|
||||
"custom": "自定义",
|
||||
"view": "查看完整",
|
||||
"shortRequired": "请提供非空且不超过200字符的简要描述,可使用技能包或自定义填写。",
|
||||
"overviewRequired": "请提供非空且不超过20,000字符的详细概述;包内内容过长时可选择自定义整理。",
|
||||
"requiredSummary": "还有必填信息未完成,请核对技能包、命名空间和两个描述。",
|
||||
"historyUnavailable": "无法读取已发布版本的介绍,请重试后提交,避免覆盖已有内容。"
|
||||
},
|
||||
"result": {
|
||||
"submitting": {
|
||||
"title": "正在提交本次版本",
|
||||
"description": "正在发送 {{skill}} {{version}},请稍候。",
|
||||
"button": "正在提交…"
|
||||
},
|
||||
"version-conflict": {
|
||||
"title": "版本 {{version}} 已存在",
|
||||
"description": "本次介绍已保留。请更换新版本号的技能包,重新核对后提交。"
|
||||
},
|
||||
"unknown": {
|
||||
"title": "暂时无法确认提交结果",
|
||||
"description": "请先查看我的技能核对本次版本,不要直接重复提交。"
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "无权限提交",
|
||||
"description": "已保留本次介绍,请核对权限或技能包后再处理。"
|
||||
},
|
||||
"invalid": {
|
||||
"title": "技能包校验失败",
|
||||
"description": "已保留本次介绍,请核对权限或技能包后再处理。"
|
||||
},
|
||||
"failed": {
|
||||
"title": "提交失败",
|
||||
"description": "已保留本次介绍,请核对权限或技能包后再处理。"
|
||||
}
|
||||
},
|
||||
"accepted": {
|
||||
"SCANNING": {
|
||||
"title": "已提交,等待安全扫描",
|
||||
"description": "本次版本尚未发布,扫描后将按平台配置处理。"
|
||||
},
|
||||
"PENDING_REVIEW": {
|
||||
"title": "已提交,等待审核",
|
||||
"description": "本次版本尚未发布,请在我的技能跟踪审核进度。"
|
||||
},
|
||||
"UPLOADED": {
|
||||
"title": "已上传,尚未发布",
|
||||
"description": "按现有可见范围流程确认发布或提交审核。"
|
||||
},
|
||||
"PUBLISHED": {
|
||||
"title": "本次版本已发布",
|
||||
"description": "可查看本次发布版本。"
|
||||
},
|
||||
"SCAN_FAILED": {
|
||||
"title": "扫描执行失败",
|
||||
"description": "这不表示已判定有害,请查看版本状态并按页面提示处理。"
|
||||
},
|
||||
"REJECTED": {
|
||||
"title": "本次版本已被拒绝",
|
||||
"description": "请查看审核意见后提交新版本。"
|
||||
},
|
||||
"YANKED": {
|
||||
"title": "本次版本已撤下",
|
||||
"description": "请查看版本详情核对当前状态。"
|
||||
},
|
||||
"DRAFT": {
|
||||
"title": "本次版本为草稿",
|
||||
"description": "本次版本尚未发布。"
|
||||
},
|
||||
"unrecognized": {
|
||||
"title": "已收到提交响应",
|
||||
"description": "请查看我的技能核对本次版本的实际状态。"
|
||||
}
|
||||
},
|
||||
"previousPublished": "已有已发布版本继续按原权限展示旧内容。",
|
||||
"viewVersion": "查看本次版本",
|
||||
"mySkills": "返回我的技能",
|
||||
"replacePackage": "返回更换版本包",
|
||||
"checkMySkills": "查看我的技能核对",
|
||||
"returnToForm": "返回继续编辑",
|
||||
"receiptHint": "这是本次提交的返回结果,最新进度请进入本次版本查看。"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"success": "成功",
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const mocks = vi.hoisted(() => ({ publish: vi.fn(), navigate: vi.fn(), submit: vi.fn(), remove: vi.fn(), refresh: vi.fn(), ownerReadOnly: false, search: { namespace: 'team', visibility: 'PUBLIC', updateSkill: 'foo', resubmitSkill: '' } }))
|
||||
vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: mocks.refresh }), useQuery: ({ queryKey, enabled }: { queryKey: unknown[]; enabled?: boolean }) => queryKey[0] === 'skill-package-preview'
|
||||
? { data: enabled ? { name: 'foo', displayName: 'Foo', version: '1.1' } : undefined, isSuccess: !!enabled, isPending: false, isError: false }
|
||||
? { data: enabled ? { name: 'foo', displayName: 'Foo', version: '1.1', description:'Summary', overview:'# Overview' } : undefined, isSuccess: !!enabled, isPending: false, isError: false }
|
||||
: { data: { avatarUrl: '/owner-active' }, isLoading: false, isError: false } }))
|
||||
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => mocks.navigate, useSearch: () => mocks.search }))
|
||||
vi.mock('react-i18next', async () => ({ ...await vi.importActual<typeof import('react-i18next')>('react-i18next'), useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
||||
useSkillVersionDetail: () => ({data:undefined,isLoading:false}),
|
||||
usePublishSkill: () => ({ mutateAsync: mocks.publish, isPending: false }),
|
||||
useSkillDetail: () => ({ data: { displayName: 'Foo', avatarUrl: '/active', ownerReadOnly: mocks.ownerReadOnly }, isLoading: false, isError: false }),
|
||||
}))
|
||||
|
|
@ -25,6 +26,11 @@ vi.mock('@/shared/lib/navigate-after-overlays', () => ({ navigateAfterOverlays:
|
|||
vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/features/skill/skill-avatar-manager', () => ({ SkillAvatarManager: () => null }))
|
||||
vi.mock('@/api/client', async () => ({ ...await vi.importActual<typeof import('@/api/client')>('@/api/client'), skillAvatarApi: { submit: mocks.submit, delete: mocks.remove } }))
|
||||
function submitPackage() {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.nextConfirm' }))
|
||||
const confirmation = screen.queryByRole('dialog', { name: 'publish.confirmDetails' })
|
||||
if (confirmation) fireEvent.click(Array.from(confirmation.querySelectorAll('button')).find(button => button.textContent === 'publish.confirm')!)
|
||||
}
|
||||
import { PublishPage } from './publish'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
|
|
@ -41,10 +47,10 @@ describe('Publish avatar submission', () => {
|
|||
expect(avatar.getAttribute('data-analytics-id')).toBe('skill.avatar.save')
|
||||
expect(document.querySelector('[data-analytics-id="skill.publish.request"]')).toBeNull()
|
||||
fireEvent.click(screen.getByText('choose package'))
|
||||
const publish = screen.getByRole('button', { name: 'publish.confirm' })
|
||||
const publish = screen.getByRole('button', { name: 'publish.nextConfirm' })
|
||||
expect(publish.getAttribute('data-analytics-id')).toBe('skill.publish.request')
|
||||
expect(document.querySelector('[data-analytics-id="skill.avatar.save"]')).toBeNull()
|
||||
fireEvent.click(publish)
|
||||
submitPackage()
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledTimes(1))
|
||||
const confirm = await screen.findByRole('button', { name: 'publish.warningConfirmContinue' })
|
||||
expect(confirm.getAttribute('data-analytics-id')).toBe('skill.publish.confirm')
|
||||
|
|
@ -61,7 +67,8 @@ describe('Publish avatar submission', () => {
|
|||
fireEvent.click(screen.getByText('image uploaded'))
|
||||
expect(screen.getByRole('button', { name: 'publish.saveAvatar' }).hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.click(screen.getByText('choose package'))
|
||||
expect(screen.getByRole('button', { name: 'publish.confirm' }).hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.click(screen.getByRole('button', {name:'publish.nextConfirm'}))
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
expect(mocks.submit).not.toHaveBeenCalled()
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
|
@ -70,7 +77,7 @@ describe('Publish avatar submission', () => {
|
|||
mocks.publish.mockRejectedValueOnce(new ApiError('Pre-publish warnings require confirmation before publishing: synthetic warning', 400))
|
||||
render(<PublishPage />)
|
||||
fireEvent.click(screen.getByText('choose package'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
submitPackage()
|
||||
const cancel = await screen.findByRole('button', { name: 'publish.warningConfirmCancel' })
|
||||
expect(cancel.getAttribute('data-analytics-id')).toBe('skill.publish.cancel')
|
||||
expect(screen.getByRole('button', { name: 'Close' }).getAttribute('data-analytics-id')).toBe('skill.publish.cancel')
|
||||
|
|
@ -99,10 +106,10 @@ describe('Publish avatar submission', () => {
|
|||
render(<PublishPage />)
|
||||
fireEvent.click(screen.getByText('choose package'))
|
||||
fireEvent.click(screen.getByText('image pending'))
|
||||
expect(screen.getByRole('button', { name: 'publish.confirm' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: 'publish.nextConfirm' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByText('image uploaded'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
submitPackage()
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(expect.objectContaining({ avatarAction: 'REPLACE', avatarUploadId: 'private-reference', expectedSlug: 'foo' })))
|
||||
})
|
||||
|
||||
|
|
@ -110,11 +117,16 @@ describe('Publish avatar submission', () => {
|
|||
mocks.publish.mockRejectedValueOnce(new Error('package invalid')).mockResolvedValueOnce({ namespace: 'team', slug: 'foo', version: '1.1', status: 'PUBLISHED' })
|
||||
render(<PublishPage />)
|
||||
fireEvent.click(screen.getByText('choose package'))
|
||||
fireEvent.click(screen.getByText('image uploaded'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
const avatarControl = screen.getByText('image uploaded')
|
||||
fireEvent.click(avatarControl)
|
||||
submitPackage()
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.navigate).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
await screen.findByRole('heading', {name:'publish.result.unknown.title'})
|
||||
fireEvent.click(screen.getByRole('button', {name:'publish.result.checkMySkills'}))
|
||||
fireEvent.click(screen.getByRole('button', {name:'publish.result.returnToForm'}))
|
||||
expect(screen.getByText('image uploaded')).toBe(avatarControl)
|
||||
submitPackage()
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledTimes(2))
|
||||
expect(mocks.publish.mock.calls[1][0]).toEqual(mocks.publish.mock.calls[0][0])
|
||||
})
|
||||
|
|
@ -125,7 +137,7 @@ describe('Publish avatar submission', () => {
|
|||
fireEvent.click(screen.getByText('remove draft'))
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByText('choose package'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
submitPackage()
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(expect.objectContaining({ avatarAction: 'REMOVE', expectedSlug: 'foo' })))
|
||||
})
|
||||
|
||||
|
|
@ -133,7 +145,7 @@ describe('Publish avatar submission', () => {
|
|||
mocks.publish.mockResolvedValue({ namespace: 'team', slug: 'foo', version: '1.1', status: 'PENDING_REVIEW' })
|
||||
render(<PublishPage />)
|
||||
fireEvent.click(screen.getByText('choose package'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
submitPackage()
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(expect.objectContaining({ avatarAction: 'KEEP', avatarUploadId: undefined, expectedSlug: 'foo' })))
|
||||
})
|
||||
it('clears the avatar draft when navigating to another skill in the same namespace', async () => {
|
||||
|
|
@ -143,7 +155,8 @@ describe('Publish avatar submission', () => {
|
|||
fireEvent.click(screen.getByText('remove draft'))
|
||||
mocks.search.updateSkill = 'bar'
|
||||
view.rerender(<PublishPage />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
fireEvent.click(screen.getByText('choose package'))
|
||||
submitPackage()
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(expect.objectContaining({ avatarAction: 'KEEP', avatarUploadId: undefined, expectedSlug: 'bar' })))
|
||||
})
|
||||
|
||||
|
|
@ -205,7 +218,8 @@ describe('Publish avatar submission', () => {
|
|||
if (mode === 'resubmit') mocks.search.resubmitSkill = 'foo'
|
||||
render(<PublishPage />)
|
||||
fireEvent.click(screen.getByText('image uploaded'))
|
||||
expect(screen.getByRole('button', { name: 'publish.confirm' }).hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.click(screen.getByRole('button', {name:'publish.nextConfirm'}))
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
expect(mocks.submit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,40 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { fireEvent, render, screen, waitFor, cleanup } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor, cleanup, within, act } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const preview = vi.hoisted(() => vi.fn())
|
||||
const readOverview = vi.hoisted(() => vi.fn())
|
||||
const zipFolder = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/features/publish/folder-zip', () => ({ packageFolderAsZip: zipFolder }))
|
||||
vi.mock('@/features/publish/package-overview', () => ({ readPackageOverview: readOverview }))
|
||||
const snapshots = vi.hoisted(() => ({ values: {} as Record<string, {version:string;parsedMetadataJson:string} | undefined> }))
|
||||
const publish = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), useSearch: () => ({ namespace: 'team-ai' }) }))
|
||||
const permissions = vi.hoisted(() => ({ ownerReadOnly: false }))
|
||||
const searchParams = vi.hoisted(() => ({ value: { namespace: 'team-ai', updateSkill: undefined as string | undefined } }))
|
||||
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), useSearch: () => searchParams.value }))
|
||||
vi.mock('react-i18next', async () => ({ ...await vi.importActual<typeof import('react-i18next')>('react-i18next'), useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({ useSkillDetail: () => ({ data: undefined }), previewSkillPackage: preview, usePublishSkill: () => ({ mutateAsync: publish, isPending: false }) }))
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({ useSkillDetail: (_namespace: string, slug: string) => ({ data: slug ? {publishedVersion:{version:'1.0.0'},ownerReadOnly:permissions.ownerReadOnly} : undefined }), useSkillVersionDetail: (namespace: string, slug: string) => ({ data: snapshots.values[`${namespace}/${slug}`] ?? snapshots.values[slug], isLoading: false }), previewSkillPackage: preview, usePublishSkill: () => ({ mutateAsync: publish, isPending: false }) }))
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({ useMyNamespaces: () => ({ data: [{ id: 1, slug: 'team-ai', displayName: 'Team' }], isLoading: false }) }))
|
||||
vi.mock('@/features/publish/upload-zone', () => ({ UploadZone: ({ onFileSelect }: { onFileSelect: (file: File) => void }) => <input aria-label="package" type="file" onChange={e => { if (e.target.files?.[0]) onFileSelect(e.target.files[0]) }} /> }))
|
||||
vi.mock('@/features/publish/upload-zone', () => ({ UploadZone: ({ onFileSelect, onFolderSelect, previewError }: { onFileSelect: (file: File) => void; onFolderSelect: (files: File[]) => void; previewError?: string }) => <><button onClick={() => onFolderSelect([new File(['folder'], 'SKILL.md')])}>select-folder</button><input aria-label="package" type="file" onChange={e => { if (e.target.files?.[0]) onFileSelect(e.target.files[0]) }} />{previewError && <p role="alert">{previewError}</p>}</> }))
|
||||
vi.mock('@/features/skill/skill-avatar-editor', () => ({ KEEP_AVATAR: { action: 'KEEP', ready: true }, SkillAvatarEditor: () => null }))
|
||||
vi.mock('@/features/skill/skill-avatar-manager', () => ({ SkillAvatarManager: () => null }))
|
||||
vi.mock('@/api/client', async (importOriginal) => ({ ...await importOriginal<typeof import('@/api/client')>(), skillAvatarApi: { status: vi.fn().mockResolvedValue({ action: 'KEEP' }) } }))
|
||||
vi.mock('@/shared/lib/navigate-after-overlays', () => ({ navigateAfterOverlays: (action: () => void) => action() }))
|
||||
vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
function submitPackage() {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.nextConfirm' }))
|
||||
const confirmation = screen.queryByRole('dialog', { name: 'publish.confirmDetails' })
|
||||
if (confirmation) fireEvent.click(Array.from(confirmation.querySelectorAll('button')).find(button => button.textContent === 'publish.confirm')!)
|
||||
}
|
||||
vi.mock('@/features/publish/presentation-editor', () => ({ PresentationEditor: ({ open, value, onApply, onCancel }: { open: boolean; value: string; onApply: (value: string) => void; onCancel: () => void }) => open ? <div><textarea aria-label="overview-draft" defaultValue={value} /><button onClick={() => onApply((document.querySelector('[aria-label="overview-draft"]') as HTMLTextAreaElement).value)}>apply-overview</button><button onClick={onCancel}>cancel-overview</button></div> : null }))
|
||||
import { PublishPage } from './publish'
|
||||
|
||||
function restoreField(index: number) {
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'publish.form.restore' })[index])
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'publish.form.restore' }))
|
||||
}
|
||||
|
||||
function mount() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
|
||||
|
|
@ -27,7 +46,109 @@ function select(name: string) {
|
|||
const metadata = (name: string) => ({ name: 'stable-slug', displayName: name, version: '1.0.0', description: 'summary' })
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => { preview.mockReset(); publish.mockReset(); publish.mockResolvedValue({ namespace: 'team-ai', slug: 'stable-slug', version: '1.0.0', status: 'PENDING_REVIEW' }) })
|
||||
beforeEach(() => { permissions.ownerReadOnly = false; searchParams.value.namespace = 'team-ai'; zipFolder.mockReset(); snapshots.values = {}; readOverview.mockReset(); readOverview.mockResolvedValue({ content: '# Default package documentation', source: 'skill' }); searchParams.value.updateSkill = undefined; preview.mockReset(); publish.mockReset(); publish.mockResolvedValue({ namespace: 'team-ai', slug: 'stable-slug', version: '1.0.0', status: 'PENDING_REVIEW' }) })
|
||||
|
||||
describe('web presentation publishing', () => {
|
||||
it('prefills package presentation without sending unedited defaults', async () => {
|
||||
preview.mockResolvedValue({ ...metadata('Package title'), shortDescription: 'Package short', overview: '# Package overview' })
|
||||
mount(); select('presentation.zip')
|
||||
await waitFor(() => expect((screen.getByLabelText('publish.shortDescription') as HTMLInputElement).value).toBe('Package short'))
|
||||
expect(screen.getByRole('heading', { name: 'Package overview' })).toBeTruthy()
|
||||
expect(screen.queryByRole('textbox', { name: 'publish.overview' })).toBeNull()
|
||||
submitPackage()
|
||||
await waitFor(() => expect(publish).toHaveBeenCalledWith(expect.objectContaining({
|
||||
shortDescription: undefined, overview: undefined, resetPresentation: true,
|
||||
})))
|
||||
})
|
||||
|
||||
it('keeps the form overview read-only and applies no changes when editing is cancelled', async () => {
|
||||
preview.mockResolvedValue({ ...metadata('Title'), overview: '# Original overview' })
|
||||
mount(); select('presentation.zip')
|
||||
await screen.findByRole('heading', { name: 'Original overview' })
|
||||
expect(screen.queryByRole('textbox', { name: 'publish.overview' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.editOverview' }))
|
||||
fireEvent.change(screen.getByLabelText('overview-draft'), { target: { value: '# Discard this' } })
|
||||
fireEvent.click(screen.getByText('cancel-overview'))
|
||||
expect(screen.getByRole('heading', { name: 'Original overview' })).toBeTruthy()
|
||||
expect(publish).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.nextConfirm' }))
|
||||
expect(publish).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.backToEdit' }))
|
||||
expect(publish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks over-limit presentation data returned by package preview', async () => {
|
||||
preview.mockResolvedValue({ ...metadata('Title'), shortDescription: 'x'.repeat(201), overview: 'x'.repeat(20001) })
|
||||
mount(); select('long.zip')
|
||||
await waitFor(() => expect((screen.getByLabelText('publish.shortDescription') as HTMLTextAreaElement).value.length).toBe(201))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.nextConfirm' })); expect(publish).not.toHaveBeenCalled()
|
||||
expect(publish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads README asynchronously and does not submit an unchanged fallback as an override', async () => {
|
||||
let finish!: (value: { content: string; source: string }) => void
|
||||
readOverview.mockReturnValue(new Promise(resolve => { finish = resolve }))
|
||||
preview.mockResolvedValue(metadata('Title'))
|
||||
mount(); select('fallback.zip')
|
||||
await screen.findByText('publish.readingOverview')
|
||||
expect(screen.getByRole('button', { name: 'publish.editOverview' }).hasAttribute('disabled')).toBe(true)
|
||||
finish({ content: '# README introduction', source: 'readme' })
|
||||
await screen.findByRole('heading', { name: 'README introduction' })
|
||||
submitPackage()
|
||||
await waitFor(() => expect(publish).toHaveBeenCalledWith(expect.objectContaining({ overview: undefined })))
|
||||
})
|
||||
|
||||
it('requires custom content when fallback preview is unavailable', async () => {
|
||||
readOverview.mockResolvedValue({ content: '', source: null, error: 'tooLarge' })
|
||||
preview.mockResolvedValue(metadata('Title'))
|
||||
mount(); select('preview-failure.zip')
|
||||
await screen.findByText('publish.overviewPreviewUnavailable')
|
||||
expect(screen.queryByText('publish.emptyOverview')).toBeNull()
|
||||
submitPackage()
|
||||
expect(publish).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.editOverview' }))
|
||||
fireEvent.change(screen.getByLabelText('overview-draft'), { target: { value: '# Custom explanation' } })
|
||||
fireEvent.click(screen.getByText('apply-overview'))
|
||||
submitPackage()
|
||||
await waitFor(() => expect(publish).toHaveBeenCalledWith(expect.objectContaining({ overview: '# Custom explanation' })))
|
||||
})
|
||||
|
||||
it('places the next action after namespace and visibility settings in document order', async () => {
|
||||
preview.mockResolvedValue(metadata('Title'))
|
||||
mount(); select('package.zip')
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'publish.nextConfirm' }).hasAttribute('disabled')).toBe(false))
|
||||
const next = screen.getByRole('button', { name: 'publish.nextConfirm' })
|
||||
for (const id of ['namespace', 'visibility']) {
|
||||
const control = document.getElementById(id)!
|
||||
expect(Boolean(control.compareDocumentPosition(next) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('sends edited presentation and reset intent independently of form defaults', async () => {
|
||||
preview.mockResolvedValue({ ...metadata('Package title'), shortDescription: 'Package short', overview: '# Package overview' })
|
||||
mount(); select('presentation.zip')
|
||||
await waitFor(() => expect((screen.getByLabelText('publish.shortDescription') as HTMLInputElement).value).toBe('Package short'))
|
||||
fireEvent.change(screen.getByLabelText('publish.shortDescription'), { target: { value: 'Web short' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.editOverview' }))
|
||||
fireEvent.change(screen.getByLabelText('overview-draft'), { target: { value: '# Web overview' } })
|
||||
fireEvent.click(screen.getByText('apply-overview'))
|
||||
submitPackage()
|
||||
await waitFor(() => expect(publish).toHaveBeenCalledWith(expect.objectContaining({
|
||||
shortDescription: 'Web short', overview: '# Web overview', resetPresentation: false,
|
||||
})))
|
||||
})
|
||||
it('restores only the confirmed field without re-sending package defaults', async () => {
|
||||
preview.mockResolvedValue({ ...metadata('Package title'), shortDescription: 'Package short', overview: '# Package overview' })
|
||||
mount(); select('presentation.zip')
|
||||
await screen.findByRole('heading', { name: 'Package overview' })
|
||||
fireEvent.change(screen.getByLabelText('publish.shortDescription'), { target: { value: 'Discard me' } })
|
||||
restoreField(0)
|
||||
expect((screen.getByLabelText('publish.shortDescription') as HTMLTextAreaElement).value).toBe('Package short')
|
||||
submitPackage()
|
||||
await waitFor(() => expect(publish).toHaveBeenCalledWith(expect.objectContaining({ shortDescription: undefined, overview: undefined, resetPresentation: true })))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('web display name publishing', () => {
|
||||
it('prefills from the package and sends the explicit edited name with the original file', async () => {
|
||||
|
|
@ -35,7 +156,7 @@ describe('web display name publishing', () => {
|
|||
mount(); select('one.zip')
|
||||
await waitFor(() => expect((screen.getByLabelText('publish.displayName') as HTMLInputElement).value).toBe('包内名称'))
|
||||
fireEvent.change(screen.getByLabelText('publish.displayName'), { target: { value: '网页名称' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
submitPackage()
|
||||
await waitFor(() => expect(publish).toHaveBeenCalledWith(expect.objectContaining({ displayName: '网页名称', file: expect.objectContaining({ name: 'one.zip' }) })))
|
||||
})
|
||||
|
||||
|
|
@ -53,7 +174,7 @@ describe('web display name publishing', () => {
|
|||
let finishOld!: (value: ReturnType<typeof metadata>) => void
|
||||
preview.mockReturnValueOnce(new Promise(resolve => { finishOld = resolve })).mockResolvedValueOnce(metadata('当前包名称'))
|
||||
mount(); select('old.zip')
|
||||
expect(screen.getByRole('button', { name: 'publish.confirm' }).hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.nextConfirm' })); expect(publish).not.toHaveBeenCalled()
|
||||
await waitFor(() => expect(preview).toHaveBeenCalledTimes(1))
|
||||
select('new.zip')
|
||||
await waitFor(() => expect((screen.getByLabelText('publish.displayName') as HTMLInputElement).value).toBe('当前包名称'))
|
||||
|
|
@ -66,7 +187,7 @@ describe('web display name publishing', () => {
|
|||
preview.mockRejectedValueOnce(new Error('invalid package')).mockResolvedValueOnce(metadata('重试后的名称'))
|
||||
mount(); select('invalid.zip')
|
||||
await screen.findByRole('alert')
|
||||
expect(screen.getByRole('button', { name: 'publish.confirm' }).hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.nextConfirm' })); expect(publish).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.retryPackagePreview' }))
|
||||
await waitFor(() => expect((screen.getByLabelText('publish.displayName') as HTMLInputElement).value).toBe('重试后的名称'))
|
||||
})
|
||||
|
|
@ -79,7 +200,7 @@ describe('examples preflight remains non-blocking', () => {
|
|||
preview.mockResolvedValue({ ...metadata('案例技能'), examplesStatus: status, ...(status === 'invalid' ? {} : { examplesCount: status === 'ready' ? 2 : 0 }) })
|
||||
mount(); select('examples.zip')
|
||||
await screen.findByText(`publish.examples${status[0].toUpperCase()}${status.slice(1)}`)
|
||||
expect(screen.getByRole('button', { name: 'publish.confirm' }).hasAttribute('disabled')).toBe(false)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'publish.nextConfirm' }).hasAttribute('disabled')).toBe(false))
|
||||
})
|
||||
}
|
||||
it('tolerates old preview responses without examples fields', async () => {
|
||||
|
|
@ -87,6 +208,199 @@ describe('examples preflight remains non-blocking', () => {
|
|||
mount(); select('old.zip')
|
||||
await waitFor(() => expect((screen.getByLabelText('publish.displayName') as HTMLInputElement).value).toBe('旧技能'))
|
||||
expect(screen.queryByText(/publish.examples/)).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'publish.confirm' }).hasAttribute('disabled')).toBe(false)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'publish.nextConfirm' }).hasAttribute('disabled')).toBe(false))
|
||||
})
|
||||
})
|
||||
|
||||
describe('implicit presentation editing and resource isolation', () => {
|
||||
it.each([['package','package'],['package','custom'],['custom','package'],['custom','custom']] as const)('submits %s summary with %s overview', async (short, overview) => {
|
||||
preview.mockResolvedValue({...metadata('Title'),shortDescription:'Package summary',overview:'# Package overview'})
|
||||
mount(); select('choices.zip')
|
||||
await screen.findByRole('heading',{name:'Package overview'})
|
||||
if (short === 'custom') fireEvent.change(screen.getByLabelText('publish.shortDescription'),{target:{value:'Edited summary'}})
|
||||
if (overview === 'custom') {
|
||||
fireEvent.click(screen.getByRole('button',{name:'publish.editOverview'}))
|
||||
fireEvent.change(screen.getByLabelText('overview-draft'),{target:{value:'# Edited overview'}})
|
||||
fireEvent.click(screen.getByText('apply-overview'))
|
||||
}
|
||||
submitPackage()
|
||||
await waitFor(() => expect(publish).toHaveBeenCalledWith(expect.objectContaining({shortDescription:short==='custom'?'Edited summary':undefined,overview:overview==='custom'?'# Edited overview':undefined,resetPresentation:short==='package'&&overview==='package'})))
|
||||
})
|
||||
|
||||
it('opening and applying the unchanged package overview does not create an override', async () => {
|
||||
preview.mockResolvedValue({...metadata('Title'),overview:'# Package overview'})
|
||||
mount(); select('unchanged.zip')
|
||||
await screen.findByRole('heading',{name:'Package overview'})
|
||||
fireEvent.click(screen.getByRole('button',{name:'publish.editOverview'}))
|
||||
fireEvent.click(screen.getByText('apply-overview'))
|
||||
expect(screen.queryByRole('button',{name:'publish.form.restore'})).toBeNull()
|
||||
submitPackage()
|
||||
await waitFor(() => expect(publish).toHaveBeenCalledWith(expect.objectContaining({overview:undefined,resetPresentation:true})))
|
||||
})
|
||||
|
||||
it('resets custom drafts when switching to another cached published resource', async () => {
|
||||
snapshots.values.a={version:'1.0.0',parsedMetadataJson:JSON.stringify({shortDescriptionOverride:'A summary',overviewOverride:'# A overview'})}
|
||||
snapshots.values.b={version:'1.0.0',parsedMetadataJson:JSON.stringify({shortDescriptionOverride:'B summary',overviewOverride:'# B overview'})}
|
||||
searchParams.value.updateSkill='a'
|
||||
preview.mockResolvedValue({...metadata('Title'),overview:'# Package overview'})
|
||||
const client=new QueryClient({defaultOptions:{queries:{retry:false}}})
|
||||
const view=render(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
|
||||
await waitFor(() => expect((screen.getByLabelText('publish.shortDescription') as HTMLTextAreaElement).value).toBe('A summary'))
|
||||
fireEvent.change(screen.getByLabelText('publish.shortDescription'),{target:{value:'A edited draft'}})
|
||||
searchParams.value.updateSkill='b'
|
||||
view.rerender(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
|
||||
await waitFor(() => expect((screen.getByLabelText('publish.shortDescription') as HTMLTextAreaElement).value).toBe('B summary'))
|
||||
expect(screen.queryByDisplayValue('A edited draft')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not replace explicit user edits when the published snapshot arrives late', async () => {
|
||||
searchParams.value.updateSkill='late'
|
||||
preview.mockResolvedValue({...metadata('Title'),shortDescription:'Package summary',overview:'# Package overview'})
|
||||
const client=new QueryClient({defaultOptions:{queries:{retry:false}}})
|
||||
const view=render(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
|
||||
select('late.zip')
|
||||
await screen.findByRole('heading',{name:'Package overview'})
|
||||
fireEvent.change(screen.getByLabelText('publish.shortDescription'),{target:{value:'My draft'}})
|
||||
snapshots.values.late={version:'1.0.0',parsedMetadataJson:JSON.stringify({shortDescriptionOverride:'Late custom',overviewOverride:'# Late overview'})}
|
||||
view.rerender(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
|
||||
expect((screen.getByLabelText('publish.shortDescription') as HTMLTextAreaElement).value).toBe('My draft')
|
||||
})
|
||||
|
||||
it('cancels restoration without losing drafts, then follows the replacement package after confirmed restore', async () => {
|
||||
searchParams.value.updateSkill='draft'
|
||||
snapshots.values.draft={version:'1.0.0',parsedMetadataJson:JSON.stringify({shortDescriptionOverride:'Saved custom',overviewOverride:'# Saved overview'})}
|
||||
preview.mockResolvedValue({...metadata('Title'),shortDescription:'Package summary',overview:'# Package overview'})
|
||||
mount(); select('draft.zip')
|
||||
await waitFor(() => expect((screen.getByLabelText('publish.shortDescription') as HTMLTextAreaElement).value).toBe('Saved custom'))
|
||||
fireEvent.change(screen.getByLabelText('publish.shortDescription'),{target:{value:'Edited custom'}})
|
||||
fireEvent.click(screen.getAllByRole('button',{name:'publish.form.restore'})[0])
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button',{name:'dialog.cancel'}))
|
||||
expect((screen.getByLabelText('publish.shortDescription') as HTMLTextAreaElement).value).toBe('Edited custom')
|
||||
restoreField(0)
|
||||
expect((screen.getByLabelText('publish.shortDescription') as HTMLTextAreaElement).value).toBe('Package summary')
|
||||
expect(screen.getByRole('heading',{name:'Saved overview'})).toBeTruthy()
|
||||
preview.mockResolvedValue({...metadata('Next'),shortDescription:'New package summary',overview:'# New package overview'})
|
||||
select('next.zip')
|
||||
await waitFor(() => expect((screen.getByLabelText('publish.shortDescription') as HTMLTextAreaElement).value).toBe('New package summary'))
|
||||
expect(screen.getByRole('heading',{name:'Saved overview'})).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('folder packaging resource boundary', () => {
|
||||
it('ignores a package produced after changing the update target', async () => {
|
||||
let finish!: (value: File) => void
|
||||
zipFolder.mockReturnValue(new Promise(resolve => { finish = resolve }))
|
||||
searchParams.value.updateSkill = 'old'
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const view = render(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
|
||||
fireEvent.click(screen.getByText('select-folder'))
|
||||
await waitFor(() => expect(zipFolder).toHaveBeenCalledTimes(1))
|
||||
searchParams.value.updateSkill = 'new'
|
||||
view.rerender(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
|
||||
finish(new File(['zip'], 'old-resource.zip'))
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(preview).not.toHaveBeenCalled()
|
||||
expect((screen.getByLabelText('publish.displayName') as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('shows a packaging failure beside the upload field without clearing custom introduction', async () => {
|
||||
preview.mockResolvedValue({ ...metadata('Title'), overview: '# Package overview' })
|
||||
zipFolder.mockRejectedValue(new Error('bad folder'))
|
||||
mount(); select('valid.zip')
|
||||
await screen.findByRole('heading', { name: 'Package overview' })
|
||||
fireEvent.change(screen.getByLabelText('publish.shortDescription'), { target: { value: 'Preserved draft' } })
|
||||
fireEvent.click(screen.getByText('select-folder'))
|
||||
await screen.findByText('publish.folderPackagingFailed')
|
||||
expect((screen.getByLabelText('publish.shortDescription') as HTMLTextAreaElement).value).toBe('Preserved draft')
|
||||
expect(preview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('cached and in-flight package resource identity', () => {
|
||||
it('hydrates the new namespace when the previous namespace has a same-slug cached snapshot', async () => {
|
||||
snapshots.values['ns-a/old'] = { version: '1.0.0', parsedMetadataJson: JSON.stringify({ shortDescriptionOverride: 'A old summary', overviewOverride: '# A old overview' }) }
|
||||
snapshots.values['ns-a/shared'] = { version: '1.0.0', parsedMetadataJson: JSON.stringify({ shortDescriptionOverride: 'Wrong namespace summary', overviewOverride: '# Wrong namespace overview' }) }
|
||||
snapshots.values['ns-b/shared'] = { version: '1.0.0', parsedMetadataJson: JSON.stringify({ shortDescriptionOverride: 'B shared summary', overviewOverride: '# B shared overview' }) }
|
||||
searchParams.value.namespace = 'ns-a'
|
||||
searchParams.value.updateSkill = 'old'
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const view = render(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
|
||||
await waitFor(() => expect((screen.getByLabelText('publish.shortDescription') as HTMLTextAreaElement).value).toBe('A old summary'))
|
||||
searchParams.value.namespace = 'ns-b'
|
||||
searchParams.value.updateSkill = 'shared'
|
||||
view.rerender(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
|
||||
await waitFor(() => expect((screen.getByLabelText('publish.shortDescription') as HTMLTextAreaElement).value).toBe('B shared summary'))
|
||||
expect(screen.getByRole('heading', { name: 'B shared overview' })).toBeTruthy()
|
||||
expect(screen.queryByRole('heading', { name: 'Wrong namespace overview' })).toBeNull()
|
||||
preview.mockResolvedValue({ ...metadata('B package title'), overview: '# B package overview' })
|
||||
select('b-package.zip')
|
||||
await waitFor(() => expect((screen.getByLabelText('publish.displayName') as HTMLInputElement).value).toBe('B package title'))
|
||||
submitPackage()
|
||||
await waitFor(() => expect(publish).toHaveBeenCalledWith(expect.objectContaining({ namespace: 'ns-b', shortDescription: 'B shared summary', overview: '# B shared overview' })))
|
||||
})
|
||||
|
||||
it('does not fill a new update target with a late response from the previous package', async () => {
|
||||
let finish!: (value: ReturnType<typeof metadata>) => void
|
||||
preview.mockReturnValue(new Promise(resolve => { finish = resolve }))
|
||||
searchParams.value.updateSkill = 'old'
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const view = render(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
|
||||
select('old-pending.zip')
|
||||
await waitFor(() => expect(preview).toHaveBeenCalledTimes(1))
|
||||
searchParams.value.updateSkill = 'new'
|
||||
view.rerender(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
|
||||
await act(async () => { finish(metadata('Late old package title')) })
|
||||
expect((screen.getByLabelText('publish.displayName') as HTMLInputElement).value).toBe('')
|
||||
expect((screen.getByLabelText('publish.shortDescription') as HTMLTextAreaElement).value).toBe('')
|
||||
expect(screen.queryByText('Late old package title')).toBeNull()
|
||||
expect(publish).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('restore confirmation focus and resource lifecycle', () => {
|
||||
it('returns cancel focus to the restore action and confirm focus to the summary', async () => {
|
||||
preview.mockResolvedValue({ ...metadata('Title'), overview: '# Package overview' })
|
||||
mount(); select('focus.zip')
|
||||
await screen.findByRole('heading', { name: 'Package overview' })
|
||||
fireEvent.change(screen.getByLabelText('publish.shortDescription'), { target: { value: 'Draft summary' } })
|
||||
const trigger = screen.getByRole('button', { name: 'publish.form.restore' })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'dialog.cancel' }))
|
||||
await waitFor(() => expect(document.activeElement).toBe(trigger))
|
||||
expect((screen.getByLabelText('publish.shortDescription') as HTMLTextAreaElement).value).toBe('Draft summary')
|
||||
restoreField(0)
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('publish.shortDescription')))
|
||||
})
|
||||
|
||||
it('returns confirmed overview restoration focus to its fixed label', async () => {
|
||||
preview.mockResolvedValue({ ...metadata('Title'), overview: '# Package overview' })
|
||||
mount(); select('overview-focus.zip')
|
||||
await screen.findByRole('heading', { name: 'Package overview' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.editOverview' }))
|
||||
fireEvent.change(screen.getByLabelText('overview-draft'), { target: { value: '# Custom overview' } })
|
||||
fireEvent.click(screen.getByText('apply-overview'))
|
||||
restoreField(0)
|
||||
await waitFor(() => expect(document.activeElement).toBe(document.getElementById('skill-overview-label')))
|
||||
expect(screen.getByRole('heading', { name: 'Package overview' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each(['readonly', 'route'])('closes restoration when %s changes', async mode => {
|
||||
searchParams.value.updateSkill = 'before'
|
||||
preview.mockResolvedValue({ ...metadata('Title'), overview: '# Package overview' })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const view = render(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
|
||||
select('closing.zip')
|
||||
await screen.findByRole('heading', { name: 'Package overview' })
|
||||
fireEvent.change(screen.getByLabelText('publish.shortDescription'), { target: { value: 'Before draft' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.form.restore' }))
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
if (mode === 'readonly') permissions.ownerReadOnly = true
|
||||
else searchParams.value.updateSkill = 'after'
|
||||
view.rerender(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
expect(publish).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ vi.mock('@/api/client', async () => ({
|
|||
}))
|
||||
vi.mock('@/shared/hooks/use-skill-queries', async () => ({
|
||||
...await vi.importActual<typeof import('@/shared/hooks/use-skill-queries')>('@/shared/hooks/use-skill-queries'),
|
||||
previewSkillPackage: vi.fn().mockResolvedValue({ name: 'foo', displayName: 'Foo', version: '1.1' }),
|
||||
useSkillVersionDetail: () => ({ data: undefined, isLoading: false }), previewSkillPackage: vi.fn().mockResolvedValue({ name: 'foo', displayName: 'Foo', version: '1.1', description:'Summary', overview:'# Overview' }),
|
||||
usePublishSkill: () => ({ mutateAsync: api.publish, isPending: false }),
|
||||
}))
|
||||
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => api.navigate, useSearch: () => api.search }))
|
||||
|
|
@ -36,13 +36,15 @@ describe.each(['update', 'resubmit'])('%s package rejection refreshes current pe
|
|||
render(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
|
||||
await waitFor(() => expect(api.detail).toHaveBeenCalledTimes(1))
|
||||
fireEvent.click(screen.getByText('choose package'))
|
||||
const submit = screen.getByRole('button', { name: 'publish.confirm' })
|
||||
const submit = screen.getByRole('button', { name: 'publish.nextConfirm' })
|
||||
await waitFor(() => expect(submit.hasAttribute('disabled')).toBe(false))
|
||||
fireEvent.click(submit)
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
await screen.findByRole('heading', {name:'publish.result.error.invalid.title'})
|
||||
fireEvent.click(screen.getByRole('button', {name:'publish.result.returnToForm'}))
|
||||
expect(api.detail).toHaveBeenCalledTimes(2)
|
||||
expect(api.publish).toHaveBeenCalledTimes(1)
|
||||
expect(submit.hasAttribute('disabled')).toBe(ownerReadOnly)
|
||||
expect(screen.getByRole('button', {name:'publish.nextConfirm'}).hasAttribute('disabled')).toBe(ownerReadOnly)
|
||||
expect(Boolean(screen.queryByText('mySkills.ownerReadOnly'))).toBe(ownerReadOnly)
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
expect(api.navigate).not.toHaveBeenCalled()
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ vi.mock('@/shared/ui/card', () => ({
|
|||
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
||||
useSkillDetail: () => ({ data: null, isLoading: false, isError: false }),
|
||||
useSkillVersionDetail: () => ({ data: null, isLoading: false, isError: false, refetch: vi.fn() }),
|
||||
usePublishSkill: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -22,11 +22,20 @@ import {
|
|||
normalizeSelectValue,
|
||||
} from '@/shared/ui/select'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { stripMarkdownFrontmatter } from '@/features/skill/markdown-frontmatter'
|
||||
import { PublishResultPanel } from '@/features/publish/publish-result-panel'
|
||||
import { classifyPublishFailure, type PublishSubmissionState } from '@/features/publish/publish-result'
|
||||
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
|
||||
import { readPackageOverview } from '@/features/publish/package-overview'
|
||||
import { PresentationEditor } from '@/features/publish/presentation-editor'
|
||||
import { presentationPayload, publishedOverrides, type PresentationSource } from '@/features/publish/presentation-source'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { Pencil, RotateCcw } from 'lucide-react'
|
||||
import { SkillAvatarEditor, KEEP_AVATAR, type AvatarEdit } from '@/features/skill/skill-avatar-editor'
|
||||
import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager'
|
||||
import { previewSkillPackage, usePublishSkill, useSkillDetail } from '@/shared/hooks/use-skill-queries'
|
||||
import { previewSkillPackage, usePublishSkill, useSkillDetail, useSkillVersionDetail } from '@/shared/hooks/use-skill-queries'
|
||||
import { getSkillDetailQueryKey } from '@/shared/hooks/query-keys'
|
||||
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
|
|
@ -52,53 +61,139 @@ export function PublishPage() {
|
|||
const previewId = useId()
|
||||
const [fileRevision, setFileRevision] = useState(0)
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [shortDescription, setShortDescription] = useState('')
|
||||
const [overview, setOverview] = useState('')
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [submissionState, setSubmissionState] = useState<PublishSubmissionState | null>(null)
|
||||
const [shortSource, setShortSource] = useState<PresentationSource>('package')
|
||||
const [overviewSource, setOverviewSource] = useState<PresentationSource>('package')
|
||||
const [attempted, setAttempted] = useState(false)
|
||||
const [viewOverview, setViewOverview] = useState(false)
|
||||
const [restoreField, setRestoreField] = useState<'short' | 'overview' | null>(null)
|
||||
const editorOriginal = useRef('')
|
||||
const restoredFocus = useRef<string | null>(null)
|
||||
const restoreOpener = useRef<HTMLButtonElement | null>(null)
|
||||
const initialized = useRef('')
|
||||
const sourceTouched = useRef({short:false,overview:false})
|
||||
const resourceIdentity = `${prefill.namespace}/${updateSlug}`
|
||||
const formIdentity = useRef(resourceIdentity)
|
||||
const displayNameEdited = useRef(false)
|
||||
const shortDescriptionEdited = useRef(false)
|
||||
const overviewEdited = useRef(false)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [namespaceSlug, setNamespaceSlug] = useState<string>(prefill.namespace)
|
||||
const [visibility, setVisibility] = useState<string>(prefill.visibility)
|
||||
const [warningDialogOpen, setWarningDialogOpen] = useState(false)
|
||||
const [precheckWarnings, setPrecheckWarnings] = useState<string[]>([])
|
||||
const [isPackaging, setIsPackaging] = useState(false)
|
||||
const [folderError, setFolderError] = useState('')
|
||||
const selectionGeneration = useRef(0)
|
||||
// Route identity is read during render. Effects must not hydrate cached data
|
||||
// from the previous namespace while its local form state is being reset.
|
||||
const resourceReady = formIdentity.current === resourceIdentity && (!updateSlug || namespaceSlug === prefill.namespace)
|
||||
|
||||
const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces()
|
||||
const publishMutation = usePublishSkill()
|
||||
const { data: updateTarget } = useSkillDetail(namespaceSlug, updateSlug ?? '', !!namespaceSlug && !!updateSlug)
|
||||
const ownerReadOnly = updateTarget?.ownerReadOnly === true
|
||||
const packagePreview = useQuery({
|
||||
queryKey: ['skill-package-preview', previewId, fileRevision],
|
||||
queryFn: () => previewSkillPackage(selectedFile!),
|
||||
enabled: selectedFile !== null,
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
})
|
||||
useEffect(() => {
|
||||
if (packagePreview.data && !displayNameEdited.current) {
|
||||
setDisplayName(packagePreview.data.displayName ?? packagePreview.data.name ?? '')
|
||||
const identity = `${prefill.namespace}/${updateSlug}`
|
||||
if (formIdentity.current !== identity) {
|
||||
selectionGeneration.current += 1
|
||||
formIdentity.current = identity
|
||||
initialized.current = ''
|
||||
sourceTouched.current = { short: false, overview: false }
|
||||
restoredFocus.current = null
|
||||
restoreOpener.current = null
|
||||
displayNameEdited.current = false
|
||||
shortDescriptionEdited.current = false
|
||||
overviewEdited.current = false
|
||||
setIsPackaging(false)
|
||||
setFolderError('')
|
||||
setSelectedFile(null)
|
||||
setFileRevision(value => value + 1)
|
||||
setDisplayName('')
|
||||
setShortDescription('')
|
||||
setOverview('')
|
||||
setShortSource('package')
|
||||
setOverviewSource('package')
|
||||
setAttempted(false)
|
||||
setSubmissionState(null)
|
||||
setEditorOpen(false)
|
||||
setConfirmOpen(false)
|
||||
setRestoreField(null)
|
||||
setViewOverview(false)
|
||||
setWarningDialogOpen(false)
|
||||
setPrecheckWarnings([])
|
||||
}
|
||||
}, [packagePreview.data])
|
||||
const avatarTarget = useQuery({ queryKey: ['skill-avatar', namespaceSlug, updateSlug], queryFn: () => skillAvatarApi.status(namespaceSlug, updateSlug), enabled: !!namespaceSlug && !!updateSlug })
|
||||
const avatarUnavailable = !!updateSlug && (avatarTarget.isLoading || avatarTarget.isError)
|
||||
const isBusy = publishMutation.isPending || isSavingAvatar
|
||||
const avatarOnly = isUpdate && !selectedFile
|
||||
const canSubmit = !ownerReadOnly && (!selectedFile || packagePreview.isSuccess) && displayName.trim().length <= 200 && !!namespaceSlug && avatarEdit.ready && !avatarUnavailable && !isBusy && (selectedFile !== null || (isUpdate && avatarEdit.action !== 'KEEP'))
|
||||
const selectedNamespace = namespaces?.find((ns) => ns.slug === namespaceSlug)
|
||||
const namespaceOnlyLabel = selectedNamespace?.type === 'GLOBAL'
|
||||
? t('publish.visibilityOptions.loggedInUsersOnly')
|
||||
: t('publish.visibilityOptions.namespaceOnly')
|
||||
|
||||
useEffect(() => {
|
||||
setNamespaceSlug(prefill.namespace)
|
||||
setVisibility(prefill.visibility)
|
||||
setAvatarEdit(KEEP_AVATAR)
|
||||
}, [prefill.namespace, prefill.visibility, updateSlug])
|
||||
|
||||
const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces()
|
||||
const publishMutation = usePublishSkill()
|
||||
const { data: updateTarget } = useSkillDetail(namespaceSlug, updateSlug ?? '', resourceReady && !!namespaceSlug && !!updateSlug)
|
||||
const ownerReadOnly = updateTarget?.ownerReadOnly === true
|
||||
useEffect(() => { if (ownerReadOnly) { setEditorOpen(false); setConfirmOpen(false); setWarningDialogOpen(false); setRestoreField(null); setViewOverview(false) } }, [ownerReadOnly])
|
||||
const packagePreview = useQuery({
|
||||
queryKey: ['skill-package-preview', previewId, resourceIdentity, fileRevision],
|
||||
queryFn: () => previewSkillPackage(selectedFile!),
|
||||
enabled: resourceReady && selectedFile !== null,
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
})
|
||||
const packageOverview = useQuery({
|
||||
queryKey: ['skill-package-overview', previewId, resourceIdentity, fileRevision],
|
||||
queryFn: ({ signal }) => readPackageOverview(selectedFile!, signal),
|
||||
enabled: resourceReady && selectedFile !== null && packagePreview.isSuccess && !packagePreview.data?.overview,
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
})
|
||||
const packageShort = packagePreview.data?.shortDescription ?? packagePreview.data?.description ?? ''
|
||||
const packageBody = packagePreview.data?.overview || packageOverview.data?.content || ''
|
||||
const readingPackageOverview = selectedFile !== null && packagePreview.isSuccess && !packagePreview.data?.overview && packageOverview.isPending
|
||||
const readingOverview = overviewSource === 'package' && readingPackageOverview
|
||||
const overviewPreviewFailed = overviewSource === 'package' && (packageOverview.isError || !!packageOverview.data?.error)
|
||||
const previewOverview = resourceReady ? (overviewSource === 'custom' ? overview : packageBody) : ''
|
||||
const previewShortDescription = resourceReady ? (shortSource === 'custom' ? shortDescription : packageShort) : ''
|
||||
const shortInvalid = !previewShortDescription.trim() || previewShortDescription.length > 200
|
||||
const overviewInvalid = !stripMarkdownFrontmatter(previewOverview).trim() || previewOverview.length > 20000
|
||||
const { data: publishedDetail, isLoading: readingPublished, isError: publishedFailed, refetch: retryPublished } = useSkillVersionDetail(namespaceSlug, updateSlug ?? '', updateTarget?.publishedVersion?.version, resourceReady && !!updateSlug && !!updateTarget?.publishedVersion?.version)
|
||||
useEffect(() => {
|
||||
const key = `${namespaceSlug}/${updateSlug}/${publishedDetail?.version}`
|
||||
if (!resourceReady || !publishedDetail || initialized.current === key) return
|
||||
initialized.current = key
|
||||
const previous = publishedOverrides(publishedDetail.parsedMetadataJson)
|
||||
if (!sourceTouched.current.short && !shortDescriptionEdited.current && previous.short !== undefined) { shortDescriptionEdited.current = true; setShortDescription(previous.short); setShortSource('custom') }
|
||||
if (!sourceTouched.current.overview && !overviewEdited.current && previous.overview !== undefined) { overviewEdited.current = true; setOverview(previous.overview); setOverviewSource('custom') }
|
||||
}, [namespaceSlug, updateSlug, publishedDetail, resourceReady])
|
||||
useEffect(() => {
|
||||
if (resourceReady && selectedFile && packagePreview.data && !displayNameEdited.current) {
|
||||
setDisplayName(packagePreview.data.displayName ?? packagePreview.data.name ?? '')
|
||||
}
|
||||
|
||||
}, [packagePreview.data, resourceReady, selectedFile])
|
||||
const avatarTarget = useQuery({ queryKey: ['skill-avatar', namespaceSlug, updateSlug], queryFn: () => skillAvatarApi.status(namespaceSlug, updateSlug), enabled: resourceReady && !!namespaceSlug && !!updateSlug })
|
||||
const avatarUnavailable = !!updateSlug && (avatarTarget.isLoading || avatarTarget.isError)
|
||||
const isBusy = publishMutation.isPending || isSavingAvatar
|
||||
const avatarOnly = isUpdate && !selectedFile
|
||||
const canSubmit = resourceReady && !ownerReadOnly && !isPackaging && (!selectedFile || packagePreview.isSuccess) && displayName.trim().length <= 200 && (avatarOnly || (!shortInvalid && !overviewInvalid && !readingOverview && !readingPublished && !publishedFailed)) && !!namespaceSlug && avatarEdit.ready && !avatarUnavailable && !isBusy && (selectedFile !== null || (isUpdate && avatarEdit.action !== 'KEEP'))
|
||||
const selectedNamespace = namespaces?.find((ns) => ns.slug === namespaceSlug)
|
||||
const namespaceOnlyLabel = selectedNamespace?.type === 'GLOBAL'
|
||||
? t('publish.visibilityOptions.loggedInUsersOnly')
|
||||
: t('publish.visibilityOptions.namespaceOnly')
|
||||
|
||||
|
||||
|
||||
const handleRemoveSelectedFile = () => {
|
||||
selectionGeneration.current += 1
|
||||
setFolderError('')
|
||||
setSelectedFile(null)
|
||||
setPrecheckWarnings([])
|
||||
setWarningDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleFileSelect = (file: File | null) => {
|
||||
selectionGeneration.current += 1
|
||||
setFolderError('')
|
||||
setSelectedFile(file)
|
||||
setFileRevision(value => value + 1)
|
||||
setPrecheckWarnings([])
|
||||
|
|
@ -106,14 +201,19 @@ export function PublishPage() {
|
|||
}
|
||||
|
||||
const handleFolderSelect = async (files: File[]) => {
|
||||
const generation = ++selectionGeneration.current
|
||||
const identity = formIdentity.current
|
||||
setFolderError('')
|
||||
setIsPackaging(true)
|
||||
try {
|
||||
const zip = await packageFolderAsZip(files)
|
||||
if (generation !== selectionGeneration.current || identity !== formIdentity.current) return
|
||||
setIsPackaging(false)
|
||||
handleFileSelect(zip)
|
||||
} catch {
|
||||
toast.error(t('publish.folderPackagingFailed'))
|
||||
if (generation === selectionGeneration.current && identity === formIdentity.current) setFolderError(t('publish.folderPackagingFailed'))
|
||||
} finally {
|
||||
setIsPackaging(false)
|
||||
if (identity === formIdentity.current && generation === selectionGeneration.current) setIsPackaging(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,6 +225,12 @@ export function PublishPage() {
|
|||
}
|
||||
|
||||
submitting.current = true
|
||||
setWarningDialogOpen(false)
|
||||
setConfirmOpen(false)
|
||||
setEditorOpen(false)
|
||||
setViewOverview(false)
|
||||
setRestoreField(null)
|
||||
if (!avatarOnly) setSubmissionState({ kind: 'submitting', label: displayName, version: packagePreview.data?.version })
|
||||
try {
|
||||
if (avatarOnly) {
|
||||
setIsSavingAvatar(true)
|
||||
|
|
@ -148,25 +254,11 @@ export function PublishPage() {
|
|||
avatarUploadId: avatarEdit.uploadId,
|
||||
expectedSlug: updateSlug || undefined,
|
||||
displayName,
|
||||
...presentationPayload(shortSource, overviewSource, shortDescription, overview),
|
||||
})
|
||||
setPrecheckWarnings([])
|
||||
setWarningDialogOpen(false)
|
||||
const avatarNotice = avatarEdit.action === 'REPLACE' ? t('skillAvatar.submittedNotice') : avatarEdit.action === 'REMOVE' ? t('skillAvatar.removedNotice') : ''
|
||||
const skillLabel = `${result.namespace}/${result.slug}@${result.version}`
|
||||
if (result.status === 'PUBLISHED') {
|
||||
toast.success(
|
||||
t('publish.publishedTitle'),
|
||||
[t('publish.publishedDescription', { skill: skillLabel }), avatarNotice].filter(Boolean).join(' ')
|
||||
)
|
||||
} else {
|
||||
toast.success(
|
||||
t('publish.pendingReviewTitle'),
|
||||
[t('publish.pendingReviewDescription', { skill: skillLabel }), avatarNotice].filter(Boolean).join(' ')
|
||||
)
|
||||
}
|
||||
navigateAfterOverlays(() => {
|
||||
navigate({ to: '/dashboard/skills' })
|
||||
})
|
||||
setSubmissionState({ kind: 'accepted', result, hadPublishedVersion: !!updateTarget?.publishedVersion })
|
||||
} catch (error) {
|
||||
// A package rejection may also reflect changed membership. Re-read the
|
||||
// server state instead of inferring permissions from its localized message.
|
||||
|
|
@ -175,6 +267,7 @@ export function PublishPage() {
|
|||
}
|
||||
// Avatar-only updates call the API directly rather than a mutation.
|
||||
if (avatarOnly) await refreshSkillPermissionsAfterForbidden(queryClient, error)
|
||||
if (!avatarOnly) { const failure = classifyPublishFailure(error, packagePreview.data?.version); if (failure) { setSubmissionState(failure); return } setSubmissionState(null) }
|
||||
if (error instanceof ApiError && error.status === 408) {
|
||||
toast.error(t('publish.timeoutTitle'), t('publish.timeoutDescription'))
|
||||
return
|
||||
|
|
@ -221,167 +314,280 @@ export function PublishPage() {
|
|||
await publishSkill(false)
|
||||
}
|
||||
|
||||
const openOverviewEditor = () => {
|
||||
editorOriginal.current = previewOverview
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const restorePackageContent = () => {
|
||||
if (!restoreField || ownerReadOnly || isBusy || !selectedFile || !packagePreview.isSuccess) return
|
||||
sourceTouched.current[restoreField] = true
|
||||
if (restoreField === 'short') {
|
||||
setShortSource('package')
|
||||
setShortDescription('')
|
||||
shortDescriptionEdited.current = false
|
||||
} else {
|
||||
setOverviewSource('package')
|
||||
setOverview('')
|
||||
overviewEdited.current = false
|
||||
}
|
||||
restoredFocus.current = restoreField === 'short' ? 'skill-short-description' : 'skill-overview-label'
|
||||
setRestoreField(null)
|
||||
}
|
||||
|
||||
const focusField = (id: string) => {
|
||||
const field = document.getElementById(id)
|
||||
field?.scrollIntoView?.({ block: 'center' })
|
||||
field?.focus()
|
||||
}
|
||||
|
||||
const missingFields = [
|
||||
{ id: 'publish-package-section', invalid: !selectedFile, label: 'publish.file' },
|
||||
{ id: 'namespace', invalid: !namespaceSlug, label: 'publish.namespace' },
|
||||
{ id: 'skill-short-description', invalid: shortInvalid, label: 'publish.shortDescription' },
|
||||
{ id: 'skill-overview-label', invalid: overviewInvalid, label: 'publish.overview' },
|
||||
].filter(field => field.invalid)
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t(isUpdate ? 'publish.updateTitle' : 'publish.title')} subtitle={t(isUpdate ? 'publish.updateSubtitle' : 'publish.subtitle')} />
|
||||
<div className="mx-auto w-full max-w-[1200px] animate-fade-up">
|
||||
{submissionState && <PublishResultPanel
|
||||
state={submissionState}
|
||||
onReturnToForm={() => {
|
||||
setSubmissionState(null)
|
||||
requestAnimationFrame(() => {
|
||||
const action = document.getElementById('publish-confirm-action')
|
||||
focusField(action?.hasAttribute('disabled') ? 'publish-form-heading' : 'publish-confirm-action')
|
||||
})
|
||||
}}
|
||||
onReplacePackage={() => {
|
||||
setSubmissionState(null)
|
||||
requestAnimationFrame(() => focusField(`${previewId}-replace-action`))
|
||||
}}
|
||||
onViewMySkills={() => navigate({ to: '/dashboard/skills' })}
|
||||
onViewVersion={result => navigate({ to: '/space/$namespace/$slug', params: { namespace: result.namespace, slug: result.slug }, search: { version: result.version } })}
|
||||
/>}
|
||||
<div hidden={!!submissionState} inert={!!submissionState} className="space-y-8">
|
||||
<div id="publish-form-heading" tabIndex={-1}>
|
||||
<DashboardPageHeader title={t(isUpdate ? 'publish.updateTitle' : 'publish.title')} subtitle={t(isUpdate ? 'publish.updateSubtitle' : 'publish.subtitle')} />
|
||||
</div>
|
||||
|
||||
{ownerReadOnly && <p role="status" className="rounded-lg border bg-muted/20 p-3 text-sm text-muted-foreground">{t('mySkills.ownerReadOnly')}</p>}
|
||||
<Card className="space-y-8 p-6 md:p-8">
|
||||
{prefill.resubmitSkill && prefill.resubmitVersion ? (
|
||||
<div className="rounded-xl border border-amber-500/25 bg-amber-500/5 p-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
{t('publish.resubmitNotice.title', {
|
||||
skill: `@${prefill.namespace}/${prefill.resubmitSkill}`,
|
||||
version: prefill.resubmitVersion,
|
||||
})}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('publish.resubmitNotice.description')}
|
||||
</p>
|
||||
{attempted && !avatarOnly && missingFields.length > 0 && <div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-destructive">
|
||||
<p>{t('publish.source.requiredSummary')}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
|
||||
{missingFields.map(field => <button key={field.id} type="button" className="underline underline-offset-4" onClick={() => focusField(field.id)}>{t(field.label)}</button>)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-start gap-3 rounded-xl border border-blue-500/20 bg-blue-500/5 p-4">
|
||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h3 className="mb-1 text-sm font-semibold text-foreground">{t('publish.reviewNotice.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('publish.reviewNotice.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="namespace" className="text-sm font-semibold font-heading">{t('publish.namespace')}</Label>
|
||||
{isLoadingNamespaces ? (
|
||||
<div className="h-11 animate-shimmer rounded-lg" />
|
||||
) : (
|
||||
<Select
|
||||
disabled={!!updateSlug || isBusy}
|
||||
value={normalizeSelectValue(namespaceSlug) ?? EMPTY_NAMESPACE_VALUE}
|
||||
onValueChange={(value) => {
|
||||
setNamespaceSlug(value === EMPTY_NAMESPACE_VALUE ? '' : value)
|
||||
setAvatarEdit(KEEP_AVATAR)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger data-analytics-id="skill.publish.namespace.interact" id="namespace">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem data-analytics-id="skill.publish.namespace.clear" value={EMPTY_NAMESPACE_VALUE}>{t('publish.selectNamespace')}</SelectItem>
|
||||
{namespaces?.map((ns) => (
|
||||
<SelectItem data-analytics-id="skill.publish.namespace.select" key={ns.id} value={ns.slug}>
|
||||
{ns.displayName} (@{ns.slug})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
{publishedFailed && <div role="alert" className="flex flex-wrap items-center gap-3 rounded-lg border p-4">
|
||||
<p>{t('publish.source.historyUnavailable')}</p>
|
||||
<Button type="button" variant="outline" onClick={() => retryPublished()}>{t('publish.retryPackagePreview')}</Button>
|
||||
</div>}
|
||||
{ownerReadOnly && <p role="status" className="rounded-lg border bg-muted/20 p-3 text-sm text-muted-foreground">{t('mySkills.ownerReadOnly')}</p>}
|
||||
{prefill.resubmitSkill && prefill.resubmitVersion && <div className="rounded-xl border border-amber-500/25 bg-amber-500/5 p-4">
|
||||
<h2 className="text-sm font-semibold">{t('publish.resubmitNotice.title', { skill: `@${prefill.namespace}/${prefill.resubmitSkill}`, version: prefill.resubmitVersion })}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('publish.resubmitNotice.description')}</p>
|
||||
</div>}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="visibility" className="text-sm font-semibold font-heading">{t('publish.visibility')}</Label>
|
||||
<Select value={visibility} onValueChange={setVisibility} disabled={ownerReadOnly || isBusy || avatarOnly}>
|
||||
<SelectTrigger data-analytics-id="skill.publish.visibility.interact" id="visibility">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem data-analytics-id="skill.publish.visibility.public" value="PUBLIC">{t('publish.visibilityOptions.public')}</SelectItem>
|
||||
<SelectItem data-analytics-id="skill.publish.visibility.namespace" value="NAMESPACE_ONLY">{namespaceOnlyLabel}</SelectItem>
|
||||
<SelectItem data-analytics-id="skill.publish.visibility.private" value="PRIVATE">{t('publish.visibilityOptions.private')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-x-10 gap-y-10 lg:grid-cols-[minmax(0,1fr)_344px]">
|
||||
<section id="publish-package-section" tabIndex={-1} aria-labelledby="publish-package-label" aria-invalid={attempted && !selectedFile} className="min-w-0 space-y-3 scroll-mt-24 lg:col-start-1 lg:row-start-1">
|
||||
<h2 id="publish-package-label" className="text-base font-semibold">{t('publish.file')} <span className="text-destructive">*</span></h2>
|
||||
{isUpdate && <p className="text-sm text-muted-foreground">{t('publish.optionalPackage')}</p>}
|
||||
<UploadZone
|
||||
key={`${namespaceSlug}/${updateSlug}`}
|
||||
formatHint={t('publish.packageFormatHint')}
|
||||
analyticsId="skill.package.select"
|
||||
folderAnalyticsId="skill.package.folder.select"
|
||||
pickerAnalyticsId="skill.package.folder.open"
|
||||
selectedFile={selectedFile}
|
||||
previewStatus={selectedFile ? packagePreview.isPending ? 'reading' : packagePreview.isError ? 'error' : packagePreview.isSuccess ? 'ready' : 'idle' : 'idle'}
|
||||
previewError={packagePreview.isError ? t('publish.packagePreviewFailed') : undefined}
|
||||
isPackaging={isPackaging}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
onRemove={handleRemoveSelectedFile}
|
||||
replaceActionId={`${previewId}-replace-action`}
|
||||
disabled={ownerReadOnly || isBusy}
|
||||
/>
|
||||
{folderError && <p role="alert" className="text-sm text-destructive">{folderError}</p>}
|
||||
{attempted && !selectedFile && <p role="alert" className="text-sm text-destructive">{t('publish.selectRequired')}</p>}
|
||||
{selectedFile && packagePreview.isError && <Button data-analytics-id="skill.package.preview.retry" type="button" size="sm" variant="outline" disabled={ownerReadOnly || isBusy} onClick={() => packagePreview.refetch()}>{t('publish.retryPackagePreview')}</Button>}
|
||||
</section>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold font-heading">{t('publish.file')}</Label>
|
||||
{isUpdate && <p className="text-sm text-muted-foreground">{t('publish.optionalPackage')}</p>}
|
||||
<UploadZone analyticsId="skill.package.select" folderAnalyticsId="skill.package.folder.select" pickerAnalyticsId="skill.package.folder.open"
|
||||
onFileSelect={handleFileSelect}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
disabled={ownerReadOnly || isBusy || isPackaging}
|
||||
/>
|
||||
{selectedFile && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/30 px-4 py-3">
|
||||
<div className="min-w-0 text-sm text-muted-foreground flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-emerald-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="truncate">
|
||||
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
<section aria-labelledby="publish-identity-heading" className="min-w-0 space-y-5 lg:col-start-1 lg:row-start-2">
|
||||
<h2 id="publish-identity-heading" className="text-lg font-semibold">{t('publish.form.identity')}</h2>
|
||||
<div className="grid min-w-0 gap-6 sm:grid-cols-[minmax(0,240px)_minmax(0,1fr)]">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<SkillAvatarEditor key={`${namespaceSlug}/${updateSlug}`} name={displayName || search.updateName || updateSlug} currentUrl={avatarTarget.data?.avatarUrl} canRemove={!!updateSlug} disabled={ownerReadOnly || isBusy || avatarUnavailable} onChange={setAvatarEdit} />
|
||||
{updateSlug && <SkillAvatarManager namespace={namespaceSlug} slug={updateSlug} disabled={ownerReadOnly || isBusy} />}
|
||||
{avatarTarget.isError && <p role="alert" className="text-sm text-destructive">{t('skillAvatar.failed')}</p>}
|
||||
</div>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Label htmlFor="skill-display-name">{t('publish.displayName')}</Label>
|
||||
<Input
|
||||
id="skill-display-name"
|
||||
value={resourceReady ? displayName : ''}
|
||||
maxLength={200}
|
||||
disabled={ownerReadOnly || isBusy || !selectedFile}
|
||||
placeholder={isUpdate ? search.updateName : t('publish.displayNamePlaceholder')}
|
||||
aria-describedby="skill-display-name-help"
|
||||
onChange={event => { displayNameEdited.current = true; setDisplayName(event.target.value) }}
|
||||
/>
|
||||
<p id="skill-display-name-help" className="text-xs leading-5 text-muted-foreground">{t('publish.form.nameHelp')}</p>
|
||||
{selectedFile && packagePreview.data && <p className="break-all text-xs text-muted-foreground"><code>{packagePreview.data.name}</code> · {packagePreview.data.version}</p>}
|
||||
{selectedFile && packagePreview.data?.examplesStatus === 'ready' && <p role="status" className="text-xs text-muted-foreground">{t('publish.examplesReady', { count: packagePreview.data.examplesCount })}</p>}
|
||||
{selectedFile && packagePreview.data?.examplesStatus === 'none' && <p className="text-xs text-muted-foreground">{t('publish.examplesNone')}</p>}
|
||||
{selectedFile && packagePreview.data?.examplesStatus === 'invalid' && <p role="status" className="text-xs text-muted-foreground">{t('publish.examplesInvalid')}</p>}
|
||||
</div>
|
||||
<Button data-analytics-id="skill.package.remove"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRemoveSelectedFile}
|
||||
disabled={ownerReadOnly || isBusy}
|
||||
>
|
||||
{t('publish.removeSelectedFile')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside aria-labelledby="publish-settings-heading" className="min-w-0 self-start rounded-xl bg-muted/40 p-5 sm:p-6 lg:sticky lg:top-24 lg:col-start-2 lg:row-start-1 lg:row-span-3">
|
||||
<h2 id="publish-settings-heading" className="text-lg font-semibold">{t('publish.settings')}</h2>
|
||||
<div className="mt-6 space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="namespace">{t('publish.namespace')} <span className="text-destructive">*</span></Label>
|
||||
{isLoadingNamespaces ? <div className="h-11 animate-shimmer rounded-lg" /> : <Select
|
||||
disabled={!!updateSlug || ownerReadOnly || isBusy}
|
||||
value={normalizeSelectValue(namespaceSlug) ?? EMPTY_NAMESPACE_VALUE}
|
||||
onValueChange={value => { setNamespaceSlug(value === EMPTY_NAMESPACE_VALUE ? '' : value); setAvatarEdit(KEEP_AVATAR) }}
|
||||
>
|
||||
<SelectTrigger data-analytics-id="skill.publish.namespace.interact" id="namespace" aria-invalid={attempted && !namespaceSlug}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem data-analytics-id="skill.publish.namespace.clear" value={EMPTY_NAMESPACE_VALUE}>{t('publish.selectNamespace')}</SelectItem>
|
||||
{namespaces?.map(ns => <SelectItem data-analytics-id="skill.publish.namespace.select" key={ns.slug} value={ns.slug}>{ns.displayName} (@{ns.slug})</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="visibility">{t('publish.visibility')} <span className="text-destructive">*</span></Label>
|
||||
<Select value={visibility} onValueChange={setVisibility} disabled={ownerReadOnly || isBusy || avatarOnly}>
|
||||
<SelectTrigger data-analytics-id="skill.publish.visibility.interact" id="visibility" aria-describedby="publish-visibility-help"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem data-analytics-id="skill.publish.visibility.public" value="PUBLIC">{t('publish.visibilityOptions.public')}</SelectItem>
|
||||
<SelectItem data-analytics-id="skill.publish.visibility.namespace" value="NAMESPACE_ONLY">{namespaceOnlyLabel}</SelectItem>
|
||||
<SelectItem data-analytics-id="skill.publish.visibility.private" value="PRIVATE">{t('publish.visibilityOptions.private')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p id="publish-visibility-help" className="text-xs leading-5 text-muted-foreground">{t('publish.reviewNotice.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section aria-labelledby="publish-presentation-heading" className="min-w-0 space-y-8 border-t pt-8 lg:col-start-1 lg:row-start-3">
|
||||
<div className="space-y-2">
|
||||
<h2 id="publish-presentation-heading" className="text-lg font-semibold">{t('publish.form.presentation')}</h2>
|
||||
<p className="text-sm leading-6 text-muted-foreground">{t('publish.form.presentationHelp')}</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="skill-short-description">{t('publish.shortDescription')} <span className="text-destructive">*</span></Label>
|
||||
<Textarea
|
||||
aria-label={t('publish.shortDescription')}
|
||||
aria-required
|
||||
id="skill-short-description"
|
||||
rows={4}
|
||||
className="min-h-[120px] resize-y"
|
||||
maxLength={200}
|
||||
value={previewShortDescription}
|
||||
disabled={ownerReadOnly || isBusy}
|
||||
aria-invalid={attempted && shortInvalid}
|
||||
aria-describedby={`skill-short-description-help${shortInvalid && selectedFile ? ' skill-short-description-error' : ''}`}
|
||||
onChange={event => {
|
||||
if (event.target.value === previewShortDescription) return
|
||||
sourceTouched.current.short = true
|
||||
shortDescriptionEdited.current = true
|
||||
setShortDescription(event.target.value)
|
||||
setShortSource('custom')
|
||||
}}
|
||||
/>
|
||||
<div id="skill-short-description-help" className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span>{t(shortSource === 'custom' ? 'publish.form.customized' : 'publish.form.packageContent')}</span>
|
||||
{shortSource === 'custom' && <button type="button" className="inline-flex items-center gap-1 text-foreground underline-offset-4 hover:underline focus-visible:underline disabled:opacity-50" disabled={ownerReadOnly || isBusy || !selectedFile || !packagePreview.isSuccess} onClick={event => { restoreOpener.current = event.currentTarget; setRestoreField('short') }}><RotateCcw aria-hidden className="h-3 w-3" />{t('publish.form.restore')}</button>}
|
||||
</div>
|
||||
<span className="tabular-nums">{previewShortDescription.length} / 200</span>
|
||||
</div>
|
||||
{shortInvalid && selectedFile && <p id="skill-short-description-error" role={attempted ? 'alert' : 'status'} className={attempted ? 'text-sm text-destructive' : 'text-sm text-amber-700 dark:text-amber-400'}>{t('publish.source.shortRequired')}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 id="skill-overview-label" tabIndex={-1} className="text-sm font-medium scroll-mt-24">{t('publish.overview')} <span className="text-destructive">*</span></h3>
|
||||
<Button type="button" variant="outline" size="sm" disabled={ownerReadOnly || isBusy || readingOverview || readingPublished || (!!selectedFile && packagePreview.isPending)} onClick={openOverviewEditor}><Pencil className="mr-2 h-4 w-4" aria-hidden />{t('publish.editOverview')}</Button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-background p-5 sm:p-6" aria-labelledby="skill-overview-label" aria-describedby={overviewInvalid && selectedFile ? 'skill-overview-error' : undefined}>
|
||||
{readingOverview ? <p role="status" className="py-5 text-sm text-muted-foreground">{t('publish.readingOverview')}</p> : overviewPreviewFailed ? <p role="status" className="py-5 text-sm text-muted-foreground">{t('publish.overviewPreviewUnavailable')}</p> : previewOverview.trim() ? <div className="max-h-80 overflow-hidden [mask-image:linear-gradient(black_85%,transparent)]"><MarkdownRenderer content={previewOverview} /></div> : <div className="py-5"><h4 className="font-medium">{t('publish.emptyOverview')}</h4><p className="mt-2 text-sm text-muted-foreground">{t('publish.emptyOverviewHelp')}</p></div>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span>{t(overviewSource === 'custom' ? 'publish.form.customized' : 'publish.form.packageContent')}</span>
|
||||
{overviewSource === 'custom' && <button type="button" className="inline-flex items-center gap-1 text-foreground underline-offset-4 hover:underline focus-visible:underline disabled:opacity-50" disabled={ownerReadOnly || isBusy || !selectedFile || !packagePreview.isSuccess || readingPackageOverview} onClick={event => { restoreOpener.current = event.currentTarget; setRestoreField('overview') }}><RotateCcw aria-hidden className="h-3 w-3" />{t('publish.form.restore')}</button>}
|
||||
</div>
|
||||
{previewOverview.trim() && <button type="button" className="underline-offset-4 hover:underline focus-visible:underline" disabled={isBusy} onClick={() => setViewOverview(true)}>{t('publish.source.view')}</button>}
|
||||
</div>
|
||||
{overviewInvalid && selectedFile && <p id="skill-overview-error" role={attempted ? 'alert' : 'status'} className={attempted ? 'text-sm text-destructive' : 'text-sm text-amber-700 dark:text-amber-400'}>{t('publish.source.overviewRequired')}</p>}
|
||||
{overviewPreviewFailed && <Button type="button" variant="outline" disabled={ownerReadOnly || isBusy} onClick={() => packageOverview.refetch()}>{t('publish.retryPackagePreview')}</Button>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-display-name">{t('publish.displayName')}</Label>
|
||||
<Input
|
||||
id="skill-display-name"
|
||||
value={displayName}
|
||||
maxLength={200}
|
||||
disabled={ownerReadOnly || isBusy || !selectedFile}
|
||||
placeholder={isUpdate ? search.updateName : t('publish.displayNamePlaceholder')}
|
||||
aria-describedby="skill-display-name-help"
|
||||
onChange={event => {
|
||||
displayNameEdited.current = true
|
||||
setDisplayName(event.target.value)
|
||||
<footer className="flex flex-wrap items-center justify-between gap-4 border-t pt-6">
|
||||
<p className="text-xs leading-5 text-muted-foreground">{t('publish.form.confirmHelp')}</p>
|
||||
<Button id="publish-confirm-action" data-analytics-id={avatarOnly ? 'skill.avatar.save' : 'skill.publish.request'}
|
||||
className="w-full text-primary-foreground disabled:text-primary-foreground sm:w-auto sm:min-w-40"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
setAttempted(true)
|
||||
if (canSubmit) { if (avatarOnly) void handlePublish(); else setConfirmOpen(true) }
|
||||
else focusField(missingFields[0]?.id ?? 'publish-form-heading')
|
||||
}}
|
||||
/>
|
||||
<p id="skill-display-name-help" className="text-sm text-muted-foreground">{t('publish.displayNameHelp')}</p>
|
||||
{selectedFile && packagePreview.isPending && <p role="status" className="text-sm text-muted-foreground">{t('publish.readingPackage')}</p>}
|
||||
{selectedFile && packagePreview.isError && <div role="alert" className="text-sm text-destructive">
|
||||
<p>{t('publish.packagePreviewFailed')}</p>
|
||||
<Button data-analytics-id="skill.package.preview.retry" type="button" size="sm" variant="outline" onClick={() => packagePreview.refetch()}>{t('publish.retryPackagePreview')}</Button>
|
||||
</div>}
|
||||
{selectedFile && packagePreview.data && <p className="break-all text-xs text-muted-foreground">{t('publish.packageName')}: <code>{packagePreview.data.name}</code></p>}
|
||||
{selectedFile && packagePreview.data?.examplesStatus === 'ready' && <p role="status" className="text-xs text-muted-foreground">{t('publish.examplesReady', { count: packagePreview.data.examplesCount })}</p>}
|
||||
{selectedFile && packagePreview.data?.examplesStatus === 'none' && <p className="text-xs text-muted-foreground">{t('publish.examplesNone')}</p>}
|
||||
{selectedFile && packagePreview.data?.examplesStatus === 'invalid' && <p role="status" className="text-xs text-muted-foreground">{t('publish.examplesInvalid')}</p>}
|
||||
</div>
|
||||
disabled={ownerReadOnly || isBusy || isPackaging || !avatarEdit.ready || avatarUnavailable || (avatarOnly && !canSubmit) || readingOverview || readingPublished || (!!selectedFile && packagePreview.isPending)}
|
||||
>{isBusy ? t('publish.publishing') : t(avatarOnly ? 'publish.saveAvatar' : 'publish.nextConfirm')}</Button>
|
||||
</footer>
|
||||
|
||||
<SkillAvatarEditor key={`${namespaceSlug}/${updateSlug}`} name={search.updateName ?? updateSlug} currentUrl={avatarTarget.data?.avatarUrl} canRemove={!!updateSlug} disabled={ownerReadOnly || isBusy || avatarUnavailable} onChange={setAvatarEdit} />
|
||||
{updateSlug && <SkillAvatarManager namespace={namespaceSlug} slug={updateSlug} disabled={ownerReadOnly || isBusy} />}
|
||||
{avatarTarget.isError && <p role="alert" className="text-sm text-destructive">{t('skillAvatar.failed')}</p>}
|
||||
|
||||
<Button data-analytics-id={avatarOnly ? "skill.avatar.save" : "skill.publish.request"}
|
||||
className="w-full text-primary-foreground disabled:text-primary-foreground"
|
||||
size="lg"
|
||||
onClick={handlePublish}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{isBusy ? t('publish.publishing') : t(avatarOnly ? 'publish.saveAvatar' : 'publish.confirm')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog confirmAnalyticsId={avatarOnly ? undefined : "skill.publish.confirm"} cancelAnalyticsId={avatarOnly ? undefined : "skill.publish.cancel"}
|
||||
open={warningDialogOpen}
|
||||
onOpenChange={setWarningDialogOpen}
|
||||
title={t('publish.warningConfirmTitle')}
|
||||
description={(
|
||||
<div className="space-y-3 text-left">
|
||||
<p>{t('publish.warningConfirmDescription')}</p>
|
||||
{precheckWarnings.length > 0 && (
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
{precheckWarnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
confirmText={t('publish.warningConfirmContinue')}
|
||||
cancelText={t('publish.warningConfirmCancel')}
|
||||
onConfirm={() => publishSkill(true)}
|
||||
/>
|
||||
<Dialog open={viewOverview} onOpenChange={setViewOverview}>
|
||||
<DialogContent className="w-[min(calc(100vw-2rem),56rem)]"><DialogTitle>{t('publish.overview')}</DialogTitle><MarkdownRenderer content={previewOverview} /></DialogContent>
|
||||
</Dialog>
|
||||
<PresentationEditor open={editorOpen} value={editorOriginal.current} maxLength={20000} onCancel={() => setEditorOpen(false)} onApply={value => {
|
||||
if (ownerReadOnly || isBusy) { setEditorOpen(false); return }
|
||||
if (value !== editorOriginal.current) {
|
||||
sourceTouched.current.overview = true
|
||||
overviewEdited.current = true
|
||||
setOverview(value)
|
||||
setOverviewSource('custom')
|
||||
}
|
||||
setEditorOpen(false)
|
||||
}} />
|
||||
<ConfirmDialog
|
||||
open={restoreField !== null}
|
||||
onOpenChange={open => { if (!open) setRestoreField(null) }}
|
||||
title={t('publish.form.restoreTitle', { field: t(restoreField === 'short' ? 'publish.shortDescription' : 'publish.overview') })}
|
||||
description={t('publish.form.restoreDescription')}
|
||||
confirmText={t('publish.form.restore')}
|
||||
cancelText={t('dialog.cancel')}
|
||||
onConfirm={restorePackageContent}
|
||||
onCloseAutoFocus={event => {
|
||||
event.preventDefault()
|
||||
const target = restoredFocus.current
|
||||
const opener = restoreOpener.current
|
||||
restoredFocus.current = null
|
||||
restoreOpener.current = null
|
||||
requestAnimationFrame(() => {
|
||||
if (target) focusField(target)
|
||||
else if (opener?.isConnected && !opener.disabled) opener.focus()
|
||||
else focusField('publish-form-heading')
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog open={confirmOpen} onOpenChange={setConfirmOpen} title={t('publish.confirmDetails')} description={<span className="whitespace-pre-line">{[displayName, `@${namespaceSlug}/${packagePreview.data?.name}@${packagePreview.data?.version}`, `${t('publish.visibility')}: ${visibility === 'PUBLIC' ? t('publish.visibilityOptions.public') : visibility === 'PRIVATE' ? t('publish.visibilityOptions.private') : namespaceOnlyLabel}`, previewShortDescription, t('publish.reviewNotice.description')].join('\n\n')}</span>} confirmText={t('publish.confirm')} cancelText={t('publish.backToEdit')} onConfirm={() => { if (!canSubmit) return; setConfirmOpen(false); void handlePublish() }} />
|
||||
<ConfirmDialog confirmAnalyticsId={avatarOnly ? undefined : 'skill.publish.confirm'} cancelAnalyticsId={avatarOnly ? undefined : 'skill.publish.cancel'}
|
||||
open={warningDialogOpen}
|
||||
onOpenChange={setWarningDialogOpen}
|
||||
title={t('publish.warningConfirmTitle')}
|
||||
description={<div className="space-y-3 text-left"><p>{t('publish.warningConfirmDescription')}</p>{precheckWarnings.length > 0 && <ul className="list-disc space-y-1 pl-5">{precheckWarnings.map(warning => <li key={warning}>{warning}</li>)}</ul>}</div>}
|
||||
confirmText={t('publish.warningConfirmContinue')}
|
||||
cancelText={t('publish.warningConfirmCancel')}
|
||||
onConfirm={() => publishSkill(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
|||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MouseEvent, ReactNode } from 'react'
|
||||
import { ApiError } from '@/api/client'
|
||||
import type { SkillFile } from '@/api/types'
|
||||
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
|
|
@ -19,6 +20,7 @@ const useSkillVersionsMock = vi.fn()
|
|||
const useSkillFilesMock = vi.fn()
|
||||
const useSkillReadmeMock = vi.fn()
|
||||
const useSkillFileMock = vi.fn()
|
||||
const useSkillVersionDetailMock = vi.fn()
|
||||
const usePromotionSourceStateMock = vi.fn()
|
||||
const submitPromotionMock = vi.fn()
|
||||
const submitRevocationMock = vi.fn()
|
||||
|
|
@ -208,7 +210,7 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
|||
useAttachSkillLabel: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useDetachSkillLabel: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useSkillVersions: (...args: unknown[]) => useSkillVersionsMock(...args),
|
||||
useSkillVersionDetail: () => ({ data: undefined }),
|
||||
useSkillVersionDetail: (...args: unknown[]) => useSkillVersionDetailMock(...args),
|
||||
useSkillFiles: (...args: unknown[]) => useSkillFilesMock(...args),
|
||||
useSkillReadme: (...args: unknown[]) => useSkillReadmeMock(...args),
|
||||
useSkillFile: (...args: unknown[]) => useSkillFileMock(...args),
|
||||
|
|
@ -301,6 +303,8 @@ describe('SkillDetailPage', () => {
|
|||
useSkillFilesMock.mockReset()
|
||||
useSkillReadmeMock.mockReset()
|
||||
useSkillFileMock.mockReset()
|
||||
useSkillVersionDetailMock.mockReset()
|
||||
useSkillVersionDetailMock.mockReturnValue({ data: undefined })
|
||||
toastMocks.success.mockReset()
|
||||
toastMocks.error.mockReset()
|
||||
hasRoleMock.mockImplementation((role: string) => role === 'USER')
|
||||
|
|
@ -1013,6 +1017,102 @@ describe('SkillDetailPage', () => {
|
|||
expect(html).toContain('leading-snug')
|
||||
})
|
||||
|
||||
it('renders the selected snapshot overview and opens the packaged SKILL.md separately', () => {
|
||||
useSkillVersionDetailMock.mockReturnValue({ data: { parsedMetadataJson: JSON.stringify({
|
||||
name: 'demo-skill', description: 'Original description', shortDescriptionOverride: 'Selected summary',
|
||||
overviewOverride: '# Selected overview',
|
||||
}) } })
|
||||
useSkillFilesMock.mockReturnValue({ data: [createSkillFile('README.md'), createSkillFile('SKILL.md')] })
|
||||
render(<SkillDetailPage />)
|
||||
expect(screen.getByText('markdown:# Selected overview')).toBeTruthy()
|
||||
expect(screen.queryByText('markdown:# Demo')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'skillDetail.tabInstructions' }))
|
||||
expect(screen.getByText(/SKILL.md ·/)).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'skillDetail.viewSkillMd' })).toBeNull()
|
||||
})
|
||||
|
||||
it('selects a version from its tab without publishing and preserves return context', () => {
|
||||
useSkillVersionsMock.mockReturnValue({ data: [
|
||||
{ id: 10, version: '1.0.0', status: 'PUBLISHED' },
|
||||
{ id: 11, version: '0.9.0', status: 'PUBLISHED' },
|
||||
] })
|
||||
render(<SkillDetailPage />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'skillDetail.tabVersions' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillDetail.viewThisVersion' }))
|
||||
expect(navigateMock).toHaveBeenCalledWith({ to: '/space/$namespace/$slug', params: routeParams, search: { returnTo: '/dashboard/skills', version: '0.9.0' } })
|
||||
expect(document.activeElement).toBe(screen.getByRole('tab', { name: 'skillDetail.tabOverview' }))
|
||||
expect(screen.getByRole('tab', { name: 'skillDetail.tabOverview' }).getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('shows a historical context and returns to the latest published version', () => {
|
||||
searchMock.value.version = '0.9.0'
|
||||
useSkillVersionsMock.mockReturnValue({ data: [
|
||||
{ id: 10, version: '1.0.0', status: 'PUBLISHED' },
|
||||
{ id: 11, version: '0.9.0', status: 'PUBLISHED' },
|
||||
] })
|
||||
render(<SkillDetailPage />)
|
||||
expect(screen.getByText('skillDetail.viewingHistoricalVersion')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillDetail.returnLatestPublished' }))
|
||||
expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ search: { returnTo: '/dashboard/skills', version: undefined } }))
|
||||
})
|
||||
|
||||
it('does not display another version when the requested version is unavailable', () => {
|
||||
searchMock.value.version = '99.0.0'
|
||||
useSkillVersionDetailMock.mockReturnValue({ error: Object.assign(new ApiError('not found', 404), { status: 404 }) })
|
||||
render(<SkillDetailPage />)
|
||||
expect(screen.getByRole('alert').textContent).toContain('skillDetail.versionUnavailable')
|
||||
expect(screen.queryByText('markdown:# Demo')).toBeNull()
|
||||
expect(screen.queryByRole('tab')).toBeNull()
|
||||
})
|
||||
|
||||
it('can read an older version absent from the first page of versions', () => {
|
||||
searchMock.value.version = '0.1.0'
|
||||
useSkillVersionDetailMock.mockReturnValue({ data: { id: 1, version: '0.1.0', status: 'PUBLISHED', parsedMetadataJson: JSON.stringify({ overviewOverride: '# Old readable introduction' }) } })
|
||||
render(<SkillDetailPage />)
|
||||
expect(screen.getByText('markdown:# Old readable introduction')).toBeTruthy()
|
||||
expect(screen.queryByText('skillDetail.versionUnavailable')).toBeNull()
|
||||
})
|
||||
|
||||
it('distinguishes transient version errors from unavailable versions and offers retry', () => {
|
||||
searchMock.value.version = '0.1.0'
|
||||
const refetch = vi.fn()
|
||||
useSkillVersionDetailMock.mockReturnValue({ error: Object.assign(new ApiError('temporary', 500), { status: 500 }), refetch })
|
||||
render(<SkillDetailPage />)
|
||||
expect(screen.queryByText('skillDetail.versionUnavailable')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillDetail.instructionsRetry' }))
|
||||
expect(refetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('restores overview focus after the selected version finishes loading', () => {
|
||||
useSkillVersionsMock.mockReturnValue({ data: [
|
||||
{ id: 10, version: '1.0.0', status: 'PUBLISHED' },
|
||||
{ id: 11, version: '0.9.0', status: 'PUBLISHED' },
|
||||
] })
|
||||
const { rerender } = render(<SkillDetailPage />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'skillDetail.tabVersions' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillDetail.viewThisVersion' }))
|
||||
searchMock.value.version = '0.9.0'
|
||||
useSkillVersionDetailMock.mockReturnValue({ isLoading: true })
|
||||
rerender(<SkillDetailPage />)
|
||||
expect(screen.getByRole('status').textContent).toBe('skillDetail.versionLoading')
|
||||
useSkillVersionDetailMock.mockReturnValue({ isLoading: false, data: { id: 11, version: '0.9.0', status: 'PUBLISHED' } })
|
||||
rerender(<SkillDetailPage />)
|
||||
expect(document.activeElement).toBe(screen.getByRole('tab', { name: 'skillDetail.tabOverview' }))
|
||||
})
|
||||
|
||||
it('returns to overview when route history changes the selected version', () => {
|
||||
useSkillVersionsMock.mockReturnValue({ data: [
|
||||
{ id: 10, version: '1.0.0', status: 'PUBLISHED' },
|
||||
{ id: 11, version: '0.9.0', status: 'PUBLISHED' },
|
||||
] })
|
||||
const { rerender } = render(<SkillDetailPage />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'skillDetail.tabFiles' }))
|
||||
searchMock.value.version = '0.9.0'
|
||||
rerender(<SkillDetailPage />)
|
||||
expect(screen.getByRole('tab', { name: 'skillDetail.tabOverview' }).getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByText('skillDetail.viewingHistoricalVersion')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens a file preview when overview markdown relative link matches a package file', () => {
|
||||
useSkillFilesMock.mockReturnValue({
|
||||
data: [
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
|||
import { Link, useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeft, ArrowUpCircle, Boxes, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react'
|
||||
import { SkillInstructions } from '@/features/skill/skill-instructions'
|
||||
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
|
||||
import { SkillVisibilityDialog } from '@/features/skill/skill-visibility-dialog'
|
||||
import { SkillAvatar } from '@/features/skill/skill-avatar'
|
||||
|
|
@ -198,22 +199,44 @@ export function SkillDetailPage() {
|
|||
?? publishedVersions[0]
|
||||
const ownerPreviewVersion = skill ? getOwnerPreviewVersion(skill) : null
|
||||
const requestedVersion = search.version
|
||||
const selectedVersion = versions?.some(version => version.version === requestedVersion)
|
||||
? requestedVersion
|
||||
: headlineVersion?.version ?? versions?.[0]?.version
|
||||
const selectedVersionEntry = versions?.find((version) => version.version === selectedVersion) ?? versions?.[0]
|
||||
const selectedVersion = requestedVersion ?? headlineVersion?.version ?? versions?.[0]?.version
|
||||
const selectedMetadata = selectedVersion === ownerPreviewVersion?.version ? ownerPreviewVersion
|
||||
: selectedVersion === publishedVersion?.version ? publishedVersion
|
||||
: selectedVersion === headlineVersion?.version ? headlineVersion : null
|
||||
const { data: selectedVersionDetail } = useSkillVersionDetail(qns, qslug, selectedVersion, skillReady && !selectedMetadata)
|
||||
const { data: selectedVersionDetail, error: versionDetailError, isLoading: versionDetailLoading, refetch: retryVersionDetail } = useSkillVersionDetail(qns, qslug, selectedVersion, skillReady)
|
||||
const unavailableVersion = Boolean(requestedVersion && versionDetailError instanceof ApiError && [403, 404].includes(versionDetailError.status))
|
||||
const selectedVersionEntry = versions?.find((version) => version.version === selectedVersion)
|
||||
?? (selectedVersionDetail ? { ...selectedVersionDetail, downloadAvailable: false, syncRevision: undefined } : undefined)
|
||||
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 { data: files, isLoading: filesLoading, error: filesError, refetch: retryFiles } = useSkillFiles(qns, qslug, selectedVersion, skillReady && !unavailableVersion)
|
||||
const documentationPath = resolveDocumentationFilePath(files)
|
||||
const { data: readme, error: readmeError } = useSkillReadme(qns, qslug, selectedVersion, documentationPath, skillReady)
|
||||
const { data: readme, error: readmeError } = useSkillReadme(qns, qslug, selectedVersion, documentationPath, skillReady && !unavailableVersion)
|
||||
const renderedDocumentation = historicalMetadata.overview ?? readme
|
||||
useEffect(() => {
|
||||
setIsOverviewExpanded(false)
|
||||
setPreviewDialogOpen(false)
|
||||
setPreviewNode(null)
|
||||
}, [namespace, slug, selectedVersion])
|
||||
const overviewTabRef = useRef<HTMLButtonElement>(null)
|
||||
const focusOverviewAfterSwitch = useRef(false)
|
||||
useEffect(() => {
|
||||
if (focusOverviewAfterSwitch.current && overviewTabRef.current && !versionDetailLoading) {
|
||||
overviewTabRef.current.focus()
|
||||
focusOverviewAfterSwitch.current = false
|
||||
}
|
||||
}, [selectedVersion, versionDetailLoading])
|
||||
const [tabState, setTabState] = useState({ version: selectedVersion, value: 'readme' })
|
||||
const activeTab = tabState.version === selectedVersion ? tabState.value : 'readme'
|
||||
const showVersion = (version?: string) => {
|
||||
focusOverviewAfterSwitch.current = true
|
||||
overviewTabRef.current?.focus()
|
||||
setTabState({ version: selectedVersion, value: 'readme' })
|
||||
void navigate({ to: '/space/$namespace/$slug', params: { namespace, slug }, search: { ...search, version } })
|
||||
}
|
||||
const { data: previewContent, isLoading: isLoadingPreview, error: previewError } = useSkillFile(
|
||||
qns,
|
||||
qslug,
|
||||
|
|
@ -859,6 +882,32 @@ export function SkillDetailPage() {
|
|||
)
|
||||
}
|
||||
|
||||
if (requestedVersion && versionDetailError && !unavailableVersion) {
|
||||
return (
|
||||
<Card className="p-6 space-y-4" role="alert">
|
||||
<p>{t('skillDetail.versionLoadError')}</p>
|
||||
<Button variant="outline" data-analytics-id="skill.version.retry" onClick={() => void retryVersionDetail()}>
|
||||
{t('skillDetail.instructionsRetry')}
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
if (requestedVersion && versionDetailLoading) {
|
||||
return <Card className="p-6" role="status">{t('skillDetail.versionLoading')}</Card>
|
||||
}
|
||||
if (unavailableVersion) {
|
||||
return (
|
||||
<Card className="p-6 space-y-4" role="alert">
|
||||
<p>{t('skillDetail.versionUnavailable', { version: requestedVersion })}</p>
|
||||
{publishedVersion && (
|
||||
<Button variant="outline" data-analytics-id="skill.version.latest" onClick={() => showVersion(publishedVersion.version)}>
|
||||
{t('skillDetail.returnLatestPublished')}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (skillDeleted) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -980,17 +1029,22 @@ export function SkillDetailPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="readme">
|
||||
<TabsList>
|
||||
<TabsTrigger data-analytics-id="skill.overview.tab" value="readme">{t('skillDetail.tabOverview')}</TabsTrigger>
|
||||
{(requestedVersion || showingUnpublishedPreview) && <div role="status" className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-secondary/30 px-4 py-3 text-sm">
|
||||
<span>{t(unavailableVersion ? 'skillDetail.versionUnavailable' : showingUnpublishedPreview ? 'skillDetail.viewingUnpublishedVersion' : selectedVersion !== publishedVersion?.version ? 'skillDetail.viewingHistoricalVersion' : 'skillDetail.viewingVersion', { version: selectedVersion })}</span>
|
||||
{publishedVersion && selectedVersion !== publishedVersion.version && <Button variant="outline" size="sm" data-analytics-id="skill.version.latest" onClick={() => showVersion(publishedVersion.version === headlineVersion?.version ? undefined : publishedVersion.version)}>{t('skillDetail.returnLatestPublished')}</Button>}
|
||||
</div>}
|
||||
<Tabs value={activeTab} onValueChange={(value) => setTabState({ version: selectedVersion, value })}>
|
||||
<TabsList className="max-w-full flex-wrap h-auto">
|
||||
<TabsTrigger ref={overviewTabRef} data-analytics-id="skill.overview.tab" value="readme">{t('skillDetail.tabOverview')}</TabsTrigger>
|
||||
<TabsTrigger data-analytics-id="skill.instructions.tab" value="instructions">{t('skillDetail.tabInstructions')}</TabsTrigger>
|
||||
<TabsTrigger data-analytics-id="skill.files.tab" value="files">{t('skillDetail.tabFiles')}</TabsTrigger>
|
||||
<TabsTrigger data-analytics-id="skill.versions.tab" value="versions">{t('skillDetail.tabVersions')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="readme" className="mt-6">
|
||||
{readme ? (
|
||||
{renderedDocumentation ? (
|
||||
<Card className="p-8 space-y-4">
|
||||
{documentationPath ? (
|
||||
{!historicalMetadata.overview && documentationPath ? (
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{t('skillDetail.documentationSource', { path: documentationPath })}
|
||||
</div>
|
||||
|
|
@ -1004,7 +1058,7 @@ export function SkillDetailPage() {
|
|||
style={!isOverviewExpanded && isOverviewCollapsible ? { maxHeight: `${overviewMaxHeight}px` } : undefined}
|
||||
>
|
||||
<div ref={overviewContentRef}>
|
||||
<MarkdownRenderer content={readme} onLinkClick={handleOverviewLinkClick} />
|
||||
<MarkdownRenderer content={renderedDocumentation} onLinkClick={handleOverviewLinkClick} />
|
||||
</div>
|
||||
{!isOverviewExpanded && isOverviewCollapsible ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-card via-card/95 to-transparent" />
|
||||
|
|
@ -1053,6 +1107,9 @@ export function SkillDetailPage() {
|
|||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="instructions" className="mt-6">
|
||||
<SkillInstructions key={`${namespace}/${slug}/${selectedVersion}`} namespace={qns} slug={qslug} version={selectedVersion} available={!unavailableVersion && Boolean(files?.some(file => file.filePath === 'SKILL.md'))} loading={Boolean(filesLoading)} error={filesError} retryFiles={() => void retryFiles()} onLinkClick={(href, event) => handlePackageMarkdownLinkClick(href, event, 'SKILL.md')} />
|
||||
</TabsContent>
|
||||
<TabsContent value="files" className="mt-6">
|
||||
{files && files.length > 0 ? (
|
||||
<FileTree analyticsId="skill.file.open" files={files} onFileClick={handleFileClick} />
|
||||
|
|
@ -1069,7 +1126,7 @@ export function SkillDetailPage() {
|
|||
<div className="space-y-0 divide-y divide-border/40">
|
||||
{versions.map((version) => (
|
||||
<div key={version.id} className="py-5 first:pt-0 last:pb-0">
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-2">
|
||||
<span className="font-semibold font-heading text-foreground flex items-center gap-2 flex-wrap min-w-0">
|
||||
<span className="px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-sm font-mono">
|
||||
{version.syncRevision ? t('review.githubRevisionValue', { count: version.syncRevision }) : `v${version.version}`}
|
||||
|
|
@ -1088,7 +1145,8 @@ export function SkillDetailPage() {
|
|||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button size="sm" variant="outline" disabled={version.version === selectedVersion} data-analytics-id="skill.version.view" onClick={() => showVersion(version.version)}>{t(version.version === selectedVersion ? 'skillDetail.viewingThisVersion' : 'skillDetail.viewThisVersion')}</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatLocalDateTime(version.publishedAt, i18n.language)}
|
||||
</span>
|
||||
|
|
@ -1361,7 +1419,7 @@ export function SkillDetailPage() {
|
|||
)}
|
||||
</Card>
|
||||
|
||||
{publishedVersion && canInteract && (
|
||||
{selectedVersionEntry && isVersionInstallable && canInteract && (
|
||||
<Card className="p-5 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-muted-foreground" />
|
||||
|
|
@ -1373,7 +1431,7 @@ export function SkillDetailPage() {
|
|||
<InstallCommand analyticsId="skill.command.copy"
|
||||
namespace={namespace}
|
||||
slug={slug}
|
||||
version={publishedVersion.version} skillhubAnalyticsId="skill.command.skillhub.select" clawhubAnalyticsId="skill.command.clawhub.select"
|
||||
version={selectedVersionEntry.version} skillhubAnalyticsId="skill.command.skillhub.select" clawhubAnalyticsId="skill.command.clawhub.select"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ async function getSkillDocumentation(namespace: string, slug: string, version: s
|
|||
return fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/file?path=${encodeURIComponent(path)}`)
|
||||
}
|
||||
|
||||
async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean; avatarAction?: 'KEEP' | 'REPLACE' | 'REMOVE'; avatarUploadId?: string; expectedSlug?: string; displayName?: string }): Promise<PublishResult> {
|
||||
async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean; avatarAction?: 'KEEP' | 'REPLACE' | 'REMOVE'; avatarUploadId?: string; expectedSlug?: string; displayName?: string; shortDescription?: string; overview?: string; resetPresentation?: boolean }): Promise<PublishResult> {
|
||||
const cleanNamespace = params.namespace.startsWith('@') ? params.namespace.slice(1) : params.namespace
|
||||
const formData = new FormData()
|
||||
formData.append('file', params.file)
|
||||
|
|
@ -54,7 +54,10 @@ async function publishSkill(params: { namespace: string; file: File; visibility:
|
|||
formData.append('avatarAction', params.avatarAction ?? 'KEEP')
|
||||
if (params.avatarUploadId) formData.append('avatarUploadId', params.avatarUploadId)
|
||||
if (params.expectedSlug) formData.append('expectedSlug', params.expectedSlug)
|
||||
if (params.displayName?.trim()) formData.append('displayName', params.displayName.trim())
|
||||
if (params.displayName !== undefined) formData.append('displayName', params.displayName)
|
||||
if (params.shortDescription !== undefined) formData.append('shortDescription', params.shortDescription)
|
||||
if (params.overview !== undefined) formData.append('overview', params.overview)
|
||||
if (params.resetPresentation) formData.append('resetPresentation', 'true')
|
||||
|
||||
return fetchJson<PublishResult>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/publish`, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,15 @@ import { describe, expect, it } from 'vitest'
|
|||
import { getVersionPresentation } from './version-presentation'
|
||||
|
||||
describe('historical version presentation', () => {
|
||||
it('reads package defaults without treating them as overrides', () => {
|
||||
expect(getVersionPresentation(JSON.stringify({
|
||||
name: 'slug', description: 'Original', frontmatter: { metadata: { 'short-description': 'Package short', overview: '# Package overview' } },
|
||||
}))).toEqual({ displayName: 'slug', summary: 'Package short', overview: '# Package overview' })
|
||||
expect(getVersionPresentation(JSON.stringify({
|
||||
name: 'slug', description: 'New default', shortDescriptionOverride: 'Frozen custom', overviewOverride: '# Frozen',
|
||||
presentationInheritedFrom: '1.0.0', frontmatter: {},
|
||||
}))).toEqual({ displayName: 'slug', summary: 'Frozen custom', overview: '# Frozen' })
|
||||
})
|
||||
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: '旧版摘要' })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/** Read legacy version snapshots without rewriting the package or trusting arbitrary JSON. */
|
||||
export function getVersionPresentation(snapshot?: string): { displayName?: string; summary?: string } {
|
||||
export function getVersionPresentation(snapshot?: string): { displayName?: string; summary?: string; overview?: string } {
|
||||
if (!snapshot) return {}
|
||||
try {
|
||||
const value: unknown = JSON.parse(snapshot)
|
||||
|
|
@ -8,7 +8,15 @@ export function getVersionPresentation(snapshot?: string): { displayName?: strin
|
|||
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 }
|
||||
const overview = typeof value.overviewOverride === 'string' && value.overviewOverride.trim().length > 0
|
||||
? value.overviewOverride.trim()
|
||||
: typeof metadata.overview === 'string' && metadata.overview.trim().length > 0 ? metadata.overview.trim() : undefined
|
||||
const summary = typeof value.shortDescriptionOverride === 'string' && value.shortDescriptionOverride.trim().length > 0
|
||||
? value.shortDescriptionOverride.trim()
|
||||
: typeof metadata['short-description'] === 'string' && metadata['short-description'].trim().length > 0
|
||||
? metadata['short-description'].trim()
|
||||
: typeof value.description === 'string' ? value.description : undefined
|
||||
return overview ? { displayName, summary, overview } : { displayName, summary }
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue