feat(sdk): support for plugin icons

This commit is contained in:
jubnl
2026-07-13 15:48:01 +02:00
parent ebc98f0a3f
commit bae98d1144
18 changed files with 1709 additions and 30 deletions
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Blocks, Stethoscope } from 'lucide-react'
import { resolvePluginIcon } from './pluginIcon'
import PluginIcon from './PluginIcon'
describe('resolvePluginIcon', () => {
it('resolves a lucide name outside the old 14-icon allowlist', () => {
expect(resolvePluginIcon('Stethoscope')).toBe(Stethoscope)
})
it('falls back to Blocks for a missing or unknown name', () => {
expect(resolvePluginIcon(null)).toBe(Blocks)
expect(resolvePluginIcon(undefined)).toBe(Blocks)
expect(resolvePluginIcon('')).toBe(Blocks)
expect(resolvePluginIcon('Stethscope')).toBe(Blocks)
})
// A manifest must not be able to reach lucide's non-icon exports.
it.each(['icons', 'createLucideIcon', 'default', 'module.exports'])(
'falls back to Blocks for the non-icon export %s',
(name) => expect(resolvePluginIcon(name)).toBe(Blocks),
)
it('returns a stable reference so nav arrays do not remount the icon', () => {
expect(resolvePluginIcon('Stethoscope')).toBe(resolvePluginIcon('Stethoscope'))
})
})
describe('PluginIcon', () => {
it('renders the plugin-declared icon', () => {
const { container } = render(<PluginIcon name="Stethoscope" />)
expect(container.querySelector('.lucide-stethoscope')).not.toBeNull()
})
it('renders Blocks when the plugin declares nothing', () => {
const { container } = render(<PluginIcon name={null} />)
expect(container.querySelector('.lucide-blocks')).not.toBeNull()
})
})
@@ -0,0 +1,13 @@
import type { CSSProperties } from 'react'
import { resolvePluginIcon } from './pluginIcon'
/** Renders the lucide icon a plugin declares in its manifest (Blocks if unknown). */
export default function PluginIcon({ name, size = 20, className, style }: {
name: string | null | undefined
size?: number
className?: string
style?: CSSProperties
}) {
const Icon = resolvePluginIcon(name)
return <Icon size={size} className={className} style={style} />
}
@@ -0,0 +1,22 @@
import * as LucideIcons from 'lucide-react'
import { Blocks, type LucideIcon } from 'lucide-react'
// Lucide names are PascalCase. Restricting the lookup to that shape keeps a manifest
// from reaching lucide's non-icon exports (`createLucideIcon`, `icons`, `default`,
// `module.exports`), which are all lowercase-initial.
const LUCIDE_ICON_NAME = /^[A-Z][A-Za-z0-9]*$/
/**
* Resolves a plugin manifest's `icon` (a lucide icon name, e.g. "Stethoscope") to the
* matching lucide component, falling back to Blocks for a missing or unknown name.
*
* Note lucide icons are forwardRef objects, not functions — never narrow this with
* `typeof x === 'function'`. The lookup returns a module-level reference, so the
* component identity is stable across renders and is safe to call inline while
* building nav/tab arrays.
*/
export function resolvePluginIcon(name: string | null | undefined): LucideIcon {
if (!name || !LUCIDE_ICON_NAME.test(name)) return Blocks
const icon = (LucideIcons as unknown as Record<string, LucideIcon | undefined>)[name]
return icon ?? Blocks
}