fix: address artwork review and separate UX reference coverage

This commit is contained in:
Yif
2026-10-09 17:58:18 +08:00
parent 64d56a968c
commit 71947ec3fa
11 changed files with 157 additions and 140 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ This skill is guidance, not a complete checklist. It covers judgment calls that
## Reuse before adding
- **Extend an existing component, container, or interaction before creating a new one.** A new capability that fits an existing surface (an extra menu item, a new prop on an existing primitive) beats a parallel element stacked beside it.
- **Action icons come from the existing icon library.** Pick the semantically closest existing icon; a new glyph requires designer approval before it lands. For official and official experimental plugin artwork, use [dsh-official-plugin-artwork](../dsh-official-plugin-artwork/SKILL.md), not enlarged toolbar glyphs.
- **Icons come from the existing icon library.** Pick the semantically closest existing icon; a new glyph requires designer approval before it lands — when reviewing a PR that adds one, suggest requesting a designer review.
- **Right-sidebar content registers a slot in the existing sidebar**, never a separate sidebar. Every sidebar tab declares a tab icon matching its meaning.
- **Icon-only actions whose meaning is not obvious get a Tooltip.** Informational content the pointer must rest on or select uses HoverCard instead. Prefer these shared primitives over ad-hoc title attributes.
@@ -32,6 +32,6 @@ Draw recognizable artwork that belongs beside the existing official icons. Offic
1. Show a few numbered alternatives before polishing uncertain geometry. Preserve their labels through review and apply the chosen version; an existing file or generated draft does not establish designer approval. Keep comparison artifacts in a gitignored directory, not the product package.
2. Compare old and new artwork beside unchanged references in light and dark themes, at actual row/card sizes as well as enlarged. Check balance, lettering, empty space, clipping, and image decoding.
3. Run the [package artwork regression](../../../packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx) with `pnpm exec vitest run packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx`, plus `pnpm run verify-package-meta` and `pnpm run verify-official-bundle-catalog`. Regenerate a stale catalog with `pnpm run gen-official-bundle-catalog`, never by hand. Structural checks do not replace visual review.
4. Build affected artifacts and inspect the real page through `dsh web`. Batch approved refinements before restarting a metadata-caching Host; after a restart, provide the current login URL. Verify the entry and nested component rows. Report exactly which tree, URL, and states were tested; do not claim HMR without a client-bundle watcher.
3. Run `pnpm run verify-package-meta` for [package metadata and SVG artwork](../../../scripts/verify-package-meta.ts), `pnpm run constraints` for the canonical package payload, and `pnpm run verify-official-bundle-catalog`. Regenerate a stale catalog with `pnpm run gen-official-bundle-catalog`, never by hand. Structural checks do not replace visual review.
4. Build affected artifacts and inspect the real page through `dsh web`. Batch approved refinements before restarting a metadata-caching Host, then verify the entry and nested component rows in both themes.
5. Follow the repository's UI testing policy and [record-browser-gif](../record-browser-gif/SKILL.md) for a GUI PR. Obtain designer approval before landing new glyphs. Record reusable drawing principles here, not the history of rejected drafts.
@@ -36,8 +36,8 @@
en: 203168fc87e939bd
zh: 72d0ccd2c54f3f44
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/registration:
en: 5fc3fae031cccff2
zh: d5aef05ec75e22f0
en: 3d70281f082988a5
zh: 17c6f595e3b57260
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/the-store:
en: 21a0bbe8eb92229c
zh: 096ff63db61992bb
@@ -110,8 +110,6 @@ The Host entry exposes `pluginRegistryProbe.fastest()` through the generated Rem
The browser plugin registers the `plugins` sidebar entry and its `main` panel through `ctx.slots.inject()`, so both follow late slot declaration, locale changes and teardown. The page is global and belongs to no Session. Display text comes from package metadata and the page's dictionary.
The Plugins list, Experimental plugins page, and detail-page breadcrumb share a 28px header inset. On macOS, each header adds the frame's 48px titlebar clearance, placing its leading content 76px below the viewport top in either sidebar state. Windows uses the frame's 40px caption inset plus the page's 28px, totaling 68px; ordinary browsers use 28px. The page does not redefine `--dsh-frame-top-clearance` or add the Windows caption inset a second time.
The add control reuses `Button`, `Menu`, and `MenuItemButton`. Both installation actions call the same `openInstall` callback and use `InstallDialog` and `PluginManagerController`. The root-scoped list slot `plugins.add.actions` accepts contributed `MenuItemButton` rows after the install action; each contribution receives an `onDismiss()` callback to close the menu before starting its action.
### The store
@@ -110,8 +110,6 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()`
浏览器插件通过 `ctx.slots.inject()` 注册 `plugins` 侧栏入口与它的 `main` 面板,使两者跟随 slot 延迟声明、本地化变化与销毁。页面为全局页面,不属于任何 Session。显示文本来自包元信息与页面字典。
插件主列表、实验性插件页与详情页面包屑共用 28px 的页头顶部内边距。macOS 下,各页头叠加框架的 48px 标题栏避让量,因此无论侧栏展开还是收起,首行内容均距视口顶部 76px。Windows 由框架提供 40px 标题栏内边距,再加页面的 28px,合计 68px;普通浏览器为 28px。页面不重新定义 `--dsh-frame-top-clearance`,也不重复叠加 Windows 标题栏内边距。
添加控件复用 `Button`、`Menu` 和 `MenuItemButton`。两个安装入口调用同一个 `openInstall` 回调,使用 `InstallDialog` 与 `PluginManagerController`。root 作用域的 list slot `plugins.add.actions` 接受贡献的 `MenuItemButton` 行,放在安装项之后;每个贡献者接收 `onDismiss()` 回调,在开始动作前关闭菜单。
### store
@@ -1,37 +0,0 @@
/** Entry-page insets are checked as CSS because jsdom does not lay out window chrome. */
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const css = readFileSync(new URL('../src/client/PluginManagerPage.module.css', import.meta.url), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(match => ({
selectors: (match[1] ?? '').split(',').map(selector => selector.trim()),
declarations: (match[2] ?? '').split(';').map(declaration => declaration.trim()),
}))
function topInsets(selector: string): string[] {
return rules.filter(rule => rule.selectors.includes(selector))
.flatMap(rule => rule.declarations.filter(declaration => /^padding(?:-top)?:/.test(declaration)))
}
describe('Plugins entry-page titlebar clearance', () => {
it.each(['.pageHead', '.moreHead', '.detailTop'])('%s keeps the browser inset and adds macOS clearance only once', (selector) => {
expect(topInsets(selector)).toEqual(['padding-top: 28px'])
expect(topInsets(`:global([data-platform='darwin']) ${selector}`))
.toEqual(['padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px))'])
})
it('leaves the top inset with the header rather than the scrolling page', () => {
expect(topInsets('.page')).toEqual(['padding: 0 clamp(24px, 4vw, 48px) 48px'])
})
it('does not redefine shared clearance or reference it outside the macOS headers', () => {
expect(css).not.toMatch(/--dsh-frame-top-clearance\s*:/)
const clearanceRules = rules.filter(rule => rule.declarations.some(declaration => declaration.includes('--dsh-frame-top-clearance')))
expect(clearanceRules.flatMap(rule => rule.selectors)).toEqual([
":global([data-platform='darwin']) .pageHead",
":global([data-platform='darwin']) .detailTop",
":global([data-platform='darwin']) .moreHead",
])
})
})
@@ -1,6 +1,4 @@
// @vitest-environment jsdom
import { globSync, readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import {
@@ -10,40 +8,6 @@ import {
afterEach(cleanup)
const packagesRoot = resolve(import.meta.dirname, '../../..')
const packageIcons = globSync('*/*/package.json', { cwd: packagesRoot }).sort().flatMap((path) => {
const manifest = JSON.parse(readFileSync(join(packagesRoot, path), 'utf8')) as { icon?: string }
return manifest.icon === undefined ? [] : [join(dirname(path), manifest.icon)]
})
/** Package SVGs render as separate images; inline React artwork can use HTML and per-instance ids. */
function assertPackageArtwork(source: string): void {
const document = new DOMParser().parseFromString(source, 'image/svg+xml')
expect(document.querySelector('parsererror')).toBeNull()
const svg = document.documentElement
expect(svg.localName).toBe('svg')
expect(svg.namespaceURI).toBe('http://www.w3.org/2000/svg')
expect(svg.getAttribute('viewBox')).toBe('0 0 36 36')
expect(svg.getAttribute('width')).toBe('36')
expect(svg.getAttribute('height')).toBe('36')
expect(svg.querySelector('path, rect, circle, ellipse, line, polyline, polygon')).not.toBeNull()
expect(svg.querySelector('script, style, image, foreignObject, text, animate, animateTransform, animateMotion, set')).toBeNull()
const elements = [svg, ...svg.querySelectorAll('*')]
const ids = elements.flatMap(element => element.hasAttribute('id') ? [element.id] : [])
expect(new Set(ids).size).toBe(ids.length)
for (const element of elements) {
for (const attribute of element.attributes) {
expect(attribute.name).not.toMatch(/^on/i)
const references = [...attribute.value.matchAll(/url\(([^)]+)\)/g)].map(match => (match[1] ?? '').replace(/['"]/g, '').trim())
if (attribute.localName === 'href') references.push(attribute.value)
for (const reference of references) {
expect(reference).toMatch(/^#/)
expect(ids).toContain(reference.slice(1))
}
}
}
}
const artworks = {
GuideArtworkBrowser,
GuideArtworkFiles,
@@ -54,39 +18,6 @@ const artworks = {
PluginArtworkDefault,
} as const
describe('package plugin artwork', () => {
const valid = '<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><defs><linearGradient id="ink"><stop stop-color="#69B9FF" /></linearGradient></defs><path d="M8 8H28V28H8Z" fill="url(#ink)" /></svg>'
it('discovers a nonempty set of package-manifest artwork', () => {
expect(packageIcons.length).toBeGreaterThan(0)
})
it.each(packageIcons)('%s is a self-contained 36-unit SVG image', (path) => {
assertPackageArtwork(readFileSync(join(packagesRoot, path), 'utf8'))
})
it('accepts a local gradient reference', () => {
assertPackageArtwork(valid)
})
it.each([
['malformed XML', valid.replace('</svg>', '')],
['toolbar canvas', valid.replace('0 0 36 36', '0 0 16 16')],
['toolbar width', valid.replace('width="36"', 'width="16"')],
['toolbar height', valid.replace('height="36"', 'height="16"')],
['missing definition', valid.replace('url(#ink)', 'url(#missing)')],
['external definition', valid.replace('url(#ink)', 'url(https://example.test/ink.svg#ink)')],
['external href', valid.replace('<path ', '<path href="https://example.test/icon.svg" ')],
['duplicate ids', valid.replace('<path ', '<path id="ink" ')],
['event handler', valid.replace('<path ', '<path onload="run()" ')],
['empty artwork', valid.replace(/<path[^>]+\/>/, '')],
...['script', 'style', 'image', 'foreignObject', 'text', 'animate', 'animateTransform', 'animateMotion', 'set']
.map(tag => [tag, valid.replace('</svg>', `<${tag} /></svg>`)]),
])('rejects %s', (_name, source) => {
expect(() => { assertPackageArtwork(source) }).toThrow()
})
})
describe('plugin artwork', () => {
it.each(Object.entries(artworks))('%s renders its 36-viewBox svg honoring size and className', (_name, Artwork) => {
const { container } = render(<Artwork size={30} className="deco" />)
@@ -19,7 +19,6 @@
"default": "./lib/index.js"
},
"./package.json": "./package.json",
"./icon": "./icon.svg",
"./locale/*.json": "./locale/*.json"
},
"icon": "./icon.svg",
@@ -32,7 +32,7 @@ describe('dsh-skill-badge', () => {
it('presents the component as DSH with the bundle tag artwork in both locales', async () => {
expect(JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))).toMatchObject({
icon: './icon.svg',
exports: { './icon': './icon.svg', './locale/*.json': './locale/*.json' },
exports: { './locale/*.json': './locale/*.json' },
})
for (const language of ['en', 'zh']) {
expect(JSON.parse(await readFile(new URL(`../locale/${language}.json`, import.meta.url), 'utf8')))
+94 -17
View File
@@ -1,11 +1,13 @@
/** Plugin metadata checks use private source-only packages and real resource exports. */
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { packageMetaProblems } from './verify-package-meta.ts'
const validSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><defs><linearGradient id="ink"><stop stop-color="#69B9FF" /></linearGradient></defs><path d="M8 8H28V28H8Z" fill="url(#ink)" /></svg>'
let root: string
let dir: string
beforeEach(() => {
@@ -26,7 +28,7 @@ function json(path: string, contents: unknown): void {
function manifest(fields: object = {}): void {
json('package.json', {
name: '@test/plugin',
name: '@deepseek-ai/dsh-test-plugin',
description: 'Package description',
type: 'module',
exports: { '.': './lib/index.js', './package.json': './package.json', './locale/*.json': './locale/*.json' },
@@ -46,14 +48,89 @@ it('does not require locale resources for exported package name and description'
it.each(['legacy.svg', './legacy.svg'])('accepts a manifest-only icon %s', (icon) => {
manifest({ icon, files: ['legacy.svg'] })
file('legacy.svg', 'legacy')
file('legacy.svg', validSvg)
expect(packageMetaProblems(root)).toEqual([])
})
it.each(['manifest', 'export'])('accepts SVG geometry and local references selected by %s', (selection) => {
manifest({
...selection === 'manifest' ? { icon: './icon.svg' } : {},
exports: { './package.json': './package.json', './icon': './icon.svg' }, files: ['icon.svg'],
})
file('icon.svg', validSvg.replace('<path ', '<use href="#outline"/><path id="outline" '))
expect(packageMetaProblems(root)).toEqual([])
})
it.each([
['malformed XML', validSvg.replace('</svg>', ''), 'well-formed XML'],
['wrong root', validSvg.replaceAll('svg', 'g'), 'svg root'],
['wrong namespace', validSvg.replace('http://www.w3.org/2000/svg', 'urn:other'), 'SVG namespace'],
['toolbar canvas', validSvg.replace('0 0 36 36', '0 0 16 16'), 'viewBox="0 0 36 36"'],
['toolbar width', validSvg.replace('width="36"', 'width="16"'), 'width="36"'],
['toolbar height', validSvg.replace('height="36"', 'height="16"'), 'height="36"'],
['missing definition', validSvg.replace('url(#ink)', 'url(#missing)'), 'must name a local id'],
['external definition', validSvg.replace('url(#ink)', 'url(https://example.test/ink.svg#ink)'), 'must name a local id'],
['external href', validSvg.replace('<path ', '<path href="https://example.test/icon.svg" '), 'must name a local id'],
['missing href target', validSvg.replace('<path ', '<path href="#missing" '), 'must name a local id'],
['duplicate ids', validSvg.replace('<path ', '<path id="ink" '), 'unique ids'],
['event handler', validSvg.replace('<path ', '<path onload="run()" '), 'event handler onload'],
['empty artwork', validSvg.replace(/<path[^>]+\/>/u, ''), 'must contain geometry'],
...['script', 'style', 'image', 'foreignObject', 'text', 'animate', 'animateTransform', 'animateMotion', 'set']
.map(tag => [tag, validSvg.replace('</svg>', `<${tag} /></svg>`), 'self-contained geometry']),
])('rejects SVG %s through metadata verification', (_name, source, diagnostic) => {
manifest({ icon: './icon.svg', files: ['icon.svg'] })
file('icon.svg', source)
const problems = packageMetaProblems(root)
expect(problems).toHaveLength(1)
expect(problems[0]).toContain(`${join(dir, 'icon.svg')}:`)
expect(problems[0]).toContain(diagnostic)
})
it('validates the selected ./icon SVG fallback without a manifest icon', () => {
manifest({ exports: { './package.json': './package.json', './icon': './icon.svg' }, files: ['icon.svg'] })
file('icon.svg', validSvg.replace('width="36"', 'width="16"'))
expect(packageMetaProblems(root)).toEqual([`${realpathSync(join(dir, 'icon.svg'))}: SVG icon must use width="36" height="36" viewBox="0 0 36 36"`])
})
it('validates the manifest icon instead of the lower-priority SVG export', () => {
manifest({ icon: './icon.svg', exports: { './package.json': './package.json', './icon': './fallback.svg' }, files: ['icon.svg', 'fallback.svg'] })
file('icon.svg', validSvg)
file('fallback.svg', 'not XML')
expect(packageMetaProblems(root)).toEqual([])
})
it.each(['png', 'jpg', 'jpeg', 'webp'].flatMap(extension => ['manifest', 'export'].map(selection => ({ extension, selection }))))(
'keeps $extension bytes opaque for $selection icons', ({ extension, selection }) => {
manifest({
...selection === 'manifest' ? { icon: `./icon.${extension}` } : {},
exports: { './package.json': './package.json', './icon': `./icon.${extension}` }, files: [`icon.${extension}`],
})
file(`icon.${extension}`, 'opaque raster bytes')
expect(packageMetaProblems(root)).toEqual([])
},
)
it.each(['@local/demo', '@deepseek-ai/other'])('keeps nonofficial %s SVGs outside official artwork conventions', (name) => {
manifest({ name, icon: './icon.svg', files: ['icon.svg'] })
file('icon.svg', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"/>')
expect(packageMetaProblems(root)).toEqual([])
})
it('checks official artwork in named nested packages', () => {
manifest({ exports: { '.': './entry.js' }, files: ['nested'] })
json('nested/package.json', {
name: '@deepseek-ai/dsh-nested', exports: { './icon': './icon.svg' }, files: ['icon.svg'],
})
file('nested/icon.svg', validSvg.replace('width="36"', 'width="16"'))
expect(packageMetaProblems(root)).toEqual([
`${realpathSync(join(dir, 'nested/icon.svg'))}: SVG icon must use width="36" height="36" viewBox="0 0 36 36"`,
])
})
it('reads the manifest image and still rejects an unresolvable lower-priority export', () => {
manifest({ icon: './legacy.svg', files: ['legacy.svg'],
exports: { './package.json': './package.json', './icon': '../invalid.svg' } })
file('legacy.svg', 'legacy')
file('legacy.svg', validSvg)
expect(packageMetaProblems(root)).toEqual([`${join(dir, 'package.json')}: exports["./icon"] must resolve to a file`])
})
@@ -62,7 +139,7 @@ it.each([['display'], ['display/manifest.json'], ['display/logo.svg']])
manifest({ files,
exports: { './package.json': './display/manifest.json' } })
json('display/manifest.json', { icon: './logo.svg' })
file('display/logo.svg', 'legacy')
file('display/logo.svg', validSvg)
const problems = packageMetaProblems(root).join('\n')
if (files.includes('display')) expect(problems).toBe('')
else expect(problems).toContain(`files must include ${files.includes('display/manifest.json') ? 'display/logo.svg' : 'display/manifest.json'}`)
@@ -71,15 +148,15 @@ it.each([['display'], ['display/manifest.json'], ['display/logo.svg']])
it('requires the root manifest icon declaration to remain accessible', () => {
manifest({ icon: './legacy.svg', files: ['legacy.svg'],
exports: { './icon': './legacy.svg' } })
file('legacy.svg', 'legacy')
file('legacy.svg', validSvg)
expect(packageMetaProblems(root).join('\n')).toContain('exports must expose its icon declaration')
})
it.each([null, false, '', './missing.svg'])('diagnoses legacy icon %j despite a valid fallback export', (icon) => {
manifest({ icon, files: ['fallback.svg'],
exports: { './package.json': './package.json', './icon': './fallback.svg' } })
file('fallback.svg', 'fallback')
expect(packageMetaProblems(root).join('\n')).toContain('Plugin metadata for @test/plugin:')
file('fallback.svg', validSvg)
expect(packageMetaProblems(root).join('\n')).toContain('Plugin metadata for @deepseek-ai/dsh-test-plugin:')
})
it.each([undefined, ['art'], ['art/*.webp'], ['./art/icon.webp']])('accepts an exported icon without other resources: %j', (files) => {
@@ -96,7 +173,7 @@ it.each([['lib'], ['art', '!art/icon.webp']])('rejects an exported icon omitted
it.each([false, 1, '', './icon.gif', '/tmp/icon.svg', './missing.png', '../outside.svg'])('validates exported icon targets: %j', (icon) => {
manifest({ exports: { './icon': icon } })
expect(packageMetaProblems(root).join('\n')).toMatch(/Plugin metadata for @test\/plugin:|exports\["\.\/icon"\] must resolve to a file/u)
expect(packageMetaProblems(root).join('\n')).toMatch(/Plugin metadata for @deepseek-ai\/dsh-test-plugin:|exports\["\.\/icon"\] must resolve to a file/u)
})
it.each([['assets'], ['lib']])('checks publication of the Node-selected conditional icon: %j', (...files) => {
@@ -116,8 +193,8 @@ it('ignores package.json exported at a subpath plugin address', () => {
it.each([['search.svg'], ['lib']])('reads and publishes a subpath icon export: %j', (...files) => {
manifest({ icon: './root.svg', exports: { './package.json': './package.json', './search': './search.js', './search/icon': './search.svg' },
files: ['root.svg', ...files] })
file('root.svg', 'root')
file('search.svg', 'search')
file('root.svg', validSvg)
file('search.svg', validSvg)
const problems = packageMetaProblems(root)
if (files.includes('search.svg')) expect(problems).toEqual([])
else expect(problems).toEqual([`${join(dir, 'package.json')}: files must include search.svg`])
@@ -165,8 +242,8 @@ it.each([
files: ['locale', 'icon.svg'], ...fields,
})
json('examples/nested/locale/en.json', { meta: { title } })
file('examples/nested/icon.svg', '<svg/>')
file('examples/nested/fallback.svg', '<svg/>')
file('examples/nested/icon.svg', validSvg)
file('examples/nested/fallback.svg', validSvg)
const problems = packageMetaProblems(root)
if (expected === undefined) expect(problems).toEqual([])
else expect(problems.join('\n')).toContain(expected)
@@ -255,7 +332,7 @@ it('does not read built locale JSON while checking a source icon declaration', (
icon: './icon.svg', files: ['icon.svg'],
exports: { './package.json': './package.json', './locale/en.json': './lib/locale/en.json' },
})
file('icon.svg', '<svg/>')
file('icon.svg', validSvg)
file('lib/locale/en.json', '{')
json('locale/en.json', { meta: { title: 'Plugin' } })
const problems = packageMetaProblems(root).join('\n')
@@ -270,7 +347,7 @@ it.each(['lib', 'tests'])('rejects an icon declaration when its only locale is e
})
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).toContain('@deepseek-ai/dsh-test-plugin: icon metadata requires locale resources to resolve to source JSON')
expect(problems).not.toContain('Plugin metadata for')
})
@@ -594,7 +671,7 @@ it('reports metadata failures from more than one workspace package', () => {
json('locale/en.json', { meta: { title: 'Second' } })
json('locale/zh.json', { meta: { description: '' } })
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain('@test/plugin')
expect(problems).toContain('@deepseek-ai/dsh-test-plugin')
expect(problems).toContain('@test/second')
})
@@ -603,6 +680,6 @@ it.each([[], ['locale']])('rejects a display manifest in build output without re
if (locales.length > 0) json('locale/en.json', { meta: { title: 'Plugin' } })
file('lib/display.json', '{')
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain('@test/plugin/package.json: display manifest must resolve to source JSON')
expect(problems).toContain('@deepseek-ai/dsh-test-plugin/package.json: display manifest must resolve to source JSON')
expect(problems).not.toContain('Plugin metadata for')
})
+57 -6
View File
@@ -1,8 +1,9 @@
/** Validate source locale metadata, Node resource exports, and package publication selections. */
/** Validate source locale metadata, official SVG artwork, Node resource exports, and publication selections. */
import { globSync, readFileSync, realpathSync } from 'node:fs'
import { basename, dirname, join, matchesGlob, relative, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { JSDOM } from 'jsdom'
import { readPluginMeta, resolvePluginResource } from '../packages/boot/app-boot/src/package-meta.ts'
interface Manifest {
@@ -72,7 +73,46 @@ function published(file: string, files: string[]): boolean {
&& !files.some(pattern => pattern.startsWith('!') && covered(pattern.slice(1)))
}
function packageProblems(manifestPath: string): string[] {
/** Structural artwork conventions only; not an SVG sanitizer or a rendered-pixel check. */
function svgArtworkProblems(icon: string, parser: DOMParser): 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']
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']
}
const problems: string[] = []
if (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')
}
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 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')
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}`)
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 (!reference.startsWith('#') || !uniqueIds.has(reference.slice(1))) {
problems.push(`SVG icon reference ${reference} must name a local id`)
}
}
}
}
return problems
}
function packageProblems(manifestPath: string, parser: DOMParser): string[] {
const problems: string[] = []
const dir = realpathSync(dirname(manifestPath))
const pkg = JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest
@@ -152,6 +192,9 @@ function packageProblems(manifestPath: string): string[] {
: 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]) {
if (target === undefined) continue
const file = relative(dir, target).replaceAll('\\', '/')
@@ -209,18 +252,26 @@ function packageProblems(manifestPath: string): string[] {
})) continue
problems.push(`${manifestPath}: exports must expose ${document.file} as a plugin locale resource with an en.json discovery baseline`)
}
return problems.concat(children.flatMap(child => packageProblems(join(dir, child.file))))
return problems.concat(children.flatMap(child => packageProblems(join(dir, child.file), parser)))
}
/**
* 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.
* @param root - repository or fixture root.
* @returns diagnostics for an empty package corpus, invalid metadata, inaccessible resources, or omitted publication files.
* @returns diagnostics for an empty package corpus, invalid metadata or SVG artwork, inaccessible resources, or omitted publication files.
*/
export function packageMetaProblems(root: string): string[] {
const manifests = globSync('packages/*/*/package.json', { cwd: root }).map(path => path.replaceAll('\\', '/'))
if (manifests.length === 0) return [`${root}: no workspace package manifests found`]
return manifests.flatMap(path => packageProblems(join(root, path)))
const { window } = new JSDOM()
try {
const parser = new window.DOMParser()
return manifests.flatMap(path => packageProblems(join(root, path), parser))
} finally {
window.close()
}
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
@@ -229,6 +280,6 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(
console.error(problems.join('\n'))
process.exitCode = 1
} else {
console.log('verify-package-meta: plugin locale metadata, resource exports, and publication files are valid.')
console.log('verify-package-meta: plugin locale metadata, SVG artwork, resource exports, and publication files are valid.')
}
}