diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index c74a8e27df..b84e57a74b 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -705,6 +705,18 @@ describe('web e2e: plugin manager', () => { expect(fits).toBe(true) await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'), await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE) + const chineseSource = await preview.getAttribute('src') + try { + await setLanguage('en') + const englishPreview = detail.getByRole('img', { name: 'DSH badge skill feature preview', exact: true }) + await englishPreview.waitFor() + await englishPreview.evaluate(async (image: HTMLImageElement) => { await image.decode() }) + expect(await englishPreview.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(640) + expect(await englishPreview.getAttribute('src')).not.toBe(chineseSource) + } finally { + await setLanguage('zh') + } + expect(await preview.getAttribute('src')).toBe(chineseSource) await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click() await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() expect(await panel.getByRole('img').count()).toBe(0) diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 94a7d3608b..aa52f1b0e4 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -21,8 +21,8 @@ en: 0894bd9f35430753 zh: 2d28090e71134edd /cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata: - en: 80c93d1bf8215864 - zh: 846a02590c73b0af + en: fa5fab4d94ca22d4 + zh: 969d5979850c94b0 /cookbook-adding-a-workspace-package/6-verify: en: 80c7fb90b1175aae zh: 2ba37962dbc57299 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index bc52f8404a..365c7e501d 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -147,7 +147,7 @@ Export `/locale/en.json` for locale lookup; expose ` For an image on bundle cards, details, and component rows, a package-root plugin sets top-level `"icon": "./icon.svg"` in its exported manifest, or exports `./icon` when that field is omitted. A subpath plugin exports its own icon, such as `"./search/icon": "./search.svg"`. Include each image in `files`. A manifest `icon` is relative to the manifest's directory; absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. An exported icon resolves through Node exports like locale resources and must remain inside its package. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Images must be self-contained; SVG is rendered as an image, not inline HTML. The Host returns a data URL without activating the plugin. Invalid declarations or unreadable files produce a metadata diagnostic while retaining valid text, without trying the export; missing or undecodable images use the panel's default artwork. -Optionally add `"preview": "./preview.svg"` to the same manifest for a larger feature image on bundle details, or export `./preview` when the field is absent. Include the file in `files`. It accepts the same formats and path restrictions as `icon`, up to 1 MiB; use a self-contained SVG or an optimized screenshot. It is read without activation. Omission leaves the detail layout unchanged; a bad preview preserves other metadata and management actions. There is no requirement to provide an image. +Optionally add `"preview": "./preview.svg"` to the same manifest for a larger feature image on bundle details, or export `./preview` when the field is absent. For artwork containing text, use a language map such as `"preview": { "en": "./preview.svg", "zh": "./preview.zh.svg" }`; English is required as the fallback, and the UI selects an image through its existing locale resolver. Include every image in `files`. The formats and path restrictions match `icon`, with a 1 MiB total limit across variants; use self-contained SVGs or optimized screenshots. Reading requires no activation. Omission leaves the detail layout unchanged; a bad preview preserves other metadata and management actions. Images remain optional. Installed bundle cards and details, component lists and configuration details, and Settings' plugin inventory display this metadata, including disabled and preset plugins. Reads do not activate plugins. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index e6c2eceef8..b6c010f861 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -147,7 +147,7 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex 要在组合包卡片、详情和组件行显示图片,包根插件在导出清单顶层设置 `"icon": "./icon.svg"`,或在省略该字段时导出 `./icon`。子路径插件导出自己的图标,例如 `"./search/icon": "./search.svg"`。将每张图片加入 `files`。清单 `icon` 相对于清单所在目录;绝对路径、URL、目录外路径,以及解析到目录外的符号链接均被拒绝。导出的图标像 locale 资源一样经 Node exports 解析,且必须位于所属包内。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。图片必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活插件。声明无效或文件不可读时,显示元信息诊断并保留有效文本,不再尝试导出的图标;图片缺失或无法解码时使用面板的默认插画。 -需要在组合包详情展示较大的功能配图时,可在同一清单中添加 `"preview": "./preview.svg"`,或在省略字段时导出 `./preview`,并将文件加入 `files`。格式和路径限制与 `icon` 相同,上限为 1 MiB;使用自包含 SVG 或优化后的截图,读取时无需激活插件。省略配图不改变详情布局;配图错误不影响其他元信息和管理操作。插件不必提供配图。 +需要在组合包详情展示较大的功能配图时,可在同一清单中添加 `"preview": "./preview.svg"`,或在省略字段时导出 `./preview`。含文字的配图可使用语言映射,如 `"preview": { "en": "./preview.svg", "zh": "./preview.zh.svg" }`;必须提供英文回退,界面通过现有语言解析器选择图片。将每张图片加入 `files`。格式和路径限制与 `icon` 相同,各语言图片合计上限为 1 MiB;使用自包含 SVG 或优化后的截图,读取时无需激活插件。省略配图不改变详情布局;配图错误不影响其他元信息和管理操作。插件不必提供配图。 已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 3345f901cc..a6d3603008 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -33,8 +33,8 @@ en: c4a301652b851ce9 zh: 784871e19cd9db0a /deepseek-ai-dsh-app-boot/use-this-package/reading-plugin-display-metadata: - en: e3a731622b9acdd2 - zh: c60284a12a17cd92 + en: fc096cfd8722ec02 + zh: 2fdd2e8ae66768a5 /deepseek-ai-dsh-app-boot/use-this-package/startup-and-reload-failures: en: dc13dfa1f79292e4 zh: d51682ced7a18ac2 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index ee877e3caf..16152756f5 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -88,7 +88,7 @@ Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifi `ON_DEMAND_BUNDLES` names the public Official packages installed only on request. `OFFICIAL_ON_DEMAND_CATALOG` embeds their package-owned localized metadata and icons for offline discovery; `pnpm gen-official-bundle-catalog` explicitly updates generated content, and `verify-official-bundle-catalog` checks resource completeness and freshness; builds consume the committed catalog without importing provider code. The catalog contains no install target: [Plugin Manager](../plugin-manager/README.md#use-this-package) derives it from the running version and installation and uses the ordinary bundle installer. The [product-use check](../../../scripts/verify-product-use.ts) composes shipped and on-demand selections against Web without executing providers. -The optional `preview` field follows the same package resource lookup and confinement as `icon`, with a 1 MiB limit. Each image is read independently: a preview failure retains valid text and the icon, and an icon failure retains a valid preview. Neither image requires plugin activation. +The optional `preview` field follows the same package resource lookup and confinement as `icon`. A manifest may provide one path or a language-to-path map with a required English fallback; all variants together are limited to 1 MiB. Each image field is read independently: an invalid preview retains valid text and the icon, and an invalid icon retains a valid preview. Neither image requires plugin activation. ### Startup and reload failures diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 1401f75cf1..5b011ef16d 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -88,7 +88,7 @@ profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek- `ON_DEMAND_BUNDLES` 列出仅按需安装的官方公开包。`OFFICIAL_ON_DEMAND_CATALOG` 内嵌这些包拥有的本地化元信息和图标,支持离线发现;`pnpm gen-official-bundle-catalog` 显式更新生成内容,`verify-official-bundle-catalog` 检查资源完整性与新鲜度;构建只读取已提交的目录,不导入提供者代码。目录不保存安装目标;[插件管理器](../plugin-manager/README.zh.md#use-this-package)根据当前版本和安装位置确定目标,并使用普通组合包安装器。[产品用途检查](../../../scripts/verify-product-use.ts)将随附和按需选择与 Web 组合,但不执行提供者。 -可选的 `preview` 字段沿用 `icon` 的包资源查询和路径限制,上限为 1 MiB。每张图片独立读取:配图出错时保留有效文本与图标,图标出错时保留有效配图。读取两类图片均无需激活插件。 +可选的 `preview` 字段沿用 `icon` 的包资源查询和路径限制。清单可提供单一路径或包含英文回退的语言路径映射,各语言图片合计上限为 1 MiB。每个图片字段独立读取:配图出错时保留有效文本与图标,图标出错时保留有效配图。读取两类图片均无需激活插件。 ### 启动与重载失败 diff --git a/packages/boot/app-boot/src/package-meta.ts b/packages/boot/app-boot/src/package-meta.ts index 382661e2c2..5f80520bbd 100644 --- a/packages/boot/app-boot/src/package-meta.ts +++ b/packages/boot/app-boot/src/package-meta.ts @@ -20,6 +20,13 @@ const IMAGE_MEDIA_TYPES = new Map([ ['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'], ]) +// A declared image resolves or throws; only an absent optional export returns undefined. +function imageOf( + field: ImageField, specifier: string, packageName: string, parentURL: string, manifestPath: string, value: string, +): string +function imageOf( + field: ImageField, specifier: string, packageName: string, parentURL: string, manifestPath: string | undefined, value: unknown, +): string | undefined function imageOf( field: ImageField, specifier: string, packageName: string, parentURL: string, manifestPath: string | undefined, value: unknown, ): string | undefined { @@ -55,6 +62,30 @@ function imageOf( return `data:${mediaType};base64,${bytes.toString('base64')}` } +/** A translated preview shares the text resolver's language keys and English fallback. */ +function previewOf( + specifier: string, packageName: string, parentURL: string, manifestPath: string | undefined, value: unknown, +): LocalizedText | undefined { + if (manifestPath === undefined || typeof value !== 'object' || value === null || Array.isArray(value)) { + return imageOf('preview', specifier, packageName, parentURL, manifestPath, value) + } + const translations = new Map() + let bytes = 0 + for (const [language, path] of Object.entries(value)) { + if (!LANGUAGE_ID.test(language)) throw new Error(`${manifestPath}: preview key ${language} must be a language id`) + const id = language.toLowerCase() + if (translations.has(id)) throw new Error(`${manifestPath}: preview duplicates locale ${id}`) + if (typeof path !== 'string' || path.trim() === '') throw new Error(`${manifestPath}: preview.${language} must be a non-empty string`) + const image = imageOf('preview', specifier, packageName, parentURL, manifestPath, path) + bytes += Buffer.byteLength(image.slice(image.indexOf(',') + 1), 'base64') + if (bytes > IMAGE_LIMIT_KIB.preview * 1024) throw new Error(`${manifestPath}: localized previews exceed 1024 KiB in total`) + translations.set(id, image) + } + const en = translations.get('en') + if (en === undefined) throw new Error(`${manifestPath}: preview translations require an English (en) image`) + return { ...Object.fromEntries(translations), en } +} + /** Require an ancestor of the real file whose package.json names the owning package. */ function assertPackageOwned(file: string, name: string, field: ImageField): void { for (let directory = dirname(file); ; directory = dirname(directory)) { @@ -163,7 +194,8 @@ function localizedText( * A subpath specifier never reads package.json; its images come only from `/icon` or `/preview`. * Manifest images remain inside their declaring directory; exported images remain inside their * owning package after realpath resolution. SVG, PNG, JPEG, and WebP are accepted, up to 256 KiB - * for icons and 1 MiB for previews. Each image failure retains the other fields without falling back. + * for icons and 1 MiB total for previews. A manifest preview may map language ids to paths with + * an English fallback. Each image field failure retains the other fields without falling back. * Non-package specifiers are skipped without invoking the resource resolver. * Language files share the directory containing the resolved English resource; * each file is resolved through the complete plugin specifier before reading. @@ -187,12 +219,17 @@ export function readPluginMeta(specifier: string, parentURL: string): PluginLoca ...title === undefined ? {} : { title }, ...description === undefined ? {} : { description }, } - const images: Partial> = {} + const images: { icon?: string; preview?: LocalizedText } = {} const errors: string[] = [] for (const field of ['icon', 'preview'] as const) { try { - const image = imageOf(field, specifier, packageName, parentURL, manifestPath, manifest?.[field]) - if (image !== undefined) images[field] = image + if (field === 'icon') { + const image = imageOf(field, specifier, packageName, parentURL, manifestPath, manifest?.icon) + if (image !== undefined) images.icon = image + } else { + const image = previewOf(specifier, packageName, parentURL, manifestPath, manifest?.preview) + if (image !== undefined) images.preview = image + } } catch (error) { errors.push(`Plugin metadata for ${specifier}: ${String(error)}`) } diff --git a/packages/boot/app-boot/tests/package-meta.spec.ts b/packages/boot/app-boot/tests/package-meta.spec.ts index 9cd872a2c5..b84dad5050 100644 --- a/packages/boot/app-boot/tests/package-meta.spec.ts +++ b/packages/boot/app-boot/tests/package-meta.spec.ts @@ -391,6 +391,48 @@ describe('plugin locale display metadata', () => { }) }) + it('reads localized preview paths without evaluating the plugin and normalizes language keys', () => { + manifest({ '.': './index.js', './package.json': './package.json' }, { + preview: { en: './preview.svg', 'ZH-CN': './preview.zh.svg' }, + }) + file(join(dir, 'preview.svg'), 'English') + file(join(dir, 'preview.zh.svg'), '中文') + expect(readPluginMeta('localized', parentURL)?.preview).toEqual({ + en: `data:image/svg+xml;base64,${Buffer.from('English').toString('base64')}`, + 'zh-cn': `data:image/svg+xml;base64,${Buffer.from('中文').toString('base64')}`, + }) + }) + + it.each([ + [{ zh: './preview.svg' }, 'require an English'], + [{ en: './preview.svg', EN: './preview.svg' }, 'duplicates locale en'], + [{ en: './preview.svg', zh_CN: './preview.svg' }, 'must be a language id'], + [{ en: './preview.svg', zh: '' }, 'must be a non-empty string'], + [{ en: './preview.svg', zh: '../outside.svg' }, 'inside its manifest directory'], + [{ en: './preview.svg', zh: 'https://example.test/preview.svg' }, 'relative file path'], + ])('keeps other metadata when localized previews are invalid: %j', (preview, diagnostic) => { + manifest({ './package.json': './package.json' }, { icon: './icon.svg', preview }) + file(join(dir, 'icon.svg'), '') + file(join(dir, 'preview.svg'), '') + file(join(dir, '../outside.svg'), '') + const meta = readPluginMeta('localized', parentURL) + expect(meta?.preview).toBeUndefined() + expect(meta?.icon).toBeDefined() + expect(meta?.title).toBe('localized') + expect(meta?.error).toContain(diagnostic) + }) + + it('bounds the combined size of localized previews at 1 MiB', () => { + manifest({ './package.json': './package.json' }, { preview: { en: './en.png', zh: './zh.png' } }) + writeFileSync(join(dir, 'en.png'), Buffer.alloc(512 * 1024)) + writeFileSync(join(dir, 'zh.png'), Buffer.alloc(512 * 1024)) + expect(readPluginMeta('localized', parentURL)?.preview).toBeDefined() + writeFileSync(join(dir, 'zh.png'), Buffer.alloc(512 * 1024 + 1)) + const meta = readPluginMeta('localized', parentURL) + expect(meta?.preview).toBeUndefined() + expect(meta?.error).toContain('localized previews exceed 1024 KiB in total') + }) + it('reads a subpath preview independently of root artwork', () => { manifest({ './preview': './root.svg', './search/preview': './search.webp' }) file(join(dir, 'root.svg'), 'root') diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 1ec9665006..69d5cfc183 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -30,8 +30,8 @@ en: e9da90f03691101b zh: 902cda935a4ce3c8 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/detail-page-extension-points: - en: 8319b9e4028844a4 - zh: 5266a572fdfbbed6 + en: 61098791b28e2777 + zh: 391369947303ca83 /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation: en: 203168fc87e939bd zh: 72d0ccd2c54f3f44 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 66a0dbb72c..a0806b4e6e 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -96,7 +96,7 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ A row's page exists only while a `plugins.row.config` entry names the row, so a contribution meant for a row renders on the page that configuration opens. -Bundle details display an optional package-owned feature preview below the description, including before activation. No preview means no image or reserved space; a decode failure shows a localized message without blocking configuration or enablement. Cards and component rows keep their small icons. +Bundle details display an optional package-owned feature preview below the description, including before activation. Translated artwork follows the UI language through the existing locale resolver, with English as the fallback. No preview means no image or reserved space; a decode failure shows a localized message without blocking configuration or enablement. Cards and component rows keep their small icons. ----- diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 745932c572..b4900676b7 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -96,7 +96,7 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ 行页只在某个 `plugins.row.config` 条目点名这一行时存在,因此给行的贡献渲染在该配置打开的页面上。 -组合包详情在描述下方展示可选的包内功能配图,未启用时也可查看。没有配图就不显示图片或预留空位;解码失败时显示本地化提示,不影响配置或启用。卡片与组件行保留小尺寸图标。 +组合包详情在描述下方展示可选的包内功能配图,未启用时也可查看。多语言配图通过现有语言解析器跟随界面语言,以英文回退。没有配图就不显示图片或预留空位;解码失败时显示本地化提示,不影响配置或启用。卡片与组件行保留小尺寸图标。 ----- diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 62d167bd5e..17f9256773 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -709,6 +709,7 @@ function PackageDetail({ }): ReactNode { const { title, description, beta } = packageText(pkg, resolveText) const status = packageStatus(pkg) + const preview = pkg.meta?.preview === undefined ? undefined : resolveText(pkg.meta.preview) const subject: PluginsSubject = { kind: 'bundle', pkg: packageRef(pkg) } return (
@@ -761,7 +762,7 @@ function PackageDetail({ {pkg.error === undefined ? null :

{t('reasonLabel')}: {managementText(pkg.error, t)}

} {pkg.readOnlyReason === undefined ? null :

{managementText({ code: pkg.readOnlyReason }, t)}

} - {pkg.meta?.preview === undefined ? null : } + {preview === undefined ? null : }
{configured ? ( diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 05634465ff..6368fc47ad 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -679,6 +679,21 @@ describe('PluginManagerPage', () => { expect(screen.queryByText(zh.previewUnavailable)).toBeNull() }) + it('switches preview artwork with the UI language and uses the English fallback', () => { + const enImage = 'data:image/svg+xml;base64,ZW4=' + const zhImage = 'data:image/svg+xml;base64,emg=' + const bundle = pkg({ meta: { preview: { en: enImage, zh: zhImage } } }) + const { set, setLanguage } = renderTab({ packages: [bundle] }) + fireEvent.click(screen.getByRole('button', { name: 'View dsh-better-sidebar' })) + expect(screen.getByRole('img').getAttribute('src')).toBe(enImage) + fireEvent.error(screen.getByRole('img')) + setLanguage(zh) + expect(screen.getByRole('img').getAttribute('src')).toBe(zhImage) + expect(screen.queryByText(zh.previewUnavailable)).toBeNull() + set({ packages: [{ ...bundle, meta: { preview: { en: enImage } } }] }) + expect(screen.getByRole('img').getAttribute('src')).toBe(enImage) + }) + it('shows metadata diagnostics without blocking management or displaying legacy descriptions', () => { const error = 'locale/zh.json: invalid title' const { actions, set, setLanguage } = renderTab({ diff --git a/packages/experimental/badge-skill-bundle/README.i18n.yaml b/packages/experimental/badge-skill-bundle/README.i18n.yaml index 360685f608..f883837324 100644 --- a/packages/experimental/badge-skill-bundle/README.i18n.yaml +++ b/packages/experimental/badge-skill-bundle/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 988e839059414d27 /deepseek-ai-dsh-experimental-badge-skill-bundle/use-this-package: - en: a5b3ce4165f7a95b - zh: a9c5f7d9d53c67cd + en: 2935cc992a9d8efd + zh: 5f3c480538d98dd0 /deepseek-ai-dsh-experimental-badge-skill-bundle/understand-the-implementation: en: e1ce6533996f4fe6 zh: 5bf2931419881347 diff --git a/packages/experimental/badge-skill-bundle/README.md b/packages/experimental/badge-skill-bundle/README.md index 69d2069e47..6b96030d7c 100644 --- a/packages/experimental/badge-skill-bundle/README.md +++ b/packages/experimental/badge-skill-bundle/README.md @@ -29,7 +29,7 @@ In Web or Desktop, open Plugins and enable **DSH badge skill** in Official. Swit The switch adds or removes a Host-wide skill provider for existing and new Agents. Presets that expose skill loading can use the badge; minimal keeps its tool catalog unchanged. -The optional `preview.svg` illustrates the official attribution badge at the end of a document. Plugin details display it before activation; the skill owns the badge assets and usage instructions. +The optional `preview.svg` and `preview.zh.svg` illustrate the official attribution badge at the end of a document in English and Chinese. Plugin details select the artwork for the UI language and display it before activation; the skill owns the badge assets and usage instructions. ----- diff --git a/packages/experimental/badge-skill-bundle/README.zh.md b/packages/experimental/badge-skill-bundle/README.zh.md index 8af98a0e3a..dc3ad7991f 100644 --- a/packages/experimental/badge-skill-bundle/README.zh.md +++ b/packages/experimental/badge-skill-bundle/README.zh.md @@ -29,7 +29,7 @@ kind: "package-bundle" 开关为已有和新建智能体添加或移除 Host 级技能提供方。提供技能加载能力的预设可以使用徽章;minimal 的工具目录保持不变。 -可选的 `preview.svg` 示意在文档末尾添加官方署名徽章的效果,插件未启用时详情页也可展示。徽章资源和使用说明由技能拥有。 +可选的 `preview.svg` 和 `preview.zh.svg` 分别用英文和中文示意在文档末尾添加官方署名徽章的效果,详情页按界面语言选择配图,插件未启用时也可展示。徽章资源和使用说明由技能拥有。 ----- diff --git a/packages/experimental/badge-skill-bundle/package.json b/packages/experimental/badge-skill-bundle/package.json index 4928dc866f..53c95c01f8 100644 --- a/packages/experimental/badge-skill-bundle/package.json +++ b/packages/experimental/badge-skill-bundle/package.json @@ -3,7 +3,10 @@ "description": "Let agents add a “powered by dsh” badge to documents, pull requests, and other outputs.", "version": "0.2.1-alpha.2", "icon": "./icon.svg", - "preview": "./preview.svg", + "preview": { + "en": "./preview.svg", + "zh": "./preview.zh.svg" + }, "publishConfig": { "access": "public" }, @@ -28,6 +31,7 @@ "files": [ "icon.svg", "preview.svg", + "preview.zh.svg", "locale/*.json", "lib/index.js", "cordis.patch.yml", diff --git a/packages/experimental/badge-skill-bundle/preview.svg b/packages/experimental/badge-skill-bundle/preview.svg index 511dc509a9..f6b88bf2d9 100644 --- a/packages/experimental/badge-skill-bundle/preview.svg +++ b/packages/experimental/badge-skill-bundle/preview.svg @@ -5,12 +5,12 @@ - + - - + + diff --git a/packages/experimental/badge-skill-bundle/preview.zh.svg b/packages/experimental/badge-skill-bundle/preview.zh.svg new file mode 100644 index 0000000000..6a816b7606 --- /dev/null +++ b/packages/experimental/badge-skill-bundle/preview.zh.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 496a815cca..be84e03a89 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -6294,7 +6294,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PluginLocalizedMeta', - declaration: 'export interface PluginLocalizedMeta {\n readonly title?: LocalizedText;\n readonly description?: LocalizedText;\n readonly icon?: string;\n readonly preview?: string;\n readonly error?: string;\n}', + declaration: 'export interface PluginLocalizedMeta {\n readonly title?: LocalizedText;\n readonly description?: LocalizedText;\n readonly icon?: string;\n readonly preview?: LocalizedText;\n readonly error?: string;\n}', }, { name: 'PluginOptions', diff --git a/packages/util/package-manifest/README.i18n.yaml b/packages/util/package-manifest/README.i18n.yaml index ef170086f9..2b35de1a0b 100644 --- a/packages/util/package-manifest/README.i18n.yaml +++ b/packages/util/package-manifest/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 1db86285abf6c499 /deepseek-ai-dsh-package-manifest/use-this-package: - en: 043740e276a0fd5c - zh: d825e0c613ddeccf + en: 801f4f07a2159bc5 + zh: 7be0f86a03864e31 /deepseek-ai-dsh-package-manifest/understand-the-implementation: en: 41ec449dee927f04 zh: ec1b583bd9025d84 diff --git a/packages/util/package-manifest/README.md b/packages/util/package-manifest/README.md index 66d7e94416..b64e45d905 100644 --- a/packages/util/package-manifest/README.md +++ b/packages/util/package-manifest/README.md @@ -56,7 +56,7 @@ The following metadata fields are optional. Omitting them leaves the format vers Public composition declarations are defined in [`src/types.ts`](src/types.ts). Internal `configTrees`, `sessionFormatMigration`, and generated `moduleFallback` metadata remain owned by their image-packer, catalog, and launcher readers; the public types do not expose them. -`DshPackageManifest.preview` optionally names a feature image, and `PluginLocalizedMeta.preview` carries its resolved data URL. It is independent of the small `icon` and is omitted when no preview is supplied. +`DshPackageManifest.preview` optionally names a feature image or maps language ids to image paths with an English fallback. `PluginLocalizedMeta.preview` carries the corresponding data URL or language map, resolved by the UI like localized text. It is independent of the small `icon` and is omitted when no preview is supplied. ----- diff --git a/packages/util/package-manifest/README.zh.md b/packages/util/package-manifest/README.zh.md index e954b3244b..537c34ad5c 100644 --- a/packages/util/package-manifest/README.zh.md +++ b/packages/util/package-manifest/README.zh.md @@ -56,7 +56,7 @@ const manifest: DshPackageManifest = { 公共组合声明定义在 [`src/types.ts`](src/types.ts) 中。内部 `configTrees`、`sessionFormatMigration` 和生成的 `moduleFallback` 元数据分别由镜像打包器、目录生成器和启动器读取方拥有;公共类型不暴露这些字段。 -`DshPackageManifest.preview` 可选地指定功能配图,`PluginLocalizedMeta.preview` 携带解析后的 data URL。它与小尺寸 `icon` 相互独立;未提供配图时省略。 +`DshPackageManifest.preview` 可选地指定功能配图,或将语言标识映射到图片路径并提供英文回退。`PluginLocalizedMeta.preview` 携带对应的 data URL 或语言映射,界面像本地化文本一样解析。它与小尺寸 `icon` 相互独立;未提供配图时省略。 ----- diff --git a/packages/util/package-manifest/src/types.ts b/packages/util/package-manifest/src/types.ts index c95c9a20ec..3e6c9532c3 100644 --- a/packages/util/package-manifest/src/types.ts +++ b/packages/util/package-manifest/src/types.ts @@ -14,8 +14,11 @@ export interface DshPackageManifest { description?: string /** SVG, PNG, JPEG, or WebP file relative to this manifest's directory, at most 256 KiB and contained there after realpath resolution. */ icon?: string - /** Feature preview image relative to this manifest, SVG/PNG/JPEG/WebP up to 1 MiB and contained in its directory. */ - preview?: string + /** + * Feature image path, or language-to-path map with an English fallback. + * SVG/PNG/JPEG/WebP files stay in this directory, up to 1 MiB total. + */ + preview?: LocalizedText /** Prevent npm publication, for example for local profile projects. */ private?: boolean /** Packages installed alongside this package. */ @@ -51,8 +54,8 @@ export interface PluginLocalizedMeta { readonly description?: LocalizedText /** Base64 image data URL from a package root's manifest icon or an exported `/icon`; render as an image, not inline markup. */ readonly icon?: string - /** Base64 image data URL for the bundle detail preview; read without enabling the plugin and rendered as an image. */ - readonly preview?: string + /** Base64 image data URL or language-to-data-URL map with an English fallback; read before activation and rendered as an image. */ + readonly preview?: LocalizedText /** Unmodified local metadata diagnostic; the plugin remains manageable. */ readonly error?: string } diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index d2c4fb6c82..6b54e8415f 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -312,7 +312,13 @@ describe('package payload constraints', () => { ]) }) - it.each(['icon.svg', 'preview.svg'])('accepts the badge skill artwork payload and rejects omission of %s', (image) => { + it('includes each localized preview in the published payload without duplicate paths', () => { + expect(expectedDshPackageFiles({ preview: { en: './preview.svg', zh: './preview.zh.svg', fr: './preview.svg' } })).toEqual([ + 'preview.svg', 'preview.zh.svg', 'lib/index.js', 'lib/types/**/*.d.ts', + ]) + }) + + it.each(['icon.svg', 'preview.svg', 'preview.zh.svg'])('accepts the badge skill artwork payload and rejects omission of %s', (image) => { const dir = 'packages/experimental/badge-skill-bundle' const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest'] expect(checkWorkspaceManifest({ dir, manifest })).toEqual([]) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0c1239c624..eda79f4844 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -18,7 +18,7 @@ import { PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES, } from './experimental-package-policy.ts' import { hasTypertRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts' -import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts' +import type { DshBundleManifest, LocalizedText } from '../packages/util/package-manifest/src/types.ts' import { OPTIONAL_BUNDLES, bundlePatchFiles } from '../packages/boot/app-boot/src/profile.ts' import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts' @@ -85,7 +85,7 @@ export interface PackageManifest { exports?: Record files?: string[] icon?: string - preview?: string + preview?: LocalizedText publishConfig?: { access?: string } repository?: { type?: string; url?: string; directory?: string } peerDependencies?: Record @@ -259,7 +259,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str const targets = (value: ExportTarget | undefined): string[] => typeof value === 'string' ? [value] : typeof value === 'object' && value !== null ? Object.values(value).flatMap(targets) : [] const images = [ - ...[manifest.icon, manifest.preview].flatMap(image => typeof image === 'string' ? [image.replace(/^\.\//u, '')] : []), + ...[manifest.icon, manifest.preview].flatMap(targets).map(image => image.replace(/^\.\//u, '')), ...Object.entries(manifest.exports ?? {}).filter(([key]) => /^\.\/(?:.+\/)?(?:icon|preview)$/u.test(key)) .flatMap(([, target]) => targets(target)).filter(icon => icon.startsWith('./')).map(icon => icon.slice(2)), ] diff --git a/scripts/gen-official-bundle-catalog.spec.ts b/scripts/gen-official-bundle-catalog.spec.ts index 3151423b42..27c0991aac 100644 --- a/scripts/gen-official-bundle-catalog.spec.ts +++ b/scripts/gen-official-bundle-catalog.spec.ts @@ -67,6 +67,19 @@ describe('Official bundle catalog generation', () => { expect(renderOfficialBundleCatalog(entries)).toContain(`preview: '${preview}'`) }) + it('preserves translated preview images in offline discovery', () => { + const root = fixture() + const name = ON_DEMAND_BUNDLES[0]! + updateManifest(root, name, { preview: { en: './preview.png', zh: './preview.zh.png' } }) + write(root, `${directory(name)}/preview.png`, 'English') + write(root, `${directory(name)}/preview.zh.png`, '中文') + const entries = generateOfficialBundleCatalog(root) + const en = `data:image/png;base64,${Buffer.from('English').toString('base64')}` + const zh = `data:image/png;base64,${Buffer.from('中文').toString('base64')}` + expect(entries[0]?.meta.preview).toEqual({ en, zh }) + expect(renderOfficialBundleCatalog(entries)).toContain(`preview: { 'en': '${en}', 'zh': '${zh}' }`) + }) + it.each(['private', 'not-bundle', 'missing', 'duplicate'])('rejects %s catalog membership', (kind) => { const root = fixture() const name = ON_DEMAND_BUNDLES[0]! diff --git a/scripts/gen-official-bundle-catalog.ts b/scripts/gen-official-bundle-catalog.ts index 66b84788bd..b5c7afa976 100644 --- a/scripts/gen-official-bundle-catalog.ts +++ b/scripts/gen-official-bundle-catalog.ts @@ -95,7 +95,7 @@ export function renderOfficialBundleCatalog(entries: readonly OfficialBundleCata + ` title: ${localized(entry.meta.title)},\n` + ` description: ${localized(entry.meta.description)},\n` + ` icon: ${quote(entry.meta.icon)},\n` - + (entry.meta.preview === undefined ? '' : ` preview: ${quote(entry.meta.preview)},\n`) + + (entry.meta.preview === undefined ? '' : ` preview: ${localized(entry.meta.preview)},\n`) + ' },\n },\n').join('') + ']\n' } diff --git a/scripts/verify-package-meta.spec.ts b/scripts/verify-package-meta.spec.ts index 50b33fc66c..c7db17812b 100644 --- a/scripts/verify-package-meta.spec.ts +++ b/scripts/verify-package-meta.spec.ts @@ -67,6 +67,18 @@ it('requires optional preview files to be published', () => { expect(packageMetaProblems(root)).toEqual([`${join(dir, 'package.json')}: files must include preview.png`]) }) +it('validates every localized preview and requires each variant in the published package', () => { + const preview = { en: './preview.svg', ZH: './preview.zh.svg' } + manifest({ preview, files: ['preview.svg'] }) + file('preview.svg', validSvg) + file('preview.zh.svg', validSvg) + expect(packageMetaProblems(root)).toEqual([`${join(dir, 'package.json')}: files must include preview.zh.svg`]) + manifest({ preview, files: ['preview*.svg'] }) + expect(packageMetaProblems(root)).toEqual([]) + file('preview.zh.svg', validSvg.replace('run() { manifest({ preview: './preview.png', exports: { '.': './lib/index.js' }, files: ['preview.png'] }) file('preview.png', 'image') diff --git a/scripts/verify-package-meta.ts b/scripts/verify-package-meta.ts index a90490b45c..604fb459f8 100644 --- a/scripts/verify-package-meta.ts +++ b/scripts/verify-package-meta.ts @@ -198,16 +198,21 @@ function packageProblems(manifestPath: string, parser: DOMParser): string[] { if (sourceManifest && resources.every(({ file }) => byPath.has(file))) { const meta = readPluginMeta(specifier, parentURL) if (meta?.error !== undefined) problems.push(meta.error) - const imageTargets = imageFields.map((field) => { + const imageTargets = imageFields.flatMap((field) => { const image = meta?.[field] - if (image === undefined) return undefined - const path = displayDocument?.[field] - const target = displayManifest !== undefined && typeof path === 'string' - ? resolve(dirname(displayManifest), path) : lookup(`${specifier}/${field}`) - if (pkg.name.startsWith('@deepseek-ai/dsh-')) { - problems.push(...svgArtworkProblems(image, parser, field).map(problem => `${target ?? specifier}: ${problem}`)) - } - return target + if (image === undefined) return [] + const declaration = displayDocument?.[field] + const variants = typeof image === 'string' ? [['', image] as const] : Object.entries(image) + return variants.map(([language, data]) => { + const path: unknown = typeof declaration === 'object' && declaration !== null + ? Object.entries(declaration).find(([key]) => key.toLowerCase() === language)?.[1] : declaration + const target = displayManifest !== undefined && typeof path === 'string' + ? resolve(dirname(displayManifest), path) : lookup(`${specifier}/${field}`) + if (pkg.name.startsWith('@deepseek-ai/dsh-')) { + problems.push(...svgArtworkProblems(data, parser, field).map(problem => `${target ?? specifier}: ${problem}`)) + } + return target + }) }) for (const target of [displayManifest, ...imageTargets]) { if (target === undefined) continue