Frontend Plugin Development
Build client-side plugins for Rasepi: custom editor extensions, embeddable widgets, themes, analytics integrations, and more, all powered by the PluginManager singleton and a strongly-typed plugin interface system.
Overview
Frontend plugins are TypeScript/JavaScript modules registered with the global PluginManager. Each plugin has a type, a lifecycle (initialize / destroy), and type-specific capabilities. The Pinia usePluginsStore bridges the backend plugin registry with the frontend — loading available plugins, installations, and UI Contribution Slots that let backend manifests inject Vue components into the app.
PluginManager
Global singleton. Registers, enables/disables, and manages all plugins. Includes a mitt-based event bus.
Plugin Interface
Each plugin type has a strongly-typed interface with required capabilities.
UI Slots
Backend manifests declare Vue component names for app-defined slots. The frontend renders them dynamically.
Pinia Store
Manages plugin installations, settings, and UI contributions from the API.
Plugin Types
Rasepi supports eight frontend plugin types. Each type has a union discriminator and type-specific properties.
| Type | Interface | Purpose |
|---|---|---|
editor | EditorPlugin | TipTap extensions, toolbar buttons, slash commands |
embed | EmbedPlugin | Rich embeds in editor (YouTube, Figma, etc.) |
widget | WidgetPlugin | Dashboard or sidebar widgets |
theme | ThemePlugin | Custom CSS themes and branding |
export | ExportPlugin | Client-side export to PDF, Markdown, etc. |
import | ImportPlugin | Client-side import handling |
analytics | AnalyticsPlugin | Page tracking, custom analytics events |
translation | TranslationProviderPlugin | Client-side translation integrations |
The union type AnyPlugin covers all of the above. Use type narrowing on plugin.type to access type-specific properties.
PluginManager
The PluginManager is a global singleton (instantiated once, exported from PluginManager.ts). It maintains the plugin registry, handles enable/disable lifecycle, and exposes a mitt event emitter.
import { pluginManager } from '@/composables/PluginManager'
// Register a plugin
pluginManager.register(myPlugin)
// Enable / disable
pluginManager.enable('my-plugin-id')
pluginManager.disable('my-plugin-id')
// Remove completely
pluginManager.unregister('my-plugin-id')
// Get all plugins, or filter by type
const all = pluginManager.getAll()
const embeds = pluginManager.getEmbedPlugins()
const editor = pluginManager.getEditorPlugins()
// Event bus
pluginManager.on('plugin:registered', (plugin) => { ... })
pluginManager.on('plugin:enabled', (plugin) => { ... })
pluginManager.emit('custom:my-event', payload)
PluginManager API
| Method | Description |
|---|---|
register(plugin) | Add a plugin. Calls plugin.initialize(context) automatically. |
unregister(id) | Remove and call plugin.destroy(). |
enable(id) | Set plugin isEnabled = true and emits plugin:enabled. |
disable(id) | Set plugin isEnabled = false and emits plugin:disabled. |
getAll() | Returns all registered plugins. |
getEditorPlugins() | Returns plugins where type === 'editor'. |
getEmbedPlugins() | Returns plugins where type === 'embed'. |
getWidgetPlugins() | Returns plugins where type === 'widget'. |
on(event, handler) | Subscribe to events (mitt). |
off(event, handler) | Unsubscribe from events. |
emit(event, payload) | Emit a custom event to all listeners. |
Creating a Plugin
Every frontend plugin must satisfy the base AnyPlugin interface: id, name, version, type, isEnabled, initialize, and destroy.
import type { PluginContext, EmbedPlugin } from '@/types'
const MyEmbedPlugin: EmbedPlugin = {
id: 'my-embed',
name: 'My Rich Embed',
version: '1.0.0',
type: 'embed',
isEnabled: true,
// Lifecycle
initialize(context: PluginContext) {
console.log('Plugin initialized with:', context)
},
destroy() {
console.log('Plugin destroyed')
},
// Type-specific: embed
supportedUrls: [/https?:\/\/(www\.)?example\.com/],
renderEmbed(url: string): string {
return `<iframe src="${url}" />`
},
}
export default MyEmbedPlugin
PluginContext
The context passed to initialize(). Provides access to the app, router, event bus, and API client.
| Property | Type | Description |
|---|---|---|
app | App | The Vue 3 application instance |
router | Router | Vue Router instance for navigation |
api | AxiosInstance | Pre-configured API client (auth, base URL) |
eventBus | Emitter | The mitt-based event emitter for plugin communication |
Editor Plugins
Editor plugins add TipTap extensions, custom toolbar buttons, or slash commands to the rich text editor.
import type { EditorPlugin, PluginContext } from '@/types'
import { Extension } from '@tiptap/core'
// A custom TipTap extension
const HighlightExtension = Extension.create({
name: 'custom-highlight',
addKeyboardShortcuts() {
return {
'Mod-Shift-h': () => this.editor.commands.toggleHighlight(),
}
},
})
const HighlightPlugin: EditorPlugin = {
id: 'highlight-plugin',
name: 'Custom Highlight',
version: '1.0.0',
type: 'editor',
isEnabled: true,
initialize(ctx: PluginContext) { /* setup */ },
destroy() { /* cleanup */ },
// Editor-specific
extensions: [HighlightExtension],
toolbarButtons: [
{
icon: 'mdi-marker',
title: 'Toggle Highlight',
action: (editor) => editor.chain().focus().toggleHighlight().run(),
isActive: (editor) => editor.isActive('highlight'),
}
],
}
export default HighlightPlugin
Embed Plugins
Embed plugins handle URL pattern matching and rendering of rich embeds inside the editor.
import type { EmbedPlugin, PluginContext } from '@/types'
// Built-in example: YouTube Embed Plugin
const YouTubeEmbedPlugin: EmbedPlugin = {
id: 'youtube-embed',
name: 'YouTube Embed',
version: '1.0.0',
type: 'embed',
isEnabled: true,
initialize(ctx: PluginContext) {
ctx.eventBus.on('editor:paste', this.handlePaste)
},
destroy() { /* cleanup listeners */ },
// URL patterns this plugin handles
supportedUrls: [
/https?:\/\/(www\.)?youtube\.com\/watch\?v=[\w-]+/,
/https?:\/\/youtu\.be\/[\w-]+/,
],
// Render the embed HTML
renderEmbed(url: string): string {
const videoId = extractYouTubeId(url)
return `<div class="youtube-embed">
<iframe
src="https://www.youtube-nocookie.com/embed/${videoId}"
allowfullscreen
loading="lazy"
></iframe>
</div>`
},
// Optional: configure embed dimensions
getEmbedConfig(url: string) {
return { width: '100%', height: '400px', aspectRatio: '16/9' }
},
}
function extractYouTubeId(url: string): string {
const match = url.match(/(?:v=|youtu\.be\/)([\w-]+)/)
return match?.[1] ?? ''
}
export default YouTubeEmbedPlugin
Widget Plugins
Widgets render Vue components in dashboards, sidebars, or other container areas.
import type { WidgetPlugin, PluginContext } from '@/types'
import { defineComponent, ref, onMounted } from 'vue'
const RecentActivityWidget = defineComponent({
setup() {
const activities = ref([])
onMounted(async () => {
const res = await fetch('/api/activities/recent')
activities.value = await res.json()
})
return { activities }
},
template: `
<div class="activity-widget">
<h3>Recent Activity</h3>
<ul>
<li v-for="a in activities" :key="a.id">
{{ a.description }}
</li>
</ul>
</div>
`,
})
const ActivityPlugin: WidgetPlugin = {
id: 'activity-widget',
name: 'Recent Activity',
version: '1.0.0',
type: 'widget',
isEnabled: true,
initialize(ctx: PluginContext) { },
destroy() { },
component: RecentActivityWidget,
slot: 'hub.dashboard.widgets',
width: 'half', // 'full' | 'half' | 'third'
}
export default ActivityPlugin
Theme Plugins
Theme plugins inject custom CSS variables or stylesheets to rebrand the app.
import type { ThemePlugin, PluginContext } from '@/types'
const DarkModePlugin: ThemePlugin = {
id: 'dark-mode',
name: 'Dark Mode',
version: '1.0.0',
type: 'theme',
isEnabled: true,
initialize(ctx: PluginContext) {
// Inject CSS variables
document.documentElement.style.setProperty('--bg', '#0f1b2d')
document.documentElement.style.setProperty('--surface', '#1a2744')
document.documentElement.style.setProperty('--text', '#e2e8f0')
},
destroy() {
// Reset to defaults
document.documentElement.style.removeProperty('--bg')
document.documentElement.style.removeProperty('--surface')
document.documentElement.style.removeProperty('--text')
},
variables: {
'--bg': '#0f1b2d',
'--surface': '#1a2744',
'--text': '#e2e8f0',
'--brand': '#c054a0',
},
}
export default DarkModePlugin
Export & Import Plugins
Client-side plugins for exporting entries to various formats or importing content from files.
import type { ExportPlugin, ImportPlugin, PluginContext } from '@/types'
// Export plugin
const PdfExportPlugin: ExportPlugin = {
id: 'pdf-export',
name: 'PDF Export',
version: '1.0.0',
type: 'export',
isEnabled: true,
initialize(ctx: PluginContext) { },
destroy() { },
format: 'PDF',
fileExtension: '.pdf',
async exportEntry(entry) {
// Use a library like jsPDF or html2pdf
const pdf = await generatePdf(entry)
return new Blob([pdf], { type: 'application/pdf' })
},
}
// Import plugin
const MarkdownImportPlugin: ImportPlugin = {
id: 'markdown-import',
name: 'Markdown Import',
version: '1.0.0',
type: 'import',
isEnabled: true,
initialize(ctx: PluginContext) { },
destroy() { },
supportedFormats: ['text/markdown', '.md'],
async importFile(file: File) {
const text = await file.text()
return markdownToTipTapJson(text)
},
}
Analytics Plugins
import type { AnalyticsPlugin, PluginContext } from '@/types'
const PlausiblePlugin: AnalyticsPlugin = {
id: 'plausible-analytics',
name: 'Plausible Analytics',
version: '1.0.0',
type: 'analytics',
isEnabled: true,
initialize(ctx: PluginContext) {
// Listen to route changes
ctx.router.afterEach((to) => {
this.trackPageView(to.fullPath)
})
},
destroy() { },
trackPageView(path: string) {
navigator.sendBeacon('/api/plausible/event', JSON.stringify({
name: 'pageview', url: path,
}))
},
trackEvent(name: string, props?: Record<string, string>) {
navigator.sendBeacon('/api/plausible/event', JSON.stringify({
name, props,
}))
},
}
Translation Provider Plugins
Client-side translation providers that integrate with the block-level translation system.
import type { TranslationProviderPlugin, PluginContext } from '@/types'
const BrowserTranslatePlugin: TranslationProviderPlugin = {
id: 'browser-translate',
name: 'Browser Translation',
version: '1.0.0',
type: 'translation',
isEnabled: true,
initialize(ctx: PluginContext) { },
destroy() { },
supportedLanguages: ['en', 'de', 'fr', 'es', 'it'],
async translate(text: string, from: string, to: string) {
// Use the browser's built-in translation API (Chrome)
// or fall back to a client-side library
const response = await fetch(`/api/translate`, {
method: 'POST',
body: JSON.stringify({ text, source: from, target: to }),
})
const data = await response.json()
return data.translatedText
},
}
UI Contribution Slots
The most powerful integration point. Backend plugin manifests declare UiContributions — a map of slot names to Vue component names. The frontend renders these components dynamically in predefined locations throughout the app.
How it works
Backend Manifest (C#) Frontend Rendering
────────────────────── ──────────────────
PluginManifest { <template>
Id: "workflow", <!-- Toolbar slot -->
UiContributions: { <component
"entry.toolbar.publish": v-for="c in toolbarSlots"
"WorkflowPublishButton", :is="c.componentName"
"entry.sidebar.status": :key="c.pluginId"
"WorkflowStatusPanel", />
"entry.approval.dialog": </template>
"ApprovalDialog",
} <script setup>
} const store = usePluginsStore()
const toolbarSlots = computed(() =>
──── API ────> store.getContributionsForSlot(
'entry.toolbar.publish'
GET /api/plugins/ )
/ui-contributions )
</script>
Available Slots
| Slot Name | Location | Use Case |
|---|---|---|
entry.toolbar.publish | Entry editor toolbar | Custom publish buttons (e.g. "Request Approval") |
entry.sidebar.status | Entry sidebar panel | Status indicators, workflow state, approver list |
entry.approval.dialog | Modal overlay | Approval/rejection dialogs |
entry.delete.confirm | Delete confirmation | Custom deletion warnings (retention policy) |
hub.admin.settings | Hub admin page | Per-hub plugin configuration panels |
tenant.admin.settings | Tenant admin page | Tenant-wide plugin configuration |
Backend: Declaring UI Contributions
public PluginManifest Manifest { get; } = new()
{
Id = "workflow",
Name = "Workflow & Approvals",
// ...
UiContributions = new Dictionary<string, string>
{
["entry.toolbar.publish"] = "WorkflowPublishButton",
["entry.sidebar.status"] = "WorkflowStatusPanel",
["entry.approval.dialog"] = "ApprovalDialog",
}.AsReadOnly(),
};
Frontend: Rendering Slot Contributions
<script setup lang="ts">
import { computed } from 'vue'
import { usePluginsStore } from '@/stores/plugins'
const pluginsStore = usePluginsStore()
// Get all components contributed to a slot
const toolbarContributions = computed(() =>
pluginsStore.getContributionsForSlot('entry.toolbar.publish')
)
</script>
<template>
<div class="toolbar">
<!-- Standard publish button -->
<v-btn @click="publish">Publish</v-btn>
<!-- Dynamic plugin contributions -->
<component
v-for="contribution in toolbarContributions"
:key="contribution.pluginId"
:is="contribution.componentName"
:entry-id="entryId"
/>
</div>
</template>
Pinia Store
The usePluginsStore bridges the backend plugin registry with the frontend. It loads available plugins, per-tenant installations, and UI contributions via the API.
import { usePluginsStore } from '@/stores/plugins'
const store = usePluginsStore()
// Load data from API
await store.loadAvailablePlugins() // GET /api/plugins
await store.loadInstallations() // GET /api/tenant/plugins
await store.loadUiContributions() // merged from manifests
// Install, enable, disable, uninstall
await store.install(pluginId)
await store.togglePlugin(pluginId, true) // enable
await store.togglePlugin(pluginId, false) // disable
await store.uninstall(pluginId)
// Update per-tenant settings
await store.updateSettings(pluginId, { apiKey: '...' })
// Get UI contributions for a slot
const contributions = store.getContributionsForSlot('entry.toolbar.publish')
// Returns: [{ pluginId, slot, componentName }]
| State Property | Type | Description |
|---|---|---|
availablePlugins | AvailablePlugin[] | All registered plugins (from backend) |
installations | TenantPluginInstallation[] | Per-tenant installations with settings & enabled state |
uiContributions | UiContribution[] | All UI slot contributions from enabled plugins |
Event Bus
Plugins communicate through the mitt-based event bus exposed by the PluginManager. This enables decoupled inter-plugin messaging and integration with the core app.
import { pluginManager } from '@/composables/PluginManager'
// Built-in lifecycle events
pluginManager.on('plugin:registered', (plugin) => { })
pluginManager.on('plugin:unregistered', (pluginId) => { })
pluginManager.on('plugin:enabled', (plugin) => { })
pluginManager.on('plugin:disabled', (plugin) => { })
// Custom plugin-to-plugin events
pluginManager.emit('workflow:approval-requested', {
entryId: '...',
approvers: ['user-1', 'user-2'],
})
pluginManager.on('workflow:approval-requested', (data) => {
// Another plugin reacts to the event
notifyApprovers(data.approvers)
})
💡 Event naming convention
Use the format {plugin-id}:{event-name} for custom events to avoid collisions. Built-in events start with plugin:.
Full Example — Figma Embed Plugin
A complete frontend embed plugin that renders Figma design frames inline in the editor.
// plugins/FigmaEmbedPlugin.ts
import type { EmbedPlugin, PluginContext } from '@/types'
let _ctx: PluginContext | null = null
const FigmaEmbedPlugin: EmbedPlugin = {
id: 'figma-embed',
name: 'Figma Embed',
version: '1.0.0',
type: 'embed',
isEnabled: true,
initialize(ctx: PluginContext) {
_ctx = ctx
ctx.eventBus.on('editor:paste', handlePaste)
},
destroy() {
_ctx?.eventBus.off('editor:paste', handlePaste)
_ctx = null
},
supportedUrls: [
/https?:\/\/([\w.-]+\.)?figma\.com\/(file|proto|design)\/.+/,
],
renderEmbed(url: string): string {
const encodedUrl = encodeURIComponent(url)
return `
<div class="figma-embed" style="position:relative;padding-bottom:56.25%;height:0">
<iframe
style="position:absolute;top:0;left:0;width:100%;height:100%;border:0"
src="https://www.figma.com/embed?embed_host=rasepi&url=${encodedUrl}"
allowfullscreen
loading="lazy"
></iframe>
</div>`
},
getEmbedConfig() {
return { width: '100%', height: '450px', aspectRatio: '16/9' }
},
}
function handlePaste(data: { url: string }) {
if (FigmaEmbedPlugin.supportedUrls.some(r => r.test(data.url))) {
_ctx?.eventBus.emit('editor:insert-embed', {
pluginId: 'figma-embed',
html: FigmaEmbedPlugin.renderEmbed(data.url),
})
}
}
export default FigmaEmbedPlugin
Register it in your app's entry point or a plugin loader:
// main.ts or plugin-loader.ts
import { pluginManager } from '@/composables/PluginManager'
import FigmaEmbedPlugin from './plugins/FigmaEmbedPlugin'
pluginManager.register(FigmaEmbedPlugin)