feat(plugins): render icons from package manifests (#4697)

* feat(plugins): render icons from package manifests

* fix(plugins): include declared icons in canonical package payloads
This commit is contained in:
Turtle authored and GitHub committed 2026-09-20 17:00:17 +08:00
1 parent 3eab1714bc
commit 8f0dd55c2c
39 files changed
+437 -85

No files matched your search

@@ -0,0 +1,5 @@
Third-party bundle card: image, 36×36, decoded
Disabled Agent Teams card: image, 36×36, decoded
Agent Teams detail: image, 36×36, decoded
Third-party bundle detail: image, 36×36, decoded
Independent search row: image, 30×30, decoded
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><circle cx="18" cy="18" r="14" fill="#145AF3" /></svg>

After

Width:  |  Height:  |  Size: 138 B

@@ -1,6 +1,7 @@
{
"name": "@fixture/bundle",
"version": "0.0.1",
"icon": "./icon.svg",
"private": true,
"description": "Registry description for the fixture bundle.",
"type": "module",
@@ -1,4 +1,5 @@
{
"name": "Search plugin",
"icon": "./icon.svg",
"description": "Search package introduction."
}
+45 -1
View File
@@ -140,6 +140,50 @@ describe('web e2e: plugin manager', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('decodes manifest icons for disabled bundles and independent plugin rows', async () => {
const panel = await openPluginsPanel()
const fixtureIcon = `data:image/svg+xml;base64,${(await readFile(join(FIXTURE_PLUGINS, 'fixture-bundle/icon.svg'))).toString('base64')}`
const teamIcon = `data:image/svg+xml;base64,${(await readFile(fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/icon.svg', import.meta.url)))).toString('base64')}`
const images: string[] = []
const checkImage = async (selector: string, source: string, label: string) => {
const image = panel.locator(`${selector} img`).first()
await image.waitFor()
expect(await image.getAttribute('src')).toBe(source)
await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
const size = await image.evaluate((node: HTMLImageElement) => ({
width: node.width, height: node.height, naturalWidth: node.naturalWidth,
}))
expect(size.naturalWidth).toBeGreaterThan(0)
images.push(`${label}: image, ${size.width}×${size.height}, decoded`)
}
await checkImage('[data-plugin-package="@fixture/bundle"]', fixtureIcon, 'Third-party bundle card')
const team = panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]')
expect(await team.getByRole('switch').getAttribute('aria-checked')).toBe('false')
await checkImage('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]', teamIcon, 'Disabled Agent Teams card')
try {
for (const colorScheme of ['light', 'dark'] as const) {
await page.emulateMedia({ colorScheme })
expect(await team.locator('img').evaluate((node: HTMLImageElement) => node.naturalWidth)).toBe(36)
if (MODE === 'refresh') {
const path = fileURLToPath(new URL(`../../../.artifacts/plugin-icons-${process.pid}-${colorScheme}.png`, import.meta.url))
await page.screenshot({ path })
console.log(`Plugin icon screenshot: ${path}`)
}
}
} finally {
await page.emulateMedia({ colorScheme: null })
}
await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click()
await checkImage('[data-plugin-detail]', teamIcon, 'Agent Teams detail')
await panel.getByRole('button', { name: '返回插件列表' }).click()
await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click()
await checkImage('[data-plugin-detail]', fixtureIcon, 'Third-party bundle detail')
await checkImage('[data-plugin-row="fixture-search"]', fixtureIcon, 'Independent search row')
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'icons.expected.md'), images.join('\n'), MODE)
await panel.getByRole('button', { name: '返回插件列表' }).click()
expect(tripwire.pageErrors).toEqual([])
})
it('localizes independent exports and falls back per field without activating the plugins', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports'))
const panel = await openPluginsPanel()
@@ -331,7 +375,7 @@ describe('web e2e: plugin manager', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
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',
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md',
])
})
})
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md
adding-a-package.md: 8c818bc371b4fcd646878ec440dd713f8c116083
adding-a-package.zh.md: f6a09b411e246c50fcf3f44117c05433b12cbd4c
adding-a-package.md: 60b5d6e047da5f9cdfab3ab504104a0f29b208f7
adding-a-package.zh.md: 44a613bcf002009c4c58a34c0f68cab85ef097f3
+3 -1
View File
@@ -143,7 +143,9 @@ Fields fall back independently before view-specific name formatting, using the e
- Title: locale `meta.title` → `package.json.name` → complete Cordis plugin name.
- Description: locale `meta.description` → `package.json.description` → no description.
Export `<package name>/locale/en.json` for locale lookup; expose `<package name>/package.json` when package-field fallback is needed.
Export `<package name>/locale/en.json` for locale lookup; expose `<package name>/package.json` for package-field fallback or an icon declaration.
For an image on bundle cards, details, and component rows, set top-level `"icon": "./icon.svg"` in that exported manifest and include the image in `files`. The path is relative to the declaring manifest's directory, including for independently exported plugin manifests. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. Images need no separate export and 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; missing or undecodable images use the panel's default artwork.
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.
+3 -1
View File
@@ -143,7 +143,9 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex
- 标题:locale `meta.title` → `package.json.name` → 完整 Cordis 插件名。
- 描述:locale `meta.description` → `package.json.description` → 不显示描述。
导出 `<包名>/locale/en.json` 供 locale 查询;需要包字段回退时,再开放 `<包名>/package.json`。
导出 `<包名>/locale/en.json` 供 locale 查询;需要包字段回退或声明图标时,开放 `<包名>/package.json`。
要在组合包卡片、详情和组件行显示图片,在该导出清单顶层设置 `"icon": "./icon.svg"`,并将图片加入 `files`。路径相对于声明清单所在目录,独立导出的插件清单也遵循此规则。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。绝对路径、URL、目录外路径,以及解析到目录外的符号链接均被拒绝。图片不需要单独导出,且必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活插件。声明无效或文件不可读时,显示元信息诊断并保留有效文本;图片缺失或无法解码时使用面板的默认插画。
已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
README.md: 8439fb9ec1173ba24f9775ac476e64d23534a594
README.zh.md: 0bb8c9819dacabed01905f6b7aff8c770ea3caae
README.md: 6f24c46485abe3761994ef7aacbea90840e01bfb
README.zh.md: b3a8aaa2746848bcc347291dad27f015e8a004e3
+1 -1
View File
@@ -68,7 +68,7 @@ Before you boot, you can print the exact configuration the app will mount: the d
### Reading plugin display metadata
Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifier, parentURL)` to read installed package display text without importing or activating the plugin. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. File paths and file URLs return no metadata without resolving resources. Missing locale fields fall back to the accessible `package.json` at that address; malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format.
Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifier, parentURL)` to read installed package display text without importing or activating the plugin. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. File paths and file URLs return no metadata without resolving resources. Missing locale fields fall back to the accessible `package.json` at that address; malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. The reader also loads `package.json.icon` as an image data URL, even when locale text is complete; an icon error preserves valid text alongside the diagnostic. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format.
<a id="startup-and-reload-failures"></a>
### Startup and reload failures
+1 -1
View File
@@ -68,7 +68,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
### 读取插件展示元信息
使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取已安装包的展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。缺失的 locale 字段回退到该地址下可访问的 `package.json`;格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。
使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取已安装包的展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。缺失的 locale 字段回退到该地址下可访问的 `package.json`;格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。即使 locale 文本完整,读取器也会将 `package.json.icon` 加载为图片 data URL;图标出错时,保留有效文本并附上诊断。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。
<a id="startup-and-reload-failures"></a>
### 启动与重载失败
+46 -11
View File
@@ -1,7 +1,7 @@
/** Read plugin display metadata through exported locale resources without evaluating plugin code. */
/** Read plugin display text and icons through exported resources without evaluating plugin code. */
import { readdirSync, readFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
import { dirname, extname, isAbsolute, relative, resolve, sep, win32 } from 'node:path'
import { fileURLToPath } from 'node:url'
import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader'
import type { LocalizedText, PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest'
@@ -11,6 +11,35 @@ 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([
['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'],
])
function iconOf(value: unknown, manifestPath: string): string | undefined {
const icon = textOf(value, `${manifestPath}: icon`)
if (icon === undefined) return 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`)
}
const mediaType = ICON_MEDIA_TYPES.get(extname(icon).toLowerCase())
if (mediaType === undefined) throw new Error(`${manifestPath}: icon must be SVG, PNG, JPEG, or WebP`)
const directory = realpathSync(dirname(manifestPath))
const file = realpathSync(resolve(directory, icon))
const local = relative(directory, file)
if (local === '..' || local.startsWith(`..${sep}`) || isAbsolute(local)) {
throw new Error(`${manifestPath}: icon must remain inside its manifest directory`)
}
const stat = statSync(file)
if (!stat.isFile()) throw new Error(`${manifestPath}: icon must be a regular file`)
if (stat.size > MAX_ICON_BYTES) throw new Error(`${manifestPath}: icon exceeds 256 KiB`)
const bytes = readFileSync(file)
if (bytes.length > MAX_ICON_BYTES) throw new Error(`${manifestPath}: icon exceeds 256 KiB`)
return `data:${mediaType};base64,${bytes.toString('base64')}`
}
/**
* Resolve a plugin resource through the active Node ESM resolver without evaluating it.
* @param specifier - complete resource module specifier, including its locale filename.
@@ -104,7 +133,9 @@ function localizedText(
}
/**
* Read meta.title and meta.description from a plugin's exported locale JSON files.
* Read localized display text and the icon declared in a plugin's exported package.json.
* Icons are manifest-relative SVG, PNG, JPEG, or WebP files of at most 256 KiB,
* contained in the manifest directory after realpath resolution. Icon failures retain display text.
* 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.
@@ -112,25 +143,29 @@ function localizedText(
* maps retain an English fallback, ultimately the full module specifier for titles and empty for descriptions.
* @param specifier - configured plugin module name, including any package subpath.
* @param parentURL - owning Loader tree's module-resolution base.
* @returns translated or fallback fields, a diagnostic, or undefined for non-package specifiers or absent display text.
* @returns display fields and any icon diagnostic, or undefined for non-package specifiers or absent metadata.
*/
export function readPluginMeta(specifier: string, parentURL: string): PluginLocalizedMeta | undefined {
if (barePackageName(specifier) === undefined) return undefined
try {
const englishPath = optionalResourcePath(`${specifier}/locale/en.json`, parentURL)
const dictionaries = englishPath === undefined ? new Map<string, DisplayFields>() : dictionariesOf(englishPath, specifier, parentURL)
const english = dictionaries.get('en')
const manifestPath = english?.title === undefined || english.description === undefined
? optionalResourcePath(`${specifier}/package.json`, parentURL)
: undefined
const manifestPath = optionalResourcePath(`${specifier}/package.json`, parentURL)
const manifest = manifestPath === undefined ? undefined : readObject(manifestPath)
const title = localizedText('title', dictionaries, fallbackText(manifest?.name), specifier)
const description = localizedText('description', dictionaries, fallbackText(manifest?.description), '')
if (title === undefined && description === undefined) return undefined
return {
const text = {
...title === undefined ? {} : { title },
...description === undefined ? {} : { description },
}
let icon: string | undefined
try {
icon = manifestPath === undefined ? undefined : iconOf(manifest?.icon, manifestPath)
} catch (error) {
return { ...text, error: `Plugin metadata for ${specifier}: ${String(error)}` }
}
if (title === undefined && description === undefined && icon === undefined) return undefined
return { ...text, ...icon === undefined ? {} : { icon } }
} catch (error) {
return { error: `Plugin metadata for ${specifier}: ${String(error)}` }
}
@@ -1,6 +1,6 @@
/** Plugin locale resources resolve independently of entry execution and package display fields. */
import fs, { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import fs, { mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
import { syncBuiltinESMExports } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
@@ -161,15 +161,122 @@ describe('plugin locale display metadata', () => {
expect(readPluginMeta('localized', parentURL)?.error).toContain('meta.title must be a non-empty string')
})
it('does not inspect package fallback when both English fields are supplied', () => {
manifest({ './locale/*.json': './locale/*.json', './package.json': './fallback.json' })
file(join(dir, 'fallback.json'), '{')
it('reads the manifest icon even when both English fields are supplied', () => {
manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { icon: './art/team.svg' })
file(join(dir, 'art', 'team.svg'), '<svg xmlns="http://www.w3.org/2000/svg"/>')
dictionary('en', { meta: { title: 'Plugin', description: 'Locale introduction' } })
expect(readPluginMeta('localized', parentURL)).toEqual({
title: { en: 'Plugin' }, description: { en: 'Locale introduction' },
icon: `data:image/svg+xml;base64,${Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>').toString('base64')}`,
})
})
it.each([
['svg', 'image/svg+xml'], ['png', 'image/png'], ['jpg', 'image/jpeg'],
['jpeg', 'image/jpeg'], ['webp', 'image/webp'], ['SVG', 'image/svg+xml'],
])('encodes a package-local %s icon without executing the plugin', (extension, mediaType) => {
manifest({ '.': './index.js', './package.json': './package.json' }, { icon: `./icon.${extension}` })
const bytes = Buffer.from([0, 1, 127, 128, 255])
writeFileSync(join(dir, `icon.${extension}`), bytes)
expect(readPluginMeta('localized', parentURL)).toEqual({
title: 'localized', icon: `data:${mediaType};base64,${bytes.toString('base64')}`,
})
})
it('reads an icon-only subexport relative to its own manifest without inheriting the package icon', () => {
manifest({ './package.json': './package.json', './search/package.json': './search/manifest.json' }, { icon: './root.png' })
file(join(dir, 'root.png'), 'root')
file(join(dir, 'search', 'manifest.json'), JSON.stringify({ icon: './icon.webp' }))
file(join(dir, 'search', 'icon.webp'), 'search')
expect(readPluginMeta('localized/search', parentURL)).toEqual({
icon: `data:image/webp;base64,${Buffer.from('search').toString('base64')}`,
})
expect(readPluginMeta('localized/review', parentURL)).toBeUndefined()
})
it.each([null, false, 1, {}, [], '', ' '])('retains localized text while rejecting malformed icon %j', (icon) => {
manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { icon })
dictionary('en', { meta: { title: 'Plugin', description: 'Introduction' } })
dictionary('zh', { meta: { title: '插件' } })
const { error, ...meta } = readPluginMeta('localized', parentURL)!
expect(meta).toEqual({ title: { en: 'Plugin', zh: '插件' }, description: { en: 'Introduction' } })
expect(error).toContain('icon must be a non-empty string')
})
it.each(['/tmp/icon.svg', 'C:\\icon.png', 'C:icon.png', '\\\\server\\share\\icon.png', 'https://example.test/icon.svg', 'data:image/svg+xml,<svg/>'])('rejects non-relative icon %s', (icon) => {
manifest({ './package.json': './package.json' }, { icon, description: 'Introduction' })
const { error, ...meta } = readPluginMeta('localized', parentURL)!
expect(meta).toEqual({ title: 'localized', description: 'Introduction' })
expect(error).toContain('icon must be a relative file path')
})
it.each(['icon.gif', 'icon.html', 'icon', 'icon.svg?query'])('rejects unsupported icon file %s', (icon) => {
manifest({ './package.json': './package.json' }, { icon })
expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must be SVG, PNG, JPEG, or WebP')
})
it('reports missing files, directories, and paths outside the manifest directory', () => {
manifest({ './package.json': './package.json' }, { icon: './missing.svg' })
expect(readPluginMeta('localized', parentURL)?.error).toContain('missing.svg')
mkdirSync(join(dir, 'missing.svg'))
expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must be a regular file')
manifest({ './package.json': './package.json' }, { icon: '../localized-other/icon.svg' })
file(join(root, 'node_modules', 'localized-other', 'icon.svg'), 'outside')
expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must remain inside its manifest directory')
})
it('accepts an icon at the byte limit and rejects one byte more', () => {
manifest({ './package.json': './package.json' }, { icon: './icon.png' })
const bytes = Buffer.alloc(256 * 1024)
writeFileSync(join(dir, 'icon.png'), bytes)
expect(readPluginMeta('localized', parentURL)?.icon).toBe(`data:image/png;base64,${bytes.toString('base64')}`)
writeFileSync(join(dir, 'icon.png'), Buffer.alloc(bytes.length + 1))
expect(readPluginMeta('localized', parentURL)?.error).toContain('icon exceeds 256 KiB')
})
it('rejects an icon that grows beyond the limit after its size check', () => {
manifest({ './package.json': './package.json' }, { icon: './icon.png' })
const icon = join(dir, 'icon.png')
file(icon, 'small')
const original = fs.statSync
const stat = vi.spyOn(fs, 'statSync')
try {
stat.mockImplementation(new Proxy(original, {
apply(target, receiver: unknown, args: unknown[]): unknown {
const result: unknown = Reflect.apply(target, receiver, args)
if (String(args[0]).endsWith(join('localized', 'icon.png'))) writeFileSync(icon, Buffer.alloc(256 * 1024 + 1))
return result
},
}))
syncBuiltinESMExports()
expect(readPluginMeta('localized', parentURL)?.error).toContain('icon exceeds 256 KiB')
} finally {
stat.mockRestore()
syncBuiltinESMExports()
}
})
it('rejects icons reached through an external directory link and accepts internal links', () => {
manifest({ './package.json': './package.json' }, { icon: './art/icon.svg' })
const outside = join(root, 'outside')
file(join(outside, 'icon.svg'), 'outside')
const link = join(dir, 'art')
symlinkSync(outside, link, 'junction')
try {
expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must remain inside its manifest directory')
} finally {
unlinkSync(link)
}
const inside = join(dir, 'images')
file(join(inside, 'icon.svg'), 'inside')
symlinkSync(inside, link, 'junction')
try {
expect(readPluginMeta('localized', parentURL)?.icon).toBe(`data:image/svg+xml;base64,${Buffer.from('inside').toString('base64')}`)
} finally {
unlinkSync(link)
}
})
it('keeps separate plugin exports independent even when their JavaScript entries share a directory', () => {
manifest({
'./search': './lib/search.js', './review': './lib/review.js',
@@ -285,8 +392,9 @@ describe('plugin locale display metadata', () => {
})
const specifier = 'localized/locale/en.json'
expect(resolveSync.mock.calls).toEqual(version === 'v1'
? [[specifier, parentURL, {}], [specifier, parentURL, {}]]
: [[parentURL, { specifier, attributes: {} }], [parentURL, { specifier, attributes: {} }]])
? [[specifier, parentURL, {}], [specifier, parentURL, {}], ['localized/package.json', parentURL, {}]]
: [[parentURL, { specifier, attributes: {} }], [parentURL, { specifier, attributes: {} }],
[parentURL, { specifier: 'localized/package.json', attributes: {} }]])
})
it('rejects case-equivalent language names in a directory listing', () => {
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md
README.md: d7bcd2299d93dab90c8440565ac87112b37c9c10
README.zh.md: 368058dabc27968b86088c34ee387dc5dd5bd364
README.md: c04a614668d5afb2e09c1f923ac9be473a4aab5a
README.zh.md: f0b30c91cb87ce96349d92086673809139956e0a
+1 -1
View File
@@ -27,7 +27,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Official** comes first and lists the bundles the installation ships for switching on — off until switched on, without an uninstall, and tagged **Beta** where the feature is one — followed by the official plugins that registered a configuration page; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section.
Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` at that plugin address; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Installation previews still use registry or manifest information.
Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` at that plugin address; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the image declared by their own `package.json.icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information.
### Installing a bundle
@@ -27,7 +27,7 @@ kind: "package-reference"
在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于 beta 功能的带 **Beta** 标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。
已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。安装预览仍使用注册表或 manifest 信息。
已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示各自 `package.json.icon` 声明的图片;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。
### 安装一个组合包
@@ -202,6 +202,10 @@
color: var(--dsw-alias-label-secondary);
}
.packageImage {
object-fit: contain;
}
.cardMain {
flex: 1;
min-width: 0;
@@ -17,7 +17,7 @@ import {
IconChevronRightOutlineRegular, IconCloseOutlineMedium,
IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular,
IconWarningOutlineRegular, Input, Modal,
PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTeam, PluginArtworkTerminal,
PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal,
StateDot, Switch, Tag, TerminalBlock, Toast, useAnchoredPosition, useDismissOnOutsidePointer,
type IconProps, type StateDotState, type TerminalBlockLabels,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -130,21 +130,23 @@ const ITEM_ARTWORK = new Map<string, (props: IconProps) => ReactNode>([
['web-search', PluginArtworkSearch],
])
/** The artwork of the official bundles with artwork of their own, by package name. */
const PACKAGE_ARTWORK = new Map<string, (props: IconProps) => ReactNode>([
['@deepseek-ai/dsh-experimental-agent-team-profile', PluginArtworkTeam],
])
/** An official plugin's card and page artwork; plugins without their own get the default. */
function itemArtwork(id: string): ReactNode {
const Artwork = ITEM_ARTWORK.get(id) ?? PluginArtworkDefault
return <Artwork size={CARD_ARTWORK_SIZE} />
}
/** A package's card and page artwork; packages without their own get the default. */
function packageArtwork(name: string): ReactNode {
const Artwork = PACKAGE_ARTWORK.get(name) ?? PluginArtworkDefault
return <Artwork size={CARD_ARTWORK_SIZE} />
/** Manifest images remain isolated from the page DOM; a failed decode keeps the position's default artwork. */
function PackageArtwork({ src, row = false, size = row ? ROW_ARTWORK_SIZE : CARD_ARTWORK_SIZE }: {
readonly src: string | undefined
readonly row?: boolean
readonly size?: number
}): ReactNode {
const [failedSource, setFailedSource] = useState<string>()
const Fallback = row ? PluginArtworkSubagent : PluginArtworkDefault
return src === undefined || src === failedSource
? <Fallback size={size} />
: <img className={css.packageImage} src={src} width={size} height={size} alt="" onError={() => { setFailedSource(src) }} />
}
/** A row's switch: locked, saying why, when the Host refuses to address the row through the profile patch. */
@@ -234,7 +236,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: {
{...row.phase === 'failed' ? { 'data-state': 'failed' } : row.enabled ? {} : { 'data-state': 'off' }}
>
<div className={css.rowLine}>
<span className={css.rowIcon} aria-hidden="true"><PluginArtworkSubagent size={ROW_ARTWORK_SIZE} /></span>
<span className={css.rowIcon} aria-hidden="true"><PackageArtwork key={row.meta?.icon} src={row.meta?.icon} row /></span>
<div className={css.rowMain}>
{configure?.has(row) === true
? (
@@ -367,7 +369,7 @@ function PackageCard({ pkg, t, resolveText, busy, highlighted, onOpen, onSetEnab
title={title}
t={t}
onOpen={onOpen}
icon={packageArtwork(pkg.name)}
icon={<PackageArtwork key={pkg.meta?.icon} src={pkg.meta?.icon} />}
tags={(
<>
{beta ? <Tag className={css.statusTag} tone="info">{t('statusBeta')}</Tag> : null}
@@ -439,7 +441,7 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot }: {
const key = rowConfigKey(pkg.name, row.rowId)
return (
<div className={css.detail} data-plugin-row-detail={key}>
<DetailTop crumbLabel={t('backToPackage', { name: title })} crumbText={title} onBack={onBack} icon={<PluginArtworkSubagent size={CARD_ARTWORK_SIZE} />} />
<DetailTop crumbLabel={t('backToPackage', { name: title })} crumbText={title} onBack={onBack} icon={<PackageArtwork key={row.meta?.icon} src={row.meta?.icon} row size={CARD_ARTWORK_SIZE} />} />
<div className={css.detailMain}>
<div className={css.titleRow}>
<h3 className={css.detailTitle}>{rowTitle}</h3>
@@ -491,7 +493,7 @@ function PackageDetail({
crumbLabel={t('backToList')}
crumbText={t('crumbRoot')}
onBack={onBack}
icon={packageArtwork(pkg.name)}
icon={<PackageArtwork key={pkg.meta?.icon} src={pkg.meta?.icon} />}
actions={(
<div className={css.detailActions}>
{pkg.installed
@@ -285,6 +285,38 @@ describe('PluginManagerPage', () => {
}
})
it('renders manifest icons for arbitrary bundles and rows, with decode fallback and source recovery', () => {
const icon = 'data:image/svg+xml;base64,PHN2Zy8+'
const updatedIcon = 'data:image/png;base64,cG5n'
const bundle = pkg({ meta: { icon }, rows: [row({ meta: { icon } }), row({ entryId: 'plain' as PluginEntryId, rowId: 'plain', moduleName: 'plain' })] })
const { set } = renderTab({ packages: [bundle] }, { rows: new Set(['dsh-better-sidebar#sidebar']) })
const image = () => document.querySelector<HTMLImageElement>('[data-plugin-package] img, [data-plugin-detail] img')!
expect(image().getAttribute('src')).toBe(icon)
expect(image().getAttribute('alt')).toBe('')
expect(image().width).toBe(36)
fireEvent.error(image())
expect(document.querySelector('[data-plugin-package] img')).toBeNull()
expect(document.querySelector('[data-plugin-package] svg')).not.toBeNull()
set({ packages: [{ ...bundle, meta: { icon: updatedIcon } }] })
expect(image().getAttribute('src')).toBe(updatedIcon)
set({ packages: [bundle] })
expect(image().getAttribute('src')).toBe(icon)
fireEvent.click(screen.getByRole('button', { name: 'View dsh-better-sidebar' }))
expect(image().getAttribute('src')).toBe(icon)
const rowImage = document.querySelector<HTMLImageElement>('[data-plugin-row] img')!
expect(rowImage.getAttribute('src')).toBe(icon)
expect(rowImage.width).toBe(30)
expect(document.querySelector('[data-plugin-row="plain"] img')).toBeNull()
expect(document.querySelector('[data-plugin-row="plain"] svg')).not.toBeNull()
fireEvent.error(rowImage)
expect(document.querySelector('[data-plugin-row] img')).toBeNull()
expect(document.querySelector('[data-plugin-row] svg')).not.toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Configure dsh-better-sidebar' }))
const detailImage = document.querySelector<HTMLImageElement>('[data-plugin-row-detail] img')!
expect(detailImage.getAttribute('src')).toBe(icon)
expect(detailImage.width).toBe(36)
})
it('shows metadata diagnostics without blocking management or displaying legacy descriptions', () => {
const error = 'locale/zh.json: invalid title'
const { actions, set, setLanguage } = renderTab({
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 51ad53a79a676f013b61213764479a1247de522c
README.zh.md: c41d19af1855c8ab1582fa92b956ef283d3466cf
README.md: f17f80d877a76d61b55b502edb05781f8e05cee4
README.zh.md: 8540a82389b4dced4e6b6048980f4278ec5722d7
+1 -1
View File
@@ -57,7 +57,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. |
| `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, and full-access choices. |
| `PluginArtworkTeam`/`Terminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. |
| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. |
| `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. |
| `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The filename grammar selection and lazy line-token highlighter shared by code preview and diff review. |
+1 -1
View File
@@ -57,7 +57,7 @@ kind: "package-library"
| `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 |
| `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium` | 只读、工作区写入与完全访问选项使用的权限模式图形。 |
| `PluginArtworkTeam`/`Terminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 |
| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 |
| `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 |
| `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的文件名 grammar 选择和惰性逐行 token 高亮。 |
+1 -1
View File
@@ -83,5 +83,5 @@ export { extractMarkdownPlainText } from './markdown/plain-text.ts'
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
export * from './icons/index.tsx'
export {
PluginArtworkTeam, PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, PluginArtworkSearch, PluginArtworkDefault,
PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, PluginArtworkSearch, PluginArtworkDefault,
} from './plugin-artwork.tsx'
@@ -12,25 +12,6 @@ import type { IconProps } from './icons/props.ts'
*/
const useArtworkId = (): string => `dsh_plugin_art_${useId().replaceAll(':', '')}`
/** Agent-team plugin artwork (four interlocking quadrants). */
export const PluginArtworkTeam = ({ size = 36, className }: IconProps) => {
const uid = useArtworkId()
return (
<svg width={size} height={size} className={className} viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3027 17.1077H9.03125V12.8372H13.3027V17.1077ZM17.5742 12.8372C17.574 15.1958 15.6623 17.1075 13.3037 17.1077V8.56567H17.5742V12.8372Z" fill={`url(#${uid}a)`} />
<path d="M18.5229 12.8372V8.56567H22.7944V12.8372H27.0659V17.1077H22.7944C20.4357 17.1077 18.5232 15.1959 18.5229 12.8372Z" fill="#F2AF63" />
<path d="M17.5742 26.6072H13.3037V18.0642C15.6625 18.0644 17.5742 19.9769 17.5742 22.3357V26.6072ZM13.3027 22.3357H9.03125V18.0642H13.3027V22.3357Z" fill="#7CB7FF" />
<path d="M22.7944 26.6072H18.5229V22.3357C18.5229 19.9768 20.4355 18.0642 22.7944 18.0642H27.0659V22.3357H22.7944V26.6072Z" fill="#45D9E7" />
<defs>
<linearGradient id={`${uid}a`} x1="16.1247" y1="6.6613" x2="9.03125" y2="19.2206" gradientUnits="userSpaceOnUse">
<stop stopColor="#7CB7FF" />
<stop offset="1" stopColor="#145AF3" />
</linearGradient>
</defs>
</svg>
)
}
/** Terminal plugin artwork (prompt chevron and cursor bar). */
export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -2,13 +2,12 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import {
PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTeam, PluginArtworkTerminal,
PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal,
} from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
const artworks = {
PluginArtworkTeam,
PluginArtworkTerminal,
PluginArtworkLoop,
PluginArtworkSubagent,
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/agent-team-profile/README.md
README.md: 2d94b4d1c0e7de9079ac28945f1e79594b17bb76
README.zh.md: b1126a266c472d5548b0adbc4139d701ac5b4f6f
README.md: 053f44b673552e7bb7d63ed944652ea6fb87145a
README.zh.md: bd6837339298fc13908e077833f75aa9c3a6696e
@@ -48,7 +48,7 @@ In an existing profile’s `package.json`, keep `@deepseek-ai/dsh-experimental-a
The layer adds the Agent Teams domain and its scoped creation, roster, messaging, interruption, waiting, and task-board tools. Direct delegation uses `spawn_teammate`, which supports fresh and fork context. The `subagent` and `subagent_fork` tools and overlapping global child controls are disabled. Workflow retains the base profile’s `spawn` provider, while the underlying Subagent services and both providers remain available to teammates and workflow.
In Web and Desktop conversations, the [Team UI](../client-ui-agent-team/README.md) displays the member roster and shared task board and opens teammate sessions. The same bundle switch controls the tools and browser UI.
In Web and Desktop conversations, the [Team UI](../client-ui-agent-team/README.md) displays the member roster and shared task board and opens teammate sessions. The same bundle switch controls the tools and browser UI. The Plugins page reads the bundle's [icon](icon.svg) from its `package.json.icon` declaration, including while the bundle is disabled.
-----
@@ -48,7 +48,7 @@ dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-profile
本层会添加 Agent Teams domain,以及 Team-scoped 创建、roster、消息、interrupt、等待与任务板工具。直接委派使用支持 fresh 和 fork 上下文的 `spawn_teammate`。`subagent`、`subagent_fork` 工具和名称重叠的全局 child control 均被禁用。Workflow 保留 base profile 的 `spawn` 提供方,底层 Subagent 服务和两个提供方仍供 teammate 与 workflow 使用。
在 Web 和 Desktop 会话中,[Team UI](../client-ui-agent-team/README.zh.md) 显示成员列表与共享任务看板,并可打开成员会话。同一个组合包开关控制工具与浏览器 UI。
在 Web 和 Desktop 会话中,[Team UI](../client-ui-agent-team/README.zh.md) 显示成员列表与共享任务看板,并可打开成员会话。同一个组合包开关控制工具与浏览器 UI。插件页通过组合包的 `package.json.icon` 声明读取其[图标](icon.svg),组合包禁用时也会显示。
-----
@@ -0,0 +1,12 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3027 17.1077H9.03125V12.8372H13.3027V17.1077ZM17.5742 12.8372C17.574 15.1958 15.6623 17.1075 13.3037 17.1077V8.56567H17.5742V12.8372Z" fill="url(#a)" />
<path d="M18.5229 12.8372V8.56567H22.7944V12.8372H27.0659V17.1077H22.7944C20.4357 17.1077 18.5232 15.1959 18.5229 12.8372Z" fill="#F2AF63" />
<path d="M17.5742 26.6072H13.3037V18.0642C15.6625 18.0644 17.5742 19.9769 17.5742 22.3357V26.6072ZM13.3027 22.3357H9.03125V18.0642H13.3027V22.3357Z" fill="#7CB7FF" />
<path d="M22.7944 26.6072H18.5229V22.3357C18.5229 19.9768 20.4355 18.0642 22.7944 18.0642H27.0659V22.3357H22.7944V26.6072Z" fill="#45D9E7" />
<defs>
<linearGradient id="a" x1="16.1247" y1="6.6613" x2="9.03125" y2="19.2206" gradientUnits="userSpaceOnUse">
<stop stop-color="#7CB7FF" />
<stop offset="1" stop-color="#145AF3" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 962 B

@@ -2,6 +2,7 @@
"name": "@deepseek-ai/dsh-experimental-agent-team-profile",
"description": "Agent Teams collaboration, tools, and Web UI in one experimental bundle",
"version": "0.1.6-alpha.2",
"icon": "./icon.svg",
"publishConfig": {
"access": "public"
},
@@ -24,6 +25,7 @@
"./package.json": "./package.json"
},
"files": [
"icon.svg",
"locale/*.json",
"lib/index.js",
"cordis.patch.yml",
@@ -5365,7 +5365,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PluginLocalizedMeta',
declaration: 'export interface PluginLocalizedMeta {\n readonly title?: LocalizedText;\n readonly description?: LocalizedText;\n readonly error?: string;\n}',
declaration: 'export interface PluginLocalizedMeta {\n readonly title?: LocalizedText;\n readonly description?: LocalizedText;\n readonly icon?: string;\n readonly error?: string;\n}',
},
{
name: 'PluginRegistries',
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/util/package-manifest/README.md
README.md: 86121432ba12329e111f2c796679ae019e403d7e
README.zh.md: d4d9d8b5b7787ac9531918fb1f7084c7c8a7a8d1
README.md: 95dbbb729792872a9d41716db4fb4f2e8ac763b5
README.zh.md: 02c21647f085af09472cca9f8c2e1a06be4e1124
+1 -1
View File
@@ -52,7 +52,7 @@ The following metadata fields are optional. Omitting them leaves the format vers
| `dsh.manifestVersion` | Manifest format identifier; the declared format is `1`, independent of the npm package version and Session format version. |
| `engines.dsh` | Author-declared compatible DSH versions as a SemVer range, including exact prerelease versions. This field sits beside `engines.node` and `engines.npm`; an engines object may omit `dsh`. |
`LocalizedText` carries literal text or a language map with a required English fallback. `PluginLocalizedMeta` carries optional display title and description fields, or a metadata diagnostic, for installed plugins. [App boot](../../boot/app-boot/README.md) reads these values; this package only supplies their types.
`LocalizedText` carries literal text or a language map with a required English fallback. `PluginLocalizedMeta` carries optional display title, description, an image data URL resolved from `package.json.icon`, and metadata diagnostics for installed plugins. [App boot](../../boot/app-boot/README.md) reads these values; this package only supplies their types.
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.
+1 -1
View File
@@ -52,7 +52,7 @@ const manifest: DshPackageManifest = {
| `dsh.manifestVersion` | manifest(元数据清单)格式标识;声明的格式为 `1`,独立于 npm 包版本和 Session 格式版本。 |
| `engines.dsh` | 作者声明的兼容 DSH 版本,使用 SemVer 范围,也可填写精确的预发布版本。此字段与 `engines.node`、`engines.npm` 并列;engines 对象可省略 `dsh`。 |
`LocalizedText` 携带字面文本或带必需英文回退值的语言映射。`PluginLocalizedMeta` 携带已安装插件的可选展示标题与描述字段,或元信息诊断。[App boot](../../boot/app-boot/README.zh.md) 读取这些值;本包只提供其类型。
`LocalizedText` 携带字面文本或带必需英文回退值的语言映射。`PluginLocalizedMeta` 携带已安装插件的可选展示标题、描述、从 `package.json.icon` 解析的图片 data URL,以及元信息诊断。[App boot](../../boot/app-boot/README.zh.md) 读取这些值;本包只提供其类型。
公共组合声明定义在 [`src/types.ts`](src/types.ts) 中。内部 `configTrees`、`sessionFormatMigration` 和生成的 `moduleFallback` 元数据分别由镜像打包器、目录生成器和启动器读取方拥有;公共类型不暴露这些字段。
+5 -1
View File
@@ -12,6 +12,8 @@ export interface DshPackageManifest {
version: string
/** Package summary for discovery and display. */
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
/** Prevent npm publication, for example for local profile projects. */
private?: boolean
/** Packages installed alongside this package. */
@@ -39,12 +41,14 @@ 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 or a diagnostic from reading exported locale files. */
/** Validated plugin display fields and diagnostics from exported locales, manifests, or icon files. */
export interface PluginLocalizedMeta {
/** Display title; omission preserves the consumer's technical-name fallback. */
readonly title?: LocalizedText
/** Display introduction after locale and package-field fallback. */
readonly description?: LocalizedText
/** Base64 image data URL read from the manifest's icon file; render as an image, not inline markup. */
readonly icon?: string
/** Unmodified local metadata diagnostic; the plugin remains manageable. */
readonly error?: string
}
@@ -164,6 +164,20 @@ describe('dsh family version coherence', () => {
})
describe('package payload constraints', () => {
it.each(['./art/icon.svg', 'art/icon.svg'])('includes declared icon %s in the canonical payload', (icon) => {
expect(expectedDshPackageFiles({ icon, exports: { './locale/*.json': './locale/*.json' } })).toEqual([
'art/icon.svg', 'locale/*.json', 'lib/index.js', 'lib/types/**/*.d.ts',
])
})
it('accepts the Agent Team icon payload and rejects its omission', () => {
const dir = 'packages/experimental/agent-team-profile'
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') } }))
.toEqual([expect.stringContaining('package.json files must be')])
})
it.each([
{ exports: { './locale/*.json': './locale/*.json' }, resources: ['locale/*.json'] },
{ exports: { './search/locale/*.json': './resources/search/*.json' }, resources: ['resources/search/*.json'] },
+4 -2
View File
@@ -90,6 +90,7 @@ export interface PackageManifest {
| undefined
>
files?: string[]
icon?: string
publishConfig?: { access?: string }
repository?: { type?: string; url?: string; directory?: string }
peerDependencies?: Record<string, string>
@@ -210,9 +211,9 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl
}
/**
* Compute canonical publication patterns, including explicitly exported locale JSON resources.
* Compute canonical publication patterns, including the declared icon and exported locale JSON resources.
* @param manifest - workspace package manifest.
* @returns deduplicated locale targets followed by the package's runtime and declaration payloads.
* @returns the icon and deduplicated locale targets followed by runtime and declaration payloads.
*/
export function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
const localeFiles = new Set<string>()
@@ -228,6 +229,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str
...(manifest.name ? packageFileExtras[manifest.name] ?? [] : []),
]
return [
...typeof manifest.icon === 'string' ? [manifest.icon.replace(/^\.\//u, '')] : [],
...[...localeFiles].sort(),
'lib/index.js',
// Packages with an invariant export publish its runtime as a separate
+75
View File
@@ -44,6 +44,57 @@ it('does not require locale resources for exported package name and description'
expect(packageMetaProblems(root)).toEqual([])
})
it.each([undefined, ['art'], ['art/*.svg'], ['./art/icon.svg']])('accepts a published package icon without locale resources: %j', (files) => {
manifest({ icon: './art/icon.svg', files })
file('art/icon.svg', '<svg/>')
expect(packageMetaProblems(root)).toEqual([])
})
it.each([{ files: ['lib'] }, { files: ['art', '!art/icon.svg'] }])('rejects an icon omitted from package publication: %j', ({ files }) => {
manifest({ icon: './art/icon.svg', files })
file('art/icon.svg', '<svg/>')
expect(packageMetaProblems(root).join('\n')).toContain('files must include art/icon.svg')
})
it.each([null, false, 1, '', './icon.gif', '/tmp/icon.svg', './missing.png', '../outside.svg'])('validates icon declarations without locale resources: %j', (icon) => {
manifest({ icon })
file('../outside.svg', '<svg/>')
expect(packageMetaProblems(root).join('\n')).toContain('Plugin metadata for @test/plugin:')
})
it('requires the root icon declaration to be exported', () => {
manifest({ icon: './icon.svg', exports: { '.': './entry.js' }, files: ['icon.svg'] })
file('icon.svg', '<svg/>')
expect(packageMetaProblems(root).join('\n')).toContain('exports must expose its icon declaration through @test/plugin/package.json')
})
it.each([
{ './search/package.json': './resources/search/manifest.json' },
{ './*/package.json': './resources/*/manifest.json' },
{ './*': './resources/*' },
])('validates icons of independent exported manifests: %j', (exports) => {
manifest({ exports, files: ['resources'] })
const document = './*' in exports ? 'resources/search/package.json' : 'resources/search/manifest.json'
json(document, { icon: './icon.webp' })
file('resources/search/icon.webp', 'webp')
expect(packageMetaProblems(root)).toEqual([])
json(document, { icon: './missing.png' })
expect(packageMetaProblems(root).join('\n')).toContain('missing.png')
})
it('requires publication of both an exported icon manifest and its image', () => {
manifest({ exports: { './search/package.json': './resources/search.json' }, files: ['resources/icon.svg'] })
json('resources/search.json', { icon: './icon.svg' })
file('resources/icon.svg', '<svg/>')
expect(packageMetaProblems(root).join('\n')).toContain('files must include resources/search.json')
})
it('ignores icon-like fields in unrelated source JSON', () => {
manifest()
json('data.json', { icon: false })
expect(packageMetaProblems(root)).toEqual([])
})
it('excludes tests, installed dependencies, and build output from metadata discovery', () => {
manifest({ exports: { '.': './lib/index.js' }, files: ['lib'] })
json('tests/fixtures/locale/en.json', { meta: { title: false } })
@@ -91,6 +142,30 @@ it.each(['en', 'zh'])('rejects %s metadata resolved into build output without re
expect(problems).not.toContain('Plugin metadata for')
})
it('does not read built locale JSON while checking a source icon declaration', () => {
manifest({
icon: './icon.svg', files: ['icon.svg'],
exports: { './package.json': './package.json', './locale/en.json': './lib/locale/en.json' },
})
file('icon.svg', '<svg/>')
file('lib/locale/en.json', '{')
json('locale/en.json', { meta: { title: 'Plugin' } })
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain('exports must expose locale/en.json')
expect(problems).not.toContain('Plugin metadata for')
})
it.each(['lib', 'tests'])('rejects an icon declaration when its only locale is excluded source: %s', (directory) => {
manifest({
icon: './missing.svg', files: ['lib'],
exports: { './package.json': './package.json', './locale/en.json': `./${directory}/locale/en.json` },
})
file(`${directory}/locale/en.json`, '{}')
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain('@test/plugin: icon metadata requires locale resources to resolve to source JSON')
expect(problems).not.toContain('Plugin metadata for')
})
it('reads static metadata without evaluating the plugin entry', () => {
manifest({ exports: { '.': './entry.js', './locale/*.json': './locale/*.json' } })
file('entry.js', "import { writeFileSync } from 'node:fs'; writeFileSync(new URL('./executed', import.meta.url), 'yes'); throw new Error('plugin entry executed')")
+29 -3
View File
@@ -9,12 +9,14 @@ interface Manifest {
name: string
exports?: unknown
files?: string[]
icon?: unknown
}
interface SourceJson {
file: string
metadata: boolean
invalid: boolean
icon?: unknown
}
function sourceJsonFiles(dir: string): SourceJson[] {
@@ -29,7 +31,7 @@ function sourceJsonFiles(dir: string): SourceJson[] {
if (typeof contents !== 'object' || contents === null || Array.isArray(contents)) {
return { file, metadata: false, invalid: true }
}
return { file, metadata: Object.hasOwn(contents, 'meta'), invalid: false }
return { file, metadata: Object.hasOwn(contents, 'meta'), invalid: false, icon: 'icon' in contents ? contents.icon : undefined }
})
}
@@ -77,7 +79,10 @@ function packageProblems(manifestPath: string): string[] {
const exported = typeof pkg.exports === 'object' && pkg.exports !== null ? Object.entries(pkg.exports) : []
for (const [key, target] of exported) {
if (!key.startsWith('.')) continue
if (!key.includes('*')) candidates.add(key)
if (!key.includes('*')) {
candidates.add(key)
if (key.endsWith('/package.json')) candidates.add(key.slice(0, -'/package.json'.length))
}
const plugin = pluginOf(key)
if (plugin !== undefined && !plugin.includes('*')) candidates.add(plugin)
for (const pattern of targetsOf(target)) {
@@ -85,6 +90,7 @@ function packageProblems(manifestPath: string): string[] {
const value = substitution(pattern, `./${document.file}`)
if (value === undefined) continue
const request = key.replaceAll('*', value)
if (request.endsWith('/package.json')) candidates.add(request.slice(0, -'/package.json'.length))
const owner = pluginOf(request)
if (owner === undefined) continue
candidates.add(owner)
@@ -116,6 +122,26 @@ function packageProblems(manifestPath: string): string[] {
const file = lookup(resourceOf(filename))
return file === undefined ? [] : [{ filename, file }]
})
const iconManifest = lookup(`${specifier}/package.json`)
const iconDocument = iconManifest === undefined ? undefined : byPath.get(iconManifest)
let iconChecked = false
if (candidate === '.' && pkg.icon !== undefined && iconManifest !== join(dir, 'package.json')) {
problems.push(`${manifestPath}: exports must expose its icon declaration through ${pkg.name}/package.json`)
}
if (iconDocument?.icon !== undefined && resources.every(({ file }) => byPath.has(file))) {
iconChecked = true
const meta = readPluginMeta(specifier, parentURL)
if (meta?.error !== undefined) problems.push(meta.error)
if (meta?.icon !== undefined && typeof iconDocument.icon === 'string') {
const iconFile = relative(dir, resolve(dirname(join(dir, iconDocument.file)), iconDocument.icon)).replaceAll('\\', '/')
if (pkg.files !== undefined && !published(iconFile, pkg.files)) problems.push(`${manifestPath}: files must include ${iconFile}`)
if (iconDocument.file !== 'package.json' && pkg.files !== undefined && !published(iconDocument.file, pkg.files)) {
problems.push(`${manifestPath}: files must include ${iconDocument.file}`)
}
}
} else if (iconDocument?.icon !== undefined) {
problems.push(`${specifier}: icon 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)
const english = lookup(resourceOf('en.json'))
@@ -138,7 +164,7 @@ function packageProblems(manifestPath: string): string[] {
problems.push(`${resourceOf(filename)}: ${file} must share the English locale directory ${directory}`)
}
}
if (sourceResources) {
if (sourceResources && !iconChecked) {
const meta = readPluginMeta(specifier, parentURL)
if (meta?.error !== undefined) problems.push(meta.error)
}