diff --git a/cli/README.md b/cli/README.md index 9d4db4f0a..700503a28 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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: diff --git a/docs/04-search-architecture.md b/docs/04-search-architecture.md index 0090be0b8..06163f802 100644 --- a/docs/04-search-architecture.md +++ b/docs/04-search-architecture.md @@ -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 | | diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index ef2a17f9a..1f989213d 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -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-` 前缀): diff --git a/docs/14-skill-lifecycle.md b/docs/14-skill-lifecycle.md index cddef3d9c..79e7b572b 100644 --- a/docs/14-skill-lifecycle.md +++ b/docs/14-skill-lifecycle.md @@ -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 审核拒绝 diff --git a/docs/frontend-analytics-manifest.json b/docs/frontend-analytics-manifest.json index b317fde9a..ed38e1697 100644 --- a/docs/frontend-analytics-manifest.json +++ b/docs/frontend-analytics-manifest.json @@ -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"]}, diff --git a/docs/skillhub/en/guide/skill-publish.md b/docs/skillhub/en/guide/skill-publish.md index 6314779ac..e9ab5ba5c 100644 --- a/docs/skillhub/en/guide/skill-publish.md +++ b/docs/skillhub/en/guide/skill-publish.md @@ -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 diff --git a/docs/skillhub/guide/skill-publish.md b/docs/skillhub/guide/skill-publish.md index 3579d3610..646342519 100644 --- a/docs/skillhub/guide/skill-publish.md +++ b/docs/skillhub/guide/skill-publish.md @@ -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 升级;套装成员还受套装固定版本约束,不能以成员发布新版本代替发布新的套装版本。 ### 在“我的技能”跟踪与管理 diff --git a/openspec/changes/add-skill-presentation-fields/design.md b/openspec/changes/add-skill-presentation-fields/design.md new file mode 100644 index 000000000..76d631fbd --- /dev/null +++ b/openspec/changes/add-skill-presentation-fields/design.md @@ -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 读取版本。简介、概述、技能指令、文件和安装/下载目标使用同一选中版本。历史版本显示明确提示,返回最新版使用当前已发布版本,不把待审版本误称为最新版。选择版本不创建版本、不提审、不重新发布,访问边界沿用服务端授权。 + +折叠状态与技能指令阅读模式按版本重置,避免旧正文或阅读状态在版本之间串用。本次交互调整不替代此前字段继承、审核展示等诊断缺陷的修复及验收。 diff --git a/openspec/changes/add-skill-presentation-fields/proposal.md b/openspec/changes/add-skill-presentation-fields/proposal.md new file mode 100644 index 000000000..594ac348d --- /dev/null +++ b/openspec/changes/add-skill-presentation-fields/proposal.md @@ -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. diff --git a/openspec/changes/add-skill-presentation-fields/specs/skill-presentation/spec.md b/openspec/changes/add-skill-presentation-fields/specs/skill-presentation/spec.md new file mode 100644 index 000000000..9291317b1 --- /dev/null +++ b/openspec/changes/add-skill-presentation-fields/specs/skill-presentation/spec.md @@ -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 diff --git a/openspec/changes/add-skill-presentation-fields/tasks.md b/openspec/changes/add-skill-presentation-fields/tasks.md new file mode 100644 index 000000000..b82bd94c9 --- /dev/null +++ b/openspec/changes/add-skill-presentation-fields/tasks.md @@ -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. diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index 7e2141310..7579bd803 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -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( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillPackagePreviewResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillPackagePreviewResponse.java index e0867b2b5..fe48923f0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillPackagePreviewResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillPackagePreviewResponse.java @@ -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); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPackagePreviewService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPackagePreviewService.java index 8ca0db72b..41d354a81 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPackagePreviewService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPackagePreviewService.java @@ -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()); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java index ad49260fc..cec607a29 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java @@ -42,13 +42,14 @@ public class SkillPublishAppService { SkillVisibility visibility, Set 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 entries, String actor, SkillVisibility visibility, Set 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) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPackagePreviewServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPackagePreviewServiceTest.java index c1a2bbc77..3f963690b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPackagePreviewServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPackagePreviewServiceTest.java @@ -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(); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index 67ae04aba..d73224045 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -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); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index c18159ff2..1341dcdaa 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -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); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadata.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadata.java index 1c1a65e4a..60a163720 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadata.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadata.java @@ -9,10 +9,35 @@ public record SkillMetadata( String version, String body, Map frontmatter, - String displayNameOverride + String displayNameOverride, + String shortDescriptionOverride, + String overviewOverride, + String presentationInheritedFrom ) { public SkillMetadata(String name, String description, String version, String body, Map 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 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 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() diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java index af7959dbe..5315c020d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java @@ -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); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 82536db4c..78e222c3d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -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); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java index 359a2f94e..efc107734 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -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() ); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index d6043acf0..b32cc56c5 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -336,8 +336,17 @@ public class SkillPublishService { String namespaceSlug, List entries, String publisherId, SkillVisibility visibility, Set platformRoles, boolean confirmWarnings, String displayName) { + return publishFromEntries(namespaceSlug, entries, publisherId, visibility, platformRoles, confirmWarnings, + displayName, null, null, false); + } + + @Transactional + public PublishResult publishFromEntries( + String namespaceSlug, List entries, String publisherId, + SkillVisibility visibility, Set 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 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 entries, String publisherId, + SkillVisibility visibility, Set 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()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index 79c5dc6f2..ce9dfd1be 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -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 versionCaptor = ArgumentCaptor.forClass(SkillVersion.class); + verify(skillVersionRepository).save(versionCaptor.capture()); + assertEquals(sourceVersion.getParsedMetadataJson(), versionCaptor.getValue().getParsedMetadataJson()); assertEquals(NEW_VERSION_ID, newSkill.getLatestVersionId()); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index bf7721578..8406410b5 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -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()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java index 5b03de325..eb42a5909 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java @@ -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 frontmatter : java.util.List.of(Map.of(), + Map.of("metadata", Map.of("overview", " ")), + Map.of("metadata", Map.of("overview", 123)), + Map.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"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index 7af64be84..390006c4b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -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); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index a47a48824..1f25a2c37 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -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 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) + .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 diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java index 20d1ecf9c..c6d22d631 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java @@ -123,13 +123,19 @@ public class PostgresSearchRebuildService implements SearchRebuildService { private SearchIndexPayload buildSearchPayload(Skill skill) { List searchParts = new ArrayList<>(); addPart(searchParts, skill.getSlug()); + addPart(searchParts, skill.getDisplayName()); addPart(searchParts, skill.getSummary()); Set 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 frontmatter = new LinkedHashMap<>(asMap(metadata.get("frontmatter"))); + Map 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, diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java index e8fcd88a8..a1bdf45a3 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java @@ -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 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); diff --git a/web/package.json b/web/package.json index feb17e5a2..7cc429aa8 100644 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 369384b5d..a3d4661f9 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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 diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index c1b21995c..b5ce994e2 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -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: { diff --git a/web/src/app/analytics-markers.test.tsx b/web/src/app/analytics-markers.test.tsx index bb79bfdcb..43f60b982 100644 --- a/web/src/app/analytics-markers.test.tsx +++ b/web/src/app/analytics-markers.test.tsx @@ -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() - 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() 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' })] } }) diff --git a/web/src/features/publish/package-overview.test.ts b/web/src/features/publish/package-overview.test.ts new file mode 100644 index 000000000..30ef8df2f --- /dev/null +++ b/web/src/features/publish/package-overview.test.ts @@ -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, 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' }) + }) +}) diff --git a/web/src/features/publish/package-overview.ts b/web/src/features/publish/package-overview.ts new file mode 100644 index 000000000..b6ead790e --- /dev/null +++ b/web/src/features/publish/package-overview.ts @@ -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): { 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 { + 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() + 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(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' } + } +} diff --git a/web/src/features/publish/presentation-editor-rich.test.tsx b/web/src/features/publish/presentation-editor-rich.test.tsx new file mode 100644 index 000000000..b899250ef --- /dev/null +++ b/web/src/features/publish/presentation-editor-rich.test.tsx @@ -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 = '
content
' + 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() + for (const script of document.querySelectorAll('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() + 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() + 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() + 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() + 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() + await loadAssets() + unmount() + expect(engine.destroy).toHaveBeenCalledOnce() + }) +}) diff --git a/web/src/features/publish/presentation-editor-rich.tsx b/web/src/features/publish/presentation-editor-rich.tsx new file mode 100644 index 000000000..df0f63a3e --- /dev/null +++ b/web/src/features/publish/presentation-editor-rich.tsx @@ -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>() +const languages = new Map() +function loadScript(url: string, id: string): Promise { + const pending = scripts.get(url) + if (pending) return pending + const promise = new Promise((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(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)[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
+} diff --git a/web/src/features/publish/presentation-editor.css b/web/src/features/publish/presentation-editor.css new file mode 100644 index 000000000..1dd17c448 --- /dev/null +++ b/web/src/features/publish/presentation-editor.css @@ -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; } diff --git a/web/src/features/publish/presentation-editor.test.tsx b/web/src/features/publish/presentation-editor.test.tsx new file mode 100644 index 000000000..3f630e321 --- /dev/null +++ b/web/src/features/publish/presentation-editor.test.tsx @@ -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
+