Barazo default frontend barazo.forum

feat(plugins): add frontend plugin system (P2.12 M2) (#190)

* feat(plugins): update API client methods and add registry endpoints

* feat(plugins): add PluginContext provider and usePlugins hook

Provides plugin state to the component tree: fetches enabled plugins
from the API on mount, exposes isPluginEnabled/getPluginSettings
helpers, and a refreshPlugins method for admin pages to trigger
re-fetch after changes. Gracefully handles unauthenticated users
by returning an empty plugin list.

* feat(plugins): add PluginSlot component and plugin registry

* feat(plugins): wire admin plugins page to real API

* test(plugins): add tests for plugin frontend system

Tests for plugin component registry, PluginSlot component, and
PluginProvider context covering registration, rendering, error
boundaries, context propagation, and accessibility.

* feat(plugins): add PluginSlot placements across app

* fix(plugins): resolve type errors and lint issues in plugin tests

- Fix PluginSource type in test mocks ('builtin' -> 'core'/'official')
- Fix PluginSettingsSchema shape in test mocks (use empty object)
- Use mutable ref pattern for context capture in tests (avoids TS narrowing)
- Remove unused imports (screen, SlotName)
- Return safe default from usePlugins hook when no provider (SSR/test compat)
- Update admin plugins page test for new wired implementation

* style(plugins): format files with prettier

authored by

Guido X Jansen and committed by
GitHub
ee9923d1 b4ceba5b

+1246 -28
+3 -3
pnpm-lock.yaml
··· 38 38 39 39 .: 40 40 dependencies: 41 - '@barazo-forum/lexicons': 42 - specifier: link:../barazo-lexicons 43 - version: link:../barazo-lexicons 44 41 '@dnd-kit/core': 45 42 specifier: 6.3.1 46 43 version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ··· 137 134 '@radix-ui/react-tooltip': 138 135 specifier: 1.2.8 139 136 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 137 + '@singi-labs/lexicons': 138 + specifier: link:../barazo-lexicons 139 + version: link:../barazo-lexicons 140 140 '@tailwindcss/typography': 141 141 specifier: 0.5.19 142 142 version: 0.5.19(tailwindcss@4.2.1)
+10 -3
src/app/[handle]/[rkey]/page.tsx
··· 27 27 import { Breadcrumbs } from '@/components/breadcrumbs' 28 28 import { TopicView } from '@/components/topic-view' 29 29 import { TopicDetailClient } from '@/components/topic-detail-client' 30 + import { PluginSlot } from '@/components/plugin-slot' 30 31 import type { CategoriesResponse, RepliesResponse } from '@/lib/api/types' 31 32 32 33 export const dynamic = 'force-dynamic' ··· 198 199 <ForumLayout 199 200 publicSettings={publicSettings} 200 201 sidebar={ 201 - categoriesResult.categories.length > 0 ? ( 202 - <CategoryNav categories={categoriesResult.categories} /> 203 - ) : undefined 202 + <> 203 + {categoriesResult.categories.length > 0 && ( 204 + <CategoryNav categories={categoriesResult.categories} /> 205 + )} 206 + <PluginSlot 207 + name="topic-sidebar" 208 + context={{ topicUri: topic.uri, categorySlug: topic.category }} 209 + /> 210 + </> 204 211 } 205 212 > 206 213 {/* JSON-LD: omitted for Adult content */}
+3
src/app/admin/page.tsx
··· 14 14 import { getCommunityStats } from '@/lib/api/client' 15 15 import type { CommunityStats } from '@/lib/api/types' 16 16 import { useAuth } from '@/hooks/use-auth' 17 + import { PluginSlot } from '@/components/plugin-slot' 17 18 18 19 interface StatCardProps { 19 20 label: string ··· 101 102 /> 102 103 </div> 103 104 )} 105 + 106 + <PluginSlot name="admin-dashboard" /> 104 107 </div> 105 108 </AdminLayout> 106 109 )
+71 -8
src/app/admin/plugins/page.test.tsx
··· 1 1 /** 2 - * Tests for admin plugins page (P3 placeholder). 2 + * Tests for admin plugins page. 3 3 * @see specs/prd-web.md Section M13 4 4 */ 5 5 6 6 import { describe, it, expect, vi } from 'vitest' 7 - import { render, screen } from '@testing-library/react' 7 + import { render, screen, waitFor } from '@testing-library/react' 8 8 import { axe } from 'vitest-axe' 9 9 import AdminPluginsPage from './page' 10 + import { usePluginManagement } from '@/hooks/admin/use-plugin-management' 11 + import type { Plugin } from '@/lib/api/types' 10 12 11 13 vi.mock('next/navigation', () => ({ 12 14 usePathname: () => '/admin/plugins', ··· 31 33 return { useAuth: () => mockAuth } 32 34 }) 33 35 36 + vi.mock('@/hooks/admin/use-plugin-management') 37 + 38 + const mockPlugin: Plugin = { 39 + id: '1', 40 + name: '@barazo/plugin-signatures', 41 + displayName: 'User Signatures', 42 + version: '1.0.0', 43 + description: 'Portable user signatures', 44 + source: 'core', 45 + enabled: true, 46 + category: 'social', 47 + dependencies: [], 48 + dependents: [], 49 + settingsSchema: {}, 50 + settings: {}, 51 + installedAt: '2026-01-01T00:00:00Z', 52 + } 53 + 54 + function mockPluginManagement(overrides: Partial<ReturnType<typeof usePluginManagement>> = {}) { 55 + vi.mocked(usePluginManagement).mockReturnValue({ 56 + plugins: [], 57 + loading: false, 58 + settingsPlugin: null, 59 + setSettingsPlugin: vi.fn(), 60 + dependencyWarning: null, 61 + setDependencyWarning: vi.fn(), 62 + loadError: null, 63 + actionError: null, 64 + setActionError: vi.fn(), 65 + fetchPlugins: vi.fn(), 66 + handleToggle: vi.fn(), 67 + confirmDisable: vi.fn(), 68 + handleSaveSettings: vi.fn(), 69 + handleUninstall: vi.fn(), 70 + settingsSaveStatus: 'idle', 71 + ...overrides, 72 + }) 73 + } 74 + 34 75 describe('AdminPluginsPage', () => { 35 76 it('renders page heading', () => { 77 + mockPluginManagement() 36 78 render(<AdminPluginsPage />) 37 79 expect(screen.getByRole('heading', { name: /plugins/i, level: 1 })).toBeInTheDocument() 38 80 }) 39 81 40 - it('shows coming in P3 message', () => { 82 + it('shows empty state when no plugins installed', () => { 83 + mockPluginManagement({ plugins: [], loading: false }) 84 + render(<AdminPluginsPage />) 85 + expect(screen.getByText(/no plugins installed/i)).toBeInTheDocument() 86 + }) 87 + 88 + it('shows loading skeletons', () => { 89 + mockPluginManagement({ loading: true }) 90 + render(<AdminPluginsPage />) 91 + expect(screen.getByLabelText(/loading plugins/i)).toBeInTheDocument() 92 + }) 93 + 94 + it('shows plugin list when plugins exist', () => { 95 + mockPluginManagement({ plugins: [mockPlugin] }) 41 96 render(<AdminPluginsPage />) 42 - expect(screen.getByRole('heading', { name: /coming in p3/i })).toBeInTheDocument() 43 - expect(screen.getByText(/planned for the p3\.2 milestone/i)).toBeInTheDocument() 97 + expect(screen.getByText('User Signatures')).toBeInTheDocument() 98 + }) 99 + 100 + it('shows load error', () => { 101 + mockPluginManagement({ loadError: 'Failed to load plugins.' }) 102 + render(<AdminPluginsPage />) 103 + expect(screen.getByText(/failed to load plugins/i)).toBeInTheDocument() 44 104 }) 45 105 46 - it('passes axe accessibility check', async () => { 106 + it('passes axe accessibility check with empty state', async () => { 107 + mockPluginManagement() 47 108 const { container } = render(<AdminPluginsPage />) 48 - const results = await axe(container) 49 - expect(results).toHaveNoViolations() 109 + await waitFor(async () => { 110 + const results = await axe(container) 111 + expect(results).toHaveNoViolations() 112 + }) 50 113 }) 51 114 })
+86 -10
src/app/admin/plugins/page.tsx
··· 2 2 * Admin plugin management page. 3 3 * URL: /admin/plugins 4 4 * Lists installed plugins with enable/disable, settings, and uninstall controls. 5 - * Backend endpoints not yet implemented (planned for P3.2). 6 5 * @see specs/prd-web.md Section M13 7 6 */ 8 7 9 - import { PuzzlePiece } from '@phosphor-icons/react/dist/ssr' 8 + 'use client' 9 + 10 + import { PuzzlePiece } from '@phosphor-icons/react' 10 11 import { AdminLayout } from '@/components/admin/admin-layout' 12 + import { ErrorAlert } from '@/components/error-alert' 13 + import { PluginCard } from '@/components/admin/plugins/plugin-card' 14 + import { PluginSettingsModal } from '@/components/admin/plugins/plugin-settings-modal' 15 + import { DependencyWarningDialog } from '@/components/admin/plugins/dependency-warning-dialog' 16 + import { usePluginManagement } from '@/hooks/admin/use-plugin-management' 11 17 12 18 export default function AdminPluginsPage() { 19 + const { 20 + plugins, 21 + loading, 22 + settingsPlugin, 23 + setSettingsPlugin, 24 + dependencyWarning, 25 + setDependencyWarning, 26 + loadError, 27 + actionError, 28 + setActionError, 29 + fetchPlugins, 30 + handleToggle, 31 + confirmDisable, 32 + handleSaveSettings, 33 + handleUninstall, 34 + settingsSaveStatus, 35 + } = usePluginManagement() 36 + 13 37 return ( 14 38 <AdminLayout> 15 39 <div className="space-y-6"> 16 40 <h1 className="text-2xl font-bold text-foreground">Plugins</h1> 17 41 18 - <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center"> 19 - <PuzzlePiece className="mb-4 h-12 w-12 text-muted-foreground/50" /> 20 - <h2 className="text-lg font-semibold text-foreground">Coming in P3</h2> 21 - <p className="mt-1 max-w-sm text-sm text-muted-foreground"> 22 - Plugin management (install, enable/disable, configure) is planned for the P3.2 23 - milestone. The plugin API endpoints are not yet available. 24 - </p> 25 - </div> 42 + {loadError && ( 43 + <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchPlugins()} /> 44 + )} 45 + 46 + {actionError && <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} />} 47 + 48 + {loading && ( 49 + <div className="space-y-3" aria-busy="true" aria-label="Loading plugins"> 50 + {[1, 2, 3].map((i) => ( 51 + <div 52 + key={i} 53 + className="h-20 animate-pulse rounded-lg border border-border bg-muted/50" 54 + /> 55 + ))} 56 + </div> 57 + )} 58 + 59 + {!loading && !loadError && plugins.length === 0 && ( 60 + <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center"> 61 + <PuzzlePiece className="mb-4 h-12 w-12 text-muted-foreground/50" aria-hidden="true" /> 62 + <h2 className="text-lg font-semibold text-foreground">No plugins installed</h2> 63 + <p className="mt-1 max-w-sm text-sm text-muted-foreground"> 64 + Plugins extend your community with additional features. Install plugins to see them 65 + listed here. 66 + </p> 67 + </div> 68 + )} 69 + 70 + {!loading && plugins.length > 0 && ( 71 + <div className="space-y-3"> 72 + {plugins.map((plugin) => ( 73 + <PluginCard 74 + key={plugin.id} 75 + plugin={plugin} 76 + allPlugins={plugins} 77 + onOpenSettings={(p) => setSettingsPlugin(p)} 78 + onToggle={(p) => void handleToggle(p)} 79 + onUninstall={(p) => void handleUninstall(p)} 80 + /> 81 + ))} 82 + </div> 83 + )} 26 84 </div> 85 + 86 + {settingsPlugin && ( 87 + <PluginSettingsModal 88 + plugin={settingsPlugin} 89 + onClose={() => setSettingsPlugin(null)} 90 + onSave={(settings) => void handleSaveSettings(settings)} 91 + saveStatus={settingsSaveStatus} 92 + /> 93 + )} 94 + 95 + {dependencyWarning && ( 96 + <DependencyWarningDialog 97 + pluginName={dependencyWarning.plugin.displayName} 98 + dependents={dependencyWarning.dependents} 99 + onConfirm={() => void confirmDisable()} 100 + onCancel={() => setDependencyWarning(null)} 101 + /> 102 + )} 27 103 </AdminLayout> 28 104 ) 29 105 }
+4 -1
src/app/layout.tsx
··· 5 5 import { AuthProvider } from '@/context/auth-context' 6 6 import { AppToastProvider } from '@/context/toast-context' 7 7 import { OnboardingProvider } from '@/context/onboarding-context' 8 + import { PluginProvider } from '@/context/plugin-context' 8 9 import { SetupGuard } from '@/components/setup-guard' 9 10 import { DynamicFavicon } from '@/components/dynamic-favicon' 10 11 ··· 61 62 <AuthProvider> 62 63 <AppToastProvider> 63 64 <OnboardingProvider> 64 - <SetupGuard>{children}</SetupGuard> 65 + <PluginProvider> 66 + <SetupGuard>{children}</SetupGuard> 67 + </PluginProvider> 65 68 </OnboardingProvider> 66 69 </AppToastProvider> 67 70 </AuthProvider>
+3
src/app/profile/[handle]/page.tsx
··· 17 17 import { useAuth } from '@/hooks/use-auth' 18 18 import { formatDateLong } from '@/lib/format' 19 19 import type { PublicSettings, UserProfile } from '@/lib/api/types' 20 + import { PluginSlot } from '@/components/plugin-slot' 20 21 21 22 interface UserProfilePageProps { 22 23 params: Promise<{ handle: string }> | { handle: string } ··· 143 144 onMuteToggle={setIsMuted} 144 145 viewerDid={user?.did ?? null} 145 146 /> 147 + 148 + <PluginSlot name="user-profile" context={{ profileDid: profile.did }} /> 146 149 147 150 {/* Recent activity */} 148 151 <section>
+8
src/app/settings/page.tsx
··· 25 25 import { AGE_OPTIONS } from '@/lib/constants' 26 26 import { cn } from '@/lib/utils' 27 27 import { useSettingsForm } from '@/hooks/use-settings-form' 28 + import { PluginSlot } from '@/components/plugin-slot' 28 29 29 30 export default function SettingsPage() { 30 31 const [publicSettings, setPublicSettings] = useState<PublicSettings | null>(null) ··· 172 173 onReactionsChange={(v) => setValues({ ...values, notifyReactions: v })} 173 174 /> 174 175 176 + <PluginSlot 177 + name="settings-community" 178 + context={{ communityDid: publicSettings?.communityDid }} 179 + /> 180 + 175 181 {/* Reports link -- scoped to this community's AppView */} 176 182 <div className="rounded-lg border border-border p-4"> 177 183 <Link ··· 265 271 onBlockUser={handleBlockUser} 266 272 onUnblockUser={handleUnblockUser} 267 273 /> 274 + 275 + <PluginSlot name="settings-global" /> 268 276 269 277 <div className="flex justify-end"> 270 278 <button
+299
src/components/plugin-slot.test.tsx
··· 1 + /** 2 + * Tests for PluginSlot component. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeEach } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import { PluginSlot } from './plugin-slot' 9 + import { usePlugins } from '@/hooks/use-plugins' 10 + import { getPluginComponents } from '@/lib/plugins/registry' 11 + import type { PluginRegistration } from '@/lib/plugins/registry' 12 + import type { PluginContextValue } from '@/context/plugin-context' 13 + 14 + vi.mock('@/hooks/use-plugins', () => ({ 15 + usePlugins: vi.fn(), 16 + })) 17 + 18 + vi.mock('@/lib/plugins/registry', () => ({ 19 + getPluginComponents: vi.fn(), 20 + })) 21 + 22 + // Test helper components 23 + function TestPluginComponent({ authorDid }: { authorDid?: string }) { 24 + return <div data-testid="test-plugin">Plugin content: {authorDid}</div> 25 + } 26 + 27 + function CrashingPluginComponent() { 28 + throw new Error('Plugin crashed!') 29 + } 30 + 31 + function createMockPluginContext(plugins: PluginContextValue['plugins'] = []): PluginContextValue { 32 + return { 33 + plugins, 34 + isPluginEnabled: (name: string) => plugins.some((p) => p.name === name && p.enabled), 35 + getPluginSettings: () => null, 36 + isLoading: false, 37 + refreshPlugins: vi.fn(), 38 + } 39 + } 40 + 41 + beforeEach(() => { 42 + vi.clearAllMocks() 43 + vi.mocked(getPluginComponents).mockReturnValue([]) 44 + vi.mocked(usePlugins).mockReturnValue(createMockPluginContext()) 45 + }) 46 + 47 + describe('PluginSlot', () => { 48 + it('renders nothing when no plugins are registered for the slot', () => { 49 + vi.mocked(getPluginComponents).mockReturnValue([]) 50 + 51 + const { container } = render(<PluginSlot name="post-content" />) 52 + expect(container.innerHTML).toBe('') 53 + }) 54 + 55 + it('renders fallback when provided and no plugins registered', () => { 56 + vi.mocked(getPluginComponents).mockReturnValue([]) 57 + 58 + render(<PluginSlot name="post-content" fallback={<div>Fallback content</div>} />) 59 + expect(screen.getByText('Fallback content')).toBeInTheDocument() 60 + }) 61 + 62 + it('renders fallback when plugins are registered but none are enabled', () => { 63 + vi.mocked(getPluginComponents).mockReturnValue([ 64 + { 65 + pluginName: 'disabled-plugin', 66 + component: TestPluginComponent, 67 + }, 68 + ] as PluginRegistration[]) 69 + 70 + vi.mocked(usePlugins).mockReturnValue( 71 + createMockPluginContext([ 72 + { 73 + id: '1', 74 + name: 'disabled-plugin', 75 + displayName: 'Disabled Plugin', 76 + version: '1.0.0', 77 + description: 'A disabled plugin', 78 + source: 'core', 79 + enabled: false, 80 + category: 'content', 81 + dependencies: [], 82 + dependents: [], 83 + settingsSchema: {}, 84 + settings: {}, 85 + installedAt: '2026-01-01T00:00:00Z', 86 + }, 87 + ]) 88 + ) 89 + 90 + render(<PluginSlot name="post-content" fallback={<div>Fallback content</div>} />) 91 + expect(screen.getByText('Fallback content')).toBeInTheDocument() 92 + expect(screen.queryByTestId('test-plugin')).not.toBeInTheDocument() 93 + }) 94 + 95 + it('renders plugin component when registered and plugin is enabled', () => { 96 + vi.mocked(getPluginComponents).mockReturnValue([ 97 + { 98 + pluginName: 'test-plugin', 99 + component: TestPluginComponent, 100 + }, 101 + ] as PluginRegistration[]) 102 + 103 + vi.mocked(usePlugins).mockReturnValue( 104 + createMockPluginContext([ 105 + { 106 + id: '1', 107 + name: 'test-plugin', 108 + displayName: 'Test Plugin', 109 + version: '1.0.0', 110 + description: 'A test plugin', 111 + source: 'core', 112 + enabled: true, 113 + category: 'content', 114 + dependencies: [], 115 + dependents: [], 116 + settingsSchema: {}, 117 + settings: {}, 118 + installedAt: '2026-01-01T00:00:00Z', 119 + }, 120 + ]) 121 + ) 122 + 123 + render(<PluginSlot name="post-content" />) 124 + expect(screen.getByTestId('test-plugin')).toBeInTheDocument() 125 + }) 126 + 127 + it('does not render plugin component when plugin is disabled', () => { 128 + vi.mocked(getPluginComponents).mockReturnValue([ 129 + { 130 + pluginName: 'test-plugin', 131 + component: TestPluginComponent, 132 + }, 133 + ] as PluginRegistration[]) 134 + 135 + vi.mocked(usePlugins).mockReturnValue( 136 + createMockPluginContext([ 137 + { 138 + id: '1', 139 + name: 'test-plugin', 140 + displayName: 'Test Plugin', 141 + version: '1.0.0', 142 + description: 'A test plugin', 143 + source: 'core', 144 + enabled: false, 145 + category: 'content', 146 + dependencies: [], 147 + dependents: [], 148 + settingsSchema: {}, 149 + settings: {}, 150 + installedAt: '2026-01-01T00:00:00Z', 151 + }, 152 + ]) 153 + ) 154 + 155 + render(<PluginSlot name="post-content" />) 156 + expect(screen.queryByTestId('test-plugin')).not.toBeInTheDocument() 157 + }) 158 + 159 + it('error boundary catches crashing plugin component and shows error message', () => { 160 + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) 161 + 162 + vi.mocked(getPluginComponents).mockReturnValue([ 163 + { 164 + pluginName: 'crashing-plugin', 165 + component: CrashingPluginComponent, 166 + }, 167 + ] as PluginRegistration[]) 168 + 169 + vi.mocked(usePlugins).mockReturnValue( 170 + createMockPluginContext([ 171 + { 172 + id: '1', 173 + name: 'crashing-plugin', 174 + displayName: 'Crashing Plugin', 175 + version: '1.0.0', 176 + description: 'A crashing plugin', 177 + source: 'core', 178 + enabled: true, 179 + category: 'content', 180 + dependencies: [], 181 + dependents: [], 182 + settingsSchema: {}, 183 + settings: {}, 184 + installedAt: '2026-01-01T00:00:00Z', 185 + }, 186 + ]) 187 + ) 188 + 189 + render(<PluginSlot name="post-content" />) 190 + expect(screen.getByText(/crashing-plugin/)).toBeInTheDocument() 191 + expect(screen.getByText(/encountered an error/)).toBeInTheDocument() 192 + 193 + consoleSpy.mockRestore() 194 + }) 195 + 196 + it('passes context props to plugin components', () => { 197 + vi.mocked(getPluginComponents).mockReturnValue([ 198 + { 199 + pluginName: 'test-plugin', 200 + component: TestPluginComponent, 201 + }, 202 + ] as PluginRegistration[]) 203 + 204 + vi.mocked(usePlugins).mockReturnValue( 205 + createMockPluginContext([ 206 + { 207 + id: '1', 208 + name: 'test-plugin', 209 + displayName: 'Test Plugin', 210 + version: '1.0.0', 211 + description: 'A test plugin', 212 + source: 'core', 213 + enabled: true, 214 + category: 'content', 215 + dependencies: [], 216 + dependents: [], 217 + settingsSchema: {}, 218 + settings: {}, 219 + installedAt: '2026-01-01T00:00:00Z', 220 + }, 221 + ]) 222 + ) 223 + 224 + render(<PluginSlot name="post-content" context={{ authorDid: 'did:plc:abc123' }} />) 225 + expect(screen.getByText('Plugin content: did:plc:abc123')).toBeInTheDocument() 226 + }) 227 + 228 + it('passes axe accessibility check', async () => { 229 + vi.mocked(getPluginComponents).mockReturnValue([ 230 + { 231 + pluginName: 'test-plugin', 232 + component: TestPluginComponent, 233 + }, 234 + ] as PluginRegistration[]) 235 + 236 + vi.mocked(usePlugins).mockReturnValue( 237 + createMockPluginContext([ 238 + { 239 + id: '1', 240 + name: 'test-plugin', 241 + displayName: 'Test Plugin', 242 + version: '1.0.0', 243 + description: 'A test plugin', 244 + source: 'core', 245 + enabled: true, 246 + category: 'content', 247 + dependencies: [], 248 + dependents: [], 249 + settingsSchema: {}, 250 + settings: {}, 251 + installedAt: '2026-01-01T00:00:00Z', 252 + }, 253 + ]) 254 + ) 255 + 256 + const { container } = render( 257 + <PluginSlot name="post-content" context={{ authorDid: 'did:plc:abc123' }} /> 258 + ) 259 + const results = await axe(container) 260 + expect(results).toHaveNoViolations() 261 + }) 262 + 263 + it('passes axe accessibility check with error boundary fallback', async () => { 264 + vi.spyOn(console, 'error').mockImplementation(() => {}) 265 + 266 + vi.mocked(getPluginComponents).mockReturnValue([ 267 + { 268 + pluginName: 'crashing-plugin', 269 + component: CrashingPluginComponent, 270 + }, 271 + ] as PluginRegistration[]) 272 + 273 + vi.mocked(usePlugins).mockReturnValue( 274 + createMockPluginContext([ 275 + { 276 + id: '1', 277 + name: 'crashing-plugin', 278 + displayName: 'Crashing Plugin', 279 + version: '1.0.0', 280 + description: 'A crashing plugin', 281 + source: 'core', 282 + enabled: true, 283 + category: 'content', 284 + dependencies: [], 285 + dependents: [], 286 + settingsSchema: {}, 287 + settings: {}, 288 + installedAt: '2026-01-01T00:00:00Z', 289 + }, 290 + ]) 291 + ) 292 + 293 + const { container } = render(<PluginSlot name="post-content" />) 294 + const results = await axe(container) 295 + expect(results).toHaveNoViolations() 296 + 297 + vi.restoreAllMocks() 298 + }) 299 + })
+71
src/components/plugin-slot.tsx
··· 1 + 'use client' 2 + 3 + import { Component, type ReactNode, type ComponentType } from 'react' 4 + import { usePlugins } from '@/hooks/use-plugins' 5 + import { getPluginComponents, type SlotName } from '@/lib/plugins/registry' 6 + 7 + interface PluginSlotProps { 8 + name: SlotName 9 + context?: Record<string, unknown> 10 + fallback?: ReactNode 11 + } 12 + 13 + // Error boundary for individual plugin components 14 + interface ErrorBoundaryProps { 15 + pluginName: string 16 + children: ReactNode 17 + } 18 + 19 + interface ErrorBoundaryState { 20 + hasError: boolean 21 + } 22 + 23 + class PluginErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { 24 + constructor(props: ErrorBoundaryProps) { 25 + super(props) 26 + this.state = { hasError: false } 27 + } 28 + 29 + static getDerivedStateFromError(): ErrorBoundaryState { 30 + return { hasError: true } 31 + } 32 + 33 + render() { 34 + if (this.state.hasError) { 35 + return ( 36 + <div className="rounded border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive"> 37 + Plugin &ldquo;{this.props.pluginName}&rdquo; encountered an error. 38 + </div> 39 + ) 40 + } 41 + return this.props.children 42 + } 43 + } 44 + 45 + export function PluginSlot({ name, context = {}, fallback }: PluginSlotProps) { 46 + const { plugins } = usePlugins() 47 + 48 + const registrations = getPluginComponents(name) 49 + 50 + // Filter to only enabled plugins 51 + const activeRegistrations = registrations.filter((reg) => 52 + plugins.some((p) => p.name === reg.pluginName && p.enabled) 53 + ) 54 + 55 + if (activeRegistrations.length === 0) { 56 + return fallback ?? null 57 + } 58 + 59 + return ( 60 + <> 61 + {activeRegistrations.map((reg) => { 62 + const PluginComponent = reg.component as ComponentType<Record<string, unknown>> 63 + return ( 64 + <PluginErrorBoundary key={reg.pluginName} pluginName={reg.pluginName}> 65 + <PluginComponent {...context} /> 66 + </PluginErrorBoundary> 67 + ) 68 + })} 69 + </> 70 + ) 71 + }
+13
src/components/reply-card.tsx
··· 24 24 import { ReportDialog, type ReportSubmission } from './report-dialog' 25 25 import { ConfirmDialog } from './confirm-dialog' 26 26 import { SelfLabelIndicator } from './self-label-indicator' 27 + import { PluginSlot } from '@/components/plugin-slot' 27 28 28 29 interface ReactionData { 29 30 type: string ··· 239 240 <MarkdownContent content={displayContent} /> 240 241 )} 241 242 </div> 243 + 244 + {/* Plugin slot for post content extensions (e.g., signatures) */} 245 + {/* TODO: Compute isFirstByAuthor properly when signatures plugin is built */} 246 + <PluginSlot 247 + name="post-content" 248 + context={{ 249 + authorDid: reply.authorDid, 250 + threadUri: reply.rootUri, 251 + postUri: reply.uri, 252 + isFirstByAuthor: false, 253 + }} 254 + /> 242 255 243 256 {/* Footer */} 244 257 <div className="flex items-center gap-3 border-t border-border px-4 py-2 text-xs text-muted-foreground">
+359
src/context/plugin-context.test.tsx
··· 1 + /** 2 + * Tests for PluginProvider context. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeEach } from 'vitest' 6 + import { render, waitFor, act } from '@testing-library/react' 7 + import { PluginProvider, PluginContext } from './plugin-context' 8 + import type { PluginContextValue } from './plugin-context' 9 + import { getPlugins } from '@/lib/api/client' 10 + import { useAuth } from '@/hooks/use-auth' 11 + import { useContext, useEffect, useRef } from 'react' 12 + import type { Plugin } from '@/lib/api/types' 13 + 14 + vi.mock('@/lib/api/client', () => ({ 15 + getPlugins: vi.fn(), 16 + })) 17 + 18 + vi.mock('@/hooks/use-auth', () => ({ 19 + useAuth: vi.fn(), 20 + })) 21 + 22 + // Mutable container to capture context without TS control-flow narrowing 23 + interface ContextRef { 24 + current: PluginContextValue | null 25 + } 26 + 27 + function createContextRef(): ContextRef { 28 + return { current: null } 29 + } 30 + 31 + // Test consumer component that renders plugin context values 32 + function PluginConsumer({ ctxRef }: { ctxRef: ContextRef }) { 33 + const context = useContext(PluginContext) 34 + const ref = useRef(ctxRef) 35 + useEffect(() => { 36 + ref.current.current = context 37 + }) 38 + return ( 39 + <div> 40 + <span data-testid="loading">{String(context?.isLoading)}</span> 41 + <span data-testid="plugin-count">{context?.plugins.length ?? 0}</span> 42 + </div> 43 + ) 44 + } 45 + 46 + const mockPlugins: Plugin[] = [ 47 + { 48 + id: '1', 49 + name: 'analytics', 50 + displayName: 'Analytics', 51 + version: '1.0.0', 52 + description: 'Analytics plugin', 53 + source: 'core', 54 + enabled: true, 55 + category: 'analytics', 56 + dependencies: [], 57 + dependents: [], 58 + settingsSchema: {}, 59 + settings: { trackPageViews: true, sampleRate: 0.5 }, 60 + installedAt: '2026-01-01T00:00:00Z', 61 + }, 62 + { 63 + id: '2', 64 + name: 'spam-filter', 65 + displayName: 'Spam Filter', 66 + version: '2.1.0', 67 + description: 'Spam filtering plugin', 68 + source: 'official', 69 + enabled: false, 70 + category: 'moderation', 71 + dependencies: [], 72 + dependents: [], 73 + settingsSchema: {}, 74 + settings: { threshold: 0.8 }, 75 + installedAt: '2026-01-15T00:00:00Z', 76 + }, 77 + ] 78 + 79 + function mockAuthUnauthenticated() { 80 + vi.mocked(useAuth).mockReturnValue({ 81 + user: null, 82 + isAuthenticated: false, 83 + isLoading: false, 84 + crossPostScopesGranted: false, 85 + getAccessToken: () => null, 86 + login: vi.fn(), 87 + logout: vi.fn(), 88 + setSessionFromCallback: vi.fn(), 89 + requestCrossPostAuth: vi.fn(), 90 + authFetch: vi.fn(), 91 + } as ReturnType<typeof useAuth>) 92 + } 93 + 94 + function mockAuthAuthenticated() { 95 + vi.mocked(useAuth).mockReturnValue({ 96 + user: { 97 + did: 'did:plc:test', 98 + handle: 'test.bsky.social', 99 + displayName: 'Test User', 100 + avatarUrl: null, 101 + role: 'user', 102 + }, 103 + isAuthenticated: true, 104 + isLoading: false, 105 + crossPostScopesGranted: false, 106 + getAccessToken: () => 'mock-token', 107 + login: vi.fn(), 108 + logout: vi.fn(), 109 + setSessionFromCallback: vi.fn(), 110 + requestCrossPostAuth: vi.fn(), 111 + authFetch: vi.fn(), 112 + } as ReturnType<typeof useAuth>) 113 + } 114 + 115 + beforeEach(() => { 116 + vi.clearAllMocks() 117 + }) 118 + 119 + describe('PluginProvider', () => { 120 + it('provides empty plugins array when not authenticated', async () => { 121 + mockAuthUnauthenticated() 122 + 123 + const ctxRef = createContextRef() 124 + render( 125 + <PluginProvider> 126 + <PluginConsumer ctxRef={ctxRef} /> 127 + </PluginProvider> 128 + ) 129 + 130 + await waitFor(() => { 131 + expect(ctxRef.current?.isLoading).toBe(false) 132 + }) 133 + 134 + expect(ctxRef.current?.plugins).toEqual([]) 135 + expect(getPlugins).not.toHaveBeenCalled() 136 + }) 137 + 138 + it('fetches plugins when authenticated', async () => { 139 + mockAuthAuthenticated() 140 + vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins }) 141 + 142 + const ctxRef = createContextRef() 143 + render( 144 + <PluginProvider> 145 + <PluginConsumer ctxRef={ctxRef} /> 146 + </PluginProvider> 147 + ) 148 + 149 + await waitFor(() => { 150 + expect(ctxRef.current?.isLoading).toBe(false) 151 + }) 152 + 153 + expect(getPlugins).toHaveBeenCalledWith('mock-token') 154 + expect(ctxRef.current?.plugins).toHaveLength(2) 155 + expect(ctxRef.current?.plugins[0]!.name).toBe('analytics') 156 + expect(ctxRef.current?.plugins[1]!.name).toBe('spam-filter') 157 + }) 158 + 159 + it('isPluginEnabled returns true for enabled plugin', async () => { 160 + mockAuthAuthenticated() 161 + vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins }) 162 + 163 + const ctxRef = createContextRef() 164 + render( 165 + <PluginProvider> 166 + <PluginConsumer ctxRef={ctxRef} /> 167 + </PluginProvider> 168 + ) 169 + 170 + await waitFor(() => { 171 + expect(ctxRef.current?.isLoading).toBe(false) 172 + }) 173 + 174 + expect(ctxRef.current?.isPluginEnabled('analytics')).toBe(true) 175 + }) 176 + 177 + it('isPluginEnabled returns false for disabled plugin', async () => { 178 + mockAuthAuthenticated() 179 + vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins }) 180 + 181 + const ctxRef = createContextRef() 182 + render( 183 + <PluginProvider> 184 + <PluginConsumer ctxRef={ctxRef} /> 185 + </PluginProvider> 186 + ) 187 + 188 + await waitFor(() => { 189 + expect(ctxRef.current?.isLoading).toBe(false) 190 + }) 191 + 192 + expect(ctxRef.current?.isPluginEnabled('spam-filter')).toBe(false) 193 + }) 194 + 195 + it('isPluginEnabled returns false for unknown plugin', async () => { 196 + mockAuthAuthenticated() 197 + vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins }) 198 + 199 + const ctxRef = createContextRef() 200 + render( 201 + <PluginProvider> 202 + <PluginConsumer ctxRef={ctxRef} /> 203 + </PluginProvider> 204 + ) 205 + 206 + await waitFor(() => { 207 + expect(ctxRef.current?.isLoading).toBe(false) 208 + }) 209 + 210 + expect(ctxRef.current?.isPluginEnabled('nonexistent')).toBe(false) 211 + }) 212 + 213 + it('getPluginSettings returns settings for installed plugin', async () => { 214 + mockAuthAuthenticated() 215 + vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins }) 216 + 217 + const ctxRef = createContextRef() 218 + render( 219 + <PluginProvider> 220 + <PluginConsumer ctxRef={ctxRef} /> 221 + </PluginProvider> 222 + ) 223 + 224 + await waitFor(() => { 225 + expect(ctxRef.current?.isLoading).toBe(false) 226 + }) 227 + 228 + const settings = ctxRef.current?.getPluginSettings('analytics') 229 + expect(settings).toEqual({ trackPageViews: true, sampleRate: 0.5 }) 230 + }) 231 + 232 + it('getPluginSettings returns a copy, not the original object', async () => { 233 + mockAuthAuthenticated() 234 + vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins }) 235 + 236 + const ctxRef = createContextRef() 237 + render( 238 + <PluginProvider> 239 + <PluginConsumer ctxRef={ctxRef} /> 240 + </PluginProvider> 241 + ) 242 + 243 + await waitFor(() => { 244 + expect(ctxRef.current?.isLoading).toBe(false) 245 + }) 246 + 247 + const settings1 = ctxRef.current?.getPluginSettings('analytics') 248 + const settings2 = ctxRef.current?.getPluginSettings('analytics') 249 + expect(settings1).toEqual(settings2) 250 + expect(settings1).not.toBe(settings2) 251 + }) 252 + 253 + it('getPluginSettings returns null for unknown plugin', async () => { 254 + mockAuthAuthenticated() 255 + vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins }) 256 + 257 + const ctxRef = createContextRef() 258 + render( 259 + <PluginProvider> 260 + <PluginConsumer ctxRef={ctxRef} /> 261 + </PluginProvider> 262 + ) 263 + 264 + await waitFor(() => { 265 + expect(ctxRef.current?.isLoading).toBe(false) 266 + }) 267 + 268 + expect(ctxRef.current?.getPluginSettings('nonexistent')).toBeNull() 269 + }) 270 + 271 + it('refreshPlugins triggers re-fetch', async () => { 272 + mockAuthAuthenticated() 273 + vi.mocked(getPlugins) 274 + .mockResolvedValueOnce({ plugins: [mockPlugins[0]!] }) 275 + .mockResolvedValueOnce({ plugins: mockPlugins }) 276 + 277 + const ctxRef = createContextRef() 278 + render( 279 + <PluginProvider> 280 + <PluginConsumer ctxRef={ctxRef} /> 281 + </PluginProvider> 282 + ) 283 + 284 + // Wait for initial fetch 285 + await waitFor(() => { 286 + expect(ctxRef.current?.isLoading).toBe(false) 287 + }) 288 + expect(ctxRef.current?.plugins).toHaveLength(1) 289 + 290 + // Trigger refresh 291 + await act(async () => { 292 + await ctxRef.current?.refreshPlugins() 293 + }) 294 + 295 + await waitFor(() => { 296 + expect(ctxRef.current?.isLoading).toBe(false) 297 + }) 298 + 299 + expect(getPlugins).toHaveBeenCalledTimes(2) 300 + expect(ctxRef.current?.plugins).toHaveLength(2) 301 + }) 302 + 303 + it('keeps existing plugins on fetch error', async () => { 304 + mockAuthAuthenticated() 305 + vi.mocked(getPlugins) 306 + .mockResolvedValueOnce({ plugins: mockPlugins }) 307 + .mockRejectedValueOnce(new Error('Network error')) 308 + 309 + const ctxRef = createContextRef() 310 + render( 311 + <PluginProvider> 312 + <PluginConsumer ctxRef={ctxRef} /> 313 + </PluginProvider> 314 + ) 315 + 316 + // Wait for initial fetch 317 + await waitFor(() => { 318 + expect(ctxRef.current?.isLoading).toBe(false) 319 + }) 320 + expect(ctxRef.current?.plugins).toHaveLength(2) 321 + 322 + // Trigger refresh that fails 323 + await act(async () => { 324 + await ctxRef.current?.refreshPlugins() 325 + }) 326 + 327 + await waitFor(() => { 328 + expect(ctxRef.current?.isLoading).toBe(false) 329 + }) 330 + 331 + // Plugins should be preserved 332 + expect(ctxRef.current?.plugins).toHaveLength(2) 333 + }) 334 + 335 + it('does not fetch while auth is still loading', () => { 336 + vi.mocked(useAuth).mockReturnValue({ 337 + user: null, 338 + isAuthenticated: false, 339 + isLoading: true, 340 + crossPostScopesGranted: false, 341 + getAccessToken: () => null, 342 + login: vi.fn(), 343 + logout: vi.fn(), 344 + setSessionFromCallback: vi.fn(), 345 + requestCrossPostAuth: vi.fn(), 346 + authFetch: vi.fn(), 347 + } as ReturnType<typeof useAuth>) 348 + 349 + const ctxRef = createContextRef() 350 + render( 351 + <PluginProvider> 352 + <PluginConsumer ctxRef={ctxRef} /> 353 + </PluginProvider> 354 + ) 355 + 356 + expect(getPlugins).not.toHaveBeenCalled() 357 + expect(ctxRef.current?.isLoading).toBe(true) 358 + }) 359 + })
+98
src/context/plugin-context.tsx
··· 1 + /** 2 + * Plugin context provider. 3 + * Fetches enabled plugins and their settings from the API on mount. 4 + * Exposes helpers to check plugin state and a refresh method for admin pages. 5 + */ 6 + 7 + 'use client' 8 + 9 + import { createContext, useCallback, useEffect, useMemo, useState } from 'react' 10 + import type { ReactNode } from 'react' 11 + import type { Plugin } from '@/lib/api/types' 12 + import { getPlugins } from '@/lib/api/client' 13 + import { useAuth } from '@/hooks/use-auth' 14 + 15 + export interface PluginContextValue { 16 + /** List of all plugins (enabled and disabled) */ 17 + plugins: Plugin[] 18 + /** Check whether a plugin with the given name is enabled */ 19 + isPluginEnabled: (name: string) => boolean 20 + /** Get the settings for a plugin by name, or null if not found */ 21 + getPluginSettings: (name: string) => Record<string, unknown> | null 22 + /** Whether the plugin list is still loading */ 23 + isLoading: boolean 24 + /** Re-fetch the plugin list (call after admin changes) */ 25 + refreshPlugins: () => Promise<void> 26 + } 27 + 28 + export const PluginContext = createContext<PluginContextValue | null>(null) 29 + 30 + interface PluginProviderProps { 31 + children: ReactNode 32 + } 33 + 34 + export function PluginProvider({ children }: PluginProviderProps) { 35 + const { getAccessToken, isLoading: authLoading } = useAuth() 36 + const [plugins, setPlugins] = useState<Plugin[]>([]) 37 + const [isLoading, setIsLoading] = useState(true) 38 + 39 + const fetchPlugins = useCallback(async () => { 40 + const token = getAccessToken() 41 + if (!token) { 42 + // Unauthenticated: no plugin data available from API 43 + setPlugins([]) 44 + setIsLoading(false) 45 + return 46 + } 47 + 48 + try { 49 + const response = await getPlugins(token) 50 + setPlugins(response.plugins) 51 + } catch { 52 + // On error, keep existing plugins (or empty on first load) 53 + setPlugins((prev) => prev) 54 + } finally { 55 + setIsLoading(false) 56 + } 57 + }, [getAccessToken]) 58 + 59 + const refreshPlugins = useCallback(async () => { 60 + setIsLoading(true) 61 + await fetchPlugins() 62 + }, [fetchPlugins]) 63 + 64 + useEffect(() => { 65 + if (authLoading) return 66 + void fetchPlugins() 67 + }, [authLoading, fetchPlugins]) 68 + 69 + const isPluginEnabled = useCallback( 70 + (name: string): boolean => { 71 + const plugin = plugins.find((p) => p.name === name) 72 + return plugin?.enabled ?? false 73 + }, 74 + [plugins] 75 + ) 76 + 77 + const getPluginSettings = useCallback( 78 + (name: string): Record<string, unknown> | null => { 79 + const plugin = plugins.find((p) => p.name === name) 80 + if (!plugin) return null 81 + return { ...plugin.settings } 82 + }, 83 + [plugins] 84 + ) 85 + 86 + const value = useMemo<PluginContextValue>( 87 + () => ({ 88 + plugins, 89 + isPluginEnabled, 90 + getPluginSettings, 91 + isLoading, 92 + refreshPlugins, 93 + }), 94 + [plugins, isPluginEnabled, getPluginSettings, isLoading, refreshPlugins] 95 + ) 96 + 97 + return <PluginContext.Provider value={value}>{children}</PluginContext.Provider> 98 + }
+23
src/hooks/use-plugins.ts
··· 1 + /** 2 + * Hook to access plugin context. 3 + * Returns a safe default when used outside PluginProvider (SSR, tests). 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useContext } from 'react' 9 + import { PluginContext } from '@/context/plugin-context' 10 + import type { PluginContextValue } from '@/context/plugin-context' 11 + 12 + const defaultContext: PluginContextValue = { 13 + plugins: [], 14 + isPluginEnabled: () => false, 15 + getPluginSettings: () => null, 16 + isLoading: false, 17 + refreshPlugins: async () => {}, 18 + } 19 + 20 + export function usePlugins(): PluginContextValue { 21 + const context = useContext(PluginContext) 22 + return context ?? defaultContext 23 + }
+42 -3
src/lib/api/client.ts
··· 47 47 ReportedUsersResponse, 48 48 AdminUsersResponse, 49 49 MaturityRating, 50 + Plugin, 50 51 PluginsResponse, 52 + RegistrySearchResponse, 51 53 OnboardingField, 52 54 AdminOnboardingFieldsResponse, 53 55 CreateOnboardingFieldInput, ··· 81 83 interface FetchOptions { 82 84 headers?: Record<string, string> 83 85 signal?: AbortSignal 84 - method?: 'GET' | 'POST' | 'PUT' | 'DELETE' 86 + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' 85 87 body?: unknown 86 88 } 87 89 ··· 675 677 `/api/plugins/${encodeURIComponent(id)}/${enabled ? 'enable' : 'disable'}`, 676 678 { 677 679 ...options, 678 - method: 'PUT', 680 + method: 'PATCH', 679 681 headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 680 682 } 681 683 ) ··· 689 691 ): Promise<void> { 690 692 return apiFetch<void>(`/api/plugins/${encodeURIComponent(id)}/settings`, { 691 693 ...options, 692 - method: 'PUT', 694 + method: 'PATCH', 693 695 headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 694 696 body: settings, 695 697 }) ··· 704 706 ...options, 705 707 method: 'DELETE', 706 708 headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 709 + }) 710 + } 711 + 712 + export function installPlugin( 713 + packageName: string, 714 + version: string | undefined, 715 + accessToken: string, 716 + options?: FetchOptions 717 + ): Promise<{ plugin: Plugin }> { 718 + return apiFetch<{ plugin: Plugin }>('/api/plugins/install', { 719 + ...options, 720 + method: 'POST', 721 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 722 + body: { packageName, ...(version ? { version } : {}) }, 723 + }) 724 + } 725 + 726 + export function searchPluginRegistry( 727 + params: { q?: string; category?: string; source?: string }, 728 + options?: FetchOptions 729 + ): Promise<RegistrySearchResponse> { 730 + const searchParams = new URLSearchParams() 731 + if (params.q) searchParams.set('q', params.q) 732 + if (params.category) searchParams.set('category', params.category) 733 + if (params.source) searchParams.set('source', params.source) 734 + const query = searchParams.toString() 735 + return apiFetch<RegistrySearchResponse>( 736 + `/api/plugins/registry/search${query ? `?${query}` : ''}`, 737 + { 738 + ...options, 739 + } 740 + ) 741 + } 742 + 743 + export function getFeaturedPlugins(options?: FetchOptions): Promise<RegistrySearchResponse> { 744 + return apiFetch<RegistrySearchResponse>('/api/plugins/registry/featured', { 745 + ...options, 707 746 }) 708 747 } 709 748
+21
src/lib/api/types.ts
··· 557 557 plugins: Plugin[] 558 558 } 559 559 560 + export interface RegistryPlugin { 561 + name: string 562 + displayName: string 563 + description: string 564 + version: string 565 + source: PluginSource 566 + category: string 567 + barazoVersion: string 568 + author: { name: string; url?: string } 569 + license: string 570 + npmUrl: string 571 + repositoryUrl?: string 572 + approved: boolean 573 + featured: boolean 574 + downloads: number 575 + } 576 + 577 + export interface RegistrySearchResponse { 578 + plugins: RegistryPlugin[] 579 + } 580 + 560 581 // --- User Preferences --- 561 582 562 583 export interface UserPreferences {
+97
src/lib/plugins/registry.test.ts
··· 1 + /** 2 + * Tests for plugin component registry. 3 + */ 4 + 5 + import { describe, it, expect, beforeEach } from 'vitest' 6 + import type { ComponentType } from 'react' 7 + import { registerPluginComponent, getPluginComponents, clearPluginRegistry } from './registry' 8 + 9 + // Stub components for testing 10 + const StubComponentA = (() => null) as ComponentType<Record<string, unknown>> 11 + const StubComponentB = (() => null) as ComponentType<Record<string, unknown>> 12 + 13 + beforeEach(() => { 14 + clearPluginRegistry() 15 + }) 16 + 17 + describe('registerPluginComponent', () => { 18 + it('adds a component to a slot', () => { 19 + registerPluginComponent('post-content', 'test-plugin', StubComponentA) 20 + 21 + const components = getPluginComponents('post-content') 22 + expect(components).toHaveLength(1) 23 + expect(components[0]!.pluginName).toBe('test-plugin') 24 + expect(components[0]!.component).toBe(StubComponentA) 25 + }) 26 + 27 + it('allows multiple plugins in the same slot', () => { 28 + registerPluginComponent('post-content', 'plugin-a', StubComponentA) 29 + registerPluginComponent('post-content', 'plugin-b', StubComponentB) 30 + 31 + const components = getPluginComponents('post-content') 32 + expect(components).toHaveLength(2) 33 + expect(components[0]!.pluginName).toBe('plugin-a') 34 + expect(components[1]!.pluginName).toBe('plugin-b') 35 + }) 36 + 37 + it('prevents duplicate registration for same plugin and slot', () => { 38 + registerPluginComponent('post-content', 'test-plugin', StubComponentA) 39 + registerPluginComponent('post-content', 'test-plugin', StubComponentB) 40 + 41 + const components = getPluginComponents('post-content') 42 + expect(components).toHaveLength(1) 43 + expect(components[0]!.component).toBe(StubComponentA) 44 + }) 45 + 46 + it('allows same plugin in different slots', () => { 47 + registerPluginComponent('post-content', 'test-plugin', StubComponentA) 48 + registerPluginComponent('topic-sidebar', 'test-plugin', StubComponentA) 49 + 50 + expect(getPluginComponents('post-content')).toHaveLength(1) 51 + expect(getPluginComponents('topic-sidebar')).toHaveLength(1) 52 + }) 53 + }) 54 + 55 + describe('getPluginComponents', () => { 56 + it('returns registered components for a slot', () => { 57 + registerPluginComponent('admin-dashboard', 'dashboard-plugin', StubComponentA) 58 + 59 + const result = getPluginComponents('admin-dashboard') 60 + expect(result).toHaveLength(1) 61 + expect(result[0]!.pluginName).toBe('dashboard-plugin') 62 + }) 63 + 64 + it('returns empty array for unregistered slot', () => { 65 + const result = getPluginComponents('user-profile') 66 + expect(result).toEqual([]) 67 + }) 68 + 69 + it('returns empty array for slot with no registrations', () => { 70 + // Register in a different slot, then query an unused one 71 + registerPluginComponent('post-content', 'some-plugin', StubComponentA) 72 + const result = getPluginComponents('settings-community') 73 + expect(result).toEqual([]) 74 + }) 75 + }) 76 + 77 + describe('clearPluginRegistry', () => { 78 + it('removes all registrations', () => { 79 + registerPluginComponent('post-content', 'plugin-a', StubComponentA) 80 + registerPluginComponent('topic-sidebar', 'plugin-b', StubComponentB) 81 + 82 + clearPluginRegistry() 83 + 84 + expect(getPluginComponents('post-content')).toEqual([]) 85 + expect(getPluginComponents('topic-sidebar')).toEqual([]) 86 + }) 87 + 88 + it('allows re-registration after clearing', () => { 89 + registerPluginComponent('post-content', 'test-plugin', StubComponentA) 90 + clearPluginRegistry() 91 + registerPluginComponent('post-content', 'test-plugin', StubComponentB) 92 + 93 + const components = getPluginComponents('post-content') 94 + expect(components).toHaveLength(1) 95 + expect(components[0]!.component).toBe(StubComponentB) 96 + }) 97 + })
+35
src/lib/plugins/registry.ts
··· 1 + import type { ComponentType } from 'react' 2 + 3 + export type SlotName = 4 + | 'settings-community' 5 + | 'settings-global' 6 + | 'post-content' 7 + | 'admin-dashboard' 8 + | 'topic-sidebar' 9 + | 'user-profile' 10 + 11 + export interface PluginRegistration { 12 + pluginName: string 13 + component: ComponentType<Record<string, unknown>> 14 + } 15 + 16 + const registry = new Map<SlotName, PluginRegistration[]>() 17 + 18 + export function registerPluginComponent( 19 + slot: SlotName, 20 + pluginName: string, 21 + component: ComponentType<Record<string, unknown>> 22 + ): void { 23 + const existing = registry.get(slot) ?? [] 24 + // Prevent duplicate registration 25 + if (existing.some((r) => r.pluginName === pluginName)) return 26 + registry.set(slot, [...existing, { pluginName, component }]) 27 + } 28 + 29 + export function getPluginComponents(slot: SlotName): PluginRegistration[] { 30 + return registry.get(slot) ?? [] 31 + } 32 + 33 + export function clearPluginRegistry(): void { 34 + registry.clear() 35 + }