diff --git a/apps/web/tests/expected/plugin-manager/preview.expected.md b/apps/web/tests/expected/plugin-manager/preview.expected.md new file mode 100644 index 0000000000..ad45631426 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/preview.expected.md @@ -0,0 +1,21 @@ +- button "返回 实验性插件": 实验性插件 +- switch "启用 DSH 徽章技能" +- heading "DSH 徽章技能" [level=3] +- text: v0.2.1-alpha.2 实验性 +- paragraph: + - code: "@deepseek-ai/dsh-experimental-badge-skill-bundle" +- paragraph: 让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。 +- img "DSH 徽章技能功能预览" +- heading "包含的组件" [level=4] +- text: 共 1 个 · 1 已停用 +- list: + - listitem: + - text: DSH 提供官方 powered-by-DSH 徽章技能。 + - code: skill-badge + - code: "@deepseek-ai/dsh-skill-badge" + - text: 已关闭 +- heading "来源信息" [level=4] +- term: 代码来源 +- definition: 内置 +- term: 当前版本 +- definition: 0.2.1-alpha.2 diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 5502602721..c74a8e27df 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -687,6 +687,31 @@ describe('web e2e: plugin manager', () => { expect(tripwire.pageErrors).toEqual([]) }) + it('shows an optional feature preview before activation and leaves other plugin details unchanged', async () => { + const panel = await openPluginsPanel() + await panel.getByRole('button', { name: '更多', exact: true }).click() + await panel.getByRole('button', { name: '查看 DSH 徽章技能', exact: true }).click() + const detail = panel.locator('[data-plugin-detail]') + const preview = detail.getByRole('img', { name: 'DSH 徽章技能功能预览', exact: true }) + await preview.waitFor() + await preview.evaluate(async (image: HTMLImageElement) => { await image.decode() }) + expect(await preview.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(640) + expect(await detail.getByRole('switch').getAttribute('aria-checked')).toBe('false') + const fits = await preview.evaluate((image: HTMLImageElement) => { + const bounds = image.getBoundingClientRect() + const parent = image.parentElement!.getBoundingClientRect() + return bounds.width > 0 && bounds.height > 0 && bounds.left >= parent.left && bounds.right <= parent.right + 1 + }) + expect(fits).toBe(true) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'), + await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE) + await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click() + await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() + expect(await panel.getByRole('img').count()).toBe(0) + await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click() + expect(tripwire.pageErrors).toEqual([]) + }) + it('localizes independent exports and falls back per field without activating the plugins', async () => { onTestFinished(closeSettings) onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports')) @@ -1067,7 +1092,7 @@ describe('web e2e: plugin manager', () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md', - 'ime-enter.expected.md', 'header-responsive.expected.md', + 'ime-enter.expected.md', 'header-responsive.expected.md', 'preview.expected.md', ]) }) }) diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 013ecb4344..94a7d3608b 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: 2382017c7f84ace6 - zh: 03af99592baaab9f + en: 80c93d1bf8215864 + zh: 846a02590c73b0af /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 af3547ea1f..bc52f8404a 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -147,6 +147,8 @@ 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. + 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. Only Settings shortens literal package-name and module-name fallbacks by removing npm scope and Cordis/DSH prefixes; Plugin Manager keeps complete names. Locale titles and descriptions remain unchanged. A row configuration page can use its registered summary when the plugin has no display description. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index c45632d3e7..e6c2eceef8 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -147,6 +147,8 @@ 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 或优化后的截图,读取时无需激活插件。省略配图不改变详情布局;配图错误不影响其他元信息和管理操作。插件不必提供配图。 + 已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。 仅设置页会移除字面包名和模块名回退值的 npm scope 与 Cordis/DSH 前缀;插件管理页保留完整名称。locale 标题和描述保持原样。插件没有展示描述时,行配置页可以使用注册组件的摘要。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index daa58ac944..3345f901cc 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: a9afb263b03d112c - zh: 31f5e797cd953c15 + en: e3a731622b9acdd2 + zh: c60284a12a17cd92 /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 a6b962e743..ee877e3caf 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -88,6 +88,8 @@ 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. + ### Startup and reload failures diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index a063fcbcc6..1401f75cf1 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -88,6 +88,8 @@ 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。每张图片独立读取:配图出错时保留有效文本与图标,图标出错时保留有效配图。读取两类图片均无需激活插件。 + ### 启动与重载失败 diff --git a/packages/boot/app-boot/src/official-bundles.ts b/packages/boot/app-boot/src/official-bundles.ts index fdcb8fda52..4916c67a50 100644 --- a/packages/boot/app-boot/src/official-bundles.ts +++ b/packages/boot/app-boot/src/official-bundles.ts @@ -8,8 +8,8 @@ export { ON_DEMAND_BUNDLES } from './official-bundle-packages.ts' export interface OfficialBundleCatalogEntry { /** Exact npm package name accepted by the existing bundle installer. */ readonly packageName: string - /** Package-owned localized text and a self-contained icon, embedded without provider code. */ - readonly meta: Required> + /** Package-owned localized text and self-contained images, embedded without provider code. */ + readonly meta: Required> & Pick } export { OFFICIAL_ON_DEMAND_CATALOG } from './official-bundles.generated.ts' diff --git a/packages/boot/app-boot/src/package-meta.ts b/packages/boot/app-boot/src/package-meta.ts index b54a930724..382661e2c2 100644 --- a/packages/boot/app-boot/src/package-meta.ts +++ b/packages/boot/app-boot/src/package-meta.ts @@ -1,4 +1,4 @@ -/** Read plugin display text and icons through exported resources without evaluating plugin code. */ +/** Read plugin display text and images through exported resources without evaluating plugin code. */ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs' import { dirname, extname, isAbsolute, join, relative, resolve, sep, win32 } from 'node:path' @@ -12,54 +12,55 @@ const LANGUAGE_ID = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u type DisplayFields = Record<'title' | 'description', string | undefined> -/** Maximum raw icon bytes admitted to plugin metadata. */ -const MAX_ICON_BYTES = 256 * 1024 -const ICON_MEDIA_TYPES = new Map([ +/** Raw image limits keep plugin metadata bounded before base64 encoding. */ +const IMAGE_LIMIT_KIB = { icon: 256, preview: 1024 } as const +type ImageField = keyof typeof IMAGE_LIMIT_KIB +const IMAGE_MEDIA_TYPES = new Map([ ['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'], ]) -function iconOf( - specifier: string, packageName: string, parentURL: string, manifestPath: string | undefined, value: unknown, +function imageOf( + field: ImageField, specifier: string, packageName: string, parentURL: string, manifestPath: string | undefined, value: unknown, ): string | undefined { - const icon = manifestPath === undefined ? undefined : textOf(value, `${manifestPath}: icon`) + const image = manifestPath === undefined ? undefined : textOf(value, `${manifestPath}: ${field}`) let target: string // Diagnostics name the file the author edits: the declaring manifest or the exported image. let label: string - if (manifestPath !== undefined && icon !== undefined) { - if (isAbsolute(icon) || win32.isAbsolute(icon) || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(icon)) { - throw new Error(`${manifestPath}: icon must be a relative file path`) + if (manifestPath !== undefined && image !== undefined) { + if (isAbsolute(image) || win32.isAbsolute(image) || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(image)) { + throw new Error(`${manifestPath}: ${field} must be a relative file path`) } label = manifestPath const root = realpathSync(dirname(manifestPath)) - target = resolve(root, icon) + target = resolve(root, image) const local = relative(root, realModuleFile(target)) if (local === '..' || local.startsWith(`..${sep}`) || isAbsolute(local)) { - throw new Error(`${label}: icon must remain inside its manifest directory`) + throw new Error(`${label}: ${field} must remain inside its manifest directory`) } } else { - const exported = optionalResourcePath(`${specifier}/icon`, parentURL) + const exported = optionalResourcePath(`${specifier}/${field}`, parentURL) if (exported === undefined) return undefined target = label = exported - assertPackageOwned(realModuleFile(target), packageName) + assertPackageOwned(realModuleFile(target), packageName, field) } - const mediaType = ICON_MEDIA_TYPES.get(extname(target).toLowerCase()) - if (mediaType === undefined) throw new Error(`${label}: icon must be SVG, PNG, JPEG, or WebP`) + const mediaType = IMAGE_MEDIA_TYPES.get(extname(target).toLowerCase()) + if (mediaType === undefined) throw new Error(`${label}: ${field} must be SVG, PNG, JPEG, or WebP`) const file = realModuleFile(target) const stat = statSync(file) - if (!stat.isFile()) throw new Error(`${label}: icon must be a regular file`) - if (stat.size > MAX_ICON_BYTES) throw new Error(`${label}: icon exceeds 256 KiB`) + if (!stat.isFile()) throw new Error(`${label}: ${field} must be a regular file`) + if (stat.size > IMAGE_LIMIT_KIB[field] * 1024) throw new Error(`${label}: ${field} exceeds ${IMAGE_LIMIT_KIB[field]} KiB`) const bytes = readFileSync(file) - if (bytes.length > MAX_ICON_BYTES) throw new Error(`${label}: icon exceeds 256 KiB`) + if (bytes.length > IMAGE_LIMIT_KIB[field] * 1024) throw new Error(`${label}: ${field} exceeds ${IMAGE_LIMIT_KIB[field]} KiB`) return `data:${mediaType};base64,${bytes.toString('base64')}` } /** Require an ancestor of the real file whose package.json names the owning package. */ -function assertPackageOwned(file: string, name: string): void { +function assertPackageOwned(file: string, name: string, field: ImageField): void { for (let directory = dirname(file); ; directory = dirname(directory)) { const manifest = join(directory, 'package.json') if (existsSync(manifest) && readObject(manifest).name === name) return - if (dirname(directory) === directory) throw new Error(`${file}: icon must remain inside its package directory`) + if (dirname(directory) === directory) throw new Error(`${file}: ${field} must remain inside its package directory`) } } @@ -156,13 +157,13 @@ function localizedText( } /** - * Read plugin locale JSON display text and icon without evaluating JavaScript entries. + * Read plugin locale JSON display text and images without evaluating JavaScript entries. * Only a package-root specifier reads its exported package.json: `name`/`description` fill missing - * locale fields, and a declared `icon` takes priority over the `/icon` resource. - * A subpath specifier never reads package.json; its icon comes only from `/icon`. - * Manifest icons remain inside their declaring directory; exported icons remain inside their - * owning package after realpath resolution. Both accept SVG, PNG, JPEG, or WebP up to 256 KiB. - * Icon failures retain display text without falling back. + * locale fields, and declared `icon`/`preview` paths take priority over their exported resources. + * 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. * 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. @@ -186,14 +187,18 @@ export function readPluginMeta(specifier: string, parentURL: string): PluginLoca ...title === undefined ? {} : { title }, ...description === undefined ? {} : { description }, } - let icon: string | undefined - try { - icon = iconOf(specifier, packageName, parentURL, manifestPath, manifest?.icon) - } catch (error) { - return { ...text, error: `Plugin metadata for ${specifier}: ${String(error)}` } + const images: Partial> = {} + 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 + } catch (error) { + errors.push(`Plugin metadata for ${specifier}: ${String(error)}`) + } } - if (title === undefined && description === undefined && icon === undefined) return undefined - return { ...text, ...icon === undefined ? {} : { icon } } + if (title === undefined && description === undefined && Object.keys(images).length === 0 && errors.length === 0) return undefined + return { ...text, ...images, ...errors.length === 0 ? {} : { error: errors.join('\n') } } } catch (error) { return { error: `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 21ea6b9fd5..9cd872a2c5 100644 --- a/packages/boot/app-boot/tests/package-meta.spec.ts +++ b/packages/boot/app-boot/tests/package-meta.spec.ts @@ -378,6 +378,55 @@ describe('plugin locale display metadata', () => { } }) + it('reads an optional manifest preview without executing the plugin and preserves its icon', () => { + manifest({ '.': './index.js', './package.json': './package.json', './preview': './fallback.svg' }, { + icon: './icon.png', preview: './preview.svg', + }) + file(join(dir, 'icon.png'), 'icon') + file(join(dir, 'preview.svg'), '') + const meta = readPluginMeta('localized', parentURL) + expect(meta).toEqual({ + title: 'localized', icon: `data:image/png;base64,${Buffer.from('icon').toString('base64')}`, + preview: `data:image/svg+xml;base64,${Buffer.from('').toString('base64')}`, + }) + }) + + it('reads a subpath preview independently of root artwork', () => { + manifest({ './preview': './root.svg', './search/preview': './search.webp' }) + file(join(dir, 'root.svg'), 'root') + file(join(dir, 'search.webp'), 'search') + expect(readPluginMeta('localized/search', parentURL)).toEqual({ + preview: `data:image/webp;base64,${Buffer.from('search').toString('base64')}`, + }) + }) + + it.each([ + ['', 'non-empty string'], ['https://example.test/preview.png', 'relative file path'], + ['../preview.png', 'inside its manifest directory'], ['./preview.html', 'SVG, PNG, JPEG, or WebP'], + ])('keeps the icon and text when preview %s is invalid', (preview, diagnostic) => { + manifest({ './package.json': './package.json' }, { icon: './icon.svg', preview }) + file(join(dir, 'icon.svg'), 'icon') + file(join(dir, '../preview.png'), 'outside') + file(join(dir, 'preview.html'), 'markup') + const meta = readPluginMeta('localized', parentURL) + expect(meta?.title).toBe('localized') + expect(meta?.icon).toBeDefined() + expect(meta?.preview).toBeUndefined() + expect(meta?.error).toContain(diagnostic) + }) + + it('bounds previews at 1 MiB independently of icons', () => { + manifest({ './package.json': './package.json' }, { icon: './missing.svg', preview: './preview.png' }) + const bytes = Buffer.alloc(1024 * 1024) + writeFileSync(join(dir, 'preview.png'), bytes) + expect(readPluginMeta('localized', parentURL)?.preview).toBe(`data:image/png;base64,${bytes.toString('base64')}`) + writeFileSync(join(dir, 'preview.png'), Buffer.alloc(bytes.length + 1)) + const meta = readPluginMeta('localized', parentURL) + expect(meta?.preview).toBeUndefined() + expect(meta?.error).toContain('missing.svg') + expect(meta?.error).toContain('preview exceeds 1024 KiB') + }) + it('keeps separate plugin exports independent even when their JavaScript entries share a directory', () => { manifest({ './search': './lib/search.js', './review': './lib/review.js', @@ -495,7 +544,7 @@ describe('plugin locale display metadata', () => { expect(readPluginMeta('localized', parentURL)).toEqual({ title: { en: 'Resolved resource' }, description: { en: 'Resolved introduction' }, }) - const requests = ['localized/locale/en.json', 'localized/locale/en.json', 'localized/package.json', 'localized/icon'] + const requests = ['localized/locale/en.json', 'localized/locale/en.json', 'localized/package.json', 'localized/icon', 'localized/preview'] expect(resolveSync.mock.calls).toEqual(requests.map(specifier => version === 'v1' ? [specifier, parentURL, {}] : [parentURL, { specifier, attributes: {} }])) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index d210974ef2..1ec9665006 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: 87f6d96467dc2383 - zh: e0fb91aa4c4d5a12 + en: 8319b9e4028844a4 + zh: 5266a572fdfbbed6 /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 77c54ad9b0..66a0dbb72c 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -96,6 +96,8 @@ 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. + ----- diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index b228f3d99f..745932c572 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -96,6 +96,8 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ 行页只在某个 `plugins.row.config` 条目点名这一行时存在,因此给行的贡献渲染在该配置打开的页面上。 +组合包详情在描述下方展示可选的包内功能配图,未启用时也可查看。没有配图就不显示图片或预留空位;解码失败时显示本地化提示,不影响配置或启用。卡片与组件行保留小尺寸图标。 + ----- diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index dda60f3c91..f34aed6900 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -1298,6 +1298,17 @@ label.registryOption { margin-top: 12px; } +.packagePreview { + display: block; + align-self: flex-start; + max-width: 100%; + max-height: 320px; + margin-top: 24px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: var(--dsw-radius-lg); + object-fit: contain; +} + .detailSections { display: flex; flex-direction: column; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index bdaa5bae69..62d167bd5e 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -237,6 +237,14 @@ function PackageArtwork({ src, row = false, size = row ? ROW_ARTWORK_SIZE : CARD : { setFailedSource(src) }} /> } +/** A package-owned preview stays available before activation and never inserts markup into the page. */ +function PackagePreview({ src, title, t }: { readonly src: string; readonly title: string; readonly t: Translate }): ReactNode { + const [failed, setFailed] = useState(false) + return failed + ?

{t('previewUnavailable')}

+ : {t('previewAlt', { setFailed(true) }} /> +} + /** A row's switch: locked, saying why, when the Host refuses to address the row through the profile patch. */ function RowSwitch({ row, title, t, busy, onChange }: { readonly row: PackageRow @@ -753,6 +761,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 : }
{configured ? ( diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 4b4f14ec22..2902240b68 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -39,6 +39,8 @@ export const zh = { statusBeta: '实验性', reasonLabel: '原因', metadataError: '包元信息错误:{error}', + previewAlt: '{name}功能预览', + previewUnavailable: '功能配图暂时无法显示', versionTag: 'v{version}', partsLabel: '包含的组件', sourceTitle: '来源信息', @@ -261,6 +263,8 @@ export const en = { statusBeta: 'Experimental', reasonLabel: 'Reason', metadataError: 'Package metadata error: {error}', + previewAlt: '{name} feature preview', + previewUnavailable: 'Feature preview is unavailable', versionTag: 'v{version}', partsLabel: 'Components', sourceTitle: 'Source', 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 a0055f2a56..05634465ff 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -658,6 +658,27 @@ describe('PluginManagerPage', () => { expect(detailImage.width).toBe(36) }) + it('shows optional feature previews only in details, including before activation, and recovers after decode failure', () => { + const preview = 'data:image/svg+xml;base64,PHN2Zy8+' + const bundle = pkg({ enabled: false, meta: { preview } }) + const { set, setLanguage, actions } = renderTab({ packages: [bundle] }) + expect(screen.queryByRole('img')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'View dsh-better-sidebar' })) + expect(screen.getByRole('img', { name: en.previewAlt.replace('{name}', bundle.name) }).getAttribute('src')).toBe(preview) + setLanguage(zh) + fireEvent.error(screen.getByRole('img', { name: zh.previewAlt.replace('{name}', bundle.name) })) + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByRole('status').textContent).toBe(zh.previewUnavailable) + fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', bundle.name) })) + expect(actions.setEnabled).toHaveBeenCalledWith(bundle.name, true) + set({ packages: [{ ...bundle, meta: { preview: 'data:image/png;base64,cG5n' } }] }) + expect(screen.getByRole('img').getAttribute('src')).toBe('data:image/png;base64,cG5n') + expect(screen.queryByText(zh.previewUnavailable)).toBeNull() + set({ packages: [{ ...bundle, meta: {} }] }) + expect(screen.queryByRole('img')).toBeNull() + expect(screen.queryByText(zh.previewUnavailable)).toBeNull() + }) + 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 60e8cbb8d4..360685f608 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: 21fc43b87f896a26 - zh: 84c71fa133f0d9a0 + en: a5b3ce4165f7a95b + zh: a9c5f7d9d53c67cd /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 e7ca5eca42..69d2069e47 100644 --- a/packages/experimental/badge-skill-bundle/README.md +++ b/packages/experimental/badge-skill-bundle/README.md @@ -29,6 +29,8 @@ 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. + ----- diff --git a/packages/experimental/badge-skill-bundle/README.zh.md b/packages/experimental/badge-skill-bundle/README.zh.md index 20f005258b..8af98a0e3a 100644 --- a/packages/experimental/badge-skill-bundle/README.zh.md +++ b/packages/experimental/badge-skill-bundle/README.zh.md @@ -29,6 +29,8 @@ kind: "package-bundle" 开关为已有和新建智能体添加或移除 Host 级技能提供方。提供技能加载能力的预设可以使用徽章;minimal 的工具目录保持不变。 +可选的 `preview.svg` 示意在文档末尾添加官方署名徽章的效果,插件未启用时详情页也可展示。徽章资源和使用说明由技能拥有。 + ----- diff --git a/packages/experimental/badge-skill-bundle/package.json b/packages/experimental/badge-skill-bundle/package.json index 03ed23a354..4928dc866f 100644 --- a/packages/experimental/badge-skill-bundle/package.json +++ b/packages/experimental/badge-skill-bundle/package.json @@ -3,6 +3,7 @@ "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", "publishConfig": { "access": "public" }, @@ -26,6 +27,7 @@ }, "files": [ "icon.svg", + "preview.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 new file mode 100644 index 0000000000..1fce5c40c8 --- /dev/null +++ b/packages/experimental/badge-skill-bundle/preview.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index f78638be5e..496a815cca 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 error?: string;\n}', + 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}', }, { name: 'PluginOptions', diff --git a/packages/util/package-manifest/README.i18n.yaml b/packages/util/package-manifest/README.i18n.yaml index 2ce3905fcf..ef170086f9 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: f275964a8845888a - zh: b284e09aacea079b + en: 043740e276a0fd5c + zh: d825e0c613ddeccf /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 d50312947e..66d7e94416 100644 --- a/packages/util/package-manifest/README.md +++ b/packages/util/package-manifest/README.md @@ -56,6 +56,8 @@ 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. + ----- diff --git a/packages/util/package-manifest/README.zh.md b/packages/util/package-manifest/README.zh.md index 9a535a094a..e954b3244b 100644 --- a/packages/util/package-manifest/README.zh.md +++ b/packages/util/package-manifest/README.zh.md @@ -56,6 +56,8 @@ const manifest: DshPackageManifest = { 公共组合声明定义在 [`src/types.ts`](src/types.ts) 中。内部 `configTrees`、`sessionFormatMigration` 和生成的 `moduleFallback` 元数据分别由镜像打包器、目录生成器和启动器读取方拥有;公共类型不暴露这些字段。 +`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 e5289ad1e2..c95c9a20ec 100644 --- a/packages/util/package-manifest/src/types.ts +++ b/packages/util/package-manifest/src/types.ts @@ -14,6 +14,8 @@ 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 /** Prevent npm publication, for example for local profile projects. */ private?: boolean /** Packages installed alongside this package. */ @@ -41,7 +43,7 @@ export interface DshManifest { /** Literal text or translations indexed by lowercase language id, with a required English fallback. */ export type LocalizedText = string | { readonly en: string; readonly [locale: string]: string } -/** Validated plugin display fields and diagnostics from exported locales, manifests, or icon files. */ +/** Validated plugin display fields and diagnostics from exported locales, manifests, or image files. */ export interface PluginLocalizedMeta { /** Display title; omission preserves the consumer's technical-name fallback. */ readonly title?: LocalizedText @@ -49,6 +51,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 /** 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 434efaca09..d2c4fb6c82 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -306,11 +306,17 @@ describe('package payload constraints', () => { ]) }) - it('accepts the Agent Team icon payload and rejects its omission', () => { - const dir = 'packages/experimental/agent-team-profile' + it('includes optional manifest and exported previews in the published payload', () => { + expect(expectedDshPackageFiles({ preview: './preview.svg', exports: { './preview': './preview.svg', './search/preview': './search.png' } })).toEqual([ + 'preview.svg', 'search.png', 'lib/index.js', 'lib/types/**/*.d.ts', + ]) + }) + + it.each(['icon.svg', 'preview.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([]) - expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files: manifest.files!.filter(file => file !== 'icon.svg') } })) + expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files: manifest.files!.filter(file => file !== image) } })) .toEqual([expect.stringContaining('package.json files must be')]) }) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e4a038cf1b..0c1239c624 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -85,6 +85,7 @@ export interface PackageManifest { exports?: Record files?: string[] icon?: string + preview?: string publishConfig?: { access?: string } repository?: { type?: string; url?: string; directory?: string } peerDependencies?: Record @@ -238,9 +239,9 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl } /** - * Compute canonical publication patterns, including declared and exported icon paths and exported locale JSON resources. + * Compute canonical publication patterns, including declared and exported image paths and exported locale JSON resources. * @param manifest - workspace package manifest. - * @returns the icon and deduplicated locale targets followed by runtime and declaration payloads. + * @returns the images and deduplicated locale targets followed by runtime and declaration payloads. */ export function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { const localeFiles = new Set() @@ -257,13 +258,13 @@ 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 icons = [ - ...typeof manifest.icon === 'string' ? [manifest.icon.replace(/^\.\//u, '')] : [], - ...Object.entries(manifest.exports ?? {}).filter(([key]) => /^\.\/(?:.+\/)?icon$/u.test(key)) + const images = [ + ...[manifest.icon, manifest.preview].flatMap(image => typeof image === 'string' ? [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)), ] return [ - ...new Set(icons), + ...new Set(images), ...[...localeFiles].sort(), 'lib/index.js', ...manifest.bin ? ['lib/bin.js'] : [], diff --git a/scripts/gen-official-bundle-catalog.spec.ts b/scripts/gen-official-bundle-catalog.spec.ts index 54da13d7c3..3151423b42 100644 --- a/scripts/gen-official-bundle-catalog.spec.ts +++ b/scripts/gen-official-bundle-catalog.spec.ts @@ -55,6 +55,18 @@ describe('Official bundle catalog generation', () => { expect(renderOfficialBundleCatalog(entries)).not.toContain('1.2.3') }) + it('preserves an optional package-owned preview for offline discovery', () => { + const root = fixture() + const name = ON_DEMAND_BUNDLES[0]! + updateManifest(root, name, { preview: './preview.png' }) + write(root, `${directory(name)}/preview.png`, 'preview') + const entries = generateOfficialBundleCatalog(root) + const preview = `data:image/png;base64,${Buffer.from('preview').toString('base64')}` + expect(entries[0]?.meta.preview).toBe(preview) + expect(entries[1]?.meta.preview).toBeUndefined() + expect(renderOfficialBundleCatalog(entries)).toContain(`preview: '${preview}'`) + }) + 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 25e08f48c6..66b84788bd 100644 --- a/scripts/gen-official-bundle-catalog.ts +++ b/scripts/gen-official-bundle-catalog.ts @@ -69,7 +69,13 @@ export function generateOfficialBundleCatalog(root: string, names: readonly stri } } } - return { packageName, meta: { title: meta.title, description: meta.description, icon: meta.icon } } + return { + packageName, + meta: { + title: meta.title, description: meta.description, icon: meta.icon, + ...meta.preview === undefined ? {} : { preview: meta.preview }, + }, + } }) } @@ -89,6 +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`) + ' },\n },\n').join('') + ']\n' } diff --git a/scripts/verify-package-meta.spec.ts b/scripts/verify-package-meta.spec.ts index 3227c18c04..50b33fc66c 100644 --- a/scripts/verify-package-meta.spec.ts +++ b/scripts/verify-package-meta.spec.ts @@ -52,6 +52,45 @@ it.each(['legacy.svg', './legacy.svg'])('accepts a manifest-only icon %s', (icon expect(packageMetaProblems(root)).toEqual([]) }) +it.each(['manifest', 'export'])('accepts optional wide preview artwork selected by %s', (selection) => { + manifest({ + ...selection === 'manifest' ? { preview: './preview.svg' } : {}, + exports: { './package.json': './package.json', './preview': './preview.svg' }, files: ['preview.svg'], + }) + file('preview.svg', validSvg.replaceAll('36', '960')) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('requires optional preview files to be published', () => { + manifest({ preview: './preview.png' }) + file('preview.png', 'image') + expect(packageMetaProblems(root)).toEqual([`${join(dir, 'package.json')}: files must include preview.png`]) +}) + +it('requires preview declarations to be discoverable', () => { + manifest({ preview: './preview.png', exports: { '.': './lib/index.js' }, files: ['preview.png'] }) + file('preview.png', 'image') + expect(packageMetaProblems(root).join('\n')).toContain('exports must expose its preview declaration') +}) + +it.each([ + ['data:image/png;base64,aW1hZ2U=', true], + ['https://example.test/badge.png', false], + ['data:image/svg+xml;base64,PHN2Zy8+', false], +])('only permits embedded raster images in previews: %s', (href, accepted) => { + manifest({ preview: './preview.svg', files: ['preview.svg'] }) + file('preview.svg', validSvg.replace('', ``)) + const problems = packageMetaProblems(root) + if (accepted) expect(problems).toEqual([]) + else expect(problems.join('\n')).toContain('must name a local id') +}) + +it('rejects executable SVG content in official previews', () => { + manifest({ preview: './preview.svg', files: ['preview.svg'] }) + file('preview.svg', validSvg.replace('alert(1) { manifest({ ...selection === 'manifest' ? { icon: './icon.svg' } : {}, diff --git a/scripts/verify-package-meta.ts b/scripts/verify-package-meta.ts index 0d0188530e..a90490b45c 100644 --- a/scripts/verify-package-meta.ts +++ b/scripts/verify-package-meta.ts @@ -11,6 +11,7 @@ interface Manifest { exports?: unknown files?: string[] icon?: unknown + preview?: unknown } interface SourceJson { @@ -18,6 +19,7 @@ interface SourceJson { metadata: boolean invalid: boolean icon?: unknown + preview?: unknown packageName?: string } @@ -37,6 +39,7 @@ function sourceJsonFiles(dir: string): SourceJson[] { return { file, metadata: Object.hasOwn(contents, 'meta'), invalid: false, icon: 'icon' in contents ? contents.icon : undefined, + preview: 'preview' in contents ? contents.preview : undefined, ...basename(file) === 'package.json' && typeof name === 'string' && name.trim() !== '' ? { packageName: name } : {}, } }) @@ -74,37 +77,40 @@ function published(file: string, files: string[]): boolean { } /** Structural artwork conventions only; not an SVG sanitizer or a rendered-pixel check. */ -function svgArtworkProblems(icon: string, parser: DOMParser): string[] { +function svgArtworkProblems(image: string, parser: DOMParser, field: 'icon' | 'preview'): string[] { const prefix = 'data:image/svg+xml;base64,' - if (!icon.startsWith(prefix)) return [] - const document = parser.parseFromString(Buffer.from(icon.slice(prefix.length), 'base64').toString('utf8'), 'image/svg+xml') - if (document.querySelector('parsererror') !== null) return ['SVG icon must contain well-formed XML'] + if (!image.startsWith(prefix)) return [] + const document = parser.parseFromString(Buffer.from(image.slice(prefix.length), 'base64').toString('utf8'), 'image/svg+xml') + if (document.querySelector('parsererror') !== null) return [`SVG ${field} must contain well-formed XML`] const svg = document.documentElement if (svg.localName !== 'svg' || svg.namespaceURI !== 'http://www.w3.org/2000/svg') { - return ['SVG icon must have an svg root in the SVG namespace'] + return [`SVG ${field} must have an svg root in the SVG namespace`] } const problems: string[] = [] - if (svg.getAttribute('viewBox') !== '0 0 36 36' || svg.getAttribute('width') !== '36' || svg.getAttribute('height') !== '36') { + if (field === 'icon' && (svg.getAttribute('viewBox') !== '0 0 36 36' || svg.getAttribute('width') !== '36' || svg.getAttribute('height') !== '36')) { problems.push('SVG icon must use width="36" height="36" viewBox="0 0 36 36"') } if (svg.querySelector('path, rect, circle, ellipse, line, polyline, polygon') === null) { - problems.push('SVG icon must contain geometry') + problems.push(`SVG ${field} must contain geometry`) } - if (svg.querySelector('script, style, image, foreignObject, text, animate, animateTransform, animateMotion, set') !== null) { - problems.push('SVG icon must use self-contained geometry without scripts, styles, images, foreignObject, text, or animation') + const forbidden = 'script, style, foreignObject, text, animate, animateTransform, animateMotion, set' + if (svg.querySelector(field === 'icon' ? `${forbidden}, image` : forbidden) !== null) { + problems.push(`SVG ${field} must use self-contained geometry without scripts, styles, foreignObject, text, or animation`) } const elements = [svg, ...svg.querySelectorAll('*')] const ids = elements.flatMap(element => element.hasAttribute('id') ? [element.id] : []) const uniqueIds = new Set(ids) - if (uniqueIds.size !== ids.length) problems.push('SVG icon must use unique ids') + if (uniqueIds.size !== ids.length) problems.push(`SVG ${field} must use unique ids`) for (const element of elements) { for (const attribute of element.attributes) { - if (/^on/iu.test(attribute.name)) problems.push(`SVG icon must not use event handler ${attribute.name}`) + if (/^on/iu.test(attribute.name)) problems.push(`SVG ${field} must not use event handler ${attribute.name}`) const references = [...attribute.value.matchAll(/url\(([^)]+)\)/gu)].map(match => (match[1] ?? '').replace(/['"]/gu, '').trim()) if (attribute.localName === 'href') references.push(attribute.value) for (const reference of references) { + if (field === 'preview' && element.localName === 'image' && attribute.localName === 'href' + && /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z\d+/]+=*$/u.test(reference)) continue if (!reference.startsWith('#') || !uniqueIds.has(reference.slice(1))) { - problems.push(`SVG icon reference ${reference} must name a local id`) + problems.push(`SVG ${field} reference ${reference} must name a local id`) } } } @@ -130,7 +136,9 @@ function packageProblems(manifestPath: string, parser: DOMParser): string[] { if (!key.startsWith('.')) continue if (!key.includes('*')) { candidates.add(key) - if (key.endsWith('/icon')) candidates.add(key.slice(0, -'/icon'.length)) + for (const field of ['icon', 'preview']) { + if (key.endsWith(`/${field}`)) candidates.add(key.slice(0, -`/${field}`.length)) + } } const plugin = pluginOf(key) if (plugin !== undefined && !plugin.includes('*')) candidates.add(plugin) @@ -171,39 +179,49 @@ function packageProblems(manifestPath: string, parser: DOMParser): string[] { return file === undefined ? [] : [{ filename, file }] }) const displayManifest = candidate === '.' ? lookup(`${specifier}/package.json`) : undefined - if (candidate === '.' && pkg.icon !== undefined && displayManifest !== join(dir, 'package.json')) { - problems.push(`${manifestPath}: exports must expose its icon declaration through ${pkg.name}/package.json`) + const imageFields = ['icon', 'preview'] as const + for (const field of imageFields) { + if (candidate === '.' && pkg[field] !== undefined && displayManifest !== join(dir, 'package.json')) { + problems.push(`${manifestPath}: exports must expose its ${field} declaration through ${pkg.name}/package.json`) + } + const resource = `${candidate}/${field}` + if (exported.some(([key]) => key === resource) && lookup(`${specifier}/${field}`) === undefined) { + problems.push(`${manifestPath}: exports["${resource}"] must resolve to a file`) + } } - const iconExport = `${candidate}/icon` - const exportsIcon = exported.some(([key]) => key === iconExport) - if (exportsIcon && lookup(`${specifier}/icon`) === undefined) { - problems.push(`${manifestPath}: exports["${iconExport}"] must resolve to a file`) - } - const iconDocument = displayManifest === undefined ? undefined : byPath.get(displayManifest) + const displayDocument = displayManifest === undefined ? undefined : byPath.get(displayManifest) // Build output is not validated source; reading it would make the result depend on stale artifacts. - const sourceManifest = displayManifest === undefined || iconDocument !== undefined + const sourceManifest = displayManifest === undefined || displayDocument !== undefined if (!sourceManifest) { problems.push(`${specifier}/package.json: display manifest must resolve to source JSON, received ${displayManifest}`) } if (sourceManifest && resources.every(({ file }) => byPath.has(file))) { const meta = readPluginMeta(specifier, parentURL) if (meta?.error !== undefined) problems.push(meta.error) - const iconTarget = meta?.icon === undefined ? undefined - : displayManifest !== undefined && typeof iconDocument?.icon === 'string' - ? resolve(dirname(displayManifest), iconDocument.icon) - : lookup(`${specifier}/icon`) - if (pkg.name.startsWith('@deepseek-ai/dsh-') && meta?.icon !== undefined) { - problems.push(...svgArtworkProblems(meta.icon, parser).map(problem => `${iconTarget ?? specifier}: ${problem}`)) - } - for (const target of [displayManifest, iconTarget]) { + const imageTargets = imageFields.map((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 + }) + for (const target of [displayManifest, ...imageTargets]) { if (target === undefined) continue const file = relative(dir, target).replaceAll('\\', '/') if (file !== 'package.json' && pkg.files !== undefined && !published(file, pkg.files)) { problems.push(`${manifestPath}: files must include ${file}`) } } - } else if (sourceManifest && (exportsIcon || iconDocument?.icon !== undefined)) { - problems.push(`${specifier}: icon metadata requires locale resources to resolve to source JSON`) + } else if (sourceManifest) { + for (const field of imageFields) { + if (exported.some(([key]) => key === `${candidate}/${field}`) || displayDocument?.[field] !== undefined) { + problems.push(`${specifier}: ${field} metadata requires locale resources to resolve to source JSON`) + } + } } if (!resources.some(({ file }) => byPath.get(file)?.metadata || byPath.get(file)?.invalid)) continue for (const { file } of resources) claimed.add(file) @@ -257,8 +275,8 @@ function packageProblems(manifestPath: string, parser: DOMParser): string[] { /** * Check workspace and named nested package metadata without evaluating plugin entries or reading built output. - * Selected SVG icons in @deepseek-ai/dsh-* packages follow official artwork conventions. - * Other packages and supported raster icons keep their metadata and publication checks. + * Selected SVG images in @deepseek-ai/dsh-* packages follow official artwork conventions. + * Other packages and supported raster images keep their metadata and publication checks. * @param root - repository or fixture root. * @returns diagnostics for an empty package corpus, invalid metadata or SVG artwork, inaccessible resources, or omitted publication files. */