feat(plugins): support optional feature previews in bundle details
This commit is contained in:
1 parent
88d90d5883
commit
e527c04663
34 files changed
+362
-94
No files matched your search
@@ -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
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -147,6 +147,8 @@ Export `<package name>/locale/en.json` for locale lookup; expose `<package name>
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 标题和描述保持原样。插件没有展示描述时,行配置页可以使用注册组件的摘要。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<a id="startup-and-reload-failures"></a>
|
||||
### Startup and reload failures
|
||||
|
||||
|
||||
@@ -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。每张图片独立读取:配图出错时保留有效文本与图标,图标出错时保留有效配图。读取两类图片均无需激活插件。
|
||||
|
||||
<a id="startup-and-reload-failures"></a>
|
||||
### 启动与重载失败
|
||||
|
||||
|
||||
@@ -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<Pick<PluginLocalizedMeta, 'title' | 'description' | 'icon'>>
|
||||
/** Package-owned localized text and self-contained images, embedded without provider code. */
|
||||
readonly meta: Required<Pick<PluginLocalizedMeta, 'title' | 'description' | 'icon'>> & Pick<PluginLocalizedMeta, 'preview'>
|
||||
}
|
||||
|
||||
export { OFFICIAL_ON_DEMAND_CATALOG } from './official-bundles.generated.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 `<specifier>/icon` resource.
|
||||
* A subpath specifier never reads package.json; its icon comes only from `<specifier>/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 `<specifier>/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<Record<ImageField, string>> = {}
|
||||
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)}` }
|
||||
}
|
||||
|
||||
@@ -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'), '<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('<svg/>').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: {} }]))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -96,6 +96,8 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
|
||||
|
||||
行页只在某个 `plugins.row.config` 条目点名这一行时存在,因此给行的贡献渲染在该配置打开的页面上。
|
||||
|
||||
组合包详情在描述下方展示可选的包内功能配图,未启用时也可查看。没有配图就不显示图片或预留空位;解码失败时显示本地化提示,不影响配置或启用。卡片与组件行保留小尺寸图标。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -237,6 +237,14 @@ function PackageArtwork({ src, row = false, size = row ? ROW_ARTWORK_SIZE : CARD
|
||||
: <img className={css.packageImage} src={src} width={size} height={size} alt="" onError={() => { 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
|
||||
? <p className={css.reason} role="status">{t('previewUnavailable')}</p>
|
||||
: <img className={css.packagePreview} src={src} alt={t('previewAlt', { name: title })} onError={() => { 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({
|
||||
<MetadataError error={pkg.meta?.error} t={t} />
|
||||
{pkg.error === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {managementText(pkg.error, t)}</p>}
|
||||
{pkg.readOnlyReason === undefined ? null : <p className={css.reason} role="status">{managementText({ code: pkg.readOnlyReason }, t)}</p>}
|
||||
{pkg.meta?.preview === undefined ? null : <PackagePreview key={pkg.meta.preview} src={pkg.meta.preview} title={title} t={t} />}
|
||||
<div className={css.detailSections}>
|
||||
{configured
|
||||
? (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -29,6 +29,8 @@ kind: "package-bundle"
|
||||
|
||||
开关为已有和新建智能体添加或移除 Host 级技能提供方。提供技能加载能力的预设可以使用徽章;minimal 的工具目录保持不变。
|
||||
|
||||
可选的 `preview.svg` 示意在文档末尾添加官方署名徽章的效果,插件未启用时详情页也可展示。徽章资源和使用说明由技能拥有。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="300" viewBox="0 0 640 300" fill="none">
|
||||
<rect width="640" height="300" rx="12" fill="#F3F5F8"/>
|
||||
<rect x="74" y="24" width="492" height="252" rx="10" fill="#FFF" stroke="#D9DFE8"/>
|
||||
<path d="M74 56H566" stroke="#E5E9EF"/>
|
||||
<circle cx="90" cy="40" r="3" fill="#CCD3DF"/>
|
||||
<circle cx="101" cy="40" r="3" fill="#CCD3DF"/>
|
||||
<circle cx="112" cy="40" r="3" fill="#CCD3DF"/>
|
||||
<rect x="98" y="80" width="184" height="10" rx="3" fill="#738399"/>
|
||||
<path d="M98 112H444M98 126H492M98 140H388" stroke="#D1D9E4" stroke-width="5" stroke-linecap="round"/>
|
||||
<rect x="98" y="162" width="444" height="34" rx="5" fill="#F4F6F9"/>
|
||||
<path d="M112 174H370M112 184H300" stroke="#BCC8D8" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M98 211H542" stroke="#E5E9EF"/>
|
||||
<image x="98" y="228" width="121" height="20" href="data:image/png;base64,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 truncated
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 17 KiB |
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -56,6 +56,8 @@ const manifest: DshPackageManifest = {
|
||||
|
||||
公共组合声明定义在 [`src/types.ts`](src/types.ts) 中。内部 `configTrees`、`sessionFormatMigration` 和生成的 `moduleFallback` 元数据分别由镜像打包器、目录生成器和启动器读取方拥有;公共类型不暴露这些字段。
|
||||
|
||||
`DshPackageManifest.preview` 可选地指定功能配图,`PluginLocalizedMeta.preview` 携带解析后的 data URL。它与小尺寸 `icon` 相互独立;未提供配图时省略。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -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 `<specifier>/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
|
||||
}
|
||||
|
||||
@@ -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')])
|
||||
})
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface PackageManifest {
|
||||
exports?: Record<string, ExportTarget | undefined>
|
||||
files?: string[]
|
||||
icon?: string
|
||||
preview?: string
|
||||
publishConfig?: { access?: string }
|
||||
repository?: { type?: string; url?: string; directory?: string }
|
||||
peerDependencies?: Record<string, string>
|
||||
@@ -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<string>()
|
||||
@@ -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'] : [],
|
||||
|
||||
@@ -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]!
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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('</svg>', `<image href="${href}"/></svg>`))
|
||||
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('<path ', '<script>alert(1)</script><path '))
|
||||
expect(packageMetaProblems(root).join('\n')).toContain('SVG preview must use self-contained geometry')
|
||||
})
|
||||
|
||||
it.each(['manifest', 'export'])('accepts SVG geometry and local references selected by %s', (selection) => {
|
||||
manifest({
|
||||
...selection === 'manifest' ? { icon: './icon.svg' } : {},
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user