Vue 3 · TypeScript · TipTap

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.

TypeInterfacePurpose
editorEditorPluginTipTap extensions, toolbar buttons, slash commands
embedEmbedPluginRich embeds in editor (YouTube, Figma, etc.)
widgetWidgetPluginDashboard or sidebar widgets
themeThemePluginCustom CSS themes and branding
exportExportPluginClient-side export to PDF, Markdown, etc.
importImportPluginClient-side import handling
analyticsAnalyticsPluginPage tracking, custom analytics events
translationTranslationProviderPluginClient-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.

typescript
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

MethodDescription
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.

typescript
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.

PropertyTypeDescription
appAppThe Vue 3 application instance
routerRouterVue Router instance for navigation
apiAxiosInstancePre-configured API client (auth, base URL)
eventBusEmitterThe 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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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

typescript
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.

typescript
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

text
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 NameLocationUse Case
entry.toolbar.publishEntry editor toolbarCustom publish buttons (e.g. "Request Approval")
entry.sidebar.statusEntry sidebar panelStatus indicators, workflow state, approver list
entry.approval.dialogModal overlayApproval/rejection dialogs
entry.delete.confirmDelete confirmationCustom deletion warnings (retention policy)
hub.admin.settingsHub admin pagePer-hub plugin configuration panels
tenant.admin.settingsTenant admin pageTenant-wide plugin configuration

Backend: Declaring UI Contributions

csharp
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

vue
<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.

typescript
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 PropertyTypeDescription
availablePluginsAvailablePlugin[]All registered plugins (from backend)
installationsTenantPluginInstallation[]Per-tenant installations with settings & enabled state
uiContributionsUiContribution[]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.

typescript
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.

typescript
// 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:

typescript
// main.ts or plugin-loader.ts
import { pluginManager } from '@/composables/PluginManager'
import FigmaEmbedPlugin from './plugins/FigmaEmbedPlugin'

pluginManager.register(FigmaEmbedPlugin)